From e48ffe6ef075054018763d3f80a8fcc04e4feac1 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 9 Jun 2026 19:22:32 +0530 Subject: [PATCH] fix: handle multi-select stock ageing filters (#55775) --- .../stock/report/stock_ageing/stock_ageing.py | 44 +++++++++---------- .../report/stock_ageing/test_stock_ageing.py | 19 +++++++- .../report/stock_balance/stock_balance.py | 33 +++++++++----- .../stock_balance/test_stock_balance.py | 18 +++++++- 4 files changed, 78 insertions(+), 36 deletions(-) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index bbe5392f4c3..2a2b5e14c51 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -11,6 +11,7 @@ from frappe.query_builder.functions import Abs, Count from frappe.utils import cint, date_diff, flt, get_datetime from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos +from erpnext.stock.doctype.warehouse.warehouse import apply_warehouse_filter from erpnext.stock.valuation import round_off_if_near_zero Filters = frappe._dict @@ -72,12 +73,17 @@ def format_report_data(filters: Filters, item_details: dict, to_date: str) -> li return data +def normalize_fifo_queue(fifo_queue: list) -> list: + """Convert batch valuation slots to the standard [qty, posting_date, value] shape.""" + return [get_batch_report_slot(slot) if is_batch_slot(slot) else slot for slot in fifo_queue] + + def get_report_fifo_queue(fifo_queue: list, has_batch_no: bool) -> list: get_posting_date = itemgetter(FIFO_POSTING_DATE_INDEX) fifo_queue = sorted([slot for slot in fifo_queue if get_posting_date(slot)], key=get_posting_date) if has_batch_no: - return [get_batch_report_slot(slot) for slot in fifo_queue] + return normalize_fifo_queue(fifo_queue) return fifo_queue @@ -113,7 +119,7 @@ def get_report_row(filters: Filters, item_dict: dict, fifo_queue: list, to_date: def get_average_age(fifo_queue: list, to_date: str) -> float: age_qty = total_qty = 0.0 - for slot in fifo_queue: + for slot in normalize_fifo_queue(fifo_queue): qty = get_slot_qty(slot) age_qty += date_diff(to_date, slot[FIFO_DATE_INDEX]) * qty total_qty += qty @@ -925,9 +931,7 @@ class FIFOSlots: ) ) - for field in ["item_code"]: - if self.filters.get(field): - query = query.where(bundle[field] == self.filters.get(field)) + query = self._apply_filter(query, bundle, "item_code") if self.filters.get("warehouse"): query = self._get_warehouse_conditions(bundle, query) @@ -965,9 +969,7 @@ class FIFOSlots: ) ) - for field in ["item_code"]: - if self.filters.get(field): - query = query.where(bundle[field] == self.filters.get(field)) + query = self._apply_filter(query, bundle, "item_code") if self.filters.get("warehouse"): query = self._get_warehouse_conditions(bundle, query) @@ -997,27 +999,25 @@ class FIFOSlots: "has_batch_no", ) - if self.filters.get("item_code"): - item = item.where(item_table.item_code == self.filters.get("item_code")) + item = self._apply_filter(item, item_table, "item_code") if self.filters.get("brand"): item = item.where(item_table.brand == self.filters.get("brand")) return item + def _apply_filter(self, query, table, fieldname: str): + filter_value = self.filters.get(fieldname) + if not filter_value: + return query + + if isinstance(filter_value, list | tuple | set): + return query.where(table[fieldname].isin(filter_value)) + + return query.where(table[fieldname] == filter_value) + def _get_warehouse_conditions(self, sle, sle_query) -> str: - warehouse = frappe.qb.DocType("Warehouse") - lft, rgt = frappe.db.get_value("Warehouse", self.filters.get("warehouse"), ["lft", "rgt"]) - - warehouse_results = ( - frappe.qb.from_(warehouse) - .select("name") - .where((warehouse.lft >= lft) & (warehouse.rgt <= rgt)) - .run() - ) - warehouse_results = [x[0] for x in warehouse_results] - - return sle_query.where(sle.warehouse.isin(warehouse_results)) + return apply_warehouse_filter(sle_query, sle, self.filters) def prepare_stock_reco_voucher_wise_count(self): self.stock_reco_voucher_wise_count = frappe._dict() diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index 3fdae7ca281..fc710b6884a 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -3,7 +3,7 @@ import frappe -from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, format_report_data +from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, format_report_data, get_average_age from erpnext.tests.utils import ERPNextTestSuite @@ -125,6 +125,18 @@ class TestStockAgeing(ERPNextTestSuite): self.assertEqual(queue[0][0], 10.0) self.assertEqual(queue[1][0], 10.0) + def test_item_filter_supports_multi_select_values(self): + bundle = frappe.qb.DocType("Serial and Batch Bundle") + query = frappe.qb.from_(bundle).select(bundle.name) + + filtered_query = FIFOSlots(frappe._dict(item_code=["Item A"]), [])._apply_filter( + query, bundle, "item_code" + ) + + sql = filtered_query.get_sql() + self.assertIn(" IN ", sql) + self.assertNotIn("=[", sql) + def test_basic_stock_reconciliation(self): """ Ledger (same wh): [+30, reco reset >> 50, -10] @@ -893,6 +905,11 @@ class TestStockAgeing(ERPNextTestSuite): self.assertEqual(report_data[0][7:15], [8.0, 80.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) + def test_average_age_accepts_batchwise_valuation_slots(self): + fifo_queue = [["SA-BATCH-SLOT", 1, 5.0, "2021-12-01", 50.0]] + + self.assertEqual(get_average_age(fifo_queue, "2021-12-10"), 9.0) + def test_serial_transfer_replay_preserves_serial_slots(self): fifo_slots = FIFOSlots(self.filters, []) transfer_key = ("001", "Serial Item", "WH 1") diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index 7d96d6a6d6b..6d66bae431a 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -16,7 +16,11 @@ import erpnext from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions from erpnext.stock.doctype.stock_closing_entry.stock_closing_entry import StockClosing from erpnext.stock.doctype.warehouse.warehouse import apply_warehouse_filter -from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, get_average_age +from erpnext.stock.report.stock_ageing.stock_ageing import ( + FIFOSlots, + get_average_age, + normalize_fifo_queue, +) from erpnext.stock.utils import add_additional_uom_columns @@ -280,8 +284,6 @@ class StockBalanceReport: self.filters["show_warehouse_wise_stock"] = True item_wise_fifo_queue = FIFOSlots(self.filters).generate() - _func = itemgetter(1) - del self.sle_entries sre_details = self.get_sre_reserved_qty_details() @@ -307,15 +309,7 @@ class StockBalanceReport: stock_ageing_data = {"average_age": 0, "earliest_age": 0, "latest_age": 0} if opening_fifo_queue: - fifo_queue = sorted(filter(_func, opening_fifo_queue), key=_func) - if not fifo_queue: - continue - - to_date = self.to_date - stock_ageing_data["average_age"] = get_average_age(fifo_queue, to_date) - stock_ageing_data["earliest_age"] = date_diff(to_date, fifo_queue[0][1]) - stock_ageing_data["latest_age"] = date_diff(to_date, fifo_queue[-1][1]) - stock_ageing_data["fifo_queue"] = fifo_queue + stock_ageing_data.update(get_stock_ageing_data(opening_fifo_queue, self.to_date)) report_data.update(stock_ageing_data) @@ -698,6 +692,21 @@ class StockBalanceReport: return opening_fifo_queue +def get_stock_ageing_data(fifo_queue: list, to_date: str) -> dict: + stock_ageing_data = {"average_age": 0, "earliest_age": 0, "latest_age": 0} + fifo_queue = sorted(filter(itemgetter(1), normalize_fifo_queue(fifo_queue)), key=itemgetter(1)) + + if not fifo_queue: + return stock_ageing_data + + stock_ageing_data["average_age"] = get_average_age(fifo_queue, to_date) + stock_ageing_data["earliest_age"] = date_diff(to_date, fifo_queue[0][1]) + stock_ageing_data["latest_age"] = date_diff(to_date, fifo_queue[-1][1]) + stock_ageing_data["fifo_queue"] = fifo_queue + + return stock_ageing_data + + def filter_items_with_no_transactions( iwb_map, float_precision: float, inventory_dimensions: list | None = None ): diff --git a/erpnext/stock/report/stock_balance/test_stock_balance.py b/erpnext/stock/report/stock_balance/test_stock_balance.py index 583e362112c..ac545e03df3 100644 --- a/erpnext/stock/report/stock_balance/test_stock_balance.py +++ b/erpnext/stock/report/stock_balance/test_stock_balance.py @@ -6,7 +6,7 @@ from frappe.utils import today from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry -from erpnext.stock.report.stock_balance.stock_balance import execute +from erpnext.stock.report.stock_balance.stock_balance import execute, get_stock_ageing_data from erpnext.tests.utils import ERPNextTestSuite @@ -166,3 +166,19 @@ class TestStockBalance(ERPNextTestSuite): rows = stock_balance(self.filters.update({"show_variant_attributes": 1, "item_code": [variant.name]})) self.assertPartialDictEq(attributes, rows[0]) self.assertInvariants(rows) + + def test_stock_ageing_data_accepts_batchwise_valuation_slots(self): + fifo_queue = [ + ["SA-BATCH-NEWER", 1, 2.0, "2021-12-05", 20.0], + ["SA-BATCH-OLDER", 1, 3.0, "2021-12-01", 30.0], + ] + + stock_ageing_data = get_stock_ageing_data(fifo_queue, "2021-12-10") + + self.assertEqual(stock_ageing_data["average_age"], 7.4) + self.assertEqual(stock_ageing_data["earliest_age"], 9) + self.assertEqual(stock_ageing_data["latest_age"], 5) + self.assertEqual( + stock_ageing_data["fifo_queue"], + [[3.0, "2021-12-01", 30.0], [2.0, "2021-12-05", 20.0]], + )