From fe864284d7e645739d34a26fd65581287b67c017 Mon Sep 17 00:00:00 2001 From: Ilyas Date: Wed, 4 Oct 2023 16:19:35 +0200 Subject: [PATCH 1/2] [FIX] mrp_bom_attribute_match: BOM Structure and cost nested --- .../reports/mrp_report_bom_structure.py | 67 ++++++- mrp_bom_attribute_match/tests/__init__.py | 1 + mrp_bom_attribute_match/tests/common.py | 185 +++++++++++------- .../tests/test_mrp_bom_attribute_match.py | 11 +- .../test_mrp_bom_attribute_match_nested.py | 87 ++++++++ 5 files changed, 270 insertions(+), 81 deletions(-) create mode 100644 mrp_bom_attribute_match/tests/test_mrp_bom_attribute_match_nested.py diff --git a/mrp_bom_attribute_match/reports/mrp_report_bom_structure.py b/mrp_bom_attribute_match/reports/mrp_report_bom_structure.py index 4574d1d3c..463c0ed15 100644 --- a/mrp_bom_attribute_match/reports/mrp_report_bom_structure.py +++ b/mrp_bom_attribute_match/reports/mrp_report_bom_structure.py @@ -1,8 +1,8 @@ # 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 models +from odoo.tools import float_round class ReportBomStructure(models.AbstractModel): @@ -32,6 +32,7 @@ class ReportBomStructure(models.AbstractModel): if to_ignore_line_ids: for to_ignore_line_id in to_ignore_line_ids: bom.bom_line_ids = [(3, to_ignore_line_id, 0)] + product = bom._get_component_template_product(line, product, line.product_id) components, total = super()._get_bom_lines( bom, bom_quantity, product, line_id, level ) @@ -43,3 +44,67 @@ class ReportBomStructure(models.AbstractModel): if isinstance(value, models.NewId): component[key] = value.origin return components, total + + def _get_price(self, bom, factor, product): + """Replaced in order to implement component_template logic""" + price = 0 + if bom.operation_ids: + # routing are defined on a BoM and don't have a concept of quantity. + # It means that the operation time are defined for the quantity on + # the BoM (the user produces a batch of products). E.g the user + # product a batch of 10 units with a 5 minutes operation, the time + # will be the 5 for a quantity between 1-10, then doubled for + # 11-20,... + operation_cycle = float_round( + factor, precision_rounding=1, rounding_method="UP" + ) + operations = self._get_operation_line(bom, operation_cycle, 0) + price += sum([op["total"] for op in operations]) + + for line in bom.bom_line_ids: + if line._skip_bom_line(product): + continue + if line.child_bom_id: + qty = line.product_uom_id._compute_quantity( + line.product_qty * (factor / bom.product_qty), + line.child_bom_id.product_uom_id, + ) + sub_price = self._get_price(line.child_bom_id, qty, line.product_id) + price += sub_price + else: + prod_qty = line.product_qty * factor / bom.product_qty + company = bom.company_id or self.env.company + if line.component_template_id: + vals = product.product_template_attribute_value_ids.mapped( + "product_attribute_value_id" + ).ids + match_found = False + for prod in line.component_template_id.product_variant_ids: + pavs = prod.product_template_attribute_value_ids.mapped( + "product_attribute_value_id" + ) + match = set(pavs.ids).issubset(set(vals)) + if match: + match_found = True + break + if match_found: + not_rounded_price = ( + prod.uom_id._compute_price( + prod.with_company(company).standard_price, + line.product_uom_id, + ) + * prod_qty + ) + price += company.currency_id.round(not_rounded_price) + else: + continue + else: + not_rounded_price = ( + line.product_id.uom_id._compute_price( + line.product_id.with_company(company).standard_price, + line.product_uom_id, + ) + * prod_qty + ) + price += company.currency_id.round(not_rounded_price) + return price diff --git a/mrp_bom_attribute_match/tests/__init__.py b/mrp_bom_attribute_match/tests/__init__.py index 9b6b0f074..95a3f2121 100644 --- a/mrp_bom_attribute_match/tests/__init__.py +++ b/mrp_bom_attribute_match/tests/__init__.py @@ -1 +1,2 @@ from . import test_mrp_bom_attribute_match +from . import test_mrp_bom_attribute_match_nested diff --git a/mrp_bom_attribute_match/tests/common.py b/mrp_bom_attribute_match/tests/common.py index 5307b8e18..eb32bd8e0 100644 --- a/mrp_bom_attribute_match/tests/common.py +++ b/mrp_bom_attribute_match/tests/common.py @@ -1,134 +1,171 @@ -from odoo.tests import Form, common +from odoo.models import BaseModel +from odoo.tests import Form, SavepointCase -class TestMrpAttachmentMgmtBase(common.SavepointCase): +class TestMrpBomAttributeMatchBase(SavepointCase): @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": [(6, 0, cls.route_manufacture.ids)], } ) - 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": [(6, 0, cls.route_manufacture.ids)], } ) - 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": [(6, 0, cls.route_manufacture.ids)], } ) - 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": [(6, 0, 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": [(6, 0, 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 62a30adb1..141881e82 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,10 +1,10 @@ from odoo.exceptions import UserError, ValidationError from odoo.tests import Form -from .common import TestMrpAttachmentMgmtBase +from .common import TestMrpBomAttributeMatchBase -class TestMrpAttachmentMgmt(TestMrpAttachmentMgmtBase): +class TestMrpAttachmentMgmt(TestMrpBomAttributeMatchBase): @classmethod def setUpClass(cls): super().setUpClass() @@ -67,7 +67,7 @@ class TestMrpAttachmentMgmt(TestMrpAttachmentMgmtBase): self.mo_sword.action_confirm() # Assert correct component variant was selected automatically self.assertEqual( - self.mo_sword.move_raw_ids.product_id.display_name, + self.mo_sword.move_raw_ids.product_id[0].display_name, "Plastic Component (Cyan)", ) @@ -81,9 +81,8 @@ class TestMrpAttachmentMgmt(TestMrpAttachmentMgmtBase): mo_form.bom_id = self.bom_id mo_form.product_qty = 1 self.mo_sword = mo_form.save() - with self.assertRaises(UserError): - # Add some materials to consume before marking this MO as to do. - self.mo_sword.action_confirm() + # Add some materials to consume before marking this MO as to do. + self.mo_sword.action_confirm() def test_manufacturing_order_3(self): # Delete attribute from sword diff --git a/mrp_bom_attribute_match/tests/test_mrp_bom_attribute_match_nested.py b/mrp_bom_attribute_match/tests/test_mrp_bom_attribute_match_nested.py new file mode 100644 index 000000000..7865fb5d4 --- /dev/null +++ b/mrp_bom_attribute_match/tests/test_mrp_bom_attribute_match_nested.py @@ -0,0 +1,87 @@ +from .common import TestMrpBomAttributeMatchBase + + +class TestMrpBomAttributeMatchNested(TestMrpBomAttributeMatchBase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + + def setUp(self): + super().setUp() + attr1 = self.env["product.attribute"].create( + {"name": "style", "display_type": "radio", "create_variant": "always"} + ) + a1vs = self.env["product.attribute.value"].create( + [ + {"name": "office", "attribute_id": attr1.id}, + {"name": "gaming", "attribute_id": attr1.id}, + ] + ) + + top = self.env["product.template"].create( + { + "name": "Top-Level", + "type": "product", + } + ) + self.env["product.template.attribute.line"].create( + { + "attribute_id": attr1.id, + "product_tmpl_id": top.id, + "value_ids": [(6, 0, a1vs.ids)], + } + ) + + sub = self.env["product.template"].create( + { + "name": "Sub-Level", + "type": "product", + } + ) + self.env["product.template.attribute.line"].create( + { + "attribute_id": attr1.id, + "product_tmpl_id": sub.id, + "value_ids": [(6, 0, a1vs.ids)], + } + ) + + subsub = self.env["product.template"].create( + { + "name": "Sub Sub 1", + "type": "product", + } + ) + self.env["product.template.attribute.line"].create( + { + "attribute_id": attr1.id, + "product_tmpl_id": subsub.id, + "value_ids": [(6, 0, a1vs.ids)], + } + ) + + self.bom_sub = self._create_bom( + sub, + [ + dict( + component_template_id=subsub.id, + product_qty=1, + ), + ], + ) + self.bom_top = self._create_bom( + top, + [ + dict( + component_template_id=sub.id, + product_qty=1, + ), + ], + ) + + def test_nested(self): + BomStructureReport = self.env["report.mrp.report_bom_structure"] + + BomStructureReport._get_report_data(self.bom_sub.id) + BomStructureReport._get_report_data(self.bom_top.id) From 8efface66a9f320d86589f78712ddd78072fa934 Mon Sep 17 00:00:00 2001 From: Ilyas Date: Mon, 23 Oct 2023 12:46:40 +0200 Subject: [PATCH 2/2] [IMP] mrp_bom_attribute_match: added demo data --- mrp_bom_attribute_match/__manifest__.py | 1 + .../demo/product_product_demo.xml | 197 ++++++++++++++++++ .../reports/mrp_report_bom_structure.py | 6 +- 3 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 mrp_bom_attribute_match/demo/product_product_demo.xml diff --git a/mrp_bom_attribute_match/__manifest__.py b/mrp_bom_attribute_match/__manifest__.py index 2b14def26..280cfde48 100644 --- a/mrp_bom_attribute_match/__manifest__.py +++ b/mrp_bom_attribute_match/__manifest__.py @@ -11,4 +11,5 @@ "data": [ "views/mrp_bom_views.xml", ], + "demo": ["demo/product_product_demo.xml"], } diff --git a/mrp_bom_attribute_match/demo/product_product_demo.xml b/mrp_bom_attribute_match/demo/product_product_demo.xml new file mode 100644 index 000000000..522972ce5 --- /dev/null +++ b/mrp_bom_attribute_match/demo/product_product_demo.xml @@ -0,0 +1,197 @@ + + + + + + + Top Level + product + + + + + + + + Sub Level + product + + + + + + + + Sub Sub + product + + + + + + + + Sub Sub 2 + product + + + + + + + + + attr1 + 10 + + + office + + 1 + + + gaming + + 1 + + + + attr2 + 10 + + + v1 + + 1 + + + v2 + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + 1 + + + + + + + + + + 1 + + + + + diff --git a/mrp_bom_attribute_match/reports/mrp_report_bom_structure.py b/mrp_bom_attribute_match/reports/mrp_report_bom_structure.py index 463c0ed15..7861e86ec 100644 --- a/mrp_bom_attribute_match/reports/mrp_report_bom_structure.py +++ b/mrp_bom_attribute_match/reports/mrp_report_bom_structure.py @@ -32,7 +32,9 @@ class ReportBomStructure(models.AbstractModel): if to_ignore_line_ids: for to_ignore_line_id in to_ignore_line_ids: bom.bom_line_ids = [(3, to_ignore_line_id, 0)] - product = bom._get_component_template_product(line, product, line.product_id) + product = bom._get_component_template_product( + line, product, line.product_id + ) components, total = super()._get_bom_lines( bom, bom_quantity, product, line_id, level ) @@ -74,6 +76,7 @@ class ReportBomStructure(models.AbstractModel): else: prod_qty = line.product_qty * factor / bom.product_qty company = bom.company_id or self.env.company + # Modification start if line.component_template_id: vals = product.product_template_attribute_value_ids.mapped( "product_attribute_value_id" @@ -98,6 +101,7 @@ class ReportBomStructure(models.AbstractModel): price += company.currency_id.round(not_rounded_price) else: continue + # Modification end else: not_rounded_price = ( line.product_id.uom_id._compute_price(