diff --git a/README.md b/README.md
index 87f14a2..2249110 100644
--- a/README.md
+++ b/README.md
@@ -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.
- **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.
+- **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
diff --git a/__manifest__.py b/__manifest__.py
index d809af1..03516c8 100644
--- a/__manifest__.py
+++ b/__manifest__.py
@@ -8,11 +8,17 @@
It allows users to define the quantity to produce based on the selected packaging and its quantity.
""",
'author': 'Suherdy Yacob',
- 'depends': ['mrp', 'product'],
+ 'depends': ['mrp', 'product', 'mrp_mps'],
'data': [
+ 'security/ir.model.access.csv',
+ 'views/mrp_packaging_views.xml',
'views/mrp_production_views.xml',
+ 'views/mrp_bom_views.xml',
+ 'views/mrp_mps_views.xml',
],
+
'installable': True,
'application': False,
'license': 'LGPL-3',
}
+
diff --git a/models/__init__.py b/models/__init__.py
index a9e5f13..81dfa6d 100644
--- a/models/__init__.py
+++ b/models/__init__.py
@@ -1 +1,6 @@
+
from . import mrp_production
+from . import mrp_bom
+from . import mrp_production_schedule
+from . import mrp_packaging
+
diff --git a/models/mrp_bom.py b/models/mrp_bom.py
new file mode 100644
index 0000000..ad11c24
--- /dev/null
+++ b/models/mrp_bom.py
@@ -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
diff --git a/models/mrp_packaging.py b/models/mrp_packaging.py
new file mode 100644
index 0000000..b7ffd23
--- /dev/null
+++ b/models/mrp_packaging.py
@@ -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)
diff --git a/models/mrp_production.py b/models/mrp_production.py
index feb87c8..0d3b72a 100644
--- a/models/mrp_production.py
+++ b/models/mrp_production.py
@@ -3,10 +3,127 @@ from odoo import api, fields, models
class MrpProduction(models.Model):
_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):
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()
diff --git a/models/mrp_production_schedule.py b/models/mrp_production_schedule.py
new file mode 100644
index 0000000..841b0bc
--- /dev/null
+++ b/models/mrp_production_schedule.py
@@ -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)
diff --git a/security/ir.model.access.csv b/security/ir.model.access.csv
new file mode 100644
index 0000000..3513399
--- /dev/null
+++ b/security/ir.model.access.csv
@@ -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
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..f43d1cd
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_mrp_packaging_qty
diff --git a/tests/test_mrp_packaging_qty.py b/tests/test_mrp_packaging_qty.py
new file mode 100644
index 0000000..75e9fbd
--- /dev/null
+++ b/tests/test_mrp_packaging_qty.py
@@ -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")
diff --git a/views/mrp_bom_views.xml b/views/mrp_bom_views.xml
new file mode 100644
index 0000000..5bd35df
--- /dev/null
+++ b/views/mrp_bom_views.xml
@@ -0,0 +1,17 @@
+
+
+
+ mrp.bom.form.inherit.packaging
+ mrp.bom
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/views/mrp_mps_views.xml b/views/mrp_mps_views.xml
new file mode 100644
index 0000000..b4f5930
--- /dev/null
+++ b/views/mrp_mps_views.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+ mrp.production.schedule.form.inherit.packaging
+ mrp.production.schedule
+
+
+
+
+
+
+
+
+
+ mrp.production.schedule.search.inherit.packaging
+ mrp.production.schedule
+
+
+
+
+
+
+
+
+
diff --git a/views/mrp_packaging_views.xml b/views/mrp_packaging_views.xml
new file mode 100644
index 0000000..6e32699
--- /dev/null
+++ b/views/mrp_packaging_views.xml
@@ -0,0 +1,36 @@
+
+
+
+ mrp.packaging.form
+ mrp.packaging
+
+
+
+
+
+
+ mrp.packaging.list
+ mrp.packaging
+
+
+
+
+
+
+
+
+
+
diff --git a/views/mrp_production_views.xml b/views/mrp_production_views.xml
index e4ba039..24b9afc 100644
--- a/views/mrp_production_views.xml
+++ b/views/mrp_production_views.xml
@@ -5,9 +5,24 @@
mrp.production
-
-
-
+
+ packaging_id
+
+
+ packaging_id
+
+
+
+
+
+
+
+ /
+
+
+
+
To Produce
+