mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-26 13:25:20 +00:00
Merge pull request #57399 from mihir-kandoi/production-plan-reserve-bom-qty
fix: Production Plan raw material qty calculation and bin reservation
This commit is contained in:
@@ -489,9 +489,8 @@ def get_material_request_items(
|
||||
consumed_qty,
|
||||
):
|
||||
required_qty = _required_qty_for_mr(
|
||||
doc, row, ignore_existing_ordered_qty, warehouse, bin_dict, consumed_qty
|
||||
doc, row, ignore_existing_ordered_qty, warehouse, bin_dict, consumed_qty, include_safety_stock
|
||||
)
|
||||
required_qty = _adjust_required_qty_for_uom(row, required_qty, include_safety_stock)
|
||||
item_group_defaults = get_item_group_defaults(row.item_code, company)
|
||||
conversion_factor = _mr_purchase_conversion_factor(row)
|
||||
return _material_request_item_row(
|
||||
@@ -499,24 +498,31 @@ def get_material_request_items(
|
||||
)
|
||||
|
||||
|
||||
def _required_qty_for_mr(doc, row, ignore_existing_ordered_qty, warehouse, bin_dict, consumed_qty):
|
||||
if not ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0:
|
||||
required_qty = flt(row.get("qty"))
|
||||
else:
|
||||
key = (row.get("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"))
|
||||
def _required_qty_for_mr(
|
||||
doc, row, ignore_existing_ordered_qty, warehouse, bin_dict, consumed_qty, include_safety_stock
|
||||
):
|
||||
safety_stock = flt(row["safety_stock"]) if include_safety_stock else 0
|
||||
qty = flt(row.get("qty"))
|
||||
|
||||
if doc.get("consider_minimum_order_qty") and 0 < required_qty < row["min_order_qty"]:
|
||||
required_qty = row["min_order_qty"]
|
||||
if not ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0:
|
||||
required_qty = _apply_minimum_order_qty(doc, row, qty + safety_stock)
|
||||
return _adjust_required_qty_for_uom(row, required_qty)
|
||||
|
||||
key = (row.get("item_code"), warehouse)
|
||||
available_qty = flt(bin_dict.get("projected_qty", 0)) - consumed_qty[key]
|
||||
required_qty = _apply_minimum_order_qty(doc, row, max(0, qty - (available_qty - safety_stock)))
|
||||
required_qty = _adjust_required_qty_for_uom(row, required_qty)
|
||||
consumed_qty[key] += qty - required_qty
|
||||
return required_qty
|
||||
|
||||
|
||||
def _adjust_required_qty_for_uom(row, required_qty, include_safety_stock):
|
||||
def _apply_minimum_order_qty(doc, row, required_qty):
|
||||
if doc.get("consider_minimum_order_qty") and 0 < required_qty < row["min_order_qty"]:
|
||||
return row["min_order_qty"]
|
||||
return required_qty
|
||||
|
||||
|
||||
def _adjust_required_qty_for_uom(row, required_qty):
|
||||
if not row["purchase_uom"]:
|
||||
row["purchase_uom"] = row["stock_uom"]
|
||||
|
||||
@@ -531,8 +537,6 @@ def _adjust_required_qty_for_uom(row, required_qty, include_safety_stock):
|
||||
|
||||
if frappe.db.get_value("UOM", row["purchase_uom"], "must_be_whole_number"):
|
||||
required_qty = ceil(required_qty)
|
||||
if include_safety_stock:
|
||||
required_qty += flt(row["safety_stock"])
|
||||
return required_qty
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
"""Stock reservation for Production Plan (extracted from production_plan.py)."""
|
||||
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
@@ -48,15 +47,11 @@ def get_reserved_qty_for_production_plan(item_code, warehouse):
|
||||
def _production_plan_reserved_qty(item_code, warehouse, non_completed_production_plans):
|
||||
table = frappe.qb.DocType("Production Plan")
|
||||
child = frappe.qb.DocType("Material Request Plan Item")
|
||||
qty = (
|
||||
Case().when(child.quantity == 0, child.required_bom_qty).else_(child.quantity)
|
||||
* child.conversion_factor
|
||||
)
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.inner_join(child)
|
||||
.on(table.name == child.parent)
|
||||
.select(Sum(qty))
|
||||
.select(Sum(child.required_bom_qty))
|
||||
.where(_plan_reserved_filter(table, child, item_code, warehouse))
|
||||
)
|
||||
if non_completed_production_plans:
|
||||
|
||||
@@ -82,7 +82,7 @@ class SubAssemblyService:
|
||||
frappe.throw(_("Row #{0}: Please select the BOM No in Assembly Items").format(row.idx))
|
||||
|
||||
def _warn_sufficient_sub_assembly(self):
|
||||
label = self.meta.get_field("skip_available_sub_assembly_item").label
|
||||
label = self.doc.meta.get_field("skip_available_sub_assembly_item").label
|
||||
message = (
|
||||
_(
|
||||
"As there are sufficient Sub Assembly Items, Work Order is not required for Warehouse {0}."
|
||||
|
||||
@@ -1559,6 +1559,80 @@ class TestProductionPlan(ERPNextTestSuite):
|
||||
reserved_qty_after_mr = flt(frappe.db.get_value("Bin", bin_name, "reserved_qty_for_production_plan"))
|
||||
self.assertEqual(reserved_qty_after_mr, before_qty)
|
||||
|
||||
def test_reserved_qty_for_production_plan_with_partial_stock(self):
|
||||
from erpnext.stock.utils import get_or_make_bin
|
||||
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
rm_item = make_item(properties={"is_stock_item": 1}).name
|
||||
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse="_Test Warehouse - _TC")
|
||||
|
||||
make_stock_entry(item_code=rm_item, qty=4, rate=100, target="_Test Warehouse - _TC")
|
||||
|
||||
bin_name = get_or_make_bin(rm_item, "_Test Warehouse - _TC")
|
||||
before_qty = flt(frappe.db.get_value("Bin", bin_name, "reserved_qty_for_production_plan"))
|
||||
|
||||
pln = create_production_plan(item_code=fg_item, planned_qty=10, ignore_existing_ordered_qty=1)
|
||||
|
||||
row = next(d for d in pln.mr_items if d.item_code == rm_item)
|
||||
self.assertEqual(row.required_bom_qty, 10)
|
||||
self.assertEqual(row.quantity, 6)
|
||||
|
||||
after_qty = flt(frappe.db.get_value("Bin", bin_name, "reserved_qty_for_production_plan"))
|
||||
self.assertEqual(after_qty - before_qty, 10)
|
||||
|
||||
pln.reload()
|
||||
pln.cancel()
|
||||
|
||||
after_cancel = flt(frappe.db.get_value("Bin", bin_name, "reserved_qty_for_production_plan"))
|
||||
self.assertEqual(after_cancel, before_qty)
|
||||
|
||||
def _plan_with_shared_raw_material(self, rm_item, qty_per_order):
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse="_Test Warehouse - _TC")
|
||||
|
||||
pln = create_production_plan(
|
||||
item_code=fg_item,
|
||||
ignore_existing_ordered_qty=1,
|
||||
do_not_save=1,
|
||||
skip_getting_mr_items=1,
|
||||
)
|
||||
pln.get_items_from = "Sales Order"
|
||||
for _ in range(2):
|
||||
so = make_sales_order(item_code=fg_item, qty=qty_per_order)
|
||||
pln.append(
|
||||
"sales_orders",
|
||||
{
|
||||
"sales_order": so.name,
|
||||
"sales_order_date": so.transaction_date,
|
||||
"customer": so.customer,
|
||||
"grand_total": so.grand_total,
|
||||
},
|
||||
)
|
||||
pln.get_items()
|
||||
return pln
|
||||
|
||||
def test_safety_stock_added_once_for_repeated_raw_material(self):
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "safety_stock": 10, "valuation_rate": 100}).name
|
||||
make_stock_entry(item_code=rm_item, qty=100, rate=100, target="_Test Warehouse - _TC")
|
||||
|
||||
pln = self._plan_with_shared_raw_material(rm_item, qty_per_order=50)
|
||||
pln.include_safety_stock = 1
|
||||
|
||||
items = get_items_for_material_requests(pln.as_dict())
|
||||
quantities = sorted(flt(d.get("quantity")) for d in items if d.get("item_code") == rm_item)
|
||||
self.assertEqual(quantities, [0, 10])
|
||||
|
||||
def test_minimum_order_qty_surplus_covers_later_rows(self):
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "min_order_qty": 100, "valuation_rate": 100}).name
|
||||
make_stock_entry(item_code=rm_item, qty=40, rate=100, target="_Test Warehouse - _TC")
|
||||
|
||||
pln = self._plan_with_shared_raw_material(rm_item, qty_per_order=50)
|
||||
pln.consider_minimum_order_qty = 1
|
||||
|
||||
items = get_items_for_material_requests(pln.as_dict())
|
||||
quantities = sorted(flt(d.get("quantity")) for d in items if d.get("item_code") == rm_item)
|
||||
self.assertEqual(quantities, [0, 100])
|
||||
|
||||
def test_from_warehouse_for_purchase_material_request(self):
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
from erpnext.stock.utils import get_or_make_bin
|
||||
|
||||
@@ -501,3 +501,4 @@ erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm
|
||||
erpnext.patches.v16_0.access_control_for_project_users
|
||||
erpnext.patches.v16_0.enable_book_stock_expense_gl_entries
|
||||
execute:frappe.db.set_single_value("Stock Settings", "use_inline_serial_batch_editor", 0)
|
||||
erpnext.patches.v16_0.recompute_production_plan_reserved_qty
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
plans = frappe.get_all(
|
||||
"Production Plan",
|
||||
filters={"docstatus": 1, "status": ("not in", ["Completed", "Closed"])},
|
||||
pluck="name",
|
||||
)
|
||||
if not plans:
|
||||
return
|
||||
|
||||
rows = frappe.get_all(
|
||||
"Material Request Plan Item",
|
||||
filters={"parent": ("in", plans)},
|
||||
fields=["item_code", "warehouse"],
|
||||
)
|
||||
|
||||
for item_code, warehouse in {(row.item_code, row.warehouse) for row in rows}:
|
||||
bin_name = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse})
|
||||
if not bin_name:
|
||||
continue
|
||||
frappe.get_doc("Bin", bin_name, for_update=True).update_reserved_qty_for_production_plan()
|
||||
Reference in New Issue
Block a user