diff --git a/mrp_account_bom_attribute_match/README.rst b/mrp_account_bom_attribute_match/README.rst new file mode 100644 index 000000000..f86ee85e1 --- /dev/null +++ b/mrp_account_bom_attribute_match/README.rst @@ -0,0 +1 @@ +TO BE GENERATED diff --git a/mrp_account_bom_attribute_match/__init__.py b/mrp_account_bom_attribute_match/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/mrp_account_bom_attribute_match/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/mrp_account_bom_attribute_match/__manifest__.py b/mrp_account_bom_attribute_match/__manifest__.py new file mode 100644 index 000000000..0171062e6 --- /dev/null +++ b/mrp_account_bom_attribute_match/__manifest__.py @@ -0,0 +1,16 @@ +# Copyright 2023 Camptocamp SA (https://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "MRP Account BOM Attribute Match", + "summary": "Glue module between `mrp_account` and `mrp_bom_attribute_match`", + "version": "15.0.1.0.0", + "author": "Camptocamp, Odoo Community Association (OCA)", + "maintainers": ["ivantodorovich"], + "website": "https://github.com/OCA/manufacture", + "license": "AGPL-3", + "category": "Manufacturing", + "depends": ["mrp_account", "mrp_bom_attribute_match"], + "auto_install": True, +} diff --git a/mrp_account_bom_attribute_match/models/__init__.py b/mrp_account_bom_attribute_match/models/__init__.py new file mode 100644 index 000000000..5c74c8c30 --- /dev/null +++ b/mrp_account_bom_attribute_match/models/__init__.py @@ -0,0 +1 @@ +from . import product_product diff --git a/mrp_account_bom_attribute_match/models/product_product.py b/mrp_account_bom_attribute_match/models/product_product.py new file mode 100644 index 000000000..7cb219164 --- /dev/null +++ b/mrp_account_bom_attribute_match/models/product_product.py @@ -0,0 +1,34 @@ +# Copyright 2023 Camptocamp SA (https://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import Command, models + + +class ProductProduct(models.Model): + _inherit = "product.product" + + def _compute_bom_price(self, bom, boms_to_recompute=False, byproduct_bom=False): + # OVERRIDE to fill in the `line.product_id` if a component template is used. + # To avoid a complete override, we HACK the bom by replacing it with a virtual + # record, and modifying it's lines on-the-fly. + has_template_lines = bom and any( + line.component_template_id for line in bom.bom_line_ids + ) + if has_template_lines: + bom = bom.new(origin=bom) + to_ignore_line_ids = [] + for line in bom.bom_line_ids: + if line._skip_bom_line(self) or not line.component_template_id: + continue + line_product = bom._get_component_template_product( + line, self, line.product_id + ) + if not line_product: + to_ignore_line_ids.append(line.id) + continue + else: + line.product_id = line_product + if to_ignore_line_ids: + bom.bom_line_ids = [Command.unlink(id) for id in to_ignore_line_ids] + return super()._compute_bom_price(bom, boms_to_recompute, byproduct_bom) diff --git a/mrp_account_bom_attribute_match/tests/__init__.py b/mrp_account_bom_attribute_match/tests/__init__.py new file mode 100644 index 000000000..50455c9c1 --- /dev/null +++ b/mrp_account_bom_attribute_match/tests/__init__.py @@ -0,0 +1 @@ +from . import test_mrp_account_bom_attribute_match diff --git a/mrp_account_bom_attribute_match/tests/test_mrp_account_bom_attribute_match.py b/mrp_account_bom_attribute_match/tests/test_mrp_account_bom_attribute_match.py new file mode 100644 index 000000000..1aa9f7ffb --- /dev/null +++ b/mrp_account_bom_attribute_match/tests/test_mrp_account_bom_attribute_match.py @@ -0,0 +1,23 @@ +# Copyright 2023 Camptocamp SA (https://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.addons.mrp_bom_attribute_match.tests.common import ( + TestMrpBomAttributeMatchBase, +) + + +class TestMrpAccount(TestMrpBomAttributeMatchBase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + def test_bom_cost(self): + sword_cyan, sword_magenta = self.product_sword.product_variant_ids + plastic_cyan, plastic_magenta = self.product_plastic.product_variant_ids + plastic_cyan.standard_price = 1.00 + plastic_magenta.standard_price = 2.00 + sword_cyan.button_bom_cost() + sword_magenta.button_bom_cost() + self.assertEqual(sword_cyan.standard_price, 1.00) + self.assertEqual(sword_magenta.standard_price, 2.00) diff --git a/mrp_bom_attribute_match/__init__.py b/mrp_bom_attribute_match/__init__.py index 0650744f6..55ec7fc9a 100644 --- a/mrp_bom_attribute_match/__init__.py +++ b/mrp_bom_attribute_match/__init__.py @@ -1 +1,2 @@ from . import models +from . import reports diff --git a/mrp_bom_attribute_match/reports/__init__.py b/mrp_bom_attribute_match/reports/__init__.py new file mode 100644 index 000000000..d5f0e0470 --- /dev/null +++ b/mrp_bom_attribute_match/reports/__init__.py @@ -0,0 +1 @@ +from . import mrp_report_bom_structure diff --git a/mrp_bom_attribute_match/reports/mrp_report_bom_structure.py b/mrp_bom_attribute_match/reports/mrp_report_bom_structure.py new file mode 100644 index 000000000..3cb228911 --- /dev/null +++ b/mrp_bom_attribute_match/reports/mrp_report_bom_structure.py @@ -0,0 +1,44 @@ +# Copyright 2023 Camptocamp SA (https://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import Command, models + + +class ReportBomStructure(models.AbstractModel): + _inherit = "report.mrp.report_bom_structure" + + def _get_bom_lines(self, bom, bom_quantity, product, line_id, level): + # OVERRIDE to fill in the `line.product_id` if a component template is used. + # To avoid a complete override, we HACK the bom by replacing it with a virtual + # record, and modifying it's lines on-the-fly. + has_template_lines = any( + line.component_template_id for line in bom.bom_line_ids + ) + if has_template_lines: + bom = bom.new(origin=bom) + to_ignore_line_ids = [] + for line in bom.bom_line_ids: + if line._skip_bom_line(product) or not line.component_template_id: + continue + line_product = bom._get_component_template_product( + line, product, line.product_id + ) + if not line_product: + to_ignore_line_ids.append(line.id) + continue + else: + line.product_id = line_product + if to_ignore_line_ids: + bom.bom_line_ids = [Command.unlink(id) for id in to_ignore_line_ids] + components, total = super()._get_bom_lines( + bom, bom_quantity, product, line_id, level + ) + # Replace any NewId value by the real record id + # Otherwise it's evaluated as False in some situations, and it may cause issues + if has_template_lines: + for component in components: + for key, value in component.items(): + if isinstance(value, models.NewId): + component[key] = value.origin + return components, total diff --git a/mrp_bom_attribute_match/tests/common.py b/mrp_bom_attribute_match/tests/common.py index 82f747818..12ef736ef 100644 --- a/mrp_bom_attribute_match/tests/common.py +++ b/mrp_bom_attribute_match/tests/common.py @@ -1,134 +1,172 @@ +from odoo import Command +from odoo.models import BaseModel from odoo.tests import Form, TransactionCase -class TestMrpAttachmentMgmtBase(TransactionCase): +class TestMrpBomAttributeMatchBase(TransactionCase): @classmethod def setUpClass(cls): super().setUpClass() - cls._create_products(cls) - cls._create_boms(cls) - - def _create_products(self): - self.warehouse = self.env.ref("stock.warehouse0") - route_manufacture = self.warehouse.manufacture_pull_id.route_id.id - self.product_sword = self.env["product.template"].create( + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.warehouse = cls.env.ref("stock.warehouse0") + cls.route_manufacture = cls.warehouse.manufacture_pull_id.route_id + # Create products + cls.product_sword = cls.env["product.template"].create( { "name": "Plastic Sword", "type": "product", } ) - self.product_surf = self.env["product.template"].create( + cls.product_surf = cls.env["product.template"].create( { "name": "Surf", "type": "product", } ) - self.product_fin = self.env["product.template"].create( + cls.product_fin = cls.env["product.template"].create( { "name": "Surf Fin", "type": "product", } ) - self.product_plastic = self.env["product.template"].create( + cls.product_plastic = cls.env["product.template"].create( { "name": "Plastic Component", "type": "product", } ) - self.p1 = self.env["product.template"].create( + cls.p1 = cls.env["product.template"].create( { "name": "P1", "type": "product", - "route_ids": [(6, 0, [route_manufacture])], + "route_ids": [Command.link(cls.route_manufacture.id)], } ) - self.p2 = self.env["product.template"].create( + cls.p2 = cls.env["product.template"].create( { "name": "P2", "type": "product", - "route_ids": [(6, 0, [route_manufacture])], + "route_ids": [Command.link(cls.route_manufacture.id)], } ) - self.p3 = self.env["product.template"].create( + cls.p3 = cls.env["product.template"].create( { "name": "P3", "type": "product", - "route_ids": [(6, 0, [route_manufacture])], + "route_ids": [Command.link(cls.route_manufacture.id)], } ) - self.product_9 = self.env["product.product"].create( + cls.product_9 = cls.env["product.product"].create( { "name": "Paper", } ) - self.product_10 = self.env["product.product"].create( + cls.product_10 = cls.env["product.product"].create( { "name": "Stone", } ) - self.product_attribute = self.env["product.attribute"].create( + cls.product_attribute = cls.env["product.attribute"].create( {"name": "Colour", "display_type": "radio", "create_variant": "always"} ) - self.attribute_value_ids = self.env["product.attribute.value"].create( + cls.attribute_value_ids = cls.env["product.attribute.value"].create( [ - {"name": "Cyan", "attribute_id": self.product_attribute.id}, - {"name": "Magenta", "attribute_id": self.product_attribute.id}, + {"name": "Cyan", "attribute_id": cls.product_attribute.id}, + {"name": "Magenta", "attribute_id": cls.product_attribute.id}, ] ) - self.plastic_attrs = self.env["product.template.attribute.line"].create( + cls.plastic_attrs = cls.env["product.template.attribute.line"].create( { - "attribute_id": self.product_attribute.id, - "product_tmpl_id": self.product_plastic.id, - "value_ids": [(6, 0, self.product_attribute.value_ids.ids)], + "attribute_id": cls.product_attribute.id, + "product_tmpl_id": cls.product_plastic.id, + "value_ids": [Command.set(cls.product_attribute.value_ids.ids)], } ) - self.sword_attrs = self.env["product.template.attribute.line"].create( + cls.sword_attrs = cls.env["product.template.attribute.line"].create( { - "attribute_id": self.product_attribute.id, - "product_tmpl_id": self.product_sword.id, - "value_ids": [(6, 0, self.product_attribute.value_ids.ids)], + "attribute_id": cls.product_attribute.id, + "product_tmpl_id": cls.product_sword.id, + "value_ids": [Command.set(cls.product_attribute.value_ids.ids)], } ) + # Create boms + cls.bom_id = cls._create_bom( + cls.product_sword, + [ + dict( + component_template_id=cls.product_plastic.id, + product_qty=1, + ), + dict( + product_id=cls.product_9, + product_qty=1, + ), + ], + ) + cls.fin_bom_id = cls._create_bom( + cls.product_fin, + [ + dict( + product_id=cls.product_plastic.product_variant_ids[0], + product_qty=1, + ), + ], + ) + cls.surf_bom_id = cls._create_bom( + cls.product_surf, + [ + dict( + product_id=cls.product_fin.product_variant_ids[0], + product_qty=1, + ), + ], + ) + cls.p1_bom_id = cls._create_bom( + cls.p1, + [ + dict( + product_id=cls.p2.product_variant_ids[0], + product_qty=1, + ), + ], + ) + cls.p2_bom_id = cls._create_bom( + cls.p2, + [ + dict( + product_id=cls.p3.product_variant_ids[0], + product_qty=1, + ), + ], + ) + cls.p3_bom_id = cls._create_bom( + cls.p3, + [ + dict( + product_id=cls.p1.product_variant_ids[0], + product_qty=1, + ), + ], + ) - def _create_boms(self): - mrp_bom_form = Form(self.env["mrp.bom"]) - mrp_bom_form.product_tmpl_id = self.product_sword - with mrp_bom_form.bom_line_ids.new() as line_form: - line_form.component_template_id = self.product_plastic - line_form.product_qty = 1 - self.bom_id = mrp_bom_form.save() - - mrp_bom_form = Form(self.env["mrp.bom"]) - mrp_bom_form.product_tmpl_id = self.product_fin - with mrp_bom_form.bom_line_ids.new() as line_form: - line_form.product_id = self.product_plastic.product_variant_ids[0] - line_form.product_qty = 1 - self.fin_bom_id = mrp_bom_form.save() - - mrp_bom_form = Form(self.env["mrp.bom"]) - mrp_bom_form.product_tmpl_id = self.product_surf - with mrp_bom_form.bom_line_ids.new() as line_form: - line_form.product_id = self.product_fin.product_variant_ids[0] - line_form.product_qty = 1 - self.surf_bom_id = mrp_bom_form.save() - - mrp_bom_form = Form(self.env["mrp.bom"]) - mrp_bom_form.product_tmpl_id = self.p1 - with mrp_bom_form.bom_line_ids.new() as line_form: - line_form.product_id = self.p2.product_variant_ids[0] - line_form.product_qty = 1 - self.p1_bom_id = mrp_bom_form.save() - - mrp_bom_form = Form(self.env["mrp.bom"]) - mrp_bom_form.product_tmpl_id = self.p2 - with mrp_bom_form.bom_line_ids.new() as line_form: - line_form.product_id = self.p3.product_variant_ids[0] - line_form.product_qty = 1 - self.p2_bom_id = mrp_bom_form.save() - - mrp_bom_form = Form(self.env["mrp.bom"]) - mrp_bom_form.product_tmpl_id = self.p3 - with mrp_bom_form.bom_line_ids.new() as line_form: - line_form.product_id = self.p1.product_variant_ids[0] - line_form.product_qty = 1 - self.p3_bom_id = mrp_bom_form.save() + @classmethod + def _create_bom(cls, product, line_form_vals): + if product._name == "product.template": + template = product + product = cls.env["product.product"] + else: + template = product.product_tmpl_id + with Form(cls.env["mrp.bom"]) as form: + form.product_tmpl_id = template + form.product_id = product + for vals in line_form_vals: + with form.bom_line_ids.new() as line_form: + for key, value in vals.items(): + field = line_form._model._fields.get(key) + if field and field.relational: # pragma: no cover + if value and not isinstance(value, BaseModel): + value = cls.env[field.comodel_name].browse(value) + elif not value: + value = cls.env[field.comodel_name] + setattr(line_form, key, value) + return form.save() diff --git a/mrp_bom_attribute_match/tests/test_mrp_bom_attribute_match.py b/mrp_bom_attribute_match/tests/test_mrp_bom_attribute_match.py index e6b5b5083..90cbbca05 100644 --- a/mrp_bom_attribute_match/tests/test_mrp_bom_attribute_match.py +++ b/mrp_bom_attribute_match/tests/test_mrp_bom_attribute_match.py @@ -1,14 +1,10 @@ from odoo.exceptions import UserError, ValidationError from odoo.tests import Form -from .common import TestMrpAttachmentMgmtBase +from .common import TestMrpBomAttributeMatchBase -class TestMrpAttachmentMgmt(TestMrpAttachmentMgmtBase): - @classmethod - def setUpClass(cls): - super().setUpClass() - +class TestMrpBomAttributeMatch(TestMrpBomAttributeMatchBase): def test_bom_1(self): mrp_bom_form = Form(self.env["mrp.bom"]) mrp_bom_form.product_tmpl_id = self.product_sword @@ -71,18 +67,18 @@ class TestMrpAttachmentMgmt(TestMrpAttachmentMgmtBase): plastic_smells_like_orchid.unlink() def test_manufacturing_order_1(self): + sword_cyan = self.product_sword.product_variant_ids[0] + plastic_cyan = self.product_plastic.product_variant_ids[0] mo_form = Form(self.env["mrp.production"]) - mo_form.product_id = self.product_sword.product_variant_ids.filtered( - lambda x: x.display_name == "Plastic Sword (Cyan)" - ) + mo_form.product_id = sword_cyan mo_form.bom_id = self.bom_id mo_form.product_qty = 1 self.mo_sword = mo_form.save() self.mo_sword.action_confirm() # Assert correct component variant was selected automatically self.assertEqual( - self.mo_sword.move_raw_ids.product_id.display_name, - "Plastic Component (Cyan)", + self.mo_sword.move_raw_ids.product_id, + plastic_cyan + self.product_9, ) def test_manufacturing_order_2(self): @@ -172,3 +168,22 @@ class TestMrpAttachmentMgmt(TestMrpAttachmentMgmtBase): ) with self.assertRaisesRegex(UserError, r"Recursion error! .+"): test_bom_3.explode(self.product_9, 1) + + def test_mrp_report_bom_structure(self): + sword_cyan = self.product_sword.product_variant_ids[0] + BomStructureReport = self.env["report.mrp.report_bom_structure"] + res = BomStructureReport._get_report_data(self.bom_id.id) + self.assertTrue(res["is_variant_applied"]) + self.assertEqual(res["lines"]["product"], sword_cyan) + self.assertEqual( + res["lines"]["components"][0]["line_id"], + self.bom_id.bom_line_ids[0].id, + ) + self.assertEqual( + res["lines"]["components"][1]["line_id"], + self.bom_id.bom_line_ids[1].id, + ) + self.assertEqual( + res["lines"]["components"][0]["parent_id"], + self.bom_id.id, + ) diff --git a/setup/mrp_account_bom_attribute_match/odoo/addons/mrp_account_bom_attribute_match b/setup/mrp_account_bom_attribute_match/odoo/addons/mrp_account_bom_attribute_match new file mode 120000 index 000000000..cb758dc5c --- /dev/null +++ b/setup/mrp_account_bom_attribute_match/odoo/addons/mrp_account_bom_attribute_match @@ -0,0 +1 @@ +../../../../mrp_account_bom_attribute_match \ No newline at end of file diff --git a/setup/mrp_account_bom_attribute_match/setup.py b/setup/mrp_account_bom_attribute_match/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/mrp_account_bom_attribute_match/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)