feat: Improve lot generator quantity calculation and UOM conversion, and add related unit tests.

This commit is contained in:
Suherdy Yacob 2026-01-23 09:30:10 +07:00
parent 816bd3bd71
commit deef4b357e
4 changed files with 207 additions and 12 deletions

View File

@ -168,6 +168,48 @@ class StockMove(models.Model):
fake_first = 'SEQDUMMY-1'
vals_list = super().action_generate_lot_line_vals(context, mode, fake_first, count, lot_text)
# Fix UOM issue: if Move UOM is different from Product UOM,
# the default generation might use Base UOM but with Move's quantity value
move = False
# 1. Try finding move via active_id
if context.get('active_id'):
candidate = self.env['stock.move'].browse(context.get('active_id'))
if candidate.exists() and candidate.product_id.id == product_id:
move = candidate
# 2. If not found, try finding via context parameters (passed by JS widget)
if not move and product_id:
domain = [
('product_id', '=', product_id),
('state', 'not in', ['done', 'cancel']),
]
if context.get('default_location_id'):
domain.append(('location_id', '=', context.get('default_location_id')))
if context.get('default_location_dest_id'):
domain.append(('location_dest_id', '=', context.get('default_location_dest_id')))
if context.get('default_picking_id'):
domain.append(('picking_id', '=', context.get('default_picking_id')))
elif context.get('active_model') == 'stock.picking' and context.get('active_id'):
domain.append(('picking_id', '=', context.get('active_id')))
moves = self.env['stock.move'].search(domain, limit=1)
if moves:
move = moves[0]
try:
if move:
move_uom = move.product_uom
base_uom = move.product_id.uom_id
if move_uom and base_uom and move_uom != base_uom:
# Update all generate lines to use the move's UOM
# assuming reasonable consistency
for vals in vals_list:
# Pass as tuple (id, name) so frontend displays it correctly immediately
vals['product_uom_id'] = (move_uom.id, move_uom.display_name)
except Exception as e:
_logger.error(f"Error fixing UOM in product_lot_sequence_per_product: {e}")
# Overwrite the lot_name with sequence-based names
for vals, name in zip(vals_list, generated_names):
vals['lot_name'] = name

View File

@ -54,12 +54,51 @@ class StockMoveLine(models.Model):
# Use safe field access for quantity fields (field names may vary in different Odoo versions)
MoveLine = self.env['stock.move.line']
quantity_value = 1.0 # default fallback
src_uom = self.product_uom_id
# Try different quantity field names
for field_name in ['reserved_uom_qty', 'product_qty', 'product_uom_qty']:
if field_name in MoveLine._fields and hasattr(self, field_name):
quantity_value = getattr(self, field_name, 1.0) or 1.0
break
# Try to get quantity from the line first
if 'quantity' in MoveLine._fields and self.quantity:
quantity_value = self.quantity
src_uom = self.product_uom_id
elif 'qty_done' in MoveLine._fields and self.qty_done:
quantity_value = self.qty_done
src_uom = self.product_uom_id
elif 'reserved_uom_qty' in MoveLine._fields and getattr(self, 'reserved_uom_qty', 0):
quantity_value = self.reserved_uom_qty
src_uom = self.product_uom_id
elif 'quantity_product_uom' in MoveLine._fields and getattr(self, 'quantity_product_uom', 0):
quantity_value = self.quantity_product_uom
src_uom = self.product_uom_id
elif 'product_qty' in MoveLine._fields and getattr(self, 'product_qty', 0):
quantity_value = self.product_qty
src_uom = self.product_id.uom_id
elif 'product_uom_qty' in MoveLine._fields and getattr(self, 'product_uom_qty', 0):
quantity_value = self.product_uom_qty
src_uom = self.product_uom_id
else:
quantity_value = 0.0
src_uom = self.product_uom_id
# If a quantity was found on the line, assume it is in src_uom
if quantity_value > 0:
# Convert to Move UOM if needed
if self.move_id and self.move_id.product_uom and src_uom and src_uom != self.move_id.product_uom:
quantity_value = src_uom._compute_quantity(quantity_value, self.move_id.product_uom)
else:
# Fallback: Line has no quantity (e.g. new line)
# Use Move's remaining demand or default to 1.0 (Move UOM)
if self.move_id:
# Try to fill with remaining demand
# If quantity_done is available on move
done = getattr(self.move_id, 'quantity_done', 0.0) or getattr(self.move_id, 'quantity', 0.0)
demand = self.move_id.product_uom_qty
remaining = max(demand - done, 0.0)
quantity_value = remaining if remaining > 0 else 1.0
else:
quantity_value = 1.0
# Since we pulled from Move (or assumed 1 unit of Move), the value is already in Move UOM
pass
return {
'name': 'Generate Lots for Move Line',

View File

@ -0,0 +1,105 @@
from odoo.tests.common import TransactionCase
class TestUOMConversion(TransactionCase):
def setUp(self):
super().setUp()
self.uom_kg = self.env.ref('uom.product_uom_kgm')
self.uom_gram = self.env.ref('uom.product_uom_gram')
self.product = self.env['product.product'].create({
'name': 'Test Product Grams',
'type': 'product',
'uom_id': self.uom_gram.id,
'uom_po_id': self.uom_gram.id,
'tracking': 'lot',
})
self.stock_location = self.env.ref('stock.stock_location_stock')
self.customer_location = self.env.ref('stock.stock_location_customers')
self.picking_type = self.env.ref('stock.picking_type_out')
def test_uom_conversion_kg_to_gram(self):
# Move in Grams
move = self.env['stock.move'].create({
'name': 'Test Move',
'product_id': self.product.id,
'product_uom': self.uom_gram.id,
'product_uom_qty': 1000,
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
})
move._action_confirm()
# Line in KG (Detailed Operation)
# 1 KG = 1000 Grams
# Note: Using qty_done/quantity depending on version.
# API usually accepts qty_done in older and quantity in newer.
# We try to set both or flexible.
line_vals = {
'move_id': move.id,
'product_id': self.product.id,
'product_uom_id': self.uom_kg.id,
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
}
# Odoo 17+ uses quantity. Older uses qty_done.
# We can try to assume the environment supports 'quantity' if we are in 18.
# Or just pass 'qty_done' as it is often aliased.
# However, checking the module under test, it checks both.
# Let's set 'qty_done' for creation as it's safer for 'create' in many versions
# unless 'quantity' is strictly required.
line_vals['qty_done'] = 1
line = self.env['stock.move.line'].create(line_vals)
# Ensure we have quantity set effectively
if hasattr(line, 'quantity') and line.quantity == 0:
line.quantity = 1
# Action
action = line.action_open_lot_generator()
ctx = action.get('context', {})
default_qty = ctx.get('default_quantity')
# Expect 1000 Grams (because Move is in Grams)
self.assertEqual(default_qty, 1000.0, "Should convert 1 KG to 1000 Grams")
def test_uom_conversion_gram_to_kg(self):
# Move in KG
move = self.env['stock.move'].create({
'name': 'Test Move KG',
'product_id': self.product.id,
'product_uom': self.uom_kg.id,
'product_uom_qty': 1,
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
})
move._action_confirm()
# Line in Grams (Detailed Operation)
# 1000 Grams = 1 KG
line_vals = {
'move_id': move.id,
'product_id': self.product.id,
'product_uom_id': self.uom_gram.id,
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
}
line_vals['qty_done'] = 1000
line = self.env['stock.move.line'].create(line_vals)
if hasattr(line, 'quantity') and line.quantity == 0:
line.quantity = 1000
# Action
action = line.action_open_lot_generator()
ctx = action.get('context', {})
default_qty = ctx.get('default_quantity')
# Expect 1 KG (because Move is in KG)
# 1000 Grams -> 1 KG
self.assertEqual(default_qty, 1.0, "Should convert 1000 Grams to 1 KG")

View File

@ -78,26 +78,35 @@ class SubcontractLotGenerator(models.TransientModel):
lots = Lot.create(lot_vals_list)
# Create move lines
# Calculate quantity in Product's Base UOM
# self.quantity is in self.move_id.product_uom (or whatever was passed)
base_uom = self.product_id.uom_id
move_uom = self.move_id.product_uom
total_base_qty = self.quantity
if move_uom and base_uom and move_uom != base_uom:
total_base_qty = move_uom._compute_quantity(self.quantity, base_uom)
if self.tracking == 'serial':
# One move line per lot for serial tracking
# One move line per lot for serial tracking, usually 1 unit of base UOM per serial
qty_per_lot = 1.0
else:
# Distribute quantity across lots for lot tracking
qty_per_lot = self.quantity / self.lot_count
qty_per_lot = total_base_qty / self.lot_count
for lot in lots:
self.env['stock.move.line'].create({
vals = {
'move_id': self.move_id.id,
'product_id': self.product_id.id,
'lot_id': lot.id,
'product_uom_qty': qty_per_lot,
'qty_done': 0.0,
'product_uom_id': self.move_id.product_uom.id,
'product_uom_id': base_uom.id,
'location_id': self.move_id.location_id.id,
'location_dest_id': self.move_id.location_dest_id.id,
'picking_id': self.picking_id.id,
})
}
self.env['stock.move.line'].create(vals)
return {
'type': 'ir.actions.client',