Compare commits

...

1 Commits

Author SHA1 Message Date
rohitwaghchaure
6341bb6d0f feat(stock): GL-only reposting from Stock and Account Value Comparison report (#59127)
* feat(stock): repost only GL entries from the value comparison report

Adds a "Create GL Reposting Entries" button to the Stock and Account Value
Comparison report, next to the existing "Create Reposting Entries" which is
unchanged. It queues Repost Item Valuation entries with
`repost_only_accounting_ledgers` set, so the General and Payment Ledger are
rebuilt for the selected vouchers while the stock ledgers and valuation rates
are left untouched. This is for the case where stock valuation is already
correct and only the accounting ledger has drifted, which avoids paying for a
full revaluation.

Rows of ledger type "GL Entry" are rejected: they have accounting entries but
no stock ledger entries to rebuild them from, so a GL-only repost would just
wipe their GL. The same restriction is enforced on Repost Item Valuation for
callers outside the report.

Repeated selections are deduplicated, and vouchers that already have a queued
or in-progress GL-only repost are skipped.

* feat(stock): bound GL reposting by a From Date, skip non-stock rows

* fix: batch the pending GL repost lookup and index it on existing sites

(cherry picked from commit c055faabc1)

# Conflicts:
#	erpnext/patches.txt
#	erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py
2026-09-18 11:32:30 +00:00
6 changed files with 347 additions and 8 deletions

View File

@@ -523,3 +523,8 @@ erpnext.patches.v16_0.set_supplier_quotation_order_status
erpnext.patches.v16_0.recalculate_holiday_list_totals
erpnext.patches.v16_0.recalculate_returned_delivery_note_billing_status
erpnext.patches.v16_0.rename_component_cost_valuation_type
<<<<<<< HEAD
=======
erpnext.patches.v16_0.enable_serial_no_wise_valuation
erpnext.patches.v16_0.add_voucher_index_to_repost_item_valuation
>>>>>>> c055faa (feat(stock): GL-only reposting from Stock and Account Value Comparison report (#59127))

View File

@@ -0,0 +1,6 @@
import frappe
def execute():
# on_doctype_update only runs when the DocType itself is re-synced, so existing sites need this.
frappe.db.add_index("Repost Item Valuation", ["voucher_no", "voucher_type", "status"], "voucher_status")

View File

@@ -92,6 +92,7 @@ class RepostItemValuation(Document):
def validate(self):
self.set_default_posting_time()
self.reset_repost_only_accounting_ledgers()
self.validate_repost_only_accounting_ledgers()
self.set_company()
self.validate_update_stock()
self.validate_period_closing_voucher()
@@ -112,6 +113,19 @@ class RepostItemValuation(Document):
if self.repost_only_accounting_ledgers and self.based_on != "Transaction":
self.repost_only_accounting_ledgers = 0
def validate_repost_only_accounting_ledgers(self):
if not self.repost_only_accounting_ledgers:
return
# A GL Entry is not a stock transaction, so there are no stock ledger entries to rebuild its
# accounting ledgers from; reposting it would only delete the entries it already has.
if self.voucher_type == "GL Entry":
frappe.throw(
_("GL reposting is not allowed against the voucher type {0}.").format(
frappe.bold(_("GL Entry"))
)
)
def validate_update_stock(self):
if (
self.voucher_type in ["Sales Invoice", "Purchase Invoice"]
@@ -534,6 +548,7 @@ def mark_covered_transaction_reposts(source, coverage, affected):
def on_doctype_update():
frappe.db.add_index("Repost Item Valuation", ["warehouse", "item_code"], "item_warehouse")
frappe.db.add_index("Repost Item Valuation", ["voucher_no", "voucher_type", "status"], "voucher_status")
def repost(doc):

View File

@@ -51,12 +51,7 @@ frappe.query_reports["Stock and Account Value Comparison"] = {
<p>${__("Are you sure you want to create Reposting Entries?")}</p>
</div>
`;
let indexes = frappe.query_report.datatable.rowmanager.getCheckedRows();
let selected_rows = indexes.map((i) => frappe.query_report.data[i]);
if (!selected_rows.length) {
frappe.throw(__("Please select rows to create Reposting Entries"));
}
let selected_rows = get_selected_rows(__("Reposting Entries"));
frappe.confirm(message, () => {
frappe.call({
@@ -68,5 +63,52 @@ frappe.query_reports["Stock and Account Value Comparison"] = {
});
});
});
report.page.add_inner_button(__("Create GL Reposting Entries"), function () {
let selected_rows = get_selected_rows(__("GL Reposting Entries"));
frappe.prompt(
[
{
label: __("From Date"),
fieldname: "from_date",
fieldtype: "Date",
reqd: 1,
},
{
fieldname: "note",
fieldtype: "HTML",
options: `<p class="text-muted small">
${__(
"Only the accounting ledgers (General Ledger and Payment Ledger) will be reposted, and only for the selected rows posted on or after the From Date. Selected rows posted before it are ignored. Stock Ledger Entries and item valuation rates are left untouched."
)}
</p>`,
},
],
(values) => {
frappe.call({
method: "erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison.create_gl_reposting_entries",
args: {
rows: selected_rows,
company: frappe.query_report.get_filter_values().company,
from_date: values.from_date,
},
});
},
__("Create GL Reposting Entries"),
__("Create")
);
});
},
};
function get_selected_rows(label) {
let indexes = frappe.query_report.datatable.rowmanager.getCheckedRows();
let selected_rows = indexes.map((i) => frappe.query_report.data[i]);
if (!selected_rows.length) {
frappe.throw(__("Please select rows to create {0}", [label]));
}
return selected_rows;
}

View File

@@ -2,9 +2,11 @@
# For license information, please see license.txt
from datetime import date
import frappe
from frappe import _
from frappe.utils import get_datetime, get_link_to_form, parse_json
from frappe.utils import create_batch, get_datetime, get_link_to_form, getdate, parse_json
import erpnext
from erpnext.accounts.utils import get_currency_precision, get_stock_accounts
@@ -273,3 +275,108 @@ def repost_based_on_transaction(rows, company=None, entries=None):
entries.append(get_link_to_form("Repost Item Valuation", doc.name))
except frappe.DuplicateEntryError:
frappe.db.rollback(save_point="repost_based_on_transaction")
@frappe.whitelist()
def create_gl_reposting_entries(rows: str | list, company: str, from_date: str | date | None = None):
"""Repost only the accounting ledgers for the selected vouchers posted on or after `from_date`.
Unlike `create_reposting_entries`, the stock ledgers and the valuation rates are left untouched.
This is meant for the case where the stock valuation itself is correct but the General Ledger has
drifted away from it, so there is no need to pay for a full (and much slower) revaluation.
`from_date` bounds how far back the accounting ledgers are rewritten: selected rows posted before
it are ignored, so a stale selection cannot reach into an already reconciled period.
"""
frappe.has_permission("Repost Item Valuation", "create", throw=True)
if isinstance(rows, str):
rows = parse_json(rows)
if not rows:
frappe.throw(_("Please select rows to create GL Reposting Entries"))
if not from_date:
frappe.throw(_("Please select the date to repost the accounting ledgers from"))
from_date = getdate(from_date)
entries = []
processed_vouchers = set()
# One batched lookup for the whole selection. Checking each row on its own meant a query per
# row, which does not hold up when the report is used on the large selections it is meant for.
pending_vouchers = get_pending_gl_reposting_vouchers(
[(row.get("voucher_type"), row.get("voucher_no")) for row in rows]
)
for row in rows:
# Rows posted before the From Date are skipped, so a stale selection cannot rewrite the
# accounting ledgers of an already reconciled period.
if getdate(row.get("posting_date")) < from_date:
continue
voucher_type, voucher_no = row.get("voucher_type"), row.get("voucher_no")
# journal entry has not stock stock value, so no need to create a reposting entry for it
if voucher_type == "Journal Entry":
continue
# Skip duplicate vouchers in the selection: a single reposting entry is enough to rewrite the accounting ledgers for a given voucher.
if (voucher_type, voucher_no) in processed_vouchers:
continue
processed_vouchers.add((voucher_type, voucher_no))
# A repost queued by an earlier run still has to rewrite this voucher, so queuing another one
# now would just rebuild the same ledgers twice.
if (voucher_type, voucher_no) in pending_vouchers:
continue
doc = frappe.get_doc(
{
"doctype": "Repost Item Valuation",
"based_on": "Transaction",
"status": "Queued",
"voucher_type": voucher_type,
"voucher_no": voucher_no,
"posting_date": row.get("posting_date"),
"posting_time": row.get("posting_time"),
"company": company,
"repost_only_accounting_ledgers": 1,
}
).submit()
entries.append(get_link_to_form("Repost Item Valuation", doc.name))
if entries:
if len(entries) > 20:
entries = entries[:20] + ["..."]
frappe.msgprint(_("GL reposting entries created: {0}").format(", ".join(entries)))
else:
frappe.msgprint(_("No new GL reposting entries were created for the selected rows."))
def get_pending_gl_reposting_vouchers(transactions) -> set[tuple[str, str]]:
"""Vouchers that already have a GL-only repost queued or running."""
pending_vouchers = set()
for chunk in create_batch(transactions, 1000):
entries = frappe.get_all(
"Repost Item Valuation",
filters={
"based_on": "Transaction",
"repost_only_accounting_ledgers": 1,
"docstatus": 1,
"status": ("in", ["Queued", "In Progress"]),
"voucher_no": ("in", [voucher_no for _, voucher_no in chunk]),
},
fields=["voucher_type", "voucher_no"],
)
pending_vouchers.update((d.voucher_type, d.voucher_no) for d in entries)
return pending_vouchers

View File

@@ -2,7 +2,7 @@
# For license information, please see license.txt
import frappe
from frappe.utils import today
from frappe.utils import add_days, today
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
@@ -10,6 +10,7 @@ from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.stock.doctype.warehouse.warehouse import get_warehouses_based_on_account
from erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison import (
create_gl_reposting_entries,
create_reposting_entries,
execute,
)
@@ -131,7 +132,170 @@ class TestStockAndAccountValueComparison(ERPNextTestSuite):
self.assertIn(inheriting, warehouses)
self.assertNotIn(overriding, warehouses)
<<<<<<< HEAD
def run_report(self, **extra):
filters = {"company": COMPANY, "as_on_date": "2026-12-31"}
filters.update(extra)
return execute(frappe._dict(filters))[1]
=======
def test_gl_reposting_only_repost_accounting_ledgers(self):
# When the stock ledger is correct but the accounting ledger has drifted, the report can queue a
# repost that touches only the accounting ledgers, leaving stock valuation alone.
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)
frappe.db.delete("GL Entry", {"voucher_type": "Purchase Receipt", "voucher_no": pr.name})
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")
create_gl_reposting_entries([row], PI_COMPANY, from_date=pr.posting_date)
rivs = frappe.get_all(
"Repost Item Valuation",
filters={"voucher_no": pr.name, "voucher_type": "Purchase Receipt"},
fields=["name", "based_on", "repost_only_accounting_ledgers"],
)
self.assertEqual(len(rivs), 1)
self.assertEqual(rivs[0].based_on, "Transaction")
self.assertTrue(rivs[0].repost_only_accounting_ledgers)
# Reposts run inline during tests, so the missing accounting entries must be back.
self.assertTrue(
frappe.db.exists("GL Entry", {"voucher_type": "Purchase Receipt", "voucher_no": pr.name})
)
def test_gl_reposting_skips_already_queued_voucher(self):
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)
row = {
"ledger_type": "Stock Ledger Entry",
"voucher_type": "Purchase Receipt",
"voucher_no": pr.name,
"posting_date": pr.posting_date,
"posting_time": pr.posting_time,
}
# The same voucher selected twice, and then selected again on a second run, must not pile up
# duplicate reposting entries.
frappe.flags.dont_execute_stock_reposts = True
try:
create_gl_reposting_entries([row, dict(row)], PI_COMPANY, from_date=pr.posting_date)
create_gl_reposting_entries([row], PI_COMPANY, from_date=pr.posting_date)
finally:
frappe.flags.dont_execute_stock_reposts = False
rivs = frappe.get_all(
"Repost Item Valuation",
filters={
"voucher_no": pr.name,
"voucher_type": "Purchase Receipt",
"repost_only_accounting_ledgers": 1,
},
)
self.assertEqual(len(rivs), 1)
def test_gl_reposting_skips_journal_entry_rows(self):
# A Journal Entry posted straight to a stock account shows up in the report but has no stock
# ledger entries, so it is skipped rather than blocking the whole selection.
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)
frappe.db.delete("GL Entry", {"voucher_type": "Purchase Receipt", "voucher_no": pr.name})
filters = frappe._dict(company=PI_COMPANY, as_on_date=today())
_columns, data = execute(filters)
pr_row = next((d for d in data if d.get("voucher_no") == pr.name), None)
journal_row = {
"ledger_type": "GL Entry",
"voucher_type": "Journal Entry",
"voucher_no": "_Test JE for GL Reposting",
"posting_date": today(),
}
create_gl_reposting_entries([journal_row, pr_row], PI_COMPANY, pr.posting_date)
self.assertFalse(
frappe.db.exists("Repost Item Valuation", {"voucher_type": "Journal Entry"}),
"Journal Entry rows must be skipped",
)
self.assertTrue(frappe.db.exists("Repost Item Valuation", {"voucher_no": pr.name}))
def test_gl_reposting_ignores_rows_posted_before_from_date(self):
# Rows posted before the From Date must be dropped, so a stale selection cannot rewrite the
# accounting ledgers of an already reconciled period.
item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name
old_pr = make_purchase_receipt(
item_code=item,
company=PI_COMPANY,
warehouse=PI_STORES,
qty=5,
rate=100,
posting_date=add_days(today(), -10),
)
new_pr = make_purchase_receipt(
item_code=item, company=PI_COMPANY, warehouse=PI_STORES, qty=5, rate=100
)
rows = [
{
"ledger_type": "Stock Ledger Entry",
"voucher_type": "Purchase Receipt",
"voucher_no": pr.name,
"posting_date": pr.posting_date,
"posting_time": pr.posting_time,
}
for pr in (old_pr, new_pr)
]
frappe.flags.dont_execute_stock_reposts = True
try:
create_gl_reposting_entries(rows, PI_COMPANY, from_date=add_days(today(), -1))
finally:
frappe.flags.dont_execute_stock_reposts = False
self.assertFalse(
frappe.db.exists("Repost Item Valuation", {"voucher_no": old_pr.name}),
"Row posted before the From Date must be ignored",
)
self.assertTrue(frappe.db.exists("Repost Item Valuation", {"voucher_no": new_pr.name}))
def test_gl_reposting_requires_from_date(self):
row = {
"ledger_type": "Stock Ledger Entry",
"voucher_type": "Purchase Receipt",
"voucher_no": "some-receipt",
"posting_date": today(),
}
self.assertRaises(frappe.ValidationError, create_gl_reposting_entries, [row], PI_COMPANY, None)
def test_gl_reposting_not_allowed_against_gl_entry_voucher_type(self):
# Guard on the Repost Item Valuation itself, for anything creating one outside the report.
riv = frappe.new_doc("Repost Item Valuation")
riv.update(
{
"based_on": "Transaction",
"voucher_type": "GL Entry",
"voucher_no": "some-gl-entry",
"posting_date": today(),
"company": PI_COMPANY,
"repost_only_accounting_ledgers": 1,
}
)
self.assertRaises(frappe.ValidationError, riv.validate_repost_only_accounting_ledgers)
riv.repost_only_accounting_ledgers = 0
riv.validate_repost_only_accounting_ledgers()
>>>>>>> c055faa (feat(stock): GL-only reposting from Stock and Account Value Comparison report (#59127))