refactor: frappe.db.sql to frappe.qb for update_qty_in_future_sle (#56609)

This commit is contained in:
rohitwaghchaure
2026-06-30 15:40:10 +05:30
committed by GitHub
parent 8447f551e7
commit b8be1c8efd
2 changed files with 144 additions and 49 deletions

View File

@@ -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."""

View File

@@ -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):