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 a3905e0f4d8..d0fcec592ad 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 @@ -1290,6 +1290,107 @@ class TestStockLedgerEntry(ERPNextTestSuite, StockTestMixin): self.assertEqual(sle[0].qty_after_transaction, 105) self.assertEqual(sle[0].actual_qty, 100) + def test_update_qty_in_future_sle_shifts_same_timestamp_later_entry(self): + # update_qty_in_future_sle treats "future" as strictly after the current entry in the + # (posting_datetime, creation) order. An entry sharing the exact posting timestamp but created + # later must still have its running balance shifted; comparing posting_datetime alone would skip + # it. The current entry itself (same timestamp, same creation) must not be shifted. + from erpnext.stock.stock_ledger import update_qty_in_future_sle + + item = make_item().name + warehouse = "_Test Warehouse - _TC" + + receipt1 = make_purchase_receipt( + item_code=item, + warehouse=warehouse, + qty=10, + rate=10, + posting_date="2021-01-01", + posting_time="02:00:00", + ) + time.sleep(1) + receipt2 = make_purchase_receipt( + item_code=item, + warehouse=warehouse, + qty=20, + rate=10, + posting_date="2021-01-01", + posting_time="02:00:00", # identical timestamp, later creation + ) + + def sle(voucher): + return frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": voucher.name, "is_cancelled": 0}, + ["name", "posting_date", "posting_time", "creation", "qty_after_transaction"], + as_dict=True, + ) + + sle1, sle2 = sle(receipt1), sle(receipt2) + self.assertEqual(sle1.qty_after_transaction, 10) + self.assertEqual(sle2.qty_after_transaction, 30) + + # Simulate a +5 qty shift originating at receipt1's ledger position. + args = frappe._dict( + { + "item_code": item, + "warehouse": warehouse, + "voucher_type": "Purchase Receipt", + "voucher_no": receipt1.name, + "posting_date": sle1.posting_date, + "posting_time": sle1.posting_time, + "creation": sle1.creation, + "actual_qty": 5, + } + ) + update_qty_in_future_sle(args, allow_negative_stock=True) + + # receipt2 (same timestamp, later creation) is shifted; receipt1 (the current entry) is not. + self.assertEqual(frappe.db.get_value("Stock Ledger Entry", sle2.name, "qty_after_transaction"), 35) + self.assertEqual(frappe.db.get_value("Stock Ledger Entry", sle1.name, "qty_after_transaction"), 10) + + def test_get_next_stock_reco_respects_creation_order(self): + # A stock reco sharing the exact posting timestamp of the current entry must only count as the + # "next" reco when it was created after that entry. A reco created before it actually precedes + # the entry and must not bound (truncate) the qty-shift range. + from erpnext.stock.stock_ledger import get_next_stock_reco + + item = make_item().name + warehouse = "_Test Warehouse - _TC" + + reco = create_stock_reconciliation( + item_code=item, + warehouse=warehouse, + qty=10, + rate=100, + posting_date="2021-01-01", + posting_time="02:00:00", + ) + reco_sle = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": reco.name, "is_cancelled": 0}, + ["posting_date", "posting_time", "creation"], + as_dict=True, + ) + + base_kwargs = { + "item_code": item, + "warehouse": warehouse, + "voucher_no": "SOME-OTHER-VOUCHER", + "posting_date": reco_sle.posting_date, + "posting_time": reco_sle.posting_time, + } + + # Current entry created AFTER the reco at the same timestamp -> reco precedes it -> not returned. + after = {**base_kwargs, "creation": add_to_date(reco_sle.creation, seconds=5)} + self.assertFalse(get_next_stock_reco(after)) + + # Current entry created BEFORE the reco at the same timestamp -> reco follows it -> returned. + before = {**base_kwargs, "creation": add_to_date(reco_sle.creation, seconds=-5)} + result = get_next_stock_reco(before) + self.assertTrue(result) + self.assertEqual(result[0].voucher_no, reco.name) + @ERPNextTestSuite.change_settings("System Settings", {"float_precision": 3, "currency_precision": 2}) def test_transfer_invariants(self): """Extact stock value should be transferred.""" diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index c5ecdc130fa..65eb1db2c76 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -2126,20 +2126,36 @@ def get_valuation_rate( def update_qty_in_future_sle(args, allow_negative_stock=False): """Recalculate Qty after Transaction in future SLEs based on current SLE.""" - datetime_limit_condition = "" qty_shift = args.actual_qty - args["posting_datetime"] = get_combine_datetime(args["posting_date"], args["posting_time"]) + posting_datetime = get_combine_datetime(args["posting_date"], args["posting_time"]) + args["posting_datetime"] = posting_datetime # find difference/shift in qty caused by stock reconciliation if args.voucher_type == "Stock Reconciliation": qty_shift = get_stock_reco_qty_shift(args) + sle = frappe.qb.DocType("Stock Ledger Entry") + + future_condition = sle.posting_datetime > posting_datetime + if args.get("creation") and not args.get("is_cancelled"): + future_condition = future_condition | ( + (sle.posting_datetime == posting_datetime) & (sle.creation > args.get("creation")) + ) + + query = frappe.qb.update(sle).where( + (sle.item_code == args.get("item_code")) + & (sle.warehouse == args.get("warehouse")) + & (sle.is_cancelled == 0) + & future_condition + ) + # find the next nearest stock reco so that we only recalculate SLEs till that point next_stock_reco_detail = get_next_stock_reco(args) if next_stock_reco_detail: - detail = next_stock_reco_detail[0] - datetime_limit_condition = get_datetime_limit_condition(detail) + query = query.where(get_datetime_limit_condition(sle, next_stock_reco_detail[0])) + + new_qty = sle.qty_after_transaction + qty_shift if get_valuation_method(args.get("item_code"), args.get("company")) == "Standard Cost": # Standard Cost inventory is always carried at the standard rate, so a backdated entry only @@ -2153,38 +2169,13 @@ def update_qty_in_future_sle(args, allow_negative_stock=False): get_item_standard_rate(args.get("item_code"), args.get("company"), args.get("posting_date")) ) - frappe.db.sql( # nosemgrep - f""" - update `tabStock Ledger Entry` - set stock_value = (qty_after_transaction + {qty_shift}) * {standard_rate}, - qty_after_transaction = qty_after_transaction + {qty_shift} - where - item_code = %(item_code)s - and warehouse = %(warehouse)s - and is_cancelled = 0 - and ( - posting_datetime > %(posting_datetime)s - ) - {datetime_limit_condition} - """, - args, - ) - else: - frappe.db.sql( # nosemgrep - f""" - update `tabStock Ledger Entry` - set qty_after_transaction = qty_after_transaction + {qty_shift} - where - item_code = %(item_code)s - and warehouse = %(warehouse)s - and is_cancelled = 0 - and ( - posting_datetime > %(posting_datetime)s - ) - {datetime_limit_condition} - """, - args, - ) + # Set stock_value before qty_after_transaction: MariaDB evaluates SET left-to-right with the + # already-updated values, so stock_value must be computed while qty still holds its pre-shift + # value. (Postgres uses pre-update values throughout, so the result is the same either way.) + query = query.set(sle.stock_value, new_qty * standard_rate) + + query = query.set(sle.qty_after_transaction, new_qty) + query.run() validate_negative_qty_in_future_sle(args, allow_negative_stock) @@ -2219,6 +2210,17 @@ def get_stock_reco_qty_shift(args): return stock_reco_qty_shift +def get_next_reco_datetime_condition(sle, kwargs): + current_datetime = get_combine_datetime(kwargs.get("posting_date"), kwargs.get("posting_time")) + + if kwargs.get("is_cancelled"): + return sle.posting_datetime >= current_datetime + + return (sle.posting_datetime > current_datetime) | ( + (sle.posting_datetime == current_datetime) & (sle.creation > kwargs.get("creation")) + ) + + def get_next_stock_reco(kwargs): """Returns next nearest stock reconciliaton's details.""" @@ -2244,10 +2246,7 @@ def get_next_stock_reco(kwargs): & (sle.voucher_type == "Stock Reconciliation") & (sle.voucher_no != kwargs.get("voucher_no")) & (sle.is_cancelled == 0) - & ( - sle.posting_datetime - >= get_combine_datetime(kwargs.get("posting_date"), kwargs.get("posting_time")) - ) + & get_next_reco_datetime_condition(sle, kwargs) ) .orderby(sle.posting_datetime) .orderby(sle.creation) @@ -2260,17 +2259,12 @@ def get_next_stock_reco(kwargs): return query.run(as_dict=True) -def get_datetime_limit_condition(detail): +def get_datetime_limit_condition(sle, detail): posting_datetime = get_combine_datetime(detail.posting_date, detail.posting_time) - return f""" - and - (posting_datetime < '{posting_datetime}' - or ( - posting_datetime = '{posting_datetime}' - and creation < '{detail.creation}' - ) - )""" + return (sle.posting_datetime < posting_datetime) | ( + (sle.posting_datetime == posting_datetime) & (sle.creation < detail.creation) + ) def validate_negative_qty_in_future_sle(args, allow_negative_stock=False):