mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-20 13:14:03 +00:00
feat: add alternate UOM balance columns to Stock Balance report
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 2d93c5835a)
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user