diff --git a/mrp_production_auto_validate/models/__init__.py b/mrp_production_auto_validate/models/__init__.py index 15d4e50ea..47c076dd6 100644 --- a/mrp_production_auto_validate/models/__init__.py +++ b/mrp_production_auto_validate/models/__init__.py @@ -1,3 +1,4 @@ from . import mrp_bom from . import mrp_production from . import stock_picking +from . import stock_rule diff --git a/mrp_production_auto_validate/models/mrp_production.py b/mrp_production_auto_validate/models/mrp_production.py index 51c0c843a..7e46ae83b 100644 --- a/mrp_production_auto_validate/models/mrp_production.py +++ b/mrp_production_auto_validate/models/mrp_production.py @@ -21,6 +21,7 @@ class MrpProduction(models.Model): @api.constrains("bom_id", "auto_validate", "product_qty") def check_bom_auto_validate(self): for mo in self: + # FIXME: Handle different UOM between BOM and MO qty_ok = ( tools.float_compare( mo.product_qty, @@ -91,3 +92,101 @@ class MrpProduction(models.Model): ) wiz = wiz_model.create({}) return wiz.action_backorder() + + @api.model_create_multi + def create(self, values_list): + new_values_list, messages_to_post = self.adapt_values_qty_for_auto_validation( + values_list + ) + res = super().create(new_values_list) + if messages_to_post: + for pos, msg in messages_to_post.items(): + prod = res[pos] + prod.message_post(body=msg) + return res + + @api.model + def adapt_values_qty_for_auto_validation(self, values_list): + """Adapt create values according to qty with auto validated BOM + + If MOs are to be created with a BOM having auto validation, we must ensure + the quantity of the MO is equal to the quantity of the BOM. + However when MOs are created through procurements, the requested quantity + is based on the procurement quantity, so we should either + * increase the quantity to match the BOM if procurement value is lower + * split the values to create one MO into multiple values to create multiple + MOs matching the BOM quantity if procurement value is bigger + """ + messages_to_post = {} + if not self.env.context.get("_split_create_values_for_auto_validation"): + return values_list, messages_to_post + new_values_list = [] + for values in values_list: + bom_id = values.get("bom_id") + if not bom_id: + new_values_list.append(values) + continue + bom = self.env["mrp.bom"].browse(bom_id) + if not bom.mo_auto_validation: + new_values_list.append(values) + continue + create_qty = values.get("product_qty") + create_uom = self.env["uom.uom"].browse(values.get("product_uom_id")) + bom_qty = bom.product_qty + bom_uom = bom.product_uom_id + if create_uom != bom_uom: + create_qty = create_uom._compute_quantity(create_qty, bom_uom) + if ( + tools.float_compare( + create_qty, bom_qty, precision_rounding=bom_uom.rounding + ) + == 0 + ): + new_values_list.append(values) + continue + elif ( + tools.float_compare( + create_qty, bom_qty, precision_rounding=bom_uom.rounding + ) + < 0 + ): + procure_qty = values.get("product_qty") + values["product_qty"] = bom_qty + values["product_uom_id"] = bom_uom.id + msg = _( + "Quantity in procurement (%s %s) was increased to %s %s due to auto " + "validation feature preventing to create an MO with a different " + "qty than defined on the BOM." + ) % ( + procure_qty, + create_uom.display_name, + bom_qty, + bom_uom.display_name, + ) + messages_to_post[len(new_values_list)] = msg + new_values_list.append(values) + continue + # If we get here we need to split the prepared MO values + # into multiple MO values to respect BOM qty + while ( + tools.float_compare(create_qty, 0, precision_rounding=bom_uom.rounding) + > 0 + ): + new_values = values.copy() + new_values["product_qty"] = bom_qty + new_values["product_uom_id"] = bom_uom.id + msg = _( + "Quantity in procurement (%s %s) was split to multiple production " + "orders of %s %s due to auto validation feature preventing to " + "set a quantity to produce different than the quantity defined " + "on the Bill of Materials." + ) % ( + values.get("product_qty"), + create_uom.display_name, + bom_qty, + bom_uom.display_name, + ) + messages_to_post[len(new_values_list)] = msg + new_values_list.append(new_values) + create_qty -= bom_qty + return new_values_list, messages_to_post diff --git a/mrp_production_auto_validate/models/stock_rule.py b/mrp_production_auto_validate/models/stock_rule.py new file mode 100644 index 000000000..b59044910 --- /dev/null +++ b/mrp_production_auto_validate/models/stock_rule.py @@ -0,0 +1,13 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from odoo import api, models + + +class StockRule(models.Model): + _inherit = "stock.rule" + + @api.model + def _run_manufacture(self, procurements): + return super( + StockRule, self.with_context(_split_create_values_for_auto_validation=True) + )._run_manufacture(procurements) diff --git a/mrp_production_auto_validate/tests/test_auto_validate.py b/mrp_production_auto_validate/tests/test_auto_validate.py index 05a543acd..1a59dc126 100644 --- a/mrp_production_auto_validate/tests/test_auto_validate.py +++ b/mrp_production_auto_validate/tests/test_auto_validate.py @@ -14,10 +14,33 @@ class TestManufacturingOrderAutoValidate(SavepointCase): # "pick components" transfer operation cls.wh = cls.env.ref("stock.warehouse0") cls.wh.manufacture_steps = "pbm" + # Configure the product to be replenished through manufacture route + cls.product_template = cls.env.ref( + "mrp.product_product_computer_desk_head_product_template" + ) + cls.manufacture_route = cls.env.ref("mrp.route_warehouse0_manufacture") + cls.product_template.route_ids = [(6, 0, [cls.manufacture_route.id])] # Configure the BoM to auto-validate manufacturing orders # NOTE: to ease tests we take a BoM with only one component cls.bom = cls.env.ref("mrp.mrp_bom_table_top") # Tracked by S/N cls.bom.mo_auto_validation = True + cls.uom_unit = cls.env.ref("uom.product_uom_unit") + + @classmethod + def _replenish_product(cls, product, product_qty=1, product_uom=None): + if product_uom is None: + product_uom = cls.uom_unit + wiz = ( + cls.env["product.replenish"] + .with_context(default_product_id=product.id) + .create( + { + "quantity": product_qty, + "product_uom_id": product_uom.id, + } + ) + ) + wiz.launch_replenishment() @classmethod def _create_manufacturing_order(cls, bom, product_qty=1): @@ -139,3 +162,36 @@ class TestManufacturingOrderAutoValidate(SavepointCase): self.assertFalse(order_done) self.assertEqual(order.state, "confirmed") self.assertEqual(order.product_qty, 2) + + def test_keep_qty_on_replenishment(self): + existing_mos = self.env["mrp.production"].search([]) + self._replenish_product(self.product_template.product_variant_id, product_qty=1) + created_mos = self.env["mrp.production"].search( + [("id", "not in", existing_mos.ids)] + ) + self.assertEqual(len(created_mos), 1) + self.assertEqual(created_mos.product_qty, self.bom.product_qty) + self.assertFalse(any("split" in m.body for m in created_mos.message_ids)) + self.assertFalse(any("increased" in m.body for m in created_mos.message_ids)) + + def test_split_qty_on_replenishment(self): + existing_mos = self.env["mrp.production"].search([]) + self._replenish_product(self.product_template.product_variant_id, product_qty=3) + created_mos = self.env["mrp.production"].search( + [("id", "not in", existing_mos.ids)] + ) + self.assertEqual(len(created_mos), 3) + for mo in created_mos: + self.assertEqual(mo.product_qty, self.bom.product_qty) + self.assertTrue(any("split" in m.body for m in mo.message_ids)) + + def test_raise_qty_on_replenishment(self): + existing_mos = self.env["mrp.production"].search([]) + self.bom.product_qty = 5 + self._replenish_product(self.product_template.product_variant_id, product_qty=3) + created_mos = self.env["mrp.production"].search( + [("id", "not in", existing_mos.ids)] + ) + self.assertEqual(len(created_mos), 1) + self.assertEqual(created_mos.product_qty, self.bom.product_qty) + self.assertTrue(any("increased" in m.body for m in created_mos.message_ids))