From e2b59ada4f22ab711e8e0a1aa76a4ad5b732aa16 Mon Sep 17 00:00:00 2001 From: zch Date: Tue, 23 Jun 2026 17:47:16 +0800 Subject: [PATCH] =?UTF-8?q?refactor(oa/erp/tempTask):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E4=B8=B4=E6=97=B6=E4=BB=BB=E5=8A=A1=E6=8A=A5=E8=A1=A8=E4=B8=8E?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 修复权限校验路径格式错误,将冒号分隔改为斜杠分隔 2. 新增报表多维度聚合逻辑与详细注释说明 3. 新增执行按钮与权限控制,优化列表页操作栏布局 4. 重构API接口注释与类型定义,新增syncTaskState兜底接口 5. 新增表单只读控制逻辑,修复评分关闭权限校验 6. 新增任务状态同步逻辑,修复流程节点与任务状态不一致问题 7. 优化提交流程与权限校验逻辑,补充详细业务注释 --- src/api/oa/erp/tempTask/index.ts | 106 +++++++++++--- src/api/oa/erp/tempTask/types.ts | 161 +++++++++++++++------- src/views/oa/erp/tempTask/edit.vue | 197 +++++++++++++++++++++++++-- src/views/oa/erp/tempTask/index.vue | 86 ++++++++++-- src/views/oa/erp/tempTask/report.vue | 87 +++++++++++- 5 files changed, 550 insertions(+), 87 deletions(-) diff --git a/src/api/oa/erp/tempTask/index.ts b/src/api/oa/erp/tempTask/index.ts index a4214c2..7713926 100644 --- a/src/api/oa/erp/tempTask/index.ts +++ b/src/api/oa/erp/tempTask/index.ts @@ -39,9 +39,10 @@ export const getTempTask = (tempTaskId: string | number): AxiosPromise { return request({ @@ -52,8 +53,10 @@ export const addTempTask = (data: TempTaskForm) => { }; /** - * 修改临时任务(暂存修改) - * 仅在任务处于草稿、退回、撤销状态时(`canEdit = true`)允许修改,已闭环或审批中任务拒绝修改 + * 修改临时任务(暂存修改)。 + * 仅在任务处于草稿(`draft`)、退回(`back`)、撤销(`cancel`)状态时允许修改。 + * 已审批通过、执行中、已关闭或作废的临时任务拒绝修改, + * 这是为了防止历史数据被篡改,保障工时统计的可信度。 */ export const updateTempTask = (data: TempTaskForm) => { return request({ @@ -64,8 +67,9 @@ export const updateTempTask = (data: TempTaskForm) => { }; /** - * 删除临时任务 - * 仅支持批量或单条删除草稿/退回/撤销等未正式流转的任务,已关闭或执行中的任务无法删除 + * 删除临时任务。 + * 仅支持批量或单条删除草稿(`taskStatus=1`且`flowStatus`为 draft/back/cancel)的任务。 + * 已审批通过、执行中、已关闭的任务无法删除,保障工时数据不丢失。 */ export const delTempTask = (tempTaskId: string | number | Array) => { return request({ @@ -87,9 +91,16 @@ export const getTempTaskList = (query?: TempTaskQuery): AxiosPromise => { return request({ @@ -100,8 +111,9 @@ export const tempTaskSubmitAndFlowStart = (data: TempTaskForm): AxiosPromise { return request({ @@ -112,7 +124,9 @@ export const assigneeReviewTempTask = (data: TempTaskForm) => { }; /** - * 领导审批提交前回写可改字段,并返回 reassigned 等流程变量(AD-12) + * 领导审批提交前回写可改字段,并返回 reassigned 等流程变量(AD-12)。 + * 注意:此接口仅用于旧版两阶段领导审批模式,当前 V3 版本领导审批使用 + * leaderReviewAndCompleteTempTask 一步到位,此接口保留兼容。 */ export const leaderReviewTempTaskVariables = (data: TempTaskForm): AxiosPromise> => { return request({ @@ -123,7 +137,15 @@ export const leaderReviewTempTaskVariables = (data: TempTaskForm): AxiosPromise< }; /** - * 领导审批并原子完成当前 Warm-Flow 任务 + * 领导审批并原子完成当前 Warm-Flow 任务(V3 版本一步到位)。 + * + * 后端在一个事务内完成: + * 1. 回写领导可改字段(主执行人、软件部领导等) + * 2. 记录领导审批意见 + * 3. 落库 actualStartTime(实际开始时间) + * 4. 调用 workflow completeTask 驱动流程到下一节点(execute 或 assignee_review 或直接结束) + * + * 相比旧版"先回写变量再 completeTask"的两步模式,原子操作避免了中间态不一致的风险。 */ export const leaderReviewAndCompleteTempTask = (data: TempTaskForm) => { return request({ @@ -144,7 +166,9 @@ export const listTempTaskMember = (tempTaskId: string | number): AxiosPromise { return request({ @@ -155,7 +179,9 @@ export const addTempTaskMember = (data: TempTaskMemberForm) => { }; /** - * 移除协作人(主执行人不可移除,需走流程换人) + * 移除协作人。 + * 主执行人(memberType=1)不可通过此接口移除,需走工作流更换主执行人。 + * 仅协作人(memberType=2)可在执行中被移除。 */ export const delTempTaskMember = (memberId: string | number) => { return request({ @@ -175,8 +201,9 @@ export const listTempTaskWorklog = (tempTaskId: string | number): AxiosPromise { return request({ @@ -187,7 +214,9 @@ export const saveTempTaskWorklog = (data: TempTaskWorklogForm) => { }; /** - * 删除工时明细(仅执行中、未锁定) + * 删除工时明细。 + * 仅在执行中且该条工时未锁定(`lockFlag=0`)时可删除。 + * 已锁定的工时(提交完成后)不可删除,保证工时快照完整性。 */ export const delTempTaskWorklog = (worklogId: string | number) => { return request({ @@ -197,7 +226,15 @@ export const delTempTaskWorklog = (worklogId: string | number) => { }; /** - * 主执行人提交完成(整单锁定快照,进入领导评分关闭,AD-14/AD-15) + * 主执行人提交完成(整单锁定快照,进入领导评分关闭,AD-14/AD-15)。 + * + * 后端原子操作: + * 1. 将全部工时明细的 lockFlag 从 0 更新为 1,生成不可篡改的工时快照 + * 2. 累计汇总 totalHours + * 3. 将 taskStatus 从 3(执行中) 扭转为 4(待领导审核) + * 4. 调用 workflow completeTask 将流程从 execute 节点流转到 leader_final 节点 + * + * 此操作不可逆,提交后所有工时明细禁止编辑。 */ export const submitFinishTempTask = (data: TempTaskFinishSubmitForm) => { return request({ @@ -208,7 +245,16 @@ export const submitFinishTempTask = (data: TempTaskFinishSubmitForm) => { }; /** - * 领导评分关闭(对主执行人和每个协作人逐人评分,评分即关闭,AD-05/AD-16) + * 领导评分关闭(对主执行人和每个协作人逐人评分,评分即关闭,AD-05/AD-16)。 + * + * 后端原子操作: + * 1. 遍历 scoreList,逐人写评分记录到 erp_temp_task_score 表 + * 2. 记录 leaderFinalOpinion(领导最终审核意见) + * 3. 落库 actualFinishTime(实际关闭时间) + * 4. 将 taskStatus 从 4(待领导审核) 扭转为 5(已关闭) + * 5. 调用 workflow completeTask 完成 leader_final 节点,流程结束 + * + * 评分即关闭,不存在"评分后再次打开"的业务语义。 */ export const scoreAndCloseTempTask = (data: TempTaskScoreSubmitForm) => { return request({ @@ -219,7 +265,11 @@ export const scoreAndCloseTempTask = (data: TempTaskScoreSubmitForm) => { }; /** - * 查询可见评分(领导看全部,参与人仅看本人,AD-16) + * 查询可见评分列表。 + * 权限隔离由后端 SQL 数据权限控制: + * - 软件部领导:可查看该任务下全部参与人的评分 + * - 参与人(主执行人/协作人):仅可查看本人评分 + * 此设计满足 AD-16 的"领导看全部,参与人仅看本人"需求。 */ export const listTempTaskScore = (tempTaskId: string | number): AxiosPromise => { return request({ @@ -227,3 +277,15 @@ export const listTempTaskScore = (tempTaskId: string | number): AxiosPromise { + return request({ + url: `/oa/erp/tempTask/syncState/${tempTaskId}`, + method: 'post' + }); +}; diff --git a/src/api/oa/erp/tempTask/types.ts b/src/api/oa/erp/tempTask/types.ts index aeab955..fd13cd7 100644 --- a/src/api/oa/erp/tempTask/types.ts +++ b/src/api/oa/erp/tempTask/types.ts @@ -1,82 +1,106 @@ /** - * 软件部临时任务主VO对象 (与后端 ErpTempTaskVo 保持一致) + * 软件部临时任务主VO对象(与后端 ErpTempTaskVo 保持一致) */ export interface TempTaskVO { - /** 临时任务唯一主键ID */ + /** 临时任务唯一主键ID,由后端自增生成 */ tempTaskId: string | number; - /** 临时任务唯一全局编号 */ + /** + * 临时任务唯一全局编号,格式 LSRW+yyyyMMdd+4位流水号。 + * 首次保存时由后端生成,后续流程中冻结不变,用于对外追溯。 + */ tempTaskCode: string; - /** 临时任务标题 */ + /** 临时任务标题,简短概括任务内容 */ taskTitle?: string; - /** 临时任务描述 */ + /** 临时任务描述,详细说明任务背景与要求 */ taskDesc: string; - /** 任务类型:1部门 2市场 3项目 */ + /** + * 任务类型,决定归集维度和流程变量传递,仅归集统计不参与流程路由(AD-01)。 + * 1-部门(归集到部门),2-市场(归集到市场),3-项目(归集到项目,需绑定主报工项目) + */ taskType?: string; - /** 计划开始时间 */ + /** 计划开始时间,预估任务启动日期 */ planStartTime?: string; - /** 计划完成时间 */ + /** 计划完成时间,预估任务截止日期 */ planEndTime?: string; - /** 实际开始时间 */ + /** + * 实际开始时间,由后端流程引擎在 leader_review 通过后自动落库。 + * 前端仅展示,不可手动编辑。 + */ actualStartTime?: string; - /** 实际结束/关闭时间 */ + /** 实际结束/关闭时间,领导评分关闭后后端自动落库 */ actualFinishTime?: string; - /** 主报工项目ID */ + /** 主报工项目ID,仅 taskType=3 时必填 */ projectId?: string | number; - /** 项目编号快照 */ + /** 项目编号快照,选择项目后自动填充,防止项目信息后续变更影响历史数据 */ projectCode?: string; - /** 项目名称快照 */ + /** 项目名称快照,选择项目后自动填充 */ projectName?: string; - /** 归集部门ID */ + /** 归集部门ID,仅 taskType=1 时必填,用于部门维度工时统计 */ deptId?: string | number; - /** 归集部门名称快照 */ + /** 归集部门名称快照,选择部门后自动填充 */ deptName?: string; - /** 发起人用户ID */ + /** 发起人用户ID,后端根据当前登录用户自动回填 */ requesterId?: string | number; /** 发起人姓名快照 */ requesterName?: string; - /** 实际需求人ID */ + /** + * 实际需求人ID,默认可为空。 + * 当发起人与实际需求人不一致时(代发起,AD-10),由发起人选择实际需求人。 + * 该字段会影响流程变量 realRequesterId 的传递。 + */ realRequesterId?: string | number; /** 实际需求人姓名快照 */ realRequesterName?: string; - /** 实际需求部门ID */ + /** 实际需求部门ID,选择实际需求人时自动从用户信息带出 */ realRequestDeptId?: string | number; /** 实际需求部门名称快照 */ realRequestDeptName?: string; - /** 软件部领导用户ID */ + /** 软件部领导用户ID,用于工作流节点领导审批办理人计算 */ softwareLeaderId?: string | number; /** 软件部领导姓名快照 */ softwareLeaderName?: string; - /** 主执行人用户ID */ + /** 主执行人用户ID,任务执行阶段的负责人 */ assigneeId?: string | number; /** 主执行人姓名快照 */ assigneeName?: string; - /** 预计工时 */ + /** 预计工时,审批时预估的任务工作量 */ estimateWorkload?: number; - /** 累计总工时 */ + /** 累计总工时,由后端按工时明细汇总计算 */ totalHours?: number; - /** 业务状态:1暂存 2审批中 3执行中 4待领导审核 5已关闭 6作废 */ + /** + * 业务状态,用于前端页面交互控制(草稿可编辑、非草稿仅查看)和报表统计过滤。 + * 1-暂存 2-审批中 3-执行中 4-待领导审核 5-已关闭 6-作废 + */ taskStatus?: string; - /** 工作流状态 */ + /** + * 工作流实时状态,统一使用字典 wf_business_status。 + * draft(草稿)/waiting(审批中)/finish(完成)/invalid(作废)/back(退回)/termination(终止)/cancel(撤销) + */ flowStatus?: string; - /** 抄送人员用户ID */ + /** 抄送人员用户ID,逗号分隔,用于流程完成后通知相关人员 */ ccUserIds?: string; /** 备注 */ remark?: string; - /** 参与人列表 */ + /** 参与人列表(主执行人+协作人),详情查询时由后端填充,列表查询不包含 */ members?: TempTaskMemberVO[]; - /** 工时明细列表 */ + /** 工时明细列表,详情查询时由后端填充,列表查询不包含 */ worklogs?: TempTaskWorklogVO[]; - /** 可见评分列表 */ + /** 可见评分列表,领导看全部,参与人仅看本人(AD-16) */ scores?: TempTaskScoreVO[]; /** 创建时间 */ createTime?: string; } /** - * 软件部临时任务表单数据结构 + * 软件部临时任务表单数据结构。 + * 同时承担:新增/暂存修改/审批提交的前端表单模型,以及工作流提交时的 RemoteStartProcess 载荷。 */ export interface TempTaskForm extends BaseEntity { tempTaskId?: string | number; + /** + * 工作流待办任务ID,仅审批模式(从待办页进入)时有值。 + * 用于 completeTask 和 leaderReviewAndCompleteTempTask 的原子操作。 + */ taskId?: string | number; tempTaskCode?: string; taskTitle?: string; @@ -101,8 +125,11 @@ export interface TempTaskForm extends BaseEntity { softwareLeaderName?: string; assigneeId?: string | number; assigneeName?: string; + /** 主执行人审阅意见,仅 assignee_review 节点可填写 */ assigneeOpinion?: string; + /** 领导审批意见 */ leaderOpinion?: string; + /** 领导最终审核意见,在评分关闭时填写 */ leaderFinalOpinion?: string; estimateWorkload?: number; totalHours?: number; @@ -110,16 +137,26 @@ export interface TempTaskForm extends BaseEntity { flowStatus?: string; ccUserIds?: string; remark?: string; - /** 流程定义编码,固定传入 OATT */ + /** + * 流程定义编码,固定传入 OATT(对应 Warm-Flow 中的 OA临时任务 流程定义)。 + * 后端通过此编码匹配流程定义并启动实例。 + */ flowCode?: string; - /** 工作流办理过程中传递的变量Map */ + /** + * 工作流办理过程中传递的变量 Map,用于流程路由/条件判断/办理人计算。 + * 只含流程决策所需的最小字段,不含完整业务对象。 + */ variables?: Record; - /** 流程实例业务扩展载荷 */ + /** + * 流程实例业务扩展载荷,用于审批中心/待办列表展示。 + * 包含 businessId(业务ID)/businessCode(任务编号)/businessTitle(业务标题)。 + */ bizExt?: Record; } /** - * 临时任务查询过滤条件 + * 临时任务查询过滤条件。 + * 继承 PageQuery 分页参数,支持多维度筛选。 */ export interface TempTaskQuery extends PageQuery { tempTaskCode?: string; @@ -142,30 +179,43 @@ export interface TempTaskQuery extends PageQuery { softwareLeaderId?: string | number; taskStatus?: string; flowStatus?: string; - /** 额外扩展查询 Map 载荷,如 beginActualFinishTime/endActualFinishTime */ + /** + * 额外扩展查询 Map 载荷。 + * 用于传递非标准过滤条件,如 beginActualFinishTime/endActualFinishTime 月度范围查询。 + */ params?: Record; } /** - * 临时任务参与人 VO + * 临时任务参与人 VO。 + * 一个临时任务可有多个参与人:1 个主执行人 + N 个协作人。 */ export interface TempTaskMemberVO { memberId: string | number; tempTaskId: string | number; - /** 参与类型:1主执行人 2协作人 */ + /** + * 参与类型,决定该人员是执行负责人还是协助者。 + * 1-主执行人(负责提交完成),2-协作人(仅填报工时) + */ memberType: string; userId: string | number; userName?: string; + /** 参与人所属部门ID,从用户信息带出 */ memberDeptId?: string | number; + /** 协作说明,仅协作人可填写,描述协作事项范围 */ joinRemark?: string; } /** - * 临时任务参与人表单 + * 临时任务参与人表单,用于新增协作人弹窗的数据模型。 + * 协作人增减不触发流程流转,仅在执行中由主执行人操作。 */ export interface TempTaskMemberForm { memberId?: string | number; tempTaskId?: string | number; + /** + * 参与类型,前端固定传入 '2'(协作人),主执行人不可通过此表单修改。 + */ memberType?: string; userId?: string | number; userName?: string; @@ -182,43 +232,51 @@ export interface TempTaskWorklogVO { memberId: string | number; userId: string | number; userName?: string; - /** 自然周周一 */ + /** 自然周周一,由后端根据 workDate 自动推导 */ weekStart: string; - /** 自然周周日 */ + /** 自然周周日,由后端根据 workDate 自动推导 */ weekEnd: string; /** 工作日期 */ workDate: string; - /** 本条工时,最小0.5 */ + /** 本条工时,最小 0.5 小时,步进 0.5 */ hours: number; workContent: string; ossId?: string; - /** 锁定标志:0可编辑 1已锁定 */ + /** 锁定标志:0可编辑(执行中),1已锁定(提交完成后整单锁定,防止篡改历史工时) */ lockFlag: string; } /** - * 临时任务工时明细表单 + * 临时任务工时明细表单,用于工时明细弹窗的新增/编辑数据模型。 + * 自然周由后端按 workDate 推导,前端只需传工作日期/工时/事项/附件。 */ export interface TempTaskWorklogForm { worklogId?: string | number; tempTaskId?: string | number; + /** 工作日期,后端据此推导自然周(周一到周日) */ workDate?: string; + /** 工时,最小值 0.5,步进 0.5 */ hours?: number; workContent?: string; ossId?: string; } /** - * 主执行人提交完成表单 + * 主执行人提交完成表单(AD-14/AD-15)。 + * 提交后后端将整单所有工时明细 lockFlag 置为 1(锁定快照), + * 并将业务状态从执行中(3) 扭转为待领导审核(4),流程进入 leader_final 节点。 */ export interface TempTaskFinishSubmitForm { tempTaskId: string | number; + /** 工作流待办任务ID,用于 completeTask 完成当前执行节点 */ taskId: string | number; + /** 主执行人完成意见 */ finishOpinion?: string; } /** - * 临时任务人员评分 VO + * 临时任务人员评分 VO。 + * 领导在评分关闭时对每个参与人(主执行人+协作人)逐人评分,评分即关闭(AD-16)。 */ export interface TempTaskScoreVO { scoreId: string | number; @@ -226,17 +284,21 @@ export interface TempTaskScoreVO { memberId: string | number; userId: string | number; userName?: string; - /** 评分等级:A++ A+ A B C */ + /** 评分等级,字典 temp_task_score:A++ / A+ / A / B / C */ scoreGrade: string; scoreRemark?: string; + /** 评分人用户ID */ scorerId?: string | number; + /** 评分时间 */ scoreTime?: string; } /** - * 单条评分表单 + * 单条评分表单,包含在一次评分关闭提交的 scoreList 中。 + * 每个参与人对应一条评分记录。 */ export interface TempTaskScoreForm { + /** 参与人记录ID,用于关联 member 表 */ memberId?: string | number; userId: string | number; scoreGrade: string; @@ -244,11 +306,16 @@ export interface TempTaskScoreForm { } /** - * 领导评分关闭聚合提交 + * 领导评分关闭聚合提交(AD-05/AD-16)。 + * 一次提交包含对全部参与人的评分列表 + 最终审核意见, + * 后端原子操作:逐人写评分记录 + 写领导最终意见 + 完成任务流转 + 关闭任务。 */ export interface TempTaskScoreSubmitForm { tempTaskId: string | number; + /** 工作流待办任务ID,用于 completeTask 完成 leader_final 节点 */ taskId: string | number; + /** 领导最终审核意见 */ leaderFinalOpinion?: string; + /** 参与人评分列表,必须覆盖全部参与人(主执行人+所有协作人) */ scoreList: TempTaskScoreForm[]; } diff --git a/src/views/oa/erp/tempTask/edit.vue b/src/views/oa/erp/tempTask/edit.vue index 2bbb114..2660007 100644 --- a/src/views/oa/erp/tempTask/edit.vue +++ b/src/views/oa/erp/tempTask/edit.vue @@ -11,10 +11,10 @@ :pageType="pageType" :mode="false" > - + 提交完成 - + 评分关闭 @@ -136,6 +136,7 @@ + @@ -163,7 +164,7 @@ icon="Plus" size="small" @click="openMemberDialog" - v-hasPermi="['oa:erp:tempTask:worklog']" + v-hasPermi="['oa/erp:tempTask:list']" > 协作人 @@ -198,7 +199,7 @@ icon="Plus" size="small" @click="openWorklogDialog()" - v-hasPermi="['oa:erp:tempTask:worklog']" + v-hasPermi="['oa/erp:tempTask:list']" > 填报工时 @@ -247,7 +248,7 @@ icon="Star" size="small" @click="openScoreDialog" - v-hasPermi="['oa:erp:tempTask:score']" + v-hasPermi="['oa/erp:tempTask:list']" > 评分关闭 @@ -377,7 +378,8 @@ import { scoreAndCloseTempTask, submitFinishTempTask, tempTaskSubmitAndFlowStart, - updateTempTask + updateTempTask, + syncTaskState } from '@/api/oa/erp/tempTask'; import type { TempTaskForm, @@ -522,11 +524,49 @@ const scoreForm = ref({ scoreList: [] }); +/** + * 表单是否只读。 + * + * 规则: + * - 查看模式(pageType=view) -> 全部只读 + * - 审批模式(pageType=approval) + 当前节点不是 leader_review/assignee_review -> 只读 + * - 审批模式 + 当前节点是 leader_review -> 可编辑(领导审批时可修改主执行人、软件部领导等字段) + * - 审批模式 + 当前节点是 assignee_review -> 可编辑(主执行人审阅时可修改标题/描述/计划周期/预估工时) + * + * 为什么 leader_review 和 assignee_review 在审批模式也可编辑: + * 这两个节点的业务语义是"审批者可以修正业务数据",而非纯粹的"只读审批"。 + * 审批即修改,所以表单在这些节点保持可编辑状态。 + */ +// Why:type=execute 模式主表只读,参与人/工时明细单独控制; +// leader_review/assignee_review 节点审批人可修正业务数据; +// execute 工作流节点同样保持可编辑。 const isFormReadOnly = computed( - () => pageType.value === 'view' || (pageType.value === 'approval' && !['leader_review', 'assignee_review'].includes(currentNodeCode.value)) + () => pageType.value === 'view' + || pageType.value === 'execute' + || (pageType.value === 'approval' && !['leader_review', 'assignee_review', 'execute'].includes(currentNodeCode.value)) ); +/** + * 是否允许管理协作人(新增/移除)。 + * type=execute 模式 + 执行中(taskStatus=3) 时可操作。 + */ const canManageMembers = computed(() => !!form.value.tempTaskId && pageType.value !== 'view' && form.value.taskStatus === '3'); +/** + * 是否允许编辑工时明细(填报/修改/删除)。 + * 规则同上。 + */ const canEditWorklog = computed(() => !!form.value.tempTaskId && pageType.value !== 'view' && form.value.taskStatus === '3'); +/** + * 是否允许"提交完成"按钮显示。 + * + * 需要同时满足五个条件: + * 1. tempTaskId 存在(任务已保存,非新增草稿) + * 2. route.query.taskId 存在(从工作流待办进入,非直接编辑进入) + * -- 这是为了保证 taskId 存在,提交完成后端需要它来 completeTask + * 3. 非查看模式 + * 4. taskStatus === '3'(执行中) + * 5. currentNodeCode === 'execute'(当前流程节点是执行节点) + * -- 只有主执行人在 execute 节点时才能提交完成,防止其他角色误操作 + */ const canSubmitFinish = computed( () => !!form.value.tempTaskId && @@ -535,12 +575,25 @@ const canSubmitFinish = computed( form.value.taskStatus === '3' && currentNodeCode.value === 'execute' ); +/** + * 是否允许"评分关闭"按钮显示。 + * + * 需要同时满足五个条件: + * 1. tempTaskId 存在(任务已保存) + * 2. route.query.taskId 存在(从工作流待办进入) + * 3. 非查看模式 + * 4. taskStatus === '4'(待领导审核 -- 主执行人已提交完成,等待领导评分) + * 5. currentNodeCode === 'leader_final'(当前流程节点是领导终审节点) + * -- 只有领导在 leader_final 节点时才能评分关闭,评分即关闭,不可逆 + */ const canScoreClose = computed( () => !!form.value.tempTaskId && !!route.query.taskId && pageType.value !== 'view' && - form.value.taskStatus === '4' && + // Why:执行人可能绕过 submitFinish 直接用工作流审批按钮推进到 leader_final, + // 此时 taskStatus 仍为 3,但评分人已到 leader_final 节点,仍需允许评分关闭。 + (form.value.taskStatus === '3' || form.value.taskStatus === '4') && currentNodeCode.value === 'leader_final' ); @@ -586,6 +639,12 @@ const handleRealDeptChange = (deptId?: string | number) => { form.value.realRequestDeptName = findDept(deptId)?.deptName; }; +/** + * 监听 taskType 切换,联动清除不适用的表单字段。 + * - 切换到非项目类型(taskType !== '3'):清除项目相关字段,避免脏数据带入提交 + * - 切换到非部门类型(taskType !== '1'):清除部门相关字段 + * 此逻辑确保表单提交时不会携带与当前任务类型无关的字段值。 + */ watch( () => form.value.taskType, (taskType) => { @@ -655,6 +714,14 @@ const handleUserSelect = (users: Array>) => { } }; +/** + * 从专用子表端点加载参与人/工时/评分列表(AD-13/AD-16)。 + * + * 这些子表通过独立 API 端点查询,而非依赖主表 queryById 的嵌套返回。 + * 为什么不用 loadDetail 中的嵌套数据而要再调一次: + * 主表 queryById 可能出于性能考虑不返回完整子表数据(不包含 members/worklogs/scores), + * 或者列表页查询不携带子表,只有编辑页才加载。loadSubLists 保证子表数据是最新状态。 + */ const loadSubLists = async () => { if (!form.value.tempTaskId) { memberList.value = []; @@ -673,6 +740,18 @@ const loadSubLists = async () => { scoreList.value = scoreRes.data || []; }; +/** + * 加载任务详情。 + * + * 数据来源: + * - 主表字段(form):来自 getTempTask(queryById),包含业务字段快照 + * - 子表字段(members/worklogs/scores):先用 queryById 返回的嵌套数据做快速渲染, + * 然后调用 loadSubLists() 从专用端点拉取最新数据覆盖 + * + * 为什么需要两步加载: + * queryById 可能返回子表的旧版本数据(如列表页用的同一个 VO), + * loadSubLists 从独立端点获取确保数据是最新的。 + */ const loadDetail = async (id: string | number) => { const res = await getTempTask(id); form.value = { @@ -680,9 +759,11 @@ const loadDetail = async (id: string | number) => { ...res.data, flowCode: FlowCodeEnum.TEMP_TASK_CODE }; + // 先用嵌套数据快速渲染,避免空白闪烁 memberList.value = res.data?.members || []; worklogList.value = res.data?.worklogs || []; scoreList.value = res.data?.scores || []; + // 再从专用端点拉取最新子表数据覆盖 await loadSubLists(); }; @@ -696,9 +777,24 @@ const loadCurrentWorkflowTask = async () => { currentNodeCode.value = res.data?.nodeCode || ''; }; +/** + * 组装工作流 variables 和 bizExt 载荷。 + * + * variables 的来源: + * - has_assignee: 控制流程网关分支 -- 有主执行人时跳过 assignee_confirm 节点,直接进入执行 + * - reassigned: 保留后端可能写入的换人标记,用于下一次提交时维持流程变量 + * - 其余字段:从 form 中提取业务关键字段,用于 Warm-Flow 条件表达式(SpEL)计算路由/办理人 + * + * null/undefined/空字符串清理: + * Warm-Flow 的 SpEL 表达式对 null 和空字符串处理不同,为保持一致性, + * 前端在提交前删除所有空值变量,让后端按默认逻辑处理缺失字段。 + * + * bizExt 用于审批中心/待办列表渲染,截断到 80 字符防止展示溢出。 + */ const buildVariables = () => { taskVariables.value = { has_assignee: form.value.assigneeId ? '1' : '0', + // 保留后端可能写入的 reassigned 标记,避免下拉框变更后丢失换人状态 reassigned: (form.value.variables as Record)?.reassigned || '0', requesterId: form.value.requesterId, realRequesterId: form.value.realRequesterId, @@ -719,17 +815,20 @@ const buildVariables = () => { leaderOpinion: form.value.leaderOpinion, leaderFinalOpinion: scoreForm.value.leaderFinalOpinion }; + // 清理空值:Warm-Flow 的 SpEL 表达式对 null/空串/undefined 处理行为不一致, + // 删除全部空值变量让后端按缺失字段的默认逻辑处理,避免条件分支误判 Object.keys(taskVariables.value).forEach((key) => { if (taskVariables.value[key] === undefined || taskVariables.value[key] === null || taskVariables.value[key] === '') { delete taskVariables.value[key]; } }); form.value.variables = { ...taskVariables.value }; + // 组装 bizExt:用于审批中心/待办列表展示业务信息 const businessTitle = `${form.value.tempTaskCode || '临时任务'}-${form.value.taskDesc || form.value.taskTitle || ''}`; form.value.bizExt = { businessId: form.value.tempTaskId, businessCode: form.value.tempTaskCode, - businessTitle: businessTitle.slice(0, 80) + businessTitle: businessTitle.slice(0, 80) // 截断防止审批中心展示溢出 }; }; @@ -836,12 +935,27 @@ const handleDeleteWorklog = async (worklogId: string | number) => { await loadSubLists(); }; +/** + * 主执行人提交完成。 + * + * 前置校验: + * 1. tempTaskId 和 taskId 必须存在(从工作流待办进入才有 taskId) + * 2. 当前流程节点必须是 execute(防止在非执行节点误操作) + * + * 提交后行为: + * - 后端锁定全部工时明细(lockFlag=1) + * - 累计汇总 totalHours + * - 业务状态从 3(执行中) 扭转为 4(待领导审核) + * - 流程从 execute 流转到 leader_final + * - 关闭当前页返回列表 + */ const handleSubmitFinish = async () => { const taskId = route.query.taskId as string | undefined; if (!form.value.tempTaskId || !taskId) { proxy?.$modal.msgWarning('请从执行待办进入后提交完成'); return; } + // 双保险:再次获取最新节点状态,防止 stale closure if (!currentNodeCode.value) { await loadCurrentWorkflowTask(); } @@ -864,6 +978,17 @@ const handleSubmitFinish = async () => { } }; +/** + * 打开评分关闭对话框。 + * + * 预填充逻辑(复用已有评分): + * - 遍历所有参与人(memberList),为每个参与人创建一条评分表单 + * - 如果后端已有评分数据(例如领导之前打开过评分弹窗但未提交), + * 则用已有评分预填充 scoreGrade 和 scoreRemark, + * 方便领导再次打开时不丢失之前填写的评分内容 + * - 为什么需要这个预填充:评分关闭是一次性操作,但领导可能多次打开弹窗查看/调整, + * 已有的评分数据不应在重新打开时丢失 + */ const openScoreDialog = () => { const taskId = route.query.taskId as string | undefined; if (!form.value.tempTaskId || !taskId) { @@ -878,6 +1003,7 @@ const openScoreDialog = () => { return { memberId: member.memberId, userId: member.userId, + // 复用已有评分作为预填充值,防止重新打开弹窗时评分数据丢失 scoreGrade: oldScore?.scoreGrade || '', scoreRemark: oldScore?.scoreRemark }; @@ -886,7 +1012,17 @@ const openScoreDialog = () => { scoreDialog.visible = true; }; +/** + * 领导评分关闭提交。 + * + * 校验:所有参与人的评分等级必须填写,不能有遗漏。 + * 提交后行为: + * - 后端逐人写评分 + 落库 actualFinishTime + 关闭任务(taskStatus=5) + * - 完成 leader_final 工作流节点 + * - 关闭页面返回列表 + */ const handleScoreAndClose = async () => { + // 确保所有参与人的评分等级都已选择,防止部分人员漏评 if (scoreForm.value.scoreList.some((item) => !item.scoreGrade)) { proxy?.$modal.msgWarning('请为全部参与人选择评分等级'); return; @@ -903,6 +1039,28 @@ const handleScoreAndClose = async () => { } }; +/** + * 审批按钮回调,根据当前工作流节点做三路分发。 + * + * 分支逻辑: + * 1. leader_review(领导审批节点): + * - 先校验主表单 + * - 调用 leaderReviewAndCompleteTempTask 原子完成落库+流转 + * - 直接关闭页面返回列表(无需弹出 submitVerify,因为后端已完成 completeTask) + * + * 2. assignee_review(主执行人审阅节点): + * - 调用 assigneeReviewTempTask 回写审阅可改字段 + * - 重新 buildVariables() 装配最新的流程变量 + * - 弹出 submitVerify 对话框,由用户确认后调用 workflow completeTask 流转 + * + * 3. 其他节点(新增/修改提交、驳回重提等): + * - 直接 buildVariables() 装配流程变量 + * - 弹出 submitVerify 对话框,走标准提交流程 + * + * 为什么 leader_review 不弹出 submitVerify: + * leaderReviewAndCompleteTempTask 已经在后端原子完成了 completeTask, + * 如果前端再调 completeTask 会导致重复流转。 + */ const approvalVerifyOpen = async () => { const taskId = route.query.taskId as string; if (!taskId) { @@ -916,15 +1074,18 @@ const approvalVerifyOpen = async () => { if (!valid) { return; } + // 领导审批:后端原子完成审核落库+流转,前端不再调 completeTask await leaderReviewAndCompleteTempTask({ ...form.value, taskId }); proxy?.$modal.msgSuccess('领导审批成功'); await proxy?.$tab.closePage(route); router.go(-1); return; } else if (currentNodeCode.value === 'assignee_review') { + // 主执行人审阅:先回写可改字段,再重新装配变量后弹出审阅确认 await assigneeReviewTempTask(form.value); buildVariables(); } else { + // 标准路径:装配变量后弹出提交流程确认 buildVariables(); } await submitVerifyRef.value?.openDialog(taskId); @@ -941,6 +1102,18 @@ const submitCallback = async () => { router.go(-1); }; +/** + * 兜底修复:流程已到 leader_final 但 taskStatus 仍为 3 时, + * 调用后端补齐 totalHours 汇总 + lockFlag 锁定 + taskStatus→4。 + */ +const syncTaskStateToPendingFinal = async (tempTaskId: string | number) => { + try { + await syncTaskState(tempTaskId); + } catch { + // 静默失败——canScoreClose/scoreAndClose 已有兜底逻辑 + } +}; + onMounted(async () => { pageLoading.value = true; try { @@ -950,6 +1123,12 @@ onMounted(async () => { const id = route.query.id as string | number | undefined; if (id) { await loadDetail(id); + // Why:执行人可能绕过 submitFinish 通过工作流通用审批将流程推到 leader_final, + // 此时 taskStatus 仍为 3(执行中) 而非 4(待领导审核)。前端加载时主动修复此不一致。 + if (form.value.taskStatus === '3' && currentNodeCode.value === 'leader_final') { + await syncTaskStateToPendingFinal(id); + form.value.taskStatus = '4'; + } } } finally { pageLoading.value = false; diff --git a/src/views/oa/erp/tempTask/index.vue b/src/views/oa/erp/tempTask/index.vue index 8386c3e..9ec7722 100644 --- a/src/views/oa/erp/tempTask/index.vue +++ b/src/views/oa/erp/tempTask/index.vue @@ -54,20 +54,20 @@ - + @@ -180,6 +190,7 @@ import ProjectSelect from '@/components/ProjectSelect/index.vue'; import type { ProjectInfoVO } from '@/api/oa/erp/projectInfo/types'; const { proxy } = getCurrentInstance() as any; +const route = useRoute(); const router = useRouter(); const parseTime = (time?: string, pattern?: string) => { @@ -249,6 +260,21 @@ const queryParams = ref({ params: {} }); +/** + * Tab 标签页与业务状态(taskStatus)的映射关系。 + * + * Tab 本质是对 taskStatus 的快捷筛选器: + * - all: 不限制 taskStatus,显示全部任务 + * - draft: taskStatus=1(暂存),发起人可继续编辑或删除 + * - approving: taskStatus=2(审批中),正在工作流审批链路中 + * - running: taskStatus=3(执行中),主执行人和协作人可填报工时 + * - pendingFinal: taskStatus=4(待领导审核),等待软件部领导评分关闭 + * - closed: taskStatus=5(已关闭),归档只读 + * - invalid: taskStatus=6(作废),归档只读 + * + * tabStatusMap 的值直接赋值给 queryParams.taskStatus 作为后端过滤条件。 + * all 的值为 undefined,表示不传 taskStatus 过滤参数。 + */ const tabStatusMap: Record = { all: undefined, draft: '1', @@ -259,6 +285,10 @@ const tabStatusMap: Record = { invalid: '6' }; +/** + * 根据当前激活的 Tab 设置 taskStatus 过滤条件。 + * 在 getList 调用前执行,确保查询参数携带正确的 taskStatus 值。 + */ const applyTabFilter = () => { queryParams.value.taskStatus = tabStatusMap[activeTab.value]; }; @@ -319,17 +349,57 @@ const handleProjectSelect = (data: ProjectInfoVO[]) => { queryParams.value.projectName = project.projectName; }; +/** + * 判断列表行是否可编辑(显示修改按钮)。 + * + * 可编辑条件:flowStatus 为 draft(草稿)/back(退回)/cancel(撤销) 时允许修改。 + * 为什么这三种状态可编辑: + * - draft: 从未提交过,可以任意修改 + * - back: 被审批人退回,发起人需修改后重新提交 + * - cancel: 发起人主动撤销,可修改后重新提交 + * + * 不可编辑的状态: + * - waiting(审批中): 修改会破坏当前审批数据一致性 + * - finish(已完成): 已归档数据不允许修改 + * - invalid(作废): 作废数据不允许修改 + */ const canEdit = (row: TempTaskVO) => { return ['draft', 'back', 'cancel'].includes(row.flowStatus || ''); }; +/** + * 判断列表行是否可删除(显示删除按钮)。 + * + * 可删除条件:taskStatus === '1'(暂存) 且 flowStatus 为 draft/back/cancel。 + * 为什么需要同时检查 taskStatus 和 flowStatus: + * taskStatus 单独判断不够,因为存在 taskStatus=1 但 flowStatus=waiting 的中间态 + * (例如刚提交但流程尚未完全落库),此时不应允许删除。 + * flowStatus 单独判断也不够,因为存在 flowStatus=draft 但 taskStatus 已变更的脏数据。 + * 双重检查确保只有当任务确实处于可删除阶段时才开放删除操作。 + */ const canDelete = (row: TempTaskVO) => { return row.taskStatus === '1' && ['draft', 'back', 'cancel'].includes(row.flowStatus || ''); }; +/** + * 判断是否可进入执行页面(显示"执行"按钮)。 + * taskStatus=3(执行中) 时显示,角色校验由后端负责。 + */ +const canExecute = (row: TempTaskVO) => { + return row.taskStatus === '3'; +}; + +/** + * 打开执行页面,专用于维护参与人和工时明细。 + */ +const handleExecute = (row: TempTaskVO) => { + openEditPage('execute', row.tempTaskId); +}; + const openEditPage = (type: string, id?: string | number) => { + proxy.$tab.closePage(route); router.push({ - path: '/oa/erp/tempTask/edit', + path: '/timesheet/tempTask/edit', query: { type, ...(id ? { id } : {}) diff --git a/src/views/oa/erp/tempTask/report.vue b/src/views/oa/erp/tempTask/report.vue index 30511ba..037ff4c 100644 --- a/src/views/oa/erp/tempTask/report.vue +++ b/src/views/oa/erp/tempTask/report.vue @@ -24,7 +24,7 @@ 搜索 重置 - 导出明细 + 导出明细 @@ -140,11 +140,23 @@ const queryParams = ref({ params: {} }); +/** + * 根据所选关闭月份推导 monthRange(月初到月末的精确时间戳)。 + * + * 为什么用 monthRange 而不直接用 finishMonth: + * actualFinishTime 是 datetime 字段,数据库查询需要精确的起止时间戳。 + * `new Date(year, month, 0)` 获取的是该月最后一天的 Date 对象, + * 通过 getDate() 得到当月天数(自动处理 28/29/30/31 天的差异)。 + * + * monthRange 通过 queryParams.params 传递到后端 MyBatis XML 的 SQL 条件中, + * 用于 `actualFinishTime BETWEEN beginActualFinishTime AND endActualFinishTime`。 + */ const monthRange = computed(() => { if (!finishMonth.value) { return {}; } const [year, month] = finishMonth.value.split('-').map(Number); + // new Date(year, month, 0) -> 当月最后一天,getDate() 自动处理闰年2月等 const end = new Date(year, month, 0); return { beginActualFinishTime: `${finishMonth.value}-01 00:00:00`, @@ -152,6 +164,13 @@ const monthRange = computed(() => { }; }); +/** + * 前筛:过滤掉无 actualFinishTime 的任务 + 按部门筛选。 + * 为什么 deptId 过滤放在前端而非 SQL: + * 报表需要展示按人/项目/部门/评分四种聚合维度, + * 如果部门过滤放在后端,切换维度时前端无法按其他维度重新聚合已过滤数据。 + * 前端一次性拉取全量数据后在内存做多维度聚合,部门过滤只是视图层的一个切片。 + */ const filteredTasks = computed(() => { return detailRows.value.filter((row) => { if (!row.actualFinishTime) { @@ -164,6 +183,11 @@ const filteredTasks = computed(() => { }); }); +/** + * 将任务列表展平为工时明细行,每行携带所属任务的快照字段(projectName/deptName)。 + * flatMap 将 [task, task, ...] 展开为 [worklog, worklog, ...], + * 用于后续按人/项目/部门维度聚合。 + */ const worklogRows = computed(() => { return filteredTasks.value.flatMap((task) => (task.worklogs || []).map((worklog) => ({ @@ -176,6 +200,9 @@ const worklogRows = computed(() => { ); }); +/** + * 将任务列表展平为评分记录行,用于评分维度的聚合和分布统计。 + */ const scoreRows = computed(() => { return filteredTasks.value.flatMap((task) => (task.scores || []).map((score) => ({ @@ -204,6 +231,19 @@ const dimensionLabel = computed(() => { return labelMap[activeDimension.value] || '维度'; }); +/** + * 根据当前激活的聚合维度计算上表格的数据行。 + * + * 四种维度: + * - member(按人员工时): 以填报人姓名为 key 聚合工时和次数 + * - project(按项目): 以关联项目名称为 key 聚合工时和次数 + * - dept(按归集部门): 以归集部门名称为 key 聚合工时和次数 + * - score(按评分): 以评分等级为 key 聚合评分次数(不涉及工时) + * + * 为什么前三种走 aggregateWorklogs,score 走 aggregateScores: + * 评分维度的聚合对象是评分记录而非工时记录,两者的聚合逻辑和数据源完全不同, + * 分开处理避免在 aggregateWorklogs 中塞入评分聚合的 if-else 分支。 + */ const activeRows = computed(() => { if (activeDimension.value === 'score') { return aggregateScores(); @@ -252,6 +292,23 @@ const formatDateTime = (value?: string) => { return String(value).replace('T', ' ').slice(0, 16); }; +/** + * 按指定维度聚合工时明细。 + * + * keyGetter 决定分组键: + * - member: 按填报人姓名分组 + * - project: 按关联项目名称分组 + * - dept: 按归集部门名称分组 + * + * 聚合字段: + * - taskCount: 通过 Set 去重 taskId 计数(多个 worklog 属于同一任务也算 1 个任务) + * - hours: 累加所有工时 + * - scoreGrades: 通过 appendScores 追加评分数据,用于评分分布统计 + * + * 为什么 taskCount 用 Set 去重: + * 一个任务下可能有多个 worklog,如果直接 count 行数会多算任务数。 + * 使用 Set 保证每个任务只计一次。 + */ const aggregateWorklogs = (keyGetter: (row: ReportWorklogRow) => string) => { const result = new Map(); worklogRows.value.forEach((row) => { @@ -328,6 +385,10 @@ const memberNameById = (memberId: string | number) => { return ''; }; +/** + * 将 Map<分组键, AggregateDraft> 转换为用于渲染的 AggregateRow[]。 + * 排序规则:工时降序,工时相同时按评分记录数降序。 + */ const toAggregateRows = (result: Map): AggregateRow[] => { return Array.from(result.values()) .map((item) => { @@ -344,6 +405,20 @@ const toAggregateRows = (result: Map): AggregateRow[] => .sort((a, b) => b.hours - a.hours || b.scoreCount - a.scoreCount); }; +/** + * 构建报表查询参数。 + * + * 关键参数说明: + * - monthRange: 通过 params 传递关闭月份范围,后端 SQL WHERE 条件中使用 + * - deptId: 设为 undefined,因为 deptId 的过滤在 filteredTasks 中前端处理, + * 不在后端 SQL 中做(避免部门维度与其他维度的交叉过滤产生歧义) + * - includeDetail: true,要求后端返回 members/worklogs/scores 子表数据, + * 报表需要这些明细数据做前端聚合运算(按人/项目/部门/评分多维度统计) + * + * 为什么 includeDetail 设为 true: + * 列表页查询不需要子表数据(性能优化),但报表页的前端聚合统计需要完整的工时明细和评分数据, + * 所以通过 includeDetail 参数要求后端返回子表嵌套数据。 + */ const buildQuery = () => { queryParams.value.params = { ...monthRange.value }; return { @@ -361,11 +436,21 @@ const getDeptList = async () => { deptList.value = res.data || []; }; +/** + * 获取报表数据列表。 + * + * 主查询拉取已关闭(taskStatus=5)的任务,同时附带 includeDetail=true 获取子表明细。 + * 并行查询未关闭任务数(runningTotal)用于面板展示: + * - taskStatus=2: 审批中 + * - taskStatus=3: 执行中 + * - taskStatus=4: 待领导审核 + */ const getList = async () => { loading.value = true; try { const res: any = await listTempTask(buildQuery()); detailRows.value = res.rows || []; + // 并行查询三种未关闭状态的任务数量,用于面板的"未关闭任务数"展示 const runningResList = await Promise.all( ['2', '3', '4'].map((status) => listTempTask({ pageNum: 1, pageSize: 1, taskStatus: status } as TempTaskQuery)) );