Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 47 additions & 23 deletions stock_location_flowable/models/stock_picking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
110 changes: 110 additions & 0 deletions stock_location_flowable/tests/test_stock_picking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading