mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-06 11:13:04 +00:00
Compare commits
11 Commits
v15.119.0
...
version-15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2ded11e09 | ||
|
|
1bfe2e25ef | ||
|
|
5e7860362e | ||
|
|
b25a2f2cdf | ||
|
|
21c3e07f48 | ||
|
|
f5ab55c38d | ||
|
|
328c72da18 | ||
|
|
7be8a99a23 | ||
|
|
ebdc0cdf7f | ||
|
|
69c00b3362 | ||
|
|
edc8daae21 |
@@ -222,6 +222,9 @@ class Subscription(Document):
|
||||
"""
|
||||
Sets the status of the `Subscription`
|
||||
"""
|
||||
if self.status == "Cancelled":
|
||||
return
|
||||
|
||||
if self.is_trialling():
|
||||
self.status = "Trialling"
|
||||
elif self.status == "Active" and self.end_date and getdate(posting_date) > getdate(self.end_date):
|
||||
@@ -558,6 +561,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):
|
||||
@@ -567,8 +575,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()
|
||||
|
||||
|
||||
@@ -280,6 +280,59 @@ class TestSubscription(FrappeTestCase):
|
||||
settings.cancel_after_grace = default_grace_period_action
|
||||
settings.save()
|
||||
|
||||
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"
|
||||
)
|
||||
subscription.process(posting_date=nowdate()) # generate first invoice
|
||||
invoice = subscription.get_current_invoice()
|
||||
self.assertIsNotNone(invoice)
|
||||
|
||||
invoice.db_set("outstanding_amount", 0)
|
||||
invoice.db_set("status", "Paid")
|
||||
|
||||
subscription.cancel_subscription()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
cancelation_date = getdate(subscription.cancelation_date)
|
||||
|
||||
subscription.set_subscription_status()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
self.assertEqual(getdate(subscription.cancelation_date), cancelation_date)
|
||||
|
||||
subscription.cancel_at_period_end = 1
|
||||
subscription.end_date = None
|
||||
invoice_count = len(subscription.invoices)
|
||||
subscription.process()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
self.assertEqual(len(subscription.invoices), invoice_count)
|
||||
|
||||
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(),
|
||||
generate_invoice_at="End of the current subscription period",
|
||||
plans=[{"plan": "_Test plan name 11", "qty": 1}],
|
||||
)
|
||||
subscription.cancel_at_period_end = 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_subscription_restart_and_process(self):
|
||||
settings = frappe.get_single("Subscription Settings")
|
||||
default_grace_period_action = settings.cancel_after_grace
|
||||
|
||||
@@ -117,8 +117,11 @@ frappe.query_reports["Accounts Payable"] = {
|
||||
{
|
||||
fieldname: "supplier_group",
|
||||
label: __("Supplier Group"),
|
||||
fieldtype: "Link",
|
||||
fieldtype: "MultiSelectList",
|
||||
options: "Supplier Group",
|
||||
get_data: function (txt) {
|
||||
return frappe.db.get_link_options("Supplier Group", txt);
|
||||
},
|
||||
hidden: 1,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -121,6 +121,36 @@ class TestAccountsPayable(AccountsTestMixin, FrappeTestCase):
|
||||
self.assertEqual(len(report[1]), 2)
|
||||
self.assertEqual([pi.name, payment_term1.payment_term_name], [row.voucher_no, row.payment_term])
|
||||
|
||||
def test_supplier_group_filter(self):
|
||||
pi = self.create_purchase_invoice()
|
||||
supplier_group = frappe.db.get_value("Supplier", self.supplier, "supplier_group")
|
||||
other_group = frappe.get_doc(
|
||||
doctype="Supplier Group",
|
||||
supplier_group_name="_Test Supplier Group AP",
|
||||
parent_supplier_group="All Supplier Groups",
|
||||
).insert()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"party_type": "Supplier",
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
"supplier_group": supplier_group,
|
||||
}
|
||||
self.assertIn(pi.name, [row.voucher_no for row in execute(filters)[1]])
|
||||
|
||||
filters.update({"supplier_group": [other_group.name]})
|
||||
self.assertEqual(len(execute(filters)[1]), 0)
|
||||
|
||||
filters.update({"supplier_group": [supplier_group, other_group.name]})
|
||||
self.assertIn(pi.name, [row.voucher_no for row in execute(filters)[1]])
|
||||
|
||||
filters.update({"supplier_group": ["All Supplier Groups"]})
|
||||
self.assertIn(pi.name, [row.voucher_no for row in execute(filters)[1]])
|
||||
|
||||
filters.update({"supplier_group": ["_Test Supplier Group Mars"]})
|
||||
self.assertRaises(frappe.ValidationError, execute, filters)
|
||||
|
||||
def test_project_filter(self):
|
||||
project = frappe.get_doc(
|
||||
{"doctype": "Project", "project_name": "_Test AP Project", "company": self.company}
|
||||
|
||||
@@ -100,8 +100,11 @@ frappe.query_reports["Accounts Payable Summary"] = {
|
||||
{
|
||||
fieldname: "supplier_group",
|
||||
label: __("Supplier Group"),
|
||||
fieldtype: "Link",
|
||||
fieldtype: "MultiSelectList",
|
||||
options: "Supplier Group",
|
||||
get_data: function (txt) {
|
||||
return frappe.db.get_link_options("Supplier Group", txt);
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "based_on_payment_terms",
|
||||
|
||||
@@ -140,8 +140,11 @@ frappe.query_reports["Accounts Receivable"] = {
|
||||
{
|
||||
fieldname: "territory",
|
||||
label: __("Territory"),
|
||||
fieldtype: "Link",
|
||||
fieldtype: "MultiSelectList",
|
||||
options: "Territory",
|
||||
get_data: function (txt) {
|
||||
return frappe.db.get_link_options("Territory", txt);
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "group_by_party",
|
||||
|
||||
@@ -1013,7 +1013,13 @@ class ReceivablePayableReport:
|
||||
self.qb_selection_filter.append(self.ple.party.isin(customers))
|
||||
|
||||
if self.filters.get("territory"):
|
||||
self.get_hierarchical_filters("Territory", "territory")
|
||||
territories = get_nested_set_children("Territory", self.filters.territory)
|
||||
customers = (
|
||||
qb.from_(self.customer)
|
||||
.select(self.customer.name)
|
||||
.where(self.customer["territory"].isin(territories))
|
||||
)
|
||||
self.qb_selection_filter.append(self.ple.party.isin(customers))
|
||||
|
||||
if self.filters.get("payment_terms_template"):
|
||||
customer_ptt = self.ple.party.isin(
|
||||
@@ -1034,11 +1040,10 @@ class ReceivablePayableReport:
|
||||
def add_supplier_filters(self):
|
||||
supplier = qb.DocType("Supplier")
|
||||
if self.filters.get("supplier_group"):
|
||||
groups = get_party_group_with_children("Supplier", self.filters.supplier_group)
|
||||
self.qb_selection_filter.append(
|
||||
self.ple.party.isin(
|
||||
qb.from_(supplier)
|
||||
.select(supplier.name)
|
||||
.where(supplier.supplier_group == self.filters.get("supplier_group"))
|
||||
qb.from_(supplier).select(supplier.name).where(supplier.supplier_group.isin(groups))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1090,16 +1095,6 @@ class ReceivablePayableReport:
|
||||
|
||||
return ptt
|
||||
|
||||
def get_hierarchical_filters(self, doctype, key):
|
||||
lft, rgt = frappe.db.get_value(doctype, self.filters.get(key), ["lft", "rgt"])
|
||||
|
||||
doc = qb.DocType(doctype)
|
||||
ple = self.ple
|
||||
customer = self.customer
|
||||
groups = qb.from_(doc).select(doc.name).where((doc.lft >= lft) & (doc.rgt <= rgt))
|
||||
customers = qb.from_(customer).select(customer.name).where(customer[key].isin(groups))
|
||||
self.qb_selection_filter.append(ple.party.isin(customers))
|
||||
|
||||
def add_accounting_dimensions_filters(self):
|
||||
accounting_dimensions = get_accounting_dimensions(as_list=False)
|
||||
|
||||
@@ -1329,19 +1324,23 @@ def get_party_group_with_children(party, party_groups):
|
||||
if party not in ("Customer", "Supplier"):
|
||||
return []
|
||||
|
||||
group_dtype = f"{party} Group"
|
||||
if not isinstance(party_groups, list):
|
||||
party_groups = [d.strip() for d in party_groups.strip().split(",") if d]
|
||||
return get_nested_set_children(f"{party} Group", party_groups)
|
||||
|
||||
all_party_groups = []
|
||||
for d in party_groups:
|
||||
if frappe.db.exists(group_dtype, d):
|
||||
lft, rgt = frappe.db.get_value(group_dtype, d, ["lft", "rgt"])
|
||||
children = frappe.get_all(
|
||||
group_dtype, filters={"lft": [">=", lft], "rgt": ["<=", rgt]}, pluck="name"
|
||||
)
|
||||
all_party_groups += children
|
||||
|
||||
def get_nested_set_children(doctype, values):
|
||||
if not isinstance(values, list):
|
||||
values = [d.strip() for d in values.split(",") if d.strip()]
|
||||
|
||||
if not values:
|
||||
frappe.throw(_("Please select a valid {0}").format(_(doctype)))
|
||||
|
||||
all_values = []
|
||||
for d in values:
|
||||
if frappe.db.exists(doctype, d):
|
||||
lft, rgt = frappe.db.get_value(doctype, d, ["lft", "rgt"])
|
||||
children = frappe.get_all(doctype, filters={"lft": [">=", lft], "rgt": ["<=", rgt]}, pluck="name")
|
||||
all_values += children
|
||||
else:
|
||||
frappe.throw(_("{0}: {1} does not exist").format(group_dtype, d))
|
||||
frappe.throw(_("{0}: {1} does not exist").format(doctype, d))
|
||||
|
||||
return list(set(all_party_groups))
|
||||
return list(set(all_values))
|
||||
|
||||
@@ -771,6 +771,38 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
|
||||
# Assert that the customer group of each row is in the list of customer groups
|
||||
self.assertIn(row.customer_group, cus_groups_list)
|
||||
|
||||
def test_territory_filter(self):
|
||||
self.create_sales_invoice()
|
||||
territory = frappe.db.get_value("Customer", self.customer, "territory")
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
"territory": territory,
|
||||
}
|
||||
report = execute(filters)[1]
|
||||
self.assertEqual(len(report), 1)
|
||||
self.assertEqual(
|
||||
[100.0, 100.0, territory], [report[0].invoiced, report[0].outstanding, report[0].territory]
|
||||
)
|
||||
|
||||
filters.update({"territory": ["_Test Territory United States"]})
|
||||
self.assertEqual(len(execute(filters)[1]), 0)
|
||||
|
||||
filters.update({"territory": [territory, "_Test Territory United States"]})
|
||||
self.assertEqual(len(execute(filters)[1]), 1)
|
||||
|
||||
frappe.db.set_value("Customer", self.customer, "territory", "_Test Territory Maharashtra")
|
||||
filters.update({"territory": ["_Test Territory India"]})
|
||||
self.assertEqual(len(execute(filters)[1]), 1)
|
||||
|
||||
filters.update({"territory": ["_Test Territory Mars"]})
|
||||
self.assertRaises(frappe.ValidationError, execute, filters)
|
||||
|
||||
filters.update({"territory": " "})
|
||||
self.assertRaises(frappe.ValidationError, execute, filters)
|
||||
|
||||
def test_party_account_filter(self):
|
||||
si1 = self.create_sales_invoice()
|
||||
self.customer2 = (
|
||||
|
||||
@@ -106,8 +106,11 @@ frappe.query_reports["Accounts Receivable Summary"] = {
|
||||
{
|
||||
fieldname: "territory",
|
||||
label: __("Territory"),
|
||||
fieldtype: "Link",
|
||||
fieldtype: "MultiSelectList",
|
||||
options: "Territory",
|
||||
get_data: function (txt) {
|
||||
return frappe.db.get_link_options("Territory", txt);
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "sales_partner",
|
||||
|
||||
@@ -948,6 +948,8 @@ class TestPurchaseOrder(FrappeTestCase):
|
||||
# self.assertEqual(po.payment_terms_template, pi.payment_terms_template)
|
||||
compare_payment_schedules(self, po, pi)
|
||||
|
||||
@change_settings("Selling Settings", {"maintain_same_sales_rate": 1})
|
||||
@change_settings("Buying Settings", {"maintain_same_rate": 1})
|
||||
def test_internal_transfer_flow(self):
|
||||
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
|
||||
@@ -959,9 +961,6 @@ class TestPurchaseOrder(FrappeTestCase):
|
||||
)
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
|
||||
|
||||
frappe.db.set_single_value("Selling Settings", "maintain_same_sales_rate", 1)
|
||||
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1)
|
||||
|
||||
prepare_data_for_internal_transfer()
|
||||
supplier = "_Test Internal Supplier 2"
|
||||
|
||||
|
||||
@@ -312,13 +312,12 @@ class StatusUpdater(Document):
|
||||
qty_or_amount,
|
||||
)
|
||||
|
||||
role_allowed_to_over_deliver_receive = frappe.db.get_single_value(
|
||||
"Stock Settings", "role_allowed_to_over_deliver_receive"
|
||||
)
|
||||
role_allowed_to_over_bill = frappe.db.get_single_value(
|
||||
"Accounts Settings", "role_allowed_to_over_bill"
|
||||
)
|
||||
role = role_allowed_to_over_deliver_receive if qty_or_amount == "qty" else role_allowed_to_over_bill
|
||||
role = None
|
||||
if qty_or_amount == "qty":
|
||||
if args.get("overflow_type") in ("delivery", "receipt"):
|
||||
role = frappe.get_single_value("Stock Settings", "role_allowed_to_over_deliver_receive")
|
||||
else:
|
||||
role = frappe.get_single_value("Accounts Settings", "role_allowed_to_over_bill")
|
||||
|
||||
overflow_percent = (
|
||||
(item[args["target_field"]] - item[args["target_ref_field"]]) / item[args["target_ref_field"]]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests.utils import FrappeTestCase, change_settings
|
||||
from frappe.utils import add_months, today
|
||||
|
||||
from erpnext import get_company_currency
|
||||
@@ -91,6 +91,32 @@ class TestBlanketOrder(FrappeTestCase):
|
||||
frappe.db.set_single_value("Buying Settings", "blanket_order_allowance", 10)
|
||||
po.submit()
|
||||
|
||||
@change_settings("Selling Settings", {"blanket_order_allowance": 0})
|
||||
@change_settings("Buying Settings", {"blanket_order_allowance": 0})
|
||||
@change_settings(
|
||||
"Stock Settings",
|
||||
{"over_delivery_receipt_allowance": 10, "role_allowed_to_over_deliver_receive": "Stock Manager"},
|
||||
)
|
||||
def test_stock_over_delivery_role_does_not_bypass_blanket_order_allowance(self):
|
||||
test_user = frappe.get_doc("User", "test@example.com")
|
||||
test_user.add_roles("Accounts User", "Stock Manager")
|
||||
|
||||
frappe.clear_cache()
|
||||
for blanket_order_type, doctype, date_field in (
|
||||
("Selling", "Sales Order", "delivery_date"),
|
||||
("Purchasing", "Purchase Order", "schedule_date"),
|
||||
):
|
||||
bo = make_blanket_order(blanket_order_type=blanket_order_type, quantity=100)
|
||||
frappe.flags.args.doctype = doctype
|
||||
order = make_order(bo.name)
|
||||
order.currency = get_company_currency(order.company)
|
||||
setattr(order, date_field, today())
|
||||
order.items[0].qty = 110
|
||||
|
||||
with self.set_user("test@example.com"):
|
||||
order.flags.ignore_permissions = True
|
||||
self.assertRaises(frappe.ValidationError, order.submit)
|
||||
|
||||
def test_party_item_code(self):
|
||||
item_doc = make_item("_Test Item 1 for Blanket Order")
|
||||
item_code = item_doc.name
|
||||
|
||||
@@ -4996,6 +4996,66 @@ class TestPurchaseReceipt(FrappeTestCase):
|
||||
|
||||
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
|
||||
|
||||
@@ -402,6 +402,13 @@ class SerialandBatchBundle(Document):
|
||||
|
||||
valuation_method = get_valuation_method(self.item_code)
|
||||
|
||||
# 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":
|
||||
@@ -435,6 +442,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)
|
||||
|
||||
@@ -463,6 +476,41 @@ 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) == "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:
|
||||
return
|
||||
|
||||
@@ -131,7 +131,8 @@
|
||||
"description": "The percentage you are allowed to receive or deliver more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed to receive 110 units.",
|
||||
"fieldname": "over_delivery_receipt_allowance",
|
||||
"fieldtype": "Float",
|
||||
"label": "Over Delivery/Receipt Allowance (%)"
|
||||
"label": "Over Delivery/Receipt Allowance (%)",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"default": "Stop",
|
||||
@@ -282,7 +283,8 @@
|
||||
"description": "The percentage you are allowed to transfer more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed transfer 110 units.",
|
||||
"fieldname": "mr_qty_allowance",
|
||||
"fieldtype": "Float",
|
||||
"label": "Over Transfer Allowance"
|
||||
"label": "Over Transfer Allowance",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -446,7 +448,8 @@
|
||||
"description": "The percentage you are allowed to pick more items in the pick list than the ordered quantity.",
|
||||
"fieldname": "over_picking_allowance",
|
||||
"fieldtype": "Percent",
|
||||
"label": "Over Picking Allowance"
|
||||
"label": "Over Picking Allowance",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
@@ -528,7 +531,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-03-27 22:39:16.812184",
|
||||
"modified": "2026-08-01 23:35:02.896836",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Settings",
|
||||
|
||||
@@ -101,6 +101,7 @@ class StockSettings(Document):
|
||||
)
|
||||
|
||||
self.validate_warehouses()
|
||||
self.validate_over_delivery_receipt_allowance()
|
||||
self.cant_change_valuation_method()
|
||||
self.validate_clean_description_html()
|
||||
self.validate_pending_reposts()
|
||||
@@ -110,6 +111,10 @@ class StockSettings(Document):
|
||||
self.change_precision_for_purchase()
|
||||
self.validate_do_not_use_batchwise_valuation()
|
||||
|
||||
def validate_over_delivery_receipt_allowance(self):
|
||||
if not self.over_delivery_receipt_allowance:
|
||||
self.role_allowed_to_over_deliver_receive = None
|
||||
|
||||
def validate_do_not_use_batchwise_valuation(self):
|
||||
doc_before_save = self.get_doc_before_save()
|
||||
if not doc_before_save:
|
||||
|
||||
@@ -7,8 +7,10 @@ from collections import defaultdict
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import CombineDatetime, Sum
|
||||
from frappe.query_builder.functions import CombineDatetime, 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 = {}
|
||||
|
||||
@@ -687,43 +690,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):
|
||||
@@ -779,7 +859,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")
|
||||
@@ -799,17 +887,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):
|
||||
|
||||
@@ -5,20 +5,335 @@ import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
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
|
||||
|
||||
WAREHOUSE = "Stores - _TC"
|
||||
|
||||
|
||||
class TestStockLedgerReeport(FrappeTestCase):
|
||||
def setUp(self) -> None:
|
||||
make_serial_item_with_serial("_Test Stock Report Serial Item")
|
||||
self.filters = frappe._dict(
|
||||
company="_Test Company",
|
||||
from_date=today(),
|
||||
to_date=add_days(today(), 30),
|
||||
item_code=["_Test Stock Report Serial Item"],
|
||||
)
|
||||
class TestStockLedgerReport(FrappeTestCase):
|
||||
"""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 tearDown(self) -> None:
|
||||
frappe.db.rollback()
|
||||
|
||||
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=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),
|
||||
)
|
||||
|
||||
@@ -910,6 +910,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) == "Moving Average" and frappe.db.get_single_value(
|
||||
"Stock Settings", "do_not_use_batchwise_valuation"
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user