fix: sync Stock Reconciliation difference amount with GL after reposting (backport #56574) (#56585)

* fix: sync Stock Reconciliation difference amount with GL after reposting (#56574)

* fix: sync Stock Reconciliation difference amount with GL after reposting

* fix: placement of recalculate differece amount function

(cherry picked from commit c7ef42ef98)

# Conflicts:
#	erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
#	erpnext/stock/stock_ledger.py

* chore: fix conflicts

Removed unused import and fixed import order.

* chore: fix conflicts

Refactor update_rate_on_stock_reconciliation to use recalculation method for difference amount.

---------

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
This commit is contained in:
mergify[bot]
2026-06-27 17:08:54 +05:30
committed by GitHub
parent 655d6dac87
commit e834098c28
3 changed files with 291 additions and 33 deletions

View File

@@ -1008,6 +1008,102 @@ class StockReconciliation(StockController):
d.quantity_difference = flt(d.qty) - flt(d.current_qty)
d.amount_difference = flt(d.amount) - flt(d.current_amount)
def recalculate_difference_amount_from_ledger(self):
"""Sync the displayed current qty/rate and difference amount with the (reposted) ledger.
Submitted reconciliations freeze ``difference_amount`` and the per-row current values at
submit time, but reposting/backdated transactions recompute the reconciliation's Stock Ledger
Entries and rebuild the GL from them. Without this sync the document keeps showing stale figures
that no longer match the GL entries. Anchoring ``amount_difference`` to the row's summed
``stock_value_difference`` keeps the document and the GL consistent by construction.
"""
difference_amount = 0.0
for row in self.items:
stock_value_difference = flt(get_row_stock_value_difference(self.doctype, self.name, row.name))
amount = flt(flt(row.qty) * flt(row.valuation_rate), row.precision("amount"))
amount_difference = flt(stock_value_difference, row.precision("amount_difference"))
current_amount = flt(amount - amount_difference, row.precision("current_amount"))
current_qty = self.get_current_qty_from_ledger(row)
current_valuation_rate = (
flt(current_amount / current_qty, row.precision("current_valuation_rate"))
if current_qty
else 0.0
)
row.db_set(
{
"amount": amount,
"current_qty": current_qty,
"current_valuation_rate": current_valuation_rate,
"current_amount": current_amount,
"quantity_difference": flt(row.qty) - current_qty,
"amount_difference": amount_difference,
},
update_modified=False,
)
difference_amount += amount_difference
self.db_set(
"difference_amount",
flt(difference_amount, self.precision("difference_amount")),
update_modified=False,
)
def get_current_qty_from_ledger(self, row: StockReconciliationItem):
"""Current (pre-reconciliation) qty for a row, recomputed from the ledger after reposting.
Serial/batch rows cannot have backdated qty changes inserted before a future reconciliation
(blocked by ``check_future_entries_exists``), so their current qty is frozen and read straight
from the current bundle. Non-serial rows can float, so read the ledger balance just before the
reconciliation, excluding the reconciliation's own entries.
"""
if row.current_serial_and_batch_bundle:
total_qty = frappe.db.get_value(
"Serial and Batch Bundle", row.current_serial_and_batch_bundle, "total_qty"
)
return abs(flt(total_qty, row.precision("current_qty")))
reco_sle = frappe.db.get_value(
"Stock Ledger Entry",
{
"voucher_type": self.doctype,
"voucher_no": self.name,
"voucher_detail_no": row.name,
"is_cancelled": 0,
},
["posting_datetime", "creation"],
as_dict=True,
)
if not reco_sle:
return flt(row.current_qty, row.precision("current_qty"))
sle = frappe.qb.DocType("Stock Ledger Entry")
previous_sle = (
frappe.qb.from_(sle)
.select(sle.qty_after_transaction)
.where(
(sle.item_code == row.item_code)
& (sle.warehouse == row.warehouse)
& (sle.is_cancelled == 0)
& (
(sle.posting_datetime < reco_sle.posting_datetime)
| (
(sle.posting_datetime == reco_sle.posting_datetime)
& (sle.creation < reco_sle.creation)
)
)
)
.orderby(sle.posting_datetime, order=frappe.qb.desc)
.orderby(sle.creation, order=frappe.qb.desc)
.limit(1)
).run()
return flt(previous_sle[0][0], row.precision("current_qty")) if previous_sle else 0.0
def submit(self):
if len(self.items) > 100:
msgprint(
@@ -1194,6 +1290,23 @@ def get_itemwise_batch(warehouse, posting_date, company, item_code=None):
return itemwise_batch_data
def get_row_stock_value_difference(voucher_type: str, voucher_no: str, voucher_detail_no: str):
"""Net stock value change posted to the GL by a reconciliation row (sum of its SLEs)."""
sle = frappe.qb.DocType("Stock Ledger Entry")
result = (
frappe.qb.from_(sle)
.select(Sum(sle.stock_value_difference))
.where(
(sle.voucher_type == voucher_type)
& (sle.voucher_no == voucher_no)
& (sle.voucher_detail_no == voucher_detail_no)
& (sle.is_cancelled == 0)
)
).run()
return flt(result[0][0]) if result and result[0][0] else 0.0
@frappe.whitelist()
def get_stock_balance_for(
item_code: str,

View File

@@ -787,6 +787,172 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
sr1.load_from_db()
self.assertEqual(sr1.difference_amount, 10000)
def assert_reco_difference_matches_gl(self, reco_name):
"""The displayed Difference Amount (doc and per-row) must equal the reposted GL impact,
i.e. the sum of the reconciliation's Stock Ledger Entry ``stock_value_difference``."""
from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
get_row_stock_value_difference,
)
reco = frappe.get_doc("Stock Reconciliation", reco_name)
total_difference = 0.0
for row in reco.items:
row_difference = flt(
get_row_stock_value_difference("Stock Reconciliation", reco_name, row.name),
row.precision("amount_difference"),
)
self.assertEqual(flt(row.amount_difference), row_difference)
total_difference += row_difference
self.assertEqual(
flt(reco.difference_amount, reco.precision("difference_amount")),
flt(total_difference, reco.precision("difference_amount")),
)
def test_difference_amount_synced_with_gl_after_repost_non_serialized(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
item_code = self.make_item().name
warehouse = "_Test Warehouse - _TC"
# Opening stock => 100 * 100 = 10000
make_stock_entry(
item_code=item_code,
target=warehouse,
qty=100,
basic_rate=100,
posting_date=add_days(nowdate(), -5),
posting_time="10:00:00",
)
# Reconcile to 100 @ 200 => difference 20000 - 10000 = 10000
reco = create_stock_reconciliation(
item_code=item_code,
warehouse=warehouse,
qty=100,
rate=200,
posting_date=add_days(nowdate(), -2),
)
self.assertEqual(reco.difference_amount, 10000)
self.assert_reco_difference_matches_gl(reco.name)
# Backdated reconciliation lowers the pre-reco stock value to 50 * 50 = 2500
create_stock_reconciliation(
item_code=item_code,
warehouse=warehouse,
qty=50,
rate=50,
posting_date=add_days(nowdate(), -3),
)
reco.load_from_db()
# Current is now 2500 => difference 20000 - 2500 = 17500
self.assertEqual(reco.difference_amount, 17500)
self.assert_reco_difference_matches_gl(reco.name)
def test_difference_amount_synced_with_gl_after_repost_batched(self):
from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
make_landed_cost_voucher,
)
item_code = self.make_item(
"Test Batch Item Reco Difference Sync",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TEST-BATCH-DIFFSYNC-.###",
},
).name
warehouse = "_Test Warehouse - _TC"
# Receive 10 @ 100 (batch value 1000)
pr = make_purchase_receipt(
item_code=item_code,
warehouse=warehouse,
qty=10,
rate=100,
posting_date=add_days(nowdate(), -5),
)
batch_no = get_batch_from_bundle(pr.items[0].serial_and_batch_bundle)
# Reconcile the batch to 10 @ 500 => difference 5000 - 1000 = 4000
reco = create_stock_reconciliation(
item_code=item_code,
warehouse=warehouse,
qty=10,
rate=500,
batch_no=batch_no,
use_serial_batch_fields=1,
posting_date=add_days(nowdate(), -2),
)
difference_on_submit = reco.difference_amount
self.assert_reco_difference_matches_gl(reco.name)
# Landed cost retroactively raises the receipt (and batch) valuation, reposting the reco
make_landed_cost_voucher(
receipt_document_type="Purchase Receipt",
receipt_document=pr.name,
charges=1000,
company="_Test Company",
)
reco.load_from_db()
self.assertNotEqual(reco.difference_amount, difference_on_submit)
self.assert_reco_difference_matches_gl(reco.name)
def test_difference_amount_synced_with_gl_after_repost_serialized(self):
from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
make_landed_cost_voucher,
)
item_code = self.make_item(
"Test Serial Item Reco Difference Sync",
{
"is_stock_item": 1,
"has_serial_no": 1,
"serial_no_series": "TSIRDS.####",
},
).name
warehouse = "_Test Warehouse - _TC"
# Receive 5 serial nos @ 100 (value 500)
pr = make_purchase_receipt(
item_code=item_code,
warehouse=warehouse,
qty=5,
rate=100,
posting_date=add_days(nowdate(), -5),
)
serial_nos = get_serial_nos_from_bundle(pr.items[0].serial_and_batch_bundle)
# Reconcile the serial nos to 5 @ 500 => difference 2500 - 500 = 2000
reco = create_stock_reconciliation(
item_code=item_code,
warehouse=warehouse,
qty=5,
rate=500,
serial_no="\n".join(serial_nos),
use_serial_batch_fields=1,
posting_date=add_days(nowdate(), -2),
)
difference_on_submit = reco.difference_amount
self.assert_reco_difference_matches_gl(reco.name)
# Landed cost retroactively raises the receipt (and serial) valuation, reposting the reco
make_landed_cost_voucher(
receipt_document_type="Purchase Receipt",
receipt_document=pr.name,
charges=1000,
company="_Test Company",
)
reco.load_from_db()
self.assertNotEqual(reco.difference_amount, difference_on_submit)
self.assert_reco_difference_matches_gl(reco.name)
def test_make_stock_zero_for_serial_batch_item(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry

View File

@@ -1337,6 +1337,11 @@ class update_entries_after:
Update outgoing rate in Stock Entry, Delivery Note, Sales Invoice and Sales Return
In case of Stock Entry, also calculate FG Item rate and total incoming/outgoing amount
"""
if sle.voucher_type == "Stock Reconciliation":
if flt(sle.actual_qty) <= 0 and not self.args.get("sle_id"):
self.update_rate_on_stock_reconciliation(sle)
return
if sle.actual_qty and sle.voucher_detail_no:
outgoing_rate = abs(flt(sle.stock_value_difference)) / abs(sle.actual_qty)
@@ -1348,8 +1353,6 @@ class update_entries_after:
self.update_rate_on_purchase_receipt(sle, outgoing_rate)
elif flt(sle.actual_qty) < 0 and sle.voucher_type == "Subcontracting Receipt":
self.update_rate_on_subcontracting_receipt(sle, outgoing_rate)
elif sle.voucher_type == "Stock Reconciliation":
self.update_rate_on_stock_reconciliation(sle)
def update_rate_on_stock_entry(self, sle, outgoing_rate):
frappe.db.set_value("Stock Entry Detail", sle.voucher_detail_no, "basic_rate", outgoing_rate)
@@ -1443,37 +1446,13 @@ class update_entries_after:
d.db_update()
def update_rate_on_stock_reconciliation(self, sle):
if not sle.serial_no and not sle.batch_no:
sr = frappe.get_lazy_doc("Stock Reconciliation", sle.voucher_no, for_update=True)
for item in sr.items:
# Skip for Serial and Batch Items
if item.name != sle.voucher_detail_no or item.serial_no or item.batch_no:
continue
previous_sle = get_previous_sle(
{
"item_code": item.item_code,
"warehouse": item.warehouse,
"posting_date": sr.posting_date,
"posting_time": sr.posting_time,
"sle": sle.name,
}
)
item.current_qty = previous_sle.get("qty_after_transaction") or 0.0
item.current_valuation_rate = previous_sle.get("valuation_rate") or 0.0
item.current_amount = flt(item.current_qty) * flt(item.current_valuation_rate)
item.amount = flt(item.qty) * flt(item.valuation_rate)
item.quantity_difference = item.qty - item.current_qty
item.amount_difference = item.amount - item.current_amount
else:
sr.difference_amount = sum([item.amount_difference for item in sr.items])
sr.db_update()
for item in sr.items:
item.db_update()
# Refresh the reconciliation's difference amount and per-row current qty/rate from the reposted
# ledger so the document keeps matching the GL entries. Handles serialized, batched and
# non-serialized items uniformly (the document method reads the current bundle for serial/batch
# rows and the pre-reconciliation ledger balance for non-serial rows).
frappe.get_lazy_doc(
"Stock Reconciliation", sle.voucher_no, for_update=True
).recalculate_difference_amount_from_ledger()
def get_incoming_value_for_serial_nos(self, sle, serial_nos):
# get rate from serial nos within same company