feat: Introduce mrp.packaging model and integrate packaging quantity management into MRP Production, BOM, and Production Schedule, along with production tracking and move line merging.

This commit is contained in:
Suherdy Yacob 2026-02-09 17:18:01 +07:00
parent c305b0f113
commit 076fb7ee0b
14 changed files with 384 additions and 8 deletions

View File

@ -8,6 +8,9 @@ This module extends the Odoo Manufacturing app to allow creating Manufacturing O
- **Quantity Packaging**: Adds a `Quantity Packaging` field to specify the number of packages to produce. - **Quantity Packaging**: Adds a `Quantity Packaging` field to specify the number of packages to produce.
- **Auto-Calculation**: Automatically calculates the total `Quantity` (to produce) based on the selected packaging and the number of packages (Quantity = Packaging Size * Quantity Packaging). - **Auto-Calculation**: Automatically calculates the total `Quantity` (to produce) based on the selected packaging and the number of packages (Quantity = Packaging Size * Quantity Packaging).
- **Manual Override**: Allows users to manually adjust the Quantity if needed, or create MOs without using packaging. - **Manual Override**: Allows users to manually adjust the Quantity if needed, or create MOs without using packaging.
- **Traceability Logic**: Automatically consolidates split stock move lines into a single line per Lot/Location when producing more than planned, preventing duplicate entries in Traceability Reports.
- **Packaging Reset**: Automatically resets `Quantity Packaging` to 0.0 when the Packaging field is cleared, allowing seamless transition back to standard UOM input.
- **Clean Reports**: Uses advanced logic (SQL Delete) to remove duplicate 'Done' lines that cannot be unlinked via ORM, ensuring Traceability Reports are accurate and free of zero-quantity lines.
## Usage ## Usage

View File

@ -8,11 +8,17 @@
It allows users to define the quantity to produce based on the selected packaging and its quantity. It allows users to define the quantity to produce based on the selected packaging and its quantity.
""", """,
'author': 'Suherdy Yacob', 'author': 'Suherdy Yacob',
'depends': ['mrp', 'product'], 'depends': ['mrp', 'product', 'mrp_mps'],
'data': [ 'data': [
'security/ir.model.access.csv',
'views/mrp_packaging_views.xml',
'views/mrp_production_views.xml', 'views/mrp_production_views.xml',
'views/mrp_bom_views.xml',
'views/mrp_mps_views.xml',
], ],
'installable': True, 'installable': True,
'application': False, 'application': False,
'license': 'LGPL-3', 'license': 'LGPL-3',
} }

View File

@ -1 +1,6 @@
from . import mrp_production from . import mrp_production
from . import mrp_bom
from . import mrp_production_schedule
from . import mrp_packaging

22
models/mrp_bom.py Normal file
View File

@ -0,0 +1,22 @@
from odoo import api, fields, models
class MrpBom(models.Model):
_inherit = 'mrp.bom'
packaging_id = fields.Many2one('mrp.packaging', string='Packaging', domain="[('product_tmpl_id', '=', product_tmpl_id)]", check_company=True)
packaging_qty = fields.Float('Quantity Packaging', default=0.0)
@api.onchange('packaging_qty')
def _onchange_packaging_qty(self):
if self.packaging_id and self.packaging_qty:
self.product_qty = self.packaging_id.qty * self.packaging_qty
@api.onchange('packaging_id')
def _onchange_packaging_id(self):
if self.packaging_id and self.packaging_id.qty:
self.packaging_qty = self.product_qty / self.packaging_id.qty
@api.onchange('product_qty')
def _onchange_product_qty(self):
if self.packaging_id and self.packaging_id.qty:
self.packaging_qty = self.product_qty / self.packaging_id.qty

11
models/mrp_packaging.py Normal file
View File

@ -0,0 +1,11 @@
from odoo import fields, models
class MrpPackaging(models.Model):
_name = "mrp.packaging"
_description = "MRP Packaging"
name = fields.Char(required=True)
product_tmpl_id = fields.Many2one('product.template', string='Product', required=True, check_company=True)
qty = fields.Float('Quantity', default=1.0, required=True)
barcode = fields.Char('Barcode')
company_id = fields.Many2one('res.company', 'Company', index=True)

View File

@ -3,10 +3,127 @@ from odoo import api, fields, models
class MrpProduction(models.Model): class MrpProduction(models.Model):
_inherit = 'mrp.production' _inherit = 'mrp.production'
packaging_id = fields.Many2one('product.uom', string='Packaging', domain="[('product_id', '=', product_id)]", check_company=True)
packaging_qty = fields.Float('Quantity Packaging', default=0.0, digits='Product Unit of Measure')
@api.onchange('packaging_id', 'packaging_qty') packaging_id = fields.Many2one('mrp.packaging', string='Packaging', domain="[('product_tmpl_id', '=', product_tmpl_id)]", check_company=True)
packaging_qty = fields.Float('Quantity Packaging', default=0.0)
@api.onchange('packaging_qty')
def _onchange_packaging_qty(self): def _onchange_packaging_qty(self):
if self.packaging_id and self.packaging_qty: if self.packaging_id and self.packaging_qty:
self.product_qty = self.packaging_id.uom_id._compute_quantity(self.packaging_qty, self.product_uom_id) self.product_qty = self.packaging_id.qty * self.packaging_qty
@api.onchange('packaging_id')
def _onchange_packaging_id(self):
if self.packaging_id and self.packaging_id.qty:
self.packaging_qty = self.product_qty / self.packaging_id.qty
else:
self.packaging_qty = 0.0
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:
record.qty_producing_packaging = record.qty_producing / record.packaging_id.qty
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:
record.qty_producing = record.qty_producing_packaging * record.packaging_id.qty
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:
self.qty_producing = self.qty_producing_packaging * self.packaging_id.qty
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()
def write(self, vals):
res = super().write(vals)
if 'qty_producing' in vals or 'qty_producing_packaging' in vals:
self._merge_finished_move_lines()
return res
def _set_qty_producing(self, *args, **kwargs):
res = super()._set_qty_producing(*args, **kwargs)
self._merge_finished_move_lines()
return res
def _post_inventory(self, *args, **kwargs):
res = super()._post_inventory(*args, **kwargs)
self._merge_finished_move_lines()
return res
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):
if self.packaging_id and self.packaging_id.qty:
self.packaging_qty = self.product_qty / self.packaging_id.qty
@api.onchange('bom_id')
def _onchange_bom_id(self):
super()._onchange_bom_id()
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
self._onchange_packaging_qty()

View File

@ -0,0 +1,38 @@
from odoo import api, fields, models
from odoo.tools.float_utils import float_round
class MrpProductionSchedule(models.Model):
_inherit = 'mrp.production.schedule'
packaging_id = fields.Many2one('mrp.packaging', string='Packaging', domain="[('product_tmpl_id', '=', product_tmpl_id)]", check_company=True)
def get_production_schedule_view_state(self, period_scale=False, use_all_schedules=False):
res = super().get_production_schedule_view_state(period_scale, use_all_schedules)
for state in res:
mps = self.browse(state['id'])
if mps.packaging_id:
packaging_qty = mps.packaging_id.qty
if packaging_qty:
# Adjust header details if needed, but mostly we modify forecast_ids
for forecast in state['forecast_ids']:
forecast['forecast_qty'] = forecast['forecast_qty'] / packaging_qty
forecast['replenish_qty'] = forecast['replenish_qty'] / packaging_qty
forecast['safety_stock_qty'] = forecast['safety_stock_qty'] / packaging_qty
forecast['starting_inventory_qty'] = forecast['starting_inventory_qty'] / packaging_qty
forecast['incoming_qty'] = forecast['incoming_qty'] / packaging_qty
forecast['outgoing_qty'] = forecast['outgoing_qty'] / packaging_qty
forecast['indirect_demand_qty'] = forecast['indirect_demand_qty'] / packaging_qty
if state.get('product_uom_id'):
state['product_uom_id'] = (state['product_uom_id'][0], mps.packaging_id.name)
return res
def set_forecast_qty(self, date_index, quantity, period_scale=False):
if self.packaging_id:
quantity = float(quantity) * self.packaging_id.qty
return super().set_forecast_qty(date_index, quantity, period_scale)
def set_replenish_qty(self, date_index, quantity, period_scale=False):
if self.packaging_id:
quantity = float(quantity) * self.packaging_id.qty
return super().set_replenish_qty(date_index, quantity, period_scale)

View File

@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_mrp_packaging,mrp.packaging,model_mrp_packaging,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_mrp_packaging mrp.packaging model_mrp_packaging base.group_user 1 1 1 1

1
tests/__init__.py Normal file
View File

@ -0,0 +1 @@
from . import test_mrp_packaging_qty

View File

@ -0,0 +1,75 @@
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestMrpPackagingQty(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.product = cls.env['product.product'].create({
'name': 'Test Product',
'type': 'product',
})
# Create mrp.packaging instead of product.uom/product.packaging
cls.packaging = cls.env['mrp.packaging'].create({
'name': 'Box of 10',
'product_tmpl_id': cls.product.product_tmpl_id.id,
'qty': 10.0,
'barcode': 'PACK10',
})
cls.bom = cls.env['mrp.bom'].create({
'product_tmpl_id': cls.product.product_tmpl_id.id,
'product_qty': 1.0,
'packaging_id': cls.packaging.id,
'packaging_qty': 2.0,
})
def test_bom_packaging_calculation(self):
""" Test if BOM quantity is calculated correctly based on packaging """
# Trigger onchange manually
self.bom._onchange_packaging_qty()
# Logic: packaging_qty (2) * packaging.qty (10) = 20
self.assertEqual(self.bom.product_qty, 20.0, "BOM Quantity should be 2 * 10 = 20")
def test_mo_creation_packaging(self):
""" Test if MO created from BOM gets the packaging """
mo_form = self.env['mrp.production'].create({
'product_id': self.product.id,
'bom_id': self.bom.id,
'product_qty': 1.0, # Initial dummy value
})
# Simulate onchange
mo_form._onchange_bom_id()
self.assertEqual(mo_form.packaging_id, self.packaging, "MO should inherit packaging from BOM")
self.assertEqual(mo_form.packaging_qty, 2.0, "MO should inherit packaging qty from BOM")
# Trigger calculation
mo_form._onchange_packaging_qty()
self.assertEqual(mo_form.product_qty, 20.0, "MO product qty should be updated based on packaging")
def test_mps_packaging(self):
""" Test MPS view state with packaging """
if 'mrp.production.schedule' not in self.env:
return # Skip if mps not installed
mps = self.env['mrp.production.schedule'].create({
'product_id': self.product.id,
'warehouse_id': self.env['stock.warehouse'].search([], limit=1).id,
'packaging_id': self.packaging.id,
})
# Test get_view_state scaling
# Mock some forecast data
date_range = self.env.company._get_date_range()
date_start = date_range[0][0]
# Set forecast quantity in packaging units (e.g. 5 packs)
mps.set_forecast_qty(0, 5)
# Verify stored value is 5 * 10 = 50
forecast = mps.forecast_ids.filtered(lambda f: f.date >= date_start)[:1]
self.assertEqual(forecast.forecast_qty, 50.0, "Forecast qty should be stored as 5 * 10 = 50")
# Verify view state returns 5 (50 / 10)
view_state = mps.get_production_schedule_view_state()[0]
view_forecast = view_state['forecast_ids'][0]
self.assertEqual(view_forecast['forecast_qty'], 5.0, "View state should return 5.0 packs")

17
views/mrp_bom_views.xml Normal file
View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="mrp_bom_form_view_inherit_packaging" model="ir.ui.view">
<field name="name">mrp.bom.form.inherit.packaging</field>
<field name="model">mrp.bom</field>
<field name="inherit_id" ref="mrp.mrp_bom_form_view"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='product_qty']/.." position="after">
<label for="packaging_id" string="Packaging"/>
<div class="o_row">
<field name="packaging_qty"/>
<field name="packaging_id" context="{'default_product_tmpl_id': product_tmpl_id}"/>
</div>
</xpath>
</field>
</record>
</odoo>

28
views/mrp_mps_views.xml Normal file
View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- We cannot easily inherit the MPS client action view as it is rendered via JS -->
<!-- However, we can add the field to the tree view if it exists or form view -->
<record id="mrp_mps_production_schedule_form_view_inherit_packaging" model="ir.ui.view">
<field name="name">mrp.production.schedule.form.inherit.packaging</field>
<field name="model">mrp.production.schedule</field>
<field name="inherit_id" ref="mrp_mps.mrp_mps_production_schedule_form_view"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='warehouse_id']" position="after">
<field name="packaging_id"/>
</xpath>
</field>
</record>
<record id="mrp_mps_search_view_inherit_packaging" model="ir.ui.view">
<field name="name">mrp.production.schedule.search.inherit.packaging</field>
<field name="model">mrp.production.schedule</field>
<field name="inherit_id" ref="mrp_mps.mrp_mps_search_view"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='product_id']" position="after">
<field name="packaging_id"/>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="mrp_packaging_form_view" model="ir.ui.view">
<field name="name">mrp.packaging.form</field>
<field name="model">mrp.packaging</field>
<field name="arch" type="xml">
<form string="Packaging">
<sheet>
<group>
<group>
<field name="name"/>
<field name="product_tmpl_id"/>
</group>
<group>
<field name="qty"/>
<field name="barcode"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="mrp_packaging_tree_view" model="ir.ui.view">
<field name="name">mrp.packaging.list</field>
<field name="model">mrp.packaging</field>
<field name="arch" type="xml">
<list string="Packaging" editable="bottom">
<field name="name"/>
<field name="product_tmpl_id"/>
<field name="qty"/>
<field name="barcode"/>
</list>
</field>
</record>
</odoo>

View File

@ -5,9 +5,24 @@
<field name="model">mrp.production</field> <field name="model">mrp.production</field>
<field name="inherit_id" ref="mrp.mrp_production_form_view"/> <field name="inherit_id" ref="mrp.mrp_production_form_view"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//field[@name='product_qty']/.." position="after"> <xpath expr="//label[@for='product_qty']" position="attributes">
<field name="packaging_id"/> <attribute name="invisible">packaging_id</attribute>
<field name="packaging_qty"/> </xpath>
<xpath expr="//div[@name='qty']" position="attributes">
<attribute name="invisible">packaging_id</attribute>
</xpath>
<xpath expr="//div[@name='qty']" position="after">
<field name="packaging_id" placeholder="Packaging" invisible="packaging_id" readonly="state in ('done', 'cancel')"/>
<label for="packaging_qty" string="Quantity" invisible="not packaging_id"/>
<div class="o_row g-0 d-flex" name="qty_packaging" invisible="not packaging_id">
<div invisible="state == 'draft'" class="o_row flex-grow-1">
<field name="qty_producing_packaging" class="text-start text-truncate" readonly="state == 'cancel' or (state == 'done' and is_locked)" force_save="1"/>
/
</div>
<field name="packaging_qty" class="oe_inline text-start text-truncate" readonly="state != 'draft'" force_save="1" style="width:auto!important"/>
<field name="packaging_id" options="{'no_open': True, 'no_create': True}" readonly="state in ('done', 'cancel')" class="oe_inline" style="width:auto!important"/>
<span name="to_produce_pkg" class='fw-bold text-nowrap'>To Produce</span>
</div>
</xpath> </xpath>
</field> </field>
</record> </record>