From 755b9ccbc3cf9f50fd4d1c2488163ce4e167abd0 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 10 Jul 2026 17:46:57 +0530 Subject: [PATCH] fix(stock): link job card in stock entry created from pick list (backport #57031) A Stock Entry created from a Pick List against a job card's Material Request never set job_card, job_card_item, fg_completed_qty or the 'Material Transfer for Manufacture' purpose, so the Job Card did not recognize the transfer and blocked submission. The WIP warehouse was also not populated. Route such pick lists through a job-card-aware branch mirroring the direct Material Request -> Stock Entry mapper, and set the purpose to 'Material Transfer for Manufacture' in the work order branch so the WO -> MR -> Pick List flow updates the work order too. --- .../doctype/job_card/test_job_card.py | 42 +++++++++++++ erpnext/stock/doctype/pick_list/pick_list.py | 59 ++++++++++++++++++- 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 3f75c24a4bb..8551d5e04ff 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -662,6 +662,48 @@ class TestJobCard(ERPNextTestSuite): self.assertEqual(ste.from_bom, 1.0) self.assertEqual(ste.bom_no, work_order.bom_no) + def test_job_card_material_transfer_via_pick_list(self): + from erpnext.stock.doctype.material_request.material_request import create_pick_list + from erpnext.stock.doctype.pick_list.pick_list import ( + create_stock_entry as create_stock_entry_from_pick_list, + ) + + create_bom_with_multiple_operations() + work_order = make_wo_with_transfer_against_jc() + + for item in work_order.required_items: + make_stock_entry( + item_code=item.item_code, + target=item.source_warehouse, + qty=item.required_qty * 2, + basic_rate=100, + ) + + job_card_name = frappe.db.get_value("Job Card", {"work_order": work_order.name}, "name") + job_card = frappe.get_doc("Job Card", job_card_name) + + mr = make_material_request(job_card_name) + mr.schedule_date = today() + mr.submit() + + pick_list = create_pick_list(mr.name) + pick_list.submit() + + ste = frappe.get_doc(create_stock_entry_from_pick_list(pick_list.as_dict())) + self.assertEqual(ste.purpose, "Material Transfer for Manufacture") + self.assertEqual(ste.job_card, job_card_name) + self.assertEqual(ste.work_order, work_order.name) + self.assertEqual(ste.fg_completed_qty, job_card.for_quantity) + for row in ste.items: + self.assertEqual(row.t_warehouse, job_card.wip_warehouse) + self.assertTrue(row.job_card_item) + + ste.insert() + ste.submit() + + job_card.reload() + self.assertEqual(job_card.transferred_qty, job_card.for_quantity) + def test_job_card_proccess_qty_and_completed_qty(self): from erpnext.manufacturing.doctype.routing.test_routing import ( create_routing, diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 4cbef22c241..8b7c6bdf320 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -1585,15 +1585,22 @@ def create_stock_entry(pick_list: str | dict): stock_entry.pick_list = pick_list.get("name") stock_entry.purpose = pick_list.get("purpose") stock_entry.company = pick_list.get("company") - stock_entry.set_stock_entry_type() - if pick_list.get("work_order"): + job_card = pick_list.get("material_request") and frappe.db.get_value( + "Material Request", pick_list.get("material_request"), "job_card" + ) + + if job_card: + stock_entry = update_stock_entry_based_on_job_card(pick_list, stock_entry, job_card) + elif pick_list.get("work_order"): stock_entry = update_stock_entry_based_on_work_order(pick_list, stock_entry) elif pick_list.get("material_request"): stock_entry = update_stock_entry_based_on_material_request(pick_list, stock_entry) else: stock_entry = update_stock_entry_items_with_no_reference(pick_list, stock_entry) + stock_entry.set_stock_entry_type() + if not stock_entry.get("items"): return frappe.msgprint(_("All picked items have already been transferred against this Pick List")) @@ -1684,9 +1691,57 @@ def stock_entry_exists(pick_list_name): return frappe.db.exists("Stock Entry", {"pick_list": pick_list_name}) +def update_stock_entry_based_on_job_card(pick_list, stock_entry, job_card): + job_card = frappe.db.get_value( + "Job Card", + job_card, + ["name", "work_order", "bom_no", "semi_fg_bom", "for_quantity", "transferred_qty", "wip_warehouse"], + as_dict=True, + ) + + stock_entry.purpose = "Material Transfer for Manufacture" + stock_entry.job_card = job_card.name + stock_entry.work_order = job_card.work_order + stock_entry.from_bom = 1 + stock_entry.bom_no = job_card.semi_fg_bom or job_card.bom_no + stock_entry.fg_completed_qty = max(flt(job_card.for_quantity) - flt(job_card.transferred_qty), 0) + stock_entry.to_warehouse = job_card.wip_warehouse + + job_card_items = get_job_card_items_by_material_request_item(pick_list) + + for location in pick_list.locations: + if get_pending_transfer_stock_qty(location) <= 0: + continue + item = frappe._dict() + update_common_item_properties(item, location) + item.t_warehouse = job_card.wip_warehouse + item.job_card_item = job_card_items.get(location.material_request_item) + stock_entry.append("items", item) + + return stock_entry + + +def get_job_card_items_by_material_request_item(pick_list): + material_request_items = [ + location.material_request_item for location in pick_list.locations if location.material_request_item + ] + if not material_request_items: + return {} + + return dict( + frappe.get_all( + "Material Request Item", + filters={"name": ["in", material_request_items]}, + fields=["name", "job_card_item"], + as_list=True, + ) + ) + + def update_stock_entry_based_on_work_order(pick_list, stock_entry): work_order = frappe.get_doc("Work Order", pick_list.get("work_order")) + stock_entry.purpose = "Material Transfer for Manufacture" stock_entry.work_order = work_order.name stock_entry.company = work_order.company stock_entry.from_bom = 1