mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-06 11:13:04 +00:00
Compare commits
7 Commits
v16.31.1
...
version-16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa70d9bbc3 | ||
|
|
626e35135f | ||
|
|
0e26f9b1db | ||
|
|
243266f5ef | ||
|
|
af3184c8b4 | ||
|
|
eeb3cd238e | ||
|
|
adfa6768c9 |
@@ -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):
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user