feat(manufacturing): prompt for qty when creating material request from work order (#57855)

This commit is contained in:
Mihir Kandoi
2026-08-07 22:55:51 +05:30
committed by GitHub
parent 5e372df533
commit 5822cef1d9
12 changed files with 769 additions and 46 deletions

View File

@@ -9,6 +9,7 @@ the controller; work_order.py re-exports them for backward compatibility.
"""
import json
import math
from functools import partial
import frappe
@@ -476,42 +477,104 @@ def create_pick_list(
):
frappe.has_permission("Pick List", "create", throw=True)
for_qty = for_qty or frappe.parse_json(target_doc).get("for_qty")
max_finished_goods_qty = frappe.db.get_value("Work Order", source_name, "qty")
postprocess = partial(
_set_pick_list_item_qty, for_qty=for_qty, max_finished_goods_qty=max_finished_goods_qty
)
if for_qty is None:
for_qty = frappe.parse_json(target_doc or "{}").get("for_qty")
doc = get_mapped_doc("Work Order", source_name, _pick_list_mapping(postprocess), target_doc)
for_qty = _validated_for_qty(for_qty)
work_order = frappe.get_doc("Work Order", source_name)
allocation = _allocate_material_demand(work_order, for_qty / flt(work_order.qty))
postprocess = partial(_set_pick_list_item_qty, allocation_by_item=allocation)
doc = get_mapped_doc("Work Order", source_name, _pick_list_mapping(postprocess, allocation), target_doc)
_validate_material_is_pending(doc.locations)
doc.purpose = "Material Transfer for Manufacture"
doc.for_qty = for_qty
doc.set_item_locations()
return doc
def _pick_list_mapping(postprocess):
def _pick_list_mapping(postprocess, allocation):
return {
"Work Order": {"doctype": "Pick List", "validation": {"docstatus": ["=", 1]}},
"Work Order Item": {
"doctype": "Pick List Item",
"field_no_map": ["transferred_qty"],
"postprocess": postprocess,
"condition": lambda doc: abs(doc.transferred_qty) < abs(doc.required_qty),
"condition": lambda doc: _allocation_key(doc) in allocation,
},
}
def _set_pick_list_item_qty(source, target, source_parent, for_qty, max_finished_goods_qty):
pending_to_issue = flt(source.required_qty) - flt(source.transferred_qty)
desire_to_transfer = flt(source.required_qty) / max_finished_goods_qty * flt(for_qty)
def _allocate_material_demand(work_order, fraction):
"""Fraction of each (item, warehouse, operation row) group's requirement, capped
at the group's proportional share of the item's pending pool."""
required_by_item = {}
covered_by_item = {}
required_by_group = {}
for row in work_order.required_items:
required_by_item[row.item_code] = required_by_item.get(row.item_code, 0.0) + flt(row.required_qty)
covered_by_item.setdefault(
row.item_code,
flt(row.transferred_qty) + flt(row.requested_qty) + flt(row.picked_qty),
)
key = _allocation_key(row)
required_by_group[key] = required_by_group.get(key, 0.0) + flt(row.required_qty)
qty = 0
if desire_to_transfer <= pending_to_issue:
qty = desire_to_transfer
elif pending_to_issue > 0:
qty = pending_to_issue
pending_pool = {
item_code: required_qty - covered_by_item[item_code]
for item_code, required_qty in required_by_item.items()
}
if not qty:
allocation = {}
for key, required_qty in required_by_group.items():
item_code = key[0]
if required_by_item[item_code] <= 0:
continue
pool_share = pending_pool[item_code] * required_qty / required_by_item[item_code]
qty = min(required_qty * fraction, pool_share)
if qty > 0:
allocation[key] = qty
return allocation
def _allocation_key(row):
"""Manual rows have no operation_row_id; their operation label splits them."""
return (row.item_code, row.source_warehouse, cint(row.operation_row_id) or row.operation)
def _merge_allocation_per_item(allocation):
"""Material Request rejects repeated item codes unless Buying Settings allows them."""
merged = {}
key_by_item = {}
for key, qty in allocation.items():
item_code = key[0]
if item_code in key_by_item:
merged[key_by_item[item_code]] += qty
else:
key_by_item[item_code] = key
merged[key] = qty
return merged
def _validated_for_qty(for_qty):
qty = flt(for_qty)
if not math.isfinite(qty) or qty <= 0:
frappe.throw(_("Quantity must be greater than zero."))
return qty
def _validate_material_is_pending(rows):
if not rows:
frappe.throw(
_("All required items have already been transferred, requested or picked."),
title=_("No Pending Materials"),
)
def _set_pick_list_item_qty(source, target, source_parent, allocation_by_item):
qty = allocation_by_item.pop(_allocation_key(source), 0.0)
if qty <= 0:
target.delete()
return
@@ -523,15 +586,32 @@ def _set_pick_list_item_qty(source, target, source_parent, for_qty, max_finished
@frappe.whitelist()
def make_material_request(source_name: str, target_doc: str | dict | Document | None = None):
def make_material_request(
source_name: str, target_doc: str | dict | Document | None = None, for_qty: float | None = None
):
frappe.has_permission("Material Request", "create", throw=True)
doc = get_mapped_doc("Work Order", source_name, _material_request_mapping(), target_doc)
if for_qty is None and frappe.flags.args:
for_qty = frappe.flags.args.for_qty
work_order = frappe.get_doc("Work Order", source_name)
fraction = 1.0
if for_qty is not None:
fraction = _validated_for_qty(for_qty) / flt(work_order.qty)
allocation = _allocate_material_demand(work_order, fraction)
if not cint(frappe.db.get_single_value("Buying Settings", "allow_multiple_items")):
allocation = _merge_allocation_per_item(allocation)
postprocess = partial(_set_material_request_item, allocation_by_item=allocation)
doc = get_mapped_doc(
"Work Order", source_name, _material_request_mapping(postprocess, allocation), target_doc
)
_validate_material_is_pending(doc.items)
doc.material_request_type = "Material Transfer"
return doc
def _material_request_mapping():
def _material_request_mapping(postprocess, allocation):
return {
"Work Order": {
"doctype": "Material Request",
@@ -541,19 +621,23 @@ def _material_request_mapping():
"Work Order Item": {
"doctype": "Material Request Item",
"field_map": [
("required_qty", "qty"),
("stock_uom", "uom"),
("source_warehouse", "from_warehouse"),
],
"postprocess": _set_material_request_item,
"condition": lambda doc: abs(doc.transferred_qty) < abs(doc.required_qty),
"postprocess": postprocess,
"condition": lambda doc: _allocation_key(doc) in allocation,
},
}
def _set_material_request_item(source, target, source_parent):
def _set_material_request_item(source, target, source_parent, allocation_by_item):
qty = allocation_by_item.pop(_allocation_key(source), 0.0)
if qty <= 0:
target.delete()
return
target.warehouse = source_parent.wip_warehouse
target.qty = flt(source.required_qty) - flt(source.transferred_qty)
target.qty = qty
target.schedule_date = nowdate()

View File

@@ -9,6 +9,7 @@ callers and the whitelisted entry point keep working unchanged.
"""
import frappe
from frappe import _
from frappe.utils import flt
from pypika import functions as fn
@@ -198,6 +199,97 @@ class RequiredItemsService:
for row in self.doc.required_items:
row.db_set("returned_qty", (returned_dict.get(row.item_code) or 0.0), update_modified=False)
def validate_incoming_material_demand(self, incoming_qty_by_item):
"""Reject demand exceeding the pending requirement; callers must hold the
work order row lock (for_update=True)."""
required_by_item = {}
uom_by_item = {}
for row in self.doc.required_items:
required_by_item[row.item_code] = required_by_item.get(row.item_code, 0.0) + flt(row.required_qty)
uom_by_item.setdefault(row.item_code, row.stock_uom)
transferred = self._material_transfer_qty_by_item(is_return=0)
requested = self._material_request_pending_qty_by_item()
picked = self._pick_list_pending_qty_by_item()
for item_code, incoming_qty in incoming_qty_by_item.items():
if item_code not in required_by_item:
continue
pending = (
required_by_item[item_code]
- flt(transferred.get(item_code))
- flt(requested.get(item_code))
- flt(picked.get(item_code))
)
if flt(incoming_qty - pending, 6) > 0:
frappe.throw(
_("Only {0} {1} of {2} is pending in Work Order {3}.").format(
max(pending, 0.0), uom_by_item[item_code], item_code, self.doc.name
),
title=_("Exceeds Pending Qty"),
)
def update_requested_qty_for_required_items(self):
"""Refresh per-row qty requested via open Material Requests but not yet transferred."""
requested_items = self._material_request_pending_qty_by_item()
for row in self.doc.required_items:
row.db_set("requested_qty", (requested_items.get(row.item_code) or 0.0), update_modified=False)
def _material_request_pending_qty_by_item(self):
mr = frappe.qb.DocType("Material Request")
mr_item = frappe.qb.DocType("Material Request Item")
query = (
frappe.qb.from_(mr)
.inner_join(mr_item)
.on(mr_item.parent == mr.name)
.select(mr_item.item_code, fn.Sum(mr_item.stock_qty - mr_item.ordered_qty).as_("qty"))
.where(
(mr.docstatus == 1)
& (mr.work_order == self.doc.name)
& (mr.material_request_type == "Material Transfer")
& (mr.status != "Stopped")
& (mr_item.stock_qty > mr_item.ordered_qty)
)
.groupby(mr_item.item_code)
)
return frappe._dict({d.item_code: flt(d.qty) for d in query.run(as_dict=1)})
def update_picked_qty_for_required_items(self):
"""Refresh per-row qty picked but not yet transferred. Rows of a live material
request count as requested_qty instead, until that request stops or cancels."""
picked_items = self._pick_list_pending_qty_by_item()
for row in self.doc.required_items:
row.db_set("picked_qty", (picked_items.get(row.item_code) or 0.0), update_modified=False)
def _pick_list_pending_qty_by_item(self):
pick_list = frappe.qb.DocType("Pick List")
pick_list_item = frappe.qb.DocType("Pick List Item")
mr = frappe.qb.DocType("Material Request")
query = (
frappe.qb.from_(pick_list)
.inner_join(pick_list_item)
.on(pick_list_item.parent == pick_list.name)
.left_join(mr)
.on(pick_list_item.material_request == mr.name)
.select(
pick_list_item.item_code,
fn.Sum(pick_list_item.picked_qty - pick_list_item.transferred_qty).as_("qty"),
)
.where(
(pick_list.docstatus == 1)
& (pick_list.work_order == self.doc.name)
& (pick_list_item.picked_qty > pick_list_item.transferred_qty)
& (
(fn.Coalesce(pick_list_item.material_request_item, "") == "")
| (mr.docstatus != 1)
| (mr.status == "Stopped")
)
)
.groupby(pick_list_item.item_code)
)
return frappe._dict({d.item_code: flt(d.qty) for d in query.run(as_dict=1)})
def _material_transfer_qty_by_item(self, is_return):
ste = frappe.qb.DocType("Stock Entry")
ste_child = frappe.qb.DocType("Stock Entry Detail")

View File

@@ -1638,6 +1638,359 @@ class TestWorkOrder(ERPNextTestSuite):
self.assertEqual(work_order.material_transferred_for_manufacturing, 0.0)
self.assertEqual(work_order.status, "In Process")
def test_material_request_qty_scales_with_requested_qty(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
required_qty = {row.item_code: flt(row.required_qty) for row in work_order.required_items}
mr = make_material_request(work_order.name, for_qty=4)
self.assertEqual(len(mr.items), len(required_qty))
for row in mr.items:
self.assertEqual(row.qty, required_qty[row.item_code] * 4 / 10)
mr = make_material_request(work_order.name)
for row in mr.items:
self.assertEqual(row.qty, required_qty[row.item_code])
def test_material_request_qty_capped_at_pending_qty(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
partially_transferred = work_order.required_items[0]
partially_transferred.db_set("transferred_qty", flt(partially_transferred.required_qty) - 1)
work_order.reload()
mr = make_material_request(work_order.name, for_qty=10)
requested_qty = {row.item_code: row.qty for row in mr.items}
self.assertEqual(requested_qty[partially_transferred.item_code], 1)
def test_material_request_maps_only_selected_rows(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
selected = work_order.required_items[0]
try:
frappe.flags.selected_children = {"required_items": [selected.name]}
mr = make_material_request(work_order.name, for_qty=4)
finally:
frappe.flags.selected_children = None
self.assertEqual([row.item_code for row in mr.items], [selected.item_code])
self.assertEqual(mr.items[0].qty, flt(selected.required_qty) * 4 / 10)
def test_material_request_rejects_nonpositive_qty(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
self.assertRaises(frappe.ValidationError, make_material_request, work_order.name, for_qty=0)
self.assertRaises(frappe.ValidationError, make_material_request, work_order.name, for_qty=-1)
self.assertRaises(
frappe.ValidationError, make_material_request, work_order.name, for_qty=float("inf")
)
self.assertRaises(
frappe.ValidationError, make_material_request, work_order.name, for_qty=float("nan")
)
def test_pick_list_rejects_nonpositive_qty(self):
from erpnext.manufacturing.doctype.work_order.mapper import create_pick_list
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name, for_qty=0)
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name, for_qty=-1)
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name, for_qty=float("inf"))
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name, for_qty=float("nan"))
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name)
def submit_material_request(self, work_order_name, for_qty=None):
mr = make_material_request(work_order_name, for_qty=for_qty)
mr.schedule_date = today()
for item in mr.items:
item.schedule_date = today()
mr.insert()
mr.submit()
return mr
def receive_test_fg_raw_materials(self):
test_stock_entry.make_stock_entry(
item_code="_Test Item", target="Stores - _TC", qty=100, basic_rate=5000.0
)
test_stock_entry.make_stock_entry(
item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=100, basic_rate=1000.0
)
def test_requested_qty_tracks_open_material_requests(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
mr = self.submit_material_request(work_order.name, for_qty=4)
mr_qty = {row.item_code: flt(row.qty) for row in mr.items}
work_order.reload()
for row in work_order.required_items:
self.assertEqual(row.requested_qty, mr_qty[row.item_code])
remainder_mr = make_material_request(work_order.name, for_qty=10)
for row in remainder_mr.items:
required_row = next(item for item in work_order.required_items if item.item_code == row.item_code)
self.assertEqual(row.qty, flt(required_row.required_qty) - mr_qty[row.item_code])
mr.cancel()
work_order.reload()
for row in work_order.required_items:
self.assertEqual(row.requested_qty, 0)
def test_requested_qty_moves_to_transferred_qty_on_stock_entry(self):
from erpnext.stock.doctype.material_request.mapper import make_stock_entry as mr_to_stock_entry
self.receive_test_fg_raw_materials()
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
mr = self.submit_material_request(work_order.name, for_qty=4)
mr_qty = {row.item_code: flt(row.qty) for row in mr.items}
stock_entry = frappe.get_doc(mr_to_stock_entry(mr.name))
stock_entry.insert()
stock_entry.submit()
work_order.reload()
for row in work_order.required_items:
self.assertEqual(row.requested_qty, 0)
self.assertEqual(row.transferred_qty, mr_qty[row.item_code])
remainder_mr = make_material_request(work_order.name)
for row in remainder_mr.items:
required_row = next(item for item in work_order.required_items if item.item_code == row.item_code)
self.assertEqual(row.qty, flt(required_row.required_qty) - mr_qty[row.item_code])
def test_picked_qty_tracks_open_pick_lists(self):
from erpnext.manufacturing.doctype.work_order.mapper import create_pick_list
self.receive_test_fg_raw_materials()
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
pick_list = create_pick_list(work_order.name, for_qty=4)
pick_list.insert()
pick_list.submit()
picked_qty = {row.item_code: flt(row.stock_qty) for row in pick_list.locations}
work_order.reload()
for row in work_order.required_items:
self.assertEqual(row.picked_qty, picked_qty[row.item_code])
remainder_pick_list = create_pick_list(work_order.name, for_qty=10)
for row in remainder_pick_list.locations:
required_row = next(item for item in work_order.required_items if item.item_code == row.item_code)
self.assertEqual(row.qty, flt(required_row.required_qty) - picked_qty[row.item_code])
remainder_pick_list.insert()
remainder_pick_list.submit()
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name, for_qty=10)
def test_material_request_submit_rejects_exceeding_pending_qty(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
def full_draft():
mr = make_material_request(work_order.name)
mr.schedule_date = today()
for item in mr.items:
item.schedule_date = today()
mr.insert()
return mr
first, second = full_draft(), full_draft()
first.submit()
self.assertRaises(frappe.ValidationError, second.submit)
def test_picked_qty_counts_pick_list_of_stopped_material_request(self):
from erpnext.stock.doctype.material_request.mapper import create_pick_list as mr_to_pick_list
self.receive_test_fg_raw_materials()
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
mr = self.submit_material_request(work_order.name, for_qty=4)
mr_qty = {row.item_code: flt(row.qty) for row in mr.items}
pick_list = mr_to_pick_list(mr.name)
pick_list.insert()
pick_list.submit()
work_order.reload()
for row in work_order.required_items:
self.assertEqual(row.requested_qty, mr_qty[row.item_code])
self.assertEqual(row.picked_qty, 0)
mr.reload()
mr.update_status("Stopped")
work_order.reload()
for row in work_order.required_items:
self.assertEqual(row.requested_qty, 0)
self.assertEqual(row.picked_qty, mr_qty[row.item_code])
def test_pending_demand_shared_across_duplicate_item_rows(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
first = work_order.required_items[0]
duplicate = work_order.append(
"required_items",
{
"item_code": first.item_code,
"required_qty": 5,
"stock_uom": first.stock_uom,
"source_warehouse": first.source_warehouse,
"docstatus": 1,
},
)
duplicate.db_insert()
work_order.reload()
total_required = flt(first.required_qty) + 5
mr = self.submit_material_request(work_order.name, for_qty=4)
requested = sum(flt(row.qty) for row in mr.items if row.item_code == first.item_code)
self.assertAlmostEqual(requested, total_required * 4 / 10, places=6)
work_order.reload()
for row in work_order.required_items:
if row.item_code == first.item_code:
self.assertAlmostEqual(row.requested_qty, requested, places=6)
remainder_mr = make_material_request(work_order.name, for_qty=10)
remainder = sum(flt(row.qty) for row in remainder_mr.items if row.item_code == first.item_code)
self.assertAlmostEqual(remainder, total_required - requested, places=6)
def test_allocation_splits_by_source_warehouse(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
first = work_order.required_items[0]
duplicate = work_order.append(
"required_items",
{
"item_code": first.item_code,
"required_qty": 5,
"stock_uom": first.stock_uom,
"source_warehouse": "_Test Warehouse 1 - _TC",
"docstatus": 1,
},
)
duplicate.db_insert()
work_order.reload()
with self.change_settings("Buying Settings", {"allow_multiple_items": 1}):
mr = make_material_request(work_order.name, for_qty=4)
rows = {row.from_warehouse: flt(row.qty) for row in mr.items if row.item_code == first.item_code}
self.assertEqual(len(rows), 2)
self.assertAlmostEqual(rows["Stores - _TC"], flt(first.required_qty) * 4 / 10, places=6)
self.assertAlmostEqual(rows["_Test Warehouse 1 - _TC"], 5 * 4 / 10, places=6)
def test_allocation_collapses_groups_when_multiple_items_disallowed(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
first = work_order.required_items[0]
duplicate = work_order.append(
"required_items",
{
"item_code": first.item_code,
"required_qty": 5,
"stock_uom": first.stock_uom,
"source_warehouse": "_Test Warehouse 1 - _TC",
"docstatus": 1,
},
)
duplicate.db_insert()
work_order.reload()
mr = self.submit_material_request(work_order.name, for_qty=4)
rows = [row for row in mr.items if row.item_code == first.item_code]
self.assertEqual(len(rows), 1)
self.assertAlmostEqual(flt(rows[0].qty), (flt(first.required_qty) + 5) * 4 / 10, places=6)
def test_remainder_allocation_splits_proportionally_across_groups(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
first = work_order.required_items[0]
duplicate = work_order.append(
"required_items",
{
"item_code": first.item_code,
"required_qty": 5,
"stock_uom": first.stock_uom,
"source_warehouse": "_Test Warehouse 1 - _TC",
"docstatus": 1,
},
)
duplicate.db_insert()
work_order.reload()
with self.change_settings("Buying Settings", {"allow_multiple_items": 1}):
self.submit_material_request(work_order.name, for_qty=4)
work_order.reload()
remainder = make_material_request(work_order.name, for_qty=10)
rows = {
row.from_warehouse: flt(row.qty) for row in remainder.items if row.item_code == first.item_code
}
self.assertAlmostEqual(rows["Stores - _TC"], flt(first.required_qty) * 6 / 10, places=6)
self.assertAlmostEqual(rows["_Test Warehouse 1 - _TC"], 5 * 6 / 10, places=6)
def test_allocation_splits_manual_rows_by_operation_label(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
first = work_order.required_items[0]
for operation in ("_Test Operation A", "_Test Operation B"):
row = work_order.append(
"required_items",
{
"item_code": first.item_code,
"required_qty": 5,
"stock_uom": first.stock_uom,
"source_warehouse": first.source_warehouse,
"operation": operation,
"docstatus": 1,
},
)
row.db_insert()
work_order.reload()
with self.change_settings("Buying Settings", {"allow_multiple_items": 1}):
mr = make_material_request(work_order.name, for_qty=4)
rows = [flt(row.qty) for row in mr.items if row.item_code == first.item_code]
self.assertEqual(len(rows), 3)
self.assertAlmostEqual(sum(rows), (flt(first.required_qty) + 10) * 4 / 10, places=6)
def test_pick_list_rejects_over_pick_against_material_request(self):
from erpnext.stock.doctype.material_request.mapper import create_pick_list as mr_to_pick_list
self.receive_test_fg_raw_materials()
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
mr = self.submit_material_request(work_order.name, for_qty=4)
pick_list = mr_to_pick_list(mr.name)
pick_list.insert()
pick_list.locations[0].picked_qty = flt(pick_list.locations[0].stock_qty) + 1
self.assertRaises(frappe.ValidationError, pick_list.submit)
def test_backflushed_batch_raw_materials_based_on_transferred(self):
frappe.db.set_single_value(
"Manufacturing Settings",

View File

@@ -852,7 +852,10 @@ erpnext.work_order = {
function () {
let purpose = "Material Transfer for Manufacture";
erpnext.work_order
.show_prompt_for_qty_input(frm, purpose, qty, 1)
.show_prompt_for_qty_input(frm, purpose, {
qty: qty,
additional_transfer_entry: 1,
})
.then((data) => {
return frappe.xcall(
"erpnext.manufacturing.doctype.work_order.mapper.make_stock_entry",
@@ -1038,6 +1041,26 @@ erpnext.work_order = {
return flt(max, precision("qty"));
},
get_max_requestable_qty: (frm) => {
const required = {};
const covered = {};
(frm.doc.required_items || []).forEach((row) => {
required[row.item_code] = (required[row.item_code] || 0) + flt(row.required_qty);
if (!(row.item_code in covered)) {
covered[row.item_code] =
flt(row.transferred_qty) + flt(row.requested_qty) + flt(row.picked_qty);
}
});
let max_fraction = 0;
Object.keys(required).forEach((item_code) => {
if (required[item_code] <= 0) return;
const pending = required[item_code] - covered[item_code];
max_fraction = Math.max(max_fraction, pending / required[item_code]);
});
return flt(max_fraction * flt(frm.doc.qty), precision("qty"));
},
show_disassembly_prompt: function (frm) {
let max_qty = flt(frm.doc.produced_qty - frm.doc.disassembled_qty);
@@ -1092,20 +1115,20 @@ erpnext.work_order = {
});
},
show_prompt_for_qty_input: function (frm, purpose, qty, additional_transfer_entry) {
let max = !additional_transfer_entry ? this.get_max_transferable_qty(frm, purpose) : qty;
show_prompt_for_qty_input: function (frm, purpose, { qty, additional_transfer_entry, target } = {}) {
let max = qty == null ? this.get_max_transferable_qty(frm, purpose) : qty;
let fields = [
{
fieldtype: "Float",
label: __("Qty for {0}", [__(purpose)]),
label: __("Qty for {0}", [target || __(purpose)]),
fieldname: "qty",
description: __("Max: {0}", [max]),
default: max,
},
];
if (!additional_transfer_entry) {
if (!additional_transfer_entry && !target) {
fields.push({
fieldtype: "Check",
label: __("Consider Process Loss"),
@@ -1127,6 +1150,11 @@ erpnext.work_order = {
(data) => {
max += (frm.doc.qty * (frm.doc.__onload.overproduction_percentage || 0.0)) / 100;
if (!data.qty || data.qty <= 0) {
frappe.msgprint(__("Quantity must be greater than zero."));
reject();
return;
}
if (data.qty > max) {
frappe.msgprint(__("Quantity must not be more than {0}", [max]));
reject();
@@ -1169,15 +1197,32 @@ erpnext.work_order = {
}
},
make_material_request: function (frm) {
frappe.model.open_mapped_doc({
method: "erpnext.manufacturing.doctype.work_order.mapper.make_material_request",
frm,
});
make_material_request: function (frm, purpose = "Material Transfer for Manufacture") {
const max = this.get_max_requestable_qty(frm);
if (max <= 0) {
frappe.msgprint(__("All required items have already been transferred, requested or picked."));
return;
}
const get_material_request = (for_qty) =>
frappe.model.open_mapped_doc({
method: "erpnext.manufacturing.doctype.work_order.mapper.make_material_request",
frm,
args: { for_qty: for_qty },
});
this.show_prompt_for_qty_input(frm, purpose, {
qty: max,
target: __("Material Request"),
}).then((data) => get_material_request(data.qty));
},
create_pick_list: function (frm, purpose = "Material Transfer for Manufacture") {
const max = this.get_max_transferable_qty(frm, purpose);
const max = this.get_max_requestable_qty(frm);
if (max <= 0) {
frappe.msgprint(__("All required items have already been transferred, requested or picked."));
return;
}
const get_pick_list = (for_qty) =>
frappe
@@ -1190,11 +1235,10 @@ erpnext.work_order = {
frappe.set_route("Form", pick_list.doctype, pick_list.name);
});
if (max <= 0) {
get_pick_list(frm.doc.qty);
} else {
this.show_prompt_for_qty_input(frm, purpose).then((data) => get_pick_list(data.qty));
}
this.show_prompt_for_qty_input(frm, purpose, {
qty: max,
target: __("Pick List"),
}).then((data) => get_pick_list(data.qty));
},
make_consumption_se: function (frm, backflush_raw_materials_based_on) {

View File

@@ -22,6 +22,8 @@
"amount",
"column_break_11",
"transferred_qty",
"requested_qty",
"picked_qty",
"consumed_qty",
"returned_qty",
"section_break_idhr",
@@ -93,6 +95,22 @@
"label": "Transferred Qty",
"read_only": 1
},
{
"depends_on": "eval:!parent.skip_transfer",
"fieldname": "requested_qty",
"fieldtype": "Float",
"label": "Requested Qty",
"no_copy": 1,
"read_only": 1
},
{
"depends_on": "eval:!parent.skip_transfer",
"fieldname": "picked_qty",
"fieldtype": "Float",
"label": "Picked Qty",
"no_copy": 1,
"read_only": 1
},
{
"default": "0",
"depends_on": "eval:!parent.subcontracting_inward_order",
@@ -209,7 +227,7 @@
"grid_page_length": 50,
"istable": 1,
"links": [],
"modified": "2026-05-12 12:05:16.687866",
"modified": "2026-08-07 10:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order Item",

View File

@@ -31,8 +31,10 @@ class WorkOrderItem(Document):
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
picked_qty: DF.Float
rate: DF.Currency
required_qty: DF.Float
requested_qty: DF.Float
returned_qty: DF.Float
source_warehouse: DF.Link | None
stock_reserved_qty: DF.Float

View File

@@ -508,3 +508,4 @@ erpnext.patches.v16_0.move_warehouse_defaults_to_company
erpnext.patches.v16_0.backfill_repost_accounting_ledger_status
erpnext.patches.v16_0.merge_seeded_item_group_root
erpnext.patches.v16_0.set_stock_uom_in_job_card
erpnext.patches.v16_0.set_work_order_requested_and_picked_qty

View File

@@ -0,0 +1,38 @@
import frappe
from erpnext.manufacturing.doctype.work_order.services.required_items import RequiredItemsService
def execute():
"""Backfill requested_qty and picked_qty for work orders with open demand;
fulfilled documents leave the zero default."""
work_orders = set(
frappe.get_all(
"Material Request",
filters={
"docstatus": 1,
"material_request_type": "Material Transfer",
"work_order": ("is", "set"),
"status": ("!=", "Stopped"),
"per_ordered": ("<", 100),
},
pluck="work_order",
distinct=True,
)
)
work_orders.update(
frappe.get_all(
"Pick List",
filters={"docstatus": 1, "work_order": ("is", "set"), "status": ("!=", "Completed")},
pluck="work_order",
distinct=True,
)
)
for name in work_orders:
if frappe.db.get_value("Work Order", name, "docstatus") != 1:
continue
service = RequiredItemsService(frappe.get_doc("Work Order", name))
service.update_requested_qty_for_required_items()
service.update_picked_qty_for_required_items()

View File

@@ -315,7 +315,8 @@
"fieldtype": "Link",
"label": "Work Order",
"options": "Work Order",
"read_only": 1
"read_only": 1,
"search_index": 1
},
{
"fieldname": "terms_tab",
@@ -376,7 +377,7 @@
"idx": 70,
"is_submittable": 1,
"links": [],
"modified": "2026-07-30 11:04:31.517204",
"modified": "2026-08-07 10:30:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Material Request",

View File

@@ -273,6 +273,7 @@ class MaterialRequest(BuyingController):
def on_submit(self):
self.update_requested_qty_in_production_plan()
self.update_requested_qty()
self.update_requested_qty_in_work_order()
if self.material_request_type == "Purchase":
self.update_prevdoc_status()
if frappe.db.exists("Budget", {"applicable_on_material_request": 1, "docstatus": 1}):
@@ -283,6 +284,20 @@ class MaterialRequest(BuyingController):
def before_submit(self):
self.set_status(update=True)
self.validate_pending_qty_in_work_order()
def validate_pending_qty_in_work_order(self):
if not self.work_order or self.material_request_type != "Material Transfer":
return
from erpnext.manufacturing.doctype.work_order.services.required_items import RequiredItemsService
work_order = frappe.get_doc("Work Order", self.work_order, for_update=True)
incoming = {}
for row in self.items:
incoming[row.item_code] = incoming.get(row.item_code, 0.0) + flt(row.stock_qty)
RequiredItemsService(work_order).validate_incoming_material_demand(incoming)
def before_cancel(self):
# if MRQ is already closed, no point saving the document
@@ -301,6 +316,7 @@ class MaterialRequest(BuyingController):
self.status_can_change(status)
self.set_status(update=True, status=status)
self.update_requested_qty()
self.update_requested_qty_in_work_order()
def status_can_change(self, status):
"""
@@ -330,6 +346,7 @@ class MaterialRequest(BuyingController):
def on_cancel(self):
self.update_requested_qty_in_production_plan(cancel=True)
self.update_requested_qty()
self.update_requested_qty_in_work_order()
if self.material_request_type == "Purchase":
self.update_prevdoc_status()
@@ -417,6 +434,19 @@ class MaterialRequest(BuyingController):
update_modified,
)
self.update_requested_qty_in_work_order()
def update_requested_qty_in_work_order(self):
"""Refresh both counters: stop and cancel also flip pick list coverage."""
if not self.work_order or self.material_request_type != "Material Transfer":
return
from erpnext.manufacturing.doctype.work_order.services.required_items import RequiredItemsService
service = RequiredItemsService(frappe.get_doc("Work Order", self.work_order))
service.update_requested_qty_for_required_items()
service.update_picked_qty_for_required_items()
def update_requested_qty(self, mr_item_rows=None):
"""update requested qty (before ordered_qty is updated)"""
item_wh_list = []

View File

@@ -80,7 +80,8 @@
"fieldname": "work_order",
"fieldtype": "Link",
"label": "Work Order",
"options": "Work Order"
"options": "Work Order",
"search_index": 1
},
{
"fieldname": "locations",
@@ -278,7 +279,7 @@
],
"is_submittable": 1,
"links": [],
"modified": "2026-07-01 14:27:50.617011",
"modified": "2026-08-07 10:30:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Pick List",

View File

@@ -240,6 +240,45 @@ class PickList(TransactionBase):
def before_submit(self):
self.validate_sales_order()
self.validate_picked_items()
self.validate_pending_qty_in_work_order()
def validate_pending_qty_in_work_order(self):
"""Rows covered by a live material request must stay within that request;
every other row must fit the work order's pending requirement."""
if not self.work_order:
return
from erpnext.manufacturing.doctype.work_order.services.required_items import RequiredItemsService
work_order = frappe.get_doc("Work Order", self.work_order, for_update=True)
live_requests = {}
request_pending = {}
incoming = {}
for row in self.locations:
if row.material_request not in live_requests:
live_requests[row.material_request] = is_live_material_request(row.material_request)
if not (row.material_request_item and live_requests[row.material_request]):
incoming[row.item_code] = incoming.get(row.item_code, 0.0) + flt(row.picked_qty)
continue
if row.material_request_item not in request_pending:
stock_qty, ordered_qty = frappe.db.get_value(
"Material Request Item", row.material_request_item, ["stock_qty", "ordered_qty"]
)
request_pending[row.material_request_item] = flt(stock_qty) - flt(ordered_qty)
if flt(row.picked_qty - request_pending[row.material_request_item], 6) > 0:
frappe.throw(
_("Row #{0}: picked qty {1} {2} exceeds the pending qty in Material Request {3}.").format(
row.idx, row.picked_qty, row.stock_uom, row.material_request
),
title=_("Exceeds Requested Qty"),
)
request_pending[row.material_request_item] -= flt(row.picked_qty)
RequiredItemsService(work_order).validate_incoming_material_demand(incoming)
def validate_sales_order(self):
"""Raises an exception if the `Sales Order` has reserved stock."""
@@ -281,6 +320,7 @@ class PickList(TransactionBase):
self.update_bundle_picked_qty()
self.update_reference_qty()
self.update_sales_order_picking_status()
self.update_picked_qty_in_work_order()
self.update_prevdoc_status()
def validate_expired_batches(self):
@@ -358,6 +398,7 @@ class PickList(TransactionBase):
self.update_bundle_picked_qty()
self.update_reference_qty()
self.update_sales_order_picking_status()
self.update_picked_qty_in_work_order()
self.delink_serial_and_batch_bundle()
self.update_prevdoc_status()
@@ -494,6 +535,15 @@ class PickList(TransactionBase):
for sales_order in sales_orders:
frappe.get_doc("Sales Order", sales_order, for_update=True).update_picking_status()
def update_picked_qty_in_work_order(self):
if not self.work_order:
return
from erpnext.manufacturing.doctype.work_order.services.required_items import RequiredItemsService
work_order = frappe.get_doc("Work Order", self.work_order)
RequiredItemsService(work_order).update_picked_qty_for_required_items()
@frappe.whitelist()
def create_stock_reservation_entries(self, notify: bool = True) -> None:
"""Creates Stock Reservation Entries for Sales Order Items against Pick List."""
@@ -936,6 +986,15 @@ def update_pick_list_status(pick_list):
if pick_list:
doc = frappe.get_doc("Pick List", pick_list)
doc.run_method("update_status")
doc.update_picked_qty_in_work_order()
def is_live_material_request(material_request):
if not material_request:
return False
docstatus, status = frappe.db.get_value("Material Request", material_request, ["docstatus", "status"])
return docstatus == 1 and status != "Stopped"
def get_picked_items_qty(items, contains_packed_items=False) -> list[dict]: