Pick list stock availability (#58243)

* feat: stock availability insight on pick list

* fix: show holding pick lists inline in stock availability dialog

* fix: dashboard layout for stock availability dialog

* fix: reword stock release hint in availability dialog

* fix: tree layout for stock held by section

* fix: escape values in blocking pick lists table
This commit is contained in:
rohitwaghchaure
2026-08-17 17:58:15 +05:30
committed by GitHub
parent e9c6ec4d33
commit bf1ec51680
3 changed files with 411 additions and 48 deletions

View File

@@ -124,6 +124,10 @@ frappe.ui.form.on("Pick List", {
frm.trigger("update_warehouse_property");
erpnext.toggle_serial_batch_fields(frm);
if ((frm.doc.locations || []).length && !["Completed", "Cancelled"].includes(frm.doc.status)) {
frm.add_custom_button(__("Stock Availability"), () => frm.events.show_stock_availability(frm));
}
if (frm.doc.docstatus === 1) {
const status_completed = frm.doc.status === "Completed";
@@ -326,6 +330,43 @@ frappe.ui.form.on("Pick List", {
},
});
},
show_stock_availability(frm) {
const seen = new Set();
const items = [];
(frm.doc.locations || []).forEach((row) => {
if (!row.item_code || !row.warehouse) return;
const key = `${row.item_code}||${row.warehouse}`;
if (seen.has(key)) return;
seen.add(key);
items.push({ item_code: row.item_code, warehouse: row.warehouse });
});
if (!items.length) {
frappe.msgprint(__("Add items with a warehouse in the Item Locations table"));
return;
}
frappe
.xcall("erpnext.stock.doctype.pick_list.pick_list.get_stock_availability", {
items: items,
pick_list: frm.doc.name,
})
.then((rows) => frm.events.render_stock_availability(rows));
},
render_stock_availability(rows) {
const dialog = new frappe.ui.Dialog({
title: __("Stock Availability"),
size: "extra-large",
});
dialog.$body.html(get_availability_html(rows));
dialog.show();
},
show_reserved_stock(frm) {
// Get the latest modified date from the locations table.
var to_date = moment(
@@ -417,6 +458,146 @@ frappe.ui.form.on("Pick List Item", {
},
});
function format_float(qty) {
return frappe.format(qty, { fieldtype: "Float" });
}
function get_availability_html(rows) {
return `
${get_availability_cards_html(rows)}
${get_availability_summary_html(rows)}
${get_holding_documents_html(rows)}`;
}
function get_availability_cards_html(rows) {
const blocked = rows.filter((row) => row.free_qty <= 0);
const held = rows.filter((row) => row.pick_lists.length || row.reservations.length);
const cards = [
{ label: __("Items"), value: rows.length, color: "var(--text-color)" },
{
label: __("Held by Other Documents"),
value: held.length,
color: held.length ? "var(--orange-500)" : "var(--green-500)",
},
{
label: __("Not Free to Pick"),
value: blocked.length,
color: blocked.length ? "var(--red-500)" : "var(--green-500)",
},
];
const card_html = cards
.map(
(card) => `
<div style="flex: 1; border: 1px solid var(--border-color); border-radius: var(--border-radius-md); padding: 12px 15px;">
<div class="text-muted" style="font-size: var(--text-sm); margin-bottom: 4px;">${card.label}</div>
<div style="font-size: var(--text-2xl); font-weight: 600; color: ${card.color};">${card.value}</div>
</div>`
)
.join("");
return `<div style="display: flex; gap: 15px; margin-bottom: 20px;">${card_html}</div>`;
}
function get_availability_summary_html(rows) {
const header = `
<tr>
<th>${__("Item")}</th>
<th>${__("Warehouse")}</th>
<th class="text-right">${__("Actual Qty")}</th>
<th class="text-right">${__("Held by Pick Lists")}</th>
<th class="text-right">${__("Reserved Qty")}</th>
<th class="text-right">${__("Free to Pick")}</th>
</tr>`;
const body = rows
.map((row) => {
const has_detail = row.pick_lists.length || row.reservations.length;
const color = row.free_qty <= 0 ? "red" : has_detail ? "orange" : "green";
return `
<tr>
<td><span class="indicator ${color}"></span> ${frappe.utils.escape_html(row.item_code)}</td>
<td>${frappe.utils.escape_html(row.warehouse)}</td>
<td class="text-right">${format_float(row.actual_qty)}</td>
<td class="text-right">${format_float(row.picked_qty)}</td>
<td class="text-right">${format_float(row.reserved_qty)}</td>
<td class="text-right"><b>${format_float(row.free_qty)}</b></td>
</tr>`;
})
.join("");
return `
<h5 style="margin-bottom: 10px;">${__("Availability")}</h5>
<table class="table table-bordered">${header}${body}</table>`;
}
function get_holding_documents_html(rows) {
const with_holders = rows.filter((row) => row.pick_lists.length || row.reservations.length);
if (!with_holders.length) return "";
const header = `
<tr>
<th style="width: 45%">${__("Item / Document")}</th>
<th>${__("Status")}</th>
<th>${__("Batch No")}</th>
<th class="text-right">${__("Qty")}</th>
</tr>`;
const body = with_holders.map((row) => get_holding_tree_rows_html(row)).join("");
return `
<h5 style="margin: 20px 0 10px;">${__("Stock Held By")}</h5>
<div class="text-muted" style="font-size: var(--text-sm); margin-bottom: 10px;">
${__("Cancel or delete these documents to release the stock.")}
</div>
<table class="table table-bordered">${header}${body}</table>`;
}
function get_holding_tree_rows_html(row) {
const total = row.picked_qty + row.reserved_qty;
let html = `
<tr style="background-color: var(--control-bg);">
<td colspan="3">
<span class="text-muted">${__("Item")}:</span>
<b>${frappe.utils.escape_html(row.item_code)}</b>
<span class="text-muted" style="margin: 0 8px;">·</span>
<span class="text-muted">${__("Warehouse")}:</span>
<b>${frappe.utils.escape_html(row.warehouse)}</b>
</td>
<td class="text-right"><b>${format_float(total)}</b></td>
</tr>`;
const child_cell = (content) =>
`<td style="padding-left: 30px;"><span class="text-muted">└─</span> ${content}</td>`;
row.pick_lists.forEach((d) => {
html += `
<tr>
${child_cell(frappe.utils.get_form_link("Pick List", d.pick_list, true))}
<td>${__(d.status)}</td>
<td>${frappe.utils.escape_html(d.batch_no || "")}</td>
<td class="text-right">${format_float(d.holding_qty)}</td>
</tr>`;
});
row.reservations.forEach((d) => {
const against = frappe.utils.get_form_link(d.voucher_type, d.voucher_no, true);
const sre_link = frappe.utils.get_form_link("Stock Reservation Entry", d.name, true);
html += `
<tr>
${child_cell(`${sre_link} · ${__("Reserved for {0}", [against])}`)}
<td>${__(d.status)}</td>
<td></td>
<td class="text-right">${format_float(d.reserved_qty)}</td>
</tr>`;
});
return html;
}
function get_item_details(item_code, uom = null, warehouse = null, company = null) {
if (item_code) {
return frappe.xcall("erpnext.stock.doctype.pick_list.pick_list.get_item_details", {

View File

@@ -10,7 +10,7 @@ from frappe import _, bold
from frappe.model.document import Document
from frappe.query_builder import Case
from frappe.query_builder.functions import Coalesce, GroupConcat, Locate, Lower, Max, Replace, Sum
from frappe.utils import cint, floor, flt, get_link_to_form
from frappe.utils import cint, escape_html, floor, flt, get_link_to_form
from frappe.utils.nestedset import get_descendants_of
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
@@ -670,6 +670,7 @@ class PickList(TransactionBase):
picked_item_details=picked_items_details.get(item_code),
consider_rejected_warehouses=self.consider_rejected_warehouses,
priority_warehouses=priority_warehouses,
pick_list=self.name,
),
)
@@ -880,39 +881,18 @@ class PickList(TransactionBase):
picked_items[row.item_code][key]["serial_no"].extend(serial_no)
def _get_pick_list_items(self, items):
pi = frappe.qb.DocType("Pick List")
pi_item = frappe.qb.DocType("Pick List Item")
query = (
frappe.qb.from_(pi)
.inner_join(pi_item)
.on(pi.name == pi_item.parent)
.select(
pi_item.item_code,
pi_item.warehouse,
pi_item.batch_no,
pi_item.serial_and_batch_bundle,
pi_item.serial_no,
(
Case()
.when(
(pi_item.picked_qty > 0) & (pi_item.docstatus == 1),
pi_item.picked_qty - pi_item.delivered_qty,
)
.else_(pi_item.stock_qty)
).as_("picked_qty"),
)
.where(
(pi_item.item_code.isin([x.item_code for x in items]))
& ((pi_item.picked_qty > 0) | (pi_item.stock_qty > 0))
& (pi.status != "Completed")
& (pi.status != "Cancelled")
& (pi_item.docstatus != 2)
)
query = get_open_pick_list_items_query(
[x.item_code for x in items], exclude_pick_list=self.name
).select(
pi_item.item_code,
pi_item.warehouse,
pi_item.batch_no,
pi_item.serial_and_batch_bundle,
pi_item.serial_no,
get_holding_qty_case(pi_item).as_("picked_qty"),
)
if self.name:
query = query.where(pi_item.parent != self.name)
query = query.for_update()
return query.run(as_dict=True)
@@ -1042,6 +1022,143 @@ def get_picked_items_qty(items, contains_packed_items=False) -> list[dict]:
return query.run(as_dict=True)
def get_open_pick_list_items_query(item_codes, exclude_pick_list=None):
pi = frappe.qb.DocType("Pick List")
pi_item = frappe.qb.DocType("Pick List Item")
query = (
frappe.qb.from_(pi)
.inner_join(pi_item)
.on(pi.name == pi_item.parent)
.where(
(pi_item.item_code.isin(item_codes))
& ((pi_item.picked_qty > 0) | (pi_item.stock_qty > 0))
& (pi.status != "Completed")
& (pi.status != "Cancelled")
& (pi_item.docstatus != 2)
)
)
if exclude_pick_list:
query = query.where(pi_item.parent != exclude_pick_list)
return query
def get_holding_qty_case(pi_item):
return (
Case()
.when(
(pi_item.picked_qty > 0) & (pi_item.docstatus == 1),
pi_item.picked_qty - pi_item.delivered_qty,
)
.else_(pi_item.stock_qty)
)
def get_pick_list_holders(item_codes, warehouses=None, exclude_pick_list=None):
pi = frappe.qb.DocType("Pick List")
pi_item = frappe.qb.DocType("Pick List Item")
query = (
get_open_pick_list_items_query(item_codes, exclude_pick_list=exclude_pick_list)
.select(
pi.name.as_("pick_list"),
pi.status,
pi_item.item_code,
pi_item.warehouse,
pi_item.batch_no,
Sum(get_holding_qty_case(pi_item)).as_("holding_qty"),
)
.groupby(pi.name, pi.status, pi_item.item_code, pi_item.warehouse, pi_item.batch_no)
)
if warehouses:
query = query.where(pi_item.warehouse.isin(warehouses))
return query.run(as_dict=True)
def get_reservation_holders(item_codes, warehouses):
sre = frappe.qb.DocType("Stock Reservation Entry")
return (
frappe.qb.from_(sre)
.select(
sre.name,
sre.status,
sre.item_code,
sre.warehouse,
sre.voucher_type,
sre.voucher_no,
(sre.reserved_qty - sre.delivered_qty - sre.transferred_qty - sre.consumed_qty).as_(
"reserved_qty"
),
)
.where(
(sre.docstatus == 1)
& (sre.item_code.isin(item_codes))
& (sre.warehouse.isin(warehouses))
& (sre.delivered_qty < sre.reserved_qty)
& (sre.status.notin(["Closed", "Delivered"]))
& (Coalesce(sre.from_voucher_type, "") != "Pick List")
)
).run(as_dict=True)
def get_bin_qty_map(item_codes, warehouses):
bin = frappe.qb.DocType("Bin")
data = (
frappe.qb.from_(bin)
.select(bin.item_code, bin.warehouse, bin.actual_qty)
.where((bin.item_code.isin(item_codes)) & (bin.warehouse.isin(warehouses)))
).run(as_dict=True)
return {(d.item_code, d.warehouse): flt(d.actual_qty) for d in data}
@frappe.whitelist()
def get_stock_availability(items: str | list, pick_list: str | None = None) -> list[dict]:
frappe.has_permission("Pick List", throw=True)
items = frappe.parse_json(items)
keys = {(d.get("item_code"), d.get("warehouse")) for d in items}
keys = {key for key in keys if all(key)}
if not keys:
return []
item_codes = list({key[0] for key in keys})
warehouses = list({key[1] for key in keys})
holders = get_pick_list_holders(item_codes, warehouses=warehouses, exclude_pick_list=pick_list)
reservations = get_reservation_holders(item_codes, warehouses)
bin_qty_map = get_bin_qty_map(item_codes, warehouses)
return [get_availability_row(key, bin_qty_map, holders, reservations) for key in sorted(keys)]
def get_availability_row(key, bin_qty_map, holders, reservations):
item_code, warehouse = key
row_holders = [d for d in holders if (d.item_code, d.warehouse) == key]
row_reservations = [d for d in reservations if (d.item_code, d.warehouse) == key]
actual_qty = flt(bin_qty_map.get(key))
picked_qty = flt(sum(flt(d.holding_qty) for d in row_holders))
reserved_qty = flt(sum(flt(d.reserved_qty) for d in row_reservations))
return frappe._dict(
item_code=item_code,
warehouse=warehouse,
actual_qty=actual_qty,
picked_qty=picked_qty,
reserved_qty=reserved_qty,
free_qty=actual_qty - picked_qty - reserved_qty,
pick_lists=row_holders,
reservations=row_reservations,
)
def get_items_with_location_and_quantity(item_doc, item_location_map, docstatus):
available_locations = item_location_map.get(item_doc.item_code)
locations = []
@@ -1106,6 +1223,7 @@ def get_available_item_locations(
picked_item_details=None,
consider_rejected_warehouses=False,
priority_warehouses=None,
pick_list=None,
):
locations = []
@@ -1149,7 +1267,7 @@ def get_available_item_locations(
locations = get_locations_based_on_required_qty(locations, required_qty, priority_warehouses)
if not ignore_validation:
validate_picked_materials(item_code, required_qty, locations, picked_item_details)
validate_picked_materials(item_code, required_qty, locations, picked_item_details, pick_list)
return locations
@@ -1174,7 +1292,7 @@ def get_locations_based_on_required_qty(locations, required_qty, priority_wareho
return filtered_locations
def validate_picked_materials(item_code, required_qty, locations, picked_item_details=None):
def validate_picked_materials(item_code, required_qty, locations, picked_item_details=None, pick_list=None):
for location in list(locations):
if location["qty"] < 0:
locations.remove(location)
@@ -1182,21 +1300,41 @@ def validate_picked_materials(item_code, required_qty, locations, picked_item_de
total_qty_available = sum(location.get("qty") for location in locations)
remaining_qty = required_qty - total_qty_available
if remaining_qty > 0:
if picked_item_details:
frappe.msgprint(
_(
"{0} units of Item {1} is not available in any of the warehouses. Other Pick Lists exist for this item."
).format(remaining_qty, get_link_to_form("Item", item_code)),
title=_("Already Picked"),
)
else:
frappe.msgprint(
_("{0} units of Item {1} is not available in any of the warehouses.").format(
remaining_qty, get_link_to_form("Item", item_code)
),
title=_("Insufficient Stock"),
)
if remaining_qty <= 0:
return
msg = _("{0} units of Item {1} is not available in any of the warehouses.").format(
remaining_qty, get_link_to_form("Item", item_code)
)
if picked_item_details:
blockers = get_blocking_pick_lists_html(item_code, exclude_pick_list=pick_list)
if blockers:
msg += "<br><br>" + _("The stock is held by the following Pick Lists:") + blockers
frappe.msgprint(msg, title=_("Stock Held by Other Pick Lists"))
else:
frappe.msgprint(msg, title=_("Insufficient Stock"))
def get_blocking_pick_lists_html(item_code, exclude_pick_list=None):
holders = get_pick_list_holders([item_code], exclude_pick_list=exclude_pick_list)
if not holders:
return ""
header = "<tr><th>{}</th><th>{}</th><th>{}</th><th style='text-align:right'>{}</th></tr>".format(
_("Pick List"), _("Status"), _("Warehouse"), _("Qty")
)
rows = "".join(
"<tr><td>{}</td><td>{}</td><td>{}</td><td style='text-align:right'>{}</td></tr>".format(
get_link_to_form("Pick List", d.pick_list),
escape_html(_(d.status)),
escape_html(d.warehouse),
flt(d.holding_qty),
)
for d in holders
)
return f"<table class='table table-bordered'>{header}{rows}</table>"
def filter_locations_by_picked_materials(locations, picked_item_details) -> list[dict]:

View File

@@ -93,6 +93,50 @@ class TestPickList(ERPNextTestSuite):
self.assertEqual(pick_list.locations[0].warehouse, "_Test Warehouse - _TC")
self.assertEqual(pick_list.locations[0].qty, 5)
def test_get_stock_availability(self):
from erpnext.stock.doctype.pick_list.pick_list import get_stock_availability
item = make_item(properties={"is_stock_item": 1}).name
make_stock_entry(item=item, to_warehouse="_Test Warehouse - _TC", qty=100, basic_rate=100)
pick_list = frappe.get_doc(
{
"doctype": "Pick List",
"company": "_Test Company",
"purpose": "Material Transfer",
"locations": [
{
"item_code": item,
"qty": 40,
"stock_qty": 40,
"picked_qty": 40,
"conversion_factor": 1,
"warehouse": "_Test Warehouse - _TC",
}
],
}
).save()
items = frappe.as_json([{"item_code": item, "warehouse": "_Test Warehouse - _TC"}])
rows = get_stock_availability(items)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].actual_qty, 100.0)
self.assertEqual(rows[0].picked_qty, 40.0)
self.assertEqual(rows[0].reserved_qty, 0.0)
self.assertEqual(rows[0].free_qty, 60.0)
self.assertEqual([d.pick_list for d in rows[0].pick_lists], [pick_list.name])
self.assertEqual(rows[0].pick_lists[0].status, "Draft")
rows = get_stock_availability(items, pick_list=pick_list.name)
self.assertEqual(rows[0].picked_qty, 0.0)
self.assertEqual(rows[0].free_qty, 100.0)
pick_list.submit()
rows = get_stock_availability(items)
self.assertEqual(rows[0].picked_qty, 40.0)
self.assertEqual(rows[0].pick_lists[0].status, "Open")
def test_pick_list_splits_row_according_to_warehouse_availability(self):
try:
frappe.get_doc(