diff --git a/erpnext/stock/doctype/pick_list/pick_list.js b/erpnext/stock/doctype/pick_list/pick_list.js
index d4ea997b060..ae00460bc83 100644
--- a/erpnext/stock/doctype/pick_list/pick_list.js
+++ b/erpnext/stock/doctype/pick_list/pick_list.js
@@ -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) => `
+
+
${card.label}
+
${card.value}
+
`
+ )
+ .join("");
+
+ return `${card_html}
`;
+}
+
+function get_availability_summary_html(rows) {
+ const header = `
+
+ | ${__("Item")} |
+ ${__("Warehouse")} |
+ ${__("Actual Qty")} |
+ ${__("Held by Pick Lists")} |
+ ${__("Reserved Qty")} |
+ ${__("Free to Pick")} |
+
`;
+
+ 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 `
+
+ | ${frappe.utils.escape_html(row.item_code)} |
+ ${frappe.utils.escape_html(row.warehouse)} |
+ ${format_float(row.actual_qty)} |
+ ${format_float(row.picked_qty)} |
+ ${format_float(row.reserved_qty)} |
+ ${format_float(row.free_qty)} |
+
`;
+ })
+ .join("");
+
+ return `
+ ${__("Availability")}
+ `;
+}
+
+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 = `
+
+ | ${__("Item / Document")} |
+ ${__("Status")} |
+ ${__("Batch No")} |
+ ${__("Qty")} |
+
`;
+
+ const body = with_holders.map((row) => get_holding_tree_rows_html(row)).join("");
+
+ return `
+ ${__("Stock Held By")}
+
+ ${__("Cancel or delete these documents to release the stock.")}
+
+ `;
+}
+
+function get_holding_tree_rows_html(row) {
+ const total = row.picked_qty + row.reserved_qty;
+
+ let html = `
+
+ |
+ ${__("Item")}:
+ ${frappe.utils.escape_html(row.item_code)}
+ ·
+ ${__("Warehouse")}:
+ ${frappe.utils.escape_html(row.warehouse)}
+ |
+ ${format_float(total)} |
+
`;
+
+ const child_cell = (content) =>
+ `└─ ${content} | `;
+
+ row.pick_lists.forEach((d) => {
+ html += `
+
+ ${child_cell(frappe.utils.get_form_link("Pick List", d.pick_list, true))}
+ | ${__(d.status)} |
+ ${frappe.utils.escape_html(d.batch_no || "")} |
+ ${format_float(d.holding_qty)} |
+
`;
+ });
+
+ 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 += `
+
+ ${child_cell(`${sre_link} · ${__("Reserved for {0}", [against])}`)}
+ | ${__(d.status)} |
+ |
+ ${format_float(d.reserved_qty)} |
+
`;
+ });
+
+ 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", {
diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py
index 3cf7c25307f..5418979bf86 100644
--- a/erpnext/stock/doctype/pick_list/pick_list.py
+++ b/erpnext/stock/doctype/pick_list/pick_list.py
@@ -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 += "
" + _("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 = "| {} | {} | {} | {} |
".format(
+ _("Pick List"), _("Status"), _("Warehouse"), _("Qty")
+ )
+ rows = "".join(
+ "| {} | {} | {} | {} |
".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""
def filter_locations_by_picked_materials(locations, picked_item_details) -> list[dict]:
diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py
index 3ca41f29bb3..c292710edbd 100644
--- a/erpnext/stock/doctype/pick_list/test_pick_list.py
+++ b/erpnext/stock/doctype/pick_list/test_pick_list.py
@@ -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(