diff --git a/stock_location_package_restriction/__manifest__.py b/stock_location_package_restriction/__manifest__.py index 40bc13796..620f5f324 100644 --- a/stock_location_package_restriction/__manifest__.py +++ b/stock_location_package_restriction/__manifest__.py @@ -1,13 +1,15 @@ # Copyright 2023 Raumschmiede (http://www.raumschmiede.de) +# Copyright 2023 Jacques-Etienne Baudoux (BCIM) # 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""", + Control if the location can contain products in a package""", "version": "14.0.1.2.1", "category": "Warehouse Management", "author": "Raumschmiede.de, BCIM, Odoo Community Association (OCA)", + "maintainters": ["jbaudoux"], "website": "https://github.com/OCA/stock-logistics-warehouse", "license": "AGPL-3", "depends": ["stock"], diff --git a/stock_location_package_restriction/models/stock_location.py b/stock_location_package_restriction/models/stock_location.py index 8ce6cd654..9fe6523d7 100644 --- a/stock_location_package_restriction/models/stock_location.py +++ b/stock_location_package_restriction/models/stock_location.py @@ -1,8 +1,11 @@ # Copyright 2023 Raumschmiede (http://www.raumschmiede.de) +# Copyright 2024 Jacques-Etienne Baudoux (BCIM) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from odoo import api, fields, models +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError +NOPACKAGE = "nopackage" SINGLEPACKAGE = "singlepackage" MULTIPACKAGE = "multiplepackage" @@ -16,21 +19,96 @@ class StockLocation(models.Model): Control if the location can contain products not in a package. Options: - * False (not set): Not mandatory, the location can contain products - not part of a package + * False (not set): No restriction, the location can contain products + with and without package + * Forbidden: The location cannot have products part of a package * 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 + not part of a package and you may store multiple packages on the location """, - store=True, ) @api.model def _selection_package_restriction(self): return [ + (NOPACKAGE, "Forbidden"), (MULTIPACKAGE, "Mandatory"), (SINGLEPACKAGE, "Mandatory and unique"), ] + + def _check_package_restriction(self, move_lines=None): + """Check if the location respect the package restrictions + + :param move_lines: Optional planned move_line to validate its destination location + :raises ValidationError: if the restriction is not respected + """ + error_msgs = [] + move_lines = move_lines or self.env["stock.move.line"] + for location in self: + if not location.package_restriction: + continue + invalid_products = False + if location.package_restriction == NOPACKAGE: + if move_lines: + move_lines_with_package = move_lines.filtered("result_package_id") + if move_lines_with_package: + invalid_products = move_lines_with_package.product_id + else: + quants_with_package = location.quant_ids.filtered( + lambda q: q.package_id and q.quantity + ) + if quants_with_package: + invalid_products = quants_with_package.product_id + if invalid_products: + error_msgs.append( + _( + "A package is not allowed on the location {location}." + "You cannot move the product(s) {product} with a package." + ).format( + location=location.display_name, + product=", ".join(invalid_products.mapped("display_name")), + ) + ) + continue + # SINGLE or MULTI + if move_lines: + move_lines_without_package = move_lines.filtered( + lambda ml: not ml.result_package_id + ) + if move_lines_without_package: + invalid_products = move_lines_without_package.product_id + else: + quants_without_package = location.quant_ids.filtered( + lambda q: not q.package_id and q.quantity + ) + if quants_without_package: + invalid_products = quants_without_package.product_id + if invalid_products: + error_msgs.append( + _( + "A package is mandatory on the location {location}.\n" + "You cannot move the product(s) {product} without a package." + ).format( + location=location.display_name, + product=", ".join(invalid_products.mapped("display_name")), + ) + ) + continue + if location.package_restriction == MULTIPACKAGE: + continue + # SINGLE + packages = ( + move_lines.result_package_id + | location.quant_ids.filtered("quantity").package_id + ) + if len(packages) > 1: + error_msgs.append( + _("Only one package is allowed on the location {location}.").format( + location=location.display_name + ) + ) + if error_msgs: + raise ValidationError("\n".join(error_msgs)) diff --git a/stock_location_package_restriction/models/stock_move.py b/stock_location_package_restriction/models/stock_move.py index 3e7c0849c..a55350df8 100644 --- a/stock_location_package_restriction/models/stock_move.py +++ b/stock_location_package_restriction/models/stock_move.py @@ -1,112 +1,21 @@ # Copyright 2023 Raumschmiede (http://www.raumschmiede.de) +# Copyright 2024 Jacques-Etienne Baudoux (BCIM) # 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 SINGLEPACKAGE +from odoo import models class StockMove(models.Model): _inherit = "stock.move" - def _check_location_package_restriction( - self, new_location=None, only_qty_done=True - ): - """Check if the moves can be done regarding potential package restrictions - - By default will only check move lines with a quantity done and their set - destination location. - - If `new_location` is set it will be use as destination location. - If `only_qty_done` is False the check is executed on all - related move lines. - - """ - Package = self.env["stock.quant.package"] - error_msgs = [] - dest_location = new_location or self.move_line_ids.location_dest_id - quants_grouped = self.env["stock.quant"].read_group( - [ - ("location_id", "in", dest_location.ids), - ("location_id.package_restriction", "!=", False), - ("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 - } - lines_being_processed = ( - self.move_line_ids.filtered(lambda line: line.qty_done) - if only_qty_done - else self.move_line_ids - ) - for location, move_lines in groupby( - lines_being_processed, lambda m: m.location_dest_id - ): - if not location.package_restriction: - continue - - if new_location: - location = new_location - 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 _check_location_package_restriction(self): + """Check if the moves can be done regarding potential package restrictions""" + self.filtered( + lambda m: m.state == "done" + ).location_dest_id._check_package_restriction() def _action_done(self, cancel_backorder=False): + res = super()._action_done(cancel_backorder=cancel_backorder) self._check_location_package_restriction() - return super()._action_done(cancel_backorder=cancel_backorder) + return res diff --git a/stock_location_package_restriction/readme/CONTRIBUTORS.rst b/stock_location_package_restriction/readme/CONTRIBUTORS.rst index 3ea86fa6b..a7c7a4709 100644 --- a/stock_location_package_restriction/readme/CONTRIBUTORS.rst +++ b/stock_location_package_restriction/readme/CONTRIBUTORS.rst @@ -1,3 +1,3 @@ * Raumschmiede -* Jacques-Etienne Baudoux +* Jacques-Etienne Baudoux (BCIM) * Juan Miguel Sánchez Arce diff --git a/stock_location_package_restriction/readme/USAGE.rst b/stock_location_package_restriction/readme/USAGE.rst index b6324bf2b..c6b2d049c 100644 --- a/stock_location_package_restriction/readme/USAGE.rst +++ b/stock_location_package_restriction/readme/USAGE.rst @@ -1,11 +1,14 @@ -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. +Configure on the location the package restriction. The options are: + * False (not set): No restriction, the location can contain products with and + without package + * Forbidden: The location cannot have products part of a package * 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 + * Mandatory and not unique: The location cannot have products not part of a + package and you may store multiple packages on the location + +When the module is installed, by default there is no restriction on the locations. + diff --git a/stock_location_package_restriction/tests/__init__.py b/stock_location_package_restriction/tests/__init__.py index 4b33b6f53..bf87d721d 100644 --- a/stock_location_package_restriction/tests/__init__.py +++ b/stock_location_package_restriction/tests/__init__.py @@ -1 +1,3 @@ +from . import test_stock_inventory from . import test_stock_move +from . import test_stock_move_line diff --git a/stock_location_package_restriction/tests/common.py b/stock_location_package_restriction/tests/common.py new file mode 100644 index 000000000..3a2c38e83 --- /dev/null +++ b/stock_location_package_restriction/tests/common.py @@ -0,0 +1,142 @@ +# Copyright 2023 Raumschmiede (http://www.raumschmiede.de) +# Copyright 2024 Jacques-Etienne Baudoux (BCIM) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from collections import namedtuple + +from odoo.tests.common import SavepointCase + +ShortMoveInfo = namedtuple( + "ShortMoveInfo", ["product", "location_dest", "qty", "package_id"] +) + + +class TestLocationPackageRestrictionCommon(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 and package.id, + "inventory_quantity": qty, + "location_id": location.id, + } + ) + + @classmethod + def _get_package_in_location(cls, location): + return ( + cls.env["stock.quant"] + .search([("location_id", "=", location.id)]) + .mapped("package_id") + ) + + @classmethod + def _create_and_assign_picking(cls, short_move_infos, location_dest=None): + location_dest = location_dest or cls.location_1 + picking_in = cls.StockPicking.create( + { + "partner_id": cls.partner_1.id, + "picking_type_id": cls.picking_type_in.id, + "location_id": cls.supplier_location.id, + "location_dest_id": location_dest.id, + } + ) + for move_info in short_move_infos: + cls.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": cls.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 + + @classmethod + def _process_picking(cls, picking): + picking.action_assign() + for line in picking.move_line_ids: + line.qty_done = line.product_qty + picking.button_validate() diff --git a/stock_location_package_restriction/tests/test_stock_inventory.py b/stock_location_package_restriction/tests/test_stock_inventory.py new file mode 100644 index 000000000..2a92cfa68 --- /dev/null +++ b/stock_location_package_restriction/tests/test_stock_inventory.py @@ -0,0 +1,79 @@ +# Copyright 2023 Raumschmiede (http://www.raumschmiede.de) +# Copyright 2024 Jacques-Etienne Baudoux (BCIM) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + + +from odoo.exceptions import ValidationError + +from odoo.addons.stock_location_package_restriction.models.stock_location import ( + NOPACKAGE, + SINGLEPACKAGE, +) + +from .common import TestLocationPackageRestrictionCommon + + +class TestInventory(TestLocationPackageRestrictionCommon): + def test_inventory_no_restriction(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_inventory_no_package_success(self): + """ + Data: + location_1 with no package_restriction + Test case: + Add qty of product_1 into location_1 + Expected result: + The location contains the product + """ + self.location_1.package_restriction = NOPACKAGE + # Inventory Add product_1 to location_1 + self._change_product_qty(self.product_1, self.location_1, False, 50) + + def test_inventory_no_package_error(self): + """ + Data: + location_1 with no package_restriction + Test case: + Add qty of product_1 into location_1 with pack_1 + Expected result: + ValidationError + """ + self.location_1.package_restriction = NOPACKAGE + # Inventory Add product_1 to location_1 + with self.assertRaises(ValidationError): + self._change_product_qty(self.product_1, self.location_1, self.pack_1, 50) + + def test_inventory_single_package(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) diff --git a/stock_location_package_restriction/tests/test_stock_move.py b/stock_location_package_restriction/tests/test_stock_move.py index 695de10b8..ce3bfb319 100644 --- a/stock_location_package_restriction/tests/test_stock_move.py +++ b/stock_location_package_restriction/tests/test_stock_move.py @@ -1,190 +1,24 @@ # Copyright 2023 Raumschmiede (http://www.raumschmiede.de) +# Copyright 2024 Jacques-Etienne Baudoux (BCIM) # 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, + NOPACKAGE, SINGLEPACKAGE, ) -ShortMoveInfo = namedtuple( - "ShortMoveInfo", ["product", "location_dest", "qty", "package_id"] -) +from .common import ShortMoveInfo, TestLocationPackageRestrictionCommon -class TestStockMove(SavepointCase): - @classmethod - def setUpClass(cls): +class TestStockMove(TestLocationPackageRestrictionCommon): + def test_picking_multi_package(self): """ 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 + location_2 without product nor package restriction a picking with two move with destination location_2 Test case: Process the picking @@ -215,7 +49,56 @@ class TestStockMove(SavepointCase): self._get_package_in_location(self.location_2), ) - def test_03(self): + def test_picking_no_package_success(self): + """ + Data: + location_1 with package restriction = 'no package' + a picking with destination location_1 + Test case: + Process the picking without result package + Expected result: + Picking processed + """ + self.location_1.package_restriction = NOPACKAGE + picking = self._create_and_assign_picking( + [ + ShortMoveInfo( + product=self.product_1, + location_dest=self.location_1, + qty=2, + package_id=False, + ), + ], + location_dest=self.location_1, + ) + self._process_picking(picking) + + def test_picking_no_package_error(self): + """ + Data: + location_1 with package restriction = 'no package' + a picking with destination location_1 + Test case: + Process the picking with result package + Expected result: + ValidationError + """ + self.location_1.package_restriction = NOPACKAGE + picking = self._create_and_assign_picking( + [ + ShortMoveInfo( + product=self.product_1, + location_dest=self.location_1, + qty=2, + package_id=self.pack_1, + ), + ], + location_dest=self.location_1, + ) + with self.assertRaises(ValidationError): + self._process_picking(picking) + + def test_picking_single_package_location_empty(self): """ Data: location_2 without package but with package restriction = 'single package' @@ -246,7 +129,7 @@ class TestStockMove(SavepointCase): with self.assertRaises(ValidationError): self._process_picking(picking) - def test_03_with_backorder(self): + def test_picking_single_package_with_backorder(self): """ Data: location_2 without package but with package restriction = 'single package' @@ -289,10 +172,10 @@ class TestStockMove(SavepointCase): wizard.process() self.assertEqual(self.pack_1, self._get_package_in_location(self.location_2)) - def test_04(self): + def test_picking_no_restriction(self): """ Data: - location_1 with product_1 and without product restriction = 'single package' + location_1 with product_1 and without package restriction = 'single package' a picking with two moves: * product_1 -> location_1, * product_2 -> location_1 @@ -331,10 +214,10 @@ class TestStockMove(SavepointCase): self._get_package_in_location(self.location_1), ) - def test_05(self): + def test_picking_single_package_location_not_empty(self): """ Data: - location_1 with product_1 but with product restriction = 'single package' + location_1 with product_1 but with package restriction = 'single package' a picking with one move: product_2 -> location_1 Test case: Process the picking diff --git a/stock_location_package_restriction/tests/test_stock_move_line.py b/stock_location_package_restriction/tests/test_stock_move_line.py new file mode 100644 index 000000000..6a0ef528a --- /dev/null +++ b/stock_location_package_restriction/tests/test_stock_move_line.py @@ -0,0 +1,143 @@ +# Copyright 2024 Jacques-Etienne Baudoux (BCIM) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + + +from odoo.exceptions import ValidationError + +from odoo.addons.stock_location_package_restriction.models.stock_location import ( + MULTIPACKAGE, + NOPACKAGE, + SINGLEPACKAGE, +) + +from .common import ShortMoveInfo, TestLocationPackageRestrictionCommon + + +class TestMoveLine(TestLocationPackageRestrictionCommon): + def test_move_line_multi_package(self): + """ + Data: + location_1 with pack1 + a move line with destination location_1 + Test case: + Process the picking + Expected result: + The two packages are on location 2 + """ + self.location_2.package_restriction = MULTIPACKAGE + self._change_product_qty(self.product_1, self.location_1, self.pack_1, 50) + 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, + ) + self.location_1._check_package_restriction(move_lines=picking.move_line_ids) + + def test_move_line_no_package_success(self): + """ + Data: + location_1 with package restriction = 'no package' + a move line with destination location_1 + Test case: + Process the picking without result package + Expected result: + Picking processed + """ + self.location_1.package_restriction = NOPACKAGE + picking = self._create_and_assign_picking( + [ + ShortMoveInfo( + product=self.product_1, + location_dest=self.location_1, + qty=2, + package_id=False, + ), + ], + location_dest=self.location_1, + ) + self.location_1._check_package_restriction(move_lines=picking.move_line_ids) + + def test_move_line_no_package_error(self): + """ + Data: + location_1 with package restriction = 'no package' + a move line with destination location_1 + Test case: + Process the picking with result package + Expected result: + ValidationError + """ + self.location_1.package_restriction = NOPACKAGE + picking = self._create_and_assign_picking( + [ + ShortMoveInfo( + product=self.product_1, + location_dest=self.location_1, + qty=2, + package_id=self.pack_1, + ), + ], + location_dest=self.location_1, + ) + with self.assertRaises(ValidationError): + self.location_1._check_package_restriction(move_lines=picking.move_line_ids) + + def test_move_line_single_package_location_empty(self): + """ + Data: + location_1 without package but with package restriction = 'single package' + a move line with two move with destination location_2 + Test case: + Process the picking + Expected result: + ValidationError + """ + self.location_1.package_restriction = SINGLEPACKAGE + picking = self._create_and_assign_picking( + [ + ShortMoveInfo( + product=self.product_1, + location_dest=self.location_1, + qty=2, + package_id=self.pack_1, + ), + ], + location_dest=self.location_1, + ) + self.location_1._check_package_restriction(move_lines=picking.move_line_ids) + + def test_move_line_single_package_location_not_empty(self): + """ + Data: + location_1 with product_1 but with package restriction = 'single package' + a picking with one move: product_2 -> location_1 + Test case: + Process the picking + Expected result: + ValidationError + """ + 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.location_1._check_package_restriction(move_lines=picking.move_line_ids)