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)