From cd1f872912fbaf34a36570ed2989c6ee70ab3177 Mon Sep 17 00:00:00 2001 From: Dipen Gala Date: Fri, 12 Jun 2026 14:11:13 +0530 Subject: [PATCH] feat: add alternate UOM balance columns to Stock Balance report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #52953 The Stock Balance report previously showed balance qty only in the item's stock UOM. To view balance in an alternate UOM, users had to set the "Include UOM" filter — which applies a single UOM to all items. This breaks down when different items use different alternate UOMs (e.g., Pens in Box, Ink in Milliliters). This change adds a new "Show Alternate UOM Balance" checkbox filter. When enabled, up to two alternate UOM columns are injected right after the Balance Qty column: Balance Qty | Alt UOM 1 | Balance Qty (Alt UOM 1) | Alt UOM 2 | Balance Qty (Alt UOM 2) Each row resolves its own alternate UOMs from `tabUOM Conversion Detail` (ordered by idx, excluding the item's stock UOM). The converted balance qty is computed as: stock qty / conversion_factor. Items with fewer than 2 alternate UOMs leave the extra columns blank. The existing "Include UOM" filter behaviour is unchanged. (cherry picked from commit 2d93c5835a2e0b14b1ef81145aa7e9f2957e588d) --- .../report/stock_balance/stock_balance.js | 6 ++ .../report/stock_balance/stock_balance.py | 82 +++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/erpnext/stock/report/stock_balance/stock_balance.js b/erpnext/stock/report/stock_balance/stock_balance.js index fa3b977a121..eef79ce6a27 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.js +++ b/erpnext/stock/report/stock_balance/stock_balance.js @@ -110,6 +110,12 @@ frappe.query_reports["Stock Balance"] = { fieldtype: "Link", options: "UOM", }, + { + fieldname: "show_alt_uom_balance", + label: __("Show Alternate UOM Balance"), + fieldtype: "Check", + default: 0, + }, { fieldname: "show_variant_attributes", label: __("Show Variant Attributes"), diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index 746231a81f4..2c728ae9d8b 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -35,6 +35,7 @@ class StockBalanceFilter(TypedDict): include_uom: str | None # include extra info in converted UOM show_stock_ageing_data: bool show_variant_attributes: bool + show_alt_uom_balance: bool SLEntry = dict[str, Any] @@ -76,6 +77,7 @@ class StockBalanceReport: self.columns = self.get_columns() self.add_additional_uom_columns() + self.add_alt_uom_columns() return self.columns, self.data @@ -602,6 +604,86 @@ class StockBalanceReport: conversion_factors = self.get_itemwise_conversion_factor() add_additional_uom_columns(self.columns, self.data, self.filters.include_uom, conversion_factors) + def add_alt_uom_columns(self) -> None: + """Add up to 2 alternate UOM balance columns per item after the Balance Qty column.""" + if not self.filters.get("show_alt_uom_balance"): + return + + item_alt_uom_map = self.get_item_alt_uom_map() + if not item_alt_uom_map: + return + + # Insert columns right after bal_qty (in reverse order so slot 1 comes first) + bal_qty_idx = next( + (i for i, col in enumerate(self.columns) if isinstance(col, dict) and col.get("fieldname") == "bal_qty"), + None, + ) + if bal_qty_idx is None: + return + + for slot in (2, 1): + self.columns.insert( + bal_qty_idx + 1, + { + "label": _(f"Balance Qty (Alt UOM {slot})"), + "fieldname": f"alt_uom_{slot}_bal_qty", + "fieldtype": "Float", + "width": 140, + }, + ) + self.columns.insert( + bal_qty_idx + 1, + { + "label": _(f"Alt UOM {slot}"), + "fieldname": f"alt_uom_{slot}", + "fieldtype": "Data", + "width": 90, + }, + ) + + for row in self.data: + alt_uoms = item_alt_uom_map.get(row.item_code, []) + for slot in (1, 2): + idx = slot - 1 + if idx < len(alt_uoms): + uom, factor = alt_uoms[idx]["uom"], flt(alt_uoms[idx]["conversion_factor"]) + row[f"alt_uom_{slot}"] = uom + row[f"alt_uom_{slot}_bal_qty"] = flt(row.get("bal_qty", 0)) / factor if factor else 0.0 + else: + row[f"alt_uom_{slot}"] = "" + row[f"alt_uom_{slot}_bal_qty"] = 0.0 + + def get_item_alt_uom_map(self) -> dict: + """Return {item_code: [{uom, conversion_factor}, ...]} for alternate UOMs (excluding stock UOM).""" + item_codes = list({d["item_code"] for d in self.data}) + if not item_codes: + return {} + + uom_detail = frappe.qb.DocType("UOM Conversion Detail") + item_table = frappe.qb.DocType("Item") + + rows = ( + frappe.qb.from_(uom_detail) + .join(item_table) + .on(uom_detail.parent == item_table.name) + .select(uom_detail.parent, uom_detail.uom, uom_detail.conversion_factor) + .where( + (uom_detail.parenttype == "Item") + & (uom_detail.parent.isin(item_codes)) + & (uom_detail.uom != item_table.stock_uom) + ) + .orderby(uom_detail.parent) + .orderby(uom_detail.idx) + ).run(as_dict=True) + + result: dict = {} + for row in rows: + result.setdefault(row.parent, []) + if len(result[row.parent]) < 2: # keep up to 2 alternate UOMs + result[row.parent].append({"uom": row.uom, "conversion_factor": row.conversion_factor}) + + return result + def get_itemwise_conversion_factor(self): items = [] if self.filters.item_code or self.filters.item_group: