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 8671213c3cc..ac63caf2d2d 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py +++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py @@ -6,9 +6,10 @@ import copy import frappe from frappe import _ -from frappe.query_builder.functions import Sum -from frappe.utils import add_days, flt, formatdate, getdate +from frappe.query_builder.functions import Max, Sum +from frappe.utils import add_days, flt, fmt_money, formatdate, get_link_to_form, getdate +from erpnext import is_perpetual_inventory_enabled from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import ( make_closing_entries, ) @@ -18,6 +19,8 @@ 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.doctype.stock_closing_entry.stock_closing_entry import apply_unscoped_filters +from erpnext.stock.utils import get_stock_value_on class PeriodClosingVoucher(AccountsController): @@ -139,6 +142,121 @@ 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): + closing_entry = frappe.db.get_value( + "Stock Closing Entry", + apply_unscoped_filters( + {"company": self.company, "to_date": self.period_end_date, "docstatus": 1} + ), + ["name", "status", "modified"], + as_dict=True, + ) + + if not closing_entry: + frappe.throw( + _( + "Create a Stock Closing Entry for the entire company with To Date as {0} before submitting the Period Closing Voucher." + ).format(frappe.bold(formatdate(self.period_end_date))), + title=_("Stock Closing Entry Required"), + ) + + if closing_entry.status != "Completed": + 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"), + ) + + self.validate_stock_closing_entry_is_fresh(closing_entry) + + def validate_stock_closing_entry_is_fresh(self, closing_entry): + sle = frappe.qb.DocType("Stock Ledger Entry") + last_change = ( + frappe.qb.from_(sle) + .select(Max(sle.modified)) + .where((sle.company == self.company) & (sle.posting_date <= self.period_end_date)) + ).run() + + if last_change and last_change[0][0] and last_change[0][0] > closing_entry.modified: + frappe.throw( + _( + "Stock transactions were created or modified after the Stock Closing Entry {0} was generated. Regenerate it before submitting the Period Closing Voucher." + ).format(get_link_to_form("Stock Closing Entry", closing_entry.name)), + title=_("Stock Closing Entry Outdated"), + ) + 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 8ab96447544..83aaacbe046 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 @@ -3,7 +3,7 @@ import unittest import frappe -from frappe.utils import today +from frappe.utils import flt, today from erpnext.accounts.doctype.finance_book.test_finance_book import create_finance_book from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry @@ -307,6 +307,218 @@ class TestPeriodClosingVoucher(ERPNextTestSuite): repost_doc.posting_date = today() repost_doc.save() + def test_stock_validations_before_period_closing(self): + from unittest.mock import patch + + from frappe.custom.doctype.custom_field.custom_field import create_custom_fields + + from erpnext.stock.doctype.item.test_item import make_item + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + + create_custom_fields( + { + "Stock Closing Entry": [ + { + "fieldname": "warehouse", + "label": "Warehouse", + "fieldtype": "Link", + "options": "Warehouse", + } + ] + } + ) + + 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, + "warehouse": "Stores - TPC", + } + ).insert() + + with patch("erpnext.stock.doctype.stock_closing_entry.stock_closing_entry.enqueue"): + sce.submit() + + sce.db_set("status", "Completed") + + pcv.reload() + self.assertRaisesRegex(frappe.ValidationError, "Create a Stock Closing Entry", pcv.submit) + + frappe.db.set_value("Stock Closing Entry", sce.name, {"warehouse": None, "status": "In Progress"}) + + 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() + self.assertRaisesRegex(frappe.ValidationError, "Regenerate", pcv.submit) + + self.rebuild_stock_closing_balance(sce) + pcv.reload() + pcv.submit() + self.assertEqual(pcv.docstatus, 1) + + def test_batch_valuation_seeded_from_stock_closing_after_period_closing(self): + from erpnext.stock.doctype.item.test_item import make_item + from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( + get_batch_from_bundle, + ) + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + + item = make_item( + "Test PCV Batch Item", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TPCVB.####", + }, + ) + se1 = make_stock_entry( + item_code=item.name, + qty=10, + rate=100, + to_warehouse="Stores - TPC", + company="Test PCV Company", + posting_date="2021-03-15", + ) + batch_no = get_batch_from_bundle(se1.items[0].serial_and_batch_bundle) + make_stock_entry( + item_code=item.name, + qty=10, + rate=200, + to_warehouse="Stores - TPC", + company="Test PCV Company", + posting_date="2021-06-15", + batch_no=batch_no, + ) + + pcv = self.make_period_closing_voucher(posting_date="2021-03-31", submit=False) + sce = self.make_completed_stock_closing_entry(pcv.period_start_date, pcv.period_end_date) + pcv.reload() + pcv.submit() + + outward = make_stock_entry( + item_code=item.name, + qty=5, + from_warehouse="Stores - TPC", + company="Test PCV Company", + posting_date="2022-04-01", + batch_no=batch_no, + ) + stock_value_difference = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": outward.name, "is_cancelled": 0}, + "stock_value_difference", + ) + self.assertEqual(flt(stock_value_difference, 2), -750.0) + + self.assertRaisesRegex( + frappe.ValidationError, + "frozen", + make_stock_entry, + item_code=item.name, + qty=1, + rate=100, + to_warehouse="Stores - TPC", + company="Test PCV Company", + posting_date="2021-05-01", + ) + self.assertRaisesRegex(frappe.ValidationError, "frozen", se1.cancel) + self.assertRaisesRegex(frappe.ValidationError, "closed accounting period", sce.cancel) + + def test_period_closing_blocks_stale_stock_closing_entry(self): + 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}) + 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) + sce = self.make_completed_stock_closing_entry(pcv.period_start_date, pcv.period_end_date) + + make_stock_entry( + item_code=item.name, + qty=5, + rate=100, + to_warehouse="Stores - TPC", + company="Test PCV Company", + posting_date="2021-05-01", + ) + + pcv.reload() + self.assertRaisesRegex(frappe.ValidationError, "Regenerate", pcv.submit) + + self.rebuild_stock_closing_balance(sce) + pcv.reload() + pcv.submit() + self.assertEqual(pcv.docstatus, 1) + + def make_completed_stock_closing_entry(self, from_date, to_date): + from unittest.mock import patch + + sce = frappe.get_doc( + { + "doctype": "Stock Closing Entry", + "company": "Test PCV Company", + "from_date": from_date, + "to_date": to_date, + } + ).insert() + + with patch("erpnext.stock.doctype.stock_closing_entry.stock_closing_entry.enqueue"): + sce.submit() + + sce.create_stock_closing_balance_entries() + sce.db_set("status", "Completed") + return sce + + def rebuild_stock_closing_balance(self, sce): + sce.remove_stock_closing() + sce.create_stock_closing_balance_entries() + sce.db_set("status", "Completed") + def make_period_closing_voucher(self, posting_date, submit=True): surplus_account = create_account() cost_center = create_cost_center("Test Cost Center 1") diff --git a/erpnext/stock/deprecated_serial_batch.py b/erpnext/stock/deprecated_serial_batch.py index 9432e8c59ae..77d691837f1 100644 --- a/erpnext/stock/deprecated_serial_batch.py +++ b/erpnext/stock/deprecated_serial_batch.py @@ -159,6 +159,9 @@ class DeprecatedBatchNoValuation: if self.sle.name: query = query.where(sle.name != self.sle.name) + if getattr(self, "stock_closing_from_datetime", None): + query = query.where(sle.posting_datetime >= self.stock_closing_from_datetime) + return query.run(as_dict=True) @deprecated( diff --git a/erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py b/erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py index 5c523cc560e..30471ad817d 100644 --- a/erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py +++ b/erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py @@ -10,9 +10,51 @@ from frappe.desk.form.load import get_attachments from frappe.model.document import Document from frappe.utils import add_days, get_date_str, get_link_to_form, nowtime, parse_json from frappe.utils.background_jobs import enqueue +from frappe.utils.caching import request_cache from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions +SCOPE_FIELDS = ("warehouse", "item_code", "item_group", "warehouse_type") + + +def apply_unscoped_filters(filters): + meta = frappe.get_meta("Stock Closing Entry") + for fieldname in SCOPE_FIELDS: + if meta.has_field(fieldname): + filters[fieldname] = ("is", "not set") + + return filters + + +def get_closing_entry_for_closed_period(company): + closed_upto = frappe.db.get_value( + "Period Closing Voucher", {"docstatus": 1, "company": company}, [{"MAX": "period_end_date"}] + ) + if not closed_upto: + return None + + return _get_completed_closing_entry(company, str(closed_upto)) + + +@request_cache +def _get_completed_closing_entry(company, closed_upto): + filters = apply_unscoped_filters( + { + "company": company, + "docstatus": 1, + "status": "Completed", + "to_date": ("<=", closed_upto), + } + ) + + return frappe.db.get_value( + "Stock Closing Entry", + filters, + ["name", "to_date"], + order_by="to_date desc", + as_dict=True, + ) + class StockClosingEntry(Document): # begin: auto-generated types @@ -68,7 +110,7 @@ class StockClosingEntry(Document): ) ) - for fieldname in ["warehouse", "item_code", "item_group", "warehouse_type"]: + for fieldname in SCOPE_FIELDS: if self.get(fieldname): query = query.where(table[fieldname] == self.get(fieldname)) @@ -86,14 +128,30 @@ class StockClosingEntry(Document): self.enqueue_job() def on_cancel(self): + self.validate_closed_period_lock() self.set_status(save=True) self.remove_stock_closing() + def validate_closed_period_lock(self): + pcv = frappe.db.get_value( + "Period Closing Voucher", + {"company": self.company, "docstatus": 1, "period_end_date": (">=", self.to_date)}, + "name", + ) + + if pcv: + frappe.throw( + _( + "Stock Closing Entry {0} belongs to a closed accounting period. Cancel the Period Closing Voucher {1} first." + ).format(self.name, get_link_to_form("Period Closing Voucher", pcv)), + title=_("Closed Period"), + ) + def remove_stock_closing(self): table = frappe.qb.DocType("Stock Closing Balance") frappe.qb.from_(table).delete().where(table.stock_closing_entry == self.name).run() - @frappe.whitelist() + @frappe.whitelist(methods=["POST"]) def enqueue_job(self): self.db_set("status", "In Progress") enqueue(prepare_closing_stock_balance, name=self.name, queue="long", timeout=1500) @@ -103,8 +161,9 @@ class StockClosingEntry(Document): ).format(self.name) ) - @frappe.whitelist() + @frappe.whitelist(methods=["POST"]) def regenerate_closing_balance(self): + self.validate_closed_period_lock() self.remove_stock_closing() self.enqueue_job() diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index e7ccac40115..491a9667d2a 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -820,13 +820,14 @@ class BatchNoValuation(DeprecatedBatchNoValuation): "Serial and Batch Bundle", self.sle.serial_and_batch_bundle, "total_amount" ) else: - entries = self.get_batch_stock_before_date() self.stock_value_change = 0.0 self.batch_avg_rate = defaultdict(float) self.available_qty = defaultdict(float) self.stock_value_differece = defaultdict(float) - for ledger in entries: + self.seed_from_stock_closing_balance() + + for ledger in self.get_batch_stock_before_date(): self.stock_value_differece[ledger.batch_no] += flt(ledger.incoming_rate) self.available_qty[ledger.batch_no] += flt(ledger.qty) @@ -834,6 +835,52 @@ class BatchNoValuation(DeprecatedBatchNoValuation): self.calculate_avg_rate_for_non_batchwise_valuation() self.set_stock_value_difference() + def seed_from_stock_closing_balance(self): + self.stock_closing_from_datetime = None + closing_entry = self.get_closing_entry_for_seeding() + if not closing_entry: + return + + from erpnext.stock.utils import get_combine_datetime + + self.stock_closing_from_datetime = get_combine_datetime( + add_days(closing_entry.to_date, 1), "00:00:00" + ) + + for row in self.get_stock_closing_balance_entries(closing_entry.name): + self.stock_value_differece[row.batch_no] += flt(row.stock_value_difference) + self.available_qty[row.batch_no] += flt(row.actual_qty) + + def get_closing_entry_for_seeding(self): + from erpnext.stock.doctype.stock_closing_entry.stock_closing_entry import ( + get_closing_entry_for_closed_period, + ) + + if not self.batchwise_valuation_batches or not self.sle.posting_date: + return None + + company = self.sle.company or frappe.get_cached_value("Warehouse", self.sle.warehouse, "company") + closing_entry = get_closing_entry_for_closed_period(company) + if not closing_entry or getdate(self.sle.posting_date) <= getdate(closing_entry.to_date): + return None + + return closing_entry + + def get_stock_closing_balance_entries(self, closing_entry): + table = frappe.qb.DocType("Stock Closing Balance") + + return ( + frappe.qb.from_(table) + .select(table.batch_no, table.actual_qty, table.stock_value_difference) + .where( + (table.stock_closing_entry == closing_entry) + & (table.item_code == self.sle.item_code) + & (table.warehouse == self.sle.warehouse) + & table.batch_no.isin(self.batchwise_valuation_batches) + & (table.inventory_dimension_key.isnull() | (table.inventory_dimension_key == "")) + ) + ).run(as_dict=True) + def get_batch_stock_before_date(self) -> list[dict]: # Get batch wise stock value difference from Serial and Batch Bundle considering time condition if not self.batchwise_valuation_batches: @@ -909,6 +956,9 @@ class BatchNoValuation(DeprecatedBatchNoValuation): if timestamp_condition: query = query.where(timestamp_condition) + if self.stock_closing_from_datetime: + query = query.where(child.posting_datetime >= self.stock_closing_from_datetime) + return query.run(as_dict=True) def prepare_batches(self): diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 23e99899068..60fcec3f7cb 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -56,6 +56,32 @@ class SerialNoExistsInFutureTransaction(frappe.ValidationError): pass +def validate_stock_frozen_by_closing_entry(sl_entries): + from erpnext.stock.doctype.stock_closing_entry.stock_closing_entry import ( + get_closing_entry_for_closed_period, + ) + + company = sl_entries[0].get("company") + if not company: + company = frappe.get_cached_value("Warehouse", sl_entries[0].get("warehouse"), "company") + + closing_entry = get_closing_entry_for_closed_period(company) + if not closing_entry: + return + + for sle in sl_entries: + if sle.get("posting_date") and getdate(sle.get("posting_date")) <= getdate(closing_entry.to_date): + frappe.throw( + _( + "Stock transactions dated on or before {0} are frozen because the period is closed and the Stock Closing Entry {1} has been generated. To make changes, cancel the Period Closing Voucher first." + ).format( + frappe.bold(format_date(closing_entry.to_date)), + get_link_to_form("Stock Closing Entry", closing_entry.name), + ), + title=_("Stock Frozen"), + ) + + def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False): """Create SL entries from SL entry dicts @@ -70,6 +96,8 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc from erpnext.controllers.stock_controller import future_sle_exists if sl_entries: + validate_stock_frozen_by_closing_entry(sl_entries) + cancelled = sl_entries[0].get("is_cancelled") if cancelled: validate_cancellation(sl_entries)