diff --git a/mrp_production_operation_injection/README.rst b/mrp_production_operation_injection/README.rst new file mode 100644 index 000000000..ccfc3f41a --- /dev/null +++ b/mrp_production_operation_injection/README.rst @@ -0,0 +1 @@ +To be auto generated diff --git a/mrp_production_operation_injection/__init__.py b/mrp_production_operation_injection/__init__.py new file mode 100644 index 000000000..9b4296142 --- /dev/null +++ b/mrp_production_operation_injection/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/mrp_production_operation_injection/__manifest__.py b/mrp_production_operation_injection/__manifest__.py new file mode 100644 index 000000000..ccf4c6511 --- /dev/null +++ b/mrp_production_operation_injection/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +{ + "name": "MRP Production Inject Operation", + "summary": "Adds an existing operation from the Bill of Material", + "version": "15.0.1.0.0", + "development_status": "Beta", + "category": "Manufacturing", + "website": "https://github.com/OCA/manufacture", + "author": "Camptocamp, Odoo Community Association (OCA)", + "maintainers": ["grindtildeath"], + "license": "AGPL-3", + "application": False, + "installable": True, + "depends": [ + "mrp_workorder_sequence", + ], + "data": [ + "security/ir.model.access.csv", + "views/mrp_production.xml", + "views/mrp_workorder.xml", + "wizard/mrp_workorder_injector.xml", + ], +} diff --git a/mrp_production_operation_injection/models/__init__.py b/mrp_production_operation_injection/models/__init__.py new file mode 100644 index 000000000..5a0ba17ab --- /dev/null +++ b/mrp_production_operation_injection/models/__init__.py @@ -0,0 +1,2 @@ +from . import mrp_production +from . import mrp_workorder diff --git a/mrp_production_operation_injection/models/mrp_production.py b/mrp_production_operation_injection/models/mrp_production.py new file mode 100644 index 000000000..f357bad12 --- /dev/null +++ b/mrp_production_operation_injection/models/mrp_production.py @@ -0,0 +1,72 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from odoo import api, fields, models + + +class MrpProduction(models.Model): + _inherit = "mrp.production" + + display_inject_workorder = fields.Boolean( + compute="_compute_display_inject_workorder" + ) + + @api.depends("state", "bom_id", "bom_id.operation_ids", "workorder_ids") + def _compute_display_inject_workorder(self): + for production in self: + production.display_inject_workorder = ( + production.state in ["confirmed", "progress", "to_close"] + and production.bom_id.operation_ids + and production.workorder_ids + ) + + def action_open_workorder_injector(self): + self.ensure_one() + action = self.env["ir.actions.act_window"]._for_xml_id( + "mrp_production_operation_injection.mrp_workorder_injector_action" + ) + ctx = self.env.context.copy() + ctx["default_production_id"] = self.id + action.update({"context": ctx}) + return action + + def _prepare_injected_workorder_values(self, operation): + self.ensure_one() + return { + "name": operation.name, + "production_id": self.id, + "workcenter_id": operation.workcenter_id.id, + "product_uom_id": self.product_uom_id.id, + "operation_id": operation.id, + "state": "pending", + } + + def _add_workorder(self, operation, previous_workorder): + self.ensure_one() + following_workorders = self.workorder_ids.filtered( + lambda w: w.sequence > previous_workorder.sequence + ) + next_workorder = fields.first(following_workorders) + # Prepare creation of new workorder + workorder_values = self._prepare_injected_workorder_values(operation) + workorder_values["sequence"] = previous_workorder.sequence + 1 + workorder_values["next_work_order_id"] = next_workorder.id + # FIXME: state computation is not good in Odoo anyway so handle + # only most 'probable' cases only + if next_workorder.state in ["ready", "progress"]: + workorder_values["state"] = "ready" + # Update following workorders sequence before create to make sure workorders + # can be ordered properly for _action_confirm (cf override in mrp_workorder) + for wo in following_workorders: + wo.sequence += 1 + new_workorder = self.env["mrp.workorder"].create(workorder_values) + # Update next workorder + # FIXME: state computation is not good in Odoo anyway so handle + # only most 'probable' cases only + if next_workorder.state == "ready": + next_workorder.state = "pending" + new_workorder.duration_expected = new_workorder._get_duration_expected() + # Replan if needed after cache invalidation to make sure all workorders are considered + self.invalidate_cache() + if self.is_planned: + self._plan_workorders(replan=True) + return True diff --git a/mrp_production_operation_injection/models/mrp_workorder.py b/mrp_production_operation_injection/models/mrp_workorder.py new file mode 100644 index 000000000..cd56610ce --- /dev/null +++ b/mrp_production_operation_injection/models/mrp_workorder.py @@ -0,0 +1,17 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from odoo import fields, models + + +class MrpWorkorder(models.Model): + + _inherit = "mrp.workorder" + + sequence = fields.Integer(readonly=True) + + def _action_confirm(self): + # HACK: Ensure self is ordered according to redefined _order attribute + # in mrp_sequence module as _action_confirm needs to loop in this + # order to redefine next_work_order_id properly + self = self.sorted() + return super()._action_confirm() diff --git a/mrp_production_operation_injection/readme/CONTRIBUTORS.rst b/mrp_production_operation_injection/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..e31e2f0c4 --- /dev/null +++ b/mrp_production_operation_injection/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Akim Juillerat diff --git a/mrp_production_operation_injection/readme/DESCRIPTION.rst b/mrp_production_operation_injection/readme/DESCRIPTION.rst new file mode 100644 index 000000000..027f93302 --- /dev/null +++ b/mrp_production_operation_injection/readme/DESCRIPTION.rst @@ -0,0 +1,2 @@ +This module provides a wizard to add extra workorders based on existing BOM +operations, and to select where such workorder should be added. diff --git a/mrp_production_operation_injection/security/ir.model.access.csv b/mrp_production_operation_injection/security/ir.model.access.csv new file mode 100644 index 000000000..86553a73a --- /dev/null +++ b/mrp_production_operation_injection/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 +mrp_production_operation_injection.access_mrp_workorder_injector,access_mrp_workorder_injector,mrp_production_operation_injection.model_mrp_workorder_injector,mrp.group_mrp_user,1,1,1,1 diff --git a/mrp_production_operation_injection/tests/__init__.py b/mrp_production_operation_injection/tests/__init__.py new file mode 100644 index 000000000..d29351eb8 --- /dev/null +++ b/mrp_production_operation_injection/tests/__init__.py @@ -0,0 +1 @@ +from . import test_mrp_production_inject_operation diff --git a/mrp_production_operation_injection/tests/test_mrp_production_inject_operation.py b/mrp_production_operation_injection/tests/test_mrp_production_inject_operation.py new file mode 100644 index 000000000..5fc7d37fb --- /dev/null +++ b/mrp_production_operation_injection/tests/test_mrp_production_inject_operation.py @@ -0,0 +1,262 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from odoo import fields +from odoo.tests.common import Form, TransactionCase + + +class TestMrpProductionInjectOperation(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.product_template_drawer = cls.env.ref( + "product.product_product_27_product_template" + ) + cls.product_drawer = cls.env.ref("product.product_product_27") + cls.bom_drawer = cls.env.ref("mrp.mrp_bom_drawer_rout") + cls.packing_operation = cls.env.ref("mrp.mrp_routing_workcenter_4") + cls.testing_operation = cls.env.ref("mrp.mrp_routing_workcenter_3") + cls.assembly_operation = cls.env.ref("mrp.mrp_routing_workcenter_1") + cls.assembly_line_workcenter = cls.env.ref("mrp.mrp_workcenter_3") + cls.productive_time = cls.env.ref("mrp.block_reason7") + + cls.color_attribute = cls.env.ref("product.product_attribute_2") + cls.color_att_value_white = cls.env.ref("product.product_attribute_value_3") + cls.color_att_value_black = cls.env.ref("product.product_attribute_value_4") + + @classmethod + def _define_color_attribute_on_drawer(cls): + """ + Redefine Drawer product to manage attributes by: + - Creating white product + - Defining color as attribute of drawer product with white and black values + - Adding white component to the BOM drawer applying only on selected variant + - Adding a painting operation to the BOM drawer consuming white product only + on selected variant + + """ + white_color_product_tmpl_form = Form(cls.env["product.template"]) + white_color_product_tmpl_form.name = "White color" + white_color_product_tmpl = white_color_product_tmpl_form.save() + + cls.env["product.template.attribute.line"].create( + { + "product_tmpl_id": cls.product_template_drawer.id, + "attribute_id": cls.color_attribute.id, + "value_ids": [ + fields.Command.set( + [cls.color_att_value_white.id, cls.color_att_value_black.id] + ) + ], + } + ) + + tmpl_attr_value_white = cls.env["product.template.attribute.value"].search( + [ + ("product_tmpl_id", "=", cls.product_template_drawer.id), + ("product_attribute_value_id", "=", cls.color_att_value_white.id), + ] + ) + tmpl_attr_value_black = cls.env["product.template.attribute.value"].search( + [ + ("product_tmpl_id", "=", cls.product_template_drawer.id), + ("product_attribute_value_id", "=", cls.color_att_value_black.id), + ] + ) + + cls.env["mrp.bom.line"].create( + { + "bom_id": cls.bom_drawer.id, + "product_id": white_color_product_tmpl.product_variant_id.id, + "bom_product_template_attribute_value_ids": [ + fields.Command.link(tmpl_attr_value_white.id) + ], + } + ) + + cls.env["mrp.routing.workcenter"].create( + { + "name": "Painting", + "bom_id": cls.bom_drawer.id, + "workcenter_id": cls.assembly_line_workcenter.id, + "bom_product_template_attribute_value_ids": [ + fields.Command.link(tmpl_attr_value_white.id) + ], + } + ) + + white_drawer = cls.product_template_drawer.product_variant_ids.filtered( + lambda p: p.product_template_variant_value_ids == tmpl_attr_value_white + ) + black_drawer = cls.product_template_drawer.product_variant_ids.filtered( + lambda p: p.product_template_variant_value_ids == tmpl_attr_value_black + ) + + return white_drawer, black_drawer + + @classmethod + def _create_manufacturing_order(cls, product, bom=None): + mo_form = Form(cls.env["mrp.production"]) + mo_form.product_id = product + if bom is not None: + mo_form.bom_id = bom + mo = mo_form.save() + return mo + + @classmethod + def _get_injector_wizard_form(cls, production): + action = production.action_open_workorder_injector() + injector_form = Form( + cls.env[action["res_model"]].with_context(**action.get("context", {})) + ) + return injector_form + + @classmethod + def _inject_operation(cls, production, new_operation, previous_workorder): + injector_form = cls._get_injector_wizard_form(production) + injector_form.operation_id = new_operation + injector_form.workorder_id = previous_workorder + injector_wiz = injector_form.save() + injector_wiz.action_add_operation() + + @classmethod + def _get_new_workorder(cls, previously_existing_wos, existing_wos): + return previously_existing_wos - existing_wos + + @classmethod + def _record_time_tracking(cls, workorder, duration, productivity): + workorder_form = Form(workorder) + with workorder_form.time_ids.new() as time_tracking_form: + time_tracking_form.date_end = fields.Datetime.add( + time_tracking_form.date_start, seconds=duration + ) + time_tracking_form.loss_id = productivity + + def test_injector_allowed_operations_no_variant(self): + """Test only operations from bom are allowed in wizard""" + mo = self._create_manufacturing_order(self.product_drawer, self.bom_drawer) + mo.action_confirm() + injector_form = self._get_injector_wizard_form(mo) + non_related_bom_operations = self.env["mrp.routing.workcenter"].search( + [("id", "not in", self.bom_drawer.operation_ids.ids)] + ) + self.assertTrue(non_related_bom_operations) + for op in non_related_bom_operations: + self.assertNotIn(op, injector_form.allowed_bom_operation_ids) + for op in mo.bom_id.operation_ids: + self.assertIn(op, injector_form.allowed_bom_operation_ids) + + def test_injector_allowed_operations_variant(self): + """Test only operations from bom are allowed in wizard""" + white_drawer, black_drawer = self._define_color_attribute_on_drawer() + mo = self._create_manufacturing_order(white_drawer, self.bom_drawer) + mo.action_confirm() + injector_form = self._get_injector_wizard_form(mo) + for op in mo.bom_id.operation_ids: + self.assertIn(op, injector_form.allowed_bom_operation_ids) + mo = self._create_manufacturing_order(black_drawer, self.bom_drawer) + mo.action_confirm() + injector_form = self._get_injector_wizard_form(mo) + for op in mo.bom_id.operation_ids: + if op.bom_product_template_attribute_value_ids: + self.assertEqual(op.name, "Painting") + self.assertNotIn(op, injector_form.allowed_bom_operation_ids) + else: + self.assertIn(op, injector_form.allowed_bom_operation_ids) + + def test_injector_allowed_workorders_no_variant(self): + """Test only workorders from manufacturing order are allowed in wizard""" + mo = self._create_manufacturing_order(self.product_drawer, self.bom_drawer) + mo.action_confirm() + injector_form = self._get_injector_wizard_form(mo) + non_mo_workorders = self.env["mrp.workorder"].search( + [("id", "not in", mo.workorder_ids.ids)] + ) + for wo in non_mo_workorders: + self.assertNotIn(wo, injector_form.production_workorder_ids) + for wo in mo.workorder_ids: + self.assertIn(wo, injector_form.production_workorder_ids) + # Ensure only last done workorder is selectable as previous operation + first_workorder = fields.first(mo.workorder_ids) + second_workorder = first_workorder.next_work_order_id + third_workorder = second_workorder.next_work_order_id + first_workorder.button_start() + first_workorder.button_finish() + second_workorder.button_start() + second_workorder.button_finish() + injector_form = self._get_injector_wizard_form(mo) + self.assertNotIn(first_workorder, injector_form.production_workorder_ids) + self.assertIn(second_workorder, injector_form.production_workorder_ids) + self.assertIn(third_workorder, injector_form.production_workorder_ids) + + def test_inject_operation(self): + mo = self._create_manufacturing_order(self.product_drawer, self.bom_drawer) + mo.action_confirm() + mo.button_plan() + first_workorder = fields.first(mo.workorder_ids) + second_workorder = first_workorder.next_work_order_id + third_workorder = second_workorder.next_work_order_id + # Inject extra testing operation at the end + self._inject_operation(mo, self.testing_operation, third_workorder) + self.assertEqual(len(mo.workorder_ids), 4) + last_workorder = fields.first(mo.workorder_ids.sorted(reverse=True)) + self.assertEqual(last_workorder.name, self.testing_operation.name) + self.assertEqual(last_workorder.operation_id, self.testing_operation) + self.assertEqual( + last_workorder.workcenter_id, self.testing_operation.workcenter_id + ) + self.assertEqual(last_workorder.state, "pending") + self.assertEqual(last_workorder.sequence, 4) + self.assertEqual( + last_workorder.date_planned_start, third_workorder.date_planned_finished + ) + self.assertEqual( + last_workorder.date_planned_finished, + last_workorder.workcenter_id.resource_calendar_id.plan_hours( + last_workorder.duration_expected / 60.0, + last_workorder.date_planned_start, + compute_leaves=True, + domain=[("time_type", "in", ["leave", "other"])], + ), + ) + self.assertEqual(third_workorder.next_work_order_id, last_workorder) + # Start first op and register time tracking + first_workorder.button_start() + self._record_time_tracking(first_workorder, 60, self.productive_time) + first_workorder.button_finish() + self.assertEqual(first_workorder.state, "done") + self.assertEqual(second_workorder.state, "ready") + # Inject extra packing operation before second workorder + pre_existing_wo_ids = set(mo.workorder_ids.ids) + self._inject_operation(mo, self.packing_operation, first_workorder) + existing_wo_ids = set(mo.workorder_ids.ids) + new_workorder = self.env["mrp.workorder"].browse( + existing_wo_ids - pre_existing_wo_ids + ) + self.assertEqual(new_workorder.state, "ready") + self.assertEqual(new_workorder.sequence, 2) + self.assertEqual( + new_workorder.date_planned_start, + new_workorder.workcenter_id.resource_calendar_id.plan_hours( + -new_workorder.duration_expected / 60.0, + new_workorder.date_planned_finished, + compute_leaves=True, + domain=[("time_type", "in", ["leave", "other"])], + ), + ) + self.assertEqual( + new_workorder.date_planned_finished, second_workorder.date_planned_start + ) + self.assertEqual(new_workorder.next_work_order_id, second_workorder) + # Second workorder is now the third one + self.assertEqual(second_workorder.state, "pending") + self.assertEqual(second_workorder.sequence, 3) + self.assertEqual(second_workorder.next_work_order_id, third_workorder) + # Third workorder is now the fourth one + self.assertEqual(third_workorder.state, "pending") + self.assertEqual(third_workorder.sequence, 4) + self.assertEqual(third_workorder.next_work_order_id, last_workorder) + # Last workorder is still the last one + self.assertEqual(last_workorder.state, "pending") + self.assertEqual(last_workorder.sequence, 5) + self.assertFalse(last_workorder.next_work_order_id) diff --git a/mrp_production_operation_injection/views/mrp_production.xml b/mrp_production_operation_injection/views/mrp_production.xml new file mode 100644 index 000000000..3edfcbf20 --- /dev/null +++ b/mrp_production_operation_injection/views/mrp_production.xml @@ -0,0 +1,19 @@ + + + + mrp.production.form.inherit + mrp.production + + +
+ +
+
+
+
diff --git a/mrp_production_operation_injection/views/mrp_workorder.xml b/mrp_production_operation_injection/views/mrp_workorder.xml new file mode 100644 index 000000000..7a3700b93 --- /dev/null +++ b/mrp_production_operation_injection/views/mrp_workorder.xml @@ -0,0 +1,16 @@ + + + + mrp.workorder.tree + mrp.workorder + + + + 1 + + + + diff --git a/mrp_production_operation_injection/wizard/__init__.py b/mrp_production_operation_injection/wizard/__init__.py new file mode 100644 index 000000000..f30517b90 --- /dev/null +++ b/mrp_production_operation_injection/wizard/__init__.py @@ -0,0 +1 @@ +from . import mrp_workorder_injector diff --git a/mrp_production_operation_injection/wizard/mrp_workorder_injector.py b/mrp_production_operation_injection/wizard/mrp_workorder_injector.py new file mode 100644 index 000000000..e848b0cc4 --- /dev/null +++ b/mrp_production_operation_injection/wizard/mrp_workorder_injector.py @@ -0,0 +1,67 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo import api, fields, models + + +class MrpWorkorderInjector(models.TransientModel): + + _name = "mrp.workorder.injector" + _description = "Inject operation from BOM into workorders" + + production_id = fields.Many2one("mrp.production", required=True) + bom_id = fields.Many2one("mrp.bom", related="production_id.bom_id") + allowed_bom_operation_ids = fields.Many2many( + "mrp.routing.workcenter", compute="_compute_allowed_bom_operation_ids" + ) + production_workorder_ids = fields.Many2many( + "mrp.workorder", compute="_compute_production_workorder_ids" + ) + operation_id = fields.Many2one( + "mrp.routing.workcenter", "New operation", required=True + ) + workorder_id = fields.Many2one("mrp.workorder", "Previous workorder", required=True) + + @api.depends("bom_id", "bom_id.operation_ids") + def _compute_allowed_bom_operation_ids(self): + for wiz in self: + bom_operations = wiz.bom_id.operation_ids + # TODO: Move this check in default_get or somewhere else? + if not wiz.bom_id or not bom_operations: + wiz.allowed_bom_operation_ids = [fields.Command.clear()] + continue + # Filter out operations applying only for other variants + allowed_operations = bom_operations.filtered( + lambda o: not o._skip_operation_line(wiz.production_id.product_id) + ) + # TODO: filter out operations consuming components? + # AFAICS the link from bom line to operations will only be used to define + # on the stock move in which workorder such component is supposed to be + # consumed, and will then be used to compute the state of the workorder + # through the reservation_state field of the MO: + # - Waiting components if move is not assigned + # - Ready if move is assigned + wiz.allowed_bom_operation_ids = [fields.Command.set(allowed_operations.ids)] + + @api.depends("production_id") + def _compute_production_workorder_ids(self): + for wiz in self: + prod_workorders = wiz.production_id.workorder_ids + if not prod_workorders: + wiz.production_workorder_ids = [fields.Command.clear()] + continue + done_wos = prod_workorders.filtered(lambda w: w.state == "done") + if not done_wos or len(done_wos) == 1: + wiz.production_workorder_ids = [fields.Command.set(prod_workorders.ids)] + continue + # Only allow to add new operation after last Done workorder + last_done_wo = fields.first(done_wos.sorted(reverse=True)) + allowed_wos = last_done_wo + prod_workorders.filtered( + lambda w: w.state != "done" + ) + wiz.production_workorder_ids = allowed_wos + + def action_add_operation(self): + self.ensure_one() + self.production_id._add_workorder(self.operation_id, self.workorder_id) + return {"type": "ir.actions.act_window_close"} diff --git a/mrp_production_operation_injection/wizard/mrp_workorder_injector.xml b/mrp_production_operation_injection/wizard/mrp_workorder_injector.xml new file mode 100644 index 000000000..7568b83bf --- /dev/null +++ b/mrp_production_operation_injection/wizard/mrp_workorder_injector.xml @@ -0,0 +1,42 @@ + + + + mrp.workorder.injector.form.view + mrp.workorder.injector + +
+ + + + + + + + +
+
+
+
+
+ + Inject operation + mrp.workorder.injector + form + new + +
diff --git a/setup/mrp_production_operation_injection/odoo/addons/mrp_production_operation_injection b/setup/mrp_production_operation_injection/odoo/addons/mrp_production_operation_injection new file mode 120000 index 000000000..792e0aea4 --- /dev/null +++ b/setup/mrp_production_operation_injection/odoo/addons/mrp_production_operation_injection @@ -0,0 +1 @@ +../../../../mrp_production_operation_injection \ No newline at end of file diff --git a/setup/mrp_production_operation_injection/setup.py b/setup/mrp_production_operation_injection/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/mrp_production_operation_injection/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)