From cd1f872912fbaf34a36570ed2989c6ee70ab3177 Mon Sep 17 00:00:00 2001 From: Dipen Gala Date: Fri, 12 Jun 2026 14:11:13 +0530 Subject: [PATCH 1/3] 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: From 1349bcc5353777d34928e3defad64961df4fbef1 Mon Sep 17 00:00:00 2001 From: Dipen Gala Date: Sat, 13 Jun 2026 11:34:28 +0530 Subject: [PATCH 2/3] refactor(stock-balance): reduce alt UOM to single column, fix i18n, add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reduce from 2 alternate UOM columns to 1 (first alt UOM by idx) - Fix broken translation strings: replace _(f"...{slot}") with _("...") — f-strings inside _() are never extracted by bench get-untranslated, breaking non-English installations - Simplify fieldnames: alt_uom_1/alt_uom_1_bal_qty → alt_uom/alt_uom_bal_qty - Add 4 test cases covering: single alt UOM, no alt UOM, disabled filter, and multiple alt UOMs (first-wins behaviour) Co-Authored-By: Claude Sonnet 4.6 (cherry picked from commit 021b80705775a12861872a2742f7c6c430538a5a) --- .../report/stock_balance/stock_balance.py | 59 +++++++++---------- .../stock_balance/test_stock_balance.py | 46 +++++++++++++++ 2 files changed, 74 insertions(+), 31 deletions(-) diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index 2c728ae9d8b..5a3e8911fcf 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -605,7 +605,7 @@ class StockBalanceReport: 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.""" + """Add an alternate UOM balance column after the Balance Qty column.""" if not self.filters.get("show_alt_uom_balance"): return @@ -613,7 +613,6 @@ class StockBalanceReport: 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, @@ -621,37 +620,35 @@ class StockBalanceReport: 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, - }, - ) + # Insert in reverse so "Alt UOM" name column appears before qty column + self.columns.insert( + bal_qty_idx + 1, + { + "label": _("Balance Qty (Alt UOM)"), + "fieldname": "alt_uom_bal_qty", + "fieldtype": "Float", + "width": 140, + }, + ) + self.columns.insert( + bal_qty_idx + 1, + { + "label": _("Alt UOM"), + "fieldname": "alt_uom", + "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 + if alt_uoms: + uom, factor = alt_uoms[0]["uom"], flt(alt_uoms[0]["conversion_factor"]) + row["alt_uom"] = uom + row["alt_uom_bal_qty"] = flt(row.get("bal_qty", 0)) / factor if factor else 0.0 + else: + row["alt_uom"] = "" + row["alt_uom_bal_qty"] = 0.0 def get_item_alt_uom_map(self) -> dict: """Return {item_code: [{uom, conversion_factor}, ...]} for alternate UOMs (excluding stock UOM).""" @@ -679,7 +676,7 @@ class StockBalanceReport: result: dict = {} for row in rows: result.setdefault(row.parent, []) - if len(result[row.parent]) < 2: # keep up to 2 alternate UOMs + if not result[row.parent]: # keep only the first alternate UOM (lowest idx) result[row.parent].append({"uom": row.uom, "conversion_factor": row.conversion_factor}) return result diff --git a/erpnext/stock/report/stock_balance/test_stock_balance.py b/erpnext/stock/report/stock_balance/test_stock_balance.py index ac545e03df3..16ecb971620 100644 --- a/erpnext/stock/report/stock_balance/test_stock_balance.py +++ b/erpnext/stock/report/stock_balance/test_stock_balance.py @@ -167,6 +167,52 @@ class TestStockBalance(ERPNextTestSuite): self.assertPartialDictEq(attributes, rows[0]) self.assertInvariants(rows) + def test_alt_uom_balance_single_uom(self): + """Alt UOM columns show correct name and converted qty for an item with one alternate UOM.""" + self.item.append("uoms", {"conversion_factor": 12, "uom": "Box"}) + self.item.save() + + self.generate_stock_ledger(self.item.name, [_dict(qty=24, rate=10)]) + + rows = stock_balance(self.filters.update({"show_alt_uom_balance": 1})) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0].get("alt_uom"), "Box") + self.assertAlmostEqual(rows[0].get("alt_uom_bal_qty"), 2.0) # 24 / 12 + + def test_alt_uom_balance_no_alternate_uom(self): + """Alt UOM columns are not added when no items in the report have alt UOMs.""" + self.generate_stock_ledger(self.item.name, [_dict(qty=5, rate=10)]) + + columns, _ = execute(self.filters.update({"show_alt_uom_balance": 1})) + col_fieldnames = [c.get("fieldname") for c in columns if isinstance(c, dict)] + self.assertNotIn("alt_uom", col_fieldnames) + self.assertNotIn("alt_uom_bal_qty", col_fieldnames) + + def test_alt_uom_balance_filter_disabled(self): + """No alt UOM columns are injected when show_alt_uom_balance is not set.""" + self.item.append("uoms", {"conversion_factor": 12, "uom": "Box"}) + self.item.save() + + self.generate_stock_ledger(self.item.name, [_dict(qty=24, rate=10)]) + + columns, _ = execute(self.filters) + col_fieldnames = [c.get("fieldname") for c in columns if isinstance(c, dict)] + self.assertNotIn("alt_uom", col_fieldnames) + self.assertNotIn("alt_uom_bal_qty", col_fieldnames) + + def test_alt_uom_balance_uses_first_alternate_uom(self): + """When an item has multiple alt UOMs, only the first (lowest idx) is shown.""" + self.item.append("uoms", {"conversion_factor": 12, "uom": "Box"}) + self.item.append("uoms", {"conversion_factor": 144, "uom": "Carton"}) + self.item.save() + + self.generate_stock_ledger(self.item.name, [_dict(qty=144, rate=10)]) + + rows = stock_balance(self.filters.update({"show_alt_uom_balance": 1})) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0].get("alt_uom"), "Box") + self.assertAlmostEqual(rows[0].get("alt_uom_bal_qty"), 12.0) # 144 / 12, not 144 / 144 + def test_stock_ageing_data_accepts_batchwise_valuation_slots(self): fifo_queue = [ ["SA-BATCH-NEWER", 1, 2.0, "2021-12-05", 20.0], From 96465a79363c25eca26f2c4ba3414b9a49de70f7 Mon Sep 17 00:00:00 2001 From: Dipen Gala Date: Tue, 16 Jun 2026 15:10:00 +0530 Subject: [PATCH 3/3] fix: resolve pre-commit formatting and missing UOM in stock balance test - Reformat generator expression in add_alt_uom_columns to satisfy ruff line-length rule (pre-commit was auto-fixing this and failing CI) - Create "Carton" UOM before use in test_alt_uom_balance_uses_first_alternate_uom to avoid LinkValidationError when "Carton" doesn't exist in test DB Co-Authored-By: Claude Sonnet 4.6 (cherry picked from commit 987f606b4d05313d252bac6cf3eebea31dc36ae8) --- erpnext/stock/report/stock_balance/stock_balance.py | 6 +++++- erpnext/stock/report/stock_balance/test_stock_balance.py | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index 5a3e8911fcf..7c77c81e58f 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -614,7 +614,11 @@ class StockBalanceReport: return bal_qty_idx = next( - (i for i, col in enumerate(self.columns) if isinstance(col, dict) and col.get("fieldname") == "bal_qty"), + ( + 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: diff --git a/erpnext/stock/report/stock_balance/test_stock_balance.py b/erpnext/stock/report/stock_balance/test_stock_balance.py index 16ecb971620..2a504f637e0 100644 --- a/erpnext/stock/report/stock_balance/test_stock_balance.py +++ b/erpnext/stock/report/stock_balance/test_stock_balance.py @@ -202,6 +202,7 @@ class TestStockBalance(ERPNextTestSuite): def test_alt_uom_balance_uses_first_alternate_uom(self): """When an item has multiple alt UOMs, only the first (lowest idx) is shown.""" + frappe.get_doc({"doctype": "UOM", "uom_name": "Carton"}).insert(ignore_if_duplicate=True) self.item.append("uoms", {"conversion_factor": 12, "uom": "Box"}) self.item.append("uoms", {"conversion_factor": 144, "uom": "Carton"}) self.item.save()