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")}
+ ${header}${body}
`; +} + +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.")} +
+ ${header}${body}
`; +} + +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"{header}{rows}
" 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(