Merge pull request #1002 from frappe/mergify/bp/main-hotfix/pr-984

This commit is contained in:
Shariq Ansari 2025-07-02 16:46:10 +05:30 committed by GitHub
commit 9f6832a5b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 413 additions and 58 deletions

View File

@ -14,6 +14,8 @@
"column_break_ijan",
"status",
"deal_owner",
"lost_reason",
"lost_notes",
"section_break_jgpm",
"probability",
"deal_value",
@ -391,12 +393,25 @@
{
"fieldname": "column_break_kpxa",
"fieldtype": "Column Break"
},
{
"fieldname": "lost_reason",
"fieldtype": "Link",
"label": "Lost Reason",
"mandatory_depends_on": "eval: doc.status == \"Lost\"",
"options": "CRM Lost Reason"
},
{
"fieldname": "lost_notes",
"fieldtype": "Text",
"label": "Lost Notes",
"mandatory_depends_on": "eval: doc.lost_reason == \"Other\""
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-06-16 11:42:49.413483",
"modified": "2025-07-02 11:07:50.192089",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Deal",

View File

@ -25,6 +25,7 @@ class CRMDeal(Document):
if self.has_value_changed("status"):
add_status_change_log(self)
self.validate_forcasting_fields()
self.validate_lost_reason()
def after_insert(self):
if self.deal_owner:
@ -141,14 +142,32 @@ class CRMDeal(Document):
if self.status == "Won" and not self.close_date:
self.close_date = frappe.utils.nowdate()
def update_default_probability(self):
"""
Update the default probability based on the status.
"""
if not self.probability or self.probability == 0:
self.probability = frappe.db.get_value("CRM Deal Status", self.status, "probability") or 0
def validate_forcasting_fields(self):
self.update_close_date()
self.update_default_probability()
if frappe.db.get_single_value("FCRM Settings", "enable_forecasting"):
if not self.deal_value or self.deal_value == 0:
frappe.throw(_("Deal Value is required."), frappe.MandatoryError)
if not self.close_date:
frappe.throw(_("Close Date is required."), frappe.MandatoryError)
def validate_lost_reason(self):
"""
Validate the lost reason if the status is set to "Lost".
"""
if self.status == "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:
frappe.throw(_("Please specify the reason for losing the deal."), frappe.ValidationError)
@staticmethod
def default_list_data():
columns = [

View File

@ -27,9 +27,10 @@
"label": "Details"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-01-02 22:13:30.498404",
"modified": "2025-06-30 16:53:51.721752",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Lead Source",
@ -44,7 +45,7 @@
"print": 1,
"read": 1,
"report": 1,
"role": "Sales User",
"role": "System Manager",
"share": 1,
"write": 1
},
@ -60,6 +61,15 @@
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Sales User",
"share": 1
},
{
"email": 1,
"export": 1,
@ -71,7 +81,8 @@
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
}

View File

@ -0,0 +1,8 @@
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
// frappe.ui.form.on("CRM Lost Reason", {
// refresh(frm) {
// },
// });

View File

@ -0,0 +1,79 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:lost_reason",
"creation": "2025-06-30 16:51:31.082360",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"lost_reason",
"description"
],
"fields": [
{
"fieldname": "lost_reason",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Lost Reason",
"reqd": 1,
"unique": 1
},
{
"fieldname": "description",
"fieldtype": "Text Editor",
"label": "Description"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-06-30 16:59:15.094049",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Lost Reason",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Sales Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Sales User",
"share": 1,
"write": 1
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@ -0,0 +1,9 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class CRMLostReason(Document):
pass

View File

@ -0,0 +1,30 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
# On IntegrationTestCase, the doctype test records and all
# link-field test record dependencies are recursively loaded
# Use these module variables to add/remove to/from that list
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
class UnitTestCRMLostReason(UnitTestCase):
"""
Unit tests for CRMLostReason.
Use this class for testing individual functions and methods.
"""
pass
class IntegrationTestCRMLostReason(IntegrationTestCase):
"""
Integration tests for CRMLostReason.
Use this class for testing interactions between multiple components.
"""
pass

View File

@ -162,7 +162,7 @@ declare module 'vue' {
ListIcon: typeof import('./src/components/Icons/ListIcon.vue')['default']
ListRows: typeof import('./src/components/ListViews/ListRows.vue')['default']
LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default']
LucideCalendar: typeof import('~icons/lucide/calendar')['default']
LostReasonModal: typeof import('./src/components/Modals/LostReasonModal.vue')['default']
LucideInfo: typeof import('~icons/lucide/info')['default']
LucideMoreHorizontal: typeof import('~icons/lucide/more-horizontal')['default']
LucidePlus: typeof import('~icons/lucide/plus')['default']

View File

@ -368,6 +368,7 @@
<DataFields
:doctype="doctype"
:docname="doc.data.name"
@beforeSave="(data) => emit('beforeSave', data)"
@afterSave="(data) => emit('afterSave', data)"
/>
</div>
@ -518,7 +519,7 @@ const props = defineProps({
},
})
const emit = defineEmits(['afterSave'])
const emit = defineEmits(['beforeSave', 'afterSave'])
const route = useRoute()

View File

@ -66,7 +66,7 @@ import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue'
import { usersStore } from '@/stores/users'
import { useDocument } from '@/data/document'
import { isMobileView } from '@/composables/settings'
import { ref, watch } from 'vue'
import { ref, watch, getCurrentInstance } from 'vue'
const props = defineProps({
doctype: {
@ -79,10 +79,13 @@ const props = defineProps({
},
})
const emit = defineEmits(['afterSave'])
const emit = defineEmits(['beforeSave', 'afterSave'])
const { isManager } = usersStore()
const instance = getCurrentInstance()
const attrs = instance?.vnode?.props ?? {}
const showDataFieldsModal = ref(false)
const { document } = useDocument(props.doctype, props.docname)
@ -107,9 +110,15 @@ function saveChanges() {
return acc
}, {})
document.save.submit(null, {
onSuccess: () => emit('afterSave', changes),
})
const hasListener = attrs['onBeforeSave'] !== undefined
if (hasListener) {
emit('beforeSave', changes)
} else {
document.save.submit(null, {
onSuccess: () => emit('afterSave', changes),
})
}
}
watch(

View File

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

View File

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

View File

@ -0,0 +1,98 @@
<template>
<Dialog
v-model="show"
:options="{ title: __('Lost reason') }"
@close="cancel"
>
<template #body-content>
<div class="-mt-3 mb-4 text-p-base text-ink-gray-7">
{{ __('Please provide a reason for marking this deal as lost') }}
</div>
<div class="flex flex-col gap-3">
<div>
<div class="mb-2 text-sm text-ink-gray-5">
{{ __('Lost reason') }}
<span class="text-ink-red-2">*</span>
</div>
<Link
class="form-control flex-1 truncate"
:value="lostReason"
doctype="CRM Lost Reason"
@change="(v) => (lostReason = v)"
:onCreate="onCreate"
/>
</div>
<div>
<div class="mb-2 text-sm text-ink-gray-5">
{{ __('Lost notes') }}
<span v-if="lostReason == 'Other'" class="text-ink-red-2">*</span>
</div>
<FormControl
class="form-control flex-1 truncate"
type="textarea"
:value="lostNotes"
@change="(e) => (lostNotes = e.target.value)"
/>
</div>
</div>
</template>
<template #actions>
<div class="flex justify-between items-center gap-2">
<div><ErrorMessage :message="error" /></div>
<div class="flex gap-2">
<Button :label="__('Cancel')" @click="cancel" />
<Button variant="solid" :label="__('Save')" @click="save" />
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import Link from '@/components/Controls/Link.vue'
import { createDocument } from '@/composables/document'
import { Dialog } from 'frappe-ui'
import { ref } from 'vue'
const props = defineProps({
deal: {
type: Object,
required: true,
},
})
const show = defineModel()
const lostReason = ref(props.deal.doc.lost_reason || '')
const lostNotes = ref(props.deal.doc.lost_notes || '')
const error = ref('')
function cancel() {
show.value = false
error.value = ''
lostReason.value = ''
lostNotes.value = ''
props.deal.doc.status = props.deal.originalDoc.status
}
function save() {
if (!lostReason.value) {
error.value = __('Lost reason is required')
return
}
if (lostReason.value === 'Other' && !lostNotes.value) {
error.value = __('Lost notes are required when lost reason is "Other"')
return
}
error.value = ''
show.value = false
props.deal.doc.lost_reason = lostReason.value
props.deal.doc.lost_notes = lostNotes.value
props.deal.save.submit()
}
function onCreate(value, close) {
createDocument('CRM Lost Reason', value, close)
}
</script>

View File

@ -400,7 +400,7 @@ import { getFormat, evaluateDependsOnValue } from '@/utils'
import { flt } from '@/utils/numberFormat.js'
import { Tooltip, DateTimePicker, DatePicker } from 'frappe-ui'
import { useDocument } from '@/data/document'
import { ref, computed } from 'vue'
import { ref, computed, getCurrentInstance } from 'vue'
const props = defineProps({
sections: {
@ -424,7 +424,7 @@ const props = defineProps({
},
})
const emit = defineEmits(['afterFieldChange', 'reload'])
const emit = defineEmits(['beforeFieldChange', 'afterFieldChange', 'reload'])
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
getMeta(props.doctype)
@ -496,18 +496,23 @@ function parsedField(field) {
return _field
}
const instance = getCurrentInstance()
const attrs = instance?.vnode?.props ?? {}
async function fieldChange(value, df) {
if (props.preview) return
await triggerOnChange(df.fieldname, value)
document.save.submit(null, {
onSuccess: () => {
emit('afterFieldChange', {
[df.fieldname]: value,
})
},
})
const hasListener = attrs['onBeforeFieldChange'] !== undefined
if (hasListener) {
emit('beforeFieldChange', { [df.fieldname]: value })
} else {
document.save.submit(null, {
onSuccess: () => emit('afterFieldChange', { [df.fieldname]: value }),
})
}
}
function parsedSection(section, editButtonAdded) {

View File

@ -26,9 +26,10 @@
:options="
statusOptions(
'deal',
document,
deal.data._customStatuses,
triggerOnChange,
document.statuses?.length
? document.statuses
: deal.data._customStatuses,
triggerStatusChange,
)
"
>
@ -60,6 +61,7 @@
v-model:reload="reload"
v-model:tabIndex="tabIndex"
v-model="deal"
@beforeSave="beforeStatusChange"
@afterSave="reloadAssignees"
/>
</template>
@ -147,6 +149,7 @@
doctype="CRM Deal"
:docname="deal.data.name"
@reload="sections.reload"
@beforeFieldChange="beforeStatusChange"
@afterFieldChange="reloadAssignees"
>
<template #actions="{ section }">
@ -326,6 +329,11 @@
:docname="props.dealId"
name="Deals"
/>
<LostReasonModal
v-if="showLostReasonModal"
v-model="showLostReasonModal"
:deal="document"
/>
</template>
<script setup>
import ErrorPage from '@/components/ErrorPage.vue'
@ -349,6 +357,7 @@ import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
import Activities from '@/components/Activities/Activities.vue'
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
import LostReasonModal from '@/components/Modals/LostReasonModal.vue'
import AssignTo from '@/components/AssignTo.vue'
import FilesUploader from '@/components/FilesUploader/FilesUploader.vue'
import ContactModal from '@/components/Modals/ContactModal.vue'
@ -755,6 +764,36 @@ const { assignees, document, triggerOnChange } = useDocument(
props.dealId,
)
async function triggerStatusChange(value) {
await triggerOnChange('status', value)
setLostReason()
}
const showLostReasonModal = ref(false)
function setLostReason() {
if (
document.doc.status !== 'Lost' ||
(document.doc.lost_reason && document.doc.lost_reason !== 'Other') ||
(document.doc.lost_reason === 'Other' && document.doc.lost_notes)
) {
document.save.submit()
return
}
showLostReasonModal.value = true
}
function beforeStatusChange(data) {
if (data?.hasOwnProperty('status') && data.status == 'Lost') {
setLostReason()
} else {
document.save.submit(null, {
onSuccess: () => reloadAssignees(data),
})
}
}
function reloadAssignees(data) {
if (data?.hasOwnProperty('deal_owner')) {
assignees.reload()

View File

@ -26,9 +26,10 @@
:options="
statusOptions(
'lead',
document,
lead.data._customStatuses,
triggerOnChange,
document.statuses?.length
? document.statuses
: lead.data._customStatuses,
triggerStatusChange,
)
"
>
@ -320,6 +321,11 @@ const { triggerOnChange, assignees, document } = useDocument(
props.leadId,
)
async function triggerStatusChange(value) {
await triggerOnChange('status', value)
document.save.submit()
}
const lead = createResource({
url: 'crm.fcrm.doctype.crm_lead.api.get_lead',
params: { name: props.leadId },

View File

@ -14,9 +14,10 @@
:options="
statusOptions(
'deal',
document,
deal.data._customStatuses,
triggerOnChange,
document.statuses?.length
? document.statuses
: deal.data._customStatuses,
triggerStatusChange,
)
"
>
@ -78,6 +79,7 @@
doctype="CRM Deal"
:docname="deal.data.name"
@reload="sections.reload"
@beforeFieldChange="beforeStatusChange"
@afterFieldChange="reloadAssignees"
>
<template #actions="{ section }">
@ -222,6 +224,8 @@
v-model:reload="reload"
v-model:tabIndex="tabIndex"
v-model="deal"
@beforeSave="beforeStatusChange"
@afterSave="reloadAssignees"
/>
</TabPanel>
</Tabs>
@ -244,6 +248,11 @@
afterInsert: (doc) => addContact(doc.name),
}"
/>
<LostReasonModal
v-if="showLostReasonModal"
v-model="showLostReasonModal"
:deal="document"
/>
</template>
<script setup>
import Icon from '@/components/Icon.vue'
@ -264,6 +273,7 @@ import SuccessIcon from '@/components/Icons/SuccessIcon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
import Activities from '@/components/Activities/Activities.vue'
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
import LostReasonModal from '@/components/Modals/LostReasonModal.vue'
import AssignTo from '@/components/AssignTo.vue'
import ContactModal from '@/components/Modals/ContactModal.vue'
import Section from '@/components/Section.vue'
@ -624,6 +634,36 @@ const { assignees, document, triggerOnChange } = useDocument(
props.dealId,
)
async function triggerStatusChange(value) {
await triggerOnChange('status', value)
setLostReason()
}
const showLostReasonModal = ref(false)
function setLostReason() {
if (
document.doc.status !== 'Lost' ||
(document.doc.lost_reason && document.doc.lost_reason !== 'Other') ||
(document.doc.lost_reason === 'Other' && document.doc.lost_notes)
) {
document.save.submit()
return
}
showLostReasonModal.value = true
}
function beforeStatusChange(data) {
if (data?.hasOwnProperty('status') && data.status == 'Lost') {
setLostReason()
} else {
document.save.submit(null, {
onSuccess: () => reloadAssignees(data),
})
}
}
function reloadAssignees(data) {
if (data?.hasOwnProperty('deal_owner')) {
assignees.reload()

View File

@ -14,9 +14,10 @@
:options="
statusOptions(
'lead',
document,
lead.data._customStatuses,
triggerOnChange,
document.statuses?.length
? document.statuses
: lead.data._customStatuses,
triggerStatusChange,
)
"
>
@ -473,6 +474,11 @@ const { assignees, document, triggerOnChange } = useDocument(
props.leadId,
)
async function triggerStatusChange(value) {
await triggerOnChange('status', value)
document.save.submit()
}
function reloadAssignees(data) {
if (data?.hasOwnProperty('lead_owner')) {
assignees.reload()

View File

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