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( + "