Merge pull request #920 from shariquerik/forecasting

This commit is contained in:
Shariq Ansari 2025-06-13 15:04:23 +05:30 committed by GitHub
commit 20405be86c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 202 additions and 51 deletions

View File

@ -11,11 +11,14 @@
"naming_series", "naming_series",
"organization", "organization",
"next_step", "next_step",
"probability",
"column_break_ijan", "column_break_ijan",
"status", "status",
"close_date",
"deal_owner", "deal_owner",
"section_break_jgpm",
"probability",
"deal_value",
"column_break_kpxa",
"close_date",
"contacts_tab", "contacts_tab",
"contacts", "contacts",
"contact", "contact",
@ -91,7 +94,7 @@
{ {
"fieldname": "close_date", "fieldname": "close_date",
"fieldtype": "Date", "fieldtype": "Date",
"label": "Close Date" "label": "Expected Closure Date"
}, },
{ {
"fieldname": "next_step", "fieldname": "next_step",
@ -374,12 +377,26 @@
"label": "Net Total", "label": "Net Total",
"options": "currency", "options": "currency",
"read_only": 1 "read_only": 1
},
{
"fieldname": "section_break_jgpm",
"fieldtype": "Section Break"
},
{
"fieldname": "deal_value",
"fieldtype": "Currency",
"label": "Deal Value",
"options": "currency"
},
{
"fieldname": "column_break_kpxa",
"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-05-12 12:30:55.415282", "modified": "2025-06-11 12:58:22.439045",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "FCRM", "module": "FCRM",
"name": "CRM Deal", "name": "CRM Deal",

View File

@ -24,6 +24,7 @@ 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)
self.update_close_date()
def after_insert(self): def after_insert(self):
if self.deal_owner: if self.deal_owner:
@ -133,6 +134,13 @@ class CRMDeal(Document):
if sla: if sla:
sla.apply(self) sla.apply(self)
def update_close_date(self):
"""
Update the close date based on the "Won" status.
"""
if self.status == "Won" and not self.close_date:
self.close_date = frappe.utils.nowdate()
@staticmethod @staticmethod
def default_list_data(): def default_list_data():
columns = [ columns = [

View File

@ -8,7 +8,8 @@
"field_order": [ "field_order": [
"deal_status", "deal_status",
"color", "color",
"position" "position",
"probability"
], ],
"fields": [ "fields": [
{ {
@ -32,11 +33,17 @@
"fieldtype": "Int", "fieldtype": "Int",
"in_list_view": 1, "in_list_view": 1,
"label": "Position" "label": "Position"
},
{
"fieldname": "probability",
"fieldtype": "Percent",
"label": "Probability"
} }
], ],
"grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2024-01-19 21:56:44.552134", "modified": "2025-06-11 13:00:34.518808",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "FCRM", "module": "FCRM",
"name": "CRM Deal Status", "name": "CRM Deal Status",
@ -68,7 +75,8 @@
"write": 1 "write": 1
} }
], ],
"row_format": "Dynamic",
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [] "states": []
} }

View File

@ -7,6 +7,7 @@
"field_order": [ "field_order": [
"defaults_tab", "defaults_tab",
"restore_defaults", "restore_defaults",
"enable_forecasting",
"branding_tab", "branding_tab",
"brand_name", "brand_name",
"brand_logo", "brand_logo",
@ -28,7 +29,7 @@
{ {
"fieldname": "defaults_tab", "fieldname": "defaults_tab",
"fieldtype": "Tab Break", "fieldtype": "Tab Break",
"label": "Defaults" "label": "Settings"
}, },
{ {
"fieldname": "branding_tab", "fieldname": "branding_tab",
@ -56,12 +57,19 @@
"fieldname": "favicon", "fieldname": "favicon",
"fieldtype": "Attach", "fieldtype": "Attach",
"label": "Favicon" "label": "Favicon"
},
{
"default": "0",
"description": "It will make deal's \"Expected Closure Date\" mandatory to get accurate forecasting insights",
"fieldname": "enable_forecasting",
"fieldtype": "Check",
"label": "Enable Forecasting"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2025-02-20 12:38:38.088477", "modified": "2025-06-11 19:12:16.762499",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "FCRM", "module": "FCRM",
"name": "FCRM Settings", "name": "FCRM Settings",
@ -95,7 +103,8 @@
"share": 1 "share": 1
} }
], ],
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [] "states": []
} }

View File

@ -3,6 +3,7 @@
import frappe import frappe
from frappe import _ from frappe import _
from frappe.custom.doctype.property_setter.property_setter import delete_property_setter, make_property_setter
from frappe.model.document import Document from frappe.model.document import Document
from crm.install import after_install from crm.install import after_install
@ -15,6 +16,7 @@ class FCRMSettings(Document):
def validate(self): def validate(self):
self.do_not_allow_to_delete_if_standard() self.do_not_allow_to_delete_if_standard()
self.setup_forecasting()
def do_not_allow_to_delete_if_standard(self): def do_not_allow_to_delete_if_standard(self):
if not self.has_value_changed("dropdown_items"): if not self.has_value_changed("dropdown_items"):
@ -29,6 +31,23 @@ class FCRMSettings(Document):
return return
frappe.throw(_("Cannot delete standard items {0}").format(", ".join(deleted_standard_items))) frappe.throw(_("Cannot delete standard items {0}").format(", ".join(deleted_standard_items)))
def setup_forecasting(self):
if self.has_value_changed("enable_forecasting"):
if not self.enable_forecasting:
delete_property_setter(
"CRM Deal",
"reqd",
"close_date",
)
else:
make_property_setter(
"CRM Deal",
"close_date",
"reqd",
1 if self.enable_forecasting else 0,
"Check",
)
def get_standard_dropdown_items(): def get_standard_dropdown_items():
return [item.get("name1") for item in frappe.get_hooks("standard_dropdown_items")] return [item.get("name1") for item in frappe.get_hooks("standard_dropdown_items")]
@ -57,3 +76,36 @@ def sync_table(key, hook):
crm_settings.set(key, items) crm_settings.set(key, items)
crm_settings.save() crm_settings.save()
def create_forecasting_script():
if not frappe.db.exists("CRM Form Script", "Forecasting Script"):
script = get_forecasting_script()
frappe.get_doc(
{
"doctype": "CRM Form Script",
"name": "Forecasting Script",
"dt": "CRM Deal",
"view": "Form",
"script": script,
"enabled": 1,
"is_standard": 1,
}
).insert()
def get_forecasting_script():
return """class CRMDeal {
async status() {
await this.doc.trigger('updateProbability')
}
async updateProbability() {
let status = await call("frappe.client.get_value", {
doctype: "CRM Deal Status",
fieldname: "probability",
filters: { name: this.doc.status },
})
this.doc.probability = status.probability
}
}"""

View File

@ -359,5 +359,8 @@ def add_standard_dropdown_items():
def add_default_scripts(): def add_default_scripts():
from crm.fcrm.doctype.fcrm_settings.fcrm_settings import create_forecasting_script
for doctype in ["CRM Lead", "CRM Deal"]: for doctype in ["CRM Lead", "CRM Deal"]:
create_product_details_script(doctype) create_product_details_script(doctype)
create_forecasting_script()

View File

@ -12,4 +12,4 @@ crm.patches.v1_0.create_default_sidebar_fields_layout
crm.patches.v1_0.update_deal_quick_entry_layout crm.patches.v1_0.update_deal_quick_entry_layout
crm.patches.v1_0.update_layouts_to_new_format 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 crm.patches.v1_0.create_default_scripts # 13-06-2025

View File

@ -509,8 +509,7 @@ const deleteRows = () => {
} }
function fieldChange(value, field, row) { function fieldChange(value, field, row) {
row[field.fieldname] = value triggerOnChange(field.fieldname, value, row)
triggerOnChange(field.fieldname, row)
} }
function getDefaultValue(defaultValue, fieldtype) { function getDefaultValue(defaultValue, fieldtype) {

View File

@ -332,12 +332,10 @@ const getPlaceholder = (field) => {
} }
function fieldChange(value, df) { function fieldChange(value, df) {
data.value[df.fieldname] = value
if (isGridRow) { if (isGridRow) {
triggerOnChange(df.fieldname, data.value) triggerOnChange(df.fieldname, value, data.value)
} else { } else {
triggerOnChange(df.fieldname) triggerOnChange(df.fieldname, value)
} }
} }

View File

@ -94,7 +94,7 @@ const show = defineModel()
const router = useRouter() const router = useRouter()
const error = ref(null) const error = ref(null)
const { document: deal } = useDocument('CRM Deal') const { document: deal, triggerOnChange } = useDocument('CRM Deal')
const hasOrganizationSections = ref(true) const hasOrganizationSections = ref(true)
const hasContactSections = ref(true) const hasContactSections = ref(true)
@ -164,7 +164,7 @@ const tabs = createResource({
}) })
const dealStatuses = computed(() => { const dealStatuses = computed(() => {
let statuses = statusOptions('deal') let statuses = statusOptions('deal', null, [], triggerOnChange)
if (!deal.doc.status) { if (!deal.doc.status) {
deal.doc.status = statuses[0].value deal.doc.status = statuses[0].value
} }

View File

@ -70,10 +70,10 @@ const router = useRouter()
const error = ref(null) const error = ref(null)
const isLeadCreating = ref(false) const isLeadCreating = ref(false)
const { document: lead } = useDocument('CRM Lead') const { document: lead, triggerOnChange } = useDocument('CRM Lead')
const leadStatuses = computed(() => { const leadStatuses = computed(() => {
let statuses = statusOptions('lead') let statuses = statusOptions('lead', null, [], triggerOnChange)
if (!lead.doc.status) { if (!lead.doc.status) {
lead.doc.status = statuses?.[0]?.value lead.doc.status = statuses?.[0]?.value
} }

View File

@ -44,18 +44,21 @@
> >
<Tooltip :text="__(field.label)" :hoverDelay="1"> <Tooltip :text="__(field.label)" :hoverDelay="1">
<div <div
class="w-[35%] min-w-20 shrink-0 truncate text-sm text-ink-gray-5" class="w-[35%] min-w-20 shrink-0 flex items-center gap-0.5"
> >
{{ __(field.label) }} <div class="truncate text-sm text-ink-gray-5">
<span {{ __(field.label) }}
</div>
<div
v-if=" v-if="
field.reqd || field.reqd ||
(field.mandatory_depends_on && (field.mandatory_depends_on &&
field.mandatory_via_depends_on) field.mandatory_via_depends_on)
" "
class="text-ink-red-2" class="text-ink-red-2"
>*</span
> >
*
</div>
</div> </div>
</Tooltip> </Tooltip>
<div class="flex items-center justify-between w-[65%]"> <div class="flex items-center justify-between w-[65%]">
@ -489,9 +492,7 @@ function parsedField(field) {
async function fieldChange(value, df) { async function fieldChange(value, df) {
if (props.preview) return if (props.preview) return
document.doc[df.fieldname] = value await triggerOnChange(df.fieldname, value)
await triggerOnChange(df.fieldname)
document.save.submit(null, { document.save.submit(null, {
onSuccess: () => { onSuccess: () => {

View File

@ -117,20 +117,26 @@ export function useDocument(doctype, docname) {
await trigger(handler) await trigger(handler)
} }
async function triggerOnChange(fieldname, row) { async function triggerOnChange(fieldname, value, row) {
const oldValue = documentsCache[doctype][docname || ''].doc[fieldname]
documentsCache[doctype][docname || ''].doc[fieldname] = value
const handler = async function () { const handler = async function () {
this.value = value
this.oldValue = oldValue
if (row) { if (row) {
this.currentRowIdx = row.idx this.currentRowIdx = row.idx
this.value = row[fieldname]
this.oldValue = getOldValue(fieldname, row)
} else {
this.value = documentsCache[doctype][docname || ''].doc[fieldname]
this.oldValue = getOldValue(fieldname)
} }
await this[fieldname]?.() await this[fieldname]?.()
} }
await trigger(handler, row) try {
await trigger(handler, row)
} catch (error) {
documentsCache[doctype][docname || ''].doc[fieldname] = oldValue
console.error(handler)
throw error
}
} }
async function triggerOnRowAdd(row) { async function triggerOnRowAdd(row) {

View File

@ -46,6 +46,11 @@ export function getScript(doctype, view = 'Form') {
helpers.router = router helpers.router = router
helpers.call = call helpers.call = call
helpers.throwError = (message) => {
toast.error(message || __('An error occurred'))
throw new Error(message || __('An error occurred'))
}
helpers.crm = { helpers.crm = {
makePhoneCall: makeCall, makePhoneCall: makeCall,
} }

View File

@ -23,7 +23,14 @@
/> />
<Dropdown <Dropdown
v-if="document.doc" v-if="document.doc"
:options="statusOptions('deal', document, deal.data._customStatuses)" :options="
statusOptions(
'deal',
document,
deal.data._customStatuses,
triggerOnChange,
)
"
> >
<template #default="{ open }"> <template #default="{ open }">
<Button :label="document.doc.status"> <Button :label="document.doc.status">
@ -726,7 +733,10 @@ function openEmailBox() {
activities.value.emailBox.show = true activities.value.emailBox.show = true
} }
const { assignees, document } = useDocument('CRM Deal', props.dealId) const { assignees, document, triggerOnChange } = useDocument(
'CRM Deal',
props.dealId,
)
function reloadAssignees(data) { function reloadAssignees(data) {
if (data?.hasOwnProperty('deal_owner')) { if (data?.hasOwnProperty('deal_owner')) {

View File

@ -23,7 +23,14 @@
/> />
<Dropdown <Dropdown
v-if="document.doc" v-if="document.doc"
:options="statusOptions('lead', document, lead.data._customStatuses)" :options="
statusOptions(
'lead',
document,
lead.data._customStatuses,
triggerOnChange,
)
"
> >
<template #default="{ open }"> <template #default="{ open }">
<Button :label="document.doc.status"> <Button :label="document.doc.status">
@ -344,7 +351,12 @@ import SidePanelLayout from '@/components/SidePanelLayout.vue'
import FieldLayout from '@/components/FieldLayout/FieldLayout.vue' import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
import SLASection from '@/components/SLASection.vue' import SLASection from '@/components/SLASection.vue'
import CustomActions from '@/components/CustomActions.vue' import CustomActions from '@/components/CustomActions.vue'
import { openWebsite, setupCustomizations, copyToClipboard, validateIsImageFile } from '@/utils' import {
openWebsite,
setupCustomizations,
copyToClipboard,
validateIsImageFile,
} from '@/utils'
import { showQuickEntryModal, quickEntryProps } from '@/composables/modals' import { showQuickEntryModal, quickEntryProps } from '@/composables/modals'
import { getView } from '@/utils/view' import { getView } from '@/utils/view'
import { getSettings } from '@/stores/settings' import { getSettings } from '@/stores/settings'
@ -605,10 +617,8 @@ const existingOrganizationChecked = ref(false)
const existingContact = ref('') const existingContact = ref('')
const existingOrganization = ref('') const existingOrganization = ref('')
const { triggerConvertToDeal, assignees, document } = useDocument( const { triggerConvertToDeal, triggerOnChange, assignees, document } =
'CRM Lead', useDocument('CRM Lead', props.leadId)
props.leadId,
)
async function convertToDeal() { async function convertToDeal() {
if (existingContactChecked.value && !existingContact.value) { if (existingContactChecked.value && !existingContact.value) {

View File

@ -11,7 +11,14 @@
<div class="absolute right-0"> <div class="absolute right-0">
<Dropdown <Dropdown
v-if="document.doc" v-if="document.doc"
:options="statusOptions('deal', document, deal.data._customStatuses)" :options="
statusOptions(
'deal',
document,
deal.data._customStatuses,
triggerOnChange,
)
"
> >
<template #default="{ open }"> <template #default="{ open }">
<Button :label="document.doc.status"> <Button :label="document.doc.status">
@ -612,7 +619,10 @@ async function deleteDeal(name) {
router.push({ name: 'Deals' }) router.push({ name: 'Deals' })
} }
const { assignees, document } = useDocument('CRM Deal', props.dealId) const { assignees, document, triggerOnChange } = useDocument(
'CRM Deal',
props.dealId,
)
function reloadAssignees(data) { function reloadAssignees(data) {
if (data?.hasOwnProperty('deal_owner')) { if (data?.hasOwnProperty('deal_owner')) {

View File

@ -11,7 +11,14 @@
<div class="absolute right-0"> <div class="absolute right-0">
<Dropdown <Dropdown
v-if="document.doc" v-if="document.doc"
:options="statusOptions('lead', document, lead.data._customStatuses)" :options="
statusOptions(
'lead',
document,
lead.data._customStatuses,
triggerOnChange,
)
"
> >
<template #default="{ open }"> <template #default="{ open }">
<Button :label="document.doc.status"> <Button :label="document.doc.status">
@ -461,7 +468,10 @@ async function convertToDeal() {
} }
} }
const { assignees, document } = useDocument('CRM Lead', props.leadId) const { assignees, document, triggerOnChange } = useDocument(
'CRM Lead',
props.leadId,
)
function reloadAssignees(data) { function reloadAssignees(data) {
if (data?.hasOwnProperty('lead_owner')) { if (data?.hasOwnProperty('lead_owner')) {

View File

@ -77,7 +77,12 @@ export const statusesStore = defineStore('crm-statuses', () => {
return communicationStatuses[name] return communicationStatuses[name]
} }
function statusOptions(doctype, document, statuses = []) { function statusOptions(
doctype,
document,
statuses = [],
triggerOnChange = null,
) {
let statusesByName = let statusesByName =
doctype == 'deal' ? dealStatusesByName : leadStatusesByName doctype == 'deal' ? dealStatusesByName : leadStatusesByName
@ -85,7 +90,7 @@ export const statusesStore = defineStore('crm-statuses', () => {
statuses = document.statuses statuses = document.statuses
} }
if (statuses.length) { if (statuses?.length) {
statusesByName = statuses.reduce((acc, status) => { statusesByName = statuses.reduce((acc, status) => {
acc[status] = statusesByName[status] acc[status] = statusesByName[status]
return acc return acc
@ -98,10 +103,10 @@ export const statusesStore = defineStore('crm-statuses', () => {
label: statusesByName[status]?.name, label: statusesByName[status]?.name,
value: statusesByName[status]?.name, value: statusesByName[status]?.name,
icon: () => h(IndicatorIcon, { class: statusesByName[status]?.color }), icon: () => h(IndicatorIcon, { class: statusesByName[status]?.color }),
onClick: () => { onClick: async () => {
capture('status_changed', { doctype, status }) capture('status_changed', { doctype, status })
if (document) { if (document) {
document.doc.status = statusesByName[status]?.name await triggerOnChange?.('status', statusesByName[status]?.name)
document.save.submit() document.save.submit()
} }
}, },