Compare commits

..

7 Commits

Author SHA1 Message Date
rohitwaghchaure
aa70d9bbc3 fix: purchase return of batchwise valuation batch valued at original receipt rate instead of batch avg rate (version-16-hotfix) (#57836)
* fix: use current batch avg rate for outward returns of batchwise valuation batches

* fix: honor zero batch average and avoid duplicate batch classification query
2026-08-06 15:44:56 +05:30
Mihir Kandoi
626e35135f fix(stock): drop call to confirm_if_drafts_exist missing on v16 (#57833) 2026-08-06 08:08:45 +00:00
Henil Maru
0e26f9b1db fix(sales-invoice): respect Customize Form hidden setting on Update Stock (#57819)
frm.toggle_display("update_stock", ...) unconditionally forced the
field visible based only on has_subcontracted, overwriting whatever
Customize Form had set on every refresh. OR it with the field's
original (property-setter-driven) hidden value instead.

Backport of #57818.
2026-08-05 17:58:58 +05:30
rohitwaghchaure
243266f5ef feat: stock validations in Period Closing Voucher and snapshot-seeded batch valuation (backport #57811) (#57816)
* feat: validate stock value and stock closing entry before period closing

(cherry picked from commit 20450bd4ec)

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

(cherry picked from commit 359a347be2)

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

(cherry picked from commit 49a127d59c)
2026-08-05 17:20:53 +05:30
mergify[bot]
af3184c8b4 fix(stock): handle multi-item opening balance in Stock Ledger report (backport #57591) (#57796)
* fix(stock): handle multi-item opening balance in Stock Ledger report (#57591)

* fix(stock): handle multi-item opening balance in Stock

* test(stock): add unit test for multi-item Stock Ledger report

---------

Co-authored-by: Afsal Syed <afsalsyed12@gmail.com>
(cherry picked from commit 0dbe410414)

# Conflicts:
#	erpnext/stock/report/stock_ledger/test_stock_ledger_report.py

* fix(stock): resolve stock ledger backport conflicts

---------

Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
Co-authored-by: Sudharsanan11 <sudharsananashok1975@gmail.com>
2026-08-05 13:15:40 +05:30
Jatin3128
eeb3cd238e fix(subscription): don't reactivate a cancelled subscription (backport #57774)
* fix(subscription): don't reactivate a cancelled subscription

Backport of #57774 to version-16-hotfix.

set_subscription_status() unconditionally set status to Active once
there was no outstanding invoice, even if the subscription had been
intentionally cancelled. Paying off an invoice issued before
cancellation (directly, or via the Payment Entry -> refresh hook)
flipped a Cancelled subscription back to Active while cancelation_date
stayed set.

process()'s cancel_at_period_end check compared posting_date against
getdate(self.end_date), and getdate(None) returns today, so an empty
end_date was silently treated as "cancel now" on every scheduler run.
Combined with the reactivation bug, this let a cancelled subscription
toggle Cancelled -> Active on each run and generate another invoice at
the next period boundary.

Fixes #57761

* test: fix flaky test_update_bom_cost_in_all_boms via valuation reset

Backport of #56796 to version-16-hotfix.

reset_item_valuation_rate() only reconciled warehouses where the item
currently has positive stock (actual_qty > 0). get_valuation_rate()
averages Sum(stock_value)/Sum(actual_qty) across all of an item's
bins, so a negative balance left over in another warehouse by a prior
test can cancel out the reset qty and collapse the average to 0,
failing the assertion with 0.0 != 10.0.

This branch never got #56796 (it predates the frappe.get_all
refactor of this helper and still uses raw SQL), so applying the same
fix here: reconcile every warehouse with a non-zero balance, not just
positive ones.

* fix(subscription): don't let period rollover defeat cancel_at_period_end

process() can advance current_invoice_end to the next period (via
update_subscription_period(), when generating the current period's
invoice) before the cancel_at_period_end check further down runs. For
a subscription with no end_date, that check now compared posting_date
against the already-rolled-forward current_invoice_end, which is
always in the future, so cancel_at_period_end was silently never
honored.

Snapshot current_invoice_end before any rollover and use that in the
check instead, so it still targets the period that just ended.

Found via review on the version-15-hotfix backport (#57780).

---------

Co-authored-by: test <test@test.com>
2026-08-05 12:23:31 +05:30
mergify[bot]
adfa6768c9 fix: incorrect batch-wise valuation rate for entries with same posting datetime (backport #57794) (#57797)
fix: incorrect batch-wise valuation rate for entries with same posting datetime (#57794)

* fix: incorrect batch-wise valuation rate for entries with same posting datetime

The tie-breaker in get_batch_no_ledgers compared the bundle's creation
against the SLE's creation. These are different timelines - a bundle can
be created (drafted) much before its SLE (created at submission). For
entries sharing a posting datetime (backdated / amended vouchers), this
mis-ordered the entries against the ledger's replay order (SLE creation),
causing double counting or omission of batch qty / value and runaway
outgoing rates that no repost could heal.

Now the tie is broken using the creation of the bundle's own SLE (same
timeline on both sides). When the valuation runs through the bundle
before its SLE exists, the entry is by definition last in its timestamp
group, so all same-timestamp entries already in the ledger precede it.



* test: batch-wise valuation ordering for same posting datetime entries

Covers both tie-breaking branches of get_batch_no_ledgers:
- submission (pre-insertion) branch: same-timestamp inward at a different
  rate plus a multi-row outward voucher (same item and warehouse), at
  submission and after a backdated repost
- existing-SLE branch: a bundle created after its sibling's SLE, the
  ordering must follow the SLE creation and not the bundle creation

Both tests fail with the previous parent.creation < sle.creation
tie-breaker and pass with the fix.



---------

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 11:06:14 +05:30
16 changed files with 1353 additions and 73 deletions

View File

@@ -6,7 +6,7 @@ import frappe
from frappe.model.document import Document
from frappe.utils.user import is_website_user
__version__ = "16.31.1"
__version__ = "16.26.2"
def get_default_company(user=None):

View File

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

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

View File

@@ -1180,7 +1180,16 @@ frappe.ui.form.on("Sales Invoice", {
}
frm.set_df_property("update_stock", "read_only", frm.doc.has_subcontracted);
frm.toggle_display("update_stock", !frm.doc.has_subcontracted);
// frm.set_df_property mutates a per-document copy, not the doctype's shared field
// metadata, so this always reflects the original (Customize Form) hidden value.
const hidden_by_customization = cint(
frappe.meta.get_docfield("Sales Invoice", "update_stock")?.hidden
);
frm.set_df_property(
"update_stock",
"hidden",
cint(frm.doc.has_subcontracted) || hidden_by_customization
);
},
});

View File

@@ -254,6 +254,9 @@ class Subscription(Document):
"""
Sets the status of the `Subscription`
"""
if self.status == "Cancelled":
return
if self.is_trialling():
self.status = "Trialing"
elif (
@@ -605,6 +608,11 @@ class Subscription(Document):
1. `process_for_active`
2. `process_for_past_due`
"""
# Snapshot before update_subscription_period() below can roll this forward,
# so the cancel_at_period_end check further down still targets the period
# that just ended, not the next one.
current_period_end = self.current_invoice_end
if not self.is_current_invoice_generated(
self.current_invoice_start, self.current_invoice_end
) and self.can_generate_new_invoice(posting_date):
@@ -625,8 +633,8 @@ class Subscription(Document):
self.update_subscription_period()
if self.cancel_at_period_end and (
getdate(posting_date) >= getdate(self.current_invoice_end)
or getdate(posting_date) >= getdate(self.end_date)
getdate(posting_date) >= getdate(current_period_end)
or (self.end_date and getdate(posting_date) >= getdate(self.end_date))
):
self.cancel_subscription()

View File

@@ -614,6 +614,32 @@ class TestSubscription(ERPNextTestSuite):
self.assertRaises(frappe.ValidationError, subscription.process, posting_date=add_days(start_date, 7))
def test_subscription_cancels_at_period_end_without_end_date(self):
# https://github.com/frappe/erpnext/issues/57761 -- generate_invoice() rolls
# current_invoice_end forward to the next period before this check runs, so
# with no end_date to fall back on, cancel_at_period_end must compare
# against the period that just ended, not the (already advanced) next one.
create_plan(
plan_name="_Test plan name 11",
cost=80,
currency="INR",
billing_interval="Day",
billing_interval_count=3,
)
subscription = create_subscription(
start_date=nowdate(),
cancel_at_period_end=1,
generate_invoice_at="End of the current subscription period",
plans=[{"plan": "_Test plan name 11", "qty": 1}],
)
self.assertEqual(len(subscription.invoices), 0)
period_end = subscription.current_invoice_end
subscription.process(posting_date=period_end)
self.assertEqual(subscription.status, "Cancelled")
self.assertEqual(len(subscription.invoices), 1)
def test_invoice_generated_when_scheduler_runs_one_day_late(self):
# The trigger date (period end) is long past, yet catch-up still bills the period
# on creation (Bug 1: the check is `>= trigger`, not `== trigger`).
@@ -774,6 +800,38 @@ class TestSubscription(ERPNextTestSuite):
subscription.reload()
self.assertEqual(subscription.status, "Active")
def test_cancelled_subscription_stays_cancelled_after_payment_and_reprocess(self):
# https://github.com/frappe/erpnext/issues/57761
subscription = create_subscription(
start_date=nowdate(),
generate_invoice_at="Beginning of the current subscription period",
submit_invoice=1,
cancel_at_period_end=1,
)
subscription.process(posting_date=nowdate())
invoice = subscription.get_current_invoice()
self.assertGreater(invoice.outstanding_amount, 0)
subscription.cancel_subscription()
self.assertEqual(subscription.status, "Cancelled")
cancelation_date = getdate(subscription.cancelation_date)
self.assertIsNotNone(cancelation_date)
payment_entry = get_payment_entry(invoice.doctype, invoice.name, bank_account="_Test Bank - _TC")
payment_entry.reference_no = "12345"
payment_entry.reference_date = nowdate()
payment_entry.submit()
subscription.reload()
self.assertEqual(subscription.status, "Cancelled")
self.assertEqual(getdate(subscription.cancelation_date), cancelation_date)
invoice_count = len(subscription.invoices)
subscription.process()
subscription.reload()
self.assertEqual(subscription.status, "Cancelled")
self.assertEqual(len(subscription.invoices), invoice_count)
def test_first_invoice_generated_on_create_for_prepaid(self):
subscription = create_subscription(
start_date=nowdate(),

View File

@@ -881,10 +881,15 @@ def reset_item_valuation_rate(item_code, warehouse_list=None, qty=None, rate=Non
warehouse_list = [warehouse_list]
if not warehouse_list:
# Reconcile every warehouse the item has a non-zero balance in -- including
# negative balances left by other tests. get_valuation_rate averages
# Sum(stock_value)/Sum(actual_qty) across all bins, so a leftover negative
# balance in one warehouse can cancel the reset qty elsewhere and make the
# average collapse to 0, which is a source of flaky BOM-cost failures.
warehouse_list = frappe.db.sql_list(
"""
select warehouse from `tabBin`
where item_code=%s and actual_qty > 0
where item_code=%s and actual_qty != 0
""",
item_code,
)

View File

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

View File

@@ -5611,6 +5611,66 @@ class TestPurchaseReceipt(ERPNextTestSuite):
self.assertEqual(frappe.parse_json(stock_queue), [[20, 0.0]])
def test_purchase_return_valuation_for_batchwise_valuation_batch(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
item_code = make_item(
"Test Purchase Return Batchwise Valn Item",
{
"is_stock_item": 1,
"has_batch_no": 1,
"batch_number_series": "BN-TPRBWV-.#####",
},
).name
batch_no = "BN-TPRBWV-00001"
batch = frappe.new_doc("Batch").update({"batch_id": batch_no, "item": item_code}).insert()
self.assertEqual(batch.use_batchwise_valuation, 1)
warehouse = "_Test Warehouse - _TC"
pr = make_purchase_receipt(
item_code=item_code,
qty=100,
rate=1000,
warehouse=warehouse,
batch_no=batch_no,
use_serial_batch_fields=1,
)
make_purchase_receipt(
item_code=item_code,
qty=100,
rate=400,
warehouse=warehouse,
batch_no=batch_no,
use_serial_batch_fields=1,
)
create_delivery_note(
item_code=item_code,
qty=100,
warehouse=warehouse,
batch_no=batch_no,
use_serial_batch_fields=1,
)
return_pr = make_return_doc("Purchase Receipt", pr.name)
return_pr.submit()
sle = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": return_pr.name, "is_cancelled": 0},
["stock_value_difference", "qty_after_transaction", "stock_value", "serial_and_batch_bundle"],
as_dict=True,
)
self.assertEqual(flt(sle.qty_after_transaction), 0.0)
self.assertEqual(flt(sle.stock_value_difference, 2), -70000.0)
self.assertEqual(flt(sle.stock_value, 2), 0.0)
rate = frappe.db.get_value(
"Serial and Batch Entry", {"parent": sle.serial_and_batch_bundle}, "incoming_rate"
)
self.assertEqual(flt(rate, 2), 700.0)
def test_negative_stock_error_for_purchase_return(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry

View File

@@ -414,6 +414,13 @@ class SerialandBatchBundle(Document):
valuation_method = get_valuation_method(self.item_code, self.company)
# An outward return must go out at the batch's current average rate for a
# batchwise valuation batch. The original receipt rate is only correct while
# the batch still holds stock at that rate; once other receipts have changed
# the average, removing at the original rate strands a residue in the batch
# value (negative when returning the costlier receipt).
batchwise_avg_rates = self.get_batchwise_return_avg_rates()
stock_queue = []
non_batchwise_batches = []
if not self.has_serial_no and valuation_method == "FIFO":
@@ -447,6 +454,12 @@ class SerialandBatchBundle(Document):
batches = sorted(list(valuation_details["batches"].keys()))
valuation_rate = valuation_details["batches"].get(batches[cint(row.idx) - 1])
# a batch with an available balance goes out at its current average rate (a
# valid 0.0 included); the original receipt rate applies only when there is
# no balance to average
if not row.serial_no and row.batch_no in batchwise_avg_rates:
valuation_rate = batchwise_avg_rates[row.batch_no]
row.incoming_rate = flt(valuation_rate)
row.stock_value_difference = flt(row.qty) * flt(row.incoming_rate)
@@ -475,6 +488,43 @@ class SerialandBatchBundle(Document):
elif self.type_of_transaction == "Inward":
self.set_incoming_rate_for_inward_transaction(row, save, prev_sle=prev_sle)
def get_batchwise_return_avg_rates(self):
from erpnext.stock.utils import get_valuation_method
if self.type_of_transaction != "Outward" or self.has_serial_no:
return {}
batch_nos = [d.batch_no for d in self.entries if d.batch_no]
if not batch_nos:
return {}
if get_valuation_method(
self.item_code, self.company
) == "Moving Average" and frappe.db.get_single_value(
"Stock Settings", "do_not_use_batchwise_valuation"
):
return {}
batchwise_batches = frappe.get_all(
"Batch",
filters={"name": ("in", batch_nos), "use_batchwise_valuation": 1},
pluck="name",
)
if not batchwise_batches:
return {}
# scoped to batchwise batches only, so BatchNoValuation's non-batchwise
# machinery never runs for them
sle = self.get_sle_for_outward_transaction()
sle.batch_nos = {batch_no: sle.batch_nos[batch_no] for batch_no in batchwise_batches}
sle.batchwise_valuation_batches = batchwise_batches
sn_obj = BatchNoValuation(sle=sle, item_code=self.item_code, warehouse=self.warehouse)
return {
batch_no: abs(flt(sn_obj.batch_avg_rate.get(batch_no)))
for batch_no in batchwise_batches
if flt(sn_obj.available_qty.get(batch_no))
}
def validate_returned_serial_batch_no(self, return_against, row, original_inv_details):
if frappe.flags.through_repost_item_valuation and not frappe.in_test:
return

View File

@@ -4,7 +4,7 @@
import json
import frappe
from frappe.utils import flt, nowtime, today
from frappe.utils import add_days, add_to_date, flt, nowtime, today
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
@@ -1601,3 +1601,190 @@ class TestSerialandBatchBundleLogic(ERPNextTestSuite):
self.assertNotIn(bundles[1], bundle_wise_serial_nos)
self.assertEqual(bundle_wise_serial_nos[bundles[0]], [serial_no])
@ERPNextTestSuite.change_settings(
"Stock Settings", {"auto_create_serial_and_batch_bundle_for_outward": 1}
)
def test_batchwise_valuation_for_same_posting_datetime_entries(self):
# an inward at a different rate and multiple outward rows with the same
# item and warehouse share the same posting datetime, the tie-breaking
# must include the same-timestamp entries which are already part of the
# ledger and must not let the outward rows count each other
item_code = make_item(
"Test Batchwise Same Posting Datetime Item 1",
properties={
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TBSPD-ITEM1-.#####",
"valuation_method": "FIFO",
},
).name
warehouse = "_Test Warehouse - _TC"
receipt = make_stock_entry(
item_code=item_code,
qty=10,
rate=100,
target=warehouse,
posting_date=add_days(today(), -5),
posting_time="12:00:00",
)
batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
self.assertTrue(frappe.db.get_value("Batch", batch_no, "use_batchwise_valuation"))
# same posting datetime as the outward rows below, at a different rate
make_stock_entry(
item_code=item_code,
qty=20,
rate=250,
target=warehouse,
batch_no=batch_no,
use_serial_batch_fields=1,
posting_date=add_days(today(), -3),
posting_time="12:00:00",
)
issue = make_stock_entry(
item_code=item_code,
qty=2,
source=warehouse,
posting_date=add_days(today(), -3),
posting_time="12:00:00",
do_not_save=True,
)
for qty in [3, 4]:
issue.append(
"items",
{
"item_code": item_code,
"s_warehouse": warehouse,
"qty": qty,
"conversion_factor": 1,
},
)
issue.save()
issue.submit()
# (10 * 100 + 20 * 250) / 30 = 200
self.assert_batchwise_outgoing_rate(item_code, outgoing_rate=200.0, balance_value=4200.0)
# backdated receipt reposts the same posting datetime cluster
make_stock_entry(
item_code=item_code,
qty=10,
rate=100,
target=warehouse,
batch_no=batch_no,
use_serial_batch_fields=1,
posting_date=add_days(today(), -4),
posting_time="12:00:00",
)
# (20 * 100 + 20 * 250) / 40 = 175
self.assert_batchwise_outgoing_rate(item_code, outgoing_rate=175.0, balance_value=5425.0)
@ERPNextTestSuite.change_settings(
"Stock Settings", {"auto_create_serial_and_batch_bundle_for_outward": 1}
)
def test_batchwise_valuation_when_bundle_created_before_the_sle(self):
# a bundle can be created (drafted) much before / after its SLE, the
# tie-breaking for the same posting datetime entries must follow the
# SLE creation and not the bundle creation
item_code = make_item(
"Test Batchwise Same Posting Datetime Item 2",
properties={
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TBSPD-ITEM2-.#####",
"valuation_method": "FIFO",
},
).name
warehouse = "_Test Warehouse - _TC"
receipt = make_stock_entry(
item_code=item_code,
qty=10,
rate=100,
target=warehouse,
posting_date=add_days(today(), -5),
posting_time="12:00:00",
)
batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
# inward at a different rate, same posting datetime as the outward below
inward = make_stock_entry(
item_code=item_code,
qty=10,
rate=200,
target=warehouse,
batch_no=batch_no,
use_serial_batch_fields=1,
posting_date=add_days(today(), -3),
posting_time="12:00:00",
)
outward = make_stock_entry(
item_code=item_code,
qty=10,
source=warehouse,
posting_date=add_days(today(), -3),
posting_time="12:00:00",
)
# simulate the inward's bundle drafted after the outward's SLE, the
# bundle creation timeline no longer matches the SLE creation timeline
outward_sle_creation = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": outward.name, "is_cancelled": 0},
"creation",
)
frappe.db.set_value(
"Serial and Batch Bundle",
inward.items[0].serial_and_batch_bundle,
"creation",
add_to_date(outward_sle_creation, minutes=30),
update_modified=False,
)
repost = frappe.get_doc(
{
"doctype": "Repost Item Valuation",
"based_on": "Item and Warehouse",
"item_code": item_code,
"warehouse": warehouse,
"posting_date": add_days(today(), -6),
"posting_time": "00:00:00",
"allow_negative_stock": 1,
}
)
repost.submit()
# (10 * 100 + 10 * 200) / 20 = 150, the inward precedes the outward as
# per the SLE creation even though its bundle was created afterwards
self.assert_batchwise_outgoing_rate(item_code, outgoing_rate=150.0, balance_value=1500.0)
def assert_batchwise_outgoing_rate(self, item_code, outgoing_rate, balance_value):
sl_entries = frappe.get_all(
"Stock Ledger Entry",
filters={"item_code": item_code, "is_cancelled": 0},
fields=["actual_qty", "stock_value_difference", "stock_value"],
order_by="posting_datetime, creation",
)
for sle in sl_entries:
if sle.actual_qty > 0:
continue
self.assertEqual(flt(sle.stock_value_difference, 2), flt(sle.actual_qty * outgoing_rate, 2))
self.assertEqual(flt(sl_entries[-1].stock_value, 2), flt(balance_value, 2))

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

@@ -7,8 +7,10 @@ from collections import defaultdict
import frappe
from frappe import _
from frappe.query_builder.functions import Sum
from frappe.query_builder.functions import IfNull, Sum
from frappe.utils import cint, flt, get_datetime
from pypika import Order
from pypika.analytics import RowNumber
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
@@ -53,14 +55,15 @@ def execute(filters=None):
data = []
conversion_factors = []
if opening_row:
data.append(opening_row)
opening_rows = opening_row if isinstance(opening_row, list) else ([opening_row] if opening_row else [])
for row in opening_rows:
data.append(row)
conversion_factors.append(0)
actual_qty = stock_value = 0
if opening_row:
actual_qty = opening_row.get("qty_after_transaction")
stock_value = opening_row.get("stock_value")
if opening_rows:
actual_qty = opening_rows[0].get("qty_after_transaction", 0)
stock_value = opening_rows[0].get("stock_value", 0)
available_serial_nos = {}
@@ -693,43 +696,120 @@ def get_opening_balance(filters, columns, sl_entries, inv_dimension_wise_value=N
if not (filters.item_code and filters.warehouse and filters.from_date):
return
from erpnext.stock.stock_ledger import get_previous_sle
item_codes = filters.item_code
if isinstance(item_codes, str):
item_codes = [item_codes]
project = None
if filters.get("project") and not frappe.get_all(
"Inventory Dimension", filters={"reference_document": "Project"}
):
project = filters.get("project")
warehouses = get_matching_warehouses(filters.warehouse)
if not warehouses:
return
last_entry = get_previous_sle(
{
"item_code": filters.item_code,
"warehouse_condition": get_warehouse_condition(filters.warehouse),
"posting_date": filters.from_date,
"posting_time": "00:00:00",
"project": project,
},
for_report=True,
sle_doctype = frappe.qb.DocType("Stock Ledger Entry")
sr_doctype = frappe.qb.DocType("Stock Reconciliation")
opening_reco_query = (
frappe.qb.from_(sle_doctype)
.inner_join(sr_doctype)
.on(sle_doctype.voucher_no == sr_doctype.name)
.select(sle_doctype.voucher_no)
.where(sle_doctype.docstatus < 2)
.where(sle_doctype.is_cancelled == 0)
.where(sle_doctype.item_code.isin(item_codes))
.where(sle_doctype.warehouse.isin(warehouses))
.where(sle_doctype.voucher_type == "Stock Reconciliation")
.where(sle_doctype.posting_date == filters.from_date)
.where(sr_doctype.purpose == "Opening Stock")
)
# check if any SLEs are actually Opening Stock Reconciliation
for sle in list(sl_entries):
if (
sle.get("voucher_type") == "Stock Reconciliation"
and sle.posting_date == filters.from_date
and frappe.db.get_value("Stock Reconciliation", sle.voucher_no, "purpose") == "Opening Stock"
):
last_entry = sle
sl_entries.remove(sle)
opening_reco_vouchers = set(opening_reco_query.run(pluck=True))
row = {
if opening_reco_vouchers:
sl_entries[:] = [sle for sle in sl_entries if sle.get("voucher_no") not in opening_reco_vouchers]
sle_cond = (sle_doctype.posting_date < filters.from_date) | (
(sle_doctype.posting_date == filters.from_date) & (sle_doctype.posting_time == "00:00:00")
)
if opening_reco_vouchers:
sle_cond = sle_cond | (
(sle_doctype.posting_date == filters.from_date)
& (sle_doctype.voucher_no.isin(list(opening_reco_vouchers)))
)
subq = (
frappe.qb.from_(sle_doctype)
.select(
sle_doctype.qty_after_transaction,
sle_doctype.stock_value,
RowNumber()
.over(sle_doctype.item_code, sle_doctype.warehouse)
.orderby(sle_doctype.posting_datetime, sle_doctype.creation, sle_doctype.name, order=Order.desc)
.as_("rn"),
)
.where(sle_doctype.docstatus < 2)
.where(sle_doctype.is_cancelled == 0)
.where(sle_doctype.item_code.isin(item_codes))
.where(sle_doctype.warehouse.isin(warehouses))
.where(sle_cond)
)
for field in ["voucher_no", "project", "company"]:
if filters.get(field):
subq = subq.where(sle_doctype[field] == filters.get(field))
inventory_dimension_fields = get_inventory_dimension_fields()
if inventory_dimension_fields:
for fieldname in inventory_dimension_fields:
if filters.get(fieldname):
subq = subq.where(sle_doctype[fieldname].isin(filters.get(fieldname)))
query = (
frappe.qb.from_(subq)
.select(
IfNull(Sum(subq.qty_after_transaction), 0.0).as_("total_qty"),
IfNull(Sum(subq.stock_value), 0.0).as_("total_stock_value"),
)
.where(subq.rn == 1)
)
res = query.run(as_dict=True)
total_qty = flt(res[0].total_qty) if res else 0.0
total_stock_value = flt(res[0].total_stock_value) if res else 0.0
valuation_rate = flt(total_stock_value / total_qty) if total_qty else 0.0
return {
"item_code": _("'Opening'"),
"qty_after_transaction": last_entry.get("qty_after_transaction", 0),
"valuation_rate": last_entry.get("valuation_rate", 0),
"stock_value": last_entry.get("stock_value", 0),
"qty_after_transaction": total_qty,
"valuation_rate": valuation_rate,
"stock_value": total_stock_value,
}
return row
def get_matching_warehouses(warehouses):
if not warehouses:
return []
if isinstance(warehouses, str):
warehouses = [warehouses]
warehouse_details = frappe.get_all(
"Warehouse",
filters={"name": ("in", warehouses)},
fields=["lft", "rgt"],
)
if not warehouse_details:
return warehouses
wh = frappe.qb.DocType("Warehouse")
cond = None
for d in warehouse_details:
c = (wh.lft >= d.lft) & (wh.rgt <= d.rgt)
cond = c if cond is None else (cond | c)
matching = (frappe.qb.from_(wh).select(wh.name).where(cond)).run(pluck=True)
return matching if matching else warehouses
def get_warehouse_condition(warehouses):
@@ -785,7 +865,15 @@ def get_opening_balance_for_inv_dimension(filters, inv_dimension_wise_value):
if not filters.item_code or not filters.warehouse or not filters.from_date:
return
if len(filters.get("item_code")) > 1 or len(filters.get("warehouse")) > 1:
item_codes = filters.get("item_code")
if isinstance(item_codes, str):
item_codes = [item_codes]
warehouses = filters.get("warehouse")
if isinstance(warehouses, str):
warehouses = [warehouses]
if len(item_codes) > 1 or len(warehouses) > 1:
return
sl_doctype = frappe.qb.DocType("Stock Ledger Entry")
@@ -805,17 +893,11 @@ def get_opening_balance_for_inv_dimension(filters, inv_dimension_wise_value):
)
)
if filters.get("item_code"):
if isinstance(filters.item_code, list | tuple):
query = query.where(sl_doctype.item_code.isin(filters.item_code))
else:
query = query.where(sl_doctype.item_code == filters.item_code)
if item_codes:
query = query.where(sl_doctype.item_code.isin(item_codes))
if filters.get("warehouse"):
if isinstance(filters.warehouse, list | tuple):
query = query.where(sl_doctype.warehouse.isin(filters.warehouse))
else:
query = query.where(sl_doctype.warehouse == filters.warehouse)
if warehouses:
query = query.where(sl_doctype.warehouse.isin(warehouses))
for key, value in inv_dimension_wise_value.items():
if isinstance(value, list | tuple):

View File

@@ -4,18 +4,333 @@
import frappe
from frappe.utils import add_days, today
from erpnext.maintenance.doctype.maintenance_schedule.test_maintenance_schedule import (
make_serial_item_with_serial,
)
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.report.stock_ledger.stock_ledger import execute
from erpnext.tests.utils import ERPNextTestSuite
WAREHOUSE = "Stores - _TC"
class TestStockLedgerReeport(ERPNextTestSuite):
def setUp(self) -> None:
make_serial_item_with_serial(self, "_Test Stock Report Serial Item")
self.filters = frappe._dict(
class TestStockLedgerReport(ERPNextTestSuite):
"""Correctness tests for the Stock Ledger report.
A shared `make_movements`/`run` pair keeps each test small without persisting
any data: movements are created per test and rolled back, while the report runs
read-only. Tests reuse bootstrap items and transact in `Stores - _TC`, which
starts clean (zero balance) for these items.
"""
def make_movements(self, item_code, movements):
for movement in movements:
make_stock_entry(item_code=item_code, **movement)
def run_report(self, item_code, from_date=None, to_date=None):
filters = frappe._dict(
company="_Test Company",
from_date=today(),
to_date=add_days(today(), 30),
item_code=["_Test Stock Report Serial Item"],
from_date=from_date or add_days(today(), -1),
to_date=to_date or today(),
item_code=[item_code],
warehouse=WAREHOUSE,
)
return list(execute(filters)[1])
def test_in_out_quantities_and_running_balance(self):
item = "_Test Item"
self.make_movements(
item,
[
{"qty": 10, "to_warehouse": WAREHOUSE, "basic_rate": 100},
{"qty": 4, "from_warehouse": WAREHOUSE},
],
)
rows = self.run_report(item)
receipt = next(row for row in rows if row.get("in_qty"))
issue = next(row for row in rows if row.get("out_qty"))
self.assertEqual(receipt["in_qty"], 10)
self.assertEqual(receipt["qty_after_transaction"], 10)
self.assertEqual(issue["out_qty"], -4)
self.assertEqual(issue["qty_after_transaction"], 6)
def test_opening_balance_reflects_movements_before_from_date(self):
item = "_Test Item"
self.make_movements(
item,
[
{
"qty": 10,
"to_warehouse": WAREHOUSE,
"basic_rate": 100,
"posting_date": add_days(today(), -10),
},
{"qty": 4, "from_warehouse": WAREHOUSE, "posting_date": today()},
],
)
rows = self.run_report(item, from_date=add_days(today(), -5), to_date=today())
# the receipt predates the range, so it surfaces as the opening balance
self.assertEqual(rows[0]["item_code"], "'Opening'")
self.assertEqual(rows[0]["qty_after_transaction"], 10)
# the in-range issue draws down from the opening balance
issue = next(row for row in rows if row.get("out_qty"))
self.assertEqual(issue["qty_after_transaction"], 6)
def test_filters_to_requested_item_only(self):
item_a = "_Test Item"
item_b = "_Test Item 2"
self.make_movements(item_a, [{"qty": 5, "to_warehouse": WAREHOUSE, "basic_rate": 100}])
self.make_movements(item_b, [{"qty": 7, "to_warehouse": WAREHOUSE, "basic_rate": 100}])
rows = self.run_report(item_a)
item_codes = {row["item_code"] for row in rows if row.get("voucher_no")}
self.assertEqual(item_codes, {item_a})
def test_multi_item_opening_balance_with_and_without_transactions(self):
item_a = "_Test Item"
item_b = "_Test Item 2"
self.make_movements(
item_a,
[
{
"qty": 10,
"to_warehouse": WAREHOUSE,
"basic_rate": 100,
"posting_date": add_days(today(), -10),
}
],
)
self.make_movements(
item_b,
[{"qty": 5, "to_warehouse": WAREHOUSE, "basic_rate": 50, "posting_date": add_days(today(), -10)}],
)
self.make_movements(
item_a,
[{"qty": 2, "from_warehouse": WAREHOUSE, "posting_date": today()}],
)
filters = frappe._dict(
company="_Test Company",
from_date=add_days(today(), -5),
to_date=today(),
item_code=[item_a, item_b],
warehouse=WAREHOUSE,
)
columns, rows = execute(filters)
opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"]
self.assertEqual(len(opening_rows), 1)
self.assertEqual(opening_rows[0]["qty_after_transaction"], 15)
def test_multi_warehouse_opening_balance_aggregation(self):
item = "_Test Item"
warehouse_1 = "Stores - _TC"
warehouse_2 = "Finished Goods - _TC"
self.make_movements(
item,
[
{
"qty": 10,
"to_warehouse": warehouse_1,
"basic_rate": 100,
"posting_date": add_days(today(), -10),
},
{
"qty": 20,
"to_warehouse": warehouse_2,
"basic_rate": 100,
"posting_date": add_days(today(), -10),
},
],
)
filters = frappe._dict(
company="_Test Company",
from_date=add_days(today(), -5),
to_date=today(),
item_code=[item],
warehouse=[warehouse_1, warehouse_2],
)
columns, rows = execute(filters)
opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"]
self.assertEqual(len(opening_rows), 1)
self.assertEqual(opening_rows[0]["qty_after_transaction"], 30)
def test_opening_stock_reconciliation_on_from_date_non_midnight_time(self):
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation,
)
item = "_Test Item"
from_date = today()
sr = create_stock_reconciliation(
item_code=item,
warehouse=WAREHOUSE,
qty=25,
rate=100,
posting_date=from_date,
posting_time="10:30:00",
purpose="Opening Stock",
do_not_submit=False,
)
filters = frappe._dict(
company="_Test Company",
from_date=from_date,
to_date=from_date,
item_code=[item],
warehouse=WAREHOUSE,
)
columns, rows = execute(filters)
opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"]
self.assertEqual(len(opening_rows), 1)
self.assertEqual(opening_rows[0]["qty_after_transaction"], 25)
# Ensure the Opening Stock Reconciliation is not duplicated in detail transaction rows
reco_rows = [row for row in rows if row.get("voucher_no") == sr.name]
self.assertEqual(len(reco_rows), 0)
def test_backdated_sle_independent_maxima_handling(self):
item = "_Test Item"
# Entry 1: Later posting date (2026-07-20), created first
self.make_movements(
item,
[
{
"qty": 10,
"to_warehouse": WAREHOUSE,
"basic_rate": 100,
"posting_date": add_days(today(), -10),
}
],
)
# Entry 2: Backdated posting date (2026-07-15), created LATER
self.make_movements(
item,
[
{
"qty": 5,
"to_warehouse": WAREHOUSE,
"basic_rate": 100,
"posting_date": add_days(today(), -15),
}
],
)
filters = frappe._dict(
company="_Test Company",
from_date=add_days(today(), -5),
to_date=today(),
item_code=[item],
warehouse=WAREHOUSE,
)
columns, rows = execute(filters)
opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"]
self.assertEqual(len(opening_rows), 1)
# Should correctly pick the latest posting date entry (15 Qty) despite backdated creation order
self.assertEqual(opening_rows[0]["qty_after_transaction"], 15)
def test_filtered_opening_balance_does_not_pick_excluded_creation_entry(self):
item = "_Test Item"
posting_date = add_days(today(), -10)
posting_time = "09:00:00"
included_entry = make_stock_entry(
item_code=item,
qty=10,
to_warehouse=WAREHOUSE,
basic_rate=100,
posting_date=posting_date,
posting_time=posting_time,
)
make_stock_entry(
item_code=item,
qty=50,
to_warehouse=WAREHOUSE,
basic_rate=100,
posting_date=posting_date,
posting_time=posting_time,
)
filters = frappe._dict(
company="_Test Company",
from_date=add_days(today(), -5),
to_date=today(),
item_code=[item],
warehouse=WAREHOUSE,
voucher_no=included_entry.name,
)
columns, rows = execute(filters)
opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"]
self.assertEqual(len(opening_rows), 1)
self.assertEqual(opening_rows[0]["qty_after_transaction"], 10)
def test_tied_creation_terminal_sle_is_not_summed_twice(self):
item = "_Test Item"
posting_date = add_days(today(), -10)
posting_time = "09:00:00"
stock_entry_1 = make_stock_entry(
item_code=item,
qty=10,
to_warehouse=WAREHOUSE,
basic_rate=100,
posting_date=posting_date,
posting_time=posting_time,
)
stock_entry_2 = make_stock_entry(
item_code=item,
qty=5,
to_warehouse=WAREHOUSE,
basic_rate=100,
posting_date=posting_date,
posting_time=posting_time,
)
sle_rows = frappe.get_all(
"Stock Ledger Entry",
filters={
"voucher_type": "Stock Entry",
"voucher_no": ("in", [stock_entry_1.name, stock_entry_2.name]),
"item_code": item,
"warehouse": WAREHOUSE,
"is_cancelled": 0,
},
fields=["name", "qty_after_transaction"],
order_by="name desc",
)
self.assertEqual(len(sle_rows), 2)
for sle in sle_rows:
frappe.db.set_value(
"Stock Ledger Entry",
sle.name,
"creation",
"2026-01-01 00:00:00.000000",
update_modified=False,
)
filters = frappe._dict(
company="_Test Company",
from_date=add_days(today(), -5),
to_date=today(),
item_code=[item],
warehouse=WAREHOUSE,
)
columns, rows = execute(filters)
opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"]
self.assertEqual(len(opening_rows), 1)
self.assertEqual(opening_rows[0]["qty_after_transaction"], sle_rows[0].qty_after_transaction)
self.assertNotEqual(
opening_rows[0]["qty_after_transaction"],
sum(sle.qty_after_transaction for sle in sle_rows),
)

View File

@@ -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:
@@ -841,14 +888,45 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
child = frappe.qb.DocType("Serial and Batch Entry")
sle_creation = self.sle.creation if self.sle.get("name") else None
if not self.sle.get("name") and self.sle.get("serial_and_batch_bundle"):
sle_creation = frappe.db.get_value(
"Stock Ledger Entry",
{"serial_and_batch_bundle": self.sle.serial_and_batch_bundle, "is_cancelled": 0},
"creation",
)
timestamp_condition = ""
if self.sle.posting_datetime:
timestamp_condition = child.posting_datetime < self.sle.posting_datetime
if self.sle.creation:
timestamp_condition |= (child.posting_datetime == self.sle.posting_datetime) & (
child.creation < self.sle.creation
sle_table = frappe.qb.DocType("Stock Ledger Entry")
if sle_creation:
# bundle creation and SLE creation are different timelines (a
# bundle can be created much before its SLE), so break the tie
# using the creation of the bundle's own SLE
tie_condition = ExistsCriterion(
frappe.qb.from_(sle_table)
.select(sle_table.name)
.where(
(sle_table.serial_and_batch_bundle == child.parent)
& (sle_table.is_cancelled == 0)
& (sle_table.creation < sle_creation)
)
)
else:
# the current entry is not yet in the ledger and will get the
# latest creation, so the same-timestamp entries which are
# already in the ledger precede it
tie_condition = ExistsCriterion(
frappe.qb.from_(sle_table)
.select(sle_table.name)
.where(
(sle_table.serial_and_batch_bundle == child.parent) & (sle_table.is_cancelled == 0)
)
)
timestamp_condition |= (child.posting_datetime == self.sle.posting_datetime) & tie_condition
query = (
frappe.qb.from_(child)
@@ -878,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):
@@ -890,6 +971,11 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
self.batchwise_valuation_batches = []
self.non_batchwise_valuation_batches = []
if batchwise_batches := self.sle.get("batchwise_valuation_batches"):
self.batchwise_valuation_batches = list(batchwise_batches)
self.non_batchwise_valuation_batches = list(set(self.batches) - set(batchwise_batches))
return
if get_valuation_method(
self.sle.item_code, self.sle.company
) == "Moving Average" and frappe.get_single_value("Stock Settings", "do_not_use_batchwise_valuation"):

View File

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