Skip to content

Commit

Permalink
[IMP] stock_available_to_promise_release: Unrelease when merging moves
Browse files Browse the repository at this point in the history
When moves are merged into an outgoing picking and the product_uom_qty is updated it's important to unrelease the move since the release process has been done for the previous qty. This case occurs for example when the qty is modified for a line on a confirmed SO.
  • Loading branch information
lmignon committed Oct 14, 2024
1 parent b7a641e commit 98b705a
Show file tree
Hide file tree
Showing 3 changed files with 192 additions and 0 deletions.
53 changes: 53 additions & 0 deletions stock_available_to_promise_release/models/stock_move.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
1 change: 1 addition & 0 deletions stock_available_to_promise_release/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from . import test_merge_moves
from . import test_reservation
from . import test_unrelease
from . import test_unrelease_2steps
Expand Down
138 changes: 138 additions & 0 deletions stock_available_to_promise_release/tests/test_merge_moves.py
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit 98b705a

Please sign in to comment.