diff --git a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py index 954acf998d8..421529c90e6 100644 --- a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py +++ b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py @@ -20,6 +20,7 @@ SLE_FIELDS = ( "outgoing_rate", "stock_queue", "batch_no", + "serial_no", "stock_value", "stock_value_difference", "valuation_rate", @@ -52,16 +53,16 @@ def add_invariant_check_fields(sles, filters): balance_qty = 0.0 balance_stock_value = 0.0 - incorrect_idx = 0 - precision = frappe.get_precision("Stock Ledger Entry", "actual_qty") + incorrect_idx = None + float_precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) or 3 + currency_precision = ( + cint(frappe.db.get_single_value("System Settings", "currency_precision")) or float_precision + ) for idx, sle in enumerate(sles): - queue = json.loads(sle.stock_queue) if sle.stock_queue else [] - - fifo_qty = 0.0 - fifo_value = 0.0 - for qty, rate in queue: - fifo_qty += qty - fifo_value += qty * rate + if sle.batch_no: + sle.use_batchwise_valuation = frappe.db.get_value( + "Batch", sle.batch_no, "use_batchwise_valuation", cache=True + ) if sle.actual_qty < 0: sle.consumption_rate = sle.stock_value_difference / sle.actual_qty @@ -77,57 +78,66 @@ def add_invariant_check_fields(sles, filters): if balance_qty is None: balance_qty = sle.qty_after_transaction - sle.fifo_queue_qty = fifo_qty - sle.fifo_stock_value = fifo_value - sle.fifo_valuation_rate = fifo_value / fifo_qty if fifo_qty else None sle.balance_value_by_qty = ( sle.stock_value / sle.qty_after_transaction if sle.qty_after_transaction else None ) sle.expected_qty_after_transaction = balance_qty sle.stock_value_from_diff = balance_stock_value - # set difference fields sle.difference_in_qty = sle.qty_after_transaction - sle.expected_qty_after_transaction - sle.fifo_qty_diff = sle.qty_after_transaction - fifo_qty - sle.fifo_value_diff = sle.stock_value - fifo_value - sle.fifo_valuation_diff = ( - sle.valuation_rate - sle.fifo_valuation_rate if sle.fifo_valuation_rate else None - ) sle.valuation_diff = ( sle.valuation_rate - sle.balance_value_by_qty if sle.balance_value_by_qty else None ) sle.diff_value_diff = sle.stock_value_from_diff - sle.stock_value - if not incorrect_idx and filters.get("show_incorrect_entries"): - if is_sle_has_correct_data(sle, precision): - continue - else: - incorrect_idx = idx + if maintains_fifo_queue(sle): + add_fifo_fields(sle, sles[idx - 1] if idx else None) - if idx > 0: - sle.fifo_stock_diff = sle.fifo_stock_value - sles[idx - 1].fifo_stock_value - sle.fifo_difference_diff = sle.fifo_stock_diff - sle.stock_value_difference - - if sle.batch_no: - sle.use_batchwise_valuation = frappe.db.get_value( - "Batch", sle.batch_no, "use_batchwise_valuation", cache=True - ) + if incorrect_idx is None and not is_sle_has_correct_data(sle, float_precision, currency_precision): + incorrect_idx = idx if filters.get("show_incorrect_entries"): - if incorrect_idx > 0: - sles = sles[cint(incorrect_idx) - 1 :] - - return [] + if incorrect_idx is None: + return [] + return sles[max(incorrect_idx - 1, 0) :] return sles -def is_sle_has_correct_data(sle, precision): - if flt(sle.difference_in_qty, precision) != 0.0 or flt(sle.diff_value_diff, precision) != 0: - print(flt(sle.difference_in_qty, precision), flt(sle.diff_value_diff, precision)) - return False +def maintains_fifo_queue(sle): + # no queue is maintained for serialized/batchwise-valued stock + return not ( + sle.serial_and_batch_bundle or sle.serial_no or (sle.batch_no and sle.use_batchwise_valuation) + ) - return True + +def add_fifo_fields(sle, prev_sle): + queue = json.loads(sle.stock_queue) if sle.stock_queue else [] + + fifo_qty = 0.0 + fifo_value = 0.0 + for qty, rate in queue: + fifo_qty += qty + fifo_value += qty * rate + + sle.fifo_queue_qty = fifo_qty + sle.fifo_stock_value = fifo_value + sle.fifo_valuation_rate = fifo_value / fifo_qty if fifo_qty else None + sle.fifo_qty_diff = sle.qty_after_transaction - fifo_qty + sle.fifo_value_diff = sle.stock_value - fifo_value + sle.fifo_valuation_diff = ( + sle.valuation_rate - sle.fifo_valuation_rate if sle.fifo_valuation_rate else None + ) + if prev_sle and prev_sle.fifo_stock_value is not None: + sle.fifo_stock_diff = sle.fifo_stock_value - prev_sle.fifo_stock_value + sle.fifo_difference_diff = sle.fifo_stock_diff - sle.stock_value_difference + + +def is_sle_has_correct_data(sle, float_precision, currency_precision): + return ( + flt(sle.difference_in_qty, float_precision) == 0.0 + and flt(sle.diff_value_diff, currency_precision) == 0.0 + ) def get_columns(): diff --git a/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py new file mode 100644 index 00000000000..0f71a8834b2 --- /dev/null +++ b/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py @@ -0,0 +1,76 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe + +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from erpnext.stock.report.stock_ledger_invariant_check.stock_ledger_invariant_check import execute +from erpnext.tests.utils import ERPNextTestSuite + +WAREHOUSE = "Stores - _TC" +COMPANY = "_Test Company" +ITEM = "_Test Item" + + +class TestStockLedgerInvariantCheck(ERPNextTestSuite): + def run_report(self, **extra): + filters = frappe._dict({"company": COMPANY, "warehouse": WAREHOUSE}) + filters.update(extra) + return execute(filters)[1] + + def make_movements(self) -> str: + frappe.db.set_value("Item", ITEM, "valuation_method", "FIFO") + make_stock_entry(item_code=ITEM, to_warehouse=WAREHOUSE, qty=10, rate=100, posting_date="2026-06-01") + make_stock_entry(item_code=ITEM, to_warehouse=WAREHOUSE, qty=5, rate=120, posting_date="2026-06-02") + make_stock_entry(item_code=ITEM, from_warehouse=WAREHOUSE, qty=4, rate=0, posting_date="2026-06-03") + return ITEM + + def test_diagnostic_rows_have_no_discrepancy(self): + item = self.make_movements() + + data = self.run_report(item_code=item) + + self.assertEqual(len(data), 3) + for row in data: + self.assertLess(abs(row.difference_in_qty), 0.01) + self.assertLess(abs(row.fifo_qty_diff), 0.01) + self.assertLess(abs(row.diff_value_diff), 0.01) + + def test_running_balance_matches(self): + item = self.make_movements() + + data = self.run_report(item_code=item) + + self.assertEqual(data[-1].qty_after_transaction, 11) + + def test_show_incorrect_entries(self): + item = self.make_movements() + + self.assertEqual(self.run_report(item_code=item, show_incorrect_entries=1), []) + + sle = frappe.get_last_doc( + "Stock Ledger Entry", {"item_code": item, "warehouse": WAREHOUSE, "is_cancelled": 0} + ) + frappe.db.set_value( + "Stock Ledger Entry", sle.name, "qty_after_transaction", sle.qty_after_transaction + 5 + ) + + data = self.run_report(item_code=item, show_incorrect_entries=1) + self.assertEqual(len(data), 2) # incorrect entry + one before it for context + self.assertEqual(data[-1].name, sle.name) + + def test_batch_item_skips_fifo_queue_checks(self): + from erpnext.stock.doctype.item.test_item import make_item + + item = make_item( + properties={"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "SLIC-BAT-.####"} + ).name + make_stock_entry(item_code=item, to_warehouse=WAREHOUSE, qty=10, rate=100) + + data = self.run_report(item_code=item) + self.assertTrue(data) + for row in data: + self.assertIsNone(row.fifo_qty_diff) + self.assertIsNone(row.fifo_value_diff) + + self.assertEqual(self.run_report(item_code=item, show_incorrect_entries=1), []) diff --git a/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py b/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py index 327f158e3f6..f02f630d716 100644 --- a/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py +++ b/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py @@ -205,7 +205,10 @@ def get_data(filters=None): data = [] if item_warehouse_map: - precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) + float_precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) or 3 + currency_precision = ( + cint(frappe.db.get_single_value("System Settings", "currency_precision")) or float_precision + ) for item_warehouse in item_warehouse_map: report_data = stock_ledger_invariant_check(item_warehouse) @@ -215,7 +218,11 @@ def get_data(filters=None): for row in report_data: if has_difference( - row, precision, filters.difference_in, item_warehouse.valuation_method or valuation_method + row, + float_precision, + currency_precision, + filters.difference_in, + item_warehouse.valuation_method or valuation_method, ): row.update( { @@ -261,23 +268,26 @@ def get_item_warehouse_combinations(filters: dict | None = None) -> dict: return query.run(as_dict=1) -def has_difference(row, precision, difference_in, valuation_method): +def has_difference(row, float_precision, currency_precision, difference_in, valuation_method): if valuation_method == "Moving Average": - qty_diff = flt(row.difference_in_qty, precision) - value_diff = flt(row.diff_value_diff, precision) - valuation_diff = flt(row.valuation_diff, precision) + qty_diff = flt(row.difference_in_qty, float_precision) + value_diff = flt(row.diff_value_diff, currency_precision) + valuation_diff = flt(row.valuation_diff, currency_precision) else: - qty_diff = flt(row.difference_in_qty, precision) - value_diff = flt(row.diff_value_diff, precision) + qty_diff = flt(row.difference_in_qty, float_precision) + value_diff = flt(row.diff_value_diff, currency_precision) if row.stock_queue and json.loads(row.stock_queue): value_diff = value_diff or ( - flt(row.fifo_value_diff, precision) or flt(row.fifo_difference_diff, precision) + flt(row.fifo_value_diff, currency_precision) + or flt(row.fifo_difference_diff, currency_precision) ) - qty_diff = qty_diff or flt(row.fifo_qty_diff, precision) + qty_diff = qty_diff or flt(row.fifo_qty_diff, float_precision) - valuation_diff = flt(row.valuation_diff, precision) or flt(row.fifo_valuation_diff, precision) + valuation_diff = flt(row.valuation_diff, currency_precision) or flt( + row.fifo_valuation_diff, currency_precision + ) if difference_in == "Qty" and qty_diff: return True