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..4c135403767 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,15 @@ import copy import frappe from frappe import _ +<<<<<<< HEAD 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 +>>>>>>> d71fc3b774 (feat: validate stock value and stock closing entry before period closing (#57811)) +from erpnext import is_perpetual_inventory_enabled from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import ( make_closing_entries, ) @@ -18,6 +24,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 +147,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..d4e3ae16d5a 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,292 @@ class TestPeriodClosingVoucher(ERPNextTestSuite): repost_doc.posting_date = today() repost_doc.save() +<<<<<<< HEAD +======= + def test_dimension_grouped_opening_balance_matches_gl_scan(self): + """ + A dimension-grouped Balance Sheet must produce identical per-dimension + figures whether opening balances come from + + - Account Closing Balance (the fast path) or + - from a full GL scan (the fallback). + """ + from frappe.utils import add_days, getdate + + from erpnext.accounts.report.balance_sheet.balance_sheet import execute + from erpnext.accounts.report.financial_statements import build_period_list + + company = "Test PCV Company" + cc1 = create_cost_center("Test Cost Center 1") + cc2 = create_cost_center("Test Cost Center 2") + + # Post to two cost centers, then close the year so balances land in Account Closing Balance. + for amount, cost_center in ((400, cc1), (200, cc2)): + jv = make_journal_entry( + posting_date="2021-03-15", + amount=amount, + account1="Cash - TPC", + account2="Sales - TPC", + cost_center=cost_center, + company=company, + save=False, + ) + jv.company = company + jv.save() + jv.submit() + + pcv = self.make_period_closing_voucher(posting_date="2021-03-31") + report_date = add_days(getdate(pcv.period_end_date), 1) + + report_filters = frappe._dict( + company=company, + period_start_date=report_date, + period_end_date=report_date, + periodicity="Yearly", + filter_based_on="Date Range", + accumulated_values=True, + group_by_dimension="Cost Center", + ) + + period_list = build_period_list(report_filters) + period_keys = [p.key for p in period_list] + + def key_for(cost_center): + return next(p.key for p in period_list if p.dimension_value == cost_center) + + def figures(data): + return { + row["account_name"]: {k: row.get(k) for k in period_keys} + for row in data + if row.get("account_name") + } + + # Fast path: opening balance sourced from Account Closing Balance. + acb_figures = figures(execute(report_filters)[1]) + + # Fallback: force a full GL scan and expect the same numbers. + with self.change_settings("Accounts Settings", {"ignore_account_closing_balance": 1}): + gl_figures = figures(execute(report_filters)[1]) + + self.assertEqual(acb_figures, gl_figures) + + # the fast path must carry per-dimension opening balances, not aggregates or zeros + 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 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") + +>>>>>>> d71fc3b774 (feat: validate stock value and stock closing entry before period closing (#57811)) 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..ee1fe52c975 100644 --- a/erpnext/stock/deprecated_serial_batch.py +++ b/erpnext/stock/deprecated_serial_batch.py @@ -135,6 +135,26 @@ class DeprecatedBatchNoValuation: sle.creation < self.sle.creation ) +<<<<<<< HEAD +======= + conditions = ( + (sle.item_code == self.sle.item_code) + & (sle.warehouse == self.sle.warehouse) + & (sle.batch_no.isin(self.batchwise_valuation_batches)) + & (sle.batch_no.isnotnull()) + & (sle.is_cancelled == 0) + ) + if timestamp_condition: + conditions &= timestamp_condition + if self.sle.name: + conditions &= sle.name != self.sle.name + + if getattr(self, "stock_closing_from_datetime", None): + conditions &= sle.posting_datetime >= self.stock_closing_from_datetime + + # MariaDB carries a row lock on the grouped query below; on postgres the caller + # (calculate_avg_rate) serializes via a txn-scoped advisory lock on (item, warehouse). +>>>>>>> d71fc3b774 (feat: validate stock value and stock closing entry before period closing (#57811)) query = ( frappe.qb.from_(sle) .select( 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..c512f28410b 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -820,13 +820,27 @@ class BatchNoValuation(DeprecatedBatchNoValuation): "Serial and Batch Bundle", self.sle.serial_and_batch_bundle, "total_amount" ) else: +<<<<<<< HEAD entries = self.get_batch_stock_before_date() +======= + # Serialize concurrent valuations of this (item, warehouse) on postgres. MariaDB's + # grouped FOR UPDATE + gap locks do this via the history reads below; postgres has no + # gap locks, and row-locking the whole history writes a lock marker on every tuple -- + # a txn-scoped advisory lock (released at commit/rollback) serializes without either. + if frappe.db.db_type == "postgres": + frappe.db.transaction_advisory_lock( + ("batch-valuation", self.sle.item_code, self.sle.warehouse) + ) + +>>>>>>> d71fc3b774 (feat: validate stock value and stock closing entry before period closing (#57811)) 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 +848,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: @@ -881,6 +941,33 @@ class BatchNoValuation(DeprecatedBatchNoValuation): timestamp_condition |= (child.posting_datetime == self.sle.posting_datetime) & tie_condition +<<<<<<< HEAD +======= + conditions = ( + (child.item_code == self.sle.item_code) + & (child.warehouse == self.sle.warehouse) + & (child.batch_no.isin(self.batchwise_valuation_batches)) + & (child.docstatus == 1) + & (child.type_of_transaction.isin(["Inward", "Outward"])) + ) + + # Important to exclude the current voucher detail no / voucher no to calculate the correct stock value difference + if self.sle.voucher_detail_no: + conditions &= child.voucher_detail_no != self.sle.voucher_detail_no + elif self.sle.voucher_no: + conditions &= child.voucher_no != self.sle.voucher_no + + conditions &= child.voucher_type != "Pick List" + if timestamp_condition: + conditions &= timestamp_condition + + if self.stock_closing_from_datetime: + conditions &= child.posting_datetime >= self.stock_closing_from_datetime + + # MariaDB carries a row lock on the grouped query below; on postgres the caller + # (calculate_avg_rate) serializes via a txn-scoped advisory lock on (item, warehouse) + # instead of row-locking the whole history (FOR UPDATE is invalid with GROUP BY there). +>>>>>>> d71fc3b774 (feat: validate stock value and stock closing entry before period closing (#57811)) query = ( frappe.qb.from_(child) .select( diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 23e99899068..d5e0f3e392e 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -56,6 +56,81 @@ class SerialNoExistsInFutureTransaction(frappe.ValidationError): pass +<<<<<<< HEAD +======= +def validate_standard_cost_posting_date(sl_entries): + """R2: a Standard Cost item's stock transaction cannot be dated before the latest Item + Standard Cost effective date. A backdated entry would slip in behind the standard-rate + revaluation, making its on-hand snapshot stale and forcing a repost — which Standard Cost + deliberately avoids. Enforced here so every stock voucher is covered uniformly.""" + from erpnext.stock.utils import get_valuation_method + + checked = {} + for sle in sl_entries: + item_code = sle.get("item_code") + company = sle.get("company") + posting_date = sle.get("posting_date") + if not item_code or not company or not posting_date: + continue + + key = (item_code, company) + if key not in checked: + latest_isc = None + if get_valuation_method(item_code, company) == "Standard Cost": + latest_isc = frappe.db.get_value( + "Item Standard Cost", + {"item_code": item_code, "company": company, "docstatus": 1}, + ["name", "effective_date"], + order_by="effective_date desc", + as_dict=True, + ) + checked[key] = latest_isc + + latest_isc = checked[key] + if latest_isc and getdate(posting_date) < getdate(latest_isc.effective_date): + effective_date = frappe.bold(frappe.format(latest_isc.effective_date, "Date")) + frappe.throw( + _( + "Cannot post Standard Cost item {0} on {1}: it is before {2}, the effective date of its latest Standard Valuation Rate {3}." + ).format( + get_link_to_form("Item", item_code), + frappe.bold(frappe.format(posting_date, "Date")), + effective_date, + get_link_to_form("Item Standard Cost", latest_isc.name), + ) + + "

" + + _("Post this entry on or after {0}.").format(effective_date), + title=_("Backdated Entry Not Allowed"), + ) + + +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"), + ) + + +>>>>>>> d71fc3b774 (feat: validate stock value and stock closing entry before period closing (#57811)) def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False): """Create SL entries from SL entry dicts @@ -70,6 +145,15 @@ 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: +<<<<<<< HEAD +======= + # Sorted so two vouchers touching the same pairs can't take the gates in opposite order. + for pair in sorted({(d.get("item_code"), d.get("warehouse")) for d in sl_entries}): + sle_processing_gate(*pair) + + validate_stock_frozen_by_closing_entry(sl_entries) + +>>>>>>> d71fc3b774 (feat: validate stock value and stock closing entry before period closing (#57811)) cancelled = sl_entries[0].get("is_cancelled") if cancelled: validate_cancellation(sl_entries)