From d428d673bc8005d588a1bc4f3712466d1f3745ec Mon Sep 17 00:00:00 2001 From: Marc Durepos Date: Wed, 10 Sep 2025 13:13:28 -0400 Subject: [PATCH] [MIG] improved_mo_origin to 18.0 --- improved_mo_origin/__init__.py | 1 + improved_mo_origin/__manifest__.py | 38 +++++ improved_mo_origin/models/__init__.py | 1 + improved_mo_origin/models/mrp_production.py | 131 +++++++++++++++ .../static/src/mrp_display/mrp_display.esm.js | 25 +++ .../src/mrp_display/mrp_display_action.esm.js | 14 ++ .../src/mrp_display/mrp_display_record.esm.js | 9 ++ .../src/mrp_display/mrp_display_record.xml | 30 ++++ improved_mo_origin/tests/__init__.py | 1 + .../tests/test_mrp_production.py | 151 ++++++++++++++++++ .../views/mrp_production_views.xml | 25 +++ 11 files changed, 426 insertions(+) create mode 100644 improved_mo_origin/__init__.py create mode 100644 improved_mo_origin/__manifest__.py create mode 100644 improved_mo_origin/models/__init__.py create mode 100644 improved_mo_origin/models/mrp_production.py create mode 100644 improved_mo_origin/static/src/mrp_display/mrp_display.esm.js create mode 100644 improved_mo_origin/static/src/mrp_display/mrp_display_action.esm.js create mode 100644 improved_mo_origin/static/src/mrp_display/mrp_display_record.esm.js create mode 100644 improved_mo_origin/static/src/mrp_display/mrp_display_record.xml create mode 100644 improved_mo_origin/tests/__init__.py create mode 100644 improved_mo_origin/tests/test_mrp_production.py create mode 100644 improved_mo_origin/views/mrp_production_views.xml diff --git a/improved_mo_origin/__init__.py b/improved_mo_origin/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/improved_mo_origin/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/improved_mo_origin/__manifest__.py b/improved_mo_origin/__manifest__.py new file mode 100644 index 0000000..162b51f --- /dev/null +++ b/improved_mo_origin/__manifest__.py @@ -0,0 +1,38 @@ +# +# Bemade Inc. +# +# Copyright (C) 2023-June Bemade Inc. (). +# Author: Marc Durepos (Contact : marc@bemade.org) +# +# This program is under the terms of the GNU Lesser General Public License, +# version 3. +# +# For full license details, see https://www.gnu.org/licenses/lgpl-3.0.en.html. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. +# +{ + "name": "Improved Manufacturing Origin", + "version": "18.0.1.0.0", + "summary": "Adds better origin information for multi-tiered manufacturing.", + "category": "Manufacturing/Manufacturing", + "author": "Bemade Inc.", + "website": "http://www.bemade.org", + "license": "LGPL-3", + "depends": ["sale_mrp"], + "data": ["views/mrp_production_views.xml"], + "assets": { + "web.assets_backend": [ + "improved_mo_origin/static/src/**/*.xml", + "improved_mo_origin/static/src/**/*.js", + ] + }, + "installable": True, + "auto_install": False, +} diff --git a/improved_mo_origin/models/__init__.py b/improved_mo_origin/models/__init__.py new file mode 100644 index 0000000..a9e5f13 --- /dev/null +++ b/improved_mo_origin/models/__init__.py @@ -0,0 +1 @@ +from . import mrp_production diff --git a/improved_mo_origin/models/mrp_production.py b/improved_mo_origin/models/mrp_production.py new file mode 100644 index 0000000..6d96a7d --- /dev/null +++ b/improved_mo_origin/models/mrp_production.py @@ -0,0 +1,131 @@ +from odoo import models, fields, api + + +class MrpProduction(models.Model): + _inherit = "mrp.production" + + customer_ids = fields.Many2many( + comodel_name="res.partner", + compute="_compute_customers", + string="Customers", + store=True, + compute_sudo=True, + ) + source_sale_orders = fields.Char( + compute="_compute_source_sale_orders", + string="Sale Order(s)", + store=True, + compute_sudo=True, + ) + source_sale_ids = fields.Many2many( + comodel_name="sale.order", + compute="_compute_source_sale_ids", + compute_sudo=True, + ) + + def _get_related_sales(self): + self.ensure_one() + # Try multiple paths to find related sale orders + sales = self.env['sale.order'] + + # Path 1: Through procurement group + if self.procurement_group_id: + sales |= ( + self.procurement_group_id + .mapped("mrp_production_ids") + .mapped("move_dest_ids") + .mapped("group_id") + .mapped("sale_id") + ) + + # Path 2: Through move_dest_ids directly + sales |= ( + self.mapped("move_dest_ids") + .mapped("group_id") + .mapped("sale_id") + ) + + # Path 3: Through move_finished_ids -> move_dest_ids + sales |= ( + self.mapped("move_finished_ids") + .mapped("move_dest_ids") + .mapped("group_id") + .mapped("sale_id") + ) + + return sales + + def _get_sources(self): + """Get source MOs that might have sale order information""" + self.ensure_one() + # Look for parent MOs through stock moves + parent_mos = self.env['mrp.production'] + + # Check raw material moves for parent MOs + for move in self.move_raw_ids: + if move.move_orig_ids: + parent_mos |= move.move_orig_ids.mapped('production_id') + + # Also check if this MO is a source for others (for merged MOs) + # Find MOs that have this MO as a source through move_orig_ids + source_mos = self.env['mrp.production'].search([ + ('move_raw_ids.move_orig_ids.production_id', '=', self.id) + ]) + + return parent_mos | source_mos + + @api.depends( + "procurement_group_id", + "procurement_group_id.mrp_production_ids", + "procurement_group_id.mrp_production_ids.move_dest_ids", + "procurement_group_id.stock_move_ids.move_dest_ids", + "move_raw_ids.move_orig_ids", + "move_raw_ids.move_orig_ids.production_id", + ) + def _compute_customers(self): + for rec in self: + customers = rec._get_related_sales().mapped("partner_id") + + # If no direct customers, check source MOs (without recursion) + if not customers: + sources = rec._get_sources() + if sources: + # Get customers from source MOs directly + for source in sources: + source_sales = source._get_related_sales() + customers |= source_sales.mapped("partner_id") + + rec.customer_ids = customers + + @api.depends( + "procurement_group_id", + "procurement_group_id.mrp_production_ids", + "procurement_group_id.mrp_production_ids.move_dest_ids", + "procurement_group_id.stock_move_ids.move_dest_ids", + "move_raw_ids.move_orig_ids", + "move_raw_ids.move_orig_ids.production_id", + ) + def _compute_source_sale_ids(self): + for rec in self: + source_sale_ids = rec._get_related_sales() + + # If no direct sales, check source MOs (without recursion) + if not source_sale_ids: + sources = rec._get_sources() + if sources: + # Get sale orders from source MOs directly + for source in sources: + source_sales = source._get_related_sales() + source_sale_ids |= source_sales + + rec.source_sale_ids = source_sale_ids + + @api.depends("source_sale_ids") + def _compute_source_sale_orders(self): + for rec in self: + rec.source_sale_orders = ( + ", ".join(rec.source_sale_ids.mapped("name")) + if rec.source_sale_ids + else "" + ) + diff --git a/improved_mo_origin/static/src/mrp_display/mrp_display.esm.js b/improved_mo_origin/static/src/mrp_display/mrp_display.esm.js new file mode 100644 index 0000000..3acbdfe --- /dev/null +++ b/improved_mo_origin/static/src/mrp_display/mrp_display.esm.js @@ -0,0 +1,25 @@ +/** @odoo-module **/ + +import {MrpDisplay} from "@mrp_workorder/mrp_display/mrp_display"; +import {patch} from "@web/core/utils/patch"; + +patch(MrpDisplay.prototype, { + _makeModelParams() { + var params = super._makeModelParams(); + const customerFields = this.props.models.find( + (m) => m.resModel === "res.partner" + ).fields; + params.config.activeFields.customer_ids.related = { + fields: customerFields, + activeFields: customerFields, + }; + const saleOrderFields = this.props.models.find( + (m) => m.resModel === "sale.order" + ).fields; + params.config.activeFields.source_sale_ids.related = { + fields: saleOrderFields, + activeFields: saleOrderFields, + }; + return params; + }, +}); diff --git a/improved_mo_origin/static/src/mrp_display/mrp_display_action.esm.js b/improved_mo_origin/static/src/mrp_display/mrp_display_action.esm.js new file mode 100644 index 0000000..392d679 --- /dev/null +++ b/improved_mo_origin/static/src/mrp_display/mrp_display_action.esm.js @@ -0,0 +1,14 @@ +/** @odoo-module **/ + +import {MrpDisplayAction} from "@mrp_workorder/mrp_display/mrp_display_action"; +import {patch} from "@web/core/utils/patch"; + +patch(MrpDisplayAction.prototype, { + get fieldsStructure() { + const vals = super.fieldsStructure; + vals["mrp.production"].push("customer_ids", "source_sale_ids"); + vals["res.partner"] = ["id", "display_name"]; + vals["sale.order"] = ["id", "display_name"]; + return vals; + }, +}); diff --git a/improved_mo_origin/static/src/mrp_display/mrp_display_record.esm.js b/improved_mo_origin/static/src/mrp_display/mrp_display_record.esm.js new file mode 100644 index 0000000..ac07d1f --- /dev/null +++ b/improved_mo_origin/static/src/mrp_display/mrp_display_record.esm.js @@ -0,0 +1,9 @@ +/** @odoo-module **/ + +import {Many2ManyTagsField} from "@web/views/fields/many2many_tags/many2many_tags_field"; +import {MrpDisplayRecord} from "@mrp_workorder/mrp_display/mrp_display_record"; + +MrpDisplayRecord.components = { + ...MrpDisplayRecord.components, + Many2ManyTagsField, +}; diff --git a/improved_mo_origin/static/src/mrp_display/mrp_display_record.xml b/improved_mo_origin/static/src/mrp_display/mrp_display_record.xml new file mode 100644 index 0000000..c7e35e2 --- /dev/null +++ b/improved_mo_origin/static/src/mrp_display/mrp_display_record.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + diff --git a/improved_mo_origin/tests/__init__.py b/improved_mo_origin/tests/__init__.py new file mode 100644 index 0000000..fae3edd --- /dev/null +++ b/improved_mo_origin/tests/__init__.py @@ -0,0 +1 @@ +from . import test_mrp_production diff --git a/improved_mo_origin/tests/test_mrp_production.py b/improved_mo_origin/tests/test_mrp_production.py new file mode 100644 index 0000000..5da473a --- /dev/null +++ b/improved_mo_origin/tests/test_mrp_production.py @@ -0,0 +1,151 @@ +from odoo.addons.sale_mrp.tests.test_sale_mrp_flow import TestSaleMrpFlowCommon +from odoo.tests import Form, tagged + + +@tagged("-at_install", "post_install") +class TestMrpProduction(TestSaleMrpFlowCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + + def _setup_products(self): + route_manufacture = self.company_data[ + "default_warehouse" + ].manufacture_pull_id.route_id + route_mto = self.company_data["default_warehouse"].mto_pull_id.route_id + product_a = self._cls_create_product( + "Product A", + self.uom_unit, + routes=[route_manufacture, route_mto], + ) + product_c = self._cls_create_product( + "Product C", + self.uom_kg, + ) + product_b = self._cls_create_product( + "Product B", + self.uom_dozen, + routes=[route_manufacture, route_mto], + ) + product_d = self._cls_create_product( + "Product D", + self.uom_unit, + routes=[route_manufacture, route_mto], + ) + # Bill of materials for Product A. + with Form(self.env["mrp.bom"]) as form: + form.product_tmpl_id = product_a.product_tmpl_id + form.product_id = product_a + form.product_qty = 2 + form.product_uom_id = self.uom_dozen + form.save() + with form.bom_line_ids.new() as line: + line.product_id = product_b + line.product_qty = 3 + line.product_uom_id = self.uom_unit + with form.bom_line_ids.new() as line: + line.product_id = product_c + line.product_qty = 300.0 + line.product_uom_id = self.uom_gm + with form.bom_line_ids.new() as line: + line.product_id = product_d + line.product_qty = 4 + line.product_uom_id = self.uom_unit + # Bill of materials for Product B. + with Form(self.env["mrp.bom"]) as form: + form.product_tmpl_id = product_b.product_tmpl_id + form.product_id = product_b + form.product_qty = 1 + form.product_uom_id = self.uom_dozen + form.save() + with form.bom_line_ids.new() as line: + line.product_id = product_d + line.product_qty = 2 + line.product_uom_id = self.uom_unit + return product_a + + def _create_order(self, partner_name: str, product): + order_form = Form(self.env["sale.order"]) + order_form.partner_id = self.env["res.partner"].create({"name": partner_name}) + with order_form.order_line.new() as line: + line.product_id = product + line.product_uom = self.uom_dozen + line.product_uom_qty = 10 + return order_form.save() + + def _setup_test_mo_single_client(self): + product_a = self._setup_products() + + order = self._create_order("Test Partner", product_a) + order.action_confirm() + # A sales order creating an MO should have its partner listed in the MO's customer_ids field + + self.assertNotEqual(order.mrp_production_count, 0) + base_mos = order.mrp_production_ids | order.mrp_production_ids + child_mos = self.env["mrp.production"] + for mo in base_mos: + child_mos |= mo._get_children() + return order, base_mos | child_mos + + def test_mo_single_client_correctly_listed(self): + order, all_mos = self._setup_test_mo_single_client() + for mo in all_mos: + self.assertIn(order.partner_id, mo.customer_ids) + + def test_mo_single_client_so_correctly_set(self): + order, all_mos = self._setup_test_mo_single_client() + for mo in all_mos: + self.assertEqual(order.name, mo.source_sale_orders) + + def _setup_test_mo_multiple_so(self): + product_a = self._setup_products() + order1 = self._create_order("Test Partner 1", product_a) + order2 = self._create_order("Test Partner 2", product_a) + order1.action_confirm() + order2.action_confirm() + + # Run scheduler to create manufacturing orders with proper BOMs + self.env["procurement.group"].run_scheduler() + + # Get top-level MOs for Product A only (not child MOs for components) + top_level_mos_order1 = order1.mrp_production_ids.filtered( + lambda mo: mo.product_id == product_a + ) + top_level_mos_order2 = order2.mrp_production_ids.filtered( + lambda mo: mo.product_id == product_a + ) + + # Merge only the top-level MOs for the same product + (top_level_mos_order1 | top_level_mos_order2).action_merge() + self.env.invalidate_all(flush=True) + + # After merge, find the merged MO (original MOs are deleted, new one created) + merged_mos = self.env['mrp.production'].search([ + ('product_id', '=', product_a.id), + ('state', 'in', ['draft', 'confirmed', 'progress', 'to_close']), + ]) + + self.assertTrue(merged_mos, "Should find the merged MO") + self.assertEqual(len(merged_mos), 1, "Should have exactly one merged MO") + merged_mo = merged_mos[0] + + # Collect all MOs (merged top-level + all children) + all_mos = merged_mo + for mo in merged_mo: + all_mos |= mo._get_children() + + return order1, order2, all_mos + + def test_mo_multiple_so_clients_correctly_listed(self): + order1, order2, all_mos = self._setup_test_mo_multiple_so() + # MOs are merged so we should get both partners on each MO + for mo in all_mos: + self.assertIn(order1.partner_id, mo.customer_ids) + self.assertIn(order2.partner_id, mo.customer_ids) + + def test_mo_multiple_so_sales_correctly_set(self): + order1, order2, all_mos = self._setup_test_mo_multiple_so() + # MOs are merged so both sale_ids should be set on all MOs + for mo in all_mos: + self.assertIn(order1.name, mo.source_sale_orders) + self.assertIn(order2.name, mo.source_sale_orders) diff --git a/improved_mo_origin/views/mrp_production_views.xml b/improved_mo_origin/views/mrp_production_views.xml new file mode 100644 index 0000000..3fc6de7 --- /dev/null +++ b/improved_mo_origin/views/mrp_production_views.xml @@ -0,0 +1,25 @@ + + + + mrp.production.view.form.customer_name + mrp.production + + + + + + + + + + mrp.production.view.form.customer_name + mrp.production + + + + + + + + +