mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-08 23:09:33 +00:00
* fix: round purchase quantities up to minimum order qty Backport #57883 to version-16-hotfix. Adapt the purchase quantity conversion to the monolithic Production Plan controller and retain the Purchase Order rounding notice. Add coverage for the complete Production Plan to Material Request to supplier-selected Purchase Order flow. * fix(buying): skip rounding notice for mixed UOM increments Require one shared rounding increment across the Purchase Order rows for each item before attributing the total excess to UOM rounding. Cover mixed UOMs in both row orders, three mixed rows, and matching UOM rows that still require the notice.
This commit is contained in:
@@ -331,6 +331,43 @@ class PurchaseOrder(BuyingController):
|
||||
).format(item_code, qty, 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_steps = {}
|
||||
itemwise_stock_uom = frappe._dict()
|
||||
for d in self.get("items"):
|
||||
step = 10 ** -d.precision("qty") * flt(d.conversion_factor)
|
||||
itemwise_steps.setdefault(d.item_code, set()).add(step)
|
||||
itemwise_stock_uom[d.item_code] = d.stock_uom
|
||||
|
||||
for item_code, qty in itemwise_qty.items():
|
||||
steps = itemwise_steps[item_code]
|
||||
if len(steps) != 1:
|
||||
continue
|
||||
|
||||
step = next(iter(steps))
|
||||
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 < step:
|
||||
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 validate_bom_for_subcontracting_items(self):
|
||||
for item in self.items:
|
||||
if not item.bom:
|
||||
|
||||
@@ -724,6 +724,72 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
po = create_purchase_order(company="_Test Company 1", do_not_save=True)
|
||||
self.assertRaises(InvalidWarehouseCompany, po.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))
|
||||
|
||||
@ERPNextTestSuite.change_settings("Buying Settings", {"allow_multiple_items": 1})
|
||||
def test_marginal_min_order_qty_toast_with_duplicate_rows(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)
|
||||
|
||||
item = make_item(
|
||||
properties={"min_order_qty": 1000.5, "stock_uom": "_Test UOM 1"},
|
||||
uoms=[{"uom": "Pound", "conversion_factor": 1000}],
|
||||
)
|
||||
conversion_factors = {"_Test UOM 1": 1, "Pound": 1000}
|
||||
cases = [
|
||||
([("Pound", 1), ("_Test UOM 1", 0.6)], False),
|
||||
([("_Test UOM 1", 0.6), ("Pound", 1)], False),
|
||||
([("Pound", 0.5), ("_Test UOM 1", 0.6), ("Pound", 0.5)], False),
|
||||
([("Pound", 0.5), ("Pound", 0.501)], True),
|
||||
]
|
||||
for rows, expect_toast in cases:
|
||||
with self.subTest(rows=rows):
|
||||
po = create_purchase_order(
|
||||
do_not_save=1,
|
||||
rm_items=[
|
||||
{
|
||||
"item_code": item.name,
|
||||
"uom": uom,
|
||||
"conversion_factor": conversion_factors[uom],
|
||||
"qty": qty,
|
||||
"rate": 1,
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"schedule_date": add_days(nowdate(), 1),
|
||||
}
|
||||
for uom, qty in rows
|
||||
],
|
||||
)
|
||||
frappe.clear_messages()
|
||||
po.insert()
|
||||
has_toast = any(
|
||||
"due to purchase UOM rounding" in message.get("message", "")
|
||||
for message in frappe.get_message_log()
|
||||
)
|
||||
self.assertEqual(has_toast, expect_toast)
|
||||
|
||||
def test_uom_integer_validation(self):
|
||||
from erpnext.utilities.transaction_base import UOMMustBeIntegerError
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import copy
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from decimal import ROUND_CEILING, Decimal
|
||||
|
||||
import frappe
|
||||
from frappe import _, msgprint
|
||||
@@ -1496,11 +1497,11 @@ def get_material_request_items(
|
||||
get_conversion_factor(row.item_code, item_details.purchase_uom).get("conversion_factor") or 1.0
|
||||
)
|
||||
|
||||
precision = frappe.get_precision("Material Request Plan Item", "quantity")
|
||||
min_order_qty = flt(row.get("min_order_qty")) if doc.get("consider_minimum_order_qty") else 0
|
||||
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"),
|
||||
@@ -1523,6 +1524,18 @@ def get_material_request_items(
|
||||
}
|
||||
|
||||
|
||||
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 get_sales_orders(self):
|
||||
bom = frappe.qb.DocType("BOM")
|
||||
pi = frappe.qb.DocType("Packed Item")
|
||||
@@ -1909,7 +1922,10 @@ def get_materials_from_other_locations(
|
||||
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)
|
||||
|
||||
|
||||
@@ -2262,6 +2262,125 @@ 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.production_plan 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_min_order_qty_round_trip_to_purchase_order(self):
|
||||
from erpnext.stock.doctype.material_request.material_request import (
|
||||
get_item_default_suppliers,
|
||||
make_purchase_orders_by_supplier,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
rm_item = make_item(
|
||||
properties={
|
||||
"is_stock_item": 1,
|
||||
"stock_uom": "_Test UOM 1",
|
||||
"purchase_uom": "Pound",
|
||||
"min_order_qty": 50000,
|
||||
},
|
||||
uoms=[{"uom": "Pound", "conversion_factor": 453.592292197}],
|
||||
).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, skip_getting_mr_items=1, do_not_submit=1
|
||||
)
|
||||
pln.consider_minimum_order_qty = 1
|
||||
pln.set("mr_items", get_items_for_material_requests(pln.as_dict()))
|
||||
pln.submit_material_request = 1
|
||||
pln.save()
|
||||
pln.submit()
|
||||
pln.make_material_request()
|
||||
|
||||
mr_name = frappe.db.get_value(
|
||||
"Material Request Item", {"production_plan": pln.name, "item_code": rm_item}, "parent"
|
||||
)
|
||||
self.assertTrue(mr_name)
|
||||
pending_items = get_item_default_suppliers(mr_name)
|
||||
self.assertEqual(len(pending_items), 1)
|
||||
self.assertEqual(flt(pending_items[0]["pending_qty"], 3), 110.232)
|
||||
|
||||
purchase_orders = make_purchase_orders_by_supplier(
|
||||
mr_name,
|
||||
[
|
||||
row | {"qty": flt(row["pending_qty"], 3), "supplier": "_Test Supplier"}
|
||||
for row in pending_items
|
||||
],
|
||||
)
|
||||
self.assertEqual(len(purchase_orders), 1)
|
||||
po = frappe.get_doc("Purchase Order", purchase_orders[0])
|
||||
self.assertEqual(po.items[0].qty, 110.232)
|
||||
self.assertGreaterEqual(po.items[0].stock_qty, 50000)
|
||||
|
||||
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