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..7c77c81e58f 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,87 @@ 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 an alternate UOM balance column 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 + + 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 + + # 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, []) + 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).""" + 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 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 + def get_itemwise_conversion_factor(self): items = [] if self.filters.item_code or self.filters.item_group: diff --git a/erpnext/stock/report/stock_balance/test_stock_balance.py b/erpnext/stock/report/stock_balance/test_stock_balance.py index ac545e03df3..2a504f637e0 100644 --- a/erpnext/stock/report/stock_balance/test_stock_balance.py +++ b/erpnext/stock/report/stock_balance/test_stock_balance.py @@ -167,6 +167,53 @@ 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.""" + 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() + + 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],