Skip to content

Commit

Permalink
[IMP] stock_available_to_promise_release: Makes the creation of backo…
Browse files Browse the repository at this point in the history
…rder at release time configurable
  • Loading branch information
lmignon committed Mar 30, 2023
1 parent cb72761 commit 20d852c
Show file tree
Hide file tree
Showing 6 changed files with 288 additions and 13 deletions.
43 changes: 33 additions & 10 deletions stock_available_to_promise_release/models/stock_move.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Copyright 2019-2020 Camptocamp (https://www.camptocamp.com)
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).

import itertools
import logging
import operator as py_operator

Expand Down Expand Up @@ -353,7 +353,8 @@ def _run_stock_rule(self):
backorder_links[unreleased_move.picking_id] = original_picking

for backorder, origin in backorder_links.items():
backorder._release_link_backorder(origin)
if backorder != origin:
backorder._release_link_backorder(origin)

self.env["procurement.group"].run_defer(procurement_requests)

Expand Down Expand Up @@ -382,24 +383,32 @@ def _after_release_update_chain(self):
pickings.write({"priority": max(priorities)})

def _after_release_assign_moves(self):
moves = self
while moves:
moves._action_assign()
moves = moves.mapped("move_orig_ids")
move_ids = []
for origin_moves in self._get_chained_moves_iterator("move_orig_ids"):
move_ids += origin_moves.ids
self.env["stock.move"].browse(move_ids)._action_assign()

def _release_split(self, remaining_qty):
"""Split move and create a new picking for it.
Instead of splitting the move and leave remaining qty into the same picking
we move it to a new one so that we can release it later as soon as
the qty is available.
By default, when we split a move at release to isolate the remaining qty
into a new move, we also create a new picking for it so that we can
release it later as soon as the qty is available.
This behavior can be changed by setting the flag no_backorder_at_release
on the stock.route of the move. This will allow to create the backorder
at the end of the picking process and release the unreleased moves into
the same picking as long as the picking is not done. By doing so, we
can also cleanup the backorders of the linked pickings created when
a released move was not processed (no qty found, or no time to do it for
example).
"""
context = self.env.context
self = self.with_context(release_available_to_promise=True)
# Rely on `printed` flag to make _assign_picking create a new picking.
# See `stock.move._assign_picking` and
# `stock.move._search_picking_for_assignation`.
if not self.picking_id.printed:
original_printed = self.picking_id.printed
if not self.picking_id.printed and not self.rule_id.no_backorder_at_release:
self.picking_id.printed = True
new_move = self # Work on the current move if split doesn't occur
new_move_vals = self._split(remaining_qty)
Expand All @@ -411,6 +420,9 @@ def _release_split(self, remaining_qty):
# and the move is not assigned.
new_move._assign_picking()

# restore the original value of the printed flag only used to ensure
# that a backorder is created if required
self.picking_id.printed = original_printed
return new_move.with_context(**context)

def _assign_picking_post_process(self, new=False):
Expand All @@ -419,3 +431,14 @@ def _assign_picking_post_process(self, new=False):
if priorities:
self.picking_id.write({"priority": max(priorities)})
return res

def _get_chained_moves_iterator(self, chain_field):
"""Return an iterator on the moves of the chain.
The iterator returns the moves in the order of the chain.
The loop into the iterator is the current moves.
"""
moves = self
while moves:
yield moves
moves = moves.mapped(chain_field)
22 changes: 20 additions & 2 deletions stock_available_to_promise_release/models/stock_picking.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ class StockPicking(models.Model):
state_id = fields.Many2one(related="partner_id.state_id", store=True)
city = fields.Char(related="partner_id.city", store=True)

set_printed_at_release = fields.Boolean(compute="_compute_set_printed_at_release")

@api.depends("move_ids")
def _compute_set_printed_at_release(self):
for picking in self:
picking.set_printed_at_release = not (
any(picking.move_ids.mapped("rule_id.no_backorder_at_release"))
)

@api.depends("move_ids.need_release")
def _compute_need_release(self):
data = self.env["stock.move"].read_group(
Expand Down Expand Up @@ -153,14 +162,23 @@ def _after_release_update_chain(self):
self._after_release_set_expected_date()

def _after_release_set_printed(self):
self.filtered(lambda p: not p.printed).printed = True
self.filtered(
lambda p: not p.printed and p.set_printed_at_release
).printed = True

def _after_release_set_expected_date(self):
prep_time = self.env.company.stock_release_max_prep_time
new_expected_date = fields.Datetime.add(
fields.Datetime.now(), minutes=prep_time
)
self.scheduled_date = new_expected_date
move_to_update = self.move_ids.filtered(lambda m: m.state == "assigned")
move_to_update_ids = move_to_update.ids
for origin_moves in move_to_update._get_chained_moves_iterator("move_dest_ids"):
move_to_update_ids += origin_moves.ids

self.env["stock.move"].browse(move_to_update_ids).write(
{"date": new_expected_date}
)

def action_open_move_need_release(self):
self.ensure_one()
Expand Down
7 changes: 7 additions & 0 deletions stock_available_to_promise_release/models/stock_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,10 @@ class StockRoute(models.Model):
"Transfers must be released manually when they have enough available"
" to promise.",
)

no_backorder_at_release = fields.Boolean(
string="No backorder at release",
default=False,
help="When releasing a transfer, do not create a backorder for the "
"moves created for the unavailable quantities.",
)
10 changes: 9 additions & 1 deletion stock_available_to_promise_release/models/stock_rule.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,21 @@
class StockRule(models.Model):
_inherit = "stock.rule"

available_to_promise_defer_pull = fields.Boolean(
related="route_id.available_to_promise_defer_pull", store=True
)

no_backorder_at_release = fields.Boolean(
related="route_id.no_backorder_at_release", store=True
)

def _run_pull(self, procurements):
actions_to_run = []

for procurement, rule in procurements:
if (
not self.env.context.get("_rule_no_available_defer")
and rule.route_id.available_to_promise_defer_pull
and rule.available_to_promise_defer_pull
# We still want to create the first part of the chain
and not rule.picking_type_id.code == "outgoing"
):
Expand Down
218 changes: 218 additions & 0 deletions stock_available_to_promise_release/tests/test_reservation.py
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,224 @@ def test_defer_multi_move_unreleased_in_backorder(self):
],
)

def test_defer_creation_no_backorder_partial_available(self):
"""Test that the creation of a backorder is not done at release time
when the field `ǹo_backorder_at_release` is set on the stock.route"""
self.wh.delivery_route_id.write(
{"available_to_promise_defer_pull": True, "no_backorder_at_release": True}
)
self._update_qty_in_location(self.loc_bin1, self.product1, 7.0)
pickings = self._create_picking_chain(self.wh, [(self.product1, 20)])
self.assertEqual(len(pickings), 1, "expect only the last out->customer")

cust_picking = pickings
move_state = cust_picking.mapped("move_ids").mapped("state")
self.assertTrue(len(move_state) == 1 and move_state[0] == "waiting")
self.assertEqual(cust_picking.state, "waiting")
self.assertRecordValues(
cust_picking,
[
{
"state": "waiting",
"location_id": self.wh.wh_output_stock_loc_id.id,
"location_dest_id": self.loc_customer.id,
"printed": False,
}
],
)

cust_picking.release_available_to_promise()
split_cust_picking = cust_picking.backorder_ids
self.assertEqual(len(split_cust_picking), 0)

out_picking = self._pickings_in_group(pickings.group_id) - cust_picking
# the complete one is assigned and placed into stock output
self.assertRecordValues(
out_picking,
[
{
"state": "assigned",
"location_id": self.wh.lot_stock_id.id,
"location_dest_id": self.wh.wh_output_stock_loc_id.id,
"printed": False,
}
],
)
# the released customer picking is not set to "printed"
self.assertRecordValues(cust_picking, [{"printed": False}])

self.assertRecordValues(out_picking.move_ids, [{"product_qty": 7.0}])

# the splite moves remains into the customer picking
self.assertRecordValues(
cust_picking.move_ids,
[
{"product_qty": 7.0, "state": "waiting"},
{"product_qty": 13.0, "state": "waiting"},
],
)

# let's deliver what we can
self._deliver(out_picking)
self.assertRecordValues(out_picking, [{"state": "done"}])
self.assertRecordValues(cust_picking, [{"state": "assigned"}])
self.assertRecordValues(
cust_picking.move_ids,
[
{
"state": "assigned",
"product_qty": 7.0,
"reserved_availability": 7.0,
"procure_method": "make_to_order",
},
{
"state": "waiting",
"product_qty": 13.0,
"reserved_availability": 0.0,
"procure_method": "make_to_order",
},
],
)

self._deliver(cust_picking)
self.assertRecordValues(cust_picking, [{"state": "done"}])

cust_backorder = (
self._pickings_in_group(cust_picking.group_id) - cust_picking - out_picking
)
self.assertEqual(len(cust_backorder), 1)

self.env["stock.move"].invalidate_model(
fnames=[
"previous_promised_qty",
"ordered_available_to_promise_uom_qty",
"ordered_available_to_promise_qty",
]
)
# nothing happen, no stock
self.assertEqual(len(self._pickings_in_group(cust_picking.group_id)), 3)
cust_backorder.release_available_to_promise()
self.assertEqual(len(self._pickings_in_group(cust_picking.group_id)), 3)

self.env["stock.move"].invalidate_model(
fnames=[
"previous_promised_qty",
"ordered_available_to_promise_uom_qty",
"ordered_available_to_promise_qty",
]
)
# We add stock, so now the release must create the next
# chained move
self._update_qty_in_location(self.loc_bin1, self.product1, 30)
cust_backorder.release_available_to_promise()
out_backorder = (
self._pickings_in_group(cust_picking.group_id)
- cust_backorder
- cust_picking
- out_picking
)
self.assertRecordValues(
out_backorder.move_ids,
[
{
"state": "assigned",
"product_qty": 13.0,
"reserved_availability": 13.0,
"procure_method": "make_to_stock",
"location_id": self.wh.lot_stock_id.id,
"location_dest_id": self.wh.wh_output_stock_loc_id.id,
}
],
)

def test_defer_creation_no_backorder_not_available(self):
"""Unreleased moves are not put in a backorder if
`ǹo_backorder_at_release` is set to True"""
self.wh.delivery_route_id.write(
{"available_to_promise_defer_pull": True, "no_backorder_at_release": True}
)
self._update_qty_in_location(self.loc_bin1, self.product1, 10.0)
self._update_qty_in_location(self.loc_bin1, self.product2, 10.0)
pickings = self._create_picking_chain(
self.wh,
[
(self.product1, 20),
(self.product2, 10),
(self.product3, 20),
(self.product4, 10),
],
)
self.assertEqual(len(pickings), 1, "expect only the last out->customer")

cust_picking = pickings
self.assertRecordValues(
cust_picking,
[
{
"state": "waiting",
"location_id": self.wh.wh_output_stock_loc_id.id,
"location_dest_id": self.loc_customer.id,
},
],
)

cust_picking.release_available_to_promise()
backorder = cust_picking.backorder_ids
self.assertFalse(backorder)
self.assertRecordValues(
cust_picking.move_ids.sorted("id"),
[
# product 1 is partially available -> split
{
"product_qty": 10.0,
"product_id": self.product1.id,
"state": "waiting",
},
# product 2 is fully available
{
"product_qty": 10.0,
"product_id": self.product2.id,
"state": "waiting",
},
# these 2 moves were not released
{
"product_qty": 20.0,
"product_id": self.product3.id,
"state": "waiting",
},
{
"product_qty": 10.0,
"product_id": self.product4.id,
"state": "waiting",
},
# remaining 10 on product 1 because it was partially available
{
"product_qty": 10.0,
"product_id": self.product1.id,
"state": "waiting",
},
],
)

out_picking = self._pickings_in_group(pickings.group_id) - cust_picking
self.assertRecordValues(
out_picking,
[
{
"state": "assigned",
"location_id": self.wh.lot_stock_id.id,
"location_dest_id": self.wh.wh_output_stock_loc_id.id,
}
],
)
self.assertRecordValues(
out_picking.move_ids,
[
{"product_qty": 10.0, "product_id": self.product1.id},
{"product_qty": 10.0, "product_id": self.product2.id},
],
)

def test_defer_creation_uom(self):
self.wh.delivery_route_id.write({"available_to_promise_defer_pull": True})
self._update_qty_in_location(self.loc_bin1, self.product1, 12.0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<field name="arch" type="xml">
<field name="company_id" position="after">
<field name="available_to_promise_defer_pull" />
<field name="no_backorder_at_release" />
</field>
</field>
</record>
Expand Down

0 comments on commit 20d852c

Please sign in to comment.