From 42bb39f1c47666bfd1bc498aad2231c498378958 Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Fri, 27 Mar 2026 11:11:18 +0100 Subject: [PATCH] [FIX] stock_location_flowable: reserve before setting qty_done on MO lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mixing MO creation in _action_done created move lines with qty_done first, then called action_assign expecting Odoo to merge the reservation into those existing lines. This merge relies on a UoM round-trip check in _update_reserved_quantity that fails when the quant quantity has more decimal precision than the UoM rounding allows. When the merge fails, a second line is created for the same lot — one with only product_uom_qty and one with only qty_done — making the MO impossible to complete ("cannot unreserve more products than you have in stock"). The fix follows the standard Odoo pattern: call action_assign first to create proper reservation lines, then set qty_done on those lines by matching lot_id. This eliminates the dependency on the merge and prevents split lines regardless of UoM rounding configuration. --- .../models/stock_picking.py | 70 +++++++---- .../tests/test_stock_picking.py | 110 ++++++++++++++++++ 2 files changed, 157 insertions(+), 23 deletions(-) diff --git a/stock_location_flowable/models/stock_picking.py b/stock_location_flowable/models/stock_picking.py index 016fa2e53..209e6dfa5 100644 --- a/stock_location_flowable/models/stock_picking.py +++ b/stock_location_flowable/models/stock_picking.py @@ -38,18 +38,53 @@ def _prepare_lot_values(self, product, location_dest, qty_done): "product_uom_id": product.uom_id.id, } - def _prepare_production_move_line_values(self, move_line, product, location_dest): + def _set_production_qty_done(self, production, component_quants): + """Set qty_done on the reservation lines created by action_assign. + + After action_confirm + action_assign, the MO raw move has one + reservation line per lot with product_uom_qty set (reserved + quantity) and qty_done = 0. This method fills in qty_done by + matching each quant to its reservation line by lot_id. + + We also update location_dest_id to the virtual production + location, because the raw move's location_dest_id points to + the flowable location itself (both source and dest are the + same for a mixing MO). The move lines need to consume stock + to the virtual production location for correct stock + accounting when the MO is completed. + + Why not create the move lines manually with both product_uom_qty + and qty_done from the start? Because product_uom_qty must be + backed by an actual quant reservation (reserved_quantity on the + quant record). The standard way to achieve this is through + action_assign, which calls _action_assign -> _update_reserved_quantity + to both reserve the quant and create the move line atomically. + + The previous approach created move lines with qty_done first, + then called action_assign expecting Odoo to merge the reservation + into those existing lines. This merge relies on a UoM round-trip + check in _update_reserved_quantity: the quantity is converted + through the UoM and back, and if the result differs (due to UoM + rounding), a NEW line is created instead of updating the existing + one. This produced two lines for the same lot (one with only + product_uom_qty and one with only qty_done) which made the MO + impossible to complete ("cannot unreserve more products than you + have in stock"). + """ self.ensure_one() - return { - "lot_id": move_line.lot_id.id, - "product_id": product.id, - "qty_done": move_line.quantity, - "product_uom_id": product.uom_id.id, - "location_id": location_dest.id, - "location_dest_id": product.with_company( - self.company_id - ).property_stock_production.id, - } + production_location = production.product_id.with_company( + self.company_id + ).property_stock_production + ml_by_lot = {ml.lot_id: ml for ml in production.move_raw_ids.move_line_ids} + for quant in component_quants: + ml = ml_by_lot.get(quant.lot_id) + if ml: + ml.write( + { + "qty_done": quant.quantity, + "location_dest_id": production_location.id, + } + ) def _prepare_production_move_values( self, product, location_dest, quantity_to_prod, mrp_operation_type @@ -217,20 +252,9 @@ def _action_done(self): ) else: producing_lot = lot - vals = [] - for move_line in component_quant: - vals.append( - ( - 0, - 0, - rec._prepare_production_move_line_values( - move_line, product, location_dest - ), - ) - ) - production.move_raw_ids.move_line_ids = vals production.lot_producing_id = producing_lot production.action_assign() + rec._set_production_qty_done(production, component_quant) production.qty_producing = quantity_to_prod return res diff --git a/stock_location_flowable/tests/test_stock_picking.py b/stock_location_flowable/tests/test_stock_picking.py index 39ed0a41c..3648f68ee 100644 --- a/stock_location_flowable/tests/test_stock_picking.py +++ b/stock_location_flowable/tests/test_stock_picking.py @@ -1301,3 +1301,113 @@ def test_create_lots_true_topup_same_incoming_lot(self): lot_a, "Auto-generated lot should differ from the incoming lot", ) + + def test_mixing_mo_move_lines_not_split_by_lot(self): + """ + Test that mixing MO move lines have both product_uom_qty and + qty_done on the same line for each lot, even when the quant + quantities have more decimal precision than the UoM rounding. + + The bug: Odoo's _update_reserved_quantity uses a UoM round-trip + check. When quant qty (e.g. 200.395) has more decimals than + UoM rounding (e.g. 0.01), the round-trip fails and a NEW + reservation line is created instead of updating the existing one. + This leaves two lines per lot: one with product_uom_qty only + and one with qty_done only. + + PRE: - Product with a UoM whose rounding is 0.01 (coarser + than the quantities used) + - Flowable location seeded with lot A at 200.395 units + ACT: - Receive lot B at 100.43 units (triggers mixing MO) + POST: - The MO raw move has exactly one line per lot + - Each line has both product_uom_qty > 0 and qty_done > 0 + - The MO can be completed without errors + """ + # ARRANGE — custom UoM with coarse rounding to trigger the bug + self.picking_type_mrp_operation_1.flowable_operation = True + + # The roundtrip check in _update_reserved_quantity uses + # decimal.precision for float_compare. When precision >= 3 + # and UoM rounding is 0.01, a quantity of 200.395 survives + # the float_compare but not the UoM roundtrip → mismatch. + dp = self.env.ref("product.decimal_product_uom") + original_digits = dp.digits + dp.digits = 6 + + uom_category = self.env["uom.category"].create({"name": "Test Coarse Volume"}) + coarse_uom = self.env["uom.uom"].create( + { + "name": "Test Litre (coarse)", + "category_id": uom_category.id, + "uom_type": "reference", + "rounding": 0.01, + } + ) + coarse_product = self.env["product.product"].create( + { + "name": "Liquid CO2 coarse", + "type": "product", + "uom_id": coarse_uom.id, + "uom_po_id": coarse_uom.id, + "tracking": "lot", + } + ) + self.location_flowable_1.write( + { + "flowable_uom_id": coarse_uom.id, + "flowable_allowed_product_ids": [(6, 0, [coarse_product.id])], + } + ) + + lot_a = self._create_lot(coarse_product, "SPLIT-LOT-A") + self._seed_flowable_location( + self.location_flowable_1, coarse_product, lot_a, 200 + ) + + # Simulate a quant whose quantity has more decimal precision + # than the UoM rounding (0.01). This happens in production when + # the UoM rounding is tightened after quants already exist. + quant_a = self.env["stock.quant"].search( + [ + ("lot_id", "=", lot_a.id), + ("location_id", "=", self.location_flowable_1.id), + ] + ) + quant_a.sudo().write({"quantity": 200.395}) + + lot_b = self._create_lot(coarse_product, "SPLIT-LOT-B") + + # ACT — receive lot B (triggers mixing MO) + self._receive_stock(self.location_flowable_1, coarse_product, lot_b, 100) + + production = self._find_flowable_production(self.location_flowable_1) + self.assertTrue(production, "Mixing MO should have been created") + self.assertEqual(production.state, "to_close") + + raw_move = production.move_raw_ids + self.assertEqual(len(raw_move), 1, "Should have exactly one raw move") + + # ASSERT — each lot has exactly one line with both reservation and qty_done + for lot in (lot_a, lot_b): + lot_lines = raw_move.move_line_ids.filtered( + lambda ml, lt=lot: ml.lot_id == lt + ) + self.assertEqual( + len(lot_lines), + 1, + "Lot %s should have exactly one move line, got %d" + % (lot.name, len(lot_lines)), + ) + self.assertGreater( + lot_lines.product_uom_qty, + 0, + "Lot %s line must have product_uom_qty > 0" % lot.name, + ) + self.assertGreater( + lot_lines.qty_done, + 0, + "Lot %s line must have qty_done > 0" % lot.name, + ) + + # CLEANUP + dp.digits = original_digits