diff --git a/addons/stock/report/report_stock_forecasted.py b/addons/stock/report/report_stock_forecasted.py index 47d7be945b1..1dcd5b08cf9 100644 --- a/addons/stock/report/report_stock_forecasted.py +++ b/addons/stock/report/report_stock_forecasted.py @@ -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 diff --git a/addons/stock/tests/test_report.py b/addons/stock/tests/test_report.py index 991bec69120..8baa026ae0c 100644 --- a/addons/stock/tests/test_report.py +++ b/addons/stock/tests/test_report.py @@ -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'])