Merge pull request #1030 from shariquerik/deal-status-type

This commit is contained in:
Shariq Ansari 2025-07-13 15:15:19 +05:30 committed by GitHub
commit 81614418d4
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:
user = frappe.session.user
lead_chart_data = get_lead_count(from_date, to_date, user, lead_conds)
deal_chart_data = get_deal_count(from_date, to_date, user, deal_conds)
get_won_deal_count_data = get_won_deal_count(from_date, to_date, user, deal_conds)
get_average_deal_value_data = get_average_deal_value(from_date, to_date, user, deal_conds)
get_average_time_to_close_data = get_average_time_to_close(from_date, to_date, user, deal_conds)
lead_count = get_lead_count(from_date, to_date, user, lead_conds)
ongoing_deal_count = get_ongoing_deal_count(from_date, to_date, user, deal_conds)["count"]
average_ongoing_deal_value = get_ongoing_deal_count(from_date, to_date, user, deal_conds)["average"]
won_deal_count = get_won_deal_count(from_date, to_date, user, deal_conds)["count"]
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 [
lead_chart_data,
deal_chart_data,
get_won_deal_count_data,
get_average_deal_value_data,
get_average_time_to_close_data,
lead_count,
ongoing_deal_count,
average_ongoing_deal_value,
won_deal_count,
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 {
"title": _("Total Leads"),
"title": _("Total leads"),
"value": current_month_leads,
"delta": delta_in_percentage,
"deltaSuffix": "%",
"negativeIsBetter": False,
"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)
@ -102,25 +107,44 @@ def get_deal_count(from_date, to_date, user="", conds="", return_result=False):
diff = 1
if user:
conds += f" AND deal_owner = '{user}'"
conds += f" AND d.deal_owner = '{user}'"
result = frappe.db.sql(
f"""
SELECT
COUNT(CASE
WHEN creation >= %(from_date)s AND creation < DATE_ADD(%(to_date)s, INTERVAL 1 DAY)
{conds}
THEN name
ELSE NULL
END) as current_month_deals,
COUNT(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.name
ELSE NULL
END) as current_month_deals,
COUNT(CASE
WHEN creation >= %(prev_from_date)s AND creation < %(from_date)s
{conds}
THEN name
ELSE NULL
END) as prev_month_deals
FROM `tabCRM Deal`
COUNT(CASE
WHEN d.creation >= %(prev_from_date)s AND d.creation < %(from_date)s
AND s.type NOT IN ('Won', 'Lost')
{conds}
THEN d.name
ELSE NULL
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,
@ -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
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 = (
(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 {
"title": _("Total Deals"),
"value": current_month_deals,
"delta": delta_in_percentage,
"deltaSuffix": "%",
"negativeIsBetter": False,
"tooltip": _("Total number of deals"),
"count": {
"title": _("Ongoing deals"),
"value": current_month_deals,
"delta": delta_in_percentage,
"deltaSuffix": "%",
"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):
"""
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)
@ -160,26 +196,45 @@ def get_won_deal_count(from_date, to_date, user="", conds="", return_result=Fals
diff = 1
if user:
conds += f" AND deal_owner = '{user}'"
conds += f" AND d.deal_owner = '{user}'"
result = frappe.db.sql(
f"""
SELECT
COUNT(CASE
WHEN creation >= %(from_date)s AND creation < DATE_ADD(%(to_date)s, INTERVAL 1 DAY) AND status = 'Won'
{conds}
THEN name
ELSE NULL
END) as current_month_deals,
COUNT(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.name
ELSE NULL
END) as current_month_deals,
COUNT(CASE
WHEN creation >= %(prev_from_date)s AND creation < %(from_date)s AND status = 'Won'
{conds}
THEN name
ELSE NULL
END) as prev_month_deals
FROM `tabCRM Deal`
""",
COUNT(CASE
WHEN d.closed_date >= %(prev_from_date)s AND d.closed_date < %(from_date)s
AND s.type = 'Won'
{conds}
THEN d.name
ELSE NULL
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,
"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
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 = (
(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 {
"title": _("Won Deals"),
"value": current_month_deals,
"delta": delta_in_percentage,
"deltaSuffix": "%",
"negativeIsBetter": False,
"tooltip": _("Total number of won deals"),
"count": {
"title": _("Won deals"),
"value": current_month_deals,
"delta": delta_in_percentage,
"deltaSuffix": "%",
"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
if user:
conds += f" AND deal_owner = '{user}'"
conds += f" AND d.deal_owner = '{user}'"
result = frappe.db.sql(
f"""
SELECT
AVG(CASE
WHEN d.creation >= %(from_date)s AND d.creation < DATE_ADD(%(to_date)s, INTERVAL 1 DAY) AND d.status != 'Lost'
{conds}
WHEN d.creation >= %(from_date)s AND d.creation < DATE_ADD(%(to_date)s, INTERVAL 1 DAY)
AND s.type != 'Lost'
{conds}
THEN d.deal_value * IFNULL(d.exchange_rate, 1)
ELSE NULL
END) as current_month_avg,
AVG(CASE
WHEN d.creation >= %(prev_from_date)s AND d.creation < %(from_date)s AND d.status != 'Lost'
{conds}
WHEN d.creation >= %(prev_from_date)s AND d.creation < %(from_date)s
AND s.type != 'Lost'
{conds}
THEN d.deal_value * IFNULL(d.exchange_rate, 1)
ELSE NULL
END) as prev_month_avg
FROM `tabCRM Deal` AS d
JOIN `tabCRM Deal Status` s ON d.status = s.name
""",
{
"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
return {
"title": _("Avg Deal Value"),
"title": _("Avg deal value"),
"value": current_month_avg,
"tooltip": _("Average deal value of ongoing & won deals"),
"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):
"""
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)
@ -280,13 +353,18 @@ def get_average_time_to_close(from_date, to_date, user="", conds="", return_resu
result = frappe.db.sql(
f"""
SELECT
AVG(CASE WHEN d.closed_on >= %(from_date)s AND d.closed_on < DATE_ADD(%(to_date)s, INTERVAL 1 DAY)
THEN TIMESTAMPDIFF(DAY, COALESCE(l.creation, d.creation), d.closed_on) END) as current_avg,
AVG(CASE WHEN d.closed_on >= %(prev_from_date)s AND d.closed_on < %(prev_to_date)s
THEN TIMESTAMPDIFF(DAY, COALESCE(l.creation, d.creation), d.closed_on) END) as prev_avg
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_date) END) as current_avg_lead,
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_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
JOIN `tabCRM Deal Status` s ON d.status = s.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}
""",
{
@ -301,18 +379,33 @@ def get_average_time_to_close(from_date, to_date, user="", conds="", return_resu
if return_result:
return result
current_avg = result[0].current_avg or 0
prev_avg = result[0].prev_avg or 0
delta = current_avg - prev_avg if prev_avg else 0
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
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"),
"value": current_avg,
"tooltip": _("Average time taken from lead creation to deal closure"),
"suffix": " days",
"delta": delta,
"deltaSuffix": " days",
"negativeIsBetter": True,
"lead": {
"title": _("Avg time to close a lead"),
"value": current_avg_lead,
"tooltip": _("Average time taken from lead creation to deal closure"),
"suffix": " days",
"delta": delta_lead,
"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
SELECT
DATE(creation) AS date,
DATE(d.creation) AS date,
0 AS leads,
COUNT(*) AS deals,
SUM(CASE WHEN status = 'Won' THEN 1 ELSE 0 END) AS won_deals
FROM `tabCRM Deal`
WHERE DATE(creation) BETWEEN %(from)s AND %(to)s
SUM(CASE WHEN s.type = 'Won' THEN 1 ELSE 0 END) AS won_deals
FROM `tabCRM Deal` d
JOIN `tabCRM Deal Status` s ON d.status = s.name
WHERE DATE(d.creation) BETWEEN %(from)s AND %(to)s
{deal_conds}
GROUP BY DATE(creation)
GROUP BY DATE(d.creation)
) AS daily
GROUP 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,
COUNT(*) AS count
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}
GROUP BY d.lost_reason
HAVING reason IS NOT NULL AND reason != ''
@ -520,23 +615,24 @@ def get_forecasted_revenue(user="", deal_conds=""):
result = frappe.db.sql(
f"""
SELECT
DATE_FORMAT(d.close_date, '%Y-%m') AS month,
DATE_FORMAT(d.expected_closure_date, '%Y-%m') AS month,
SUM(
CASE
WHEN d.status = 'Lost' THEN d.deal_value * IFNULL(d.exchange_rate, 1)
ELSE d.deal_value * IFNULL(d.probability, 0) / 100 * IFNULL(d.exchange_rate, 1) -- forecasted
WHEN s.type = 'Lost' THEN d.expected_deal_value * IFNULL(d.exchange_rate, 1)
ELSE d.expected_deal_value * IFNULL(d.probability, 0) / 100 * IFNULL(d.exchange_rate, 1) -- forecasted
END
) AS forecasted,
SUM(
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
END
) AS actual
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}
GROUP BY DATE_FORMAT(d.close_date, '%Y-%m')
GROUP BY DATE_FORMAT(d.expected_closure_date, '%Y-%m')
ORDER BY month
""",
as_dict=True,
@ -581,7 +677,8 @@ def get_funnel_conversion_data(from_date="", to_date="", user="", lead_conds="",
# Get total leads
total_leads = frappe.db.sql(
f""" SELECT COUNT(*) AS count
f"""
SELECT COUNT(*) AS count
FROM `tabCRM Lead`
WHERE DATE(creation) BETWEEN %(from)s AND %(to)s
{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})
# Get deal stages
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})
result += get_deal_status_change_counts(from_date, to_date, deal_conds)
return result or []
@ -638,8 +717,10 @@ def get_deals_by_stage(from_date="", to_date="", user="", deal_conds=""):
f"""
SELECT
d.status AS stage,
COUNT(*) AS count
COUNT(*) AS count,
s.type AS status_type
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
{deal_conds}
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"
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",
"section_break_jgpm",
"probability",
"expected_deal_value",
"deal_value",
"column_break_kpxa",
"close_date",
"closed_on",
"expected_closure_date",
"closed_date",
"contacts_tab",
"contacts",
"contact",
@ -95,11 +96,6 @@
"fieldtype": "Data",
"label": "Website"
},
{
"fieldname": "close_date",
"fieldtype": "Date",
"label": "Close Date"
},
{
"fieldname": "next_step",
"fieldtype": "Data",
@ -412,23 +408,34 @@
"label": "Lost Notes",
"mandatory_depends_on": "eval: doc.lost_reason == \"Other\""
},
{
"fieldname": "closed_on",
"fieldtype": "Datetime",
"label": "Closed On"
},
{
"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.",
"fieldname": "exchange_rate",
"fieldtype": "Float",
"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,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-07-09 17:58:55.956639",
"modified": "2025-07-13 11:54:20.608489",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Deal",

View File

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

View File

@ -7,9 +7,11 @@
"engine": "InnoDB",
"field_order": [
"deal_status",
"color",
"type",
"position",
"probability"
"column_break_ojiu",
"probability",
"color"
],
"fields": [
{
@ -39,12 +41,24 @@
"fieldtype": "Percent",
"in_list_view": 1,
"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,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-07-01 12:06:42.937440",
"modified": "2025-07-11 16:03:28.077955",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Deal Status",

View File

@ -160,7 +160,7 @@ def add_forecasting_section(layout, doctype):
"columns": [
{
"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",
"duration",
"last_status_change_log",
"from_type",
"to_type",
"log_owner"
],
"fields": [
@ -61,18 +63,31 @@
"fieldtype": "Link",
"label": "Owner",
"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,
"istable": 1,
"links": [],
"modified": "2024-01-06 13:26:40.597277",
"modified": "2025-07-13 12:37:41.278584",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Status Change Log",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
}

View File

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

View File

@ -61,7 +61,7 @@
},
{
"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",
"fieldtype": "Check",
"label": "Enable Forecasting"
@ -77,7 +77,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-07-10 16:35:25.030011",
"modified": "2025-07-13 11:58:34.857638",
"modified_by": "Administrator",
"module": "FCRM",
"name": "FCRM Settings",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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