feat: weekly auto-repost of incorrect stock valuation entries (backport #56637) (#56700)

* feat: weekly auto-repost of incorrect stock valuation entries (#56637)

(cherry picked from commit adae0bd732)

# Conflicts:
#	erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py

* chore: fix conflicts

Removed merge conflict markers and cleaned up code.

---------

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
This commit is contained in:
mergify[bot]
2026-07-13 10:11:12 +00:00
committed by GitHub
parent 6bd903e45a
commit bf1b7f2bea
5 changed files with 380 additions and 18 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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(
"<li>{} {} &rarr; {}: {}</li>".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)
)
+ "<br><br>"
+ _(
"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"<ul>{items}</ul>"
)
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,
},
)
)

View File

@@ -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"

View File

@@ -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")