diff --git a/setup/stock_location_product_restriction/odoo/addons/stock_location_product_restriction b/setup/stock_location_product_restriction/odoo/addons/stock_location_product_restriction new file mode 120000 index 000000000..7717f33c9 --- /dev/null +++ b/setup/stock_location_product_restriction/odoo/addons/stock_location_product_restriction @@ -0,0 +1 @@ +../../../../stock_location_product_restriction \ No newline at end of file diff --git a/setup/stock_location_product_restriction/setup.py b/setup/stock_location_product_restriction/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/stock_location_product_restriction/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/stock_location_product_restriction/README.rst b/stock_location_product_restriction/README.rst new file mode 100644 index 000000000..067fe5268 --- /dev/null +++ b/stock_location_product_restriction/README.rst @@ -0,0 +1,99 @@ +================================== +Stock Location Product Restriction +================================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fstock--logistics--warehouse-lightgray.png?logo=github + :target: https://github.com/OCA/stock-logistics-warehouse/tree/10.0/stock_location_product_restriction + :alt: OCA/stock-logistics-warehouse +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/stock-logistics-warehouse-10-0/stock-logistics-warehouse-10-0-stock_location_product_restriction + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/153/10.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module extends the functionality of stock to allow you to prevent to put +items of different products into the same stock location. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +By default, Odoo allows you to put items of any product into the same location. +This behaviour remains the one by default once the addon is installed. +Once installed, you can specify at any level of the stock location hierarchy +if you want to restrict the usage of the location to only items of the same +product. This property is inherited by all the children locations while you +don't specify an other specific value on a child location. The constrains only +applies location by location. + +Once a location is configured to only contains items of the same product, the +system will prevent you to move items of any others products into a location +that already contains product items. A new filter into the tree view of the +stock locations will also allow you to find all the location where this new +restriction is violated. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* ACSONE SA/NV + +Contributors +~~~~~~~~~~~~ + +* Laurent Mignon (https://www.acsone.eu/) + +Other credits +~~~~~~~~~~~~~ + +The development of this module has been financially supported by: + +* ACSONE SA/NV +* Alcyon Benelux + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/stock-logistics-warehouse `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/stock_location_product_restriction/__init__.py b/stock_location_product_restriction/__init__.py new file mode 100644 index 000000000..6d58305f5 --- /dev/null +++ b/stock_location_product_restriction/__init__.py @@ -0,0 +1,2 @@ +from . import models +from .hooks import pre_init_hook diff --git a/stock_location_product_restriction/__manifest__.py b/stock_location_product_restriction/__manifest__.py new file mode 100644 index 000000000..1ae8a1a6d --- /dev/null +++ b/stock_location_product_restriction/__manifest__.py @@ -0,0 +1,16 @@ +# Copyright 2020 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Stock Location Product Restriction", + "summary": """ + Prevent to mix different products into the same stock location""", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "ACSONE SA/NV,Odoo Community Association (OCA)", + "maintainers": ["lmignon", "rousseldenis"], + "website": "https://github.com/OCA/stock-logistics-warehouse", + "depends": ["stock"], + "data": ["views/stock_location.xml"], + "pre_init_hook": "pre_init_hook", +} diff --git a/stock_location_product_restriction/hooks.py b/stock_location_product_restriction/hooks.py new file mode 100644 index 000000000..a1b7d82f5 --- /dev/null +++ b/stock_location_product_restriction/hooks.py @@ -0,0 +1,34 @@ +# Copyright 2020 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +import logging + +_logger = logging.getLogger(__name__) + + +def column_exists(cr, tablename, columnname): + """Return whether the given column exists.""" + query = """ SELECT 1 FROM information_schema.columns + WHERE table_name=%s AND column_name=%s """ + cr.execute(query, (tablename, columnname)) + return cr.rowcount + + +def pre_init_hook(cr): + _logger.info("Initialize product_restriction on table stock_location") + if not column_exists(cr, "stock_location", "product_restriction"): + cr.execute( + """ + ALTER TABLE stock_location + ADD COLUMN product_restriction character varying; + ALTER TABLE stock_location + ADD COLUMN parent_product_restriction character varying; + """ + ) + cr.execute( + """ + UPDATE stock_location set product_restriction = 'any'; + UPDATE stock_location set parent_product_restriction = 'any' + where location_id is not null; + """ + ) diff --git a/stock_location_product_restriction/i18n/fr.po b/stock_location_product_restriction/i18n/fr.po new file mode 100644 index 000000000..9a93caacc --- /dev/null +++ b/stock_location_product_restriction/i18n/fr.po @@ -0,0 +1,108 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_location_product_restriction +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 10.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-10-09 08:00+0000\n" +"PO-Revision-Date: 2020-10-09 08:00+0000\n" +"Last-Translator: <>\n" +"Language-Team: \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: stock_location_product_restriction +#: model:ir.model.fields,field_description:stock_location_product_restriction.field_stock_location_has_restriction_violation +msgid "Has restriction violation" +msgstr "Ne respecte pas les règles" + +#. module: stock_location_product_restriction +#: model:ir.model.fields,help:stock_location_product_restriction.field_stock_location_parent_product_restriction +#: model:ir.model.fields,help:stock_location_product_restriction.field_stock_location_product_restriction +msgid "" +"If 'Same product' is selected the system will prevent to put items of " +"different products into the same location." +msgstr "" +"Si 'Produits identiques' est sélectionné, le système empêchera de mettre des " +"produits différents dans le même emplacement." + +#. module: stock_location_product_restriction +#: model:ir.model.fields,help:stock_location_product_restriction.field_stock_location_specific_product_restriction +msgid "" +"If specified the restriction specified will apply to the current location " +"and all its children" +msgstr "" +"Si spécifié, la règle de restriction sélectionnée s'appliquera à " +"l'emplacement actuel et ses enfants." + +#. module: stock_location_product_restriction +#: model:ir.model,name:stock_location_product_restriction.model_stock_location +msgid "Inventory Locations" +msgstr "Emplacements de stock" + +#. module: stock_location_product_restriction +#: model:ir.model.fields,field_description:stock_location_product_restriction.field_stock_location_parent_product_restriction +#: model:ir.model.fields,field_description:stock_location_product_restriction.field_stock_location_product_restriction +msgid "Product restriction" +msgstr "Restriction sur les articles" + +#. module: stock_location_product_restriction +#: model:ir.model.fields,field_description:stock_location_product_restriction.field_stock_location_restriction_violation_message +msgid "Restriction violation message" +msgstr "Restriction violation message" + +#. module: stock_location_product_restriction +#: model:ir.ui.view,arch_db:stock_location_product_restriction.stock_location_form_view +msgid "Restrictions" +msgstr "Restrictions" + +#. module: stock_location_product_restriction +#: model:ir.model.fields,field_description:stock_location_product_restriction.field_stock_location_specific_product_restriction +msgid "Specific product restriction" +msgstr "Spécifique restriction sur les articles" + +#. module: stock_location_product_restriction +#: model:ir.model,name:stock_location_product_restriction.model_stock_move +msgid "Stock Move" +msgstr "Mouvement de stock" + +#. module: stock_location_product_restriction +#: code:addons/stock_location_product_restriction/models/stock_move.py:42 +#, python-format +msgid "" +"The location %s can only contain items of the same product. You plan to move " +"different products to this location. (%s)" +msgstr "" +"Impossible d'effectuer le transfer. L'emplacement %s ne peut contenir que " +"des articles identiques. (%s)" + +#. module: stock_location_product_restriction +#: code:addons/stock_location_product_restriction/models/stock_location.py:105 +#, python-format +msgid "" +"This location should only contain items of the same product but it contains " +"items of products %s" +msgstr "" +"Cet emplacement ne peut contenir que des articles identiques mais les " +"articles %s y sont présents." + +#. module: stock_location_product_restriction +#: model:ir.ui.view,arch_db:stock_location_product_restriction.stock_location_search_view +msgid "With restriction violation" +msgstr "Ne respecte pas les règles" + +#. module: stock_location_product_restriction +#: code:addons/stock_location_product_restriction/models/stock_move.py:84 +#, python-format +msgid "" +"You plan to move the product %s to the location %s but the location must " +"only contain items of same product and already contains items of other " +"product(s) (%s)." +msgstr "" +"Impossible d'effectuer le transfer du produit %s. L'emplacement %s ne peut " +"contenir que des articles identiques et il contient déjà: %s" diff --git a/stock_location_product_restriction/i18n/stock_location_product_restriction.pot b/stock_location_product_restriction/i18n/stock_location_product_restriction.pot new file mode 100644 index 000000000..07b1989fe --- /dev/null +++ b/stock_location_product_restriction/i18n/stock_location_product_restriction.pot @@ -0,0 +1,85 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_location_product_restriction +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 10.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: <>\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: stock_location_product_restriction +#: model:ir.model.fields,field_description:stock_location_product_restriction.field_stock_location_has_restriction_violation +msgid "Has restriction violation" +msgstr "" + +#. module: stock_location_product_restriction +#: model:ir.model.fields,help:stock_location_product_restriction.field_stock_location_parent_product_restriction +#: model:ir.model.fields,help:stock_location_product_restriction.field_stock_location_product_restriction +msgid "If 'Same product' is selected the system will prevent to put items of different products into the same location." +msgstr "" + +#. module: stock_location_product_restriction +#: model:ir.model.fields,help:stock_location_product_restriction.field_stock_location_specific_product_restriction +msgid "If specified the restriction specified will apply to the current location and all its children" +msgstr "" + +#. module: stock_location_product_restriction +#: model:ir.model,name:stock_location_product_restriction.model_stock_location +msgid "Inventory Locations" +msgstr "" + +#. module: stock_location_product_restriction +#: model:ir.model.fields,field_description:stock_location_product_restriction.field_stock_location_parent_product_restriction +#: model:ir.model.fields,field_description:stock_location_product_restriction.field_stock_location_product_restriction +msgid "Product restriction" +msgstr "" + +#. module: stock_location_product_restriction +#: model:ir.model.fields,field_description:stock_location_product_restriction.field_stock_location_restriction_violation_message +msgid "Restriction violation message" +msgstr "" + +#. module: stock_location_product_restriction +#: model:ir.ui.view,arch_db:stock_location_product_restriction.stock_location_form_view +msgid "Restrictions" +msgstr "" + +#. module: stock_location_product_restriction +#: model:ir.model.fields,field_description:stock_location_product_restriction.field_stock_location_specific_product_restriction +msgid "Specific product restriction" +msgstr "" + +#. module: stock_location_product_restriction +#: model:ir.model,name:stock_location_product_restriction.model_stock_move +msgid "Stock Move" +msgstr "" + +#. module: stock_location_product_restriction +#: code:addons/stock_location_product_restriction/models/stock_move.py:42 +#, python-format +msgid "The location %s can only contain items of the same product. You plan to move different products to this location. (%s)" +msgstr "" + +#. module: stock_location_product_restriction +#: code:addons/stock_location_product_restriction/models/stock_location.py:105 +#, python-format +msgid "This location should only contain items of the same product but it contains items of products %s" +msgstr "" + +#. module: stock_location_product_restriction +#: model:ir.ui.view,arch_db:stock_location_product_restriction.stock_location_search_view +msgid "With restriction violation" +msgstr "" + +#. module: stock_location_product_restriction +#: code:addons/stock_location_product_restriction/models/stock_move.py:84 +#, python-format +msgid "You plan to move the product %s to the location %s but the location must only contain items of same product and already contains items of other product(s) (%s)." +msgstr "" + diff --git a/stock_location_product_restriction/models/__init__.py b/stock_location_product_restriction/models/__init__.py new file mode 100644 index 000000000..4d109959e --- /dev/null +++ b/stock_location_product_restriction/models/__init__.py @@ -0,0 +1 @@ +from . import stock_location, stock_move diff --git a/stock_location_product_restriction/models/stock_location.py b/stock_location_product_restriction/models/stock_location.py new file mode 100644 index 000000000..89371a1db --- /dev/null +++ b/stock_location_product_restriction/models/stock_location.py @@ -0,0 +1,132 @@ +# Copyright 2020 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.osv.expression import NEGATIVE_TERM_OPERATORS + + +class StockLocation(models.Model): + + _inherit = "stock.location" + + product_restriction = fields.Selection( + selection=lambda self: self._selection_product_restriction(), + help="If 'Same product' is selected the system will prevent to put " + "items of different products into the same location.", + index=True, + required=True, + compute="_compute_product_restriction", + store=True, + default="any", + recursive=True, + ) + + specific_product_restriction = fields.Selection( + selection=lambda self: self._selection_product_restriction(), + help="If specified the restriction specified will apply to " + "the current location and all its children", + default=False, + ) + + parent_product_restriction = fields.Selection( + string="Parent Location Product Restriction", + store=True, + readonly=True, + related="location_id.product_restriction", + recursive=True, + ) + + has_restriction_violation = fields.Boolean( + compute="_compute_restriction_violation", + search="_search_has_restriction_violation", + recursive=True, + ) + + restriction_violation_message = fields.Char( + compute="_compute_restriction_violation", + recursive=True, + ) + + @api.model + def _selection_product_restriction(self): + return [ + ("any", "Items of any products are allowed into the location"), + ( + "same", + "Only items of the same product allowed into the location", + ), + ] + + @api.depends("specific_product_restriction", "parent_product_restriction") + def _compute_product_restriction(self): + default_value = "any" + for rec in self: + rec.product_restriction = ( + rec.specific_product_restriction + or rec.parent_product_restriction + or default_value + ) + + @api.depends("product_restriction") + def _compute_restriction_violation(self): + records = self + ProductProduct = self.env["product.product"] + SQL = """ + SELECT + stock_quant.location_id, + array_agg(distinct(product_id)) + FROM + stock_quant, + stock_location + WHERE + stock_quant.location_id in %s + and stock_location.id = stock_quant.location_id + and stock_location.product_restriction = 'same' + GROUP BY + stock_quant.location_id + HAVING count(distinct(product_id)) > 1 + """ + self.env.cr.execute(SQL, (tuple(records.ids),)) + product_ids_by_location_id = dict(self.env.cr.fetchall()) + for record in self: + record_id = record.id + has_restriction_violation = False + restriction_violation_message = False + product_ids = product_ids_by_location_id.get(record_id) + if product_ids: + products = ProductProduct.browse(product_ids) + has_restriction_violation = True + restriction_violation_message = _( + "This location should only contain items of the same " + "product but it contains items of products {products}" + ).format(products=" | ".join(products.mapped("name"))) + record.has_restriction_violation = has_restriction_violation + record.restriction_violation_message = restriction_violation_message + + def _search_has_restriction_violation(self, operator, value): + search_has_violation = ( + # has_restriction_violation != False + (operator in NEGATIVE_TERM_OPERATORS and not value) + # has_restriction_violation = True + or (operator not in NEGATIVE_TERM_OPERATORS and value) + ) + SQL = """ + SELECT + stock_quant.location_id + FROM + stock_quant, + stock_location + WHERE + stock_location.id = stock_quant.location_id + and stock_location.product_restriction = 'same' + GROUP BY + stock_quant.location_id + HAVING count(distinct(product_id)) > 1 + """ + self.env.cr.execute(SQL) + violation_ids = [r[0] for r in self.env.cr.fetchall()] + if search_has_violation: + op = "in" + else: + op = "not in" + return [("id", op, violation_ids)] diff --git a/stock_location_product_restriction/models/stock_move.py b/stock_location_product_restriction/models/stock_move.py new file mode 100644 index 000000000..233571c2e --- /dev/null +++ b/stock_location_product_restriction/models/stock_move.py @@ -0,0 +1,95 @@ +# Copyright 2020 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from collections import defaultdict + +from odoo import _, models +from odoo.exceptions import ValidationError + + +class StockMove(models.Model): + + _inherit = "stock.move" + + def _check_location_product_restriction(self): + """ + Check if the move can be executed according to potential restriction + defined on the stock_location + """ + StockLocation = self.env["stock.location"] + ProductProduct = self.env["product.product"] + # We only check moves with a location_dest that can + # only contain the same product + moves_to_ckeck = self.filtered( + lambda m: m.location_dest_id.product_restriction == "same" + ) + if not moves_to_ckeck: + return + product_ids_location_dest_id = defaultdict(set) + error_msgs = [] + # check dest locations into the stock moves + for move in moves_to_ckeck: + product_ids_location_dest_id[move.location_dest_id.id].add( + move.product_id.id + ) + for location_id, product_ids in product_ids_location_dest_id.items(): + if len(product_ids) > 1: + location = StockLocation.browse(location_id) + products = ProductProduct.browse(list(product_ids)) + error_msgs.append( + _( + "The location {location} can only contain items of the same " + "product. You plan to move different products to " + "this location. ({products})" + ).format( + location=location.name, + products=", ".join(products.mapped("name")), + ) + ) + + # check dest locations by taking into account product already into the + # locations + # here we use a plain SQL to avoid performance issue + SQL = """ + SELECT + location_id, + array_agg(distinct(product_id)) + FROM + stock_quant + WHERE + location_id in %s + GROUP BY + location_id + """ + self.env.cr.execute( + SQL, (tuple(moves_to_ckeck.mapped("location_dest_id").ids),) + ) + existing_product_ids_by_location_id = dict(self.env.cr.fetchall()) + for ( + location_dest_id, + existing_product_ids, + ) in existing_product_ids_by_location_id.items(): + product_ids_to_move = product_ids_location_dest_id[location_dest_id] + if set(existing_product_ids).symmetric_difference(product_ids_to_move): + location = StockLocation.browse(location_dest_id) + existing_products = ProductProduct.browse(existing_product_ids) + to_move_products = ProductProduct.browse(list(product_ids_to_move)) + error_msgs.append( + _( + "You plan to move the product {product} to the location {location} " + "but the location must only contain items of same " + "product and already contains items of other " + "product(s) " + "({existing_products})." + ).format( + product=" | ".join(to_move_products.mapped("name")), + location=location.name, + existing_products=" | ".join(existing_products.mapped("name")), + ) + ) + if error_msgs: + raise ValidationError("\n".join(error_msgs)) + + def _action_done(self, cancel_backorder=False): + self._check_location_product_restriction() + return super()._action_done(cancel_backorder=cancel_backorder) diff --git a/stock_location_product_restriction/readme/CONTRIBUTORS.rst b/stock_location_product_restriction/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..8fa151ccf --- /dev/null +++ b/stock_location_product_restriction/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Laurent Mignon (https://www.acsone.eu/) +* Denis Roussel (https://www.acsone.eu/) diff --git a/stock_location_product_restriction/readme/CREDITS.rst b/stock_location_product_restriction/readme/CREDITS.rst new file mode 100644 index 000000000..c5a3e7318 --- /dev/null +++ b/stock_location_product_restriction/readme/CREDITS.rst @@ -0,0 +1,4 @@ +The development of this module has been financially supported by: + +* ACSONE SA/NV +* Alcyon Benelux diff --git a/stock_location_product_restriction/readme/DESCRIPTION.rst b/stock_location_product_restriction/readme/DESCRIPTION.rst new file mode 100644 index 000000000..4ffdded84 --- /dev/null +++ b/stock_location_product_restriction/readme/DESCRIPTION.rst @@ -0,0 +1,2 @@ +This module extends the functionality of stock to allow you to prevent to put +items of different products into the same stock location. diff --git a/stock_location_product_restriction/readme/HISTORY.rst b/stock_location_product_restriction/readme/HISTORY.rst new file mode 100644 index 000000000..e69de29bb diff --git a/stock_location_product_restriction/readme/USAGE.rst b/stock_location_product_restriction/readme/USAGE.rst new file mode 100644 index 000000000..7917c7814 --- /dev/null +++ b/stock_location_product_restriction/readme/USAGE.rst @@ -0,0 +1,13 @@ +By default, Odoo allows you to put items of any product into the same location. +This behaviour remains the one by default once the addon is installed. +Once installed, you can specify at any level of the stock location hierarchy +if you want to restrict the usage of the location to only items of the same +product. This property is inherited by all the children locations while you +don't specify an other specific value on a child location. The constrains only +applies location by location. + +Once a location is configured to only contains items of the same product, the +system will prevent you to move items of any others products into a location +that already contains product items. A new filter into the tree view of the +stock locations will also allow you to find all the location where this new +restriction is violated. diff --git a/stock_location_product_restriction/readme/newsfragments/.gitkeep b/stock_location_product_restriction/readme/newsfragments/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/stock_location_product_restriction/static/description/icon.png b/stock_location_product_restriction/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/stock_location_product_restriction/static/description/icon.png differ diff --git a/stock_location_product_restriction/static/description/index.html b/stock_location_product_restriction/static/description/index.html new file mode 100644 index 000000000..1cddee585 --- /dev/null +++ b/stock_location_product_restriction/static/description/index.html @@ -0,0 +1,445 @@ + + + + + + +Stock Location Product Restriction + + + +
+

Stock Location Product Restriction

+ + +

Beta License: AGPL-3 OCA/stock-logistics-warehouse Translate me on Weblate Try me on Runbot

+

This module extends the functionality of stock to allow you to prevent to put +items of different products into the same stock location.

+

Table of contents

+ +
+

Usage

+

By default, Odoo allows you to put items of any product into the same location. +This behaviour remains the one by default once the addon is installed. +Once installed, you can specify at any level of the stock location hierarchy +if you want to restrict the usage of the location to only items of the same +product. This property is inherited by all the children locations while you +don’t specify an other specific value on a child location. The constrains only +applies location by location.

+

Once a location is configured to only contains items of the same product, the +system will prevent you to move items of any others products into a location +that already contains product items. A new filter into the tree view of the +stock locations will also allow you to find all the location where this new +restriction is violated.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • ACSONE SA/NV
  • +
+
+ +
+

Other credits

+

The development of this module has been financially supported by:

+
    +
  • ACSONE SA/NV
  • +
  • Alcyon Benelux
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/stock-logistics-warehouse project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/stock_location_product_restriction/tests/__init__.py b/stock_location_product_restriction/tests/__init__.py new file mode 100644 index 000000000..942d94611 --- /dev/null +++ b/stock_location_product_restriction/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_stock_location +from . import test_stock_move diff --git a/stock_location_product_restriction/tests/test_stock_location.py b/stock_location_product_restriction/tests/test_stock_location.py new file mode 100644 index 000000000..a57eddcde --- /dev/null +++ b/stock_location_product_restriction/tests/test_stock_location.py @@ -0,0 +1,260 @@ +# Copyright 2020 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests.common import TransactionCase + + +class TestStockLocation(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.StockLocation = cls.env["stock.location"] + cls.StockLocation._parent_store_compute() + cls.loc_lvl = cls.env.ref("stock.stock_location_locations") + cls.loc_lvl_1 = cls.StockLocation.create( + {"name": "level_1", "location_id": cls.loc_lvl.id} + ) + cls.loc_lvl_1_1 = cls.StockLocation.create( + {"name": "level_1_1", "location_id": cls.loc_lvl_1.id} + ) + + cls.loc_lvl_1_1_1 = cls.StockLocation.create( + {"name": "level_1_1_1", "location_id": cls.loc_lvl_1_1.id} + ) + cls.loc_lvl_1_1_2 = cls.StockLocation.create( + {"name": "level_1_1_1", "location_id": cls.loc_lvl_1_1.id} + ) + cls.default_product_restriction = "any" + + # products + Product = cls.env["product.product"] + cls.uom_unit = cls.env.ref("uom.product_uom_unit") + cls.product_1 = Product.create( + {"name": "Wood", "type": "product", "uom_id": cls.uom_unit.id} + ) + cls.product_2 = Product.create( + {"name": "Stone", "type": "product", "uom_id": cls.uom_unit.id} + ) + + # quants + StockQuant = cls.env["stock.quant"] + StockQuant.create( + { + "product_id": cls.product_1.id, + "location_id": cls.loc_lvl_1_1_1.id, + "quantity": 10.0, + "owner_id": cls.env.user.id, + } + ) + StockQuant.create( + { + "product_id": cls.product_2.id, + "location_id": cls.loc_lvl_1_1_1.id, + "quantity": 10.0, + "owner_id": cls.env.user.id, + } + ) + StockQuant.create( + { + "product_id": cls.product_1.id, + "location_id": cls.loc_lvl_1_1_2.id, + "quantity": 10.0, + "owner_id": cls.env.user.id, + } + ) + StockQuant.create( + { + "product_id": cls.product_2.id, + "location_id": cls.loc_lvl_1_1_2.id, + "quantity": 10.0, + "owner_id": cls.env.user.id, + } + ) + + def test_00(self): + """ + Data: + A 3 depths location hierarchy without + specific_product_restriction + Test Case: + 1. Specify a specific_product_restriction at root level + Expected result: + The value at each level must modified. + """ + self.loc_lvl_1.specific_product_restriction = "same" + children = self.loc_lvl_1.child_ids + + def check_field(locs, name): + for loc in locs: + self.assertEqual( + name, + loc.product_restriction, + "Wrong product restriction on loc %s" % loc.name, + ) + check_field(loc.child_ids, name) + + check_field(children, "same") + + def test_01(self): + """ + Data: + A 3 depths location hierarchy without + specific_product_restriction + Test Case: + 1. Specify a specific_product_restriction at level_1_1 + Expected result: + The value at root level and level 1 is the default + The value at level_1_1 and level_1_1_1 is the new one + """ + self.loc_lvl_1_1.specific_product_restriction = "same" + self.loc_lvl_1_1.flush_recordset() + self.assertEqual( + self.default_product_restriction, + self.loc_lvl.product_restriction, + ) + self.assertEqual( + self.default_product_restriction, + self.loc_lvl_1.product_restriction, + ) + self.assertEqual("same", self.loc_lvl_1_1.product_restriction) + self.assertEqual("same", self.loc_lvl_1_1_1.product_restriction) + + def test_02(self): + """ + Data: + Location level_1_1_1 with 2 different products no restriction + Location level_1_1_2 with 2 different products no restriction + Test Case: + 1. Search location child of loc_lvl with restriction violation + 2. Search location child of loc_lvl without restriction violation + Expected result: + 1. No result + 2. All child location are returned + """ + self.loc_lvl_1_1_1.product_restriction = "any" + self.loc_lvl_1_1_2.product_restriction = "any" + # has violation + res = self.StockLocation.search( + [ + ("id", "child_of", self.loc_lvl.id), + ("has_restriction_violation", "=", True), + ] + ) + self.assertFalse(res) + res = self.StockLocation.search( + [ + ("id", "child_of", self.loc_lvl.id), + ("has_restriction_violation", "!=", False), + ] + ) + self.assertFalse(res) + # without violation + res = self.StockLocation.search( + [ + ("id", "child_of", self.loc_lvl.id), + ("has_restriction_violation", "=", False), + ] + ) + self.assertIn(self.loc_lvl_1_1_1, res) + self.assertIn(self.loc_lvl_1_1_2, res) + res = self.StockLocation.search( + [ + ("id", "child_of", self.loc_lvl.id), + ("has_restriction_violation", "!=", True), + ] + ) + self.assertIn(self.loc_lvl_1_1_1, res) + self.assertIn(self.loc_lvl_1_1_2, res) + + def test_03(self): + """ + Data: + * Location level_1_1_1 with 2 different products no restriction + * Location level_1_1_2 with 2 different products + with restriction same + Test Case: + 1. Search location child of loc_lvl with restriction violation + 2. Search location child of loc_lvl without restriction violation + 3. Set restriction 'same' on location level_1_1_1 + 4. Search location child of loc_lvl with restriction violation + Expected result: + 1. result = level_1_1_2 + 2. level_1_1_2 is not into result but level_1_1_1 is + 4. result = level_1_1_2 and level_1_1_1 + """ + self.loc_lvl_1_1_1.product_restriction = "any" + self.loc_lvl_1_1_2.product_restriction = "same" + (self.loc_lvl_1_1_1 | self.loc_lvl_1_1_2).flush_recordset() + # 1 + res = self.StockLocation.search( + [ + ("id", "child_of", self.loc_lvl.id), + ("has_restriction_violation", "=", True), + ] + ) + self.assertEqual(self.loc_lvl_1_1_2, res) + res = self.StockLocation.search( + [ + ("id", "child_of", self.loc_lvl.id), + ("has_restriction_violation", "!=", False), + ] + ) + self.assertEqual(self.loc_lvl_1_1_2, res) + # 2 + res = self.StockLocation.search( + [ + ("id", "child_of", self.loc_lvl.id), + ("has_restriction_violation", "=", False), + ] + ) + self.assertIn(self.loc_lvl_1_1_1, res) + self.assertNotIn(self.loc_lvl_1_1_2, res) + res = self.StockLocation.search( + [ + ("id", "child_of", self.loc_lvl.id), + ("has_restriction_violation", "!=", True), + ] + ) + self.assertIn(self.loc_lvl_1_1_1, res) + self.assertNotIn(self.loc_lvl_1_1_2, res) + # 3 + self.loc_lvl_1_1_1.product_restriction = "same" + self.loc_lvl_1_1_2.product_restriction = "same" + (self.loc_lvl_1_1_1 | self.loc_lvl_1_1_2).flush_recordset() + # 4 + res = self.StockLocation.search( + [ + ("id", "child_of", self.loc_lvl.id), + ("has_restriction_violation", "=", True), + ] + ) + self.assertEqual(self.loc_lvl_1_1_2 | self.loc_lvl_1_1_1, res) + res = self.StockLocation.search( + [ + ("id", "child_of", self.loc_lvl.id), + ("has_restriction_violation", "!=", False), + ] + ) + self.assertEqual(self.loc_lvl_1_1_2 | self.loc_lvl_1_1_1, res) + + def test_04(self): + """ + Data: + * Location level_1_1_1 with 2 different products no restriction + Test Case: + 1. Check restriction message + 3. Set restriction 'same' on location level_1_1_1 + 4. Check restriction message + Expected result: + 1. No restriction message + 3. Retriction message + """ + self.loc_lvl_1_1_1.product_restriction = "any" + self.loc_lvl_1_1_1.flush_recordset() + self.assertFalse(self.loc_lvl_1_1_1.has_restriction_violation) + self.assertFalse(self.loc_lvl_1_1_1.restriction_violation_message) + self.loc_lvl_1_1_1.product_restriction = "same" + self.loc_lvl_1_1_1.flush_recordset() + self.assertTrue(self.loc_lvl_1_1_1.has_restriction_violation) + self.assertTrue(self.loc_lvl_1_1_1.restriction_violation_message) diff --git a/stock_location_product_restriction/tests/test_stock_move.py b/stock_location_product_restriction/tests/test_stock_move.py new file mode 100644 index 000000000..2573c5da0 --- /dev/null +++ b/stock_location_product_restriction/tests/test_stock_move.py @@ -0,0 +1,291 @@ +# Copyright 2020 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from collections import namedtuple + +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase + +ShortMoveInfo = namedtuple("ShortMoveInfo", ["product", "location_dest", "qty"]) + + +class TestStockMove(TransactionCase): + @classmethod + def setUpClass(cls): + """ + Data: + 2 products: product_1, product_2 + 1 new warehouse: warehouse_1 + 2 new locations: location_1 and location_2 are child of + warehouse_1's stock location and without + restriction + stock: + * 50 product_1 in location_1 + * 0 product_2 en stock + """ + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.uom_unit = cls.env.ref("uom.product_uom_unit") + Product = cls.env["product.product"] + cls.product_1 = Product.create( + {"name": "Wood", "type": "product", "uom_id": cls.uom_unit.id} + ) + cls.product_2 = Product.create( + {"name": "Stone", "type": "product", "uom_id": cls.uom_unit.id} + ) + + # Warehouses + cls.warehouse_1 = cls.env["stock.warehouse"].create( + { + "name": "Base Warehouse", + "reception_steps": "one_step", + "delivery_steps": "ship_only", + "code": "BWH", + } + ) + + # Locations + cls.location_1 = cls.env["stock.location"].create( + { + "name": "TestLocation1", + "posx": 3, + "location_id": cls.warehouse_1.lot_stock_id.id, + } + ) + + cls.location_2 = cls.env["stock.location"].create( + { + "name": "TestLocation2", + "posx": 4, + "location_id": cls.warehouse_1.lot_stock_id.id, + } + ) + + cls.supplier_location = cls.env.ref("stock.stock_location_suppliers") + + # partner + cls.partner_1 = cls.env["res.partner"].create( + {"name": "ACSONE SA/NV", "email": "info@acsone.eu"} + ) + + # picking type + cls.picking_type_in = cls.env.ref("stock.picking_type_in") + + # Inventory Add product_1 to location_1 + cls._change_product_qty(cls.product_1, cls.location_1, 50) + cls.StockMove = cls.env["stock.move"] + cls.StockPicking = cls.env["stock.picking"] + + @classmethod + def _change_product_qty(cls, product, location, qty): + cls.env["stock.quant"].with_context(inventory_mode=True).create( + { + "product_id": product.id, + "inventory_quantity": qty, + "location_id": location.id, + } + )._apply_inventory() + + def _get_products_in_location(self, location): + return ( + self.env["stock.quant"] + .search([("location_id", "=", location.id)]) + .mapped("product_id") + ) + + def _create_and_assign_picking(self, short_move_infos, location_dest=None): + location_dest = location_dest or self.location_1 + picking_in = self.StockPicking.create( + { + "partner_id": self.partner_1.id, + "picking_type_id": self.picking_type_in.id, + "location_id": self.supplier_location.id, + "location_dest_id": location_dest.id, + } + ) + for move_info in short_move_infos: + self.StockMove.create( + { + "name": move_info.product.name, + "product_id": move_info.product.id, + "product_uom_qty": move_info.qty, + "product_uom": move_info.product.uom_id.id, + "picking_id": picking_in.id, + "location_id": self.supplier_location.id, + "location_dest_id": move_info.location_dest.id, + } + ) + picking_in.action_confirm() + return picking_in + + def _process_picking(self, picking): + picking.action_assign() + for line in picking.move_line_ids: + line.qty_done = line.reserved_qty + picking.button_validate() + + def test_00(self): + """ + Data: + location_1 without product_restriction + location_1 with 50 product_1 + Test case: + Add qty of product_2 into location_1 + Expected result: + The location contains the 2 products + """ + self.assertEqual( + self.product_1, self._get_products_in_location(self.location_1) + ) + self._change_product_qty(self.product_2, self.location_1, 10) + self.assertEqual( + self.product_1 | self.product_2, + self._get_products_in_location(self.location_1), + ) + + def test_01(self): + """ + Data: + location_1 with same product restriction + location_1 with 50 product_1 + Test case: + Add qty of product_2 into location_1 + Expected result: + ValidationError + """ + self.assertEqual( + self.product_1, self._get_products_in_location(self.location_1) + ) + self.location_1.specific_product_restriction = "same" + self.location_1.flush_recordset() + with self.assertRaises(ValidationError): + self._change_product_qty(self.product_2, self.location_1, 10) + + def test_02(self): + """ + Data: + location_2 without product nor product restriction + a picking with two move with destination location_2 + Test case: + Process the picking + Expected result: + The two product are into location 2 + """ + picking = self._create_and_assign_picking( + [ + ShortMoveInfo( + product=self.product_1, + location_dest=self.location_2, + qty=2, + ), + ShortMoveInfo( + product=self.product_2, + location_dest=self.location_2, + qty=2, + ), + ], + location_dest=self.location_2, + ) + self._process_picking(picking) + self.assertEqual( + self.product_1 | self.product_2, + self._get_products_in_location(self.location_2), + ) + + def test_03(self): + """ + Data: + location_2 without product but with product restriction = 'same' + a picking with two move with destination location_2 + Test case: + Process the picking + Expected result: + ValidationError + """ + self.location_2.specific_product_restriction = "same" + self.location_2.flush_recordset() + picking = self._create_and_assign_picking( + [ + ShortMoveInfo( + product=self.product_1, + location_dest=self.location_2, + qty=2, + ), + ShortMoveInfo( + product=self.product_2, + location_dest=self.location_2, + qty=2, + ), + ], + location_dest=self.location_2, + ) + with self.assertRaises(ValidationError): + self._process_picking(picking) + + def test_04(self): + """ + Data: + location_1 with product_1 and wihout product restriction = 'same' + a picking with two moves: + * product_1 -> location_1, + * product_2 -> location_1 + Test case: + Process the picking + Expected result: + We now have two product into the same location + """ + self.assertEqual( + self.product_1, + self._get_products_in_location(self.location_1), + ) + self.location_1.specific_product_restriction = "any" + picking = self._create_and_assign_picking( + [ + ShortMoveInfo( + product=self.product_1, + location_dest=self.location_1, + qty=2, + ), + ShortMoveInfo( + product=self.product_2, + location_dest=self.location_1, + qty=2, + ), + ], + location_dest=self.location_1, + ) + self._process_picking(picking) + self.assertEqual( + self.product_1 | self.product_2, + self._get_products_in_location(self.location_1), + ) + + def test_05(self): + """ + Data: + location_1 with product_1 but with product restriction = 'same' + a picking with one move: product_2 -> location_1 + Test case: + Process the picking + Expected result: + ValidationError + """ + + self.assertEqual( + self.product_1, + self._get_products_in_location(self.location_1), + ) + self.location_1.specific_product_restriction = "same" + self.location_1.invalidate_recordset() + picking = self._create_and_assign_picking( + [ + ShortMoveInfo( + product=self.product_2, + location_dest=self.location_1, + qty=2, + ), + ], + location_dest=self.location_1, + ) + with self.assertRaises(ValidationError): + self._process_picking(picking) diff --git a/stock_location_product_restriction/views/stock_location.xml b/stock_location_product_restriction/views/stock_location.xml new file mode 100644 index 000000000..b698319b1 --- /dev/null +++ b/stock_location_product_restriction/views/stock_location.xml @@ -0,0 +1,60 @@ + + + + + + stock.location.form (in stock_location_product_restriction) + stock.location + + +
+ +
+ + + + + + +
+
+ + + stock.location.search (in stock_location_product_restriction) + stock.location + + + + + + + + + + stock.location.tree (in stock_location_unique_product) + stock.location + + + + + + + + +