from odoo import api, fields, models from odoo.tools import float_compare class MrpProduction(models.Model): _inherit = 'mrp.production' def _get_moves_raw_values(self): moves = super()._get_moves_raw_values() # Odoo's internal mrp.bom.explode uses rounding_method='UP'. # A tiny floating point inaccuracy in UoM conversion (e.g. 1.00000000005) # rounding UP with precision 0.001 results in +0.001 to all components. # We calculate the clean integer factor here and overwrite the moved quantities for production in self: if not production.bom_id: continue raw_factor = production.product_uom_id._compute_quantity(production.product_qty, production.bom_id.product_uom_id, round=False) / production.bom_id.product_qty # Truncate the floating point noise. 8 decimals is safe. clean_factor = round(raw_factor, 8) for move_vals in moves: # If this move belongs to this production and is tied to a BOM line if move_vals.get('raw_material_production_id') == production.id and move_vals.get('bom_line_id'): bom_line = production.env['mrp.bom.line'].browse(move_vals['bom_line_id']) # Instead of Odoo's noisy exploded qty, use the clean factor # Round to 8 decimals before applying the UoM rounding method (UP) line_quantity = round(clean_factor * bom_line.product_qty, 8) # It's important to use float_round rather than just python round # so we respect the UOM rounding method (UP), but on the CLEANED number! from odoo.tools import float_round clean_qty = float_round(line_quantity, precision_rounding=bom_line.product_uom_id.rounding, rounding_method='UP') move_vals['product_uom_qty'] = clean_qty return moves def _update_raw_moves(self, factor): # We override this to ensure "To Consume" (product_uom_qty) is calculated from BOM line "truth" # rather than multiplying an old (possibly noisy) quantity by a factor. res = super()._update_raw_moves(factor) clean_res = [] for move, old_qty, new_qty in res: if move.bom_line_id and self.bom_id: # Recalculate the clean truth directly from BOM clean_factor = round(self.product_uom_id._compute_quantity(self.product_qty, self.bom_id.product_uom_id, round=False) / self.bom_id.product_qty, 8) bom_qty_in_move_uom = move.bom_line_id.product_uom_id._compute_quantity(move.bom_line_id.product_qty, move.product_uom, round=False) line_quantity = round(clean_factor * bom_qty_in_move_uom, 8) from odoo.tools import float_round ideal_qty = float_round(line_quantity, precision_rounding=move.product_uom.rounding, rounding_method='UP') if move.product_uom_qty != ideal_qty: move.write({'product_uom_qty': ideal_qty}) clean_res.append((move, old_qty, ideal_qty)) else: clean_res.append((move, old_qty, new_qty)) return clean_res packaging_id = fields.Many2one('mrp.packaging', string='Packaging', domain="[('product_tmpl_id', '=', product_tmpl_id)]", check_company=True) packaging_qty = fields.Float('Quantity Packaging', compute='_compute_packaging_qty', inverse='_inverse_packaging_qty', store=True, readonly=False) @api.depends('product_qty', 'packaging_id', 'packaging_id.qty') def _compute_packaging_qty(self): for record in self: if record.packaging_id and record.packaging_id.qty: qty_in_base = record.product_uom_id._compute_quantity(record.product_qty, record.product_id.uom_id, round=False) new_qty = round(qty_in_base / record.packaging_id.qty, 8) if float_compare(new_qty, record.packaging_qty, precision_digits=2) != 0: record.packaging_qty = new_qty else: record.packaging_qty = 0.0 def _inverse_packaging_qty(self): for record in self: if record.packaging_id and record.packaging_id.qty: qty_in_base = record.packaging_qty * record.packaging_id.qty record.product_qty = round(record.product_id.uom_id._compute_quantity(qty_in_base, record.product_uom_id, round=False), 8) qty_producing_packaging = fields.Float('Quantity Producing Packaging', compute='_compute_qty_producing_packaging', inverse='_inverse_qty_producing_packaging', digits=(16, 2)) @api.depends('qty_producing', 'packaging_id', 'packaging_id.qty') def _compute_qty_producing_packaging(self): for record in self: if record.packaging_id and record.packaging_id.qty: qty_in_base = record.product_uom_id._compute_quantity(record.qty_producing, record.product_id.uom_id, round=False) record.qty_producing_packaging = round(qty_in_base / record.packaging_id.qty, 8) else: record.qty_producing_packaging = 0.0 def _inverse_qty_producing_packaging(self): for record in self: if record.packaging_id and record.packaging_id.qty: qty_in_base = record.qty_producing_packaging * record.packaging_id.qty record.qty_producing = round(record.product_id.uom_id._compute_quantity(qty_in_base, record.product_uom_id, round=False), 8) record._merge_finished_move_lines() @api.onchange('qty_producing_packaging') def _onchange_qty_producing_packaging(self): if self.packaging_id and self.packaging_id.qty: qty_in_base = self.qty_producing_packaging * self.packaging_id.qty self.qty_producing = self.product_id.uom_id._compute_quantity(qty_in_base, self.product_uom_id, round=False) self._onchange_qty_producing() self._merge_finished_move_lines() @api.onchange('qty_producing') def _onchange_qty_producing(self): super()._onchange_qty_producing() self._merge_finished_move_lines() self._clean_lingering_decimals() def write(self, vals): res = super().write(vals) if 'qty_producing' in vals or 'qty_producing_packaging' in vals or 'product_qty' in vals: self._merge_finished_move_lines() self._clean_lingering_decimals() return res def _set_qty_producing(self, *args, **kwargs): res = super()._set_qty_producing(*args, **kwargs) self._merge_finished_move_lines() self._clean_lingering_decimals() return res def _post_inventory(self, *args, **kwargs): res = super()._post_inventory(*args, **kwargs) self._merge_finished_move_lines() return res def _clean_lingering_decimals(self): for production in self: if not production.bom_id: continue # Pre-calculate clean factor for this production clean_factor = round(production.product_uom_id._compute_quantity(production.product_qty, production.bom_id.product_uom_id, round=False) / production.bom_id.product_qty, 8) for move in production.move_raw_ids: if not move.bom_line_id: continue # Calculate what the quantity SHOULD be according to BOM and MO Qty bom_qty_in_move_uom = move.bom_line_id.product_uom_id._compute_quantity(move.bom_line_id.product_qty, move.product_uom, round=False) line_quantity = round(clean_factor * bom_qty_in_move_uom, 8) from odoo.tools import float_round ideal_qty = float_round(line_quantity, precision_rounding=move.product_uom.rounding, rounding_method='UP') # 1. Fix "To Consume" (product_uom_qty) if abs(move.product_uom_qty - ideal_qty) > 0.000001: move.product_uom_qty = ideal_qty # 2. Fix "Consumed" (move_line_ids.quantity) # If the line is FULLY consumed (e.g. qty_producing == product_qty), # then move lines should match the ideal quantity. if production.qty_producing == production.product_qty: for ml in move.move_line_ids: if abs(ml.quantity - ideal_qty) > 0.000001: ml.quantity = ideal_qty else: # Otherwise just round to 2 decimals if it's "close enough" to a clean decimal # but drifted by microscopic dust. for ml in move.move_line_ids: clean_done = round(ml.quantity, 2) if 0.000001 < abs(ml.quantity - clean_done) < (move.product_uom.rounding * 1.5): ml.quantity = clean_done def _merge_finished_move_lines(self): for production in self: # Process ALL finished moves, even DONE ones, to catch splits that happened during validation moves = production.move_finished_ids.filtered(lambda m: m.product_id == production.product_id and m.state != 'cancel') for move in moves: # print(f"DEBUG MERGE: Processing move {move.id}, lines found: {len(move.move_line_ids)}") lines_by_key = {} # track lines to unlink or zero-out (if done) # Keep the original line (smallest ID) to preserve consistency sorted_lines = move.move_line_ids.sorted('id') for line in sorted_lines: # Use IDs for the key to ensure reliable grouping # Include lot_name in case lot_id is not yet set key = ( line.lot_id.id, line.lot_name, line.location_id.id, line.location_dest_id.id, line.package_id.id, line.result_package_id.id, line.owner_id.id, line.product_id.id ) if key in lines_by_key: target_line = lines_by_key[key] # print(f"DEBUG MERGE: Merging line {line.id} into {target_line.id}") # Merge Quantities target_line.quantity += line.quantity # Preserve Traceability Links if line.consume_line_ids: target_line.consume_line_ids |= line.consume_line_ids if line.produce_line_ids: target_line.produce_line_ids |= line.produce_line_ids # If the line is DONE, we cannot unlink it via ORM. # But the user wants it gone from the report (0 qty is not enough). # So we use SQL Delete (Hard Delete). if line.state == 'done': # print(f"DEBUG MERGE: SQL Deleting DONE line {line.id}") self.env.cr.execute("DELETE FROM stock_move_line WHERE id = %s", (line.id,)) line.invalidate_recordset() else: # print(f"DEBUG MERGE: Unlinking line {line.id}") line.unlink() else: lines_by_key[key] = line @api.onchange('product_qty') def _onchange_product_qty(self): # Merge finished move lines if quantity changes self._merge_finished_move_lines() self._clean_lingering_decimals() @api.onchange('bom_id') def _onchange_bom_id(self): if self.bom_id and self.bom_id.packaging_id: self.packaging_id = self.bom_id.packaging_id self.packaging_qty = self.bom_id.packaging_qty # Trigger qty calculation (handled by compute) # self._onchange_packaging_qty()