feat: validate stock value and stock closing entry before period closing (#57811)

* feat: validate stock value and stock closing entry before period closing

* fix: do not accept scoped stock closing entries as period closing prerequisite

* feat: seed batch valuation from stock closing balance and freeze closed-period stock

(cherry picked from commit d71fc3b774)

# Conflicts:
#	erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py
#	erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py
#	erpnext/stock/deprecated_serial_batch.py
#	erpnext/stock/serial_batch_bundle.py
#	erpnext/stock/stock_ledger.py
This commit is contained in:
rohitwaghchaure
2026-08-05 15:45:16 +05:30
committed by Mergify
parent af3184c8b4
commit 36dbf94243
6 changed files with 664 additions and 5 deletions

View File

@@ -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"):

View File

@@ -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")

View File

@@ -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(

View File

@@ -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()

View File

@@ -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(

View File

@@ -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),
)
+ "<br><br>"
+ _("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)