Merge pull request #56125 from mihir-kandoi/pg-crm

This commit is contained in:
Mihir Kandoi
2026-06-19 10:44:48 +05:30
committed by GitHub
11 changed files with 454 additions and 175 deletions

View File

@@ -290,13 +290,19 @@ class Opportunity(TransactionBase, CRMNote):
"name",
)
else:
return frappe.db.sql(
"""
select q.name
from `tabQuotation` q, `tabQuotation Item` qi
where q.name = qi.parent and q.docstatus=1 and qi.prevdoc_docname =%s
and q.status not in ('Lost', 'Closed')""",
self.name,
q = frappe.qb.DocType("Quotation")
qi = frappe.qb.DocType("Quotation Item")
return (
frappe.qb.from_(q)
.inner_join(qi)
.on(q.name == qi.parent)
.select(q.name)
.where(
(q.docstatus == 1)
& (qi.prevdoc_docname == self.name)
& q.status.notin(["Lost", "Closed"])
)
.run()
)
def has_ordered_quotation(self):
@@ -305,24 +311,20 @@ class Opportunity(TransactionBase, CRMNote):
"Quotation", {"opportunity": self.name, "status": "Ordered", "docstatus": 1}, "name"
)
else:
return frappe.db.sql(
"""
select q.name
from `tabQuotation` q, `tabQuotation Item` qi
where q.name = qi.parent and q.docstatus=1 and qi.prevdoc_docname =%s
and q.status = 'Ordered'""",
self.name,
q = frappe.qb.DocType("Quotation")
qi = frappe.qb.DocType("Quotation Item")
return (
frappe.qb.from_(q)
.inner_join(qi)
.on(q.name == qi.parent)
.select(q.name)
.where((q.docstatus == 1) & (qi.prevdoc_docname == self.name) & (q.status == "Ordered"))
.run()
)
def has_lost_quotation(self):
lost_quotation = frappe.db.sql(
"""
select name
from `tabQuotation`
where docstatus=1
and opportunity =%s and status = 'Lost'
""",
self.name,
lost_quotation = frappe.get_all(
"Quotation", filters={"docstatus": 1, "opportunity": self.name, "status": "Lost"}
)
if lost_quotation:
if self.has_active_quotation():
@@ -371,19 +373,19 @@ class Opportunity(TransactionBase, CRMNote):
@frappe.whitelist()
def get_item_details(item_code: str):
item = frappe.db.sql(
"""select item_name, stock_uom, image, description, item_group, brand
from `tabItem` where name = %s""",
item = frappe.db.get_value(
"Item",
item_code,
as_dict=1,
["item_name", "stock_uom", "image", "description", "item_group", "brand"],
as_dict=True,
)
return {
"item_name": item and item[0]["item_name"] or "",
"uom": item and item[0]["stock_uom"] or "",
"description": item and item[0]["description"] or "",
"image": item and item[0]["image"] or "",
"item_group": item and item[0]["item_group"] or "",
"brand": item and item[0]["brand"] or "",
"item_name": item and item.item_name or "",
"uom": item and item.stock_uom or "",
"description": item and item.description or "",
"image": item and item.image or "",
"item_group": item and item.item_group or "",
"brand": item and item.brand or "",
}

View File

@@ -0,0 +1,47 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from erpnext.crm.doctype.utils import get_last_interaction
from erpnext.tests.utils import ERPNextTestSuite
class TestCrmDoctypeUtils(ERPNextTestSuite):
def test_get_last_interaction_for_contact(self):
"""Covers the converted Communication query (contact path): returns the earliest Received
communication across the doctypes the contact is linked to. `creation` is unique, so the
LIMIT-1 pick is deterministic and identical on MariaDB and Postgres."""
customer = "_Test CRM Util Customer"
if not frappe.db.exists("Customer", customer):
frappe.get_doc(
{
"doctype": "Customer",
"customer_name": customer,
"customer_group": "_Test Customer Group",
"territory": "_Test Territory",
}
).insert(ignore_permissions=True)
contact = frappe.get_doc(
{
"doctype": "Contact",
"first_name": "CRM Util Test",
"links": [{"link_doctype": "Customer", "link_name": customer}],
}
).insert(ignore_permissions=True)
comm = frappe.get_doc(
{
"doctype": "Communication",
"subject": "hi",
"content": "first interaction",
"sent_or_received": "Received",
"reference_doctype": "Customer",
"reference_name": customer,
}
).insert(ignore_permissions=True)
result = get_last_interaction(contact=contact.name)
self.assertIsNotNone(result["last_communication"])
self.assertEqual(result["last_communication"]["name"], comm.name)

View File

@@ -1,4 +1,5 @@
import frappe
from frappe.query_builder import Criterion
@frappe.whitelist()
@@ -9,37 +10,33 @@ def get_last_interaction(contact: str | None = None, lead: str | None = None):
last_communication = None
last_issue = None
if contact:
query_condition = ""
values = []
communication = frappe.qb.DocType("Communication")
link_conditions = []
contact = frappe.get_doc("Contact", contact)
for link in contact.links:
if link.link_doctype == "Customer":
last_issue = get_last_issue_from_customer(link.link_name)
query_condition += "(`reference_doctype`=%s AND `reference_name`=%s) OR"
values += [link.link_doctype, link.link_name]
link_conditions.append(
(communication.reference_doctype == link.link_doctype)
& (communication.reference_name == link.link_name)
)
if query_condition:
# remove extra appended 'OR'
query_condition = query_condition[:-2]
last_communication = frappe.db.sql(
f"""
SELECT `name`, `content`
FROM `tabCommunication`
WHERE `sent_or_received`='Received'
AND ({query_condition})
ORDER BY `creation`
LIMIT 1
""",
values,
as_dict=1,
) # nosec
if link_conditions:
last_communication = (
frappe.qb.from_(communication)
.select(communication.name, communication.content)
.where((communication.sent_or_received == "Received") & Criterion.any(link_conditions))
.orderby(communication.creation)
.limit(1)
.run(as_dict=1)
)
if lead:
last_communication = frappe.get_all(
"Communication",
filters={"reference_doctype": "Lead", "reference_name": lead, "sent_or_received": "Received"},
fields=["name", "content"],
order_by="`creation` DESC",
order_by="creation desc",
limit=1,
)
@@ -53,7 +50,7 @@ def get_last_issue_from_customer(customer_name):
"Issue",
{"customer": customer_name},
["name", "subject", "customer"],
order_by="`creation` DESC",
order_by="creation desc",
limit=1,
)

View File

@@ -4,7 +4,8 @@
import frappe
from frappe import _
from frappe.utils import flt
from frappe.query_builder.functions import Sum
from frappe.utils import add_days, flt
def execute(filters=None):
@@ -30,17 +31,15 @@ def get_columns(based_on):
def get_lead_data(filters, based_on):
based_on_field = frappe.scrub(based_on)
conditions = get_filter_conditions(filters)
lead_details = frappe.db.sql(
f"""
select {based_on_field}, name
from `tabLead`
where {based_on_field} is not null and {based_on_field} != '' {conditions}
""",
filters,
as_dict=1,
)
lead_filters = [[based_on_field, "is", "set"]]
if filters.from_date:
lead_filters.append(["creation", ">=", filters.from_date])
if filters.to_date:
# date(creation) <= to_date, i.e. anything created before the next day
lead_filters.append(["creation", "<", add_days(filters.to_date, 1)])
lead_details = frappe.get_all("Lead", filters=lead_filters, fields=[based_on_field, "name"])
lead_map = frappe._dict()
for d in lead_details:
@@ -64,52 +63,36 @@ def get_lead_data(filters, based_on):
return data
def get_filter_conditions(filters):
conditions = ""
if filters.from_date:
conditions += " and date(creation) >= %(from_date)s"
if filters.to_date:
conditions += " and date(creation) <= %(to_date)s"
return conditions
def get_lead_quotation_count(leads):
return frappe.db.sql(
"""select count(name) from `tabQuotation`
where quotation_to = 'Lead' and party_name in (%s)"""
% ", ".join(["%s"] * len(leads)),
tuple(leads),
)[0][0] # nosec
return frappe.db.count("Quotation", {"quotation_to": "Lead", "party_name": ["in", leads]})
def get_lead_opp_count(leads):
return frappe.db.sql(
"""select count(name) from `tabOpportunity`
where opportunity_from = 'Lead' and party_name in (%s)"""
% ", ".join(["%s"] * len(leads)),
tuple(leads),
)[0][0]
return frappe.db.count("Opportunity", {"opportunity_from": "Lead", "party_name": ["in", leads]})
def get_quotation_ordered_count(leads):
return frappe.db.sql(
"""select count(name)
from `tabQuotation` where status = 'Ordered' and quotation_to = 'Lead'
and party_name in (%s)"""
% ", ".join(["%s"] * len(leads)),
tuple(leads),
)[0][0]
return frappe.db.count(
"Quotation", {"status": "Ordered", "quotation_to": "Lead", "party_name": ["in", leads]}
)
def get_order_amount(leads):
return frappe.db.sql(
"""select sum(base_net_amount)
from `tabSales Order Item`
where prevdoc_docname in (
select name from `tabQuotation` where status = 'Ordered'
and quotation_to = 'Lead' and party_name in (%s)
)"""
% ", ".join(["%s"] * len(leads)),
tuple(leads),
so_item = frappe.qb.DocType("Sales Order Item")
quotation = frappe.qb.DocType("Quotation")
return (
frappe.qb.from_(so_item)
.select(Sum(so_item.base_net_amount))
.where(
so_item.prevdoc_docname.isin(
frappe.qb.from_(quotation)
.select(quotation.name)
.where(
(quotation.status == "Ordered")
& (quotation.quotation_to == "Lead")
& quotation.party_name.isin(leads)
)
)
)
.run()
)[0][0]

View File

@@ -0,0 +1,46 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import add_days, nowdate
from erpnext.crm.report.campaign_efficiency.campaign_efficiency import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestCampaignEfficiency(ERPNextTestSuite):
def test_lead_count_per_campaign(self):
"""execute() groups Leads by utm_campaign over a creation-date window and counts leads per
group. Seed two Leads sharing one distinct UTM Campaign, run the report over a window that
includes their (now-dated) creation, and assert that campaign's row reports lead_count == 2.
The group is unique to this test, so the count is exact rather than a tautology, and both
MariaDB and Postgres must return the same row/value."""
campaign = "_Test Campaign Eff Campaign"
if not frappe.db.exists("UTM Campaign", campaign):
frappe.get_doc({"doctype": "UTM Campaign", "__newname": campaign}).insert(ignore_permissions=True)
for i in range(2):
frappe.get_doc(
{
"doctype": "Lead",
"lead_name": f"_Test Campaign Eff Lead {i}",
"utm_campaign": campaign,
}
).insert(ignore_permissions=True)
# from_date <= creation(now) < to_date + 1 -> window covers the freshly inserted leads
filters = frappe._dict(
{
"from_date": add_days(nowdate(), -7),
"to_date": add_days(nowdate(), 1),
"based_on": "utm_campaign",
}
)
columns, data = execute(filters)
row = next((r for r in data if r.get("utm_campaign") == campaign), None)
self.assertIsNotNone(row, "campaign row missing from report output")
self.assertEqual(row["lead_count"], 2)
# no quotations/orders seeded for these leads -> derived counts are zero
self.assertEqual(row["quot_count"], 0)
self.assertEqual(row["order_count"], 0)

View File

@@ -4,6 +4,8 @@
import frappe
from frappe import _
from frappe.query_builder.functions import Avg, Date
from pypika import Order
def execute(filters=None):
@@ -17,19 +19,19 @@ def execute(filters=None):
},
]
data = frappe.db.sql(
"""
SELECT
date(creation) as creation_date,
avg(first_response_time) as avg_response_time
FROM tabOpportunity
WHERE
date(creation) between %s and %s
and first_response_time > 0
GROUP BY creation_date
ORDER BY creation_date desc
""",
(filters.from_date, filters.to_date),
opportunity = frappe.qb.DocType("Opportunity")
creation_date = Date(opportunity.creation)
data = (
frappe.qb.from_(opportunity)
.select(
creation_date.as_("creation_date"), Avg(opportunity.first_response_time).as_("avg_response_time")
)
.where(
creation_date.between(filters.from_date, filters.to_date) & (opportunity.first_response_time > 0)
)
.groupby(creation_date)
.orderby(creation_date, order=Order.desc)
.run()
)
return columns, data

View File

@@ -0,0 +1,60 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import add_days, getdate, nowdate
from erpnext.crm.report.first_response_time_for_opportunity.first_response_time_for_opportunity import (
execute,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestFirstResponseTimeForOpportunity(ERPNextTestSuite):
def test_avg_first_response_time_row(self):
"""The report groups Opportunity by Date(creation) and averages first_response_time where it
is > 0, between from_date and to_date. With a single seeded Opportunity created today and a
known first_response_time, the report must return a row for today whose averaged value equals
the seeded duration on both engines (Date(creation) and Avg via the query builder)."""
response_time = 3600 # seconds (Duration)
lead_email = "_test_frt_opp@example.com"
lead_name = "_Test FRT Opportunity Lead"
lead = frappe.db.exists("Lead", {"email_id": lead_email})
if not lead:
lead = (
frappe.get_doc({"doctype": "Lead", "lead_name": lead_name, "email_id": lead_email})
.insert(ignore_permissions=True)
.name
)
opportunity = frappe.get_doc(
{
"doctype": "Opportunity",
"opportunity_from": "Lead",
"party_name": lead,
"company": "_Test Company",
"currency": "INR",
"conversion_rate": 1,
}
).insert(ignore_permissions=True)
# first_response_time is a read-only computed field; set it directly.
frappe.db.set_value(
"Opportunity",
opportunity.name,
"first_response_time",
response_time,
update_modified=False,
)
columns, data = execute(
frappe._dict(from_date=add_days(nowdate(), -1), to_date=add_days(nowdate(), 1))
)
# rows are positional lists: [creation_date, avg_response_time]
today = getdate(nowdate())
row = next((r for r in data if getdate(r[0]) == today), None)
self.assertIsNotNone(row, "no report row for today's grouped creation date")
self.assertEqual(getdate(row[0]), today)
self.assertEqual(row[1], response_time)

View File

@@ -4,6 +4,7 @@
import frappe
from frappe import _, msgprint
from frappe.query_builder.functions import Count, Date
from frappe.utils import date_diff, flt
@@ -83,59 +84,51 @@ def get_communication_details(filters):
as_dict=1,
)
si = frappe.qb.DocType("Sales Invoice")
comm = frappe.qb.DocType("Communication")
for d in opportunities:
invoice = frappe.db.sql(
"""
SELECT
date(creation)
FROM
`tabSales Invoice`
WHERE
contact_email = %s AND date(creation) between %s and %s AND docstatus != 2
ORDER BY
creation
LIMIT 1
""",
(d.contact_email, filters.from_date, filters.to_date),
invoice = (
frappe.qb.from_(si)
.select(Date(si.creation))
.where(
(si.contact_email == d.contact_email)
& Date(si.creation).between(filters.from_date, filters.to_date)
& (si.docstatus != 2)
)
.orderby(si.creation)
.limit(1)
.run()
)
if not invoice:
continue
communication_count = frappe.db.sql(
"""
SELECT
count(*)
FROM
`tabCommunication`
WHERE
sender = %s AND date(communication_date) <= %s
""",
(d.contact_email, invoice),
invoice_date = invoice[0][0]
communication_count = (
frappe.qb.from_(comm)
.select(Count("*"))
.where((comm.sender == d.contact_email) & (Date(comm.communication_date) <= invoice_date))
.run()
)[0][0]
if not communication_count:
continue
first_contact = frappe.db.sql(
"""
SELECT
date(communication_date)
FROM
`tabCommunication`
WHERE
recipients = %s AND communication_date IS NOT NULL
ORDER BY
communication_date
LIMIT 1
""",
(d.contact_email),
first_contact = (
frappe.qb.from_(comm)
.select(Date(comm.communication_date))
.where((comm.recipients == d.contact_email) & comm.communication_date.isnotnull())
.orderby(comm.communication_date)
.limit(1)
.run()
)
first_contact = first_contact[0][0] if first_contact else None
if not first_contact:
continue
duration = flt(date_diff(invoice[0][0], first_contact))
duration = flt(date_diff(invoice_date, first_contact))
support_tickets = len(frappe.db.get_all("Issue", {"raised_by": d.contact_email}))
communication_list.append(

View File

@@ -0,0 +1,59 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import add_days, nowdate
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.crm.report.lead_conversion_time.lead_conversion_time import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestLeadConversionTime(ERPNextTestSuite):
def test_first_contact_ignores_null_communication_date(self):
"""first_contact ordered by the nullable communication_date and read row[0][0]. With no
IS NOT NULL guard, MariaDB (NULLs-first) returned a NULL-dated Communication -> first_contact
None -> a wrong duration, while Postgres (NULLs-last) returned the earliest real date. Filtering
communication_date IS NOT NULL (and guarding the slice) makes both engines use the earliest
real contact date."""
email = "_test_lead_conv@example.com"
customer_name = "_Test Lead Conv 22d"
lead = frappe.get_doc({"doctype": "Lead", "lead_name": customer_name, "email_id": email}).insert(
ignore_permissions=True
)
frappe.get_doc(
{
"doctype": "Opportunity",
"opportunity_from": "Lead",
"party_name": lead.name,
"company": "_Test Company",
"currency": "INR",
"conversion_rate": 1,
"contact_email": email,
"customer_name": customer_name,
}
).insert(ignore_permissions=True)
si = create_sales_invoice(do_not_save=1)
si.contact_email = email
si.save() # draft (docstatus 0 != 2); Date(creation) is today, within range
# count query filters on `sender`; first_contact filters on `recipients` -> set both
real = frappe.get_doc(
{"doctype": "Communication", "subject": "real", "sender": email, "recipients": email}
).insert(ignore_permissions=True)
frappe.db.set_value(
"Communication", real.name, "communication_date", add_days(nowdate(), -22), update_modified=False
)
nulldate = frappe.get_doc(
{"doctype": "Communication", "subject": "nulldate", "sender": email, "recipients": email}
).insert(ignore_permissions=True)
frappe.db.set_value("Communication", nulldate.name, "communication_date", None, update_modified=False)
data = execute(frappe._dict({"from_date": add_days(nowdate(), -30), "to_date": nowdate()}))[1]
# rows are lists: [customer, interactions, duration, support_tickets]
row = next((r for r in data if r[0] == customer_name), None)
self.assertIsNotNone(row, "lead's converted-customer row missing")
# duration must be measured from the earliest REAL contact (22 days), not the NULL-dated one
self.assertEqual(row[2], 22.0)

View File

@@ -61,30 +61,47 @@ def get_columns():
def get_data(filters):
lead_details = []
lead_filters = get_lead_filters(filters)
leads = frappe.get_all("Lead", fields=["name", "lead_name", "company_name"], filters=lead_filters)
if not leads:
return lead_details
for lead in frappe.get_all("Lead", fields=["name", "lead_name", "company_name"], filters=lead_filters):
data = frappe.db.sql(
"""
select
`tabCommunication`.reference_doctype, `tabCommunication`.reference_name,
`tabCommunication`.content, `tabCommunication`.communication_date
from
(
(select name, party_name as lead from `tabOpportunity` where opportunity_from='Lead' and party_name = %(lead)s)
union
(select name, party_name as lead from `tabQuotation` where quotation_to = 'Lead' and party_name = %(lead)s)
union
(select name, lead from `tabIssue` where lead = %(lead)s and status!='Closed')
union
(select %(lead)s, %(lead)s)
)
as ref_document, `tabCommunication`
where
`tabCommunication`.reference_name = ref_document.name and
`tabCommunication`.sent_or_received = 'Received'
order by
ref_document.lead, `tabCommunication`.creation desc limit %(limit)s""",
{"lead": lead.name, "limit": filters.get("no_of_interaction")},
lead_names = [lead.name for lead in leads]
# Collect the documents (and the lead itself) that communications may reference, for all leads in
# three bulk queries instead of three per lead.
reference_names = {name: {name} for name in lead_names}
for opp in frappe.get_all(
"Opportunity",
filters={"opportunity_from": "Lead", "party_name": ["in", lead_names]},
fields=["name", "party_name"],
):
reference_names[opp.party_name].add(opp.name)
for quotation in frappe.get_all(
"Quotation",
filters={"quotation_to": "Lead", "party_name": ["in", lead_names]},
fields=["name", "party_name"],
):
reference_names[quotation.party_name].add(quotation.name)
for issue in frappe.get_all(
"Issue",
filters={"lead": ["in", lead_names], "status": ["!=", "Closed"]},
fields=["name", "lead"],
):
reference_names[issue.lead].add(issue.name)
for lead in leads:
data = frappe.get_all(
"Communication",
filters={
# constrain the doctype too: names are unique only within a doctype
"reference_doctype": ["in", ["Lead", "Opportunity", "Quotation", "Issue"]],
"reference_name": ["in", list(reference_names[lead.name])],
"sent_or_received": "Received",
},
fields=["reference_doctype", "reference_name", "content", "communication_date"],
order_by="creation desc",
limit=filters.get("no_of_interaction"),
as_list=True,
)
for lead_info in data:

View File

@@ -0,0 +1,73 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from erpnext.crm.report.prospects_engaged_but_not_converted.prospects_engaged_but_not_converted import (
execute,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestProspectsEngagedButNotConverted(ERPNextTestSuite):
def test_lead_with_received_communications_appears(self):
"""The report lists non-converted Leads that have Communications referencing them
(reference_doctype="Lead", reference_name=lead.name) with sent_or_received="Received".
Seed one Lead and two such Received Communications, then assert the Lead surfaces in the
report data and that the emitted row carries the Lead -> reference_doctype/reference_name
linkage the get_data() join relies on. Asserting a concrete row (not a count) keeps this a
real-state smoke test that exercises the same path on both MariaDB and Postgres."""
lead_name = "_Test Prospect Engaged"
email = "_test_prospect_engaged@example.com"
lead = frappe.db.exists("Lead", {"lead_name": lead_name})
if lead:
lead = frappe.get_doc("Lead", lead)
else:
lead = frappe.get_doc(
{
"doctype": "Lead",
"lead_name": lead_name,
"email_id": email,
"company_name": "_Test Prospect Org",
}
).insert(ignore_permissions=True)
# A fresh, non-converted Lead is required for it to pass the report's lead filters.
self.assertNotEqual(lead.status, "Converted")
for subject in ("_test prospect engaged 1", "_test prospect engaged 2"):
if not frappe.db.exists(
"Communication",
{
"reference_doctype": "Lead",
"reference_name": lead.name,
"subject": subject,
},
):
frappe.get_doc(
{
"doctype": "Communication",
"communication_type": "Communication",
"subject": subject,
"content": subject,
"sent_or_received": "Received",
"reference_doctype": "Lead",
"reference_name": lead.name,
}
).insert(ignore_permissions=True)
# filters are accessed via .get(...) in the report, so a plain _dict suffices
columns, data = execute(frappe._dict(no_of_interaction=1))
# rows are lists: [lead, lead_name, company_name, reference_doctype, reference_name, content, date]
row = next((r for r in data if r[0] == lead.name), None)
self.assertIsNotNone(row, "seeded Lead with Received communications missing from report data")
self.assertEqual(row[3], "Lead")
self.assertEqual(row[4], lead.name)
# content comes from one of the two seeded Received communications
self.assertIn(row[5], ("_test prospect engaged 1", "_test prospect engaged 2"))
# no_of_interaction=1 caps the per-lead communications to 1 -> exactly one row for this Lead
lead_rows = [r for r in data if r[0] == lead.name]
self.assertEqual(len(lead_rows), 1)