diff --git a/stock_available_to_promise_release/models/stock_move.py b/stock_available_to_promise_release/models/stock_move.py index 4246082e80f..4f9b9853693 100644 --- a/stock_available_to_promise_release/models/stock_move.py +++ b/stock_available_to_promise_release/models/stock_move.py @@ -54,7 +54,7 @@ class StockMove(models.Model): compute="_compute_release_ready", search="_search_release_ready", ) - need_release = fields.Boolean(index=True, copy=False) + need_release = fields.Boolean(index=True) unrelease_allowed = fields.Boolean(compute="_compute_unrelease_allowed") @api.depends("need_release", "rule_id", "rule_id.available_to_promise_defer_pull") @@ -221,15 +221,8 @@ def _previous_promised_qty_sql_moves_before(self): OR ( m.priority = move.priority AND m.date_priority = move.date_priority - AND m.picking_type_id = move.picking_type_id AND m.id < move.id ) - OR ( - m.priority = move.priority - AND m.date_priority = move.date_priority - AND m.picking_type_id != move.picking_type_id - AND m.id > move.id - ) ) """.format( moves_matches=self._previous_promised_qty_sql_moves_before_matches() @@ -269,7 +262,7 @@ def _previous_promised_qty_sql_lateral_where(self, warehouse): if horizon_date: sql += ( " AND (m.need_release IS true AND m.date <= %(horizon)s " - " OR m.need_release IS false)" + f" OR ({self._previous_promised_qty_sql_moves_no_release()}))" ) params["horizon"] = horizon_date return sql, params @@ -488,8 +481,10 @@ def _should_compute_ordered_available_to_promise(self): ) def _action_cancel(self): - # Unrelease moves that must be, before canceling them. - self.unrelease() + if not self.env.context.get("from_merge_no_need_release"): + # Unrelease moves that must be, before canceling them. + # We skip this when merging moves that are all released. + self.unrelease() super()._action_cancel() self.write({"need_release": False}) return True @@ -509,15 +504,6 @@ def _promise_reservation_horizon_date(self): def release_available_to_promise(self): return self._run_stock_rule() - def _prepare_move_split_vals(self, qty): - vals = super()._prepare_move_split_vals(qty) - # The method set procure_method as 'make_to_stock' by default on split, - # but we want to keep 'make_to_order' for chained moves when we split - # a partially available move in _run_stock_rule(). - if self.env.context.get("release_available_to_promise"): - vals.update({"procure_method": self.procure_method, "need_release": True}) - return vals - def _get_release_decimal_precision(self): return self.env["decimal.precision"].precision_get("Product Unit of Measure") @@ -556,6 +542,10 @@ def _run_stock_rule(self): move._release_split(remaining_qty) released_moves |= move + released_moves = released_moves._before_release() + + released_moves.need_release = False + # Move the unreleased moves to a backorder. # This behavior can be disabled by setting the flag # no_backorder_at_release on the stock.route of the move. @@ -563,6 +553,7 @@ def _run_stock_rule(self): unreleased_moves = released_pickings.move_ids - released_moves unreleased_moves_to_bo = unreleased_moves.filtered( lambda m: m.state not in ("done", "cancel") + and m.need_release and not m.rule_id.no_backorder_at_release ) if unreleased_moves_to_bo: @@ -570,7 +561,6 @@ def _run_stock_rule(self): # Pull the released moves for move in released_moves: - move._before_release() values = move._prepare_procurement_values() procurement_requests.append( self.env["procurement.group"].Procurement( @@ -586,17 +576,29 @@ def _run_stock_rule(self): ) self.env["procurement.group"].run_defer(procurement_requests) - assigned_moves = released_moves._after_release_assign_moves() - assigned_moves._after_release_update_chain() + released_moves._after_release_update_chain() # We could have discrepancies regarding released moves state, recompute it released_moves._recompute_state() - return assigned_moves + # some moves may have been already released but not merged because of + # an ongoing quantity on the pick step. Now that both are released, try + # to merge them + prereleased_moves = unreleased_moves.filtered( + lambda m: m.state not in ("done", "cancel") and not m.need_release + ) + if prereleased_moves: + prereleased_moves._merge_moves() + + return released_moves def _before_release(self): - """Hook that aims to be overridden.""" + """Hook that aims to be overridden. + + Return the moves that must be further released + """ self._release_set_expected_date() + return self def _release_get_expected_date(self): """Return the new scheduled date of a single delivery move""" @@ -611,15 +613,19 @@ def _release_set_expected_date(self, new_expected_date=False): This will be propagated to the chain of moves""" for move in self: - if not new_expected_date: - new_expected_date = move._release_get_expected_date() - if not new_expected_date: - continue - move.date = new_expected_date + expected_date = new_expected_date or move._release_get_expected_date() + if expected_date: + move.date = expected_date def _after_release_update_chain(self): + move_ids = [] + for origin_moves in self._get_chained_moves_iterator("move_orig_ids"): + move_ids += origin_moves.filtered( + lambda m: m.state not in ("cancel", "done") + ).ids + moves = self.browse(move_ids) + picking_ids = set() - moves = self while moves: picking_ids.update(moves.picking_id.ids) moves = moves.move_orig_ids @@ -634,16 +640,6 @@ def _after_release_update_chain(self): if priorities: pickings.write({"priority": max(priorities)}) - def _after_release_assign_moves(self): - move_ids = [] - for origin_moves in self._get_chained_moves_iterator("move_orig_ids"): - move_ids += origin_moves.filtered( - lambda m: m.state not in ("cancel", "done") - ).ids - moves = self.browse(move_ids) - moves._action_assign() - return moves - def _release_split(self, remaining_qty): """Split move and put remaining_qty to a backorder move.""" new_move_vals = self.with_context(release_available_to_promise=True)._split( @@ -653,17 +649,33 @@ def _release_split(self, remaining_qty): new_move._action_confirm(merge=False) return new_move - def _unreleased_to_backorder(self): - """Move the unreleased moves to a new backorder picking""" + def _unreleased_to_backorder(self, split_order=False): + """Move the unreleased moves to a new backorder picking + + Set split_order=True when it's the released moves that are moved to a + split order. + """ origin_pickings = {m.id: m.picking_id for m in self} self.with_context(release_available_to_promise=True)._assign_picking() backorder_links = {} for move in self: origin = origin_pickings[move.id] if origin: - backorder_links[move.picking_id] = origin + if not split_order: + backorder_links[move.picking_id] = origin + else: + backorder_links[origin] = move.picking_id for backorder, origin in backorder_links.items(): - backorder._release_link_backorder(origin) + if ( + backorder.state in ("draft", "cancel") + and len(backorder.backorder_ids) == 1 + ): + # When the backorder order is canceled and the moves are + # reassigned to a new order, post a link to the real + # backorder. Used by the module + # stock_available_to_promise_release_alternative_carrier + backorder = backorder.backorder_ids + backorder._release_link_backorder(origin, split_order=split_order) def _assign_picking_post_process(self, new=False): res = super()._assign_picking_post_process(new) @@ -848,7 +860,7 @@ def unrelease(self, safe_unrelease=False): "You cannot unrelease the move %(move_name)s " "because some origin moves %(done_move_names)s are done" ), - **msg_args + **msg_args, ) raise UserError(message) # Multiple pickings can satisfy a move @@ -908,11 +920,9 @@ def _split_origins(self, origins, qty=None): def _search_picking_for_assignation_domain(self): domain = super()._search_picking_for_assignation_domain() if self.env.context.get("release_available_to_promise"): - force_new_picking = not self.rule_id.no_backorder_at_release - if force_new_picking: - # We want a newer picking, search with '>' to prevent to select - # any old available picking - domain = expression.AND([domain, [("id", ">", self.picking_id.id)]]) + # We want a newer picking, search with '>' to prevent to select + # any old available picking + domain = expression.AND([domain, [("id", ">", self.picking_id.id)]]) if self.picking_type_id.prevent_new_move_after_release: domain = expression.AND([domain, [("last_release_date", "=", False)]]) return domain @@ -924,7 +934,10 @@ def _get_new_picking_values(self): def write(self, vals): released_moves = self.browse() - if self.env.context.get("in_merge_mode") and "product_uom_qty" in vals: + if ( + self.env.context.get("from_merge_need_release") + 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()) @@ -942,17 +955,28 @@ def write(self, vals): def _is_mergeable(self): self.ensure_one() - return self.state not in ("done", "cancel") and ( - not self._is_unreleaseable() or self.unrelease_allowed + return self.state not in ("draft", "done", "cancel") and ( + self.need_release or self.unrelease_allowed ) + def _prepare_merge_moves_distinct_fields(self): + fields = super()._prepare_merge_moves_distinct_fields() + if self.env.context.get("from_merge_no_need_release"): + # when we merge moves that do not need release, ensure candidates + # have the same value for need release (i.e. False) + fields.append("need_release") + return fields + 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 + res = super()._update_candidate_moves_list(candidate_moves) + if not self.env.context.get("from_merge_need_release"): + return res + # when merging a move that needs release, filter out the moves that are + # not unreleasable new_candidate_moves = [ candidates.filtered( lambda m, moves_to_merge=self: m in moves_to_merge or m._is_mergeable() @@ -964,13 +988,36 @@ def _update_candidate_moves_list(self, 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) - ) + res = self.browse() + no_need_release = self.filtered(lambda m: not m.need_release) + if no_need_release: + # For moves that do not need release, search moves that also do not + # need release + from_merge_no_need_release = self.env.context.get( + "from_merge_no_need_release", False + ) + res |= ( + super( + StockMove, + no_need_release.with_context(from_merge_no_need_release=True), + ) + ._merge_moves(merge_into=merge_into) + .with_context(from_merge_no_need_release=from_merge_no_need_release) + ) + need_release = self - no_need_release + if need_release: + # For moves that do need release, search moves that also need + # release or are unreleasable + from_merge_need_release = self.env.context.get( + "from_merge_need_release", False + ) + if merge_into: + merge_into = merge_into.filtered(lambda m: m._is_mergeable()) + res |= ( + super( + StockMove, need_release.with_context(from_merge_need_release=True) + ) + ._merge_moves(merge_into=merge_into) + .with_context(from_merge_need_release=from_merge_need_release) + ) + return res diff --git a/stock_available_to_promise_release/models/stock_picking.py b/stock_available_to_promise_release/models/stock_picking.py index 5dda6480aff..1970758e949 100644 --- a/stock_available_to_promise_release/models/stock_picking.py +++ b/stock_available_to_promise_release/models/stock_picking.py @@ -131,16 +131,22 @@ def release_available_to_promise(self): } self.move_ids.with_context(**context).release_available_to_promise() - def _release_link_backorder(self, origin_picking): + def _release_link_backorder(self, origin_picking, split_order=False): self.backorder_id = origin_picking - origin_picking.message_post( - body=_( - "The backorder %(name)s has been created.", - name=self.name, - id=self.id, + if origin_picking.state not in ("draft", "cancel"): + # in case of split order, the current picking may now be empty. In + # this case don't post a link, as it will be canceled and we don't + # want to advertise about a canceled transfer. + origin_picking.message_post( + body=_("The backorder %s has been created.", self._get_html_link()) + ) + if split_order: + self.message_post( + body=( + "The split order %s has been created.", + origin_picking._get_html_link(), + ) ) - ) def _after_release_update_chain(self): """Called after the moves are released diff --git a/stock_available_to_promise_release/models/stock_rule.py b/stock_available_to_promise_release/models/stock_rule.py index be7c2516761..ac7134d7634 100644 --- a/stock_available_to_promise_release/models/stock_rule.py +++ b/stock_available_to_promise_release/models/stock_rule.py @@ -48,15 +48,6 @@ def _run_pull(self, procurements): actions_to_run.append((procurement, rule)) super()._run_pull(actions_to_run) - # use first a list of ids and browse it afterwards for performance - move_ids = [ - move.id - for proc, _rule in actions_to_run - for move in proc.values.get("move_dest_ids", []) - ] - if move_ids: - moves = self.env["stock.move"].browse(move_ids) - moves.filtered(lambda r: r.need_release).write({"need_release": False}) return True diff --git a/stock_available_to_promise_release/tests/test_merge_moves.py b/stock_available_to_promise_release/tests/test_merge_moves.py index 9002bf75918..8a793698b83 100644 --- a/stock_available_to_promise_release/tests/test_merge_moves.py +++ b/stock_available_to_promise_release/tests/test_merge_moves.py @@ -1,4 +1,5 @@ # Copyright 2024 ACSONE SA/NV +# Copyright 2025 Jacques-Etienne Baudoux (BCIM) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) from datetime import datetime @@ -64,7 +65,12 @@ def _procure(self, qty): ] ) - def test_unrelease_at_move_merge(self): + def test_unrelease_at_move_merge_pos(self): + """Test merging released and need release moves + + The released move is unreleased for merging and the merged move is then + released for the new total quantity + """ self.assertFalse(self.shipping1.need_release) self.assertEqual(1, len(self.shipping1.move_ids)) original_qty = self.shipping1.move_ids.product_uom_qty @@ -73,11 +79,21 @@ def test_unrelease_at_move_merge(self): 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 + # the initial picking has been canceled self.assertEqual("cancel", self.picking1.state) + # the new picking is for the new total quantity + new_picking = self.shipping1.move_ids.move_orig_ids.filtered( + lambda m: m.state != "cancel" + ) + self.assertEqual(original_qty + 2, new_picking.product_uom_qty) - def test_unrelease_at_move_merge_2(self): - # create a negative quant to cancel teh qty to deliver + def test_unrelease_at_move_merge_neg(self): + """Test merging released and need release moves + + The released move is unreleased for merging and the merged move is + canceled as the total quantity is 0 + """ + # create a negative quant to cancel the 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 @@ -88,6 +104,11 @@ def test_unrelease_at_move_merge_2(self): # no more qty to deliver, the shipment and picking should be canceled self.assertEqual("cancel", self.shipping1.state) self.assertEqual("cancel", self.picking1.state) + # there is no new picking + new_picking = self.shipping1.move_ids.move_orig_ids.filtered( + lambda m: m.state != "cancel" + ) + self.assertEqual(0, len(new_picking)) def test_unrelease_at_move_merge_merged(self): # Create a new shipping for the same product and date @@ -125,17 +146,28 @@ def test_unrelease_at_move_merge_merged(self): # 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") + pick_move = self.picking1.move_ids.filtered( + lambda m: m.state in ("assigned", "partially_available") + ) 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 + # and merged with the existing one. The 2 released ship moves will also + # be merged. + ship_moves = self.shipping1.move_ids + self.assertEqual(2, len(ship_moves)) 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) + # the released ship moves are still in the same shipping1 + ship_moves = ship_moves.exists() + self.assertEqual(1, len(ship_moves)) + self.assertEqual(ship_moves.picking_id, self.shipping1) + pick_move = self.picking1.move_ids.filtered( + lambda m: m.state in ("assigned", "partially_available") + ) + self.assertEqual(1, len(pick_move)) + self.assertEqual(2 + original_qty_1 + original_qty_2, pick_move.product_uom_qty) + self.assertEqual(2, pick_move.move_line_ids.qty_done) def test_default_merge(self): # check that the merge is still working when the available_to_promise_defer_pull diff --git a/stock_available_to_promise_release/tests/test_reservation.py b/stock_available_to_promise_release/tests/test_reservation.py index a064b51b881..b902cd99c92 100644 --- a/stock_available_to_promise_release/tests/test_reservation.py +++ b/stock_available_to_promise_release/tests/test_reservation.py @@ -94,6 +94,12 @@ def test_ordered_available_to_promise_value_base(self): self.assertEqual(picking4.move_ids.ordered_available_to_promise_uom_qty, 0) self.assertEqual(picking5.move_ids.previous_promised_qty, 48) self.assertEqual(picking5.move_ids.ordered_available_to_promise_uom_qty, 0) + # Check that splitting a move doesn't affect allocation + move_vals = picking.move_ids._split(1) + move = self.env["stock.move"].create(move_vals) + move._action_confirm(merge=False) + self.env["stock.move"].invalidate_model(fnames=["previous_promised_qty"]) + self.assertEqual(picking2.move_ids.previous_promised_qty, 5) def test_ordered_available_to_promise_value_consider_already_released(self): self.wh.delivery_route_id.write({"available_to_promise_defer_pull": True}) @@ -284,6 +290,15 @@ def test_ordered_available_to_promise_value_horizon1(self): fnames=["previous_promised_qty", "ordered_available_to_promise_uom_qty"] ) self.assertEqual(picking4.move_ids.previous_promised_qty, 5) + # split picking1 move and check that has no impact + move_vals = picking.move_ids._split(1) + move = self.env["stock.move"].create(move_vals) + move._action_confirm(merge=False) + self.env["stock.move"].invalidate_model( + fnames=["previous_promised_qty", "ordered_available_to_promise_uom_qty"] + ) + self.assertEqual(picking4.move_ids.previous_promised_qty, 5) + move._action_confirm(merge=True) # set a higher priority for picking 5 # (restoring previous date_expected values for other pickings before) diff --git a/stock_available_to_promise_release/views/stock_move_views.xml b/stock_available_to_promise_release/views/stock_move_views.xml index 80e2c0e4e55..25f14fe0521 100644 --- a/stock_available_to_promise_release/views/stock_move_views.xml +++ b/stock_available_to_promise_release/views/stock_move_views.xml @@ -7,6 +7,9 @@ + + + @@ -43,6 +46,7 @@ + diff --git a/stock_available_to_promise_release/views/stock_picking_type_views.xml b/stock_available_to_promise_release/views/stock_picking_type_views.xml index 7ddb53aa7d7..aacafecf558 100644 --- a/stock_available_to_promise_release/views/stock_picking_type_views.xml +++ b/stock_available_to_promise_release/views/stock_picking_type_views.xml @@ -5,9 +5,6 @@ stock.picking.type - - - - Stock Allocations Un Release + Stock Allocations Unrelease stock.unrelease -
+

- The selected records will be un released. + The selected records will be unreleased.