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 @@ + + + + mrp.bom.form.inherit.minimum.quantity + mrp.bom + + + + + + + + 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()