From fe9a7d45968d3bbd91cc424fd9ff03a5624ec5c1 Mon Sep 17 00:00:00 2001 From: Yangk Date: Thu, 19 Mar 2026 16:17:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(oa/crm):=20=E9=87=8D=E6=9E=84=E5=87=BA?= =?UTF-8?q?=E5=B7=AE=E7=94=B3=E8=AF=B7=E8=A1=A8=E5=8D=95=E4=B8=BA=E4=B8=BB?= =?UTF-8?q?=E5=AD=90=E8=A1=A8=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将原有静态字段改为动态行程明细表格模式 - 添加行程明细的增删改查功能 - 实现明细数据自动汇总到主表功能 - 优化出差类型相关的动态字段显示逻辑 - 调整审批流程中的抄送人员处理机制 - 更新表单验证规则以适配新的数据结构 - 添加业务方向负责人的权限控制逻辑 --- src/api/oa/crm/businessTripDetails/index.ts | 76 +++ src/api/oa/crm/businessTripDetails/types.ts | 231 +++++++ src/views/oa/crm/businessTripApply/edit.vue | 675 ++++++++++++++----- src/views/oa/crm/businessTripApply/index.vue | 59 +- 4 files changed, 858 insertions(+), 183 deletions(-) create mode 100644 src/api/oa/crm/businessTripDetails/index.ts create mode 100644 src/api/oa/crm/businessTripDetails/types.ts diff --git a/src/api/oa/crm/businessTripDetails/index.ts b/src/api/oa/crm/businessTripDetails/index.ts new file mode 100644 index 0000000..279ed4c --- /dev/null +++ b/src/api/oa/crm/businessTripDetails/index.ts @@ -0,0 +1,76 @@ +import request from '@/utils/request'; +import { AxiosPromise } from 'axios'; +import { BusinessTripDetailsVO, BusinessTripDetailsForm, BusinessTripDetailsQuery } from '@/api/oa/crm/businessTripDetails/types'; + +/** + * 查询出差申请明细列表 + * @param query + * @returns {*} + */ + +export const listBusinessTripDetails = (query?: BusinessTripDetailsQuery): AxiosPromise => { + return request({ + url: '/oa/crm/businessTripDetails/list', + method: 'get', + params: query + }); +}; + +/** + * 查询出差申请明细详细 + * @param tripDetailsId + */ +export const getBusinessTripDetails = (tripDetailsId: string | number): AxiosPromise => { + return request({ + url: '/oa/crm/businessTripDetails/' + tripDetailsId, + method: 'get' + }); +}; + +/** + * 新增出差申请明细 + * @param data + */ +export const addBusinessTripDetails = (data: BusinessTripDetailsForm) => { + return request({ + url: '/oa/crm/businessTripDetails', + method: 'post', + data: data + }); +}; + +/** + * 修改出差申请明细 + * @param data + */ +export const updateBusinessTripDetails = (data: BusinessTripDetailsForm) => { + return request({ + url: '/oa/crm/businessTripDetails', + method: 'put', + data: data + }); +}; + +/** + * 删除出差申请明细 + * @param tripDetailsId + */ +export const delBusinessTripDetails = (tripDetailsId: string | number | Array) => { + return request({ + url: '/oa/crm/businessTripDetails/' + tripDetailsId, + method: 'delete' + }); +}; + +/** + * 下拉框查询出差申请明细列表 + * @param query + * @returns {*} + */ +export function getCrmBusinessTripDetailsList (query) { + return request({ + url: '/oa/crm/businessTripDetails/getCrmBusinessTripDetailsList', + method: 'get', + params: query + }); +}; diff --git a/src/api/oa/crm/businessTripDetails/types.ts b/src/api/oa/crm/businessTripDetails/types.ts new file mode 100644 index 0000000..d9e4ead --- /dev/null +++ b/src/api/oa/crm/businessTripDetails/types.ts @@ -0,0 +1,231 @@ +export interface BusinessTripDetailsVO { + /** + * 申请明细ID + */ + tripDetailsId: string | number; + + /** + * 出差申请ID + */ + tripId: string | number; + + /** + * 行程顺序(行程1) + */ + itineraryNumber: number; + + /** + * 出差地点 + */ + tripLocation: string; + + /** + * 开始日期 + */ + startTime: string; + + /** + * 结束日期 + */ + endTime: string; + + /** + * 时长(天) + */ + durationDays: number; + + /** + * 项目ID + */ + projectId: string | number; + + /** + * 客户ID + */ + customerId: string | number; + + /** + * 会议/展会名称 + */ + meetingName: string; + + /** + * 交流目的 + */ + exchangePurpose: string; + + /** + * 交流过程与反馈 + */ + exchangeFeedback: string; + + /** + * 出差事由 + */ + tripReason: string; + + /** + * 备注 + */ + remark: string; + + /** + * 附件ID(多个用逗号分隔) + */ + ossId: string | number; + +} + +export interface BusinessTripDetailsForm extends BaseEntity { + /** + * 申请明细ID + */ + tripDetailsId?: string | number; + + /** + * 出差申请ID + */ + tripId?: string | number; + + /** + * 行程顺序(行程1) + */ + itineraryNumber?: number; + + /** + * 出差地点 + */ + tripLocation?: string; + + /** + * 开始日期 + */ + startTime?: string; + + /** + * 结束日期 + */ + endTime?: string; + + /** + * 时长(天) + */ + durationDays?: number; + + /** + * 项目ID + */ + projectId?: string | number; + + /** + * 客户ID + */ + customerId?: string | number; + + /** + * 会议/展会名称 + */ + meetingName?: string; + + /** + * 交流目的 + */ + exchangePurpose?: string; + + /** + * 交流过程与反馈 + */ + exchangeFeedback?: string; + + /** + * 出差事由 + */ + tripReason?: string; + + /** + * 备注 + */ + remark?: string; + + /** + * 附件ID(多个用逗号分隔) + */ + ossId?: string | number; + +} + +export interface BusinessTripDetailsQuery extends PageQuery { + + /** + * 出差申请ID + */ + tripId?: string | number; + + /** + * 行程顺序(行程1) + */ + itineraryNumber?: number; + + /** + * 出差地点 + */ + tripLocation?: string; + + /** + * 开始日期 + */ + startTime?: string; + + /** + * 结束日期 + */ + endTime?: string; + + /** + * 时长(天) + */ + durationDays?: number; + + /** + * 项目ID + */ + projectId?: string | number; + + /** + * 客户ID + */ + customerId?: string | number; + + /** + * 会议/展会名称 + */ + meetingName?: string; + + /** + * 交流目的 + */ + exchangePurpose?: string; + + /** + * 交流过程与反馈 + */ + exchangeFeedback?: string; + + /** + * 出差事由 + */ + tripReason?: string; + + /** + * 附件ID(多个用逗号分隔) + */ + ossId?: string | number; + + /** + * 日期范围参数 + */ + params?: any; +} + + + diff --git a/src/views/oa/crm/businessTripApply/edit.vue b/src/views/oa/crm/businessTripApply/edit.vue index bd4f1a7..b9b7e85 100644 --- a/src/views/oa/crm/businessTripApply/edit.vue +++ b/src/views/oa/crm/businessTripApply/edit.vue @@ -46,167 +46,49 @@ - - - - - - - - - - - - - - + + + + + + - + - + @@ -261,6 +143,232 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -287,6 +395,7 @@ import ApprovalRecord from '@/components/Process/approvalRecord.vue'; import ProjectSelect from '@/components/ProjectSelect/index.vue'; import { CodeRuleEnum, FlowCodeEnum } from '@/enums/OAEnum'; import { getInfo } from '@/api/login'; +import { useUserStore } from '@/store/modules/user'; import { listUser } from '@/api/system/user'; // 导入用户列表API import { allListDept } from '@/api/system/dept'; // 导入部门列表API import { getCrmCustomerInfoList } from '@/api/oa/crm/customerInfo'; // 导入客户列表API @@ -323,6 +432,33 @@ const submitFormData = ref({ }); const taskVariables = ref>({}); +// 业务方向负责人ID映射 +const BUSINESS_DIRECTION_LEADER_MAP: Record = { + 1: '1985254821554475009', + 2: '1985258519835889666', + 3: '1985251968270127105', + 4: '1985257496048226305', + 5: '1985254145713688578' +}; + +// 交流过程与反馈字段可见性:仅申请人、业务方向负责人、陈海军、张东辉可查看 +const canViewFeedback = computed(() => { + const currentUserId = String(useUserStore().userId); + // 1. 申请人本人 + if (form.value.applicantId && currentUserId === String(form.value.applicantId)) return true; + // 2. 陈海军、张东辉(通过 userList 匹配) + const fixedNames = ['陈海军', '张东辉']; + const fixedIds = fixedNames + .map((name) => userList.value.find((u: any) => u.nickName === name)) + .filter(Boolean) + .map((u: any) => String(u.userId)); + if (fixedIds.includes(currentUserId)) return true; + // 3. 当前单据的业务方向负责人 + const bd = Number(form.value.businessDirection); + if (bd && BUSINESS_DIRECTION_LEADER_MAP[bd] && currentUserId === BUSINESS_DIRECTION_LEADER_MAP[bd]) return true; + return false; +}); + const initFormData: BusinessTripApplyForm = { tripId: undefined, applyCode: undefined, @@ -341,15 +477,14 @@ const initFormData: BusinessTripApplyForm = { exchangeObject: undefined, businessDirection: undefined, exchangePurpose: undefined, - exchangeProcess: undefined, - meetingName: undefined, - feedback: undefined, + exchangeFeedback: undefined, tripStatus: '1', // 默认暂存 flowStatus: 'draft', remark: undefined, // ossId: undefined, variables: {}, // 初始化 variables - copyUserIds: [] as string[] // 抄送人员ID列表 + copyUserIds: [] as string[], // 抄送人员ID列表 + crmBusinessTripDetailsList: [] // 行程明细列表 }; const data = reactive({ @@ -357,18 +492,14 @@ const data = reactive({ rules: { tripType: [{ required: true, message: '出差类型不能为空', trigger: 'change' }], // applyCode: [{ required: true, message: '申请单号不能为空', trigger: 'blur' }], - tripLocation: [{ required: true, message: '出差地点不能为空', trigger: 'blur' }], - startTime: [{ required: true, message: '开始时间不能为空', trigger: 'change' }], - endTime: [{ required: true, message: '结束时间不能为空', trigger: 'change' }], - durationDays: [{ required: true, message: '时长不能为空', trigger: 'blur' }], + // 动态校验 projectId: [{ required: true, message: '项目名称不能为空', trigger: 'change' }], exchangeObject: [{ required: true, message: '交流对象不能为空', trigger: 'blur' }], customerId: [{ required: true, message: '请选择交流对象(客户)', trigger: 'change' }], businessDirection: [{ required: true, message: '业务方向不能为空', trigger: 'change' }], exchangePurpose: [{ required: true, message: '交流目的不能为空', trigger: 'blur' }], - exchangeProcess: [{ required: true, message: '交流过程简述不能为空', trigger: 'blur' }], - feedback: [{ required: true, message: '结果反馈不能为空', trigger: 'blur' }], + exchangeFeedback: [{ required: true, message: '交流过程与反馈不能为空', trigger: 'blur' }], meetingName: [{ required: true, message: '会议/展会名称不能为空', trigger: 'blur' }], 'variables.approverId': [ { @@ -389,7 +520,17 @@ const data = reactive({ const { form, rules } = toRefs(data); const isFormDisabled = computed(() => { - return routeParams.value.type === 'view' || routeParams.value.type === 'approval'; + if (routeParams.value.type === 'view') { + return true; + } + if (routeParams.value.type === 'approval') { + // 申请人确认节点判定:如果目前流程处于审批类型下,且正在操作的登陆者就是本表单最初的那个申请人 + if (String(useUserStore().userId) === String(form.value.applicantId)) { + return false; // 在此阶段彻底允许他修改表单内容 + } + return true; // 其他领导审批时仅查看不可修改 + } + return false; }); // 计算动态校验规则 @@ -484,6 +625,36 @@ onMounted(async () => { setDefaultCopyUsers(); } + // 如果落到“申请人确认”节点,将固定注入陈海军和张东辉,以及业务负责人作为后台流转变量 + // 不直接侵入公共底层弹窗组件显示,而是随表单变量潜行,配合画图里的 @@businessTripCopyUsers@@ 取值 + if (routeParams.value.type === 'approval' && String(useUserStore().userId) === String(form.value.applicantId)) { + const confirmCopyIds: string[] = []; + ['张东辉', '陈海军'].forEach((name) => { + const matchedUser = userList.value.find((u: any) => u.nickName === name); + if (matchedUser) { + confirmCopyIds.push(String(matchedUser.userId)); + } + }); + + // 追加:业务方向负责人的ID映射(与后台 SpelRuleComponent.java 保持一致) + const BUSINESS_DIRECTION_TO_USERNAME: Record = { + 1: '1985254821554475009', + 2: '1985258519835889666', + 3: '1985251968270127105', + 4: '1985257496048226305', + 5: '1985254145713688578' + }; + const bd = Number(form.value.businessDirection); + if (bd && BUSINESS_DIRECTION_TO_USERNAME[bd]) { + confirmCopyIds.push(BUSINESS_DIRECTION_TO_USERNAME[bd]); + } + + // 兼容原有的手工可选抄送并做个去重合并 + form.value.copyUserIds = Array.from(new Set([...(form.value.copyUserIds || []), ...confirmCopyIds])); + // 关键挂载:将整理完的抄送群放入该次提交时的全局工作流上下文引擎变量中 + taskVariables.value.businessTripCopyUsers = form.value.copyUserIds.join(','); + } + // 确保 variables 对象存在 if (!form.value.variables) { form.value.variables = {}; @@ -506,12 +677,30 @@ const calculateDuration = () => { } else { form.value.durationDays = 0; } + } else { + form.value.durationDays = undefined; } }; +// 延迟清除校验辅助方法 +const refreshValidate = (index: number, field: string) => { + setTimeout(() => { + businessTripApplyFormRef.value?.clearValidate(`crmBusinessTripDetailsList.${index}.${field}`); + }, 0); +}; + // 打开项目选择 +const currentEditRowIndex = ref(-1); + const openProjectSelect = () => { if (isFormDisabled.value) return; + currentEditRowIndex.value = -1; // -1 表示主表项目选择 + projectSelectRef.value?.open(); +}; + +const openDetailProjectSelect = (index: number) => { + if (isFormDisabled.value) return; + currentEditRowIndex.value = index; // 记录当前操作的明细行索引 projectSelectRef.value?.open(); }; @@ -519,22 +708,124 @@ const openProjectSelect = () => { const projectInfoSelectCallBack = (data: ProjectInfoVO[]) => { if (data && data.length > 0) { const selectedProject = data[0]; - form.value.projectId = selectedProject.projectId; - form.value.projectName = selectedProject.projectName; - form.value.projectCode = selectedProject.projectCode; + if (currentEditRowIndex.value === -1) { + // 主表选择 + form.value.projectId = selectedProject.projectId; + form.value.projectName = selectedProject.projectName; + form.value.projectCode = selectedProject.projectCode; + } else { + // 明细行选择 + const index = currentEditRowIndex.value; + if (form.value.crmBusinessTripDetailsList[index]) { + form.value.crmBusinessTripDetailsList[index].projectId = selectedProject.projectId; + form.value.crmBusinessTripDetailsList[index].projectName = selectedProject.projectName; + form.value.crmBusinessTripDetailsList[index].projectCode = selectedProject.projectCode; // 自动带出项目号 - // 安装调试的出差申请时,自动带入项目经理作为下一步审批人 - if (form.value.tripType === '1' && selectedProject.managerId) { - if (!form.value.variables) { - form.value.variables = {}; + // 安装调试的出差申请时,自动带入明细项目的第一顺位项目经理作为下一步审批人 + if (form.value.tripType === '1' && selectedProject.managerId) { + if (!form.value.variables) { + form.value.variables = {}; + } + form.value.variables.approverId = String(selectedProject.managerId); + handleApproverSelectChange(String(selectedProject.managerId)); + proxy?.$modal.msgSuccess('已关联项目经理作为下一步审批人'); + } } - form.value.variables.approverId = String(selectedProject.managerId); - handleApproverSelectChange(String(selectedProject.managerId)); - proxy?.$modal.msgSuccess('已关联项目经理作为下一步审批人'); } } }; +// ================== 行程明细操作 ================== +const handleAddDetail = () => { + form.value.crmBusinessTripDetailsList.push({ + tempId: Date.now() + Math.random(), + tripLocation: undefined, + startTime: undefined, + endTime: undefined, + durationDays: undefined, + projectId: undefined, + projectName: undefined, + customerId: undefined, + meetingName: undefined, + exchangePurpose: undefined, + exchangeFeedback: undefined, + tripReason: undefined, + remark: undefined + }); +}; + +// 默认展开所有行 +const expandedRowKeys = computed(() => { + return (form.value.crmBusinessTripDetailsList || []).map((item: any) => item.tripDetailsId || item.tempId); +}); + +const handleDeleteDetail = (index: number) => { + form.value.crmBusinessTripDetailsList.splice(index, 1); +}; + +const calculateDetailDuration = (row: any) => { + if (row.startTime && row.endTime) { + const start = dayjs(row.startTime); + const end = dayjs(row.endTime); + const diff = end.diff(start, 'day'); + if (diff >= 0) { + row.durationDays = diff + 1; + } else { + row.durationDays = 0; + } + } +}; + +// 自动计算汇总开始时间、结束时间和时长 +watch( + () => form.value.crmBusinessTripDetailsList, + (newList) => { + if (newList && newList.length > 0) { + let totalDays = 0; + let minStart = newList[0].startTime; + let maxEnd = newList[0].endTime; + + newList.forEach((item) => { + // 1. 自动计算单行动动态时长 + if (item.startTime && item.endTime) { + const start = dayjs(item.startTime); + const end = dayjs(item.endTime); + const diff = end.diff(start, 'day'); + item.durationDays = diff >= 0 ? diff + 1 : 0; + } else { + item.durationDays = 0; + } + + // 2. 汇总数据 + if (item.durationDays) { + totalDays += Number(item.durationDays); + } + if (item.startTime && (!minStart || dayjs(item.startTime).isBefore(dayjs(minStart)))) { + minStart = item.startTime; + } + if (item.endTime && (!maxEnd || dayjs(item.endTime).isAfter(dayjs(maxEnd)))) { + maxEnd = item.endTime; + } + }); + form.value.durationDays = totalDays; + form.value.startTime = minStart; + form.value.endTime = maxEnd; + // 辅助归集第一行明细数据到主表,用于列表页展示 + form.value.tripLocation = newList[0].tripLocation; + form.value.customerId = newList[0].customerId; + form.value.exchangePurpose = newList[0].exchangePurpose; + form.value.exchangeFeedback = newList[0].exchangeFeedback; + form.value.meetingName = newList[0].meetingName; + form.value.projectId = newList[0].projectId; + } else { + form.value.durationDays = 0; + form.value.startTime = undefined; + form.value.endTime = undefined; + } + }, + { deep: true } +); + // 审批人选择变更 const handleApproverSelectChange = (val: any) => { const user = userList.value.find((u) => u.userId === val); @@ -564,9 +855,43 @@ const submitForm = (status: string, mode: boolean) => { }); }; +const aggregateDetailsToForm = (): boolean => { + const details = form.value.crmBusinessTripDetailsList; + if (!details || details.length === 0) { + proxy?.$modal.msgError('请添加至少一条行程明细!'); + return false; + } + + let totalDays = 0; + let minStart = details[0].startTime; + let maxEnd = details[0].endTime; + form.value.tripLocation = details[0].tripLocation; // 取第一个行程地点作为主地点 + + details.forEach((item) => { + if (item.durationDays) { + totalDays += Number(item.durationDays); + } + if (item.startTime && (!minStart || dayjs(item.startTime).isBefore(dayjs(minStart)))) { + minStart = item.startTime; + } + if (item.endTime && (!maxEnd || dayjs(item.endTime).isAfter(dayjs(maxEnd)))) { + maxEnd = item.endTime; + } + }); + form.value.durationDays = totalDays; + form.value.startTime = minStart; + form.value.endTime = maxEnd; + return true; +}; + const executeSubmit = async (status: string, mode: boolean) => { buttonLoading.value = true; try { + if (!aggregateDetailsToForm()) { + buttonLoading.value = false; + return; + } + if (status !== 'draft') { // 提交审批 form.value.tripStatus = '2'; // 审批中 @@ -615,6 +940,24 @@ const handleApprovalRecord = () => { // 打开审批详情 const approvalVerifyOpen = async () => { + if (!isFormDisabled.value) { + let isValid = false; + await businessTripApplyFormRef.value?.validate((valid) => { + isValid = valid; + }); + if (!isValid) return; + + if (!aggregateDetailsToForm()) return; + + buttonLoading.value = true; + try { + if (form.value.tripId) { + await updateBusinessTripApply(form.value); + } + } finally { + buttonLoading.value = false; + } + } await submitVerifyRef.value?.openDialog(routeParams.value.taskId); }; diff --git a/src/views/oa/crm/businessTripApply/index.vue b/src/views/oa/crm/businessTripApply/index.vue index 784e420..d59bd7c 100644 --- a/src/views/oa/crm/businessTripApply/index.vue +++ b/src/views/oa/crm/businessTripApply/index.vue @@ -119,9 +119,13 @@ - - + + +