diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index c695d17e80f..05011a6b9d9 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -158,7 +158,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/patches.txt b/erpnext/patches.txt index f28e3e1840f..f3f216ac64c 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -440,4 +440,5 @@ erpnext.patches.v16_0.set_posting_datetime_for_sabb_and_drop_indexes execute:frappe.db.set_single_value("Accounts Settings", "pcv_job_timeout", 3600) erpnext.patches.v15_0.backfill_sla_link_filters_on_custom_field erpnext.patches.v15_0.backfill_sla_link_filters_on_docfield -erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm \ No newline at end of file +erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm +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 diff --git a/erpnext/stock/doctype/pick_list/pick_list.json b/erpnext/stock/doctype/pick_list/pick_list.json index 4b46f4ecd82..5232d697ba0 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.json +++ b/erpnext/stock/doctype/pick_list/pick_list.json @@ -184,7 +184,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, @@ -246,7 +246,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2025-10-03 18:36:52.282355", + "modified": "2026-07-10 11:39:13.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 9cadca6e137..06e190c30f7 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 @@ -398,6 +400,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 = [] @@ -1473,13 +1503,10 @@ def add_product_bundles_to_delivery_note( @frappe.whitelist() -def create_stock_entry(pick_list): - pick_list = frappe.get_doc(json.loads(pick_list)) +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 been already 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") @@ -1493,6 +1520,9 @@ def create_stock_entry(pick_list): 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() @@ -1594,6 +1624,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 @@ -1605,6 +1637,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( @@ -1620,6 +1654,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) @@ -1628,11 +1664,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 @@ -1640,6 +1683,7 @@ 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 def get_rejected_warehouses(): 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/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index 114da89007e..8ed07e61381 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -10,7 +10,11 @@ from erpnext.selling.doctype.sales_order.sales_order import create_pick_list from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.stock.doctype.item.test_item import create_item, make_item from erpnext.stock.doctype.packed_item.test_packed_item import create_product_bundle -from erpnext.stock.doctype.pick_list.pick_list import create_delivery_note, create_dn_for_pick_lists +from erpnext.stock.doctype.pick_list.pick_list import ( + 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 ( get_batch_from_bundle, @@ -1016,6 +1020,64 @@ class TestPickList(FrappeTestCase): 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_create_second_delivery_note_with_fully_delivered_location(self): # When one pick list item is fully delivered by the first Delivery Note # and another item is still pending, creating a second Delivery Note from 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 adac858acac..1a0b69744c9 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", @@ -254,6 +255,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", @@ -284,7 +295,7 @@ ], "istable": 1, "links": [], - "modified": "2026-03-17 16:25:10.358013", + "modified": "2026-07-06 18:17:18.000000", "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 3aea5271d3d..ebfa7269912 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -163,6 +163,15 @@ class StockEntry(StockController): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + 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.purchase_order: self.subcontract_data = frappe._dict( { @@ -519,6 +528,7 @@ class StockEntry(StockController): self.validate_closed_subcontracting_order() self.update_subcontract_order_supplied_items() self.update_subcontracting_order_status() + self.update_pick_list_status() if self.work_order and self.purpose == "Material Consumption for Manufacture": self.validate_work_order_status() @@ -3544,6 +3554,9 @@ class StockEntry(StockController): 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 ec73d0c6fee..0153c8ef217 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json @@ -68,6 +68,7 @@ "col_break6", "material_request", "material_request_item", + "pick_list_item", "original_item", "reference_section", "against_stock_entry", @@ -415,6 +416,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", @@ -615,7 +626,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-07-03 12:11:53.714931", + "modified": "2026-07-06 18:17:18.000000", "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 bd3dda1b98f..f5b61eed0db 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py @@ -43,6 +43,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