diff --git a/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py b/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py index 8afe1d72e27..0e23561fca1 100644 --- a/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py +++ b/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py @@ -174,14 +174,20 @@ def get_columns(filters): @frappe.whitelist() -def create_reposting_entries(rows, company): +def create_reposting_entries(rows: str | list, company: str): if isinstance(rows, str): rows = parse_json(rows) entries = [] item_wh = frappe._dict() - vouchers = [row.get("voucher_no") for row in rows] + vouchers = [ + row.get("voucher_no") + for row in rows + if row.get("voucher_type") not in ["Purchase Receipt", "Purchase Invoice"] + ] + repost_based_on_transaction(rows, company, entries) + sles = get_stock_ledgers(vouchers) for sle in sles: key = (sle.item_code, sle.warehouse) @@ -214,3 +220,39 @@ def create_reposting_entries(rows, company): if entries: entries = ", ".join(entries) frappe.msgprint(_("Reposting entries created: {0}").format(entries)) + + +def repost_based_on_transaction(rows, company=None, entries=None): + if entries is None: + entries = [] + + duplicate_vouchers = set() + for row in rows: + if ( + row.get("voucher_type") == "Purchase Invoice" + and frappe.get_cached_value("Purchase Invoice", row.get("voucher_no"), "update_stock") == 0 + ): + continue + + if row.get("voucher_type") in ["Purchase Receipt", "Purchase Invoice"]: + voucher_key = (row.get("voucher_type"), row.get("voucher_no")) + if voucher_key in duplicate_vouchers: + continue + + duplicate_vouchers.add(voucher_key) + doc = frappe.get_doc( + { + "doctype": "Repost Item Valuation", + "based_on": "Transaction", + "status": "Queued", + "voucher_type": row.get("voucher_type"), + "voucher_no": row.get("voucher_no"), + "posting_date": row.get("posting_date"), + "posting_time": row.get("posting_time"), + "company": company, + "allow_nagative_stock": 1, + "recalculate_valuation_rate": 1, + } + ).submit() + + entries.append(get_link_to_form("Repost Item Valuation", doc.name)) diff --git a/erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py b/erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py new file mode 100644 index 00000000000..0795bc6ad79 --- /dev/null +++ b/erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py @@ -0,0 +1,57 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe.utils import today + +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt +from erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison import ( + create_reposting_entries, + execute, +) +from erpnext.tests.utils import ERPNextTestSuite + +PI_COMPANY = "_Test Company with perpetual inventory" +PI_STORES = "Stores - TCP1" + + +class TestStockAndAccountValueComparison(ERPNextTestSuite): + def test_purchase_voucher_reposted_transaction_based(self): + # A Purchase Receipt whose GL entries are missing must surface in the report and, when reposted + # from it, be reposted Transaction-based (so its own GL is regenerated) rather than the slower + # Item-and-Warehouse based reposting. + item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name + + pr = make_purchase_receipt(item_code=item, company=PI_COMPANY, warehouse=PI_STORES, qty=5, rate=100) + + # Simulate the out-of-sync state: stock ledger exists but the accounting ledger does not. + frappe.db.delete("GL Entry", {"voucher_type": "Purchase Receipt", "voucher_no": pr.name}) + + # The receipt now shows up in the comparison report (stock value 500 vs account value 0). + filters = frappe._dict(company=PI_COMPANY, as_on_date=today()) + _columns, data = execute(filters) + + row = next((d for d in data if d.get("voucher_no") == pr.name), None) + self.assertIsNotNone(row, "Out-of-sync Purchase Receipt should appear in the report") + self.assertEqual(row.get("voucher_type"), "Purchase Receipt") + + # Repost from the report. + create_reposting_entries([row], PI_COMPANY) + + # A Transaction-based Repost Item Valuation must have been created for this voucher... + transaction_rivs = frappe.get_all( + "Repost Item Valuation", + filters={"voucher_no": pr.name, "voucher_type": "Purchase Receipt"}, + fields=["name", "based_on"], + ) + + self.assertTrue(transaction_rivs, "Expected a Repost Item Valuation for the Purchase Receipt") + self.assertTrue(all(riv.based_on == "Transaction" for riv in transaction_rivs)) + + # ...and no Item-and-Warehouse based reposting should have been created for this item. + item_wh_rivs = frappe.get_all( + "Repost Item Valuation", + filters={"based_on": "Item and Warehouse", "item_code": item}, + ) + self.assertFalse(item_wh_rivs, "Purchase vouchers must not be reposted Item-and-Warehouse based")