From 20450bd4ec74761c0be7136521558e4f862dbcb5 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Wed, 5 Aug 2026 14:11:44 +0530 Subject: [PATCH] feat: validate stock value and stock closing entry before period closing --- .../period_closing_voucher.py | 101 +++++++++++++++++- .../test_period_closing_voucher.py | 58 ++++++++++ 2 files changed, 158 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py index c4d366c4857..c3965676eb1 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py +++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py @@ -6,8 +6,10 @@ import copy import frappe from frappe import _ -from frappe.utils import add_days, flt, formatdate, getdate +from frappe.query_builder.functions import Sum +from frappe.utils import add_days, flt, fmt_money, formatdate, getdate +from erpnext import is_perpetual_inventory_enabled from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import ( make_closing_entries, ) @@ -17,6 +19,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( from erpnext.accounts.general_ledger import check_freezing_date, is_immutable_ledger_enabled from erpnext.accounts.utils import get_account_currency, get_fiscal_year from erpnext.controllers.accounts_controller import AccountsController +from erpnext.stock.utils import get_stock_value_on class PeriodClosingVoucher(AccountsController): @@ -141,6 +144,102 @@ class PeriodClosingVoucher(AccountsController): if account_currency != company_currency: frappe.throw(_("Currency of the Closing Account must be {0}").format(company_currency)) + def before_submit(self): + if not self.has_stock_transactions(): + return + + self.validate_stock_accounts_balance() + self.validate_stock_closing_entry() + + def has_stock_transactions(self): + if not is_perpetual_inventory_enabled(self.company): + return False + + return bool( + frappe.db.exists( + "Stock Ledger Entry", + { + "company": self.company, + "is_cancelled": 0, + "posting_date": ("<=", self.period_end_date), + }, + ) + ) + + def validate_stock_accounts_balance(self): + precision = frappe.get_precision("GL Entry", "debit") + account_balance = flt(self.get_stock_accounts_balance(), precision) + stock_value = flt( + get_stock_value_on(posting_date=self.period_end_date, company=self.company), precision + ) + + if account_balance == stock_value: + return + + currency = frappe.get_cached_value("Company", self.company, "default_currency") + frappe.throw( + _( + "The closing balance {0} of the Stock Asset accounts does not match the closing value {1} of the Stock Balance report as on {2}. Resolve the difference using the Stock Ledger Variance report before closing the period." + ).format( + frappe.bold(fmt_money(account_balance, currency=currency)), + frappe.bold(fmt_money(stock_value, currency=currency)), + frappe.bold(formatdate(self.period_end_date)), + ), + title=_("Stock Value Mismatch"), + ) + + def get_stock_accounts_balance(self): + gle = frappe.qb.DocType("GL Entry") + account = frappe.qb.DocType("Account") + + stock_accounts = ( + frappe.qb.from_(account) + .select(account.name) + .where( + (account.account_type == "Stock") + & (account.company == self.company) + & (account.is_group == 0) + ) + ) + + balance = ( + frappe.qb.from_(gle) + .select(Sum(gle.debit - gle.credit)) + .where( + (gle.company == self.company) + & (gle.is_cancelled == 0) + & (gle.posting_date <= self.period_end_date) + & gle.account.isin(stock_accounts) + ) + ).run() + + return flt(balance[0][0]) if balance else 0.0 + + def validate_stock_closing_entry(self): + status = frappe.db.get_value( + "Stock Closing Entry", + {"company": self.company, "to_date": self.period_end_date, "docstatus": 1}, + "status", + ) + + if status == "Completed": + return + + if status: + frappe.throw( + _( + "The Stock Closing Entry for {0} is not completed yet. Wait for it to complete before submitting the Period Closing Voucher." + ).format(frappe.bold(formatdate(self.period_end_date))), + title=_("Stock Closing Entry In Progress"), + ) + + frappe.throw( + _( + "Create a Stock Closing Entry with To Date as {0} before submitting the Period Closing Voucher." + ).format(frappe.bold(formatdate(self.period_end_date))), + title=_("Stock Closing Entry Required"), + ) + def on_submit(self): self.db_set("gle_processing_status", "In Progress") if frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"): diff --git a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py index 6abc5a7a8f1..1ab598e2a1e 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py +++ b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py @@ -386,6 +386,64 @@ class TestPeriodClosingVoucher(ERPNextTestSuite): self.assertEqual(acb_figures["Cash"][key_for(cc1)], 400) self.assertEqual(acb_figures["Cash"][key_for(cc2)], 200) + def test_stock_validations_before_period_closing(self): + from unittest.mock import patch + + from erpnext.stock.doctype.item.test_item import make_item + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + + item = make_item("Test PCV Stock Item", {"is_stock_item": 1}) + se = make_stock_entry( + item_code=item.name, + qty=10, + rate=100, + to_warehouse="Stores - TPC", + company="Test PCV Company", + posting_date="2021-03-15", + ) + + pcv = self.make_period_closing_voucher(posting_date="2021-03-31", submit=False) + self.assertRaisesRegex(frappe.ValidationError, "Create a Stock Closing Entry", pcv.submit) + + sce = frappe.get_doc( + { + "doctype": "Stock Closing Entry", + "company": "Test PCV Company", + "from_date": pcv.period_start_date, + "to_date": pcv.period_end_date, + } + ).insert() + + with patch("erpnext.stock.doctype.stock_closing_entry.stock_closing_entry.enqueue"): + sce.submit() + + pcv.reload() + self.assertRaisesRegex(frappe.ValidationError, "is not completed yet", pcv.submit) + + sce.create_stock_closing_balance_entries() + sce.db_set("status", "Completed") + + sle = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": se.name}, + ["name", "stock_value_difference"], + as_dict=1, + ) + frappe.db.set_value( + "Stock Ledger Entry", sle.name, "stock_value_difference", sle.stock_value_difference + 100 + ) + + pcv.reload() + self.assertRaisesRegex(frappe.ValidationError, "does not match", pcv.submit) + + frappe.db.set_value( + "Stock Ledger Entry", sle.name, "stock_value_difference", sle.stock_value_difference + ) + + pcv.reload() + pcv.submit() + self.assertEqual(pcv.docstatus, 1) + def make_period_closing_voucher(self, posting_date, submit=True): surplus_account = create_account() cost_center = create_cost_center("Test Cost Center 1")