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 + + + +