From e094a61cd7b8935769b9aa538b4ee8041fbf49e9 Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Mon, 2 Mar 2026 13:10:19 +0700 Subject: [PATCH] first commit --- .gitignore | 10 +++++ README.md | 30 +++++++++++++ __init__.py | 1 + __manifest__.py | 20 +++++++++ models/__init__.py | 1 + models/mrp_mps.py | 102 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 164 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 __manifest__.py create mode 100644 models/__init__.py create mode 100644 models/mrp_mps.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68b3a01 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.pyc +*.pyo +__pycache__/ +*.pot +*.po + +# Odoo specific +/.idea +/.vscode +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..f1158c9 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# MPB Bottom-Up Replenishment (Two-Way MPS) + +## Overview +This module (`mrp_mps_bottom_up`) enhances the standard Odoo 19 Master Production Schedule (MPS) by introducing a "two-way" propagation of manual replenishment quantity changes. + +In standard Odoo MPS, modifying the replenishment quantity of a top-level component (`FG1`) creates downward indirect demand for its sub-components (`FG` or `WIP`), but modifying a sub-component (`FG`) has no effect on its parent (`FG1`). + +This module intercepts manual edits to the `replenish_qty` cell and automatically: +1. **Traces Up (Bottom-Up):** Calculates the equivalent base quantity required for any parent products and seamlessly updates their replenishment quantities in the same period without triggering infinite loops. +2. **Drills Down (Top-Down):** Calculates the equivalent quantity required for child components and explicitly pushes this demand down. + +It fully supports sub-component scaling based on correct BOM ratios. +It includes built-in compatibility with custom packaging quantities (e.g. from the `mrp_packaging_qty` module), accurately multiplying or dividing base units by packaging multipliers during the Drill Down operation. + +## Installation +Add the module to your custom addons directory and install it via the regular Odoo `Apps` menu. The module implicitly depends on the core `mrp_mps` enterprise module. + +## Usage +1. Open the **Master Production Schedule** view. +2. Ensure you have product hierarchies defined with BOMs (e.g. `Product A` requires 2x `Product B`). +3. Add both `Product A` and `Product B` to your MPS view. +4. If you artificially increase the Replenishment for the child `Product B`, you will see `Product A` automatically adjust to reflect the scaled parent demand. +5. If you increase the Replenishment for a middle component, you will observe the quantity propagate up to parents AND down to children simultaneously. + +## Dependencies +* `mrp_mps` (Core Odoo Enterprise module) +* Compatible with `mrp_packaging_qty` (Custom Module) + +## License +Licensed under LGPL-3.0. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..47aebb6 --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +{ + 'name': "MPB Bottom-Up Replenishment (Two-Way MPS)", + 'summary': "Allow bottom-up & top-down calculation in Master Production Schedule", + 'description': """ + This module overrides the MPS replenishment behavior. + Normally, setting a replenishment value in MPS only creates indirect demand downwards. + This module adds a trace-up (bottom-up) functionality to calculate the equivalent + replenishment quantities for parent products, and a drill-down (top-down) functionality + to calculate the equivalent replenishment quantities for child components. + """, + 'author': "Suherdy", + 'category': 'Manufacturing/Manufacturing', + 'version': '19.0.1.0.0', + 'depends': ['mrp_mps'], + 'data': [], + 'installable': True, + 'application': False, + 'license': 'LGPL-3', +} diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..4ddd62e --- /dev/null +++ b/models/__init__.py @@ -0,0 +1 @@ +from . import mrp_mps diff --git a/models/mrp_mps.py b/models/mrp_mps.py new file mode 100644 index 0000000..8b0883c --- /dev/null +++ b/models/mrp_mps.py @@ -0,0 +1,102 @@ +from odoo import models, api +from odoo.tools.float_utils import float_round + +class MrpProductionSchedule(models.Model): + _inherit = 'mrp.production.schedule' + + def set_replenish_qty(self, date_index, quantity, period_scale=False): + """ Save the replenish quantity and mark the cells as manually updated. + We override this to provide two-way (Trace up and Drill down) updating + of linked components in the BOM hierarchy. + """ + self.ensure_one() + + # Calculate the difference between new quantity and old quantity + date_start, date_stop = self.company_id._get_date_range(force_period=period_scale)[date_index] + existing_forecast = self.forecast_ids.filtered(lambda f: + f.date >= date_start and f.date <= date_stop) + + old_qty = sum(existing_forecast.mapped('replenish_qty')) if existing_forecast else 0.0 + + # Call super first to update this component + res = super().set_replenish_qty(date_index, quantity, period_scale) + + quantity = float_round(float(quantity), precision_rounding=self.product_uom_id.rounding) + diff_qty = quantity - old_qty + + # If the quantity changed and we're not already propagating an update to avoid infinite loops + if diff_qty != 0 and not self.env.context.get('skip_two_way_mps'): + self._propagate_replenish_qty(diff_qty, date_index, period_scale) + + return res + + def _propagate_replenish_qty(self, diff_qty, date_index, period_scale=False): + """Propagate replenishment difference to parents (bottom-up) and children (top-down)""" + + # We need to trace up (bottom-up) + parent_schedules = self._get_impacted_parent_schedules() + # We need to drill down (top-down) + child_schedules = self._get_impacted_child_schedules() + + # Fetch the BOM hierarchy relationships + schedules_to_compute = parent_schedules | child_schedules | self + indirect_demand_trees = schedules_to_compute._get_indirect_demand_tree() + indirect_ratio_mps = schedules_to_compute._get_indirect_demand_ratio_mps(indirect_demand_trees) + + # TRACE UP: Update parents + for parent_schedule in parent_schedules: + # Find the ratio: how many of 'self.product_id' are needed to make 1 'parent_schedule.product_id' + ratios = indirect_ratio_mps.get((parent_schedule.warehouse_id, parent_schedule.product_id), {}) + ratio = ratios.get(self.product_id, 0.0) + + if ratio > 0: + parent_diff = diff_qty / ratio + + # Retrieve the existing parent quantity for this period + p_date_start, p_date_stop = parent_schedule.company_id._get_date_range(force_period=period_scale)[date_index] + p_exist = parent_schedule.forecast_ids.filtered(lambda f: f.date >= p_date_start and f.date <= p_date_stop) + p_old_qty = sum(p_exist.mapped('replenish_qty')) if p_exist else 0.0 + + new_parent_qty = p_old_qty + parent_diff + if new_parent_qty < 0: + new_parent_qty = 0.0 + + # Update parent with skip context to prevent loops + parent_schedule.with_context(skip_two_way_mps=True).set_replenish_qty(date_index, new_parent_qty, period_scale) + + # DRILL DOWN: Update children + for child_schedule in child_schedules: + # Find the ratio: how many of 'child.product_id' base units are needed to make 1 'self.product_id' base unit + ratios = indirect_ratio_mps.get((self.warehouse_id, self.product_id), {}) + ratio = ratios.get(child_schedule.product_id, 0.0) + + if ratio > 0: + # child_diff is in base units + child_diff = diff_qty * ratio + + # If the packaging module is used, we must convert the base unit diff_qty + # into the packaging unit diff_qty. + # Because child_schedule.set_replenish_qty will do: quantity = float(quantity) * self.packaging_id.qty + # Therefore, we must pass the quantity *in packaging units*. + if hasattr(child_schedule, 'packaging_id') and child_schedule.packaging_id: + packaging_qty = child_schedule.packaging_id.qty + if packaging_qty: + child_diff = child_diff / packaging_qty + + c_date_start, c_date_stop = child_schedule.company_id._get_date_range(force_period=period_scale)[date_index] + c_exist = child_schedule.forecast_ids.filtered(lambda f: f.date >= c_date_start and f.date <= c_date_stop) + + # The existing replenish_qty in the database is actually stored in base units. + # BUT we need to pass the new value to set_replenish_qty in the *packaging* units if packaging is used. + c_old_qty_base = sum(c_exist.mapped('replenish_qty')) if c_exist else 0.0 + + if hasattr(child_schedule, 'packaging_id') and child_schedule.packaging_id and child_schedule.packaging_id.qty: + c_old_qty_pack = c_old_qty_base / child_schedule.packaging_id.qty + else: + c_old_qty_pack = c_old_qty_base + + new_child_qty_pack = c_old_qty_pack + child_diff + if new_child_qty_pack < 0: + new_child_qty_pack = 0.0 + + child_schedule.with_context(skip_two_way_mps=True).set_replenish_qty(date_index, new_child_qty_pack, period_scale)