Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ADD] mrp_minimum_quantity: implement Minimum Quantity in BoM #641

Draft
wants to merge 1 commit into
base: 18.0
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions mrp_minimum_quantity/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.

from . import models
from . import wizard
14 changes: 14 additions & 0 deletions mrp_minimum_quantity/__manifest__.py
Original file line number Diff line number Diff line change
@@ -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"
}
7 changes: 7 additions & 0 deletions mrp_minimum_quantity/models/__init__.py
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions mrp_minimum_quantity/models/mrp_bom.py
Original file line number Diff line number Diff line change
@@ -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")
]
24 changes: 24 additions & 0 deletions mrp_minimum_quantity/models/mrp_production.py
Original file line number Diff line number Diff line change
@@ -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)
24 changes: 24 additions & 0 deletions mrp_minimum_quantity/models/purchase_order_line.py
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions mrp_minimum_quantity/models/stock_orderpoint.py
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions mrp_minimum_quantity/models/stock_rule.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions mrp_minimum_quantity/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import test_mrp_minimum_qty
112 changes: 112 additions & 0 deletions mrp_minimum_quantity/tests/test_mrp_minimum_qty.py
Original file line number Diff line number Diff line change
@@ -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)
13 changes: 13 additions & 0 deletions mrp_minimum_quantity/views/mrp_bom_views.xml
Original file line number Diff line number Diff line change
@@ -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>
3 changes: 3 additions & 0 deletions mrp_minimum_quantity/wizard/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.

from . import product_replenish
29 changes: 29 additions & 0 deletions mrp_minimum_quantity/wizard/product_replenish.py
Original file line number Diff line number Diff line change
@@ -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()