Some checks failed
Frontend / Unit Tests & Coverage (push) Has been skipped
Regenerate POT file (translatable strings) / Regenerate POT file (develop) (push) Failing after 1m13s
Regenerate POT file (translatable strings) / Regenerate POT file (main-hotfix) (push) Failing after 6s
Server / Tests (frappe ${{ matrix.frappe-branch }}) (push) Has been skipped
Server / Resolve frappe matrix (push) Failing after 1s
Frontend / Lint (push) Failing after 1s
Frontend / Production Build (push) Failing after 1s
Server / Coverage Wrap Up (push) Has been skipped
Frontend / Coverage Wrap Up (push) Has been skipped
1324 lines
38 KiB
Python
1324 lines
38 KiB
Python
import json
|
|
|
|
import frappe
|
|
from frappe import _
|
|
from frappe.query_builder import Case, DocType
|
|
from frappe.query_builder.functions import Avg, Coalesce, Count, Date, DateFormat, IfNull, Sum
|
|
from pypika.functions import Function
|
|
|
|
from crm.fcrm.doctype.crm_dashboard.crm_dashboard import create_default_manager_dashboard
|
|
from crm.utils import sales_user_only
|
|
|
|
|
|
# Custom function for TIMESTAMPDIFF (MySQL/MariaDB)
|
|
class TimestampDiff(Function):
|
|
def __init__(self, unit, start, end, **kwargs):
|
|
super().__init__("TIMESTAMPDIFF", unit, start, end, **kwargs)
|
|
|
|
|
|
@frappe.whitelist()
|
|
def reset_to_default():
|
|
frappe.only_for("System Manager", True)
|
|
create_default_manager_dashboard(force=True)
|
|
|
|
|
|
@frappe.whitelist()
|
|
@sales_user_only
|
|
def get_dashboard(from_date: str | None = None, to_date: str | None = None, user: str | None = None):
|
|
"""
|
|
Get the dashboard data for the CRM dashboard.
|
|
"""
|
|
|
|
if not from_date or not to_date:
|
|
from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate())
|
|
to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate())
|
|
|
|
roles = frappe.get_roles(frappe.session.user)
|
|
is_sales_manager = "Sales Manager" in roles or "System Manager" in roles
|
|
is_sales_user = "Sales User" in roles and not is_sales_manager
|
|
|
|
if is_sales_user:
|
|
user = frappe.session.user
|
|
|
|
dashboard = frappe.db.exists("CRM Dashboard", "Manager Dashboard")
|
|
|
|
layout = []
|
|
|
|
if not dashboard:
|
|
layout = json.loads(create_default_manager_dashboard())
|
|
frappe.db.commit()
|
|
else:
|
|
layout = json.loads(frappe.db.get_value("CRM Dashboard", "Manager Dashboard", "layout") or "[]")
|
|
|
|
for l in layout:
|
|
method_name = f"get_{l['name']}"
|
|
if hasattr(frappe.get_attr("crm.api.dashboard"), method_name):
|
|
method = getattr(frappe.get_attr("crm.api.dashboard"), method_name)
|
|
l["data"] = method(from_date, to_date, user)
|
|
else:
|
|
l["data"] = None
|
|
|
|
return layout
|
|
|
|
|
|
#: Chart types that accept contributed options via the crm_dashboard_charts
|
|
#: hook. Deliberately excludes "chart_types" itself — contributing an
|
|
#: entirely new top-level chart type would also need DashboardItem.vue to
|
|
#: know how to render it, which this hook does not attempt to solve.
|
|
CONTRIBUTABLE_CHART_TYPES = ("number_chart", "axis_chart", "donut_chart")
|
|
|
|
|
|
def get_core_chart_options():
|
|
"""
|
|
CRM's own built-in chart options, grouped by chart type. Every "value" here
|
|
must have a matching get_<value>(from_date, to_date, user) function in this
|
|
module.
|
|
"""
|
|
return {
|
|
"chart_types": [
|
|
{"label": _("Spacer"), "value": "spacer"},
|
|
{"label": _("Number Chart"), "value": "number_chart"},
|
|
{"label": _("Axis Chart"), "value": "axis_chart"},
|
|
{"label": _("Donut Chart"), "value": "donut_chart"},
|
|
],
|
|
"number_chart": [
|
|
{"label": _("Total Leads"), "value": "total_leads"},
|
|
{"label": _("Ongoing Deals"), "value": "ongoing_deals"},
|
|
{"label": _("Avg Ongoing Deal Value"), "value": "average_ongoing_deal_value"},
|
|
{"label": _("Won Deals"), "value": "won_deals"},
|
|
{"label": _("Avg Won Deal Value"), "value": "average_won_deal_value"},
|
|
{"label": _("Avg Deal Value"), "value": "average_deal_value"},
|
|
{"label": _("Avg Time to Close a Lead"), "value": "average_time_to_close_a_lead"},
|
|
{"label": _("Avg Time to Close a Deal"), "value": "average_time_to_close_a_deal"},
|
|
],
|
|
"axis_chart": [
|
|
{"label": _("Sales Trend"), "value": "sales_trend"},
|
|
{"label": _("Forecasted Revenue"), "value": "forecasted_revenue"},
|
|
{"label": _("Funnel Conversion"), "value": "funnel_conversion"},
|
|
{"label": _("Deals by Ongoing & Won Stage"), "value": "deals_by_stage_axis"},
|
|
{"label": _("Lost Deal Reasons"), "value": "lost_deal_reasons"},
|
|
{"label": _("Deals by Territory"), "value": "deals_by_territory"},
|
|
{"label": _("Deals by Salesperson"), "value": "deals_by_salesperson"},
|
|
],
|
|
"donut_chart": [
|
|
{"label": _("Deals by Stage"), "value": "deals_by_stage_donut"},
|
|
{"label": _("Leads by Source"), "value": "leads_by_source"},
|
|
{"label": _("Deals by Source"), "value": "deals_by_source"},
|
|
],
|
|
}
|
|
|
|
|
|
def get_contributed_charts():
|
|
"""
|
|
Charts contributed by other apps via the crm_dashboard_charts hook, grouped
|
|
by chart type and validated. Each app's hooks.py declares:
|
|
|
|
crm_dashboard_charts = {
|
|
"number_chart": [
|
|
{"label": "Total Calls", "value": "total_calls", "resolver": "app.api.dashboard.get_total_calls"},
|
|
],
|
|
}
|
|
|
|
Every contributing app's list is merged (not "last one wins" — see
|
|
frappe.append_hook), so multiple apps can extend the same chart type
|
|
without clobbering each other. resolver is a dotted path to a
|
|
get_<name>(from_date, to_date, user) function, and does not need to live
|
|
in this module — that's the whole point of the hook.
|
|
"""
|
|
core_values = {
|
|
option["value"]
|
|
for chart_type in CONTRIBUTABLE_CHART_TYPES
|
|
for option in get_core_chart_options()[chart_type]
|
|
}
|
|
|
|
contributed = {chart_type: [] for chart_type in CONTRIBUTABLE_CHART_TYPES}
|
|
seen_values = set(core_values)
|
|
|
|
for chart_type, options in frappe.get_hooks("crm_dashboard_charts", default={}).items():
|
|
if chart_type not in CONTRIBUTABLE_CHART_TYPES:
|
|
continue
|
|
for option in options:
|
|
value = option.get("value")
|
|
resolver = option.get("resolver")
|
|
label = option.get("label")
|
|
if not value or not resolver or not label or value in seen_values:
|
|
# Missing fields, or a core/earlier-app chart name — core (and
|
|
# whichever app registered first) always wins; never let a
|
|
# later app's contribution shadow an existing chart.
|
|
continue
|
|
seen_values.add(value)
|
|
contributed[chart_type].append({"label": label, "value": value, "resolver": resolver})
|
|
|
|
return contributed
|
|
|
|
|
|
@frappe.whitelist()
|
|
@sales_user_only
|
|
def get_chart(
|
|
name: str, type: str, from_date: str | None = None, to_date: str | None = None, user: str | None = None
|
|
):
|
|
"""
|
|
Get number chart data for the dashboard.
|
|
"""
|
|
options = get_chart_options()
|
|
valid_chart_names = {option["value"] for values in options.values() for option in values}
|
|
if name not in valid_chart_names:
|
|
return {"error": _("Invalid chart name")}
|
|
|
|
if not from_date or not to_date:
|
|
from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate())
|
|
to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate())
|
|
|
|
roles = frappe.get_roles(frappe.session.user)
|
|
is_sales_manager = "Sales Manager" in roles or "System Manager" in roles
|
|
is_sales_user = "Sales User" in roles and not is_sales_manager
|
|
|
|
if is_sales_user:
|
|
user = frappe.session.user
|
|
|
|
method_name = f"get_{name}"
|
|
if hasattr(frappe.get_attr("crm.api.dashboard"), method_name):
|
|
method = getattr(frappe.get_attr("crm.api.dashboard"), method_name)
|
|
return method(from_date, to_date, user)
|
|
|
|
contributed = get_contributed_charts()
|
|
for chart_type in CONTRIBUTABLE_CHART_TYPES:
|
|
for option in contributed[chart_type]:
|
|
if option["value"] == name:
|
|
return frappe.get_attr(option["resolver"])(from_date, to_date, user)
|
|
|
|
return {"error": _("Invalid chart name")}
|
|
|
|
|
|
@frappe.whitelist()
|
|
@sales_user_only
|
|
def get_chart_options():
|
|
"""
|
|
Return the selectable options for the "Add Chart" dialog, grouped by chart
|
|
type — CRM's own built-in charts plus anything contributed via the
|
|
crm_dashboard_charts hook.
|
|
"""
|
|
options = get_core_chart_options()
|
|
contributed = get_contributed_charts()
|
|
for chart_type in CONTRIBUTABLE_CHART_TYPES:
|
|
options[chart_type] = options[chart_type] + [
|
|
{"label": option["label"], "value": option["value"]} for option in contributed[chart_type]
|
|
]
|
|
return options
|
|
|
|
|
|
def get_total_leads(from_date: str | None = None, to_date: str | None = None, user: str | None = None):
|
|
"""
|
|
Get lead count for the dashboard.
|
|
"""
|
|
diff = frappe.utils.date_diff(to_date, from_date)
|
|
if diff == 0:
|
|
diff = 1
|
|
|
|
prev_from_date = frappe.utils.add_days(from_date, -diff)
|
|
to_date_plus_one = frappe.utils.add_days(to_date, 1)
|
|
|
|
Lead = DocType("CRM Lead")
|
|
|
|
# Build conditions for current period
|
|
current_cond = (Lead.creation >= from_date) & (Lead.creation < to_date_plus_one)
|
|
if user:
|
|
current_cond = current_cond & (Lead.lead_owner == user)
|
|
|
|
# Build conditions for previous period
|
|
prev_cond = (Lead.creation >= prev_from_date) & (Lead.creation < from_date)
|
|
if user:
|
|
prev_cond = prev_cond & (Lead.lead_owner == user)
|
|
|
|
# Build query with CASE expressions
|
|
query = frappe.qb.from_(Lead).select(
|
|
Count(Case().when(current_cond, Lead.name).else_(None)).as_("current_month_leads"),
|
|
Count(Case().when(prev_cond, Lead.name).else_(None)).as_("prev_month_leads"),
|
|
)
|
|
|
|
result = query.run(as_dict=True)
|
|
|
|
current_month_leads = result[0].current_month_leads or 0
|
|
prev_month_leads = result[0].prev_month_leads or 0
|
|
|
|
delta_in_percentage = (
|
|
(current_month_leads - prev_month_leads) / prev_month_leads * 100 if prev_month_leads else 0
|
|
)
|
|
|
|
return {
|
|
"title": _("Total leads"),
|
|
"tooltip": _("Total number of leads"),
|
|
"value": current_month_leads,
|
|
"delta": delta_in_percentage,
|
|
"deltaSuffix": "%",
|
|
}
|
|
|
|
|
|
def get_ongoing_deals(from_date: str | None = None, to_date: str | None = None, user: str | None = None):
|
|
"""
|
|
Get ongoing deal count for the dashboard, and also calculate average deal value for ongoing deals.
|
|
"""
|
|
diff = frappe.utils.date_diff(to_date, from_date)
|
|
if diff == 0:
|
|
diff = 1
|
|
|
|
prev_from_date = frappe.utils.add_days(from_date, -diff)
|
|
to_date_plus_one = frappe.utils.add_days(to_date, 1)
|
|
|
|
Deal = DocType("CRM Deal")
|
|
Status = DocType("CRM Deal Status")
|
|
|
|
# Build conditions for current period
|
|
current_cond = (
|
|
(Deal.creation >= from_date)
|
|
& (Deal.creation < to_date_plus_one)
|
|
& (Status.type.notin(["Won", "Lost"]))
|
|
)
|
|
if user:
|
|
current_cond = current_cond & (Deal.deal_owner == user)
|
|
|
|
# Build conditions for previous period
|
|
prev_cond = (
|
|
(Deal.creation >= prev_from_date) & (Deal.creation < from_date) & (Status.type.notin(["Won", "Lost"]))
|
|
)
|
|
if user:
|
|
prev_cond = prev_cond & (Deal.deal_owner == user)
|
|
|
|
# Build query with CASE expressions
|
|
query = (
|
|
frappe.qb.from_(Deal)
|
|
.join(Status)
|
|
.on(Deal.status == Status.name)
|
|
.select(
|
|
Count(Case().when(current_cond, Deal.name).else_(None)).as_("current_month_deals"),
|
|
Count(Case().when(prev_cond, Deal.name).else_(None)).as_("prev_month_deals"),
|
|
)
|
|
)
|
|
|
|
result = query.run(as_dict=True)
|
|
|
|
current_month_deals = result[0].current_month_deals or 0
|
|
prev_month_deals = result[0].prev_month_deals or 0
|
|
|
|
delta_in_percentage = (
|
|
(current_month_deals - prev_month_deals) / prev_month_deals * 100 if prev_month_deals else 0
|
|
)
|
|
|
|
return {
|
|
"title": _("Ongoing deals"),
|
|
"tooltip": _("Total number of non won/lost deals"),
|
|
"value": current_month_deals,
|
|
"delta": delta_in_percentage,
|
|
"deltaSuffix": "%",
|
|
}
|
|
|
|
|
|
def get_average_ongoing_deal_value(
|
|
from_date: str | None = None, to_date: str | None = None, user: str | None = None
|
|
):
|
|
"""
|
|
Get ongoing deal count for the dashboard, and also calculate average deal value for ongoing deals.
|
|
"""
|
|
diff = frappe.utils.date_diff(to_date, from_date)
|
|
if diff == 0:
|
|
diff = 1
|
|
|
|
prev_from_date = frappe.utils.add_days(from_date, -diff)
|
|
to_date_plus_one = frappe.utils.add_days(to_date, 1)
|
|
|
|
Deal = DocType("CRM Deal")
|
|
Status = DocType("CRM Deal Status")
|
|
|
|
# Build conditions for current period
|
|
current_cond = (
|
|
(Deal.creation >= from_date)
|
|
& (Deal.creation < to_date_plus_one)
|
|
& (Status.type.notin(["Won", "Lost"]))
|
|
)
|
|
if user:
|
|
current_cond = current_cond & (Deal.deal_owner == user)
|
|
|
|
# Build conditions for previous period
|
|
prev_cond = (
|
|
(Deal.creation >= prev_from_date) & (Deal.creation < from_date) & (Status.type.notin(["Won", "Lost"]))
|
|
)
|
|
if user:
|
|
prev_cond = prev_cond & (Deal.deal_owner == user)
|
|
|
|
# Calculate deal value with exchange rate
|
|
deal_value_expr = Deal.deal_value * IfNull(Deal.exchange_rate, 1)
|
|
|
|
# Build query with CASE expressions
|
|
query = (
|
|
frappe.qb.from_(Deal)
|
|
.join(Status)
|
|
.on(Deal.status == Status.name)
|
|
.select(
|
|
Avg(Case().when(current_cond, deal_value_expr).else_(None)).as_("current_month_avg_value"),
|
|
Avg(Case().when(prev_cond, deal_value_expr).else_(None)).as_("prev_month_avg_value"),
|
|
)
|
|
)
|
|
|
|
result = query.run(as_dict=True)
|
|
|
|
current_month_avg_value = result[0].current_month_avg_value or 0
|
|
prev_month_avg_value = result[0].prev_month_avg_value or 0
|
|
|
|
avg_value_delta = current_month_avg_value - prev_month_avg_value if prev_month_avg_value else 0
|
|
|
|
return {
|
|
"title": _("Avg. ongoing deal value"),
|
|
"tooltip": _("Average deal value of non won/lost deals"),
|
|
"value": current_month_avg_value,
|
|
"delta": avg_value_delta,
|
|
"prefix": get_base_currency_symbol(),
|
|
}
|
|
|
|
|
|
def get_won_deals(from_date: str | None = None, to_date: str | None = None, user: str | None = None):
|
|
"""
|
|
Get won deal count for the dashboard, and also calculate average deal value for won deals.
|
|
"""
|
|
diff = frappe.utils.date_diff(to_date, from_date)
|
|
if diff == 0:
|
|
diff = 1
|
|
|
|
prev_from_date = frappe.utils.add_days(from_date, -diff)
|
|
to_date_plus_one = frappe.utils.add_days(to_date, 1)
|
|
|
|
Deal = DocType("CRM Deal")
|
|
Status = DocType("CRM Deal Status")
|
|
|
|
# Build conditions for current period
|
|
current_cond = (
|
|
(Deal.closed_date >= from_date) & (Deal.closed_date < to_date_plus_one) & (Status.type == "Won")
|
|
)
|
|
if user:
|
|
current_cond = current_cond & (Deal.deal_owner == user)
|
|
|
|
# Build conditions for previous period
|
|
prev_cond = (Deal.closed_date >= prev_from_date) & (Deal.closed_date < from_date) & (Status.type == "Won")
|
|
if user:
|
|
prev_cond = prev_cond & (Deal.deal_owner == user)
|
|
|
|
# Build query with CASE expressions
|
|
query = (
|
|
frappe.qb.from_(Deal)
|
|
.join(Status)
|
|
.on(Deal.status == Status.name)
|
|
.select(
|
|
Count(Case().when(current_cond, Deal.name).else_(None)).as_("current_month_deals"),
|
|
Count(Case().when(prev_cond, Deal.name).else_(None)).as_("prev_month_deals"),
|
|
)
|
|
)
|
|
|
|
result = query.run(as_dict=True)
|
|
|
|
current_month_deals = result[0].current_month_deals or 0
|
|
prev_month_deals = result[0].prev_month_deals or 0
|
|
|
|
delta_in_percentage = (
|
|
(current_month_deals - prev_month_deals) / prev_month_deals * 100 if prev_month_deals else 0
|
|
)
|
|
|
|
return {
|
|
"title": _("Won deals"),
|
|
"tooltip": _("Total number of won deals based on its closure date"),
|
|
"value": current_month_deals,
|
|
"delta": delta_in_percentage,
|
|
"deltaSuffix": "%",
|
|
}
|
|
|
|
|
|
def get_average_won_deal_value(
|
|
from_date: str | None = None, to_date: str | None = None, user: str | None = None
|
|
):
|
|
"""
|
|
Get won deal count for the dashboard, and also calculate average deal value for won deals.
|
|
"""
|
|
diff = frappe.utils.date_diff(to_date, from_date)
|
|
if diff == 0:
|
|
diff = 1
|
|
|
|
prev_from_date = frappe.utils.add_days(from_date, -diff)
|
|
to_date_plus_one = frappe.utils.add_days(to_date, 1)
|
|
|
|
Deal = DocType("CRM Deal")
|
|
Status = DocType("CRM Deal Status")
|
|
|
|
# Build conditions for current period
|
|
current_cond = (
|
|
(Deal.closed_date >= from_date) & (Deal.closed_date < to_date_plus_one) & (Status.type == "Won")
|
|
)
|
|
if user:
|
|
current_cond = current_cond & (Deal.deal_owner == user)
|
|
|
|
# Build conditions for previous period
|
|
prev_cond = (Deal.closed_date >= prev_from_date) & (Deal.closed_date < from_date) & (Status.type == "Won")
|
|
if user:
|
|
prev_cond = prev_cond & (Deal.deal_owner == user)
|
|
|
|
# Calculate deal value with exchange rate
|
|
deal_value_expr = Deal.deal_value * IfNull(Deal.exchange_rate, 1)
|
|
|
|
# Build query with CASE expressions
|
|
query = (
|
|
frappe.qb.from_(Deal)
|
|
.join(Status)
|
|
.on(Deal.status == Status.name)
|
|
.select(
|
|
Avg(Case().when(current_cond, deal_value_expr).else_(None)).as_("current_month_avg_value"),
|
|
Avg(Case().when(prev_cond, deal_value_expr).else_(None)).as_("prev_month_avg_value"),
|
|
)
|
|
)
|
|
|
|
result = query.run(as_dict=True)
|
|
|
|
current_month_avg_value = result[0].current_month_avg_value or 0
|
|
prev_month_avg_value = result[0].prev_month_avg_value or 0
|
|
|
|
avg_value_delta = current_month_avg_value - prev_month_avg_value if prev_month_avg_value else 0
|
|
|
|
return {
|
|
"title": _("Avg. won deal value"),
|
|
"tooltip": _("Average deal value of won deals"),
|
|
"value": current_month_avg_value,
|
|
"delta": avg_value_delta,
|
|
"prefix": get_base_currency_symbol(),
|
|
}
|
|
|
|
|
|
def get_average_deal_value(from_date: str | None = None, to_date: str | None = None, user: str | None = None):
|
|
"""
|
|
Get average deal value for the dashboard.
|
|
"""
|
|
diff = frappe.utils.date_diff(to_date, from_date)
|
|
if diff == 0:
|
|
diff = 1
|
|
|
|
prev_from_date = frappe.utils.add_days(from_date, -diff)
|
|
to_date_plus_one = frappe.utils.add_days(to_date, 1)
|
|
|
|
Deal = DocType("CRM Deal")
|
|
Status = DocType("CRM Deal Status")
|
|
|
|
# Build conditions for current period
|
|
current_cond = (Deal.creation >= from_date) & (Deal.creation < to_date_plus_one) & (Status.type != "Lost")
|
|
if user:
|
|
current_cond = current_cond & (Deal.deal_owner == user)
|
|
|
|
# Build conditions for previous period
|
|
prev_cond = (Deal.creation >= prev_from_date) & (Deal.creation < from_date) & (Status.type != "Lost")
|
|
if user:
|
|
prev_cond = prev_cond & (Deal.deal_owner == user)
|
|
|
|
# Calculate deal value with exchange rate
|
|
deal_value_expr = Deal.deal_value * IfNull(Deal.exchange_rate, 1)
|
|
|
|
# Build query with CASE expressions
|
|
query = (
|
|
frappe.qb.from_(Deal)
|
|
.join(Status)
|
|
.on(Deal.status == Status.name)
|
|
.select(
|
|
Avg(Case().when(current_cond, deal_value_expr).else_(None)).as_("current_month_avg"),
|
|
Avg(Case().when(prev_cond, deal_value_expr).else_(None)).as_("prev_month_avg"),
|
|
)
|
|
)
|
|
|
|
result = query.run(as_dict=True)
|
|
|
|
current_month_avg = result[0].current_month_avg or 0
|
|
prev_month_avg = result[0].prev_month_avg or 0
|
|
|
|
delta = current_month_avg - prev_month_avg if prev_month_avg else 0
|
|
|
|
return {
|
|
"title": _("Avg. deal value"),
|
|
"tooltip": _("Average deal value of ongoing & won deals"),
|
|
"value": current_month_avg,
|
|
"prefix": get_base_currency_symbol(),
|
|
"delta": delta,
|
|
"deltaSuffix": "%",
|
|
}
|
|
|
|
|
|
def get_average_time_to_close_a_lead(
|
|
from_date: str | None = None, to_date: str | None = None, user: str | None = None
|
|
):
|
|
"""
|
|
Get average time to close deals for the dashboard.
|
|
"""
|
|
diff = frappe.utils.date_diff(to_date, from_date)
|
|
if diff == 0:
|
|
diff = 1
|
|
|
|
prev_from_date = frappe.utils.add_days(from_date, -diff)
|
|
to_date_plus_one = frappe.utils.add_days(to_date, 1)
|
|
prev_to_date = from_date
|
|
|
|
Deal = DocType("CRM Deal")
|
|
Status = DocType("CRM Deal Status")
|
|
Lead = DocType("CRM Lead")
|
|
|
|
# Base condition: closed_date is not null and status type is Won
|
|
base_cond = (Deal.closed_date.isnotnull()) & (Status.type == "Won")
|
|
if user:
|
|
base_cond = base_cond & (Deal.deal_owner == user)
|
|
|
|
# Current period condition
|
|
current_cond = (Deal.closed_date >= from_date) & (Deal.closed_date < to_date_plus_one)
|
|
|
|
# Previous period condition
|
|
prev_cond = (Deal.closed_date >= prev_from_date) & (Deal.closed_date < prev_to_date)
|
|
|
|
# Calculate time difference from lead/deal creation to deal closure
|
|
time_diff = TimestampDiff(
|
|
frappe.qb.terms.LiteralValue("DAY"), Coalesce(Lead.creation, Deal.creation), Deal.closed_date
|
|
)
|
|
|
|
# Build query
|
|
query = (
|
|
frappe.qb.from_(Deal)
|
|
.join(Status)
|
|
.on(Deal.status == Status.name)
|
|
.left_join(Lead)
|
|
.on(Deal.lead == Lead.name)
|
|
.where(base_cond)
|
|
.select(
|
|
Avg(Case().when(current_cond, time_diff).else_(None)).as_("current_avg_lead"),
|
|
Avg(Case().when(prev_cond, time_diff).else_(None)).as_("prev_avg_lead"),
|
|
)
|
|
)
|
|
|
|
result = query.run(as_dict=True)
|
|
|
|
current_avg_lead = result[0].current_avg_lead or 0
|
|
prev_avg_lead = result[0].prev_avg_lead or 0
|
|
delta_lead = current_avg_lead - prev_avg_lead if prev_avg_lead else 0
|
|
|
|
return {
|
|
"title": _("Avg. time to close a lead"),
|
|
"tooltip": _("Average time taken from lead creation to deal closure"),
|
|
"value": current_avg_lead,
|
|
"suffix": " days",
|
|
"delta": delta_lead,
|
|
"deltaSuffix": " days",
|
|
"negativeIsBetter": True,
|
|
}
|
|
|
|
|
|
def get_average_time_to_close_a_deal(
|
|
from_date: str | None = None, to_date: str | None = None, user: str | None = None
|
|
):
|
|
"""
|
|
Get average time to close deals for the dashboard.
|
|
"""
|
|
diff = frappe.utils.date_diff(to_date, from_date)
|
|
if diff == 0:
|
|
diff = 1
|
|
|
|
prev_from_date = frappe.utils.add_days(from_date, -diff)
|
|
to_date_plus_one = frappe.utils.add_days(to_date, 1)
|
|
prev_to_date = from_date
|
|
|
|
Deal = DocType("CRM Deal")
|
|
Status = DocType("CRM Deal Status")
|
|
Lead = DocType("CRM Lead")
|
|
|
|
# Base condition: closed_date is not null and status type is Won
|
|
base_cond = (Deal.closed_date.isnotnull()) & (Status.type == "Won")
|
|
if user:
|
|
base_cond = base_cond & (Deal.deal_owner == user)
|
|
|
|
# Current period condition
|
|
current_cond = (Deal.closed_date >= from_date) & (Deal.closed_date < to_date_plus_one)
|
|
|
|
# Previous period condition
|
|
prev_cond = (Deal.closed_date >= prev_from_date) & (Deal.closed_date < prev_to_date)
|
|
|
|
# Calculate time difference from deal creation to deal closure
|
|
time_diff = TimestampDiff(frappe.qb.terms.LiteralValue("DAY"), Deal.creation, Deal.closed_date)
|
|
|
|
# Build query
|
|
query = (
|
|
frappe.qb.from_(Deal)
|
|
.join(Status)
|
|
.on(Deal.status == Status.name)
|
|
.left_join(Lead)
|
|
.on(Deal.lead == Lead.name)
|
|
.where(base_cond)
|
|
.select(
|
|
Avg(Case().when(current_cond, time_diff).else_(None)).as_("current_avg_deal"),
|
|
Avg(Case().when(prev_cond, time_diff).else_(None)).as_("prev_avg_deal"),
|
|
)
|
|
)
|
|
|
|
result = query.run(as_dict=True)
|
|
|
|
current_avg_deal = result[0].current_avg_deal or 0
|
|
prev_avg_deal = result[0].prev_avg_deal or 0
|
|
delta_deal = current_avg_deal - prev_avg_deal if prev_avg_deal else 0
|
|
|
|
return {
|
|
"title": _("Avg. time to close a deal"),
|
|
"tooltip": _("Average time taken from deal creation to deal closure"),
|
|
"value": current_avg_deal,
|
|
"suffix": " days",
|
|
"delta": delta_deal,
|
|
"deltaSuffix": " days",
|
|
"negativeIsBetter": True,
|
|
}
|
|
|
|
|
|
def get_sales_trend(from_date: str | None = None, to_date: str | None = None, user: str | None = None):
|
|
"""
|
|
Get sales trend data for the dashboard.
|
|
[
|
|
{ date: new Date('2024-05-01'), leads: 45, deals: 23, won_deals: 12 },
|
|
{ date: new Date('2024-05-02'), leads: 50, deals: 30, won_deals: 15 },
|
|
...
|
|
]
|
|
"""
|
|
if not from_date or not to_date:
|
|
from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate())
|
|
to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate())
|
|
|
|
Lead = DocType("CRM Lead")
|
|
Deal = DocType("CRM Deal")
|
|
Status = DocType("CRM Deal Status")
|
|
|
|
# Build leads query
|
|
leads_query = (
|
|
frappe.qb.from_(Lead)
|
|
.select(
|
|
Date(Lead.creation).as_("date"),
|
|
Count("*").as_("leads"),
|
|
frappe.qb.terms.ValueWrapper(0).as_("deals"),
|
|
frappe.qb.terms.ValueWrapper(0).as_("won_deals"),
|
|
)
|
|
.where(Date(Lead.creation).between(from_date, to_date))
|
|
)
|
|
|
|
if user:
|
|
leads_query = leads_query.where(Lead.lead_owner == user)
|
|
|
|
leads_query = leads_query.groupby(Date(Lead.creation))
|
|
|
|
# Build deals query
|
|
deals_query = (
|
|
frappe.qb.from_(Deal)
|
|
.join(Status)
|
|
.on(Deal.status == Status.name)
|
|
.select(
|
|
Date(Deal.creation).as_("date"),
|
|
frappe.qb.terms.ValueWrapper(0).as_("leads"),
|
|
Count("*").as_("deals"),
|
|
Sum(Case().when(Status.type == "Won", 1).else_(0)).as_("won_deals"),
|
|
)
|
|
.where(Date(Deal.creation).between(from_date, to_date))
|
|
)
|
|
|
|
if user:
|
|
deals_query = deals_query.where(Deal.deal_owner == user)
|
|
|
|
deals_query = deals_query.groupby(Date(Deal.creation))
|
|
|
|
# Combine with UNION ALL and aggregate by date
|
|
union_query = leads_query.union_all(deals_query)
|
|
|
|
# Wrap in outer query to aggregate by date
|
|
daily = (
|
|
frappe.qb.from_(union_query)
|
|
.select(
|
|
DateFormat(union_query.date, "%Y-%m-%d").as_("date"),
|
|
Sum(union_query.leads).as_("leads"),
|
|
Sum(union_query.deals).as_("deals"),
|
|
Sum(union_query.won_deals).as_("won_deals"),
|
|
)
|
|
.groupby(union_query.date)
|
|
.orderby(union_query.date)
|
|
)
|
|
|
|
result = daily.run(as_dict=True)
|
|
|
|
sales_trend = [
|
|
{
|
|
"date": frappe.utils.get_datetime(row.date).strftime("%Y-%m-%d"),
|
|
"leads": row.leads or 0,
|
|
"deals": row.deals or 0,
|
|
"won_deals": row.won_deals or 0,
|
|
}
|
|
for row in result
|
|
]
|
|
|
|
return {
|
|
"data": sales_trend,
|
|
"title": _("Sales trend"),
|
|
"subtitle": _("Daily performance of leads, deals, and wins"),
|
|
"xAxis": {
|
|
"title": _("Date"),
|
|
"key": "date",
|
|
"type": "time",
|
|
"timeGrain": "day",
|
|
},
|
|
"yAxis": {
|
|
"title": _("Count"),
|
|
},
|
|
"series": [
|
|
{"name": "leads", "type": "line", "showDataPoints": True},
|
|
{"name": "deals", "type": "line", "showDataPoints": True},
|
|
{"name": "won_deals", "type": "line", "showDataPoints": True},
|
|
],
|
|
}
|
|
|
|
|
|
def get_forecasted_revenue(from_date: str | None = None, to_date: str | None = None, user: str | None = None):
|
|
"""
|
|
Get forecasted revenue for the dashboard.
|
|
[
|
|
{ date: new Date('2024-05-01'), forecasted: 1200000, actual: 980000 },
|
|
{ date: new Date('2024-06-01'), forecasted: 1350000, actual: 1120000 },
|
|
{ date: new Date('2024-07-01'), forecasted: 1600000, actual: "" },
|
|
{ date: new Date('2024-08-01'), forecasted: 1500000, actual: "" },
|
|
...
|
|
]
|
|
"""
|
|
# Using Frappe Query Builder with CASE expressions
|
|
CRMDeal = DocType("CRM Deal")
|
|
CRMDealStatus = DocType("CRM Deal Status")
|
|
|
|
# Calculate the date 12 months ago
|
|
twelve_months_ago = frappe.utils.add_months(frappe.utils.nowdate(), -12)
|
|
|
|
forecasted_value = (
|
|
Case()
|
|
.when(CRMDealStatus.type == "Lost", CRMDeal.expected_deal_value * IfNull(CRMDeal.exchange_rate, 1))
|
|
.else_(
|
|
CRMDeal.expected_deal_value
|
|
* IfNull(CRMDeal.probability, 0)
|
|
/ 100
|
|
* IfNull(CRMDeal.exchange_rate, 1)
|
|
)
|
|
)
|
|
|
|
actual_value = (
|
|
Case()
|
|
.when(CRMDealStatus.type == "Won", CRMDeal.deal_value * IfNull(CRMDeal.exchange_rate, 1))
|
|
.else_(0)
|
|
)
|
|
|
|
query = (
|
|
frappe.qb.from_(CRMDeal)
|
|
.join(CRMDealStatus)
|
|
.on(CRMDeal.status == CRMDealStatus.name)
|
|
.select(
|
|
DateFormat(CRMDeal.expected_closure_date, "%Y-%m").as_("month"),
|
|
Sum(forecasted_value).as_("forecasted"),
|
|
Sum(actual_value).as_("actual"),
|
|
)
|
|
.where(CRMDeal.expected_closure_date >= twelve_months_ago)
|
|
.groupby(DateFormat(CRMDeal.expected_closure_date, "%Y-%m"))
|
|
.orderby(DateFormat(CRMDeal.expected_closure_date, "%Y-%m"))
|
|
)
|
|
|
|
if user:
|
|
query = query.where(CRMDeal.deal_owner == user)
|
|
|
|
result = query.run(as_dict=True)
|
|
|
|
for row in result:
|
|
row["month"] = frappe.utils.get_datetime(row["month"]).strftime("%Y-%m-01")
|
|
row["forecasted"] = row["forecasted"] or ""
|
|
row["actual"] = row["actual"] or ""
|
|
|
|
return {
|
|
"data": result or [],
|
|
"title": _("Forecasted revenue"),
|
|
"subtitle": _("Projected vs actual revenue based on deal probability"),
|
|
"xAxis": {
|
|
"title": _("Month"),
|
|
"key": "month",
|
|
"type": "time",
|
|
"timeGrain": "month",
|
|
},
|
|
"yAxis": {
|
|
"title": _("Revenue") + f" ({get_base_currency_symbol()})",
|
|
},
|
|
"series": [
|
|
{"name": "forecasted", "type": "line", "showDataPoints": True},
|
|
{"name": "actual", "type": "line", "showDataPoints": True},
|
|
],
|
|
}
|
|
|
|
|
|
def get_funnel_conversion(from_date: str | None = None, to_date: str | None = None, user: str | None = None):
|
|
"""
|
|
Get funnel conversion data for the dashboard.
|
|
[
|
|
{ stage: 'Leads', count: 120 },
|
|
{ stage: 'Qualification', count: 100 },
|
|
{ stage: 'Negotiation', count: 80 },
|
|
{ stage: 'Ready to Close', count: 60 },
|
|
{ stage: 'Won', count: 30 },
|
|
...
|
|
]
|
|
"""
|
|
lead_conds = ""
|
|
deal_conds = ""
|
|
|
|
if not from_date or not to_date:
|
|
from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate())
|
|
to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate())
|
|
|
|
lead_filters = {"from": from_date, "to": to_date}
|
|
deal_filters = {"from": from_date, "to": to_date}
|
|
|
|
if user:
|
|
lead_conds += " AND lead_owner = %(user)s"
|
|
deal_conds += " AND deal_owner = %(user)s"
|
|
lead_filters["user"] = user
|
|
deal_filters["user"] = user
|
|
|
|
result = []
|
|
|
|
# Get total leads using Query Builder
|
|
CRMLead = DocType("CRM Lead")
|
|
|
|
query = (
|
|
frappe.qb.from_(CRMLead)
|
|
.select(Count("*").as_("count"))
|
|
.where(Date(CRMLead.creation).between(from_date, to_date))
|
|
)
|
|
|
|
if user:
|
|
query = query.where(CRMLead.lead_owner == user)
|
|
|
|
total_leads = query.run(as_dict=True)
|
|
total_leads_count = total_leads[0].count if total_leads else 0
|
|
|
|
result.append({"stage": "Leads", "count": total_leads_count})
|
|
|
|
result += get_deal_status_change_counts(from_date, to_date, deal_conds, deal_filters)
|
|
|
|
return {
|
|
"data": result or [],
|
|
"title": _("Funnel conversion"),
|
|
"subtitle": _("Lead to deal conversion pipeline"),
|
|
"xAxis": {
|
|
"title": _("Stage"),
|
|
"key": "stage",
|
|
"type": "category",
|
|
},
|
|
"yAxis": {
|
|
"title": _("Count"),
|
|
},
|
|
"swapXY": True,
|
|
"series": [
|
|
{
|
|
"name": "count",
|
|
"type": "bar",
|
|
"echartOptions": {
|
|
"colorBy": "data",
|
|
},
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
def get_deals_by_stage_axis(
|
|
from_date: str | None = None, to_date: str | None = None, user: str | None = None
|
|
):
|
|
"""
|
|
Get deal data by stage for the dashboard.
|
|
[
|
|
{ stage: 'Prospecting', count: 120 },
|
|
{ stage: 'Negotiation', count: 45 },
|
|
...
|
|
]
|
|
"""
|
|
if not from_date or not to_date:
|
|
from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate())
|
|
to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate())
|
|
|
|
# Using Frappe Query Builder with NOT IN clause
|
|
CRMDeal = DocType("CRM Deal")
|
|
CRMDealStatus = DocType("CRM Deal Status")
|
|
|
|
query = (
|
|
frappe.qb.from_(CRMDeal)
|
|
.join(CRMDealStatus)
|
|
.on(CRMDeal.status == CRMDealStatus.name)
|
|
.select(CRMDeal.status.as_("stage"), Count("*").as_("count"), CRMDealStatus.type.as_("status_type"))
|
|
.where((Date(CRMDeal.creation).between(from_date, to_date)) & (CRMDealStatus.type.notin(["Lost"])))
|
|
.groupby(CRMDeal.status)
|
|
.orderby(Count("*"), order=frappe.qb.desc)
|
|
)
|
|
|
|
if user:
|
|
query = query.where(CRMDeal.deal_owner == user)
|
|
|
|
result = query.run(as_dict=True)
|
|
|
|
return {
|
|
"data": result or [],
|
|
"title": _("Deals by ongoing & won stage"),
|
|
"xAxis": {
|
|
"title": _("Stage"),
|
|
"key": "stage",
|
|
"type": "category",
|
|
},
|
|
"yAxis": {"title": _("Count")},
|
|
"series": [
|
|
{"name": "count", "type": "bar"},
|
|
],
|
|
}
|
|
|
|
|
|
def get_deals_by_stage_donut(
|
|
from_date: str | None = None, to_date: str | None = None, user: str | None = None
|
|
):
|
|
"""
|
|
Get deal data by stage for the dashboard.
|
|
[
|
|
{ stage: 'Prospecting', count: 120 },
|
|
{ stage: 'Negotiation', count: 45 },
|
|
...
|
|
]
|
|
"""
|
|
if not from_date or not to_date:
|
|
from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate())
|
|
to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate())
|
|
|
|
# Using Frappe Query Builder with JOIN
|
|
CRMDeal = DocType("CRM Deal")
|
|
CRMDealStatus = DocType("CRM Deal Status")
|
|
|
|
query = (
|
|
frappe.qb.from_(CRMDeal)
|
|
.join(CRMDealStatus)
|
|
.on(CRMDeal.status == CRMDealStatus.name)
|
|
.select(CRMDeal.status.as_("stage"), Count("*").as_("count"), CRMDealStatus.type.as_("status_type"))
|
|
.where(Date(CRMDeal.creation).between(from_date, to_date))
|
|
.groupby(CRMDeal.status)
|
|
.orderby(Count("*"), order=frappe.qb.desc)
|
|
)
|
|
|
|
if user:
|
|
query = query.where(CRMDeal.deal_owner == user)
|
|
|
|
result = query.run(as_dict=True)
|
|
|
|
return {
|
|
"data": result or [],
|
|
"title": _("Deals by stage"),
|
|
"subtitle": _("Current pipeline distribution"),
|
|
"categoryColumn": "stage",
|
|
"valueColumn": "count",
|
|
}
|
|
|
|
|
|
def get_lost_deal_reasons(from_date: str | None = None, to_date: str | None = None, user: str | None = None):
|
|
"""
|
|
Get lost deal reasons for the dashboard.
|
|
[
|
|
{ reason: 'Price too high', count: 20 },
|
|
{ reason: 'Competitor won', count: 15 },
|
|
...
|
|
]
|
|
"""
|
|
if not from_date or not to_date:
|
|
from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate())
|
|
to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate())
|
|
|
|
# Using Frappe Query Builder with JOIN
|
|
CRMDeal = DocType("CRM Deal")
|
|
CRMDealStatus = DocType("CRM Deal Status")
|
|
|
|
query = (
|
|
frappe.qb.from_(CRMDeal)
|
|
.join(CRMDealStatus)
|
|
.on(CRMDeal.status == CRMDealStatus.name)
|
|
.select(CRMDeal.lost_reason.as_("reason"), Count("*").as_("count"))
|
|
.where((Date(CRMDeal.creation).between(from_date, to_date)) & (CRMDealStatus.type == "Lost"))
|
|
.groupby(CRMDeal.lost_reason)
|
|
.having((CRMDeal.lost_reason.isnotnull()) & (CRMDeal.lost_reason != ""))
|
|
.orderby(Count("*"), order=frappe.qb.desc)
|
|
)
|
|
|
|
if user:
|
|
query = query.where(CRMDeal.deal_owner == user)
|
|
|
|
result = query.run(as_dict=True)
|
|
|
|
return {
|
|
"data": result or [],
|
|
"title": _("Lost deal reasons"),
|
|
"subtitle": _("Common reasons for losing deals"),
|
|
"xAxis": {
|
|
"title": _("Reason"),
|
|
"key": "reason",
|
|
"type": "category",
|
|
},
|
|
"yAxis": {
|
|
"title": _("Count"),
|
|
},
|
|
"series": [
|
|
{"name": "count", "type": "bar"},
|
|
],
|
|
}
|
|
|
|
|
|
def get_leads_by_source(from_date: str | None = None, to_date: str | None = None, user: str | None = None):
|
|
"""
|
|
Get lead data by source for the dashboard.
|
|
[
|
|
{ source: 'Website', count: 120 },
|
|
{ source: 'Referral', count: 45 },
|
|
...
|
|
]
|
|
"""
|
|
if not from_date or not to_date:
|
|
from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate())
|
|
to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate())
|
|
|
|
# Using Frappe Query Builder (safer, more maintainable)
|
|
CRMLead = DocType("CRM Lead")
|
|
|
|
query = (
|
|
frappe.qb.from_(CRMLead)
|
|
.select(IfNull(CRMLead.source, "Empty").as_("source"), Count("*").as_("count"))
|
|
.where(Date(CRMLead.creation).between(from_date, to_date))
|
|
.groupby(CRMLead.source)
|
|
.orderby(Count("*"), order=frappe.qb.desc)
|
|
)
|
|
|
|
if user:
|
|
query = query.where(CRMLead.lead_owner == user)
|
|
|
|
result = query.run(as_dict=True)
|
|
|
|
return {
|
|
"data": result or [],
|
|
"title": _("Leads by source"),
|
|
"subtitle": _("Lead generation channel analysis"),
|
|
"categoryColumn": "source",
|
|
"valueColumn": "count",
|
|
}
|
|
|
|
|
|
def get_deals_by_source(from_date: str | None = None, to_date: str | None = None, user: str | None = None):
|
|
"""
|
|
Get deal data by source for the dashboard.
|
|
[
|
|
{ source: 'Website', count: 120 },
|
|
{ source: 'Referral', count: 45 },
|
|
...
|
|
]
|
|
"""
|
|
if not from_date or not to_date:
|
|
from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate())
|
|
to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate())
|
|
|
|
# Using Frappe Query Builder
|
|
CRMDeal = DocType("CRM Deal")
|
|
|
|
query = (
|
|
frappe.qb.from_(CRMDeal)
|
|
.select(IfNull(CRMDeal.source, "Empty").as_("source"), Count("*").as_("count"))
|
|
.where(Date(CRMDeal.creation).between(from_date, to_date))
|
|
.groupby(CRMDeal.source)
|
|
.orderby(Count("*"), order=frappe.qb.desc)
|
|
)
|
|
|
|
if user:
|
|
query = query.where(CRMDeal.deal_owner == user)
|
|
|
|
result = query.run(as_dict=True)
|
|
|
|
return {
|
|
"data": result or [],
|
|
"title": _("Deals by source"),
|
|
"subtitle": _("Deal generation channel analysis"),
|
|
"categoryColumn": "source",
|
|
"valueColumn": "count",
|
|
}
|
|
|
|
|
|
def get_deals_by_territory(from_date: str | None = None, to_date: str | None = None, user: str | None = None):
|
|
"""
|
|
Get deal data by territory for the dashboard.
|
|
[
|
|
{ territory: 'North America', deals: 45, value: 2300000 },
|
|
{ territory: 'Europe', deals: 30, value: 1500000 },
|
|
...
|
|
]
|
|
"""
|
|
if not from_date or not to_date:
|
|
from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate())
|
|
to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate())
|
|
|
|
# Using Frappe Query Builder with complex aggregations
|
|
CRMDeal = DocType("CRM Deal")
|
|
|
|
query = (
|
|
frappe.qb.from_(CRMDeal)
|
|
.select(
|
|
IfNull(CRMDeal.territory, "Empty").as_("territory"),
|
|
Count("*").as_("deals"),
|
|
Sum(Coalesce(CRMDeal.deal_value, 0) * IfNull(CRMDeal.exchange_rate, 1)).as_("value"),
|
|
)
|
|
.where(Date(CRMDeal.creation).between(from_date, to_date))
|
|
.groupby(CRMDeal.territory)
|
|
.orderby(Count("*"), order=frappe.qb.desc)
|
|
.orderby(
|
|
Sum(Coalesce(CRMDeal.deal_value, 0) * IfNull(CRMDeal.exchange_rate, 1)), order=frappe.qb.desc
|
|
)
|
|
)
|
|
|
|
if user:
|
|
query = query.where(CRMDeal.deal_owner == user)
|
|
|
|
result = query.run(as_dict=True)
|
|
|
|
return {
|
|
"data": result or [],
|
|
"title": _("Deals by territory"),
|
|
"subtitle": _("Geographic distribution of deals and revenue"),
|
|
"xAxis": {
|
|
"title": _("Territory"),
|
|
"key": "territory",
|
|
"type": "category",
|
|
},
|
|
"yAxis": {
|
|
"title": _("Number of deals"),
|
|
},
|
|
"y2Axis": {
|
|
"title": _("Deal value") + f" ({get_base_currency_symbol()})",
|
|
},
|
|
"series": [
|
|
{"name": "deals", "type": "bar"},
|
|
{"name": "value", "type": "line", "showDataPoints": True, "axis": "y2"},
|
|
],
|
|
}
|
|
|
|
|
|
def get_deals_by_salesperson(
|
|
from_date: str | None = None, to_date: str | None = None, user: str | None = None
|
|
):
|
|
"""
|
|
Get deal data by salesperson for the dashboard.
|
|
[
|
|
{ salesperson: 'John Smith', deals: 45, value: 2300000 },
|
|
{ salesperson: 'Jane Doe', deals: 30, value: 1500000 },
|
|
...
|
|
]
|
|
"""
|
|
if not from_date or not to_date:
|
|
from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate())
|
|
to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate())
|
|
|
|
# Using Frappe Query Builder with LEFT JOIN
|
|
CRMDeal = DocType("CRM Deal")
|
|
User = DocType("User")
|
|
|
|
query = (
|
|
frappe.qb.from_(CRMDeal)
|
|
.left_join(User)
|
|
.on(User.name == CRMDeal.deal_owner)
|
|
.select(
|
|
IfNull(User.full_name, CRMDeal.deal_owner).as_("salesperson"),
|
|
Count("*").as_("deals"),
|
|
Sum(Coalesce(CRMDeal.deal_value, 0) * IfNull(CRMDeal.exchange_rate, 1)).as_("value"),
|
|
)
|
|
.where(Date(CRMDeal.creation).between(from_date, to_date))
|
|
.groupby(CRMDeal.deal_owner)
|
|
.orderby(Count("*"), order=frappe.qb.desc)
|
|
.orderby(
|
|
Sum(Coalesce(CRMDeal.deal_value, 0) * IfNull(CRMDeal.exchange_rate, 1)), order=frappe.qb.desc
|
|
)
|
|
)
|
|
|
|
if user:
|
|
query = query.where(CRMDeal.deal_owner == user)
|
|
|
|
result = query.run(as_dict=True)
|
|
|
|
return {
|
|
"data": result or [],
|
|
"title": _("Deals by salesperson"),
|
|
"subtitle": _("Number of deals and total value per salesperson"),
|
|
"xAxis": {
|
|
"title": _("Salesperson"),
|
|
"key": "salesperson",
|
|
"type": "category",
|
|
},
|
|
"yAxis": {
|
|
"title": _("Number of deals"),
|
|
},
|
|
"y2Axis": {
|
|
"title": _("Deal value") + f" ({get_base_currency_symbol()})",
|
|
},
|
|
"series": [
|
|
{"name": "deals", "type": "bar"},
|
|
{"name": "value", "type": "line", "showDataPoints": True, "axis": "y2"},
|
|
],
|
|
}
|
|
|
|
|
|
def get_base_currency_symbol():
|
|
"""
|
|
Get the base currency symbol from the system settings.
|
|
"""
|
|
base_currency = frappe.db.get_single_value("FCRM Settings", "currency") or "USD"
|
|
return frappe.db.get_value("Currency", base_currency, "symbol") or ""
|
|
|
|
|
|
def get_deal_status_change_counts(
|
|
from_date: str | None = None,
|
|
to_date: str | None = None,
|
|
deal_conds: str = "",
|
|
filters: dict | None = None,
|
|
):
|
|
"""
|
|
Get count of each status change (to) for each deal, excluding deals with current status type 'Lost'.
|
|
Order results by status position.
|
|
Returns:
|
|
[
|
|
{"status": "Qualification", "count": 120},
|
|
{"status": "Negotiation", "count": 85},
|
|
...
|
|
]
|
|
"""
|
|
# Using Frappe Query Builder with multiple JOINs and table aliases
|
|
CRMStatusChangeLog = DocType("CRM Status Change Log")
|
|
CRMDeal = DocType("CRM Deal")
|
|
CurrentStatus = DocType("CRM Deal Status").as_("s")
|
|
TargetStatus = DocType("CRM Deal Status").as_("st")
|
|
|
|
query = (
|
|
frappe.qb.from_(CRMStatusChangeLog)
|
|
.join(CRMDeal)
|
|
.on(CRMStatusChangeLog.parent == CRMDeal.name)
|
|
.join(CurrentStatus)
|
|
.on(CRMDeal.status == CurrentStatus.name)
|
|
.join(TargetStatus)
|
|
.on(CRMStatusChangeLog.to == TargetStatus.name)
|
|
.select(CRMStatusChangeLog.to.as_("stage"), Count("*").as_("count"))
|
|
.where(
|
|
(CRMStatusChangeLog.to.isnotnull())
|
|
& (CRMStatusChangeLog.to != "")
|
|
& (CurrentStatus.type != "Lost")
|
|
& (Date(CRMDeal.creation).between(from_date, to_date))
|
|
)
|
|
.groupby(CRMStatusChangeLog.to, TargetStatus.position)
|
|
.orderby(TargetStatus.position)
|
|
)
|
|
|
|
# Handle optional user filter if deal_conds contains user condition
|
|
if filters and filters.get("user"):
|
|
query = query.where(CRMDeal.deal_owner == filters["user"])
|
|
|
|
result = query.run(as_dict=True)
|
|
return result or []
|