[FIX] stock: in forecast report, associate MTO lines only with their source

Behavior prior to this commit:

If I have a pre-existing order (confirmed, but without available
product) on a product with an MTO route, and I now place a new order on
that product then confirm the generated PO, in the forecast report the
quantities from the PO will be shown as being applied to the
pre-existing order first.

For example:
 - create a product with an MTO route (set a vendor)
 - create a SO (SO1) for 5 units of that product and confirm it.  Verify that
 a PO (PO1) was generated.  Cancel the PO.
 - create a new SO (SO2) for 10 units of the product and confirm it.  Confirm
 the PO (PO2) that is generated.
 - run the forecast report.  It should show:

| Replenishment | Quantity | Used By |
|------------------------------------|
|   PO          |   10     | SO2     |
| Not available |   -5     | SO1     |

But instead it shows:

| Replenishment | Quantity | Used By |
|------------------------------------|
|   PO          |    5     | SO1     |
|   PO          |    5     | SO2     |
| Not available |   -5     | SO2     |

When the product is received for PO, the quantity received is correctly
applied to SO2, not SO1.

Behavior after this commit:

 - when generating the forecast report, we have to tie each outgoing
 shipment with 0 or more incoming shipment.  After this commit, the
 computation will be made without considering candidate incoming
 shipments that are linked (via `move_dest_ids`) to a different
 outgoing shipment.
 - for receipts that are not tied to another move (move_dest_ids is
 empty), there is no change of behavior
 - for receipts that are "over-delivering", for example if there was a
 generated PO for 10 units but they manually increased it to 15 to cover
 both SO, there is no change of behavior: it will still show as
 replenishing both SO.

Note:

 - in a multi step environment the report does not correctly allocate
 quantities once some steps of the order are processed (the quantity
 will be shown as coming from stock, but in fact not all steps have been
 processed), this is accepted as a known limitation to prevent making
 too much of a performance hit for the MTO case (this behavior is the
 same as what was present before the commit)

opw-2412137

closes odoo/odoo#63529

X-original-commit: 95435c056236438c61688855748b3afac57416a2
Signed-off-by: Nicolas Galler <ngaller@users.noreply.github.com>
This commit is contained in:
Nicolas Galler
2020-12-17 12:21:55 +00:00
parent c915456b94
commit 303c04348e
2 changed files with 152 additions and 19 deletions
+43 -19
View File
@@ -133,6 +133,32 @@ class ReplenishmentReport(models.AbstractModel):
}
def _get_report_lines(self, product_template_ids, product_variant_ids, wh_location_ids):
def _rollup_move_dests(move, seen):
for dst in move.move_dest_ids:
if dst.id not in seen:
seen.add(dst.id)
_rollup_move_dests(dst, seen)
return seen
def _reconcile_out_with_ins(lines, out, ins, demand, only_matching_move_dest=True):
index_to_remove = []
for index, in_ in enumerate(ins):
if float_is_zero(in_['qty'], precision_rounding=out.product_id.uom_id.rounding):
continue
if only_matching_move_dest and in_['move_dests'] and out.id not in in_['move_dests']:
continue
taken_from_in = min(demand, in_['qty'])
demand -= taken_from_in
lines.append(self._prepare_report_line(taken_from_in, move_in=in_['move'], move_out=out))
in_['qty'] -= taken_from_in
if in_['qty'] <= 0:
index_to_remove.append(index)
if float_is_zero(demand, precision_rounding=out.product_id.uom_id.rounding):
break
for index in index_to_remove[::-1]:
ins.pop(index)
return demand
in_domain, out_domain = self._move_confirmed_domain(
product_template_ids, product_variant_ids, wh_location_ids
)
@@ -143,7 +169,11 @@ class ReplenishmentReport(models.AbstractModel):
ins = self.env['stock.move'].search(in_domain, order='priority desc, date, id')
ins_per_product = defaultdict(lambda: [])
for in_ in ins:
ins_per_product[in_.product_id.id].append([in_.product_qty, in_])
ins_per_product[in_.product_id.id].append({
'qty': in_.product_qty,
'move': in_,
'move_dests': _rollup_move_dests(in_, set())
})
currents = {c['id']: c['qty_available'] for c in outs.product_id.read(['qty_available'])}
lines = []
@@ -156,6 +186,7 @@ class ReplenishmentReport(models.AbstractModel):
currents[product.id] -= reserved
lines.append(self._prepare_report_line(reserved, move_out=out, reservation=True))
unreconciled_outs = []
for out in outs_per_product[product.id]:
# Reconcile with the current stock.
current = currents[out.product_id.id]
@@ -170,32 +201,25 @@ class ReplenishmentReport(models.AbstractModel):
lines.append(self._prepare_report_line(taken_from_stock, move_out=out))
# Reconcile with the ins.
if not float_is_zero(demand, precision_rounding=product.uom_id.rounding):
index_to_remove = []
for index, in_ in enumerate(ins_per_product[out.product_id.id]):
if float_is_zero(in_[0], precision_rounding=product.uom_id.rounding):
continue
taken_from_in = min(demand, in_[0])
demand -= taken_from_in
lines.append(self._prepare_report_line(taken_from_in, move_in=in_[1], move_out=out))
ins_per_product[out.product_id.id][index][0] -= taken_from_in
if ins_per_product[out.product_id.id][index][0] <= 0:
index_to_remove.append(index)
if float_is_zero(demand, precision_rounding=product.uom_id.rounding):
break
for index in index_to_remove[::-1]:
ins_per_product[out.product_id.id].pop(index)
# Not reconciled.
demand = _reconcile_out_with_ins(lines, out, ins_per_product[out.product_id.id], demand, only_matching_move_dest=True)
if not float_is_zero(demand, precision_rounding=product.uom_id.rounding):
lines.append(self._prepare_report_line(demand, move_out=out, replenishment_filled=False))
unreconciled_outs.append((demand, out))
if unreconciled_outs:
for (demand, out) in unreconciled_outs:
# Another pass, in case there are some ins linked to a dest move but that still have some quantity available
demand = _reconcile_out_with_ins(lines, out, ins_per_product[product.id], demand, only_matching_move_dest=False)
if not float_is_zero(demand, precision_rounding=product.uom_id.rounding):
# Not reconciled
lines.append(self._prepare_report_line(demand, move_out=out, replenishment_filled=False))
# Unused remaining stock.
free_stock = currents.get(product.id, 0)
if not float_is_zero(free_stock, precision_rounding=product.uom_id.rounding):
lines.append(self._prepare_report_line(free_stock, product=product))
# In moves not used.
for in_ in ins_per_product[product.id]:
if float_is_zero(in_[0], precision_rounding=product.uom_id.rounding):
if float_is_zero(in_['qty'], precision_rounding=product.uom_id.rounding):
continue
lines.append(self._prepare_report_line(in_[0], move_in=in_[1]))
lines.append(self._prepare_report_line(in_['qty'], move_in=in_['move']))
return lines
@api.model
+109
View File
@@ -932,3 +932,112 @@ class TestReports(TestReportsCommon):
self.assertTrue(line_2['replenishment_filled'])
self.assertEqual(line_2['document_in'].id, receipt_2.id)
self.assertEqual(line_2['document_out'].id, delivery.id)
def test_report_forecast_8_delivery_to_receipt_link(self):
"""
Create 2 deliveries, and 1 receipt tied to the second delivery.
The report should show the source document as the 2nd delivery, and show the first
delivery completely unfilled.
"""
delivery_form = Form(self.env['stock.picking'].with_context(
force_detailed_view=True
), view='stock.view_picking_form')
delivery_form.partner_id = self.partner
delivery_form.picking_type_id = self.picking_type_out
with delivery_form.move_ids_without_package.new() as move_line:
move_line.product_id = self.product
move_line.product_uom_qty = 100
delivery = delivery_form.save()
delivery.action_confirm()
delivery_form = Form(self.env['stock.picking'].with_context(
force_detailed_view=True
), view='stock.view_picking_form')
delivery_form.partner_id = self.partner
delivery_form.picking_type_id = self.picking_type_out
with delivery_form.move_ids_without_package.new() as move_line:
move_line.product_id = self.product
move_line.product_uom_qty = 200
delivery2 = delivery_form.save()
delivery2.action_confirm()
receipt_form = Form(self.env['stock.picking'].with_context(
force_detailed_view=True
), view='stock.view_picking_form')
receipt_form.partner_id = self.partner
receipt_form.picking_type_id = self.picking_type_in
receipt = receipt_form.save()
with receipt_form.move_ids_without_package.new() as move_line:
move_line.product_id = self.product
move_line.product_uom_qty = 200
receipt = receipt_form.save()
receipt.move_lines[0].write({
'move_dest_ids': [(4, delivery2.move_lines[0].id)],
})
receipt.action_confirm()
self.env['base'].flush()
_, _, lines = self.get_report_forecast(product_template_ids=self.product_template.ids)
self.assertEqual(len(lines), 2, 'Only 2 lines')
delivery_line = [l for l in lines if l['document_out'].id == delivery.id][0]
self.assertTrue(delivery_line, 'No line for delivery 1')
self.assertFalse(delivery_line['replenishment_filled'])
delivery2_line = [l for l in lines if l['document_out'].id == delivery2.id][0]
self.assertTrue(delivery2_line, 'No line for delivery 2')
self.assertTrue(delivery2_line['replenishment_filled'])
def test_report_forecast_9_delivery_to_receipt_link_over_received(self):
"""
Create 2 deliveries, and 1 receipt tied to the second delivery.
Set the quantity on the receipt to be enough for BOTH deliveries.
For example, this can happen if they have manually increased the quantity on the generated PO.
The report should show both deliveries fulfilled.
"""
delivery_form = Form(self.env['stock.picking'].with_context(
force_detailed_view=True
), view='stock.view_picking_form')
delivery_form.partner_id = self.partner
delivery_form.picking_type_id = self.picking_type_out
with delivery_form.move_ids_without_package.new() as move_line:
move_line.product_id = self.product
move_line.product_uom_qty = 100
delivery = delivery_form.save()
delivery.action_confirm()
delivery_form = Form(self.env['stock.picking'].with_context(
force_detailed_view=True
), view='stock.view_picking_form')
delivery_form.partner_id = self.partner
delivery_form.picking_type_id = self.picking_type_out
with delivery_form.move_ids_without_package.new() as move_line:
move_line.product_id = self.product
move_line.product_uom_qty = 200
delivery2 = delivery_form.save()
delivery2.action_confirm()
receipt_form = Form(self.env['stock.picking'].with_context(
force_detailed_view=True
), view='stock.view_picking_form')
receipt_form.partner_id = self.partner
receipt_form.picking_type_id = self.picking_type_in
receipt = receipt_form.save()
with receipt_form.move_ids_without_package.new() as move_line:
move_line.product_id = self.product
move_line.product_uom_qty = 300
receipt = receipt_form.save()
receipt.move_lines[0].write({
'move_dest_ids': [(4, delivery2.move_lines[0].id)],
})
receipt.action_confirm()
self.env['base'].flush()
_, _, lines = self.get_report_forecast(product_template_ids=self.product_template.ids)
self.assertEqual(len(lines), 2, 'Only 2 lines')
delivery_line = [l for l in lines if l['document_out'].id == delivery.id][0]
self.assertTrue(delivery_line, 'No line for delivery 1')
self.assertTrue(delivery_line['replenishment_filled'])
delivery2_line = [l for l in lines if l['document_out'].id == delivery2.id][0]
self.assertTrue(delivery2_line, 'No line for delivery 2')
self.assertTrue(delivery2_line['replenishment_filled'])