mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-20 11:22:28 +00:00
* 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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>{} {} → {}: {}</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,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user