[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:
@@ -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
|
||||
|
||||
@@ -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'])
|
||||
|
||||
Reference in New Issue
Block a user