diff --git a/shipment_advice/models/shipment_advice.py b/shipment_advice/models/shipment_advice.py index 5ff3d489..571922cf 100644 --- a/shipment_advice/models/shipment_advice.py +++ b/shipment_advice/models/shipment_advice.py @@ -155,6 +155,11 @@ def _default_warehouse_id(self): compute="_compute_picking_ids", string="Loaded transfers", ) + to_validate_picking_ids = fields.One2many( + comodel_name="stock.picking", + compute="_compute_picking_ids", + string="Transfers to validate", + ) loaded_pickings_count = fields.Integer(compute="_compute_count") loaded_package_ids = fields.One2many( comodel_name="stock.quant.package", @@ -167,6 +172,17 @@ def _default_warehouse_id(self): string="Package Levels", ) loaded_packages_count = fields.Integer(compute="_compute_count") + line_to_load_ids = fields.One2many( + comodel_name="stock.move.line", + compute="_compute_line_to_load_ids", + help=( + "Lines to load in priority.\n" + "If the shipment is planned, it'll return the planned lines.\n" + "If the shipment is not planned, it'll return lines from transfers " + "partially loaded." + ), + ) + lines_to_load_count = fields.Integer(compute="_compute_count") carrier_ids = fields.Many2many( comodel_name="delivery.carrier", string="Related shipping methods", @@ -195,6 +211,69 @@ def _default_warehouse_id(self): def _default_run_in_queue_job(self): return self.env.user.company_id.shipment_advice_run_in_queue_job + def _find_move_lines_domain(self, picking_type_ids=None): + """Returns the base domain to look for move lines for a given shipment.""" + self.ensure_one() + domain = [ + ("state", "in", ("assigned", "partially_available")), + ("picking_code", "=", self.shipment_type), + "|", + ("shipment_advice_id", "=", False), + ("shipment_advice_id", "=", self.id), + ] + # Restrict on picking types if provided + if picking_type_ids: + domain.insert(0, ("picking_id.picking_type_id", "in", picking_type_ids.ids)) + else: + domain.insert( + 0, + ("picking_id.picking_type_id.warehouse_id", "=", self.warehouse_id.id), + ) + # Shipment with planned content, restrict the search to it + if self.planned_move_ids: + domain.append(("move_id.shipment_advice_id", "=", self.id)) + # Shipment without planned content, search for all unplanned moves + else: + domain.append(("move_id.shipment_advice_id", "=", False)) + # Restrict to shipment carrier delivery types (providers) + if self.carrier_ids: + domain.extend( + [ + "|", + ( + "picking_id.carrier_id.delivery_type", + "in", + self.carrier_ids.mapped("delivery_type"), + ), + ("picking_id.carrier_id", "=", False), + ] + ) + return domain + + @api.depends("planned_move_ids") + @api.depends_context("shipment_picking_type_ids") + def _compute_line_to_load_ids(self): + picking_type_ids = self.env.context.get("shipment_picking_type_ids", []) + for shipment in self: + domain = shipment._find_move_lines_domain(picking_type_ids) + # Restrict to lines not loaded + domain.insert(0, ("shipment_advice_id", "=", False)) + # Find lines to load from partially loaded transfers if the shipment + # is not planned. + if not shipment.planned_move_ids: + all_lines_to_load = self.env["stock.move.line"].search(domain) + all_pickings = all_lines_to_load.picking_id + loaded_lines = self.env["stock.move.line"].search( + [ + ("picking_id", "in", all_pickings.ids), + ("id", "not in", all_lines_to_load.ids), + ("shipment_advice_id", "!=", False), + ] + ) + pickings_partially_loaded = loaded_lines.picking_id + domain += [("picking_id", "in", pickings_partially_loaded.ids)] + shipment.line_to_load_ids = self.env["stock.move.line"].search(domain) + def _check_include_package_level(self, package_level): """Check if a package level should be listed in the shipment advice. @@ -208,11 +287,25 @@ def _compute_total_load(self): packages = shipment.loaded_move_line_ids.result_package_id shipment.total_load = sum(packages.mapped("shipping_weight")) - @api.depends("planned_move_ids", "loaded_move_line_ids") + @api.depends( + "planned_move_ids", "loaded_move_line_ids.picking_id.loaded_shipment_advice_ids" + ) def _compute_picking_ids(self): for shipment in self: shipment.planned_picking_ids = shipment.planned_move_ids.picking_id shipment.loaded_picking_ids = shipment.loaded_move_line_ids.picking_id + # Transfers to validate are those having only the current shipment + # advice to process + to_validate_picking_ids = [] + for picking in shipment.loaded_move_line_ids.picking_id: + shipments_to_process = picking.loaded_shipment_advice_ids.filtered( + lambda s: s.state not in ("done", "cancel") + ) + if shipments_to_process == shipment: + to_validate_picking_ids.append(picking.id) + shipment.to_validate_picking_ids = self.env["stock.picking"].browse( + to_validate_picking_ids + ) @api.depends( "loaded_move_line_ids.package_level_id.package_id", @@ -233,7 +326,7 @@ def _compute_package_ids(self): package_ids ) - @api.depends("planned_picking_ids", "planned_move_ids") + @api.depends("planned_picking_ids", "planned_move_ids", "line_to_load_ids") def _compute_count(self): for shipment in self: shipment.planned_pickings_count = len(shipment.planned_picking_ids) @@ -243,6 +336,7 @@ def _compute_count(self): shipment.loaded_move_line_without_package_ids ) shipment.loaded_packages_count = len(shipment.loaded_package_ids) + shipment.lines_to_load_count = len(shipment.line_to_load_ids) @api.depends("planned_picking_ids", "loaded_picking_ids") def _compute_carrier_ids(self): @@ -323,7 +417,7 @@ def _get_picking_to_process(self): self.ensure_one() if self.shipment_type == "incoming": return self.planned_picking_ids - return self.loaded_picking_ids + return self.to_validate_picking_ids def _action_done(self): # Validate transfers (create backorders for unprocessed lines) @@ -353,13 +447,17 @@ def _action_done(self): ] ), group(self.delayable(description=self.name)._unplan_undone_moves()), - group(self.delayable(description=self.name)._postprocess_action_done()), + group( + self.delayable(description=self.name)._postprocess_action_done( + backorder_policy + ) + ), ).delay() return for picking in pickings: self._validate_picking(picking, backorder_policy) self._unplan_undone_moves() - self._postprocess_action_done() + self._postprocess_action_done(backorder_policy) def _check_action_done_allowed(self): for shipment in self: @@ -402,12 +500,13 @@ def _unplan_undone_moves(self): ).filtered(lambda m: m.state not in ("cancel", "done") and not m.quantity_done) moves_to_unplan.shipment_advice_id = False - def _postprocess_action_done(self): + def _postprocess_action_done(self, backorder_policy): self.ensure_one() if self.state != "in_process": return if self._get_picking_to_process().filtered( lambda p: p.state not in ("done", "cancel") + and backorder_policy != "leave_open" ): self.write( { @@ -508,6 +607,13 @@ def button_open_loaded_packages(self): action["domain"] = [("id", "in", self.loaded_package_ids.ids)] return action + def button_open_to_load_move_lines(self): + action_xmlid = "stock.stock_move_line_action" + action = self.env["ir.actions.act_window"]._for_xml_id(action_xmlid) + action["domain"] = [("id", "in", self.line_to_load_ids.ids)] + action["context"] = {} # Disable filters + return action + def _domain_open_deliveries_in_progress(self): self.ensure_one() domain = [] diff --git a/shipment_advice/tests/__init__.py b/shipment_advice/tests/__init__.py index c25e037d..42fe7e37 100644 --- a/shipment_advice/tests/__init__.py +++ b/shipment_advice/tests/__init__.py @@ -1,6 +1,7 @@ from . import test_shipment_advice from . import test_shipment_advice_async from . import test_shipment_advice_plan +from . import test_shipment_advice_to_load from . import test_shipment_advice_load from . import test_shipment_advice_picking_values from . import test_shipment_advice_unload diff --git a/shipment_advice/tests/common.py b/shipment_advice/tests/common.py index 2838418d..ab495c58 100644 --- a/shipment_advice/tests/common.py +++ b/shipment_advice/tests/common.py @@ -39,7 +39,7 @@ def setUpClass(cls): cls.product_out1, 20, ) - cls.package = cls.env["stock.quant.package"].create({"name": "PKG_OUT2"}) + cls.package = cls.env["stock.quant.package"].create({"name": "PKG_OUT"}) cls._update_qty_in_location( cls.picking_type_out.default_location_src_id, cls.product_out2, diff --git a/shipment_advice/tests/test_shipment_advice.py b/shipment_advice/tests/test_shipment_advice.py index 9c5d7dcd..56348953 100644 --- a/shipment_advice/tests/test_shipment_advice.py +++ b/shipment_advice/tests/test_shipment_advice.py @@ -12,6 +12,34 @@ class TestShipmentAdvice(Common): def setUpClass(cls): super().setUpClass() + def _prepare_picking_with_two_packages(self): + # Prepare packages & products + package2 = self.env["stock.quant.package"].create({"name": "PKG_OUT2"}) + package3 = self.env["stock.quant.package"].create({"name": "PKG_OUT3"}) + self.env["stock.quant"]._update_available_quantity( + self.product_out2, + self.picking_type_out.default_location_src_id, + 5, + package_id=package2, + ) + self.env["stock.quant"]._update_available_quantity( + self.product_out3, + self.picking_type_out.default_location_src_id, + 5, + package_id=package3, + ) + # Prepare moves (belonging to the same transfer) + group = self.env["procurement.group"].create({}) + move_product_out2_2 = self._create_move( + self.picking_type_out, self.product_out2, 5, group + ) + self.assertEqual(move_product_out2_2.move_line_ids.package_id, package2) + move_product_out3_2 = self._create_move( + self.picking_type_out, self.product_out3, 5, group + ) + self.assertEqual(move_product_out3_2.move_line_ids.package_id, package3) + return move_product_out2_2.picking_id + def test_shipment_advice_confirm(self): self._check_sequence(self.shipment_advice_out) with self.assertRaises(UserError): @@ -78,8 +106,10 @@ def test_shipment_advice_incoming_done_partial(self): self.assertEqual(backorder.state, "assigned") def test_shipment_advice_done_full(self): - """Validating a shipment (whatever the backorder policy is) should - validate all fully loaded transfers. + """Validating a shipment validate all fully loaded related transfers. + + Whatever the backorder policy is, and if the loaded transfers are linked + to only one in progress shipment. """ picking = self.move_product_out1.picking_id self.progress_shipment_advice(self.shipment_advice_out) @@ -108,7 +138,7 @@ def test_shipment_advice_done_backorder_policy_disabled(self): # Validate the shipment => the transfer is still open self.shipment_advice_out.action_done() picking = package_level.picking_id - self.assertEqual(self.shipment_advice_out.state, "error") + self.assertEqual(self.shipment_advice_out.state, "done") # Check the transfer self.assertTrue( all( @@ -118,6 +148,42 @@ def test_shipment_advice_done_backorder_policy_disabled(self): ) self.assertEqual(picking.state, "assigned") + def test_multi_shipment_advice_done_backorder_policy_disabled(self): + """Load a transfer in multiple shipments and validate them with no BO policy. + + The last shipment validated is then responsible of the the transfer validation. + + 1. Load first package in one shipment advice + 2. Validate the first shipment advice: delivery order is not yet validated + 3. Load second package in another shipment advice + 4. Validate the second shipment advice: delivery order is now well validated + """ + # Disable the backorder policy + company = self.shipment_advice_out.company_id + company.shipment_advice_outgoing_backorder_policy = "leave_open" + # Prepare a transfer to load in two shipment advices + shipment_advice_out2 = self.env["shipment.advice"].create( + {"shipment_type": "outgoing"} + ) + picking = self._prepare_picking_with_two_packages() + line1, line2 = picking.move_line_ids + # Load first package in the first shipment advice + pl1 = line1.package_level_id + self.progress_shipment_advice(self.shipment_advice_out) + self.load_records_in_shipment(self.shipment_advice_out, pl1) + # Validate the first shipment advice: delivery order hasn't been validated + self.shipment_advice_out.action_done() + self.assertEqual(self.shipment_advice_out.state, "done") + self.assertEqual(picking.state, "assigned") + # Load second package in the second shipment advice + pl2 = line2.package_level_id + self.progress_shipment_advice(shipment_advice_out2) + self.load_records_in_shipment(shipment_advice_out2, pl2) + # Validate the second shipment advice: delivery order has now been validated + shipment_advice_out2.action_done() + self.assertEqual(shipment_advice_out2.state, "done") + self.assertEqual(picking.state, "done") + def test_shipment_advice_done_backorder_policy_enabled(self): """Validating a shipment with the backorder policy enabled should validate partial transfers and create a backorder. @@ -147,6 +213,35 @@ def test_shipment_advice_done_backorder_policy_enabled(self): ) self.assertEqual(picking2.state, "assigned") + def test_assign_lines_to_multiple_shipment_advices(self): + """Assign lines of a transfer to different shipment advices. + + 1. Load two packages in two different shipment advices + 2. Validate the first shipment advice: delivery order is not yet validated + 3. Validate the second shipment advice: delivery order is now well validated + """ + # Prepare a transfer to load in two shipment advices + shipment_advice_out2 = self.env["shipment.advice"].create( + {"shipment_type": "outgoing"} + ) + picking = self._prepare_picking_with_two_packages() + line1, line2 = picking.move_line_ids + # Load packages in different shipment advices + pl1 = line1.package_level_id + self.progress_shipment_advice(self.shipment_advice_out) + self.load_records_in_shipment(self.shipment_advice_out, pl1) + pl2 = line2.package_level_id + self.progress_shipment_advice(shipment_advice_out2) + self.load_records_in_shipment(shipment_advice_out2, pl2) + # Validate the first shipment advice: delivery order hasn't been validated + self.shipment_advice_out.action_done() + self.assertEqual(self.shipment_advice_out.state, "done") + self.assertEqual(picking.state, "assigned") + # Validate the second shipment advice: delivery order has now been validated + shipment_advice_out2.action_done() + self.assertEqual(shipment_advice_out2.state, "done") + self.assertEqual(picking.state, "done") + def test_shipment_advice_cancel(self): self.progress_shipment_advice(self.shipment_advice_out) self.shipment_advice_out.action_cancel() diff --git a/shipment_advice/tests/test_shipment_advice_async.py b/shipment_advice/tests/test_shipment_advice_async.py index c614f2f2..ba1d7e1b 100644 --- a/shipment_advice/tests/test_shipment_advice_async.py +++ b/shipment_advice/tests/test_shipment_advice_async.py @@ -148,7 +148,7 @@ def test_shipment_advice_done_backorder_policy_disabled(self): self._asset_jobs_dependency(jobs) trap.perform_enqueued_jobs() picking = package_level.picking_id - self.assertEqual(self.shipment_advice_out.state, "error") + self.assertEqual(self.shipment_advice_out.state, "done") # Check the transfer self.assertTrue( all( diff --git a/shipment_advice/tests/test_shipment_advice_to_load.py b/shipment_advice/tests/test_shipment_advice_to_load.py new file mode 100644 index 00000000..e838bed4 --- /dev/null +++ b/shipment_advice/tests/test_shipment_advice_to_load.py @@ -0,0 +1,51 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + + +from .common import Common + + +class TestShipmentAdviceToLoad(Common): + def test_shipment_advice_not_planned_lines_to_load(self): + self.progress_shipment_advice(self.shipment_advice_out) + # Load a transfer partially + self.load_records_in_shipment( + self.shipment_advice_out, self.move_product_out1.move_line_ids + ) + self.assertEqual( + self.move_product_out1.move_line_ids.shipment_advice_id, + self.shipment_advice_out, + ) + # Check the lines computed by the shipment advice that could be loaded + lines_to_load = ( + self.move_product_out2.move_line_ids | self.move_product_out3.move_line_ids + ) + self.assertFalse(lines_to_load.shipment_advice_id) + self.assertEqual(self.shipment_advice_out.line_to_load_ids, lines_to_load) + + def test_shipment_advice_planned_lines_to_load(self): + self.progress_shipment_advice(self.shipment_advice_out) + # Plan a transfer in the shipment advice + picking = self.move_product_out2.picking_id + self.plan_records_in_shipment(self.shipment_advice_out, picking) + # Check the lines computed by the shipment advice that could be loaded + # (= all the lines of the planned transfer) + lines_to_load = picking.move_line_ids + self.assertFalse(lines_to_load.shipment_advice_id) + self.assertEqual(self.shipment_advice_out.line_to_load_ids, lines_to_load) + # Load some goods from the planned transfer + self.load_records_in_shipment( + self.shipment_advice_out, self.move_product_out1.move_line_ids + ) + self.assertEqual( + self.move_product_out1.move_line_ids.shipment_advice_id, + self.shipment_advice_out, + ) + # Check the lines computed by the shipment advice that could be loaded + # (= the remaining planned lines of the transfer not yet loaded) + self.shipment_advice_out.invalidate_recordset() + lines_to_load2 = ( + self.move_product_out2.move_line_ids | self.move_product_out3.move_line_ids + ) + self.assertFalse(lines_to_load2.shipment_advice_id) + self.assertEqual(self.shipment_advice_out.line_to_load_ids, lines_to_load2) diff --git a/shipment_advice/views/shipment_advice.xml b/shipment_advice/views/shipment_advice.xml index fcb4718e..0341856f 100644 --- a/shipment_advice/views/shipment_advice.xml +++ b/shipment_advice/views/shipment_advice.xml @@ -145,6 +145,20 @@ widget="statinfo" /> +