Merge pull request #56915 from aerele/backport/pick-list-partial-transfer-v16

feat(stock): support partial transfer from pick list
This commit is contained in:
Mihir Kandoi
2026-07-09 20:30:56 +05:30
committed by GitHub
12 changed files with 215 additions and 14 deletions

View File

@@ -166,7 +166,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'",

View File

@@ -488,4 +488,5 @@ execute:frappe.db.set_single_value("Accounts Settings", "pcv_job_timeout", 3600)
erpnext.patches.v16_0.remove_mandatory_from_inv_dimension_fields
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
erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm
erpnext.patches.v16_0.backfill_pick_list_transferred_qty

View File

@@ -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

View File

@@ -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-06 18:17:18.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Pick List",

View File

@@ -73,7 +73,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
@@ -419,6 +421,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 = []
@@ -1544,13 +1574,10 @@ def add_product_bundles_to_target(pick_list, target_doc, item_mapper, sales_orde
@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")
@@ -1564,6 +1591,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()
@@ -1673,6 +1703,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
@@ -1684,6 +1716,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(
@@ -1699,6 +1733,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)
@@ -1707,11 +1743,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
@@ -1719,6 +1762,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():

View File

@@ -7,6 +7,7 @@ frappe.listview_settings["Pick List"] = {
Draft: "red",
Open: "orange",
"Partly Delivered": "orange",
"Partially Transferred": "yellow",
Completed: "green",
Cancelled: "red",
};

View File

@@ -13,6 +13,7 @@ from erpnext.stock.doctype.pick_list.pick_list 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

View File

@@ -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-06 18:17:18.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Pick List Item",

View File

@@ -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

View File

@@ -178,6 +178,15 @@ class StockEntry(StockController, SubcontractingInwardController):
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(
{
@@ -571,6 +580,7 @@ class StockEntry(StockController, SubcontractingInwardController):
self.validate_closed_subcontracting_order()
self.update_subcontract_order_supplied_items()
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":
@@ -4054,6 +4064,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):

View File

@@ -72,6 +72,7 @@
"col_break6",
"material_request",
"material_request_item",
"pick_list_item",
"original_item",
"reference_section",
"against_stock_entry",
@@ -423,6 +424,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",
@@ -678,7 +689,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",

View File

@@ -47,6 +47,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