From f970e4d27192e602ceddec7a2133932c3121ea8f Mon Sep 17 00:00:00 2001 From: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:14:16 +0530 Subject: [PATCH] fix(stock): handle multi-item opening balance in Stock Ledger report (#57591) * fix(stock): handle multi-item opening balance in Stock * test(stock): add unit test for multi-item Stock Ledger report --------- Co-authored-by: Afsal Syed (cherry picked from commit 0dbe410414b94649dcb2c47e419f507da13490df) # Conflicts: # erpnext/stock/report/stock_ledger/test_stock_ledger_report.py --- .../stock/report/stock_ledger/stock_ledger.py | 174 +++++++--- .../stock_ledger/test_stock_ledger_report.py | 305 ++++++++++++++++++ 2 files changed, 433 insertions(+), 46 deletions(-) diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py index e49279689f2..51460972992 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.py +++ b/erpnext/stock/report/stock_ledger/stock_ledger.py @@ -7,8 +7,10 @@ from collections import defaultdict import frappe from frappe import _ -from frappe.query_builder.functions import Sum +from frappe.query_builder.functions import IfNull, Sum from frappe.utils import cint, flt, get_datetime +from pypika import Order +from pypika.analytics import RowNumber from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos @@ -53,14 +55,15 @@ def execute(filters=None): data = [] conversion_factors = [] - if opening_row: - data.append(opening_row) + opening_rows = opening_row if isinstance(opening_row, list) else ([opening_row] if opening_row else []) + for row in opening_rows: + data.append(row) conversion_factors.append(0) actual_qty = stock_value = 0 - if opening_row: - actual_qty = opening_row.get("qty_after_transaction") - stock_value = opening_row.get("stock_value") + if opening_rows: + actual_qty = opening_rows[0].get("qty_after_transaction", 0) + stock_value = opening_rows[0].get("stock_value", 0) available_serial_nos = {} @@ -693,43 +696,120 @@ def get_opening_balance(filters, columns, sl_entries, inv_dimension_wise_value=N if not (filters.item_code and filters.warehouse and filters.from_date): return - from erpnext.stock.stock_ledger import get_previous_sle + item_codes = filters.item_code + if isinstance(item_codes, str): + item_codes = [item_codes] - project = None - if filters.get("project") and not frappe.get_all( - "Inventory Dimension", filters={"reference_document": "Project"} - ): - project = filters.get("project") + warehouses = get_matching_warehouses(filters.warehouse) + if not warehouses: + return - last_entry = get_previous_sle( - { - "item_code": filters.item_code, - "warehouse_condition": get_warehouse_condition(filters.warehouse), - "posting_date": filters.from_date, - "posting_time": "00:00:00", - "project": project, - }, - for_report=True, + sle_doctype = frappe.qb.DocType("Stock Ledger Entry") + sr_doctype = frappe.qb.DocType("Stock Reconciliation") + + opening_reco_query = ( + frappe.qb.from_(sle_doctype) + .inner_join(sr_doctype) + .on(sle_doctype.voucher_no == sr_doctype.name) + .select(sle_doctype.voucher_no) + .where(sle_doctype.docstatus < 2) + .where(sle_doctype.is_cancelled == 0) + .where(sle_doctype.item_code.isin(item_codes)) + .where(sle_doctype.warehouse.isin(warehouses)) + .where(sle_doctype.voucher_type == "Stock Reconciliation") + .where(sle_doctype.posting_date == filters.from_date) + .where(sr_doctype.purpose == "Opening Stock") ) - # check if any SLEs are actually Opening Stock Reconciliation - for sle in list(sl_entries): - if ( - sle.get("voucher_type") == "Stock Reconciliation" - and sle.posting_date == filters.from_date - and frappe.db.get_value("Stock Reconciliation", sle.voucher_no, "purpose") == "Opening Stock" - ): - last_entry = sle - sl_entries.remove(sle) + opening_reco_vouchers = set(opening_reco_query.run(pluck=True)) - row = { + if opening_reco_vouchers: + sl_entries[:] = [sle for sle in sl_entries if sle.get("voucher_no") not in opening_reco_vouchers] + + sle_cond = (sle_doctype.posting_date < filters.from_date) | ( + (sle_doctype.posting_date == filters.from_date) & (sle_doctype.posting_time == "00:00:00") + ) + if opening_reco_vouchers: + sle_cond = sle_cond | ( + (sle_doctype.posting_date == filters.from_date) + & (sle_doctype.voucher_no.isin(list(opening_reco_vouchers))) + ) + + subq = ( + frappe.qb.from_(sle_doctype) + .select( + sle_doctype.qty_after_transaction, + sle_doctype.stock_value, + RowNumber() + .over(sle_doctype.item_code, sle_doctype.warehouse) + .orderby(sle_doctype.posting_datetime, sle_doctype.creation, sle_doctype.name, order=Order.desc) + .as_("rn"), + ) + .where(sle_doctype.docstatus < 2) + .where(sle_doctype.is_cancelled == 0) + .where(sle_doctype.item_code.isin(item_codes)) + .where(sle_doctype.warehouse.isin(warehouses)) + .where(sle_cond) + ) + + for field in ["voucher_no", "project", "company"]: + if filters.get(field): + subq = subq.where(sle_doctype[field] == filters.get(field)) + + inventory_dimension_fields = get_inventory_dimension_fields() + if inventory_dimension_fields: + for fieldname in inventory_dimension_fields: + if filters.get(fieldname): + subq = subq.where(sle_doctype[fieldname].isin(filters.get(fieldname))) + + query = ( + frappe.qb.from_(subq) + .select( + IfNull(Sum(subq.qty_after_transaction), 0.0).as_("total_qty"), + IfNull(Sum(subq.stock_value), 0.0).as_("total_stock_value"), + ) + .where(subq.rn == 1) + ) + + res = query.run(as_dict=True) + + total_qty = flt(res[0].total_qty) if res else 0.0 + total_stock_value = flt(res[0].total_stock_value) if res else 0.0 + valuation_rate = flt(total_stock_value / total_qty) if total_qty else 0.0 + + return { "item_code": _("'Opening'"), - "qty_after_transaction": last_entry.get("qty_after_transaction", 0), - "valuation_rate": last_entry.get("valuation_rate", 0), - "stock_value": last_entry.get("stock_value", 0), + "qty_after_transaction": total_qty, + "valuation_rate": valuation_rate, + "stock_value": total_stock_value, } - return row + +def get_matching_warehouses(warehouses): + if not warehouses: + return [] + + if isinstance(warehouses, str): + warehouses = [warehouses] + + warehouse_details = frappe.get_all( + "Warehouse", + filters={"name": ("in", warehouses)}, + fields=["lft", "rgt"], + ) + + if not warehouse_details: + return warehouses + + wh = frappe.qb.DocType("Warehouse") + cond = None + for d in warehouse_details: + c = (wh.lft >= d.lft) & (wh.rgt <= d.rgt) + cond = c if cond is None else (cond | c) + + matching = (frappe.qb.from_(wh).select(wh.name).where(cond)).run(pluck=True) + + return matching if matching else warehouses def get_warehouse_condition(warehouses): @@ -785,7 +865,15 @@ def get_opening_balance_for_inv_dimension(filters, inv_dimension_wise_value): if not filters.item_code or not filters.warehouse or not filters.from_date: return - if len(filters.get("item_code")) > 1 or len(filters.get("warehouse")) > 1: + item_codes = filters.get("item_code") + if isinstance(item_codes, str): + item_codes = [item_codes] + + warehouses = filters.get("warehouse") + if isinstance(warehouses, str): + warehouses = [warehouses] + + if len(item_codes) > 1 or len(warehouses) > 1: return sl_doctype = frappe.qb.DocType("Stock Ledger Entry") @@ -805,17 +893,11 @@ def get_opening_balance_for_inv_dimension(filters, inv_dimension_wise_value): ) ) - if filters.get("item_code"): - if isinstance(filters.item_code, list | tuple): - query = query.where(sl_doctype.item_code.isin(filters.item_code)) - else: - query = query.where(sl_doctype.item_code == filters.item_code) + if item_codes: + query = query.where(sl_doctype.item_code.isin(item_codes)) - if filters.get("warehouse"): - if isinstance(filters.warehouse, list | tuple): - query = query.where(sl_doctype.warehouse.isin(filters.warehouse)) - else: - query = query.where(sl_doctype.warehouse == filters.warehouse) + if warehouses: + query = query.where(sl_doctype.warehouse.isin(warehouses)) for key, value in inv_dimension_wise_value.items(): if isinstance(value, list | tuple): diff --git a/erpnext/stock/report/stock_ledger/test_stock_ledger_report.py b/erpnext/stock/report/stock_ledger/test_stock_ledger_report.py index 8a97a64d1b3..1afd14ef481 100644 --- a/erpnext/stock/report/stock_ledger/test_stock_ledger_report.py +++ b/erpnext/stock/report/stock_ledger/test_stock_ledger_report.py @@ -19,3 +19,308 @@ class TestStockLedgerReeport(ERPNextTestSuite): to_date=add_days(today(), 30), item_code=["_Test Stock Report Serial Item"], ) +<<<<<<< HEAD +======= + return list(execute(filters)[1]) + + def test_in_out_quantities_and_running_balance(self): + item = "_Test Item" + self.make_movements( + item, + [ + {"qty": 10, "to_warehouse": WAREHOUSE, "basic_rate": 100}, + {"qty": 4, "from_warehouse": WAREHOUSE}, + ], + ) + + rows = self.run_report(item) + receipt = next(row for row in rows if row.get("in_qty")) + issue = next(row for row in rows if row.get("out_qty")) + + self.assertEqual(receipt["in_qty"], 10) + self.assertEqual(receipt["qty_after_transaction"], 10) + self.assertEqual(issue["out_qty"], -4) + self.assertEqual(issue["qty_after_transaction"], 6) + + def test_opening_balance_reflects_movements_before_from_date(self): + item = "_Test Item" + self.make_movements( + item, + [ + { + "qty": 10, + "to_warehouse": WAREHOUSE, + "basic_rate": 100, + "posting_date": add_days(today(), -10), + }, + {"qty": 4, "from_warehouse": WAREHOUSE, "posting_date": today()}, + ], + ) + + rows = self.run_report(item, from_date=add_days(today(), -5), to_date=today()) + + # the receipt predates the range, so it surfaces as the opening balance + self.assertEqual(rows[0]["item_code"], "'Opening'") + self.assertEqual(rows[0]["qty_after_transaction"], 10) + + # the in-range issue draws down from the opening balance + issue = next(row for row in rows if row.get("out_qty")) + self.assertEqual(issue["qty_after_transaction"], 6) + + def test_filters_to_requested_item_only(self): + item_a = "_Test Item" + item_b = "_Test Item 2" + self.make_movements(item_a, [{"qty": 5, "to_warehouse": WAREHOUSE, "basic_rate": 100}]) + self.make_movements(item_b, [{"qty": 7, "to_warehouse": WAREHOUSE, "basic_rate": 100}]) + + rows = self.run_report(item_a) + item_codes = {row["item_code"] for row in rows if row.get("voucher_no")} + self.assertEqual(item_codes, {item_a}) + + def test_multi_item_opening_balance_with_and_without_transactions(self): + item_a = "_Test Item" + item_b = "_Test Item 2" + self.make_movements( + item_a, + [ + { + "qty": 10, + "to_warehouse": WAREHOUSE, + "basic_rate": 100, + "posting_date": add_days(today(), -10), + } + ], + ) + self.make_movements( + item_b, + [{"qty": 5, "to_warehouse": WAREHOUSE, "basic_rate": 50, "posting_date": add_days(today(), -10)}], + ) + self.make_movements( + item_a, + [{"qty": 2, "from_warehouse": WAREHOUSE, "posting_date": today()}], + ) + + filters = frappe._dict( + company="_Test Company", + from_date=add_days(today(), -5), + to_date=today(), + item_code=[item_a, item_b], + warehouse=WAREHOUSE, + ) + columns, rows = execute(filters) + + opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"] + self.assertEqual(len(opening_rows), 1) + self.assertEqual(opening_rows[0]["qty_after_transaction"], 15) + + def test_multi_warehouse_opening_balance_aggregation(self): + item = "_Test Item" + warehouse_1 = "Stores - _TC" + warehouse_2 = "Finished Goods - _TC" + + self.make_movements( + item, + [ + { + "qty": 10, + "to_warehouse": warehouse_1, + "basic_rate": 100, + "posting_date": add_days(today(), -10), + }, + { + "qty": 20, + "to_warehouse": warehouse_2, + "basic_rate": 100, + "posting_date": add_days(today(), -10), + }, + ], + ) + + filters = frappe._dict( + company="_Test Company", + from_date=add_days(today(), -5), + to_date=today(), + item_code=[item], + warehouse=[warehouse_1, warehouse_2], + ) + columns, rows = execute(filters) + + opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"] + self.assertEqual(len(opening_rows), 1) + self.assertEqual(opening_rows[0]["qty_after_transaction"], 30) + + def test_opening_stock_reconciliation_on_from_date_non_midnight_time(self): + from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( + create_stock_reconciliation, + ) + + item = "_Test Item" + from_date = today() + + sr = create_stock_reconciliation( + item_code=item, + warehouse=WAREHOUSE, + qty=25, + rate=100, + posting_date=from_date, + posting_time="10:30:00", + purpose="Opening Stock", + do_not_submit=False, + ) + + filters = frappe._dict( + company="_Test Company", + from_date=from_date, + to_date=from_date, + item_code=[item], + warehouse=WAREHOUSE, + ) + columns, rows = execute(filters) + + opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"] + self.assertEqual(len(opening_rows), 1) + self.assertEqual(opening_rows[0]["qty_after_transaction"], 25) + + # Ensure the Opening Stock Reconciliation is not duplicated in detail transaction rows + reco_rows = [row for row in rows if row.get("voucher_no") == sr.name] + self.assertEqual(len(reco_rows), 0) + + def test_backdated_sle_independent_maxima_handling(self): + item = "_Test Item" + # Entry 1: Later posting date (2026-07-20), created first + self.make_movements( + item, + [ + { + "qty": 10, + "to_warehouse": WAREHOUSE, + "basic_rate": 100, + "posting_date": add_days(today(), -10), + } + ], + ) + # Entry 2: Backdated posting date (2026-07-15), created LATER + self.make_movements( + item, + [ + { + "qty": 5, + "to_warehouse": WAREHOUSE, + "basic_rate": 100, + "posting_date": add_days(today(), -15), + } + ], + ) + + filters = frappe._dict( + company="_Test Company", + from_date=add_days(today(), -5), + to_date=today(), + item_code=[item], + warehouse=WAREHOUSE, + ) + columns, rows = execute(filters) + + opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"] + self.assertEqual(len(opening_rows), 1) + # Should correctly pick the latest posting date entry (15 Qty) despite backdated creation order + self.assertEqual(opening_rows[0]["qty_after_transaction"], 15) + + def test_filtered_opening_balance_does_not_pick_excluded_creation_entry(self): + item = "_Test Item" + posting_date = add_days(today(), -10) + posting_time = "09:00:00" + + included_entry = make_stock_entry( + item_code=item, + qty=10, + to_warehouse=WAREHOUSE, + basic_rate=100, + posting_date=posting_date, + posting_time=posting_time, + ) + make_stock_entry( + item_code=item, + qty=50, + to_warehouse=WAREHOUSE, + basic_rate=100, + posting_date=posting_date, + posting_time=posting_time, + ) + + filters = frappe._dict( + company="_Test Company", + from_date=add_days(today(), -5), + to_date=today(), + item_code=[item], + warehouse=WAREHOUSE, + voucher_no=included_entry.name, + ) + columns, rows = execute(filters) + + opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"] + self.assertEqual(len(opening_rows), 1) + self.assertEqual(opening_rows[0]["qty_after_transaction"], 10) + + def test_tied_creation_terminal_sle_is_not_summed_twice(self): + item = "_Test Item" + posting_date = add_days(today(), -10) + posting_time = "09:00:00" + + stock_entry_1 = make_stock_entry( + item_code=item, + qty=10, + to_warehouse=WAREHOUSE, + basic_rate=100, + posting_date=posting_date, + posting_time=posting_time, + ) + stock_entry_2 = make_stock_entry( + item_code=item, + qty=5, + to_warehouse=WAREHOUSE, + basic_rate=100, + posting_date=posting_date, + posting_time=posting_time, + ) + + sle_rows = frappe.get_all( + "Stock Ledger Entry", + filters={ + "voucher_type": "Stock Entry", + "voucher_no": ("in", [stock_entry_1.name, stock_entry_2.name]), + "item_code": item, + "warehouse": WAREHOUSE, + "is_cancelled": 0, + }, + fields=["name", "qty_after_transaction"], + order_by="name desc", + ) + self.assertEqual(len(sle_rows), 2) + + for sle in sle_rows: + frappe.db.set_value( + "Stock Ledger Entry", + sle.name, + "creation", + "2026-01-01 00:00:00.000000", + update_modified=False, + ) + + filters = frappe._dict( + company="_Test Company", + from_date=add_days(today(), -5), + to_date=today(), + item_code=[item], + warehouse=WAREHOUSE, + ) + columns, rows = execute(filters) + + opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"] + self.assertEqual(len(opening_rows), 1) + self.assertEqual(opening_rows[0]["qty_after_transaction"], sle_rows[0].qty_after_transaction) + self.assertNotEqual( + opening_rows[0]["qty_after_transaction"], + sum(sle.qty_after_transaction for sle in sle_rows), + ) +>>>>>>> 0dbe410414 (fix(stock): handle multi-item opening balance in Stock Ledger report (#57591))