mrp_packaging_qty/models/mrp_production.py

251 lines
13 KiB
Python

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 total consumed should match the ideal quantity.
total_done = sum(move.move_line_ids.mapped('quantity'))
if production.qty_producing == production.product_qty:
diff = ideal_qty - total_done
# Only auto-fix if it's a microscopic rounding drift (not a user overriding quantity intentionally)
if 0.000001 < abs(diff) < (move.product_uom.rounding * 1.5):
for ml in move.move_line_ids:
if not ml.product_id:
continue
# Safely apply the tiny difference to the first line that has enough quantity
if ml.quantity and ml.quantity + diff >= 0:
ml.quantity += diff
break
else:
# Otherwise just round to 2 decimals if the total drifted by microscopic dust.
# We shouldn't blindly round every single line if they are split in strange ways,
# but we can fix the tiny decimal dust on the first available line.
clean_total = round(total_done, 2)
diff = clean_total - total_done
if 0.000001 < abs(diff) < (move.product_uom.rounding * 1.5):
for ml in move.move_line_ids:
if not ml.product_id:
continue
if ml.quantity and ml.quantity + diff >= 0:
ml.quantity += diff
break
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()