mirror of
https://github.com/frappe/erpnext.git
synced 2026-05-26 00:14:50 +00:00
Merge pull request #51103 from frappe/mergify/bp/version-15-hotfix/pr-50788
fix: cascade projected quantity across multiple items in material requests (backport #50788)
This commit is contained in:
@@ -1303,14 +1303,21 @@ def get_material_request_items(
|
|||||||
include_safety_stock,
|
include_safety_stock,
|
||||||
warehouse,
|
warehouse,
|
||||||
bin_dict,
|
bin_dict,
|
||||||
|
consumed_qty,
|
||||||
):
|
):
|
||||||
total_qty = row["qty"]
|
|
||||||
|
|
||||||
required_qty = 0
|
required_qty = 0
|
||||||
|
item_code = row.get("item_code")
|
||||||
|
|
||||||
if ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0:
|
if ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0:
|
||||||
required_qty = total_qty
|
required_qty = flt(row.get("qty"))
|
||||||
elif total_qty > bin_dict.get("projected_qty", 0):
|
else:
|
||||||
required_qty = total_qty - bin_dict.get("projected_qty", 0)
|
key = (item_code, warehouse)
|
||||||
|
available_qty = flt(bin_dict.get("projected_qty", 0)) - consumed_qty[key]
|
||||||
|
if available_qty > 0:
|
||||||
|
required_qty = max(0, flt(row.get("qty")) - available_qty)
|
||||||
|
consumed_qty[key] += min(flt(row.get("qty")), available_qty)
|
||||||
|
else:
|
||||||
|
required_qty = flt(row.get("qty"))
|
||||||
|
|
||||||
if doc.get("consider_minimum_order_qty") and required_qty > 0 and required_qty < row["min_order_qty"]:
|
if doc.get("consider_minimum_order_qty") and required_qty > 0 and required_qty < row["min_order_qty"]:
|
||||||
required_qty = row["min_order_qty"]
|
required_qty = row["min_order_qty"]
|
||||||
@@ -1354,7 +1361,7 @@ def get_material_request_items(
|
|||||||
"item_name": row.item_name,
|
"item_name": row.item_name,
|
||||||
"quantity": required_qty / conversion_factor,
|
"quantity": required_qty / conversion_factor,
|
||||||
"conversion_factor": conversion_factor,
|
"conversion_factor": conversion_factor,
|
||||||
"required_bom_qty": total_qty,
|
"required_bom_qty": row.get("qty"),
|
||||||
"stock_uom": row.get("stock_uom"),
|
"stock_uom": row.get("stock_uom"),
|
||||||
"warehouse": warehouse
|
"warehouse": warehouse
|
||||||
or row.get("source_warehouse")
|
or row.get("source_warehouse")
|
||||||
@@ -1648,9 +1655,12 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
|
|||||||
so_item_details[sales_order][item_code] = details
|
so_item_details[sales_order][item_code] = details
|
||||||
|
|
||||||
mr_items = []
|
mr_items = []
|
||||||
|
consumed_qty = defaultdict(float)
|
||||||
|
|
||||||
for sales_order in so_item_details:
|
for sales_order in so_item_details:
|
||||||
item_dict = so_item_details[sales_order]
|
item_dict = so_item_details[sales_order]
|
||||||
for details in item_dict.values():
|
for details in item_dict.values():
|
||||||
|
warehouse = warehouse or details.get("source_warehouse") or details.get("default_warehouse")
|
||||||
bin_dict = get_bin_details(details, doc.company, warehouse)
|
bin_dict = get_bin_details(details, doc.company, warehouse)
|
||||||
bin_dict = bin_dict[0] if bin_dict else {}
|
bin_dict = bin_dict[0] if bin_dict else {}
|
||||||
|
|
||||||
@@ -1664,6 +1674,7 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
|
|||||||
include_safety_stock,
|
include_safety_stock,
|
||||||
warehouse,
|
warehouse,
|
||||||
bin_dict,
|
bin_dict,
|
||||||
|
consumed_qty,
|
||||||
)
|
)
|
||||||
if items:
|
if items:
|
||||||
mr_items.append(items)
|
mr_items.append(items)
|
||||||
|
|||||||
@@ -145,6 +145,84 @@ class TestProductionPlan(FrappeTestCase):
|
|||||||
sr2.cancel()
|
sr2.cancel()
|
||||||
pln.cancel()
|
pln.cancel()
|
||||||
|
|
||||||
|
def test_projected_qty_cascading_across_multiple_sales_orders(self):
|
||||||
|
rm_item = make_item(
|
||||||
|
"_Test RM For Cascading",
|
||||||
|
{"is_stock_item": 1, "valuation_rate": 100},
|
||||||
|
).name
|
||||||
|
|
||||||
|
fg_item_a = make_item(
|
||||||
|
"_Test FG A For Cascading",
|
||||||
|
{"is_stock_item": 1, "valuation_rate": 200},
|
||||||
|
).name
|
||||||
|
|
||||||
|
if not frappe.db.exists("BOM", {"item": fg_item_a, "docstatus": 1}):
|
||||||
|
make_bom(item=fg_item_a, raw_materials=[rm_item], rm_qty=1)
|
||||||
|
|
||||||
|
# Stock for RM
|
||||||
|
sr = create_stock_reconciliation(item_code=rm_item, target="_Test Warehouse - _TC", qty=1, rate=100)
|
||||||
|
|
||||||
|
# Sales orders
|
||||||
|
so1 = make_sales_order(item_code=fg_item_a, qty=1)
|
||||||
|
so2 = make_sales_order(item_code=fg_item_a, qty=1)
|
||||||
|
so3 = make_sales_order(item_code=fg_item_a, qty=1)
|
||||||
|
|
||||||
|
# Production plan
|
||||||
|
pln = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Production Plan",
|
||||||
|
"company": "_Test Company",
|
||||||
|
"posting_date": nowdate(),
|
||||||
|
"get_items_from": "Sales Order",
|
||||||
|
"ignore_existing_ordered_qty": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
pln.append(
|
||||||
|
"sales_orders",
|
||||||
|
{
|
||||||
|
"sales_order": so1.name,
|
||||||
|
"sales_order_date": so1.transaction_date,
|
||||||
|
"customer": so1.customer,
|
||||||
|
"grand_total": so1.grand_total,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
pln.append(
|
||||||
|
"sales_orders",
|
||||||
|
{
|
||||||
|
"sales_order": so2.name,
|
||||||
|
"sales_order_date": so2.transaction_date,
|
||||||
|
"customer": so2.customer,
|
||||||
|
"grand_total": so2.grand_total,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
pln.append(
|
||||||
|
"sales_orders",
|
||||||
|
{
|
||||||
|
"sales_order": so3.name,
|
||||||
|
"sales_order_date": so3.transaction_date,
|
||||||
|
"customer": so3.customer,
|
||||||
|
"grand_total": so3.grand_total,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
pln.get_items()
|
||||||
|
pln.insert()
|
||||||
|
|
||||||
|
mr_items = get_items_for_material_requests(pln.as_dict())
|
||||||
|
quantities = [d["quantity"] for d in mr_items]
|
||||||
|
rm_qty = sum(quantities)
|
||||||
|
|
||||||
|
# Only 2 MR item created - the first SO's requirement is fully covered by stock (v15 behaviour)
|
||||||
|
self.assertEqual(len(mr_items), 2)
|
||||||
|
self.assertEqual(rm_qty, 2, "Cascading failed: total MR qty should be 2 (3 needed - 1 in stock)")
|
||||||
|
self.assertEqual(
|
||||||
|
quantities,
|
||||||
|
[1, 1],
|
||||||
|
"Cascading failed: only second and third SO should need procurement (qty=1) since first SO consumed stock",
|
||||||
|
)
|
||||||
|
|
||||||
|
sr.cancel()
|
||||||
|
|
||||||
def test_production_plan_with_non_stock_item(self):
|
def test_production_plan_with_non_stock_item(self):
|
||||||
"Test if MR Planning table includes Non Stock RM."
|
"Test if MR Planning table includes Non Stock RM."
|
||||||
pln = create_production_plan(item_code="Test Production Item 1", include_non_stock_items=1)
|
pln = create_production_plan(item_code="Test Production Item 1", include_non_stock_items=1)
|
||||||
|
|||||||
Reference in New Issue
Block a user