diff --git a/models/stock_move.py b/models/stock_move.py index a99ef50..1bfab06 100644 --- a/models/stock_move.py +++ b/models/stock_move.py @@ -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 diff --git a/models/stock_move_line.py b/models/stock_move_line.py index 6f648c1..ca271be 100644 --- a/models/stock_move_line.py +++ b/models/stock_move_line.py @@ -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', diff --git a/tests/test_uom_conversion.py b/tests/test_uom_conversion.py new file mode 100644 index 0000000..d4007d0 --- /dev/null +++ b/tests/test_uom_conversion.py @@ -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") diff --git a/wizard/subcontract_lot_generator.py b/wizard/subcontract_lot_generator.py index 4b4daff..d28d462 100644 --- a/wizard/subcontract_lot_generator.py +++ b/wizard/subcontract_lot_generator.py @@ -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',