diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 65462439c34..d614d8b6356 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -492,6 +492,7 @@ scheduler_events = { ], "weekly": [ "erpnext.accounts.utils.auto_create_exchange_rate_revaluation_weekly", + "erpnext.stock.doctype.stock_reposting_settings.stock_reposting_settings.repost_incorrect_valuation_entries", ], "monthly_long": [ "erpnext.accounts.deferred_revenue.process_deferred_accounting", diff --git a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json index ed48522d770..eaab9db4786 100644 --- a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json +++ b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json @@ -22,7 +22,9 @@ "column_break_itvd", "enable_separate_reposting_for_gl", "errors_notification_section", - "notify_reposting_error_to_role" + "notify_reposting_error_to_role", + "auto_reposting_section", + "repost_incorrect_valuation_entries" ], "fields": [ { @@ -113,12 +115,24 @@ "fieldname": "do_not_fetch_incoming_rate_from_serial_no", "fieldtype": "Check", "label": "Do not fetch incoming rate from Serial No" + }, + { + "fieldname": "auto_reposting_section", + "fieldtype": "Section Break", + "label": "Auto Reposting of Incorrect Valuation" + }, + { + "default": "0", + "description": "If enabled, a weekly scheduler scans the Stock Ledger Variance for item-warehouses with incorrect valuation in the current financial year and auto-creates Item & Warehouse based reposts to fix them.", + "fieldname": "repost_incorrect_valuation_entries", + "fieldtype": "Check", + "label": "Auto Repost Incorrect Valuation Entries (Weekly)" } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-05-15 12:59:34.392491", + "modified": "2026-07-01 14:41:51.499245", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reposting Settings", diff --git a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py index 9164f8498cb..f703a694dad 100644 --- a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py +++ b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py @@ -4,7 +4,16 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import add_to_date, get_datetime, get_time_str, time_diff_in_hours +from frappe.utils import ( + add_to_date, + get_datetime, + get_link_to_form, + get_time_str, + getdate, + time_diff_in_hours, + today, +) +from frappe.utils.user import get_users_with_role class StockRepostingSettings(Document): @@ -27,6 +36,7 @@ class StockRepostingSettings(Document): ] no_of_parallel_reposting: DF.Int notify_reposting_error_to_role: DF.Link | None + repost_incorrect_valuation_entries: DF.Check start_time: DF.Time | None # end: auto-generated types @@ -117,3 +127,205 @@ def create_repost_item_valuation(item_code, warehouse, posting_date): "status": "Queued", } ).submit() + + +def repost_incorrect_valuation_entries(): + """Weekly scheduler entry point. + + When `repost_incorrect_valuation_entries` is enabled in Stock Reposting Settings, scan each + company's Stock Ledger Variance and Stock and Account Value Comparison reports for incorrect stock + valuation in the current financial year and auto-create reposts to correct them. Journal Entries are + never reposted, and warehouses pointing at a non-'Stock' account are reported to System Managers + instead. Disabled by default; does nothing unless explicitly turned on.""" + if not frappe.db.get_single_value("Stock Reposting Settings", "repost_incorrect_valuation_entries"): + return + + for company in frappe.get_all("Company", pluck="name"): + # The Stock Ledger Variance scan runs the invariant check for every item-warehouse, so process + # each company as its own long-running background job rather than blocking the weekly scheduler. + frappe.enqueue( + repost_incorrect_valuation_entries_for_company, + queue="long", + job_id=f"repost_incorrect_valuation::{company}", + deduplicate=True, + company=company, + ) + + +def repost_incorrect_valuation_entries_for_company(company): + """Detect and repost incorrect stock valuation for a single company, limited to the current + financial year, using two reports: + + 1. Stock Ledger Variance - item-warehouses whose ledger valuation is internally inconsistent + (typically a wrong previous-SLE pick). Fixed with an Item & Warehouse repost. + 2. Stock and Account Value Comparison - vouchers whose stock value does not match the accounting + ledger. Reposted via the report's own logic (Journal Entries are excluded - ERPNext does not + repost them). If a voucher's warehouse points at an account that is not of type 'Stock', + reposting can never clear the difference, so System Managers are notified instead.""" + from erpnext.accounts.utils import get_fiscal_year + + fy_start_date = get_fiscal_year(today(), company=company)[1] + + _repost_stock_ledger_variance(company, fy_start_date) + _repost_stock_account_value_comparison(company, fy_start_date) + + +def _repost_stock_ledger_variance(company, fy_start_date): + from erpnext.stock.report.stock_ledger_variance.stock_ledger_variance import ( + get_data as get_stock_ledger_variance, + ) + + created = [] + for row in get_stock_ledger_variance({"company": company}) or []: + row = frappe._dict(row) + + # Only correct issues that originate in the current financial year. + if not row.posting_date or getdate(row.posting_date) < getdate(fy_start_date): + continue + + # Avoid piling up duplicate reposts week over week for the same item-warehouse. + if has_pending_valuation_repost(company, row.item_code, row.warehouse): + continue + + create_repost_item_valuation(row.item_code, row.warehouse, row.posting_date) + created.append(row) + + if created: + frappe.logger("stock_reposting").info( + f"Auto-reposted {len(created)} incorrect-valuation item-warehouse(s) for {company}: " + + ", ".join(f"{d.item_code} @ {d.warehouse} from {d.posting_date}" for d in created) + ) + + return created + + +def _repost_stock_account_value_comparison(company, fy_start_date): + import erpnext + + # Stock vs accounting values only exist under perpetual inventory. + if not erpnext.is_perpetual_inventory_enabled(company): + return + + from erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison import ( + create_reposting_entries, + ) + from erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison import ( + get_data as get_value_comparison, + ) + + to_repost = [] + misconfigured = [] # (voucher_type, voucher_no, warehouse, account) + + # Scope the report's DB scan to the current financial year (see get_data) instead of loading every + # voucher ever posted and filtering in Python afterwards. + comparison_filters = frappe._dict(company=company, from_date=fy_start_date, as_on_date=today()) + for row in get_value_comparison(comparison_filters) or []: + row = frappe._dict(row) + + # ERPNext does not repost Journal Entries (GL-only postings have no stock ledger to repost). + if row.voucher_type == "Journal Entry": + continue + + # Only correct issues that originate in the current financial year. + if not row.posting_date or getdate(row.posting_date) < getdate(fy_start_date): + continue + + # If a warehouse on this voucher is mapped to an account that is not of type 'Stock', reposting + # can never reconcile stock vs accounting value - flag it for a human instead of reposting. + # Only flag accounts with a concrete, non-'Stock' type. An unset/blank account_type is treated as + # "unknown" - reposting may well reconcile it - so we don't skip the voucher or email a false alarm. + wrong_accounts = [ + (warehouse, account) + for warehouse, account, account_type in get_voucher_warehouse_accounts(row.voucher_no, company) + if account_type and account_type != "Stock" + ] + if wrong_accounts: + misconfigured.extend( + (row.voucher_type, row.voucher_no, warehouse, account) + for warehouse, account in wrong_accounts + ) + continue + + to_repost.append(row) + + if to_repost: + # create_reposting_entries reposts Purchase Receipt/Invoice transaction-wise and everything else + # item-warehouse-wise, and de-duplicates against existing reposts. + create_reposting_entries(to_repost, company) + frappe.logger("stock_reposting").info( + f"Auto-reposted {len(to_repost)} stock/account value mismatch voucher(s) for {company}." + ) + + if misconfigured: + notify_incorrect_stock_account(company, misconfigured) + + +def get_voucher_warehouse_accounts(voucher_no, company): + """Return (warehouse, account, account_type) for each distinct warehouse the voucher posted stock + into, so the caller can verify the account is a 'Stock' asset account.""" + warehouses = frappe.get_all( + "Stock Ledger Entry", + filters={"voucher_no": voucher_no, "is_cancelled": 0}, + pluck="warehouse", + distinct=True, + ) + + rows = [] + for warehouse in {w for w in warehouses if w}: + account = frappe.get_cached_value("Warehouse", warehouse, "account") or frappe.get_cached_value( + "Company", company, "default_inventory_account" + ) + account_type = frappe.get_cached_value("Account", account, "account_type") if account else None + rows.append((warehouse, account, account_type)) + + return rows + + +def notify_incorrect_stock_account(company, misconfigured): + """Email System Managers about warehouse accounts that are not of type 'Stock', which keep stock + and accounting values from reconciling even after reposting.""" + recipients = get_users_with_role("System Manager") + if not recipients: + return + + items = "".join( + "
  • {} {} → {}: {}
  • ".format( + voucher_type, + get_link_to_form(voucher_type, voucher_no), + warehouse, + account or _("No account set"), + ) + for voucher_type, voucher_no, warehouse, account in misconfigured + ) + + subject = _("Incorrect Stock Asset Account in {0}").format(company) + message = ( + _("Stock and accounting values could not be reconciled by reposting for {0}.").format( + frappe.bold(company) + ) + + "

    " + + _( + "The warehouse account(s) below are not of type 'Stock'. Please set a correct Stock asset account on the warehouse (Account Type must be 'Stock'):" + ) + + f"" + ) + + frappe.sendmail(recipients=recipients, subject=subject, message=message) + + +def has_pending_valuation_repost(company, item_code, warehouse): + """True if an Item & Warehouse repost for this item-warehouse is already queued or running, so the + weekly job does not stack duplicate reposts.""" + return bool( + frappe.db.exists( + "Repost Item Valuation", + { + "company": company, + "item_code": item_code, + "warehouse": warehouse, + "based_on": "Item and Warehouse", + "status": ("in", ["Queued", "In Progress"]), + "docstatus": 1, + }, + ) + ) diff --git a/erpnext/stock/doctype/stock_reposting_settings/test_stock_reposting_settings.py b/erpnext/stock/doctype/stock_reposting_settings/test_stock_reposting_settings.py index ff89ad34ab6..00f0b67984e 100644 --- a/erpnext/stock/doctype/stock_reposting_settings/test_stock_reposting_settings.py +++ b/erpnext/stock/doctype/stock_reposting_settings/test_stock_reposting_settings.py @@ -1,14 +1,137 @@ # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt import unittest +from unittest.mock import patch import frappe +from frappe.utils import add_days, getdate, today +from erpnext.accounts.utils import get_fiscal_year +from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.repost_item_valuation.repost_item_valuation import get_recipients +from erpnext.stock.doctype.stock_reposting_settings import stock_reposting_settings as srs from erpnext.tests.utils import ERPNextTestSuite +TEST_COMPANY = "_Test Company" +TEST_WAREHOUSE = "_Test Warehouse - _TC" + class TestStockRepostingSettings(ERPNextTestSuite): + def tearDown(self): + frappe.db.set_single_value("Stock Reposting Settings", "repost_incorrect_valuation_entries", 0) + super().tearDown() + + def test_auto_repost_disabled_does_nothing(self): + frappe.db.set_single_value("Stock Reposting Settings", "repost_incorrect_valuation_entries", 0) + with patch("frappe.enqueue") as enqueue: + srs.repost_incorrect_valuation_entries() + enqueue.assert_not_called() + + def test_auto_repost_enabled_enqueues_per_company(self): + frappe.db.set_single_value("Stock Reposting Settings", "repost_incorrect_valuation_entries", 1) + with patch("frappe.enqueue") as enqueue: + srs.repost_incorrect_valuation_entries() + self.assertTrue(enqueue.called) + # one job per company + self.assertEqual(enqueue.call_count, frappe.db.count("Company")) + + def test_reposts_only_current_financial_year_entries(self): + item = make_item().name + fy_start_date = get_fiscal_year(today(), company=TEST_COMPANY)[1] + + current_fy_row = {"item_code": item, "warehouse": TEST_WAREHOUSE, "posting_date": today()} + prior_fy_row = { + "item_code": item, + "warehouse": TEST_WAREHOUSE, + "posting_date": add_days(fy_start_date, -1), + } + + calls = [] + variance_path = "erpnext.stock.report.stock_ledger_variance.stock_ledger_variance.get_data" + with ( + patch.object( + srs, "create_repost_item_valuation", side_effect=lambda i, w, d: calls.append((i, w, str(d))) + ), + patch(variance_path, return_value=[current_fy_row, prior_fy_row]), + ): + srs.repost_incorrect_valuation_entries_for_company(TEST_COMPANY) + + # Only the current-FY entry is reposted; the prior-FY one is ignored. + self.assertEqual(calls, [(item, TEST_WAREHOUSE, str(today()))]) + + def test_skips_when_repost_already_pending(self): + item = make_item().name + current_fy_row = {"item_code": item, "warehouse": TEST_WAREHOUSE, "posting_date": today()} + + calls = [] + variance_path = "erpnext.stock.report.stock_ledger_variance.stock_ledger_variance.get_data" + with ( + patch.object(srs, "has_pending_valuation_repost", return_value=True), + patch.object( + srs, "create_repost_item_valuation", side_effect=lambda i, w, d: calls.append((i, w, str(d))) + ), + patch(variance_path, return_value=[current_fy_row]), + ): + srs.repost_incorrect_valuation_entries_for_company(TEST_COMPANY) + + self.assertEqual(calls, []) + + def test_value_comparison_excludes_je_and_flags_wrong_account(self): + fy_start = getdate(today()) + + rows = [ + # correct stock account, current FY -> reposted + {"voucher_type": "Purchase Receipt", "voucher_no": "PR-OK", "posting_date": today()}, + # Journal Entry -> never reposted + {"voucher_type": "Journal Entry", "voucher_no": "JE-1", "posting_date": today()}, + # warehouse account not of type "Stock" -> notify, not reposted + {"voucher_type": "Purchase Receipt", "voucher_no": "PR-BADACC", "posting_date": today()}, + # warehouse account with an unset account_type -> unknown, repost (not a false alarm) + {"voucher_type": "Purchase Receipt", "voucher_no": "PR-NOTYPE", "posting_date": today()}, + # prior financial year -> ignored + { + "voucher_type": "Purchase Receipt", + "voucher_no": "PR-OLD", + "posting_date": add_days(fy_start, -1), + }, + ] + accounts = { + "PR-OK": [("WH-A", "Stock A - _TC", "Stock")], + "PR-BADACC": [("WH-B", "Debtors - _TC", "Receivable")], + "PR-NOTYPE": [("WH-C", "Unclassified - _TC", None)], + "PR-OLD": [("WH-A", "Stock A - _TC", "Stock")], + } + + reposted = {} + sent = [] + comparison = ( + "erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison" + ) + with ( + patch("erpnext.is_perpetual_inventory_enabled", return_value=True), + patch(f"{comparison}.get_data", return_value=rows), + patch( + f"{comparison}.create_reposting_entries", + side_effect=lambda r, c: reposted.update(rows=r, company=c), + ), + patch.object( + srs, "get_voucher_warehouse_accounts", side_effect=lambda vno, c: accounts.get(vno, []) + ), + patch.object(srs, "get_users_with_role", return_value=["sysmgr@test.com"]), + patch("frappe.sendmail", side_effect=lambda **kw: sent.append(kw)), + ): + srs._repost_stock_account_value_comparison(TEST_COMPANY, fy_start) + + # Current-FY, non-Journal-Entry vouchers are reposted: the correct-account one and the one whose + # account_type is unset (unknown is treated as "proceed", not "wrong account"). + self.assertEqual([r["voucher_no"] for r in reposted["rows"]], ["PR-OK", "PR-NOTYPE"]) + # Only the concrete wrong-account voucher triggers a System Manager notification. + self.assertEqual(len(sent), 1) + self.assertIn("PR-BADACC", sent[0]["message"]) + self.assertNotIn("PR-NOTYPE", sent[0]["message"]) + + +class TestStockRepostingSettingsNotification(ERPNextTestSuite): def test_notify_reposting_error_to_role(self): role = "Notify Reposting Role" 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 0e23561fca1..011d117e2b2 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 @@ -33,6 +33,11 @@ def get_data(report_filters): "posting_date": ("<=", report_filters.as_on_date), } + # Optional lower bound: lets callers (e.g. the weekly auto-repost job) scope the scan to the current + # fiscal year in the query itself instead of loading every voucher ever posted and filtering later. + if report_filters.get("from_date"): + filters["posting_date"] = ("between", [report_filters.from_date, report_filters.as_on_date]) + get_currency_precision() or 2 stock_ledger_entries = get_stock_ledger_data(report_filters, filters) voucher_wise_gl_data = get_gl_data(report_filters, filters) @@ -240,19 +245,26 @@ def repost_based_on_transaction(rows, company=None, entries=None): 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() + # Isolate each submit in a savepoint: an already-queued repost raises DuplicateEntryError, and on + # PostgreSQL a failed insert aborts the whole transaction, killing the rest of the loop (and the + # silent weekly job). Rolling back to the savepoint keeps prior/next reposts intact. + frappe.db.savepoint("repost_based_on_transaction") + try: + 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)) + entries.append(get_link_to_form("Repost Item Valuation", doc.name)) + except frappe.DuplicateEntryError: + frappe.db.rollback(save_point="repost_based_on_transaction")