diff --git a/stock_available_to_promise_release/models/stock_move.py b/stock_available_to_promise_release/models/stock_move.py index 33471f9fbe..d59d90eb59 100644 --- a/stock_available_to_promise_release/models/stock_move.py +++ b/stock_available_to_promise_release/models/stock_move.py @@ -765,3 +765,56 @@ def _get_new_picking_values(self): values = super()._get_new_picking_values() values["release_policy"] = values["move_type"] return values + + def write(self, vals): + released_moves = self.browse() + if self.env.context.get("in_merge_mode") and "product_uom_qty" in vals: + # when a move is merged, we need to unrelease it if the quantity + # is changed and the move is unreleasable + released_moves = self.filtered(lambda m: m._is_unreleaseable()) + # a change on the product_uom_qty on a released move with quantity + # partially done should not be possible. The 'safe_unrelease' flag + # is set to False to ensure this case is checked. Nevertheless, + # we should never reach this point as the merge candidates are + # filtered out in the method _update_candidate_moves_list to never + # merge releaseable moves with partially done quantity. + released_moves.unrelease(safe_unrelease=False) + ret = super().write(vals) + if released_moves: + released_moves.release_available_to_promise() + return ret + + def _is_mergeable(self): + self.ensure_one() + return self.state not in ("done", "cancel") and ( + self.picking_type_id.code != "outgoing" or self.unrelease_allowed + ) + + def _update_candidate_moves_list(self, candidate_moves): + # filter out the moves that are not unreleasable + res = super()._update_candidate_moves_list(candidate_moves) + # candidate_moves is a list of recordset of moves + # it contains one recordset per move to merge + # each recordset contains the moves that we want to merge (an item of self) + # and the candidate moves to merge into + new_candidate_moves = [ + candidates.filtered( + lambda m, moves_to_merge=self: m in moves_to_merge or m._is_mergeable() + ) + for candidates in candidate_moves + ] + # filter given list of moves to keep only the new ones + candidate_moves[:] = new_candidate_moves + return res + + def _merge_moves(self, merge_into=False): + # From here any write on the moves are done in the context of a merge + # and we need to unrelease them if the quantity is changed + self_ctx = self.with_context(in_merge_mode=True) + if merge_into: + merge_into = merge_into.filtered(lambda m: m._is_mergeable()) + return ( + super(StockMove, self_ctx) + ._merge_moves(merge_into=merge_into) + .with_context(in_merge_mode=False) + ) diff --git a/stock_available_to_promise_release/tests/__init__.py b/stock_available_to_promise_release/tests/__init__.py index 1eee41662b..35a813d9a2 100644 --- a/stock_available_to_promise_release/tests/__init__.py +++ b/stock_available_to_promise_release/tests/__init__.py @@ -1,3 +1,4 @@ +from . import test_merge_moves from . import test_reservation from . import test_unrelease from . import test_unrelease_2steps diff --git a/stock_available_to_promise_release/tests/test_merge_moves.py b/stock_available_to_promise_release/tests/test_merge_moves.py new file mode 100644 index 0000000000..5e08bd8642 --- /dev/null +++ b/stock_available_to_promise_release/tests/test_merge_moves.py @@ -0,0 +1,138 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from datetime import datetime + +from .common import PromiseReleaseCommonCase + + +class TestMergeMoves(PromiseReleaseCommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + delivery_pick_rule = cls.wh.delivery_route_id.rule_ids.filtered( + lambda r: r.location_src_id == cls.loc_stock + ) + delivery_pick_rule.group_propagation_option = "fixed" + cls.pc1 = cls._create_picking_chain( + cls.wh, [(cls.product1, 2)], date=datetime(2019, 9, 2, 16, 0) + ) + cls.shipping1 = cls._out_picking(cls.pc1) + cls._update_qty_in_location(cls.loc_bin1, cls.product1, 15.0) + cls.wh.delivery_route_id.write( + { + "available_to_promise_defer_pull": True, + } + ) + cls.shipping1.release_available_to_promise() + cls.picking1 = cls._prev_picking(cls.shipping1) + cls.picking1.action_assign() + + @classmethod + def _out_picking(cls, pickings): + return pickings.filtered(lambda r: r.picking_type_code == "outgoing") + + @classmethod + def _prev_picking(cls, picking): + return picking.move_ids.move_orig_ids.picking_id + + def _procure(self, qty): + """Create a procurement for a given quantity and run it. + + The created procurement will have the required values required + to create a move mergeable with the existing ones into the same + shipment. + """ + values = { + "company_id": self.wh.company_id, + "group_id": self.shipping1.group_id, + "date_planned": self.shipping1.move_ids.date, + "warehouse_id": self.wh, + } + self.env["procurement.group"].run( + [ + self.env["procurement.group"].Procurement( + self.product1, + qty, + self.product1.uom_id, + self.loc_customer, + "TEST", + "TEST", + self.wh.company_id, + values, + ) + ] + ) + + def test_unrelease_at_move_merge(self): + self.assertFalse(self.shipping1.need_release) + self.assertEqual(1, len(self.shipping1.move_ids)) + original_qty = self.shipping1.move_ids.product_uom_qty + # run a new procurement that will create a move in the same shipment + self._procure(2) + self.assertEqual(1, len(self.shipping1.move_ids)) + self.assertEqual(original_qty + 2, self.shipping1.move_ids.product_uom_qty) + self.assertFalse(self.shipping1.need_release) + # since the shipment is no more released, the picking should be canceled + self.assertEqual("cancel", self.picking1.state) + + def test_unrelease_at_move_merge_2(self): + # create a negative quant to cancel teh qty to deliver + self.assertFalse(self.shipping1.need_release) + self.assertEqual(1, len(self.shipping1.move_ids)) + original_qty = self.shipping1.move_ids.product_uom_qty + # run a new procurement that will create a move in the same shipment + self._procure(-original_qty) + self.assertEqual(1, len(self.shipping1.move_ids)) + self.assertEqual(0, self.shipping1.move_ids.product_uom_qty) + # no more qty to deliver, the shipment and picking should be canceled + self.assertEqual("cancel", self.shipping1.state) + self.assertEqual("cancel", self.picking1.state) + + def test_unrelease_at_move_merge_merged(self): + # Create a new shipping for the same product and date + # This will create a new move that will be merged with the existing one + # at merge time in the existing picking + pc2 = self._create_picking_chain( + self.wh, [(self.product1, 3)], date=datetime(2019, 9, 2, 16, 0) + ) + shipping2 = self._out_picking(pc2) + shipping2.release_available_to_promise() + picking2 = self._prev_picking(shipping2) + picking2.action_assign() + self.assertEqual(self.picking1, picking2) + self.assertEqual(1, len(self.picking1.move_ids)) + + original_qty_1 = self.shipping1.move_ids.product_uom_qty + original_qty_2 = shipping2.move_ids.product_uom_qty + + # pick1 and pick2 are the same + self.assertEqual(self.picking1, picking2) + + # partially process the picking + move = self.picking1.move_ids + move.move_line_ids.qty_done = 2 + # run a new procurement for the same product in the shipment 1 + self._procure(2) + + # the move should not be merged with the existing one since + # the first one is partially processed + self.assertEqual(2, len(self.shipping1.move_ids)) + self.assertEqual( + 2 + original_qty_1, sum(self.shipping1.move_ids.mapped("product_uom_qty")) + ) + self.assertTrue(self.shipping1.need_release) + + # the pick should still contain a move with the processed qty + # and the qty to do should be the one from shipping2 + move = self.picking1.move_ids.filtered(lambda m: m.state == "assigned") + self.assertEqual(2, move.move_line_ids.qty_done) + self.assertEqual(5, move.product_uom_qty) + + # if we release the ship 1 again, a new move should be created + # and merged with the existing one + self.shipping1.release_available_to_promise() + move = self.picking1.move_ids.filtered(lambda m: m.state == "assigned") + self.assertEqual(1, len(move)) + self.assertEqual(2 + original_qty_1 + original_qty_2, move.product_uom_qty) + self.assertEqual(2, move.move_line_ids.qty_done)