From 27d5165755d2c56996304191d69bb4778bbf2e33 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Wed, 1 Jul 2026 15:44:26 +0530 Subject: [PATCH 1/3] feat(stock): support partial transfer from pick list Creating a Stock Entry from a Pick List blocked any further entry (stock_entry_exists) and flipped the pick list to Completed as soon as one entry existed, so picked stock could not be transferred in parts. Track transferred_qty per Pick List Item (summed from submitted Stock Entry rows via a new pick_list_item link, mirroring delivered_qty), add a Partially Transferred status, and map each new Stock Entry from the remaining qty so transfers can continue until fully transferred. --- erpnext/controllers/status_updater.py | 3 +- erpnext/stock/doctype/pick_list/mapper.py | 24 +++++++++++--- .../stock/doctype/pick_list/pick_list.json | 4 +-- erpnext/stock/doctype/pick_list/pick_list.py | 32 ++++++++++++++++++- .../stock/doctype/pick_list/pick_list_list.js | 1 + .../pick_list_item/pick_list_item.json | 13 +++++++- .../doctype/pick_list_item/pick_list_item.py | 1 + .../stock/doctype/stock_entry/stock_entry.py | 13 ++++++++ .../stock_entry_detail.json | 13 +++++++- .../stock_entry_detail/stock_entry_detail.py | 1 + 10 files changed, 94 insertions(+), 11 deletions(-) diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 9e46598768f..eed56008547 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -167,7 +167,8 @@ status_map = { "Pick List": [ ["Draft", None], ["Open", "eval:self.docstatus == 1"], - ["Completed", "stock_entry_exists"], + ["Completed", "is_fully_transferred"], + ["Partially Transferred", "is_partially_transferred"], [ "Partly Delivered", "eval:self.purpose == 'Delivery' and self.delivery_status == 'Partly Delivered'", diff --git a/erpnext/stock/doctype/pick_list/mapper.py b/erpnext/stock/doctype/pick_list/mapper.py index f9f5fdc8e08..df2c3f4e2c2 100644 --- a/erpnext/stock/doctype/pick_list/mapper.py +++ b/erpnext/stock/doctype/pick_list/mapper.py @@ -285,9 +285,6 @@ def create_stock_entry(pick_list: str | dict): pick_list = frappe.get_doc(frappe.parse_json(pick_list)) validate_item_locations(pick_list) - if stock_entry_exists(pick_list.get("name")): - return frappe.msgprint(_("Stock Entry has already been created against this Pick List")) - stock_entry = frappe.new_doc("Stock Entry") stock_entry.pick_list = pick_list.get("name") stock_entry.purpose = pick_list.get("purpose") @@ -301,6 +298,9 @@ def create_stock_entry(pick_list: str | dict): else: stock_entry = update_stock_entry_items_with_no_reference(pick_list, stock_entry) + if not stock_entry.get("items"): + return frappe.msgprint(_("All picked items have already been transferred against this Pick List")) + stock_entry.set_missing_values() return stock_entry.as_dict() @@ -366,6 +366,8 @@ def update_stock_entry_based_on_work_order(pick_list, stock_entry): stock_entry.project = work_order.project 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 = wip_warehouse @@ -377,6 +379,8 @@ def update_stock_entry_based_on_work_order(pick_list, stock_entry): def update_stock_entry_based_on_material_request(pick_list, stock_entry): for location in pick_list.locations: + if get_pending_transfer_stock_qty(location) <= 0: + continue target_warehouse = None if location.material_request_item: target_warehouse = frappe.get_value( @@ -392,6 +396,8 @@ def update_stock_entry_based_on_material_request(pick_list, stock_entry): def update_stock_entry_items_with_no_reference(pick_list, stock_entry): for location in pick_list.locations: + if get_pending_transfer_stock_qty(location) <= 0: + continue item = frappe._dict() update_common_item_properties(item, location) @@ -400,11 +406,18 @@ def update_stock_entry_items_with_no_reference(pick_list, stock_entry): return stock_entry +def get_pending_transfer_stock_qty(location): + """Stock qty of this pick list row still to be moved into a Stock Entry.""" + return flt(location.picked_qty) - flt(location.transferred_qty) + + def update_common_item_properties(item, location): + pending_stock_qty = get_pending_transfer_stock_qty(location) item.item_code = location.item_code + item.item_name = location.item_name item.s_warehouse = location.warehouse - item.transfer_qty = location.picked_qty - item.qty = flt(location.picked_qty / (location.conversion_factor or 1), location.precision("qty")) + item.transfer_qty = pending_stock_qty + item.qty = flt(pending_stock_qty / (location.conversion_factor or 1), location.precision("qty")) item.uom = location.uom item.conversion_factor = location.conversion_factor item.stock_uom = location.stock_uom @@ -412,3 +425,4 @@ def update_common_item_properties(item, location): item.serial_no = location.serial_no item.batch_no = location.batch_no item.material_request_item = location.material_request_item + item.pick_list_item = location.name diff --git a/erpnext/stock/doctype/pick_list/pick_list.json b/erpnext/stock/doctype/pick_list/pick_list.json index 9ee1b7a1922..55e66f74b3c 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.json +++ b/erpnext/stock/doctype/pick_list/pick_list.json @@ -190,7 +190,7 @@ "in_standard_filter": 1, "label": "Status", "no_copy": 1, - "options": "Draft\nOpen\nPartly Delivered\nCompleted\nCancelled", + "options": "Draft\nOpen\nPartly Delivered\nPartially Transferred\nCompleted\nCancelled", "print_hide": 1, "read_only": 1, "report_hide": 1, @@ -278,7 +278,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2026-02-06 18:14:18.361039", + "modified": "2026-07-01 14:27:50.617011", "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 a25770351e4..846c020de72 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -71,7 +71,9 @@ class PickList(TransactionBase): purpose: DF.Literal["Material Transfer for Manufacture", "Material Transfer", "Delivery"] scan_barcode: DF.Data | None scan_mode: DF.Check - status: DF.Literal["Draft", "Open", "Partly Delivered", "Completed", "Cancelled"] + status: DF.Literal[ + "Draft", "Open", "Partly Delivered", "Partially Transferred", "Completed", "Cancelled" + ] work_order: DF.Link | None # end: auto-generated types @@ -417,6 +419,34 @@ class PickList(TransactionBase): return stock_entry_exists(self.name) + def get_transfer_status(self): + """Return the pick list's transfer progress based on how much of the picked qty has been + moved into submitted Stock Entries (tracked on Pick List Item.transferred_qty). + + Only applies to purposes that move stock via Stock Entry; the Delivery purpose is tracked + via delivery_status instead. Returns "Completed", "Partially Transferred" or None.""" + if self.purpose == "Delivery": + return None + + total_picked = sum(flt(row.picked_qty) for row in self.locations) + if not total_picked: + return None + + total_transferred = sum(flt(row.transferred_qty) for row in self.locations) + if total_transferred <= 0: + return None + + if total_transferred >= total_picked: + return "Completed" + + return "Partially Transferred" + + def is_fully_transferred(self): + return self.get_transfer_status() == "Completed" + + def is_partially_transferred(self): + return self.get_transfer_status() == "Partially Transferred" + def update_reference_qty(self): packed_items = [] so_items = [] diff --git a/erpnext/stock/doctype/pick_list/pick_list_list.js b/erpnext/stock/doctype/pick_list/pick_list_list.js index a675c95f973..5bc4f2f3eef 100644 --- a/erpnext/stock/doctype/pick_list/pick_list_list.js +++ b/erpnext/stock/doctype/pick_list/pick_list_list.js @@ -7,6 +7,7 @@ frappe.listview_settings["Pick List"] = { Draft: "red", Open: "orange", "Partly Delivered": "orange", + "Partially Transferred": "yellow", Completed: "green", Cancelled: "red", }; diff --git a/erpnext/stock/doctype/pick_list_item/pick_list_item.json b/erpnext/stock/doctype/pick_list_item/pick_list_item.json index 01630278168..658dff42d7f 100644 --- a/erpnext/stock/doctype/pick_list_item/pick_list_item.json +++ b/erpnext/stock/doctype/pick_list_item/pick_list_item.json @@ -22,6 +22,7 @@ "conversion_factor", "stock_uom", "delivered_qty", + "transferred_qty", "available_quantity_section", "actual_qty", "column_break_kyek", @@ -255,6 +256,16 @@ "read_only": 1, "report_hide": 1 }, + { + "default": "0", + "fieldname": "transferred_qty", + "fieldtype": "Float", + "label": "Transferred Qty (in Stock UOM)", + "no_copy": 1, + "print_hide": 1, + "read_only": 1, + "report_hide": 1 + }, { "fieldname": "available_quantity_section", "fieldtype": "Section Break", @@ -285,7 +296,7 @@ ], "istable": 1, "links": [], - "modified": "2026-03-17 16:25:10.358013", + "modified": "2026-07-01 14:27:50.617011", "modified_by": "Administrator", "module": "Stock", "name": "Pick List Item", diff --git a/erpnext/stock/doctype/pick_list_item/pick_list_item.py b/erpnext/stock/doctype/pick_list_item/pick_list_item.py index bdba97f4056..97e6525c97b 100644 --- a/erpnext/stock/doctype/pick_list_item/pick_list_item.py +++ b/erpnext/stock/doctype/pick_list_item/pick_list_item.py @@ -39,6 +39,7 @@ class PickListItem(Document): stock_qty: DF.Float stock_reserved_qty: DF.Float stock_uom: DF.Link | None + transferred_qty: DF.Float uom: DF.Link | None use_serial_batch_fields: DF.Check warehouse: DF.Link | None diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index e575e8eedb4..18ee63f6abf 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -165,6 +165,15 @@ class StockEntry(StockController, SubcontractingInwardController): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._configure_purpose_class() + self.status_updater = [ + { + "source_dt": "Stock Entry Detail", + "target_dt": "Pick List Item", + "join_field": "pick_list_item", + "target_field": "transferred_qty", + "source_field": "transfer_qty", + } + ] if self.subcontracting_inward_order: self.subcontract_data = frappe._dict( @@ -350,6 +359,7 @@ class StockEntry(StockController, SubcontractingInwardController): self.delink_asset_repair_sabb() self.validate_closed_subcontracting_order() self.update_subcontracting_order_status() + self.update_pick_list_status() self.cancel_stock_reserve_for_wip_and_fg() if self.work_order and self.purpose == "Material Consumption for Manufacture": @@ -1485,6 +1495,9 @@ class StockEntry(StockController, SubcontractingInwardController): def update_pick_list_status(self): from erpnext.stock.doctype.pick_list.pick_list import update_pick_list_status + if self.pick_list: + self.update_qty() + update_pick_list_status(self.pick_list) def set_missing_values(self): diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json index ea9d3b75b51..75f45275de1 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json @@ -72,6 +72,7 @@ "col_break6", "material_request", "material_request_item", + "pick_list_item", "original_item", "reference_section", "against_stock_entry", @@ -424,6 +425,16 @@ "print_hide": 1, "read_only": 1 }, + { + "fieldname": "pick_list_item", + "fieldtype": "Link", + "hidden": 1, + "label": "Pick List Item", + "no_copy": 1, + "options": "Pick List Item", + "print_hide": 1, + "read_only": 1 + }, { "fieldname": "original_item", "fieldtype": "Link", @@ -679,7 +690,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-06-30 12:18:34.132425", + "modified": "2026-07-01 14:27:50.617011", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry Detail", diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py index 3224ea905c7..4e690d4d8ec 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py @@ -58,6 +58,7 @@ class StockEntryDetail(Document): parent: DF.Data parentfield: DF.Data parenttype: DF.Data + pick_list_item: DF.Link | None po_detail: DF.Data | None project: DF.Link | None putaway_rule: DF.Link | None From a1daad8d4f755dca705a6b2fcb94e51267a7d674 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Wed, 1 Jul 2026 15:44:35 +0530 Subject: [PATCH 2/3] test(stock): add test for partial transfer status from pick list --- .../stock/doctype/pick_list/test_pick_list.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index c442539f1f5..e37cb0a3532 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -13,6 +13,7 @@ from erpnext.stock.doctype.pick_list.mapper import ( create_delivery, create_delivery_note, create_dn_for_pick_lists, + create_stock_entry, ) from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( @@ -1221,6 +1222,64 @@ class TestPickList(ERPNextTestSuite): pl.reload() self.assertEqual(pl.status, "Cancelled") + def test_pick_list_partial_transfer_status(self): + """Partial Stock Entries from a Pick List should track transferred_qty and drive the + Partially Transferred / Completed status, and allow further transfers for the remainder.""" + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + item = make_item(properties={"is_stock_item": 1}).name + source_warehouse = "_Test Warehouse - _TC" + target_warehouse = create_warehouse("_Test Transfer Target Warehouse") + make_stock_entry(item=item, to_warehouse=source_warehouse, qty=10) + + pick_list = frappe.get_doc( + { + "doctype": "Pick List", + "company": "_Test Company", + "purpose": "Material Transfer", + "pick_manually": 1, + "locations": [ + { + "item_code": item, + "qty": 10, + "stock_qty": 10, + "conversion_factor": 1, + "warehouse": source_warehouse, + "picked_qty": 10, + } + ], + } + ) + pick_list.submit() + self.assertEqual(pick_list.status, "Open") + + # Transfer 4 of the 10 picked units. + se1 = frappe.get_doc(create_stock_entry(pick_list.as_dict())) + self.assertEqual(se1.items[0].qty, 10) + se1.items[0].qty = 4 + se1.items[0].t_warehouse = target_warehouse + se1.submit() + + pick_list.reload() + self.assertEqual(pick_list.locations[0].transferred_qty, 4) + self.assertEqual(pick_list.status, "Partially Transferred") + + # The next Stock Entry should only offer the remaining 6 units. + se2 = frappe.get_doc(create_stock_entry(pick_list.as_dict())) + self.assertEqual(se2.items[0].qty, 6) + se2.items[0].t_warehouse = target_warehouse + se2.submit() + + pick_list.reload() + self.assertEqual(pick_list.locations[0].transferred_qty, 10) + self.assertEqual(pick_list.status, "Completed") + + # Cancelling the last entry rolls transferred_qty and status back. + se2.cancel() + pick_list.reload() + self.assertEqual(pick_list.locations[0].transferred_qty, 4) + self.assertEqual(pick_list.status, "Partially Transferred") + def test_pick_list_validation(self): warehouse = "_Test Warehouse - _TC" item = make_item("Test Non Serialized Pick List Item", properties={"is_stock_item": 1}).name From fad904d68bab0e7f331982ee22d0468196c7c465 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Wed, 1 Jul 2026 16:18:44 +0530 Subject: [PATCH 3/3] fix(stock): backfill transferred qty for existing pick lists Pick Lists transferred before this feature have transferred_qty = 0 and their Stock Entry rows carry no pick_list_item link, so the new is_fully_transferred check would never fire and, with the old duplicate-entry guard removed, they could be transferred again. Set transferred_qty = picked_qty for non-Delivery submitted pick lists that already have a linked Stock Entry so they stay completed and locked. --- erpnext/patches.txt | 1 + .../backfill_pick_list_transferred_qty.py | 58 +++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 erpnext/patches/v16_0/backfill_pick_list_transferred_qty.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 0d1c3f01025..9b13bbfefd1 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -492,3 +492,4 @@ erpnext.patches.v16_0.rename_subscription_billing_period_fields erpnext.patches.v16_0.drop_redundant_serial_no_index_from_sabb erpnext.patches.v16_0.set_default_close_opportunity_after_days execute:frappe.db.set_single_value("Accounts Settings", "pcv_job_timeout", 3600) +erpnext.patches.v16_0.backfill_pick_list_transferred_qty diff --git a/erpnext/patches/v16_0/backfill_pick_list_transferred_qty.py b/erpnext/patches/v16_0/backfill_pick_list_transferred_qty.py new file mode 100644 index 00000000000..6d3155c4c1a --- /dev/null +++ b/erpnext/patches/v16_0/backfill_pick_list_transferred_qty.py @@ -0,0 +1,58 @@ +import frappe +from frappe.query_builder.functions import Sum +from frappe.utils import flt + + +def execute(): + StockEntry = frappe.qb.DocType("Stock Entry") + StockEntryDetail = frappe.qb.DocType("Stock Entry Detail") + + pick_lists = ( + frappe.qb.from_(StockEntry) + .select(StockEntry.pick_list) + .distinct() + .where((StockEntry.pick_list.isnotnull()) & (StockEntry.docstatus == 1)) + ).run(pluck=True) + + if not pick_lists: + return + + rows = ( + frappe.qb.from_(StockEntryDetail) + .join(StockEntry) + .on(StockEntryDetail.parent == StockEntry.name) + .select( + StockEntry.pick_list, + StockEntryDetail.item_code, + StockEntryDetail.s_warehouse, + Sum(StockEntryDetail.transfer_qty).as_("qty"), + ) + .where((StockEntry.pick_list.isin(pick_lists)) & (StockEntry.docstatus == 1)) + .groupby(StockEntry.pick_list, StockEntryDetail.item_code, StockEntryDetail.s_warehouse) + ).run(as_dict=True) + + transferred = {(r.pick_list, r.item_code, r.s_warehouse): flt(r.qty) for r in rows} + + items = frappe.get_all( + "Pick List Item", + filters={"parent": ("in", pick_lists), "picked_qty": (">", 0)}, + fields=["name", "parent", "item_code", "warehouse", "picked_qty"], + order_by="idx", + ) + + updates = {} + for row in items: + key = (row.parent, row.item_code, row.warehouse) + available = transferred.get(key, 0) + if available <= 0: + continue + qty = min(flt(row.picked_qty), available) + transferred[key] = available - qty + updates[row.name] = {"transferred_qty": qty} + + if not updates: + return + + frappe.db.auto_commit_on_many_writes = True + frappe.db.bulk_update("Pick List Item", updates) + frappe.db.auto_commit_on_many_writes = False