Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit b5ad9ef

Browse files
committedJan 13, 2025··
stock_available_to_promise_release: Allow unrelease processed qties
1 parent cd82181 commit b5ad9ef

File tree

6 files changed

+235
-4
lines changed

6 files changed

+235
-4
lines changed
 

‎stock_available_to_promise_release/models/stock_location_route.py

+8
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@
77
class Route(models.Model):
88
_inherit = "stock.location.route"
99

10+
allow_unrelease_on_cancel = fields.Boolean(
11+
string="Reverse done transfer on cancellation",
12+
default=False,
13+
help=(
14+
"If checked, this option will create new inverse internal operations "
15+
"on done internal operations"
16+
),
17+
)
1018
available_to_promise_defer_pull = fields.Boolean(
1119
string="Release based on Available to Promise",
1220
default=False,

‎stock_available_to_promise_release/models/stock_move.py

+73-1
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,14 @@ def _is_unrelease_allowed_on_origin_moves(self, origin_moves):
100100
# The picking is printed, we can't unrelease the move
101101
# because the processing of the origin moves is started.
102102
return False
103+
# If allow_unrelease_on_cancel is not checked, only release assigned/waiting
104+
# moves. If checked, also unrelease done moves.
105+
if self.rule_id.allow_unrelease_on_cancel:
106+
unreleasable_states = ("cancel",)
107+
else:
108+
unreleasable_states = ("done", "cancel")
103109
origin_moves = origin_moves.filtered(
104-
lambda m: m.state not in ("done", "cancel")
110+
lambda m: m.state not in unreleasable_states
105111
)
106112
origin_qty_todo = sum(origin_moves.mapped("product_qty"))
107113
return (
@@ -591,6 +597,39 @@ def _get_chained_moves_iterator(self, chain_field):
591597
yield moves
592598
moves = moves.mapped(chain_field)
593599

600+
def _return_quantity_in_stock(self, quantity_to_return):
601+
picking = self.picking_id
602+
picking.ensure_one()
603+
604+
returned_quantity = 0
605+
# create return
606+
return_type = picking.picking_type_id.return_picking_type_id
607+
wiz_values = {
608+
"picking_id": picking.id,
609+
"original_location_id": picking.location_dest_id.id,
610+
"location_id": return_type.default_location_dest_id.id,
611+
}
612+
product_return_moves = []
613+
for move in self:
614+
if not move.state == "done":
615+
continue
616+
move_qty = min(quantity_to_return, move.quantity_done)
617+
return_move_vals = {
618+
"product_id": move.product_id.id,
619+
"quantity": move_qty,
620+
"uom_id": move.product_id.uom_id.id,
621+
"move_id": move.id,
622+
}
623+
product_return_moves.append((0, 0, return_move_vals))
624+
returned_quantity += move_qty
625+
if returned_quantity == quantity_to_return:
626+
break
627+
if product_return_moves:
628+
wiz_values["product_return_moves"] = product_return_moves
629+
return_wiz = self.env["stock.return.picking"].create(wiz_values)
630+
return_wiz.create_returns()
631+
return returned_quantity
632+
594633
def unrelease(self, safe_unrelease=False):
595634
"""Unrelease unreleasavbe moves
596635
@@ -605,13 +644,22 @@ def unrelease(self, safe_unrelease=False):
605644
impacted_picking_ids = set()
606645

607646
for move in moves_to_unrelease:
647+
# When a move is returned, it is going straight to WH/Stock,
648+
# skipping all intermediate zones (pick/pack).
649+
# That is why we need to keep track of qty returned along the way.
650+
# We do not want to return the same goods at each step.
651+
# At a given step (pick/pack/ship), qty to return is
652+
# move.product_uom_qty - cancelled_qty_at_step - already returned qties
653+
qty_to_unrelease = move.product_uom_qty
654+
qty_returned_for_move = 0
608655
iterator = move._get_chained_moves_iterator("move_orig_ids")
609656
moves_to_cancel = self.env["stock.move"]
610657
# backup procure_method as when you don't propagate cancel, the
611658
# destination move is forced to make_to_stock
612659
procure_method = move.procure_method
613660
next(iterator) # skip the current move
614661
for origin_moves in iterator:
662+
done_moves = origin_moves.filtered(lambda m: m.state == "done")
615663
origin_moves = origin_moves.filtered(
616664
lambda m: m.state not in ("done", "cancel")
617665
)
@@ -622,6 +670,30 @@ def unrelease(self, safe_unrelease=False):
622670
origin_moves.write({"propagate_cancel": False})
623671
# origin_moves._action_cancel()
624672
moves_to_cancel |= origin_moves
673+
# checking that for the current step (pick/pack/ship)
674+
# move.product_uom_qty == step.cancelled_qty + move.returned_quanty
675+
# If not the case, we have to move back goods in stock.
676+
qty_cancelled_at_step = sum(origin_moves.mapped("product_uom_qty"))
677+
qty_to_return_at_step = (
678+
qty_to_unrelease - qty_cancelled_at_step - qty_returned_for_move
679+
)
680+
# TODO check move.rule_id.allow_unrelease_on_cancel ?
681+
if not qty_to_return_at_step:
682+
continue
683+
# Multiple pickings can satisfy a move
684+
# -> len(move.move_orig_ids.picking_id) > 1
685+
# Group done_moves per picking, and create returns
686+
groups_iterator = groupby(done_moves, key=lambda m: m.picking_id)
687+
for __, moves_list in groups_iterator:
688+
moves_to_return = self.browse([move.id for move in moves_list])
689+
returned_qty = moves_to_return._return_quantity_in_stock(
690+
qty_to_return_at_step
691+
)
692+
qty_returned_for_move += returned_qty
693+
qty_to_return_at_step -= returned_qty
694+
if qty_to_return_at_step == 0:
695+
break
696+
625697
moves_to_cancel._action_cancel()
626698
# restore the procure_method overwritten by _action_cancel()
627699
move.procure_method = procure_method

‎stock_available_to_promise_release/models/stock_rule.py

+4
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ class StockRule(models.Model):
1414
related="route_id.available_to_promise_defer_pull", store=True
1515
)
1616

17+
allow_unrelease_on_cancel = fields.Boolean(
18+
related="route_id.allow_unrelease_on_cancel", store=True
19+
)
20+
1721
no_backorder_at_release = fields.Boolean(
1822
related="route_id.no_backorder_at_release", store=True
1923
)

‎stock_available_to_promise_release/tests/common.py

+13-3
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,18 @@ def _out_picking(cls, pickings):
128128
return pickings.filtered(lambda r: r.picking_type_code == "outgoing")
129129

130130
@classmethod
131-
def _deliver(cls, picking):
131+
def _get_backorder_for_pickings(cls, pickings):
132+
return cls.env["stock.picking"].search([("backorder_id", "in", pickings.ids)])
133+
134+
@classmethod
135+
def _deliver(cls, picking, product_qty=None):
132136
picking.action_assign()
133-
for line in picking.mapped("move_lines.move_line_ids"):
134-
line.qty_done = line.product_uom_qty
137+
if product_qty:
138+
lines = picking.move_lines.move_line_ids
139+
for product, qty in product_qty:
140+
line = lines.filtered(lambda m: m.product_id == product)
141+
line.qty_done = qty
142+
else:
143+
for line in picking.mapped("move_lines.move_line_ids"):
144+
line.qty_done = line.product_uom_qty
135145
picking._action_done()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Copyright 2025 Camptocamp SA
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
3+
from datetime import datetime
4+
5+
from .common import PromiseReleaseCommonCase
6+
7+
8+
class TestAvailableToPromiseReleaseCancel(PromiseReleaseCommonCase):
9+
@classmethod
10+
def setUpClass(cls):
11+
super().setUpClass()
12+
13+
cls.wh.delivery_steps = "pick_pack_ship"
14+
cls._update_qty_in_location(cls.loc_bin1, cls.product1, 15.0)
15+
16+
delivery_route = cls.wh.delivery_route_id
17+
ship_rule = delivery_route.rule_ids.filtered(
18+
lambda r: r.location_id == cls.loc_customer
19+
)
20+
cls.loc_output = ship_rule.location_src_id
21+
pack_rule = delivery_route.rule_ids.filtered(
22+
lambda r: r.location_id == cls.loc_output
23+
)
24+
cls.loc_pack = pack_rule.location_src_id
25+
pick_rule = delivery_route.rule_ids.filtered(
26+
lambda r: r.location_id == cls.loc_pack
27+
)
28+
cls.pick_type = pick_rule.picking_type_id
29+
cls.pack_type = pack_rule.picking_type_id
30+
31+
cls.picking_chain = cls._create_picking_chain(
32+
cls.wh, [(cls.product1, 10)], date=datetime(2019, 9, 2, 16, 0)
33+
)
34+
cls.ship_picking = cls._out_picking(cls.picking_chain)
35+
cls.pack_picking = cls._prev_picking(cls.ship_picking)
36+
cls.pick_picking = cls._prev_picking(cls.pack_picking)
37+
38+
# Why is this not working when creating picking after enabling this setting?
39+
delivery_route.write(
40+
{
41+
"available_to_promise_defer_pull": True,
42+
"allow_unrelease_on_cancel": True,
43+
}
44+
)
45+
cls.ship_picking.release_available_to_promise()
46+
cls.cleanup_type = cls.env["stock.picking.type"].create(
47+
{
48+
"name": "Cancel Cleanup",
49+
"default_location_dest_id": cls.loc_stock.id,
50+
"sequence_code": "CCP",
51+
"code": "internal",
52+
}
53+
)
54+
cls.pick_type.return_picking_type_id = cls.cleanup_type
55+
cls.pack_type.return_picking_type_id = cls.cleanup_type
56+
57+
@classmethod
58+
def _get_cleanup_picking(cls):
59+
return cls.env["stock.picking"].search(
60+
[("picking_type_id", "=", cls.cleanup_type.id)]
61+
)
62+
63+
def test_unrelease_picked(self):
64+
# In this case, we should get 1 return picking from
65+
# WH/PACK to WH/STOCK
66+
self._deliver(self.pick_picking)
67+
self.ship_picking.unrelease()
68+
self.assertTrue(self.ship_picking.need_release)
69+
self.assertEqual(self.pack_picking.state, "cancel")
70+
self.assertEqual(self.pick_picking.state, "done")
71+
cancel_picking = self._get_cleanup_picking()
72+
self.assertEqual(len(cancel_picking), 1)
73+
self.assertEqual(cancel_picking.location_id, self.loc_pack)
74+
self.assertEqual(cancel_picking.location_dest_id, self.loc_stock)
75+
76+
def test_unrelease_packed(self):
77+
# In this case, we should get 1 return picking from
78+
# WH/OUT to WH/STOCK
79+
self._deliver(self.pick_picking)
80+
self._deliver(self.pack_picking)
81+
self.ship_picking.unrelease()
82+
self.assertTrue(self.ship_picking.need_release)
83+
self.assertEqual(self.pack_picking.state, "done")
84+
self.assertEqual(self.pick_picking.state, "done")
85+
cancel_picking = self._get_cleanup_picking()
86+
self.assertEqual(len(cancel_picking), 1)
87+
self.assertEqual(cancel_picking.location_id, self.loc_output)
88+
self.assertEqual(cancel_picking.location_dest_id, self.loc_stock)
89+
90+
def test_unrelease_picked_partial(self):
91+
qty_picked = [(self.product1, 5.0)]
92+
self._deliver(self.pick_picking, product_qty=qty_picked)
93+
pick_backorder = self._get_backorder_for_pickings(self.pick_picking)
94+
self.assertTrue(pick_backorder)
95+
self.ship_picking.unrelease()
96+
self.assertTrue(self.ship_picking.need_release)
97+
self.assertEqual(self.pack_picking.state, "cancel")
98+
self.assertEqual(self.pick_picking.state, "done")
99+
cancel_picking = self._get_cleanup_picking()
100+
# In the end, we cancelled 5 units for the pick backorder, and returned
101+
# 5 units from pack -> stock
102+
self.assertEqual(pick_backorder.state, "cancel")
103+
self.assertEqual(cancel_picking.location_id, self.loc_pack)
104+
self.assertEqual(cancel_picking.location_dest_id, self.loc_stock)
105+
self.assertEqual(cancel_picking.move_lines.product_uom_qty, 5.0)
106+
107+
def test_unrelease_packed_partial(self):
108+
self._deliver(self.pick_picking)
109+
qty_packed = [(self.product1, 5.0)]
110+
self._deliver(self.pack_picking, product_qty=qty_packed)
111+
pack_backorder = self._get_backorder_for_pickings(self.pack_picking)
112+
self.assertTrue(pack_backorder)
113+
self.ship_picking.unrelease()
114+
self.assertTrue(self.ship_picking.need_release)
115+
self.assertEqual(self.pack_picking.state, "done")
116+
self.assertEqual(self.pick_picking.state, "done")
117+
cancel_pickings = self._get_cleanup_picking()
118+
self.assertEqual(len(cancel_pickings), 2)
119+
# In the end, we cancelled 5 units for the pack backorder, returned
120+
# 5 units from pack -> stock, and 5 units from output -> stock
121+
pack_cancel = cancel_pickings.filtered(lambda p: p.location_id == self.loc_pack)
122+
ship_cancel = cancel_pickings.filtered(
123+
lambda p: p.location_id == self.loc_output
124+
)
125+
self.assertEqual(pack_cancel.move_lines.product_uom_qty, 5.0)
126+
self.assertEqual(ship_cancel.move_lines.product_uom_qty, 5.0)
127+
128+
def test_unrelease_shipped(self):
129+
self._deliver(self.pick_picking)
130+
self._deliver(self.pack_picking)
131+
self._deliver(self.ship_picking)
132+
self.ship_picking.unrelease()
133+
# Did nothing
134+
self.assertEqual(self.ship_picking.state, "done")
135+
self.assertEqual(self.pack_picking.state, "done")
136+
self.assertEqual(self.pick_picking.state, "done")

‎stock_available_to_promise_release/views/stock_location_route_views.xml

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
<field name="arch" type="xml">
88
<xpath expr="//group/field[@name='company_id']" position="after">
99
<field name="available_to_promise_defer_pull" />
10+
<field name="allow_unrelease_on_cancel" />
1011
<field name="no_backorder_at_release" />
1112
</xpath>
1213
</field>

0 commit comments

Comments
 (0)
Please sign in to comment.