From 5822cef1d98a6b8d082921fbb62d92f804f92c25 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 7 Aug 2026 22:55:51 +0530 Subject: [PATCH] feat(manufacturing): prompt for qty when creating material request from work order (#57855) --- .../doctype/work_order/mapper.py | 134 +++++-- .../work_order/services/required_items.py | 92 +++++ .../doctype/work_order/test_work_order.py | 353 ++++++++++++++++++ .../doctype/work_order/work_order.js | 76 +++- .../work_order_item/work_order_item.json | 20 +- .../work_order_item/work_order_item.py | 2 + erpnext/patches.txt | 1 + ...set_work_order_requested_and_picked_qty.py | 38 ++ .../material_request/material_request.json | 5 +- .../material_request/material_request.py | 30 ++ .../stock/doctype/pick_list/pick_list.json | 5 +- erpnext/stock/doctype/pick_list/pick_list.py | 59 +++ 12 files changed, 769 insertions(+), 46 deletions(-) create mode 100644 erpnext/patches/v16_0/set_work_order_requested_and_picked_qty.py diff --git a/erpnext/manufacturing/doctype/work_order/mapper.py b/erpnext/manufacturing/doctype/work_order/mapper.py index c726b9e38fc..7287a9ecb74 100644 --- a/erpnext/manufacturing/doctype/work_order/mapper.py +++ b/erpnext/manufacturing/doctype/work_order/mapper.py @@ -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() diff --git a/erpnext/manufacturing/doctype/work_order/services/required_items.py b/erpnext/manufacturing/doctype/work_order/services/required_items.py index c1c55977583..09f0e3ab32c 100644 --- a/erpnext/manufacturing/doctype/work_order/services/required_items.py +++ b/erpnext/manufacturing/doctype/work_order/services/required_items.py @@ -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") diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 427a89df811..ec1201d72d4 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -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", diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index b540bb7aaa7..079b2831e78 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -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) { diff --git a/erpnext/manufacturing/doctype/work_order_item/work_order_item.json b/erpnext/manufacturing/doctype/work_order_item/work_order_item.json index dec4934ea4f..208215b7a5e 100644 --- a/erpnext/manufacturing/doctype/work_order_item/work_order_item.json +++ b/erpnext/manufacturing/doctype/work_order_item/work_order_item.json @@ -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", diff --git a/erpnext/manufacturing/doctype/work_order_item/work_order_item.py b/erpnext/manufacturing/doctype/work_order_item/work_order_item.py index 4c40e9d688a..c4f2fc4d916 100644 --- a/erpnext/manufacturing/doctype/work_order_item/work_order_item.py +++ b/erpnext/manufacturing/doctype/work_order_item/work_order_item.py @@ -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 diff --git a/erpnext/patches.txt b/erpnext/patches.txt index f2c2a447817..97cceebe5f7 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -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 diff --git a/erpnext/patches/v16_0/set_work_order_requested_and_picked_qty.py b/erpnext/patches/v16_0/set_work_order_requested_and_picked_qty.py new file mode 100644 index 00000000000..9f30b5142a4 --- /dev/null +++ b/erpnext/patches/v16_0/set_work_order_requested_and_picked_qty.py @@ -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() diff --git a/erpnext/stock/doctype/material_request/material_request.json b/erpnext/stock/doctype/material_request/material_request.json index 1c6d7db5296..922b40325c8 100644 --- a/erpnext/stock/doctype/material_request/material_request.json +++ b/erpnext/stock/doctype/material_request/material_request.json @@ -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", diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index 72760c55972..4350f33c468 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -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 = [] diff --git a/erpnext/stock/doctype/pick_list/pick_list.json b/erpnext/stock/doctype/pick_list/pick_list.json index 55e66f74b3c..c0898c7182f 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.json +++ b/erpnext/stock/doctype/pick_list/pick_list.json @@ -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", diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 50a3a0ebfb4..3cf7c25307f 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -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]: