Merge pull request #1031 from frappe/mergify/bp/main-hotfix/pr-1030

This commit is contained in:
Shariq Ansari 2025-07-13 15:20:59 +05:30 committed by GitHub
commit eaf61005cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 432 additions and 185 deletions

View File

@ -19,18 +19,24 @@ def get_number_card_data(from_date="", to_date="", user="", lead_conds="", deal_
if is_sales_user and not user: if is_sales_user and not user:
user = frappe.session.user user = frappe.session.user
lead_chart_data = get_lead_count(from_date, to_date, user, lead_conds) lead_count = get_lead_count(from_date, to_date, user, lead_conds)
deal_chart_data = get_deal_count(from_date, to_date, user, deal_conds) ongoing_deal_count = get_ongoing_deal_count(from_date, to_date, user, deal_conds)["count"]
get_won_deal_count_data = get_won_deal_count(from_date, to_date, user, deal_conds) average_ongoing_deal_value = get_ongoing_deal_count(from_date, to_date, user, deal_conds)["average"]
get_average_deal_value_data = get_average_deal_value(from_date, to_date, user, deal_conds) won_deal_count = get_won_deal_count(from_date, to_date, user, deal_conds)["count"]
get_average_time_to_close_data = get_average_time_to_close(from_date, to_date, user, deal_conds) average_won_deal_value = get_won_deal_count(from_date, to_date, user, deal_conds)["average"]
average_deal_value = get_average_deal_value(from_date, to_date, user, deal_conds)
average_time_to_close_a_lead = get_average_time_to_close(from_date, to_date, user, deal_conds)["lead"]
average_time_to_close_a_deal = get_average_time_to_close(from_date, to_date, user, deal_conds)["deal"]
return [ return [
lead_chart_data, lead_count,
deal_chart_data, ongoing_deal_count,
get_won_deal_count_data, average_ongoing_deal_value,
get_average_deal_value_data, won_deal_count,
get_average_time_to_close_data, average_won_deal_value,
average_deal_value,
average_time_to_close_a_lead,
average_time_to_close_a_deal,
] ]
@ -83,18 +89,17 @@ def get_lead_count(from_date, to_date, user="", conds="", return_result=False):
) )
return { return {
"title": _("Total Leads"), "title": _("Total leads"),
"value": current_month_leads, "value": current_month_leads,
"delta": delta_in_percentage, "delta": delta_in_percentage,
"deltaSuffix": "%", "deltaSuffix": "%",
"negativeIsBetter": False,
"tooltip": _("Total number of leads"), "tooltip": _("Total number of leads"),
} }
def get_deal_count(from_date, to_date, user="", conds="", return_result=False): def get_ongoing_deal_count(from_date, to_date, user="", conds="", return_result=False):
""" """
Get deal count for the dashboard. 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) diff = frappe.utils.date_diff(to_date, from_date)
@ -102,25 +107,44 @@ def get_deal_count(from_date, to_date, user="", conds="", return_result=False):
diff = 1 diff = 1
if user: if user:
conds += f" AND deal_owner = '{user}'" conds += f" AND d.deal_owner = '{user}'"
result = frappe.db.sql( result = frappe.db.sql(
f""" f"""
SELECT SELECT
COUNT(CASE COUNT(CASE
WHEN creation >= %(from_date)s AND creation < DATE_ADD(%(to_date)s, INTERVAL 1 DAY) WHEN d.creation >= %(from_date)s AND d.creation < DATE_ADD(%(to_date)s, INTERVAL 1 DAY)
{conds} AND s.type NOT IN ('Won', 'Lost')
THEN name {conds}
ELSE NULL THEN d.name
END) as current_month_deals, ELSE NULL
END) as current_month_deals,
COUNT(CASE COUNT(CASE
WHEN creation >= %(prev_from_date)s AND creation < %(from_date)s WHEN d.creation >= %(prev_from_date)s AND d.creation < %(from_date)s
{conds} AND s.type NOT IN ('Won', 'Lost')
THEN name {conds}
ELSE NULL THEN d.name
END) as prev_month_deals ELSE NULL
FROM `tabCRM Deal` END) as prev_month_deals,
AVG(CASE
WHEN d.creation >= %(from_date)s AND d.creation < DATE_ADD(%(to_date)s, INTERVAL 1 DAY)
AND s.type NOT IN ('Won', 'Lost')
{conds}
THEN d.deal_value * IFNULL(d.exchange_rate, 1)
ELSE NULL
END) as current_month_avg_value,
AVG(CASE
WHEN d.creation >= %(prev_from_date)s AND d.creation < %(from_date)s
AND s.type NOT IN ('Won', 'Lost')
{conds}
THEN d.deal_value * IFNULL(d.exchange_rate, 1)
ELSE NULL
END) as prev_month_avg_value
FROM `tabCRM Deal` d
JOIN `tabCRM Deal Status` s ON d.status = s.name
""", """,
{ {
"from_date": from_date, "from_date": from_date,
@ -135,24 +159,36 @@ def get_deal_count(from_date, to_date, user="", conds="", return_result=False):
current_month_deals = result[0].current_month_deals or 0 current_month_deals = result[0].current_month_deals or 0
prev_month_deals = result[0].prev_month_deals or 0 prev_month_deals = result[0].prev_month_deals or 0
current_month_avg_value = result[0].current_month_avg_value or 0
prev_month_avg_value = result[0].prev_month_avg_value or 0
delta_in_percentage = ( delta_in_percentage = (
(current_month_deals - prev_month_deals) / prev_month_deals * 100 if prev_month_deals else 0 (current_month_deals - prev_month_deals) / prev_month_deals * 100 if prev_month_deals else 0
) )
avg_value_delta = current_month_avg_value - prev_month_avg_value if prev_month_avg_value else 0
return { return {
"title": _("Total Deals"), "count": {
"value": current_month_deals, "title": _("Ongoing deals"),
"delta": delta_in_percentage, "value": current_month_deals,
"deltaSuffix": "%", "delta": delta_in_percentage,
"negativeIsBetter": False, "deltaSuffix": "%",
"tooltip": _("Total number of deals"), "tooltip": _("Total number of ongoing deals"),
},
"average": {
"title": _("Avg ongoing deal value"),
"value": current_month_avg_value,
"delta": avg_value_delta,
"prefix": get_base_currency_symbol(),
# "suffix": "K",
"tooltip": _("Average deal value of ongoing deals"),
},
} }
def get_won_deal_count(from_date, to_date, user="", conds="", return_result=False): def get_won_deal_count(from_date, to_date, user="", conds="", return_result=False):
""" """
Get won deal count for the dashboard. 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) diff = frappe.utils.date_diff(to_date, from_date)
@ -160,26 +196,45 @@ def get_won_deal_count(from_date, to_date, user="", conds="", return_result=Fals
diff = 1 diff = 1
if user: if user:
conds += f" AND deal_owner = '{user}'" conds += f" AND d.deal_owner = '{user}'"
result = frappe.db.sql( result = frappe.db.sql(
f""" f"""
SELECT SELECT
COUNT(CASE COUNT(CASE
WHEN creation >= %(from_date)s AND creation < DATE_ADD(%(to_date)s, INTERVAL 1 DAY) AND status = 'Won' WHEN d.closed_date >= %(from_date)s AND d.closed_date < DATE_ADD(%(to_date)s, INTERVAL 1 DAY)
{conds} AND s.type = 'Won'
THEN name {conds}
ELSE NULL THEN d.name
END) as current_month_deals, ELSE NULL
END) as current_month_deals,
COUNT(CASE COUNT(CASE
WHEN creation >= %(prev_from_date)s AND creation < %(from_date)s AND status = 'Won' WHEN d.closed_date >= %(prev_from_date)s AND d.closed_date < %(from_date)s
{conds} AND s.type = 'Won'
THEN name {conds}
ELSE NULL THEN d.name
END) as prev_month_deals ELSE NULL
FROM `tabCRM Deal` END) as prev_month_deals,
""",
AVG(CASE
WHEN d.closed_date >= %(from_date)s AND d.closed_date < DATE_ADD(%(to_date)s, INTERVAL 1 DAY)
AND s.type = 'Won'
{conds}
THEN d.deal_value * IFNULL(d.exchange_rate, 1)
ELSE NULL
END) as current_month_avg_value,
AVG(CASE
WHEN d.closed_date >= %(prev_from_date)s AND d.closed_date < %(from_date)s
AND s.type = 'Won'
{conds}
THEN d.deal_value * IFNULL(d.exchange_rate, 1)
ELSE NULL
END) as prev_month_avg_value
FROM `tabCRM Deal` d
JOIN `tabCRM Deal Status` s ON d.status = s.name
""",
{ {
"from_date": from_date, "from_date": from_date,
"to_date": to_date, "to_date": to_date,
@ -193,18 +248,30 @@ def get_won_deal_count(from_date, to_date, user="", conds="", return_result=Fals
current_month_deals = result[0].current_month_deals or 0 current_month_deals = result[0].current_month_deals or 0
prev_month_deals = result[0].prev_month_deals or 0 prev_month_deals = result[0].prev_month_deals or 0
current_month_avg_value = result[0].current_month_avg_value or 0
prev_month_avg_value = result[0].prev_month_avg_value or 0
delta_in_percentage = ( delta_in_percentage = (
(current_month_deals - prev_month_deals) / prev_month_deals * 100 if prev_month_deals else 0 (current_month_deals - prev_month_deals) / prev_month_deals * 100 if prev_month_deals else 0
) )
avg_value_delta = current_month_avg_value - prev_month_avg_value if prev_month_avg_value else 0
return { return {
"title": _("Won Deals"), "count": {
"value": current_month_deals, "title": _("Won deals"),
"delta": delta_in_percentage, "value": current_month_deals,
"deltaSuffix": "%", "delta": delta_in_percentage,
"negativeIsBetter": False, "deltaSuffix": "%",
"tooltip": _("Total number of won deals"), "tooltip": _("Total number of won deals based on its closure date"),
},
"average": {
"title": _("Avg won deal value"),
"value": current_month_avg_value,
"delta": avg_value_delta,
"prefix": get_base_currency_symbol(),
# "suffix": "K",
"tooltip": _("Average deal value of won deals"),
},
} }
@ -218,25 +285,28 @@ def get_average_deal_value(from_date, to_date, user="", conds="", return_result=
diff = 1 diff = 1
if user: if user:
conds += f" AND deal_owner = '{user}'" conds += f" AND d.deal_owner = '{user}'"
result = frappe.db.sql( result = frappe.db.sql(
f""" f"""
SELECT SELECT
AVG(CASE AVG(CASE
WHEN d.creation >= %(from_date)s AND d.creation < DATE_ADD(%(to_date)s, INTERVAL 1 DAY) AND d.status != 'Lost' WHEN d.creation >= %(from_date)s AND d.creation < DATE_ADD(%(to_date)s, INTERVAL 1 DAY)
{conds} AND s.type != 'Lost'
{conds}
THEN d.deal_value * IFNULL(d.exchange_rate, 1) THEN d.deal_value * IFNULL(d.exchange_rate, 1)
ELSE NULL ELSE NULL
END) as current_month_avg, END) as current_month_avg,
AVG(CASE AVG(CASE
WHEN d.creation >= %(prev_from_date)s AND d.creation < %(from_date)s AND d.status != 'Lost' WHEN d.creation >= %(prev_from_date)s AND d.creation < %(from_date)s
{conds} AND s.type != 'Lost'
{conds}
THEN d.deal_value * IFNULL(d.exchange_rate, 1) THEN d.deal_value * IFNULL(d.exchange_rate, 1)
ELSE NULL ELSE NULL
END) as prev_month_avg END) as prev_month_avg
FROM `tabCRM Deal` AS d FROM `tabCRM Deal` AS d
JOIN `tabCRM Deal Status` s ON d.status = s.name
""", """,
{ {
"from_date": from_date, "from_date": from_date,
@ -252,7 +322,7 @@ def get_average_deal_value(from_date, to_date, user="", conds="", return_result=
delta = current_month_avg - prev_month_avg if prev_month_avg else 0 delta = current_month_avg - prev_month_avg if prev_month_avg else 0
return { return {
"title": _("Avg Deal Value"), "title": _("Avg deal value"),
"value": current_month_avg, "value": current_month_avg,
"tooltip": _("Average deal value of ongoing & won deals"), "tooltip": _("Average deal value of ongoing & won deals"),
"prefix": get_base_currency_symbol(), "prefix": get_base_currency_symbol(),
@ -265,6 +335,9 @@ def get_average_deal_value(from_date, to_date, user="", conds="", return_result=
def get_average_time_to_close(from_date, to_date, user="", conds="", return_result=False): def get_average_time_to_close(from_date, to_date, user="", conds="", return_result=False):
""" """
Get average time to close deals for the dashboard. Get average time to close deals for the dashboard.
Returns both:
- Average time from lead creation to deal closure
- Average time from deal creation to deal closure
""" """
diff = frappe.utils.date_diff(to_date, from_date) diff = frappe.utils.date_diff(to_date, from_date)
@ -280,13 +353,18 @@ def get_average_time_to_close(from_date, to_date, user="", conds="", return_resu
result = frappe.db.sql( result = frappe.db.sql(
f""" f"""
SELECT SELECT
AVG(CASE WHEN d.closed_on >= %(from_date)s AND d.closed_on < DATE_ADD(%(to_date)s, INTERVAL 1 DAY) AVG(CASE WHEN d.closed_date >= %(from_date)s AND d.closed_date < DATE_ADD(%(to_date)s, INTERVAL 1 DAY)
THEN TIMESTAMPDIFF(DAY, COALESCE(l.creation, d.creation), d.closed_on) END) as current_avg, THEN TIMESTAMPDIFF(DAY, COALESCE(l.creation, d.creation), d.closed_date) END) as current_avg_lead,
AVG(CASE WHEN d.closed_on >= %(prev_from_date)s AND d.closed_on < %(prev_to_date)s AVG(CASE WHEN d.closed_date >= %(prev_from_date)s AND d.closed_date < %(prev_to_date)s
THEN TIMESTAMPDIFF(DAY, COALESCE(l.creation, d.creation), d.closed_on) END) as prev_avg THEN TIMESTAMPDIFF(DAY, COALESCE(l.creation, d.creation), d.closed_date) END) as prev_avg_lead,
AVG(CASE WHEN d.closed_date >= %(from_date)s AND d.closed_date < DATE_ADD(%(to_date)s, INTERVAL 1 DAY)
THEN TIMESTAMPDIFF(DAY, d.creation, d.closed_date) END) as current_avg_deal,
AVG(CASE WHEN d.closed_date >= %(prev_from_date)s AND d.closed_date < %(prev_to_date)s
THEN TIMESTAMPDIFF(DAY, d.creation, d.closed_date) END) as prev_avg_deal
FROM `tabCRM Deal` AS d FROM `tabCRM Deal` AS d
JOIN `tabCRM Deal Status` s ON d.status = s.name
LEFT JOIN `tabCRM Lead` l ON d.lead = l.name LEFT JOIN `tabCRM Lead` l ON d.lead = l.name
WHERE d.status = 'Won' AND d.closed_on IS NOT NULL WHERE d.closed_date IS NOT NULL AND s.type = 'Won'
{conds} {conds}
""", """,
{ {
@ -301,18 +379,33 @@ def get_average_time_to_close(from_date, to_date, user="", conds="", return_resu
if return_result: if return_result:
return result return result
current_avg = result[0].current_avg or 0 current_avg_lead = result[0].current_avg_lead or 0
prev_avg = result[0].prev_avg or 0 prev_avg_lead = result[0].prev_avg_lead or 0
delta = current_avg - prev_avg if prev_avg else 0 delta_lead = current_avg_lead - prev_avg_lead if prev_avg_lead else 0
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 { return {
"title": _("Avg Time to Close"), "lead": {
"value": current_avg, "title": _("Avg time to close a lead"),
"tooltip": _("Average time taken from lead creation to deal closure"), "value": current_avg_lead,
"suffix": " days", "tooltip": _("Average time taken from lead creation to deal closure"),
"delta": delta, "suffix": " days",
"deltaSuffix": " days", "delta": delta_lead,
"negativeIsBetter": True, "deltaSuffix": " days",
"negativeIsBetter": True,
},
"deal": {
"title": _("Avg time to close a deal"),
"value": current_avg_deal,
"tooltip": _("Average time taken from deal creation to deal closure"),
"suffix": " days",
"delta": delta_deal,
"deltaSuffix": " days",
"negativeIsBetter": True,
},
} }
@ -356,14 +449,15 @@ def get_sales_trend_data(from_date="", to_date="", user="", lead_conds="", deal_
UNION ALL UNION ALL
SELECT SELECT
DATE(creation) AS date, DATE(d.creation) AS date,
0 AS leads, 0 AS leads,
COUNT(*) AS deals, COUNT(*) AS deals,
SUM(CASE WHEN status = 'Won' THEN 1 ELSE 0 END) AS won_deals SUM(CASE WHEN s.type = 'Won' THEN 1 ELSE 0 END) AS won_deals
FROM `tabCRM Deal` FROM `tabCRM Deal` d
WHERE DATE(creation) BETWEEN %(from)s AND %(to)s JOIN `tabCRM Deal Status` s ON d.status = s.name
WHERE DATE(d.creation) BETWEEN %(from)s AND %(to)s
{deal_conds} {deal_conds}
GROUP BY DATE(creation) GROUP BY DATE(d.creation)
) AS daily ) AS daily
GROUP BY date GROUP BY date
ORDER BY date ORDER BY date
@ -488,7 +582,8 @@ def get_lost_deal_reasons(from_date="", to_date="", user="", deal_conds=""):
d.lost_reason AS reason, d.lost_reason AS reason,
COUNT(*) AS count COUNT(*) AS count
FROM `tabCRM Deal` AS d FROM `tabCRM Deal` AS d
WHERE DATE(d.creation) BETWEEN %(from)s AND %(to)s AND d.status = 'Lost' JOIN `tabCRM Deal Status` s ON d.status = s.name
WHERE DATE(d.creation) BETWEEN %(from)s AND %(to)s AND s.type = 'Lost'
{deal_conds} {deal_conds}
GROUP BY d.lost_reason GROUP BY d.lost_reason
HAVING reason IS NOT NULL AND reason != '' HAVING reason IS NOT NULL AND reason != ''
@ -520,23 +615,24 @@ def get_forecasted_revenue(user="", deal_conds=""):
result = frappe.db.sql( result = frappe.db.sql(
f""" f"""
SELECT SELECT
DATE_FORMAT(d.close_date, '%Y-%m') AS month, DATE_FORMAT(d.expected_closure_date, '%Y-%m') AS month,
SUM( SUM(
CASE CASE
WHEN d.status = 'Lost' THEN d.deal_value * IFNULL(d.exchange_rate, 1) WHEN s.type = 'Lost' THEN d.expected_deal_value * IFNULL(d.exchange_rate, 1)
ELSE d.deal_value * IFNULL(d.probability, 0) / 100 * IFNULL(d.exchange_rate, 1) -- forecasted ELSE d.expected_deal_value * IFNULL(d.probability, 0) / 100 * IFNULL(d.exchange_rate, 1) -- forecasted
END END
) AS forecasted, ) AS forecasted,
SUM( SUM(
CASE CASE
WHEN d.status = 'Won' THEN d.deal_value * IFNULL(d.exchange_rate, 1) -- actual WHEN s.type = 'Won' THEN d.deal_value * IFNULL(d.exchange_rate, 1) -- actual
ELSE 0 ELSE 0
END END
) AS actual ) AS actual
FROM `tabCRM Deal` AS d FROM `tabCRM Deal` AS d
WHERE d.close_date >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH) JOIN `tabCRM Deal Status` s ON d.status = s.name
WHERE d.expected_closure_date >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)
{deal_conds} {deal_conds}
GROUP BY DATE_FORMAT(d.close_date, '%Y-%m') GROUP BY DATE_FORMAT(d.expected_closure_date, '%Y-%m')
ORDER BY month ORDER BY month
""", """,
as_dict=True, as_dict=True,
@ -581,7 +677,8 @@ def get_funnel_conversion_data(from_date="", to_date="", user="", lead_conds="",
# Get total leads # Get total leads
total_leads = frappe.db.sql( total_leads = frappe.db.sql(
f""" SELECT COUNT(*) AS count f"""
SELECT COUNT(*) AS count
FROM `tabCRM Lead` FROM `tabCRM Lead`
WHERE DATE(creation) BETWEEN %(from)s AND %(to)s WHERE DATE(creation) BETWEEN %(from)s AND %(to)s
{lead_conds} {lead_conds}
@ -593,25 +690,7 @@ def get_funnel_conversion_data(from_date="", to_date="", user="", lead_conds="",
result.append({"stage": "Leads", "count": total_leads_count}) result.append({"stage": "Leads", "count": total_leads_count})
# Get deal stages result += get_deal_status_change_counts(from_date, to_date, deal_conds)
all_deal_stages = frappe.get_all(
"CRM Deal Status", filters={"name": ["!=", "Lost"]}, order_by="position", pluck="name"
)
# Get deal counts for each stage
for i, stage in enumerate(all_deal_stages):
stages_to_count = all_deal_stages[i:]
placeholders = ", ".join(["%s"] * len(stages_to_count))
query = f"""
SELECT COUNT(*) as count
FROM `tabCRM Deal`
WHERE DATE(creation) BETWEEN %s AND %s
AND status IN ({placeholders})
{deal_conds}
"""
params = [from_date, to_date, *stages_to_count]
row = frappe.db.sql(query, params, as_dict=True)
result.append({"stage": stage, "count": row[0]["count"] if row else 0})
return result or [] return result or []
@ -638,8 +717,10 @@ def get_deals_by_stage(from_date="", to_date="", user="", deal_conds=""):
f""" f"""
SELECT SELECT
d.status AS stage, d.status AS stage,
COUNT(*) AS count COUNT(*) AS count,
s.type AS status_type
FROM `tabCRM Deal` AS d FROM `tabCRM Deal` AS d
JOIN `tabCRM Deal Status` s ON d.status = s.name
WHERE DATE(d.creation) BETWEEN %(from)s AND %(to)s WHERE DATE(d.creation) BETWEEN %(from)s AND %(to)s
{deal_conds} {deal_conds}
GROUP BY d.status GROUP BY d.status
@ -694,3 +775,44 @@ def get_base_currency_symbol():
""" """
base_currency = frappe.db.get_single_value("FCRM Settings", "currency") or "USD" base_currency = frappe.db.get_single_value("FCRM Settings", "currency") or "USD"
return frappe.db.get_value("Currency", base_currency, "symbol") or "" return frappe.db.get_value("Currency", base_currency, "symbol") or ""
def get_deal_status_change_counts(from_date, to_date, deal_conds=""):
"""
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},
...
]
"""
result = frappe.db.sql(
f"""
SELECT
scl.to AS stage,
COUNT(*) AS count
FROM
`tabCRM Status Change Log` scl
JOIN
`tabCRM Deal` d ON scl.parent = d.name
JOIN
`tabCRM Deal Status` s ON d.status = s.name
JOIN
`tabCRM Deal Status` st ON scl.to = st.name
WHERE
scl.to IS NOT NULL
AND scl.to != ''
AND s.type != 'Lost'
AND DATE(d.creation) BETWEEN %(from)s AND %(to)s
{deal_conds}
GROUP BY
scl.to, st.position
ORDER BY
st.position ASC
""",
{"from": from_date, "to": to_date},
as_dict=True,
)
return result or []

View File

@ -18,10 +18,11 @@
"lost_notes", "lost_notes",
"section_break_jgpm", "section_break_jgpm",
"probability", "probability",
"expected_deal_value",
"deal_value", "deal_value",
"column_break_kpxa", "column_break_kpxa",
"close_date", "expected_closure_date",
"closed_on", "closed_date",
"contacts_tab", "contacts_tab",
"contacts", "contacts",
"contact", "contact",
@ -95,11 +96,6 @@
"fieldtype": "Data", "fieldtype": "Data",
"label": "Website" "label": "Website"
}, },
{
"fieldname": "close_date",
"fieldtype": "Date",
"label": "Close Date"
},
{ {
"fieldname": "next_step", "fieldname": "next_step",
"fieldtype": "Data", "fieldtype": "Data",
@ -412,23 +408,34 @@
"label": "Lost Notes", "label": "Lost Notes",
"mandatory_depends_on": "eval: doc.lost_reason == \"Other\"" "mandatory_depends_on": "eval: doc.lost_reason == \"Other\""
}, },
{
"fieldname": "closed_on",
"fieldtype": "Datetime",
"label": "Closed On"
},
{ {
"default": "1", "default": "1",
"description": "The rate used to convert the deal\u2019s currency to your crm's base currency (set in CRM Settings). It is set once when the currency is first added and doesn't change automatically.", "description": "The rate used to convert the deal\u2019s currency to your crm's base currency (set in CRM Settings). It is set once when the currency is first added and doesn't change automatically.",
"fieldname": "exchange_rate", "fieldname": "exchange_rate",
"fieldtype": "Float", "fieldtype": "Float",
"label": "Exchange Rate" "label": "Exchange Rate"
},
{
"fieldname": "expected_deal_value",
"fieldtype": "Currency",
"label": "Expected Deal Value",
"options": "currency"
},
{
"fieldname": "expected_closure_date",
"fieldtype": "Date",
"label": "Expected Closure Date"
},
{
"fieldname": "closed_date",
"fieldtype": "Date",
"label": "Closed Date"
} }
], ],
"grid_page_length": 50, "grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2025-07-09 17:58:55.956639", "modified": "2025-07-13 11:54:20.608489",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "FCRM", "module": "FCRM",
"name": "CRM Deal", "name": "CRM Deal",

View File

@ -25,8 +25,8 @@ class CRMDeal(Document):
self.assign_agent(self.deal_owner) self.assign_agent(self.deal_owner)
if self.has_value_changed("status"): if self.has_value_changed("status"):
add_status_change_log(self) add_status_change_log(self)
if self.status == "Won": if frappe.db.get_value("CRM Deal Status", self.status, "type") == "Won":
self.closed_on = frappe.utils.now_datetime() self.closed_date = frappe.utils.nowdate()
self.validate_forcasting_fields() self.validate_forcasting_fields()
self.validate_lost_reason() self.validate_lost_reason()
self.update_exchange_rate() self.update_exchange_rate()
@ -166,7 +166,7 @@ class CRMDeal(Document):
""" """
Validate the lost reason if the status is set to "Lost". Validate the lost reason if the status is set to "Lost".
""" """
if self.status == "Lost": if self.status and frappe.get_cached_value("CRM Deal Status", self.status, "type") == "Lost":
if not self.lost_reason: if not self.lost_reason:
frappe.throw(_("Please specify a reason for losing the deal."), frappe.ValidationError) frappe.throw(_("Please specify a reason for losing the deal."), frappe.ValidationError)
elif self.lost_reason == "Other" and not self.lost_notes: elif self.lost_reason == "Other" and not self.lost_notes:

View File

@ -7,9 +7,11 @@
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"deal_status", "deal_status",
"color", "type",
"position", "position",
"probability" "column_break_ojiu",
"probability",
"color"
], ],
"fields": [ "fields": [
{ {
@ -39,12 +41,24 @@
"fieldtype": "Percent", "fieldtype": "Percent",
"in_list_view": 1, "in_list_view": 1,
"label": "Probability" "label": "Probability"
},
{
"default": "Open",
"fieldname": "type",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Type",
"options": "Open\nOngoing\nOn Hold\nWon\nLost"
},
{
"fieldname": "column_break_ojiu",
"fieldtype": "Column Break"
} }
], ],
"grid_page_length": 50, "grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2025-07-01 12:06:42.937440", "modified": "2025-07-11 16:03:28.077955",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "FCRM", "module": "FCRM",
"name": "CRM Deal Status", "name": "CRM Deal Status",

View File

@ -160,7 +160,7 @@ def add_forecasting_section(layout, doctype):
"columns": [ "columns": [
{ {
"name": "column_" + str(random_string(4)), "name": "column_" + str(random_string(4)),
"fields": ["close_date", "probability", "deal_value"], "fields": ["expected_closure_date", "probability", "expected_deal_value"],
} }
], ],
}, },

View File

@ -13,6 +13,8 @@
"column_break_mwmz", "column_break_mwmz",
"duration", "duration",
"last_status_change_log", "last_status_change_log",
"from_type",
"to_type",
"log_owner" "log_owner"
], ],
"fields": [ "fields": [
@ -61,17 +63,30 @@
"fieldtype": "Link", "fieldtype": "Link",
"label": "Owner", "label": "Owner",
"options": "User" "options": "User"
},
{
"fieldname": "from_type",
"fieldtype": "Data",
"in_list_view": 1,
"label": "From Type"
},
{
"fieldname": "to_type",
"fieldtype": "Data",
"in_list_view": 1,
"label": "To Type"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2024-01-06 13:26:40.597277", "modified": "2025-07-13 12:37:41.278584",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "FCRM", "module": "FCRM",
"name": "CRM Status Change Log", "name": "CRM Status Change Log",
"owner": "Administrator", "owner": "Administrator",
"permissions": [], "permissions": [],
"row_format": "Dynamic",
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [] "states": []

View File

@ -1,15 +1,17 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors # Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt # For license information, please see license.txt
import frappe
from datetime import datetime from datetime import datetime
from frappe.utils import add_to_date, get_datetime
import frappe
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import add_to_date, get_datetime
class CRMStatusChangeLog(Document): class CRMStatusChangeLog(Document):
pass pass
def get_duration(from_date, to_date): def get_duration(from_date, to_date):
if not isinstance(from_date, datetime): if not isinstance(from_date, datetime):
from_date = get_datetime(from_date) from_date = get_datetime(from_date)
@ -18,28 +20,44 @@ def get_duration(from_date, to_date):
duration = to_date - from_date duration = to_date - from_date
return duration.total_seconds() return duration.total_seconds()
def add_status_change_log(doc): def add_status_change_log(doc):
if not doc.is_new(): if not doc.is_new():
previous_status = doc.get_doc_before_save().status if doc.get_doc_before_save() else None previous_status = doc.get_doc_before_save().status if doc.get_doc_before_save() else None
previous_status_type = (
frappe.db.get_value("CRM Deal Status", previous_status, "type") if previous_status else None
)
if not doc.status_change_log and previous_status: if not doc.status_change_log and previous_status:
now_minus_one_minute = add_to_date(datetime.now(), minutes=-1) now_minus_one_minute = add_to_date(datetime.now(), minutes=-1)
doc.append("status_change_log", { doc.append(
"from": previous_status, "status_change_log",
"to": "", {
"from_date": now_minus_one_minute, "from": previous_status,
"to_date": "", "from_type": previous_status_type,
"log_owner": frappe.session.user, "to": "",
}) "to_type": "",
"from_date": now_minus_one_minute,
"to_date": "",
"log_owner": frappe.session.user,
},
)
to_status_type = frappe.db.get_value("CRM Deal Status", doc.status, "type")
last_status_change = doc.status_change_log[-1] last_status_change = doc.status_change_log[-1]
last_status_change.to = doc.status last_status_change.to = doc.status
last_status_change.to_type = to_status_type
last_status_change.to_date = datetime.now() last_status_change.to_date = datetime.now()
last_status_change.log_owner = frappe.session.user last_status_change.log_owner = frappe.session.user
last_status_change.duration = get_duration(last_status_change.from_date, last_status_change.to_date) last_status_change.duration = get_duration(last_status_change.from_date, last_status_change.to_date)
doc.append("status_change_log", { doc.append(
"from": doc.status, "status_change_log",
"to": "", {
"from_date": datetime.now(), "from": doc.status,
"to_date": "", "from_type": to_status_type,
"log_owner": frappe.session.user, "to": "",
}) "to_type": "",
"from_date": datetime.now(),
"to_date": "",
"log_owner": frappe.session.user,
},
)

View File

@ -61,7 +61,7 @@
}, },
{ {
"default": "0", "default": "0",
"description": "It will make deal's \"Close Date\" & \"Deal Value\" mandatory to get accurate forecasting insights", "description": "It will make deal's \"Expected Closure Date\" & \"Expected Deal Value\" mandatory to get accurate forecasting insights",
"fieldname": "enable_forecasting", "fieldname": "enable_forecasting",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Enable Forecasting" "label": "Enable Forecasting"
@ -77,7 +77,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2025-07-10 16:35:25.030011", "modified": "2025-07-13 11:58:34.857638",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "FCRM", "module": "FCRM",
"name": "FCRM Settings", "name": "FCRM Settings",

View File

@ -38,24 +38,24 @@ class FCRMSettings(Document):
delete_property_setter( delete_property_setter(
"CRM Deal", "CRM Deal",
"reqd", "reqd",
"close_date", "expected_closure_date",
) )
delete_property_setter( delete_property_setter(
"CRM Deal", "CRM Deal",
"reqd", "reqd",
"deal_value", "expected_deal_value",
) )
else: else:
make_property_setter( make_property_setter(
"CRM Deal", "CRM Deal",
"close_date", "expected_closure_date",
"reqd", "reqd",
1 if self.enable_forecasting else 0, 1 if self.enable_forecasting else 0,
"Check", "Check",
) )
make_property_setter( make_property_setter(
"CRM Deal", "CRM Deal",
"deal_value", "expected_deal_value",
"reqd", "reqd",
1 if self.enable_forecasting else 0, 1 if self.enable_forecasting else 0,
"Check", "Check",

View File

@ -69,36 +69,43 @@ def add_default_deal_statuses():
statuses = { statuses = {
"Qualification": { "Qualification": {
"color": "gray", "color": "gray",
"type": "Open",
"probability": 10, "probability": 10,
"position": 1, "position": 1,
}, },
"Demo/Making": { "Demo/Making": {
"color": "orange", "color": "orange",
"type": "Ongoing",
"probability": 25, "probability": 25,
"position": 2, "position": 2,
}, },
"Proposal/Quotation": { "Proposal/Quotation": {
"color": "blue", "color": "blue",
"type": "Ongoing",
"probability": 50, "probability": 50,
"position": 3, "position": 3,
}, },
"Negotiation": { "Negotiation": {
"color": "yellow", "color": "yellow",
"type": "Ongoing",
"probability": 70, "probability": 70,
"position": 4, "position": 4,
}, },
"Ready to Close": { "Ready to Close": {
"color": "purple", "color": "purple",
"type": "Ongoing",
"probability": 90, "probability": 90,
"position": 5, "position": 5,
}, },
"Won": { "Won": {
"color": "green", "color": "green",
"type": "Won",
"probability": 100, "probability": 100,
"position": 6, "position": 6,
}, },
"Lost": { "Lost": {
"color": "red", "color": "red",
"type": "Lost",
"probability": 0, "probability": 0,
"position": 7, "position": 7,
}, },
@ -111,6 +118,7 @@ def add_default_deal_statuses():
doc = frappe.new_doc("CRM Deal Status") doc = frappe.new_doc("CRM Deal Status")
doc.deal_status = status doc.deal_status = status
doc.color = statuses[status]["color"] doc.color = statuses[status]["color"]
doc.type = statuses[status]["type"]
doc.probability = statuses[status]["probability"] doc.probability = statuses[status]["probability"]
doc.position = statuses[status]["position"] doc.position = statuses[status]["position"]
doc.insert() doc.insert()

View File

@ -14,3 +14,4 @@ crm.patches.v1_0.update_layouts_to_new_format
crm.patches.v1_0.move_twilio_agent_to_telephony_agent crm.patches.v1_0.move_twilio_agent_to_telephony_agent
crm.patches.v1_0.create_default_scripts # 13-06-2025 crm.patches.v1_0.create_default_scripts # 13-06-2025
crm.patches.v1_0.update_deal_status_probabilities crm.patches.v1_0.update_deal_status_probabilities
crm.patches.v1_0.update_deal_status_type

View File

@ -0,0 +1,44 @@
import frappe
def execute():
deal_statuses = frappe.get_all("CRM Deal Status", fields=["name", "type", "deal_status"])
openStatuses = ["New", "Open", "Unassigned", "Qualification"]
ongoingStatuses = [
"Demo/Making",
"Proposal/Quotation",
"Negotiation",
"Ready to Close",
"Demo Scheduled",
"Follow Up",
]
onHoldStatuses = ["On Hold", "Paused", "Stalled", "Awaiting Reply"]
wonStatuses = ["Won", "Closed Won", "Successful", "Completed"]
lostStatuses = [
"Lost",
"Closed",
"Closed Lost",
"Junk",
"Unqualified",
"Disqualified",
"Cancelled",
"No Response",
]
for status in deal_statuses:
if status.type is None or status.type == "":
if status.deal_status in openStatuses:
type = "Open"
elif status.deal_status in ongoingStatuses:
type = "Ongoing"
elif status.deal_status in onHoldStatuses:
type = "On Hold"
elif status.deal_status in wonStatuses:
type = "Won"
elif status.deal_status in lostStatuses:
type = "Lost"
else:
type = "Ongoing"
frappe.db.set_value("CRM Deal Status", status.name, "type", type)

View File

@ -59,7 +59,7 @@
doctype="User" doctype="User"
:filters="{ name: ['in', users.data.crmUsers?.map((u) => u.name)] }" :filters="{ name: ['in', users.data.crmUsers?.map((u) => u.name)] }"
@change="(v) => updateFilter('user', v)" @change="(v) => updateFilter('user', v)"
:placeholder="__('Sales User')" :placeholder="__('Sales user')"
:hideMe="true" :hideMe="true"
> >
<template #prefix> <template #prefix>
@ -110,8 +110,14 @@
<div v-if="funnelConversion.data" class="border rounded-md min-h-80"> <div v-if="funnelConversion.data" class="border rounded-md min-h-80">
<AxisChart :config="funnelConversion.data" /> <AxisChart :config="funnelConversion.data" />
</div> </div>
<div v-if="lostDealReasons.data" class="border rounded-md"> <div v-if="dealsByStage.data" class="border rounded-md">
<AxisChart :config="lostDealReasons.data" /> <AxisChart :config="dealsByStage.data.bar" />
</div>
<div v-if="dealsByStage.data" class="border rounded-md">
<DonutChart :config="dealsByStage.data.donut" />
</div>
<div v-if="leadsBySource.data" class="border rounded-md">
<DonutChart :config="leadsBySource.data" />
</div> </div>
<div v-if="dealsByTerritory.data" class="border rounded-md"> <div v-if="dealsByTerritory.data" class="border rounded-md">
<AxisChart :config="dealsByTerritory.data" /> <AxisChart :config="dealsByTerritory.data" />
@ -119,11 +125,8 @@
<div v-if="dealsBySalesperson.data" class="border rounded-md"> <div v-if="dealsBySalesperson.data" class="border rounded-md">
<AxisChart :config="dealsBySalesperson.data" /> <AxisChart :config="dealsBySalesperson.data" />
</div> </div>
<div v-if="dealsByStage.data" class="border rounded-md"> <div v-if="lostDealReasons.data" class="border rounded-md">
<DonutChart :config="dealsByStage.data" /> <AxisChart :config="lostDealReasons.data" />
</div>
<div v-if="leadsBySource.data" class="border rounded-md">
<DonutChart :config="leadsBySource.data" />
</div> </div>
</div> </div>
</div> </div>
@ -266,7 +269,7 @@ const salesTrend = createResource({
transform(data = []) { transform(data = []) {
return { return {
data: data, data: data,
title: __('Sales Trend'), title: __('Sales trend'),
subtitle: __('Daily performance of leads, deals, and wins'), subtitle: __('Daily performance of leads, deals, and wins'),
xAxis: { xAxis: {
title: __('Date'), title: __('Date'),
@ -300,7 +303,7 @@ const funnelConversion = createResource({
transform(data = []) { transform(data = []) {
return { return {
data: data, data: data,
title: __('Funnel Conversion'), title: __('Funnel conversion'),
subtitle: __('Lead to deal conversion pipeline'), subtitle: __('Lead to deal conversion pipeline'),
xAxis: { xAxis: {
title: __('Stage'), title: __('Stage'),
@ -338,18 +341,18 @@ const dealsBySalesperson = createResource({
transform(r = { data: [], currency_symbol: '$' }) { transform(r = { data: [], currency_symbol: '$' }) {
return { return {
data: r.data || [], data: r.data || [],
title: __('Deals by Salesperson'), title: __('Deals by salesperson'),
subtitle: 'Number of deals and total value per salesperson', subtitle: __('Number of deals and total value per salesperson'),
xAxis: { xAxis: {
title: __('Salesperson'), title: __('Salesperson'),
key: 'salesperson', key: 'salesperson',
type: 'category' as const, type: 'category' as const,
}, },
yAxis: { yAxis: {
title: __('Number of Deals'), title: __('Number of deals'),
}, },
y2Axis: { y2Axis: {
title: __('Deal Value') + ` (${r.currency_symbol})`, title: __('Deal value') + ` (${r.currency_symbol})`,
}, },
series: [ series: [
{ name: 'deals', type: 'bar' as const }, { name: 'deals', type: 'bar' as const },
@ -378,7 +381,7 @@ const dealsByTerritory = createResource({
transform(r = { data: [], currency_symbol: '$' }) { transform(r = { data: [], currency_symbol: '$' }) {
return { return {
data: r.data || [], data: r.data || [],
title: __('Deals by Territory'), title: __('Deals by territory'),
subtitle: __('Geographic distribution of deals and revenue'), subtitle: __('Geographic distribution of deals and revenue'),
xAxis: { xAxis: {
title: __('Territory'), title: __('Territory'),
@ -386,10 +389,10 @@ const dealsByTerritory = createResource({
type: 'category' as const, type: 'category' as const,
}, },
yAxis: { yAxis: {
title: __('Number of Deals'), title: __('Number of deals'),
}, },
y2Axis: { y2Axis: {
title: __('Deal Value') + ` (${r.currency_symbol})`, title: __('Deal value') + ` (${r.currency_symbol})`,
}, },
series: [ series: [
{ name: 'deals', type: 'bar' as const }, { name: 'deals', type: 'bar' as const },
@ -418,7 +421,7 @@ const lostDealReasons = createResource({
transform(data = []) { transform(data = []) {
return { return {
data: data, data: data,
title: __('Lost Deal Reasons'), title: __('Lost deal reasons'),
subtitle: __('Common reasons for losing deals'), subtitle: __('Common reasons for losing deals'),
xAxis: { xAxis: {
title: __('Reason'), title: __('Reason'),
@ -444,7 +447,7 @@ const forecastedRevenue = createResource({
transform(r = { data: [], currency_symbol: '$' }) { transform(r = { data: [], currency_symbol: '$' }) {
return { return {
data: r.data || [], data: r.data || [],
title: __('Revenue Forecast'), title: __('Revenue forecast'),
subtitle: __('Projected vs actual revenue based on deal probability'), subtitle: __('Projected vs actual revenue based on deal probability'),
xAxis: { xAxis: {
title: __('Month'), title: __('Month'),
@ -476,11 +479,26 @@ const dealsByStage = createResource({
auto: true, auto: true,
transform(data = []) { transform(data = []) {
return { return {
data: data, donut: {
title: __('Deals by Stage'), data: data,
subtitle: __('Current pipeline distribution'), title: __('Deals by stage'),
categoryColumn: 'stage', subtitle: __('Current pipeline distribution'),
valueColumn: 'count', categoryColumn: 'stage',
valueColumn: 'count',
},
bar: {
data: data.filter((d) => d.status_type != 'Lost'),
title: __('Deals by ongoing & won stage'),
xAxis: {
title: __('Stage'),
key: 'stage',
type: 'category' as const,
},
yAxis: {
title: __('Count'),
},
series: [{ name: 'count', type: 'bar' as const }],
},
} }
}, },
}) })
@ -499,7 +517,7 @@ const leadsBySource = createResource({
transform(data = []) { transform(data = []) {
return { return {
data: data, data: data,
title: __('Leads by Source'), title: __('Leads by source'),
subtitle: __('Lead generation channel analysis'), subtitle: __('Lead generation channel analysis'),
categoryColumn: 'source', categoryColumn: 'source',
valueColumn: 'count', valueColumn: 'count',

View File

@ -773,7 +773,7 @@ const showLostReasonModal = ref(false)
function setLostReason() { function setLostReason() {
if ( if (
document.doc.status !== 'Lost' || getDealStatus(document.doc.status).type !== 'Lost' ||
(document.doc.lost_reason && document.doc.lost_reason !== 'Other') || (document.doc.lost_reason && document.doc.lost_reason !== 'Other') ||
(document.doc.lost_reason === 'Other' && document.doc.lost_notes) (document.doc.lost_reason === 'Other' && document.doc.lost_notes)
) { ) {
@ -785,7 +785,7 @@ function setLostReason() {
} }
function beforeStatusChange(data) { function beforeStatusChange(data) {
if (data?.hasOwnProperty('status') && data.status == 'Lost') { if (data?.hasOwnProperty('status') && getDealStatus(data.status).type == 'Lost') {
setLostReason() setLostReason()
} else { } else {
document.save.submit(null, { document.save.submit(null, {

View File

@ -643,7 +643,7 @@ const showLostReasonModal = ref(false)
function setLostReason() { function setLostReason() {
if ( if (
document.doc.status !== 'Lost' || getDealStatus(document.doc.status).type !== 'Lost' ||
(document.doc.lost_reason && document.doc.lost_reason !== 'Other') || (document.doc.lost_reason && document.doc.lost_reason !== 'Other') ||
(document.doc.lost_reason === 'Other' && document.doc.lost_notes) (document.doc.lost_reason === 'Other' && document.doc.lost_notes)
) { ) {
@ -655,7 +655,7 @@ function setLostReason() {
} }
function beforeStatusChange(data) { function beforeStatusChange(data) {
if (data?.hasOwnProperty('status') && data.status == 'Lost') { if (data?.hasOwnProperty('status') && getDealStatus(data.status).type == 'Lost') {
setLostReason() setLostReason()
} else { } else {
document.save.submit(null, { document.save.submit(null, {

View File

@ -28,7 +28,7 @@ export const statusesStore = defineStore('crm-statuses', () => {
const dealStatuses = createListResource({ const dealStatuses = createListResource({
doctype: 'CRM Deal Status', doctype: 'CRM Deal Status',
fields: ['name', 'color', 'position'], fields: ['name', 'color', 'position', 'type'],
orderBy: 'position asc', orderBy: 'position asc',
cache: 'deal-statuses', cache: 'deal-statuses',
initialData: [], initialData: [],