diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py index 801c70920cf..ce08ac3c299 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py @@ -1594,6 +1594,153 @@ class TestStockLedgerEntry(ERPNextTestSuite, StockTestMixin): item_code=item_code, source=warehouse, qty=470.84, rate=100, posting_date=add_days(today(), -1) ) +<<<<<<< HEAD +======= + def test_zero_qty_row_is_skipped(self): + """A zero-qty non-reconciliation row must be skipped entirely: no SLE, + no crash, no reprocessing of the previous row's entry.""" + from erpnext.stock.stock_ledger import make_sl_entries + + item = make_item(properties={"is_stock_item": 1}) + voucher_no = f"zero-qty-{uuid4()}" + + make_sl_entries( + [ + frappe._dict( + item_code=item.name, + warehouse="_Test Warehouse - _TC", + company="_Test Company", + posting_date=today(), + posting_time="12:00:00", + voucher_type="Stock Entry", + voucher_no=voucher_no, + actual_qty=0, + stock_uom=item.stock_uom, + ) + ] + ) + + self.assertFalse(frappe.db.exists("Stock Ledger Entry", {"voucher_no": voucher_no})) + + def test_repost_with_diverged_posting_datetime(self): + """Reposting must use the stored posting_datetime, not one rebuilt from posting_date and posting_time.""" + from erpnext.stock.stock_ledger import get_sle_against_current_voucher + + raw = make_item().name + fg = make_item().name + warehouse = "_Test Warehouse - _TC" + clash_time = "09:04:25.784069" + + make_stock_entry( + item_code=fg, + to_warehouse=warehouse, + qty=123, + rate=2, + posting_date="2026-08-20", + posting_time="10:00:00", + ) + make_stock_entry( + item_code=fg, + to_warehouse=warehouse, + qty=500, + rate=2, + posting_date="2026-08-22", + posting_time="10:00:00", + ) + make_stock_entry( + item_code=raw, + to_warehouse=warehouse, + qty=500, + rate=2, + posting_date="2026-08-21", + posting_time="10:00:00", + ) + + # issue posted first, then a repack producing the same item at the same posting time + make_stock_entry( + item_code=fg, + from_warehouse=warehouse, + qty=500, + posting_date="2026-08-24", + posting_time=clash_time, + ) + + repack = make_stock_entry( + item_code=raw, source=warehouse, qty=500, rate=2, purpose="Repack", do_not_save=True + ) + repack.set_posting_time = 1 + repack.posting_date = "2026-08-24" + repack.posting_time = clash_time + repack.append( + "items", {"item_code": fg, "t_warehouse": warehouse, "qty": 500, "conversion_factor": 1} + ) + repack.save() + repack.submit() + + make_stock_entry( + item_code=fg, from_warehouse=warehouse, qty=8, posting_date="2026-08-25", posting_time="10:00:00" + ) + + # mimic a repair that backdates the stored value and leaves posting_time untouched + incoming = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": repack.name, "item_code": fg, "actual_qty": (">", 0)}, + ["name", "posting_date", "posting_time", "creation"], + as_dict=True, + ) + frappe.db.set_value( + "Stock Ledger Entry", + incoming.name, + "posting_datetime", + "2026-08-24 09:04:25.784068", + update_modified=False, + ) + + # the row must stay reachable through its own voucher + found = get_sle_against_current_voucher( + frappe._dict( + { + "item_code": fg, + "warehouse": warehouse, + "posting_date": incoming.posting_date, + "posting_time": incoming.posting_time, + "creation": incoming.creation, + "name": incoming.name, + } + ) + ) + self.assertEqual([d.name for d in found], [incoming.name]) + + riv = frappe.get_doc( + { + "doctype": "Repost Item Valuation", + "based_on": "Item and Warehouse", + "item_code": raw, + "warehouse": warehouse, + "posting_date": "2026-08-21", + "posting_time": "00:00:00", + "company": "_Test Company", + "allow_negative_stock": 1, + } + ).insert() + riv.submit() + + # the backdated row is replayed instead of contributing a stale balance + sle = frappe.qb.DocType("Stock Ledger Entry") + balances = ( + frappe.qb.from_(sle) + .select(sle.qty_after_transaction) + .where((sle.item_code == fg) & (sle.warehouse == warehouse) & (sle.is_cancelled == 0)) + .orderby(sle.posting_datetime) + .orderby(sle.creation) + ).run(pluck=True) + + self.assertEqual([flt(b) for b in balances], [123.0, 623.0, 1123.0, 623.0, 615.0]) + self.assertEqual( + flt(frappe.db.get_value("Bin", {"item_code": fg, "warehouse": warehouse}, "actual_qty")), 615.0 + ) + +>>>>>>> 6cee9c3 (fix(stock): use stored posting_datetime when reposting stock ledger entries (#58998)) def create_repack_entry(**args): args = frappe._dict(args) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 8ba82f31e7b..fab62fcb703 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1825,7 +1825,15 @@ class update_entries_after: def get_sle_against_current_voucher(kwargs): - kwargs["posting_datetime"] = get_combine_datetime(kwargs.posting_date, kwargs.posting_time) + # Match on the row's stored posting_datetime. Re-deriving it from posting_date and posting_time + # makes rows whose stored value differs unmatchable, so the voucher gets reposted against nothing. + if kwargs.get("name") and not kwargs.get("posting_datetime"): + kwargs["posting_datetime"] = frappe.db.get_value( + "Stock Ledger Entry", kwargs.get("name"), "posting_datetime" + ) + + if not kwargs.get("posting_datetime"): + kwargs["posting_datetime"] = get_combine_datetime(kwargs.posting_date, kwargs.posting_time) doctype = frappe.qb.DocType("Stock Ledger Entry") query = ( @@ -1958,14 +1966,19 @@ def get_stock_ledger_entries( frappe.db.escape(f"%\n{serial_no}\n%"), ) - if not previous_sle.get("posting_date"): - previous_sle["posting_datetime"] = "1900-01-01 00:00:00" - else: - posting_time = previous_sle.get("posting_time") - if not posting_time: - posting_time = "00:00:00" + if not previous_sle.get("posting_datetime"): + # Derive only when the caller has not supplied the stored posting_datetime. Re-deriving it + # would shift the boundary for rows whose stored value differs from posting_date + posting_time. + if not previous_sle.get("posting_date"): + previous_sle["posting_datetime"] = "1900-01-01 00:00:00" + else: + posting_time = previous_sle.get("posting_time") + if not posting_time: + posting_time = "00:00:00" - previous_sle["posting_datetime"] = get_combine_datetime(previous_sle["posting_date"], posting_time) + previous_sle["posting_datetime"] = get_combine_datetime( + previous_sle["posting_date"], posting_time + ) if operator in (">", "<=") and previous_sle.get("name"): conditions += " and name!=%(name)s"