mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-16 18:24:10 +00:00
Merge pull request #55977 from frappe/mergify/bp/version-16-hotfix/pr-55851
feat: add alternate UOM balance columns to Stock Balance report (backport #55851)
This commit is contained in:
@@ -110,6 +110,12 @@ frappe.query_reports["Stock Balance"] = {
|
|||||||
fieldtype: "Link",
|
fieldtype: "Link",
|
||||||
options: "UOM",
|
options: "UOM",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
fieldname: "show_alt_uom_balance",
|
||||||
|
label: __("Show Alternate UOM Balance"),
|
||||||
|
fieldtype: "Check",
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
fieldname: "show_variant_attributes",
|
fieldname: "show_variant_attributes",
|
||||||
label: __("Show Variant Attributes"),
|
label: __("Show Variant Attributes"),
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class StockBalanceFilter(TypedDict):
|
|||||||
include_uom: str | None # include extra info in converted UOM
|
include_uom: str | None # include extra info in converted UOM
|
||||||
show_stock_ageing_data: bool
|
show_stock_ageing_data: bool
|
||||||
show_variant_attributes: bool
|
show_variant_attributes: bool
|
||||||
|
show_alt_uom_balance: bool
|
||||||
|
|
||||||
|
|
||||||
SLEntry = dict[str, Any]
|
SLEntry = dict[str, Any]
|
||||||
@@ -76,6 +77,7 @@ class StockBalanceReport:
|
|||||||
self.columns = self.get_columns()
|
self.columns = self.get_columns()
|
||||||
|
|
||||||
self.add_additional_uom_columns()
|
self.add_additional_uom_columns()
|
||||||
|
self.add_alt_uom_columns()
|
||||||
|
|
||||||
return self.columns, self.data
|
return self.columns, self.data
|
||||||
|
|
||||||
@@ -602,6 +604,87 @@ class StockBalanceReport:
|
|||||||
conversion_factors = self.get_itemwise_conversion_factor()
|
conversion_factors = self.get_itemwise_conversion_factor()
|
||||||
add_additional_uom_columns(self.columns, self.data, self.filters.include_uom, conversion_factors)
|
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):
|
def get_itemwise_conversion_factor(self):
|
||||||
items = []
|
items = []
|
||||||
if self.filters.item_code or self.filters.item_group:
|
if self.filters.item_code or self.filters.item_group:
|
||||||
|
|||||||
@@ -167,6 +167,53 @@ class TestStockBalance(ERPNextTestSuite):
|
|||||||
self.assertPartialDictEq(attributes, rows[0])
|
self.assertPartialDictEq(attributes, rows[0])
|
||||||
self.assertInvariants(rows)
|
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):
|
def test_stock_ageing_data_accepts_batchwise_valuation_slots(self):
|
||||||
fifo_queue = [
|
fifo_queue = [
|
||||||
["SA-BATCH-NEWER", 1, 2.0, "2021-12-05", 20.0],
|
["SA-BATCH-NEWER", 1, 2.0, "2021-12-05", 20.0],
|
||||||
|
|||||||
Reference in New Issue
Block a user