mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-10 08:23:01 +00:00
Compare commits
12 Commits
v16.22.0
...
version-16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c8fff648f | ||
|
|
1bfb0c8e5a | ||
|
|
9dd0a9fd90 | ||
|
|
018f06d8d1 | ||
|
|
4fe7e958bf | ||
|
|
10cfac865e | ||
|
|
fa08501045 | ||
|
|
aaf2531a4e | ||
|
|
7a23a9347f | ||
|
|
f43af66246 | ||
|
|
6d038c5e71 | ||
|
|
254290a88e |
@@ -77,7 +77,8 @@
|
||||
"fieldname": "customer_email",
|
||||
"fieldtype": "Data",
|
||||
"label": "Email",
|
||||
"reqd": 1
|
||||
"reqd": 1,
|
||||
"options": "Email"
|
||||
},
|
||||
{
|
||||
"fieldname": "linked_docs_section",
|
||||
@@ -102,7 +103,7 @@
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:05:59.300573",
|
||||
"modified": "2026-06-06 13:05:59.300573",
|
||||
"modified_by": "Administrator",
|
||||
"module": "CRM",
|
||||
"name": "Appointment",
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import Case, CustomFunction
|
||||
from frappe.query_builder.functions import Count, Max, Sum
|
||||
from frappe.utils import cint
|
||||
|
||||
|
||||
@@ -24,50 +26,69 @@ def execute(filters=None):
|
||||
customers = get_sales_details(doctype)
|
||||
|
||||
data = []
|
||||
for cust in customers:
|
||||
if cint(cust[8]) >= cint(days_since_last_order):
|
||||
cust.insert(7, get_last_sales_amt(cust[0], doctype))
|
||||
data.append(cust)
|
||||
for row in customers:
|
||||
if cint(row[8]) >= cint(days_since_last_order):
|
||||
row.insert(7, get_last_sales_amt(row[0], doctype))
|
||||
data.append(row)
|
||||
return columns, data
|
||||
|
||||
|
||||
def get_sales_details(doctype):
|
||||
cond = """sum(so.base_net_total) as 'total_order_considered',
|
||||
max(so.posting_date) as 'last_order_date',
|
||||
DATEDIFF(CURRENT_DATE, max(so.posting_date)) as 'days_since_last_order' """
|
||||
if doctype == "Sales Order":
|
||||
cond = """sum(if(so.status = "Stopped",
|
||||
so.base_net_total * so.per_delivered/100,
|
||||
so.base_net_total)) as 'total_order_considered',
|
||||
max(so.transaction_date) as 'last_order_date',
|
||||
DATEDIFF(CURRENT_DATE, max(so.transaction_date)) as 'days_since_last_order'"""
|
||||
customer = frappe.qb.DocType("Customer")
|
||||
sales_doctype = frappe.qb.DocType(doctype)
|
||||
|
||||
return frappe.db.sql(
|
||||
f"""select
|
||||
cust.name,
|
||||
cust.customer_name,
|
||||
cust.territory,
|
||||
cust.customer_group,
|
||||
count(distinct(so.name)) as 'num_of_order',
|
||||
sum(base_net_total) as 'total_order_value', {cond}
|
||||
from `tabCustomer` cust, `tab{doctype}` so
|
||||
where cust.name = so.customer and so.docstatus = 1
|
||||
group by cust.name
|
||||
order by 'days_since_last_order' desc """,
|
||||
as_list=1,
|
||||
)
|
||||
date_diff = CustomFunction("DATEDIFF", ["d1", "d2"])
|
||||
current_date = CustomFunction("CURRENT_DATE", [])
|
||||
|
||||
if doctype == "Sales Order":
|
||||
total_considered = Sum(
|
||||
Case()
|
||||
.when(
|
||||
sales_doctype.status == "Stopped",
|
||||
sales_doctype.base_net_total * sales_doctype.per_delivered / 100,
|
||||
)
|
||||
.else_(sales_doctype.base_net_total)
|
||||
)
|
||||
date_col = sales_doctype.transaction_date
|
||||
else:
|
||||
total_considered = Sum(sales_doctype.base_net_total)
|
||||
date_col = sales_doctype.posting_date
|
||||
|
||||
last_order_date = Max(date_col)
|
||||
days_since_last_order = date_diff(current_date(), last_order_date)
|
||||
|
||||
return (
|
||||
frappe.qb.from_(customer)
|
||||
.inner_join(sales_doctype)
|
||||
.on(customer.name == sales_doctype.customer)
|
||||
.select(
|
||||
customer.name,
|
||||
customer.customer_name,
|
||||
customer.territory,
|
||||
customer.customer_group,
|
||||
Count(sales_doctype.name).distinct().as_("num_of_order"),
|
||||
Sum(sales_doctype.base_net_total).as_("total_order_value"),
|
||||
total_considered.as_("total_order_considered"),
|
||||
last_order_date.as_("last_order_date"),
|
||||
days_since_last_order.as_("days_since_last_order"),
|
||||
)
|
||||
.where(sales_doctype.docstatus == 1)
|
||||
.groupby(customer.name)
|
||||
.orderby(days_since_last_order, order=frappe.qb.desc)
|
||||
).run(as_list=True)
|
||||
|
||||
|
||||
def get_last_sales_amt(customer, doctype):
|
||||
cond = "posting_date"
|
||||
if doctype == "Sales Order":
|
||||
cond = "transaction_date"
|
||||
res = frappe.db.sql(
|
||||
f"""select base_net_total from `tab{doctype}`
|
||||
where customer = %s and docstatus = 1 order by {cond} desc
|
||||
limit 1""",
|
||||
customer,
|
||||
)
|
||||
sales_doctype = frappe.qb.DocType(doctype)
|
||||
date_col = sales_doctype.transaction_date if doctype == "Sales Order" else sales_doctype.posting_date
|
||||
|
||||
res = (
|
||||
frappe.qb.from_(sales_doctype)
|
||||
.select(sales_doctype.base_net_total)
|
||||
.where((sales_doctype.customer == customer) & (sales_doctype.docstatus == 1))
|
||||
.orderby(date_col, order=frappe.qb.desc)
|
||||
.limit(1)
|
||||
).run()
|
||||
|
||||
return res and res[0][0] or 0
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, getdate, today
|
||||
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.selling.report.inactive_customers.inactive_customers import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestInactiveCustomers(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.customer = frappe.get_doc(doctype="Customer", customer_name="_Test Inactive Customer").insert()
|
||||
self.last_order_date = add_days(today(), -120)
|
||||
so = make_sales_order(
|
||||
customer=self.customer.name,
|
||||
transaction_date=self.last_order_date,
|
||||
qty=5,
|
||||
rate=200,
|
||||
)
|
||||
so.submit()
|
||||
self.sales_order = so
|
||||
|
||||
def test_invalid_doctype_is_rejected(self):
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
execute,
|
||||
{"doctype": "Purchase Order", "days_since_last_order": 30},
|
||||
)
|
||||
|
||||
def test_inactive_customer_is_listed_with_expected_columns(self):
|
||||
columns, data = execute({"doctype": "Sales Order", "days_since_last_order": 30})
|
||||
|
||||
row = self.get_customer_row(data)
|
||||
self.assertIsNotNone(row, "Inactive customer should be present in the report")
|
||||
|
||||
# Column contract: the report relies on positional access.
|
||||
self.assertEqual(row[0], self.customer.name)
|
||||
self.assertEqual(row[7], 1000) # Last Order Amount inserted at index 7 (5 * 200)
|
||||
self.assertEqual(getdate(row[8]), getdate(self.last_order_date)) # Last Order Date
|
||||
self.assertGreaterEqual(row[9], 30) # Days Since Last Order
|
||||
|
||||
def test_recent_customer_is_excluded(self):
|
||||
_columns, data = execute({"doctype": "Sales Order", "days_since_last_order": 200})
|
||||
self.assertIsNone(
|
||||
self.get_customer_row(data),
|
||||
"Customer ordering within the threshold must be excluded",
|
||||
)
|
||||
|
||||
def get_customer_row(self, data):
|
||||
return next((row for row in data if row[0] == self.customer.name), None)
|
||||
@@ -89,7 +89,7 @@
|
||||
"icon": "icon-legal",
|
||||
"idx": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-29 22:51:49.285298",
|
||||
"modified": "2026-06-06 16:35:34.394675",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Setup",
|
||||
"name": "Terms and Conditions",
|
||||
@@ -135,7 +135,7 @@
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Accounts User",
|
||||
"role": "Accounts Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
@@ -152,6 +152,15 @@
|
||||
"read": 1,
|
||||
"role": "HR Manager",
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Accounts User",
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
|
||||
@@ -306,6 +306,11 @@ class FIFOSlots:
|
||||
# prepare single sle voucher detail lookup
|
||||
self.prepare_stock_reco_voucher_wise_count()
|
||||
|
||||
if stock_ledger_entries is None:
|
||||
# nested queries invalidate the streaming cursor below,
|
||||
# so batchwise valuation flags must be resolved beforehand
|
||||
self._prefetch_batchwise_valuations()
|
||||
|
||||
with frappe.db.unbuffered_cursor():
|
||||
if stock_ledger_entries is None:
|
||||
stock_ledger_entries = self._get_stock_ledger_entries()
|
||||
@@ -423,12 +428,38 @@ class FIFOSlots:
|
||||
|
||||
def _get_batchwise_valuation(self, batch_no: str):
|
||||
if batch_no not in self.batchwise_valuation_by_batch:
|
||||
# only reachable when stock ledger entries are passed in directly;
|
||||
# the streaming path prefetches all flags before iteration
|
||||
self.batchwise_valuation_by_batch[batch_no] = frappe.db.get_value(
|
||||
"Batch", batch_no, "use_batchwise_valuation"
|
||||
)
|
||||
|
||||
return self.batchwise_valuation_by_batch[batch_no]
|
||||
|
||||
def _prefetch_batchwise_valuations(self) -> None:
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
batch = frappe.qb.DocType("Batch")
|
||||
to_date = get_datetime(self.filters.get("to_date") + " 23:59:59")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(sle)
|
||||
.left_join(batch)
|
||||
.on(sle.batch_no == batch.name)
|
||||
.select(sle.batch_no, batch.use_batchwise_valuation)
|
||||
.distinct()
|
||||
.where(
|
||||
(sle.batch_no.isnotnull())
|
||||
& (sle.company == self.filters.get("company"))
|
||||
& (sle.posting_datetime <= to_date)
|
||||
& (sle.is_cancelled != 1)
|
||||
)
|
||||
)
|
||||
|
||||
query = self._apply_filter(query, sle, "item_code")
|
||||
|
||||
for batch_no, use_batchwise_valuation in query.run():
|
||||
self.batchwise_valuation_by_batch[batch_no] = use_batchwise_valuation
|
||||
|
||||
def _init_key_stores(self, row: dict) -> tuple:
|
||||
"Initialise keys and FIFO Queue."
|
||||
|
||||
|
||||
@@ -1434,6 +1434,80 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
item_result["fifo_queue"], [[batch_no.upper(), 1, 5.0, getdate(add_days(base_date, -2)), 50.0]]
|
||||
)
|
||||
|
||||
def test_legacy_batch_no_sle_with_streaming_cursor(self):
|
||||
"""SLEs carrying the legacy batch_no field must not trigger nested
|
||||
queries while entries stream through an unbuffered cursor."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from frappe.utils import add_days, nowdate
|
||||
|
||||
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_reconciliation.test_stock_reconciliation import (
|
||||
create_stock_reconciliation,
|
||||
)
|
||||
|
||||
suffix = frappe.generate_hash(length=8).upper()
|
||||
item_code = make_item(
|
||||
f"Test Stock Ageing Legacy Batch {suffix}",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": f"SA-LEG-{suffix}-.###",
|
||||
"valuation_method": "FIFO",
|
||||
},
|
||||
).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
base_date = nowdate()
|
||||
|
||||
reco = create_stock_reconciliation(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
qty=10,
|
||||
rate=10,
|
||||
posting_date=add_days(base_date, -2),
|
||||
posting_time="10:00:00",
|
||||
)
|
||||
batch_no = get_batch_from_bundle(reco.items[0].serial_and_batch_bundle)
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
|
||||
|
||||
create_stock_reconciliation(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
qty=5,
|
||||
rate=10,
|
||||
batch_no=batch_no,
|
||||
posting_date=add_days(base_date, -1),
|
||||
posting_time="10:00:00",
|
||||
)
|
||||
|
||||
# mimic pre-bundle data where SLEs carry batch_no directly
|
||||
frappe.db.set_value(
|
||||
"Stock Ledger Entry",
|
||||
{"item_code": item_code},
|
||||
"batch_no",
|
||||
batch_no,
|
||||
)
|
||||
|
||||
filters = frappe._dict(
|
||||
company="_Test Company",
|
||||
to_date=base_date,
|
||||
ranges=["30", "60", "90"],
|
||||
item_code=item_code,
|
||||
)
|
||||
fifo_slots = FIFOSlots(filters)
|
||||
|
||||
# fetch row by row so the streaming result set is still active
|
||||
# while each stock ledger entry is processed
|
||||
with patch("frappe.database.database.SQL_ITERATOR_BATCH_SIZE", 1):
|
||||
slots = fifo_slots.generate()
|
||||
|
||||
self.assertEqual(fifo_slots.batchwise_valuation_by_batch.get(batch_no), 1)
|
||||
self.assertEqual(slots[item_code]["total_qty"], 5.0)
|
||||
|
||||
|
||||
def generate_item_and_item_wh_wise_slots(filters, sle):
|
||||
"Return results with and without 'show_warehouse_wise_stock'"
|
||||
|
||||
Reference in New Issue
Block a user