mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-02 13:16:55 +00:00
* 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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user