diff --git a/setup/stock_location_package_restriction/odoo/addons/stock_location_package_restriction b/setup/stock_location_package_restriction/odoo/addons/stock_location_package_restriction new file mode 120000 index 000000000..29e3fb289 --- /dev/null +++ b/setup/stock_location_package_restriction/odoo/addons/stock_location_package_restriction @@ -0,0 +1 @@ +../../../../stock_location_package_restriction \ No newline at end of file diff --git a/setup/stock_location_package_restriction/setup.py b/setup/stock_location_package_restriction/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/stock_location_package_restriction/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/stock_location_package_restriction/README.rst b/stock_location_package_restriction/README.rst new file mode 100644 index 000000000..0a3477ba8 --- /dev/null +++ b/stock_location_package_restriction/README.rst @@ -0,0 +1 @@ +Wait for the bot diff --git a/stock_location_package_restriction/__init__.py b/stock_location_package_restriction/__init__.py new file mode 100644 index 000000000..0ee8b5073 --- /dev/null +++ b/stock_location_package_restriction/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import tests diff --git a/stock_location_package_restriction/__manifest__.py b/stock_location_package_restriction/__manifest__.py new file mode 100644 index 000000000..7a52a2f4f --- /dev/null +++ b/stock_location_package_restriction/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2023 Raumschmiede (http://www.raumschmiede.de) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +{ + "name": "Stock Location Package Restriction", + "summary": """ + Control if the location can contain products not in a package""", + "version": "14.0.1.0.0", + "category": "Warehouse Management", + "author": "Raumschmiede.de, BCIM, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/stock-logistics-warehouse", + "license": "AGPL-3", + "depends": ["stock"], + "application": False, + "installable": True, + "data": ["views/stock_location.xml"], +} diff --git a/stock_location_package_restriction/models/__init__.py b/stock_location_package_restriction/models/__init__.py new file mode 100644 index 000000000..95b3c075e --- /dev/null +++ b/stock_location_package_restriction/models/__init__.py @@ -0,0 +1,2 @@ +from . import stock_location +from . import stock_move diff --git a/stock_location_package_restriction/models/stock_location.py b/stock_location_package_restriction/models/stock_location.py new file mode 100644 index 000000000..a02f4c752 --- /dev/null +++ b/stock_location_package_restriction/models/stock_location.py @@ -0,0 +1,40 @@ +# Copyright 2023 Raumschmiede (http://www.raumschmiede.de) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + +SINGLEPACKAGE = "singlepackage" +MULTIPACKAGE = "multiplepackage" +NORESTRICTION = "norestriction" + + +class StockLocation(models.Model): + _inherit = "stock.location" + + package_restriction = fields.Selection( + selection=lambda self: self._selection_package_restriction(), + help=""" + Control if the location can contain products not in a package. + + Options: + * Mandatory and unique: The location cannot have products not + part of a package and you cannot have more than 1 package on + the location + * Mandatory and not unique: The location cannot have products + not part of a package and you may have store multiple packages + on the location + * Not mandatory: The location can contain products not part of a + package + """, + required=True, + store=True, + default="norestriction", + ) + + @api.model + def _selection_package_restriction(self): + return [ + (MULTIPACKAGE, "Mandatory"), + (SINGLEPACKAGE, "Mandatory and unique"), + (NORESTRICTION, "Not mandatory"), + ] diff --git a/stock_location_package_restriction/models/stock_move.py b/stock_location_package_restriction/models/stock_move.py new file mode 100644 index 000000000..eea352f82 --- /dev/null +++ b/stock_location_package_restriction/models/stock_move.py @@ -0,0 +1,96 @@ +# Copyright 2023 Raumschmiede (http://www.raumschmiede.de) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + + +from odoo import _, models +from odoo.exceptions import ValidationError +from odoo.tools import groupby + +from .stock_location import NORESTRICTION, SINGLEPACKAGE + + +class StockMove(models.Model): + _inherit = "stock.move" + + def _check_location_package_restriction(self): + """ + Check if the current stock.move can be executed + regarding a potential package restriction + """ + Package = self.env["stock.quant.package"] + error_msgs = [] + quants_grouped = self.env["stock.quant"].read_group( + [ + ("location_id", "in", self.move_line_ids.location_dest_id.ids), + ("location_id.package_restriction", "!=", NORESTRICTION), + ("quantity", ">", 0), + ], + ["location_id", "package_id:array_agg"], + "location_id", + ) + location_packages = { + g["location_id"][0]: set(g["package_id"]) for g in quants_grouped + } + for location, move_lines in groupby( + self.move_line_ids, lambda m: m.location_dest_id + ): + if location.package_restriction == NORESTRICTION: + continue + + existing_package_ids = location_packages.get(location.id, set()) + existing_packages = Package.browse(existing_package_ids).exists() + new_packages = Package.browse() + for move_line in move_lines: + package = move_line.result_package_id + # CASE: Package restiction exists but there is no package in move + if not package: + error_msgs.append( + _( + "A package is mandatory on the location {location}. " + "You cannot move the product {product} without a package." + ).format( + location=location.display_name, + product=move_line.product_id.display_name, + ) + ) + continue + if location.package_restriction != SINGLEPACKAGE: + continue + # CASE: Package on location, new package is different + if existing_package_ids and package.id not in existing_package_ids: + error_msgs.append( + _( + "Only one package is allowed on the location {location}." + "You cannot add the {package}, there is already " + "{existing_packages}." + ).format( + location=location.display_name, + existing_packages=", ".join( + existing_packages.mapped("name") + ), + package=package.display_name, + ) + ) + continue + # CASE: Multiple packages in Move but single location + # with singlepackage Restriction + if new_packages and package not in new_packages: + error_msgs.append( + _( + "Only one package is allowed on the location {location}." + "You cannot move multiple packages to it:" + "{packages}" + ).format( + location=location.display_name, + packages=", ".join(new_packages.mapped("display_name")), + ) + ) + continue + new_packages |= package + + if error_msgs: + raise ValidationError("\n".join(error_msgs)) + + def _action_done(self, cancel_backorder=False): + self._check_location_package_restriction() + return super()._action_done(cancel_backorder=cancel_backorder) diff --git a/stock_location_package_restriction/readme/CONTRIBUTORS.rst b/stock_location_package_restriction/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..3ea86fa6b --- /dev/null +++ b/stock_location_package_restriction/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* Raumschmiede +* Jacques-Etienne Baudoux +* Juan Miguel Sánchez Arce diff --git a/stock_location_package_restriction/readme/DESCRIPTION.rst b/stock_location_package_restriction/readme/DESCRIPTION.rst new file mode 100644 index 000000000..385b45462 --- /dev/null +++ b/stock_location_package_restriction/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +This module adds the option to set restrictions for the packages that can be put in a location. diff --git a/stock_location_package_restriction/readme/USAGE.rst b/stock_location_package_restriction/readme/USAGE.rst new file mode 100644 index 000000000..b6324bf2b --- /dev/null +++ b/stock_location_package_restriction/readme/USAGE.rst @@ -0,0 +1,11 @@ +Configure on the location if the contained products must be part of a package +and if you allow multiple package. When the module is installed, by default +there is no restriction on the locations. + +The options are: + + * Mandatory and unique: The location cannot have products not part of a + package and you cannot have more than 1 package on the location + * Mandatory and not unique: The location cannot have products not part of a + package and you may have store multiple packages on the location + * Not mandatory: The location can contain products not part of a package diff --git a/stock_location_package_restriction/tests/__init__.py b/stock_location_package_restriction/tests/__init__.py new file mode 100644 index 000000000..4b33b6f53 --- /dev/null +++ b/stock_location_package_restriction/tests/__init__.py @@ -0,0 +1 @@ +from . import test_stock_move diff --git a/stock_location_package_restriction/tests/test_stock_move.py b/stock_location_package_restriction/tests/test_stock_move.py new file mode 100644 index 000000000..7e73ec67e --- /dev/null +++ b/stock_location_package_restriction/tests/test_stock_move.py @@ -0,0 +1,321 @@ +# Copyright 2023 Raumschmiede (http://www.raumschmiede.de) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + + +from collections import namedtuple + +from odoo.exceptions import ValidationError +from odoo.tests.common import SavepointCase + +from odoo.addons.stock_location_package_restriction.models.stock_location import ( + MULTIPACKAGE, + NORESTRICTION, + SINGLEPACKAGE, +) + +ShortMoveInfo = namedtuple( + "ShortMoveInfo", ["product", "location_dest", "qty", "package_id"] +) + + +class TestStockMove(SavepointCase): + @classmethod + def setUpClass(cls): + """ + Data: + 2 products: product_1, product_2 + 2 packages: pack_1, pack_2 + 1 new warehouse: warehouse1 + 2 new locations: location1 and location2 are children of + warehouse1's stock location and without + restriction + stock: + * 50 product_1 in location_1 + * 0 product_2 in 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, + } + ) + + # Packages: + Package = cls.env["stock.quant.package"] + cls.pack_1 = Package.create({"name": "Package 1"}) + cls.pack_2 = Package.create({"name": "Package 2"}) + + cls.supplier_location = cls.env.ref("stock.stock_location_suppliers") + + # partner + cls.partner_1 = cls.env["res.partner"].create( + {"name": "Raumschmiede.de", "email": "info@raumschmiede.de"} + ) + + # picking type + cls.picking_type_in = cls.env.ref("stock.picking_type_in") + + cls.StockMove = cls.env["stock.move"] + cls.StockPicking = cls.env["stock.picking"] + + @classmethod + def _change_product_qty(cls, product, location, package, qty): + cls.env["stock.quant"].with_context(inventory_mode=True).create( + { + "product_id": product.id, + "package_id": package.id, + "inventory_quantity": qty, + "location_id": location.id, + } + ) + + def _get_package_in_location(self, location): + return ( + self.env["stock.quant"] + .search([("location_id", "=", location.id)]) + .mapped("package_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() + for move_info in short_move_infos: + line = picking_in.move_line_ids.filtered( + lambda x: x.product_id.id == move_info.product.id + ) + if line: + line.result_package_id = move_info.package_id + return picking_in + + def _process_picking(self, picking): + picking.action_assign() + for line in picking.move_line_ids: + line.qty_done = line.product_qty + picking.button_validate() + + def test_00(self): + """ + Data: + location_1 without package_restriction + location_1 with 50 product_1 and pack_1 + Test case: + Add qty of product_2 into location_1 with pack_2 + Expected result: + The location contains the 2 products in 2 different packages + """ + # Inventory Add product_1 to location_1 + self._change_product_qty(self.product_1, self.location_1, self.pack_1, 50) + self.assertEqual(self.pack_1, self._get_package_in_location(self.location_1)) + + self._change_product_qty(self.product_2, self.location_1, self.pack_2, 10) + self.assertEqual( + self.pack_1 | self.pack_2, + self._get_package_in_location(self.location_1), + ) + + def test_01(self): + """ + Data: + location_1 with single package restriction + location_1 with 50 product_1 and pack_1 + Test case: + Add qty of product_2 into location_1 with pack_2 + Expected result: + ValidationError + """ + # Inventory Add product_1 to location_1 + self._change_product_qty(self.product_1, self.location_1, self.pack_1, 50) + self.assertEqual(self.pack_1, self._get_package_in_location(self.location_1)) + self.location_1.package_restriction = SINGLEPACKAGE + with self.assertRaises(ValidationError): + self._change_product_qty(self.product_2, self.location_1, self.pack_2, 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 packages are on location 2 + """ + self.location_2.package_restriction = MULTIPACKAGE + picking = self._create_and_assign_picking( + [ + ShortMoveInfo( + product=self.product_1, + location_dest=self.location_2, + qty=2, + package_id=self.pack_1, + ), + ShortMoveInfo( + product=self.product_2, + location_dest=self.location_2, + qty=4, + package_id=self.pack_2, + ), + ], + location_dest=self.location_2, + ) + self._process_picking(picking) + self.assertEqual( + self.pack_1 | self.pack_2, + self._get_package_in_location(self.location_2), + ) + + def test_03(self): + """ + Data: + location_2 without package but with package restriction = 'single package' + a picking with two move with destination location_2 + Test case: + Process the picking + Expected result: + ValidationError + """ + self.location_2.package_restriction = SINGLEPACKAGE + picking = self._create_and_assign_picking( + [ + ShortMoveInfo( + product=self.product_1, + location_dest=self.location_2, + qty=2, + package_id=self.pack_1, + ), + ShortMoveInfo( + product=self.product_2, + location_dest=self.location_2, + qty=2, + package_id=self.pack_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 without product restriction = 'single package' + a picking with two moves: + * product_1 -> location_1, + * product_2 -> location_1 + Test case: + Process the picking + Expected result: + We now have two products into the same location + """ + # Inventory Add product_1 to location_1 + self._change_product_qty(self.product_1, self.location_1, self.pack_1, 50) + self.assertEqual( + self.pack_1, + self._get_package_in_location(self.location_1), + ) + self.location_1.package_restriction = NORESTRICTION + picking = self._create_and_assign_picking( + [ + ShortMoveInfo( + product=self.product_1, + location_dest=self.location_1, + qty=2, + package_id=self.pack_1, + ), + ShortMoveInfo( + product=self.product_2, + location_dest=self.location_1, + qty=2, + package_id=self.pack_2, + ), + ], + location_dest=self.location_1, + ) + self._process_picking(picking) + self.assertEqual( + self.pack_1 | self.pack_2, + self._get_package_in_location(self.location_1), + ) + + def test_05(self): + """ + Data: + location_1 with product_1 but with product restriction = 'single package' + a picking with one move: product_2 -> location_1 + Test case: + Process the picking + Expected result: + ValidationError + """ + # Inventory Add product_1 to location_1 + self._change_product_qty(self.product_1, self.location_1, self.pack_1, 50) + self.assertEqual( + self.pack_1, + self._get_package_in_location(self.location_1), + ) + self.location_1.package_restriction = SINGLEPACKAGE + picking = self._create_and_assign_picking( + [ + ShortMoveInfo( + product=self.product_2, + location_dest=self.location_1, + qty=2, + package_id=self.pack_2, + ), + ], + location_dest=self.location_1, + ) + with self.assertRaises(ValidationError): + self._process_picking(picking) diff --git a/stock_location_package_restriction/views/stock_location.xml b/stock_location_package_restriction/views/stock_location.xml new file mode 100644 index 000000000..291dcdd94 --- /dev/null +++ b/stock_location_package_restriction/views/stock_location.xml @@ -0,0 +1,29 @@ + + + + stock.location.form (in stock_location_package_restriction) + stock.location + + + + + + + + + + + stock.location.tree (in stock_location_unique_product) + stock.location + + + + + + + +