mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-19 01:18:43 +00:00
Merge pull request #57883 from mihir-kandoi/fix-pp-min-order-qty-round-up
fix: order smallest purchase UOM qty that meets min order qty
This commit is contained in:
@@ -319,6 +319,38 @@ class PurchaseOrder(BuyingController):
|
||||
).format(item_code, flt(qty, precision), itemwise_min_order_qty.get(item_code))
|
||||
)
|
||||
|
||||
self.warn_marginal_min_order_qty(itemwise_qty, itemwise_min_order_qty)
|
||||
|
||||
def warn_marginal_min_order_qty(self, itemwise_qty, itemwise_min_order_qty):
|
||||
"""Toast when an item's ordered qty exceeds its minimum only by purchase UOM rounding."""
|
||||
if not self.is_new():
|
||||
return
|
||||
|
||||
precision = self.items[0].precision("stock_qty")
|
||||
itemwise_step = frappe._dict()
|
||||
itemwise_stock_uom = frappe._dict()
|
||||
for d in self.get("items"):
|
||||
step = 10 ** -d.precision("qty") * flt(d.conversion_factor)
|
||||
itemwise_step[d.item_code] = max(itemwise_step.get(d.item_code, 0), step)
|
||||
itemwise_stock_uom[d.item_code] = d.stock_uom
|
||||
|
||||
for item_code, qty in itemwise_qty.items():
|
||||
min_order_qty = flt(itemwise_min_order_qty.get(item_code))
|
||||
overage = flt(qty) - min_order_qty
|
||||
if min_order_qty and flt(overage, precision) > 0 and overage < itemwise_step[item_code]:
|
||||
frappe.toast(
|
||||
_(
|
||||
"Item {0}: Ordered qty {1} {2} exceeds the minimum order qty {3} {2} by {4} {2} due to purchase UOM rounding."
|
||||
).format(
|
||||
item_code,
|
||||
flt(qty, precision),
|
||||
itemwise_stock_uom[item_code],
|
||||
min_order_qty,
|
||||
flt(overage, precision),
|
||||
),
|
||||
indicator="orange",
|
||||
)
|
||||
|
||||
def get_schedule_dates(self):
|
||||
for d in self.get("items"):
|
||||
if d.material_request_item and not d.schedule_date:
|
||||
|
||||
@@ -724,6 +724,30 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
below_minimum.items[0].conversion_factor = 0.6
|
||||
self.assertRaises(frappe.ValidationError, below_minimum.insert)
|
||||
|
||||
def test_marginal_min_order_qty_overage_toast(self):
|
||||
original_precision = frappe.db.get_default("float_precision")
|
||||
frappe.db.set_default("float_precision", "3")
|
||||
self.addCleanup(frappe.db.set_default, "float_precision", original_precision)
|
||||
|
||||
if not frappe.db.exists("UOM", "Gram"):
|
||||
frappe.get_doc({"doctype": "UOM", "uom_name": "Gram"}).insert()
|
||||
|
||||
item_doc = make_item(properties={"min_order_qty": 50000, "stock_uom": "Gram"})
|
||||
item_doc.append("uoms", {"uom": "Pound", "conversion_factor": 453.592292197})
|
||||
item_doc.save()
|
||||
item = item_doc.name
|
||||
|
||||
def insert_po(qty):
|
||||
po = create_purchase_order(item_code=item, qty=qty, do_not_save=1)
|
||||
po.items[0].uom = "Pound"
|
||||
po.items[0].conversion_factor = 453.592292197
|
||||
frappe.clear_messages()
|
||||
po.insert()
|
||||
return any("minimum order qty" in d.get("message", "") for d in frappe.get_message_log())
|
||||
|
||||
self.assertTrue(insert_po(110.232))
|
||||
self.assertFalse(insert_po(150))
|
||||
|
||||
def test_uom_integer_check_tolerates_conversion_dust(self):
|
||||
from erpnext.utilities.transaction_base import UOMMustBeIntegerError
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ existing imports of ``...services.material_planning`` keep working through here.
|
||||
import copy
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from decimal import ROUND_CEILING, Decimal
|
||||
|
||||
import frappe
|
||||
from frappe import _, msgprint
|
||||
@@ -493,8 +494,16 @@ def get_material_request_items(
|
||||
)
|
||||
item_group_defaults = get_item_group_defaults(row.item_code, company)
|
||||
conversion_factor = _mr_purchase_conversion_factor(row)
|
||||
min_order_qty = flt(row.get("min_order_qty")) if doc.get("consider_minimum_order_qty") else 0
|
||||
return _material_request_item_row(
|
||||
row, sales_order, target_warehouse, bin_dict, required_qty, conversion_factor, item_group_defaults
|
||||
row,
|
||||
sales_order,
|
||||
target_warehouse,
|
||||
bin_dict,
|
||||
required_qty,
|
||||
conversion_factor,
|
||||
item_group_defaults,
|
||||
min_order_qty,
|
||||
)
|
||||
|
||||
|
||||
@@ -539,6 +548,18 @@ def _adjust_required_qty_for_uom(row, required_qty):
|
||||
return required_qty
|
||||
|
||||
|
||||
def _quantity_in_purchase_uom(required_qty, conversion_factor, min_order_qty=0):
|
||||
"""Convert to purchase UOM; a binding minimum order qty takes the smallest
|
||||
representable quantity whose stock equivalent still meets it."""
|
||||
precision = frappe.get_precision("Material Request Plan Item", "quantity")
|
||||
quantity = flt(required_qty / conversion_factor, precision)
|
||||
if min_order_qty and quantity * conversion_factor < min_order_qty <= required_qty:
|
||||
grid = Decimal(10) ** -precision
|
||||
exact = Decimal(str(min_order_qty)) / Decimal(str(conversion_factor))
|
||||
quantity = flt(exact.quantize(grid, rounding=ROUND_CEILING))
|
||||
return quantity
|
||||
|
||||
|
||||
def _mr_purchase_conversion_factor(row):
|
||||
item_details = frappe.get_cached_value("Item", row.item_code, ["purchase_uom", "stock_uom"], as_dict=1)
|
||||
if (
|
||||
@@ -551,7 +572,14 @@ def _mr_purchase_conversion_factor(row):
|
||||
|
||||
|
||||
def _material_request_item_row(
|
||||
row, sales_order, warehouse, bin_dict, required_qty, conversion_factor, item_group_defaults
|
||||
row,
|
||||
sales_order,
|
||||
warehouse,
|
||||
bin_dict,
|
||||
required_qty,
|
||||
conversion_factor,
|
||||
item_group_defaults,
|
||||
min_order_qty=0,
|
||||
):
|
||||
warehouse = (
|
||||
warehouse
|
||||
@@ -559,11 +587,10 @@ def _material_request_item_row(
|
||||
or row.get("default_warehouse")
|
||||
or item_group_defaults.get("default_warehouse")
|
||||
)
|
||||
precision = frappe.get_precision("Material Request Plan Item", "quantity")
|
||||
return {
|
||||
"item_code": row.item_code,
|
||||
"item_name": row.item_name,
|
||||
"quantity": flt(required_qty / conversion_factor, precision),
|
||||
"quantity": _quantity_in_purchase_uom(required_qty, conversion_factor, min_order_qty),
|
||||
"conversion_factor": conversion_factor,
|
||||
"required_bom_qty": row.get("qty"),
|
||||
"stock_uom": row.get("stock_uom"),
|
||||
@@ -640,7 +667,8 @@ def _add_remaining_purchase_request(item, new_mr_items, required_qty, consider_m
|
||||
if frappe.db.get_value("UOM", purchase_uom, "must_be_whole_number"):
|
||||
required_qty = ceil(required_qty)
|
||||
|
||||
item["quantity"] = flt(required_qty / item.get("conversion_factor"), precision)
|
||||
min_order_qty = flt(item.get("min_order_qty")) if consider_minimum_order_qty else 0
|
||||
item["quantity"] = _quantity_in_purchase_uom(required_qty, item.get("conversion_factor"), min_order_qty)
|
||||
new_mr_items.append(item)
|
||||
|
||||
|
||||
|
||||
@@ -2384,6 +2384,73 @@ class TestProductionPlan(ERPNextTestSuite):
|
||||
self.assertEqual(items_by_type["Material Transfer"].get("quantity"), 7.0)
|
||||
self.assertEqual(items_by_type["Purchase"].get("quantity"), 1000.0)
|
||||
|
||||
def test_min_order_qty_conversion_takes_grid_ceiling(self):
|
||||
from erpnext.manufacturing.doctype.production_plan.services.material_request import (
|
||||
_quantity_in_purchase_uom,
|
||||
)
|
||||
|
||||
original_precision = frappe.db.get_default("float_precision")
|
||||
frappe.db.set_default("float_precision", "3")
|
||||
self.addCleanup(frappe.db.set_default, "float_precision", original_precision)
|
||||
|
||||
self.assertEqual(_quantity_in_purchase_uom(50000, 453.592292197, 50000), 110.232)
|
||||
self.assertEqual(_quantity_in_purchase_uom(2000, 0.453592, 2000), 4409.249)
|
||||
self.assertEqual(_quantity_in_purchase_uom(10, 0.5, 10), 20.0)
|
||||
self.assertEqual(_quantity_in_purchase_uom(50000, 453.592292197), 110.231)
|
||||
|
||||
def test_min_order_qty_grid_ceiling_in_plan_items(self):
|
||||
original_precision = frappe.db.get_default("float_precision")
|
||||
frappe.db.set_default("float_precision", "3")
|
||||
self.addCleanup(frappe.db.set_default, "float_precision", original_precision)
|
||||
|
||||
conversion_factor = 453.592292197
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
rm_item = make_item(
|
||||
properties={"is_stock_item": 1, "min_order_qty": 50000, "purchase_uom": "_Test UOM 1"},
|
||||
uoms=[{"uom": "_Test UOM 1", "conversion_factor": conversion_factor}],
|
||||
).name
|
||||
|
||||
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse="_Test Warehouse - _TC")
|
||||
|
||||
pln = create_production_plan(item_code=fg_item, planned_qty=1, do_not_submit=1)
|
||||
pln.consider_minimum_order_qty = 1
|
||||
mr_items = get_items_for_material_requests(pln.as_dict())
|
||||
|
||||
self.assertEqual(mr_items[0].get("quantity"), 110.232)
|
||||
self.assertGreaterEqual(mr_items[0].get("quantity") * conversion_factor, 50000)
|
||||
|
||||
def test_min_order_qty_grid_ceiling_from_other_locations(self):
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
original_precision = frappe.db.get_default("float_precision")
|
||||
frappe.db.set_default("float_precision", "3")
|
||||
self.addCleanup(frappe.db.set_default, "float_precision", original_precision)
|
||||
|
||||
conversion_factor = 453.592292197
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
rm_item = make_item(
|
||||
properties={"is_stock_item": 1, "min_order_qty": 50000, "purchase_uom": "_Test UOM 1"},
|
||||
uoms=[{"uom": "_Test UOM 1", "conversion_factor": conversion_factor}],
|
||||
).name
|
||||
|
||||
rm_warehouse = create_warehouse("MOQ Ceiling RM Warehouse", company="_Test Company")
|
||||
source_warehouse = create_warehouse("MOQ Ceiling Source Warehouse", company="_Test Company")
|
||||
make_stock_entry(item_code=rm_item, qty=4, rate=100, target=source_warehouse)
|
||||
|
||||
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse="_Test Warehouse - _TC")
|
||||
|
||||
pln = create_production_plan(item_code=fg_item, planned_qty=10, do_not_submit=1)
|
||||
pln.for_warehouse = rm_warehouse
|
||||
pln.consider_minimum_order_qty = 1
|
||||
pln.ignore_existing_ordered_qty = 1
|
||||
mr_items = get_items_for_material_requests(
|
||||
pln.as_dict(), warehouses=[{"warehouse": source_warehouse}]
|
||||
)
|
||||
|
||||
rows_by_type = {d.get("material_request_type"): d for d in mr_items}
|
||||
self.assertEqual(rows_by_type["Material Transfer"].get("quantity"), 4)
|
||||
self.assertEqual(rows_by_type["Purchase"].get("quantity"), 110.232)
|
||||
|
||||
def test_fg_item_quantity(self):
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
rm_item = make_item(properties={"is_stock_item": 1}).name
|
||||
|
||||
Reference in New Issue
Block a user