From 4edc0769b36b85ba8d2b02b64e348b460a162237 Mon Sep 17 00:00:00 2001
From: aych-odoo <aych@odoo.com>
Date: Mon, 17 Mar 2025 10:33:30 +0530
Subject: [PATCH] [ADD] mrp_minimum_quantity: implement Minimum Quantity in BoM
 & MTO Price Sync

- Added a new field `minimum_qty` to Bill of Materials (BoM) to define
the minimum production quantity.
- Enforced validation to ensure BoM quantity is
not lower than the minimum quantity.
- Restricted non-admin users from setting MO quantity below the minimum;
admins receive a warning instead.
- Applied minimum quantity validation in manual and automatic replenishment
processes, allowing only admins to override.
- Ensured that if the auto-replenishment quantity is lower than the minimum
defined in the BoM, it is automatically adjusted to meet the required minimum.
- Passed Sales Order (SO) unit price to the related Purchase Order (PO) line
in the Make-to-Order (MTO) process.
- Added test cases for all functionalities.
---
 mrp_minimum_quantity/__init__.py              |   4 +
 mrp_minimum_quantity/__manifest__.py          |  14 +++
 mrp_minimum_quantity/models/__init__.py       |   7 ++
 mrp_minimum_quantity/models/mrp_bom.py        |  13 ++
 mrp_minimum_quantity/models/mrp_production.py |  24 ++++
 .../models/purchase_order_line.py             |  24 ++++
 .../models/stock_orderpoint.py                |  42 +++++++
 mrp_minimum_quantity/models/stock_rule.py     |  13 ++
 mrp_minimum_quantity/tests/__init__.py        |   1 +
 .../tests/test_mrp_minimum_qty.py             | 112 ++++++++++++++++++
 mrp_minimum_quantity/views/mrp_bom_views.xml  |  13 ++
 mrp_minimum_quantity/wizard/__init__.py       |   3 +
 .../wizard/product_replenish.py               |  29 +++++
 13 files changed, 299 insertions(+)
 create mode 100644 mrp_minimum_quantity/__init__.py
 create mode 100644 mrp_minimum_quantity/__manifest__.py
 create mode 100644 mrp_minimum_quantity/models/__init__.py
 create mode 100644 mrp_minimum_quantity/models/mrp_bom.py
 create mode 100644 mrp_minimum_quantity/models/mrp_production.py
 create mode 100644 mrp_minimum_quantity/models/purchase_order_line.py
 create mode 100644 mrp_minimum_quantity/models/stock_orderpoint.py
 create mode 100644 mrp_minimum_quantity/models/stock_rule.py
 create mode 100644 mrp_minimum_quantity/tests/__init__.py
 create mode 100644 mrp_minimum_quantity/tests/test_mrp_minimum_qty.py
 create mode 100644 mrp_minimum_quantity/views/mrp_bom_views.xml
 create mode 100644 mrp_minimum_quantity/wizard/__init__.py
 create mode 100644 mrp_minimum_quantity/wizard/product_replenish.py

diff --git a/mrp_minimum_quantity/__init__.py b/mrp_minimum_quantity/__init__.py
new file mode 100644
index 00000000000..33bbab569d0
--- /dev/null
+++ b/mrp_minimum_quantity/__init__.py
@@ -0,0 +1,4 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import models
+from . import wizard
diff --git a/mrp_minimum_quantity/__manifest__.py b/mrp_minimum_quantity/__manifest__.py
new file mode 100644
index 00000000000..b686d9c9675
--- /dev/null
+++ b/mrp_minimum_quantity/__manifest__.py
@@ -0,0 +1,14 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+{
+    "name": "Manufacturing Minimum Quantity",
+    "version": "1.0",
+    "author": "Ayush",
+    "category": "Tutorials/MRP",
+    "depends": ["sale_management", "sale_purchase_stock", "mrp"],
+    "data": [
+        "views/mrp_bom_views.xml"
+    ],
+    "installable": True,
+    "license": "LGPL-3"
+}
diff --git a/mrp_minimum_quantity/models/__init__.py b/mrp_minimum_quantity/models/__init__.py
new file mode 100644
index 00000000000..6d71bc02e8e
--- /dev/null
+++ b/mrp_minimum_quantity/models/__init__.py
@@ -0,0 +1,7 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import mrp_bom
+from . import mrp_production
+from . import stock_orderpoint
+from . import stock_rule
+from . import purchase_order_line
diff --git a/mrp_minimum_quantity/models/mrp_bom.py b/mrp_minimum_quantity/models/mrp_bom.py
new file mode 100644
index 00000000000..a76b6282d21
--- /dev/null
+++ b/mrp_minimum_quantity/models/mrp_bom.py
@@ -0,0 +1,13 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class MrpBom(models.Model):
+    _inherit = "mrp.bom"
+
+    product_min_qty = fields.Float(string="Minimum Quantity")
+
+    _sql_constraints = [
+        ("product_min_qty", "CHECK(product_qty >= product_min_qty)", "Quantity cannot be lower than Minimum Quantity")
+    ]
diff --git a/mrp_minimum_quantity/models/mrp_production.py b/mrp_minimum_quantity/models/mrp_production.py
new file mode 100644
index 00000000000..e376ed436f1
--- /dev/null
+++ b/mrp_minimum_quantity/models/mrp_production.py
@@ -0,0 +1,24 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import _, api, models
+from odoo.exceptions import ValidationError
+
+
+class MrpProduction(models.Model):
+    _inherit = "mrp.production"
+
+    @api.onchange("product_qty")
+    def _onchange_product_qty(self):
+        if not self.bom_id:
+            return
+        if self.product_qty < self.bom_id.product_min_qty:
+            message = _("Minimum Quantity cannot be lower than Quantity.")
+            if self.env.user.has_group("mrp.group_mrp_manager"):
+                return {
+                    "warning": {
+                        "title": _("Warning"),
+                        "message": message,
+                    }
+                }
+            else:
+                raise ValidationError(message=message)
diff --git a/mrp_minimum_quantity/models/purchase_order_line.py b/mrp_minimum_quantity/models/purchase_order_line.py
new file mode 100644
index 00000000000..1c001a9464c
--- /dev/null
+++ b/mrp_minimum_quantity/models/purchase_order_line.py
@@ -0,0 +1,24 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class PurchaseOrderLine(models.Model):
+    _inherit = "purchase.order.line"
+
+    sale_order_line_id = fields.Many2one(comodel_name="sale.order.line", help="Related sale order line for MTO route")
+
+    @api.model
+    def _prepare_purchase_order_line_from_procurement(self, product_id, product_qty, product_uom, location_dest_id, name, origin, company_id, values, po):
+        res = super()._prepare_purchase_order_line_from_procurement(
+            product_id, product_qty, product_uom, location_dest_id, name, origin, company_id, values, po
+        )
+        sale_order_line = self.env["sale.order.line"].search([
+            ("order_id.name", "=", origin),
+            ("product_id", "=", product_id.id),
+            ("product_uom_qty", "=", product_qty)
+        ], limit=1)
+        if sale_order_line:
+            res["price_unit"] = sale_order_line.price_unit
+            res["sale_order_line_id"] = sale_order_line.id
+        return res
diff --git a/mrp_minimum_quantity/models/stock_orderpoint.py b/mrp_minimum_quantity/models/stock_orderpoint.py
new file mode 100644
index 00000000000..fa2f6a4e08d
--- /dev/null
+++ b/mrp_minimum_quantity/models/stock_orderpoint.py
@@ -0,0 +1,42 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import _, models
+from odoo.exceptions import UserError
+
+
+class StockWarehouseOrderpoint(models.Model):
+    _inherit = "stock.warehouse.orderpoint"
+
+    def action_replenish(self, force_to_max=False):
+        is_mrp_admin = self.env.user.has_group("mrp.group_mrp_manager")
+        errors = []
+        for orderpoint in self:
+            if not orderpoint.rule_ids.filtered(lambda r: r.action == "manufacture"):
+                continue
+            bom = self.env["mrp.bom"]._bom_find(self.product_id).get(self.product_id)
+            if not bom:
+                continue
+            if orderpoint.qty_to_order < bom.product_min_qty:
+                if orderpoint.trigger == "manual":
+                    message = _(
+                        f"The quantity to order ({orderpoint.qty_to_order}) is less than the minimum required ({bom.product_min_qty}) for product '{orderpoint.product_id.display_name}'."
+                    )
+                    if is_mrp_admin:
+                        errors.append(message)
+                    else:
+                        raise UserError(message)
+                else:
+                    orderpoint.qty_to_order = bom.product_min_qty
+        notification = super().action_replenish(force_to_max)
+        if errors:
+            return {
+                "type": "ir.actions.client",
+                "tag": "display_notification",
+                "params": {
+                    "title": _("Warning"),
+                    "message": "\n".join(errors),
+                    "sticky": False,
+                    "type": "warning"
+                }
+            }
+        return notification
diff --git a/mrp_minimum_quantity/models/stock_rule.py b/mrp_minimum_quantity/models/stock_rule.py
new file mode 100644
index 00000000000..4a56cdcd7b4
--- /dev/null
+++ b/mrp_minimum_quantity/models/stock_rule.py
@@ -0,0 +1,13 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models
+
+
+class StockRule(models.Model):
+    _inherit = "stock.rule"
+
+    def _update_purchase_order_line(self, product_id, product_qty, product_uom, company_id, values, line):
+        res = super()._update_purchase_order_line(product_id, product_qty, product_uom, company_id, values, line)
+        if line.sale_order_line_id:
+            res["price_unit"] = line.sale_order_line_id.price_unit
+        return res
diff --git a/mrp_minimum_quantity/tests/__init__.py b/mrp_minimum_quantity/tests/__init__.py
new file mode 100644
index 00000000000..7207cf5c7ce
--- /dev/null
+++ b/mrp_minimum_quantity/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_mrp_minimum_qty
diff --git a/mrp_minimum_quantity/tests/test_mrp_minimum_qty.py b/mrp_minimum_quantity/tests/test_mrp_minimum_qty.py
new file mode 100644
index 00000000000..41d56dcfccd
--- /dev/null
+++ b/mrp_minimum_quantity/tests/test_mrp_minimum_qty.py
@@ -0,0 +1,112 @@
+from odoo import Command, _
+from odoo.exceptions import UserError, ValidationError
+from odoo.tests import tagged, TransactionCase
+from odoo.tools import mute_logger
+from psycopg2 import IntegrityError
+
+
+@tagged("post_install", "-at_install")
+class TestMrpMinimumQty(TransactionCase):
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+        cls.admin_user = cls.env.ref("base.user_admin")
+        cls.normal_user = cls.env["res.users"].create({
+            "name": "Normal User",
+            "login": "normal_user",
+            "groups_id": [Command.link(cls.env.ref("mrp.group_mrp_user").id)]
+        })
+        cls.mrp_admin_user = cls.env["res.users"].create({
+            "name": "MRP Admin",
+            "login": "mrp_admin",
+            "groups_id": [
+                    Command.link(cls.env.ref("mrp.group_mrp_manager").id),
+                    Command.link(cls.env.ref("stock.group_stock_manager").id)
+            ],
+        })
+        cls.vendor = cls.env["res.partner"].create({
+            "name": "Test Vendor",
+        })
+        cls.product = cls.env["product.product"].create({
+            "name": "Test Product",
+            "type": "consu",
+            "route_ids": [Command.link(cls.env.ref("mrp.route_warehouse0_manufacture").id)]
+        })
+        cls.bom = cls.env["mrp.bom"].create({
+            "product_tmpl_id": cls.product.product_tmpl_id.id,
+            "product_qty": 10,
+            "product_min_qty": 5
+        })
+        cls.env.ref("stock.route_warehouse0_mto").action_unarchive()
+        cls.mto_product = cls.env["product.product"].create({
+            "name": "MTO Product",
+            "type": "consu",
+            "route_ids": [
+                Command.link(cls.env.ref("stock.route_warehouse0_mto").id),
+                Command.link(cls.env.ref("purchase_stock.route_warehouse0_buy").id)
+            ],
+            "seller_ids": [Command.create({"partner_id": cls.vendor.id, "price": 100.0})],
+        })
+        cls.sale_order = cls.env["sale.order"].create({
+            "partner_id": cls.env.ref("base.res_partner_1").id
+        })
+        cls.sale_order_line = cls.env["sale.order.line"].create({
+            "order_id": cls.sale_order.id,
+            "product_id": cls.mto_product.id,
+            "product_uom_qty": 5,
+            "price_unit": 150.0
+        })
+
+    @mute_logger("odoo.sql_db")
+    def test_quantity_greater_than_minimum(self):
+        with self.assertRaises(IntegrityError):
+            self.bom.update({"product_qty": 4})
+
+    def test_mrp_order_quantity_validation(self):
+        mo = self.env["mrp.production"].with_user(self.normal_user.id).create({
+            "product_id": self.product.id,
+            "product_qty": 4,
+            "bom_id": self.bom.id
+        })
+        with self.assertRaises(ValidationError):
+            mo._onchange_product_qty()
+        mo_admin = self.env["mrp.production"].with_user(self.mrp_admin_user.id).create({
+            "product_id": self.product.id,
+            "product_qty": 4,
+            "bom_id": self.bom.id
+        })
+        self.assertEqual(mo_admin._onchange_product_qty()["warning"]["title"], _("Warning"))
+
+    def test_replenishment_validation(self):
+        manufacture_route = self.env.ref("mrp.route_warehouse0_manufacture")
+        self.orderpoint = self.env["stock.warehouse.orderpoint"].create({
+            "product_id": self.product.id,
+            "qty_to_order": 4,
+            "trigger": "manual",
+            "route_id": manufacture_route.id
+        })
+        with self.assertRaises(UserError):
+            self.orderpoint.with_user(self.normal_user.id).action_replenish()
+        self.orderpoint.with_user(self.mrp_admin_user.id).action_replenish()
+        self.env["stock.warehouse.orderpoint"].search([("product_id", "=", self.product.id)]).unlink()
+        self.env["mrp.production"].search([("state", "!=", "done")]).unlink()
+        self.orderpoint = self.env["stock.warehouse.orderpoint"].create({
+            "product_id": self.product.id,
+            "qty_to_order": 4,
+            "trigger": "auto",
+            "route_id": manufacture_route.id
+        })
+        self.orderpoint.sudo().action_replenish_auto()
+        mo = self.env["mrp.production"].search([], order="id desc", limit=1)
+        self.assertTrue(mo)
+        self.assertEqual(mo.product_qty, 5.0)
+
+    def test_mto_price_transfer_to_po(self):
+        self.sale_order.sudo().action_confirm()
+        purchase_order = self.env["purchase.order"].search([("origin", "=", self.sale_order.name)], limit=1)
+        self.assertTrue(purchase_order)
+        purchase_order_line = self.env["purchase.order.line"].search([
+            ("order_id", "=", purchase_order.id),
+            ("product_id", "=", self.mto_product.id)], limit=1)
+        self.assertTrue(purchase_order_line)
+        self.assertEqual(purchase_order_line.price_unit, self.sale_order_line.price_unit)
diff --git a/mrp_minimum_quantity/views/mrp_bom_views.xml b/mrp_minimum_quantity/views/mrp_bom_views.xml
new file mode 100644
index 00000000000..730c7b113b2
--- /dev/null
+++ b/mrp_minimum_quantity/views/mrp_bom_views.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<odoo>
+    <record id="mrp_bom_form_view_inherit_minimum_qty" model="ir.ui.view">
+        <field name="name">mrp.bom.form.inherit.minimum.quantity</field>
+        <field name="model">mrp.bom</field>
+        <field name="inherit_id" ref="mrp.mrp_bom_form_view" />
+        <field name="arch" type="xml">
+            <xpath expr="//label[@for='product_qty']/following-sibling::div[1]" position="after">
+                <field name="product_min_qty" />
+            </xpath>
+        </field>
+    </record>
+</odoo>
diff --git a/mrp_minimum_quantity/wizard/__init__.py b/mrp_minimum_quantity/wizard/__init__.py
new file mode 100644
index 00000000000..82c76aa7de1
--- /dev/null
+++ b/mrp_minimum_quantity/wizard/__init__.py
@@ -0,0 +1,3 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import product_replenish
diff --git a/mrp_minimum_quantity/wizard/product_replenish.py b/mrp_minimum_quantity/wizard/product_replenish.py
new file mode 100644
index 00000000000..527a6e89b9b
--- /dev/null
+++ b/mrp_minimum_quantity/wizard/product_replenish.py
@@ -0,0 +1,29 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import _, models
+from odoo.exceptions import UserError
+
+
+class ProductReplenish(models.TransientModel):
+    _inherit = "product.replenish"
+
+    def launch_replenishment(self):
+        if not self.route_id:
+            raise UserError(_("You need to select a route to replenish your products"))
+        if not self.route_id.rule_ids.filtered(lambda x: x.action == "manufacture"):
+            return super().launch_replenishment()
+        bom = self.env["mrp.bom"]._bom_find(self.product_id).get(self.product_id)
+        if not bom:
+            return super().launch_replenishment()
+        if self.quantity < bom.product_min_qty:
+            message = _(
+                f"The quantity to order ({self.quantity}) is less than the minimum required ({bom.product_min_qty})."
+            )
+            if self.env.user.has_group("mrp.group_mrp_manager"):
+                notification = super().launch_replenishment()
+                notification["params"]["message"] += f" ({message})"
+                notification["params"]["type"] = "warning"
+                return notification
+            else:
+                raise UserError(message=message)
+        return super().launch_replenishment()