diff --git a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/controller/ErpTempTaskController.java b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/controller/ErpTempTaskController.java index 560e6aac..a3528799 100644 --- a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/controller/ErpTempTaskController.java +++ b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/controller/ErpTempTaskController.java @@ -48,7 +48,7 @@ public class ErpTempTaskController extends BaseController { /** * 查询软件部临时任务主列表 */ - @SaCheckPermission("oa:erp:tempTask:list") + @SaCheckPermission("oa/erp:tempTask:list") @GetMapping("/list") public TableDataInfo list(ErpTempTaskBo bo, PageQuery pageQuery) { return erpTempTaskService.queryPageList(bo, pageQuery); @@ -57,7 +57,7 @@ public class ErpTempTaskController extends BaseController { /** * 导出软件部临时任务主列表 */ - @SaCheckPermission("oa:erp:tempTask:export") + @SaCheckPermission("oa/erp:tempTask:export") @Log(title = "软件部临时任务主", businessType = BusinessType.EXPORT) @PostMapping("/export") public void export(ErpTempTaskBo bo, HttpServletResponse response) { @@ -70,7 +70,7 @@ public class ErpTempTaskController extends BaseController { * * @param tempTaskId 主键 */ - @SaCheckPermission("oa:erp:tempTask:query") + @SaCheckPermission("oa/erp:tempTask:query") @GetMapping("/{tempTaskId}") public R getInfo(@NotNull(message = "主键不能为空") @PathVariable("tempTaskId") Long tempTaskId) { @@ -80,7 +80,7 @@ public class ErpTempTaskController extends BaseController { /** * 新增软件部临时任务主 */ - @SaCheckPermission("oa:erp:tempTask:add") + @SaCheckPermission("oa/erp:tempTask:add") @Log(title = "软件部临时任务主", businessType = BusinessType.INSERT) @RepeatSubmit() @PostMapping() @@ -91,7 +91,7 @@ public class ErpTempTaskController extends BaseController { /** * 修改软件部临时任务主 */ - @SaCheckPermission("oa:erp:tempTask:edit") + @SaCheckPermission("oa/erp:tempTask:edit") @Log(title = "软件部临时任务主", businessType = BusinessType.UPDATE) @RepeatSubmit() @PutMapping() @@ -104,7 +104,7 @@ public class ErpTempTaskController extends BaseController { * * @param tempTaskIds 主键串 */ - @SaCheckPermission("oa:erp:tempTask:remove") + @SaCheckPermission("oa/erp:tempTask:remove") @Log(title = "软件部临时任务主", businessType = BusinessType.DELETE) @DeleteMapping("/{tempTaskIds}") public R remove(@NotEmpty(message = "主键不能为空") @@ -124,7 +124,7 @@ public class ErpTempTaskController extends BaseController { /** * 提交临时任务并发起审批流程 */ - @SaCheckPermission("oa:erp:tempTask:submit") + @SaCheckPermission("oa/erp:tempTask:edit") @Log(title = "软件部临时任务提交审批", businessType = BusinessType.UPDATE) @RepeatSubmit() @PostMapping("/submit") @@ -135,7 +135,7 @@ public class ErpTempTaskController extends BaseController { /** * 主执行人审阅提交(无论是否修改,提交后回领导审批,AD-12) */ - @SaCheckPermission("oa:erp:tempTask:edit") + @SaCheckPermission("oa/erp:tempTask:edit") @Log(title = "临时任务主执行人审阅", businessType = BusinessType.UPDATE) @RepeatSubmit() @PostMapping("/assigneeReview") @@ -146,7 +146,7 @@ public class ErpTempTaskController extends BaseController { /** * 领导审批提交前回写可改字段,并返回换人回审变量(AD-12) */ - @SaCheckPermission("oa:erp:tempTask:edit") + @SaCheckPermission("oa/erp:tempTask:edit") @Log(title = "临时任务领导审批变量", businessType = BusinessType.UPDATE) @RepeatSubmit() @PostMapping("/leaderReviewVariables") @@ -157,7 +157,7 @@ public class ErpTempTaskController extends BaseController { /** * 领导审批并完成当前流程任务 */ - @SaCheckPermission("oa:erp:tempTask:edit") + @SaCheckPermission("oa/erp:tempTask:edit") @Log(title = "临时任务领导审批", businessType = BusinessType.UPDATE) @RepeatSubmit() @PostMapping("/leaderReviewComplete") @@ -168,7 +168,7 @@ public class ErpTempTaskController extends BaseController { /** * 查询任务参与人列表(主执行人+协作人) */ - @SaCheckPermission("oa:erp:tempTask:query") + @SaCheckPermission("oa/erp:tempTask:query") @GetMapping("/{tempTaskId}/members") public R> members(@NotNull(message = "主键不能为空") @PathVariable("tempTaskId") Long tempTaskId) { @@ -178,7 +178,7 @@ public class ErpTempTaskController extends BaseController { /** * 主执行人新增协作人(仅执行中,协作人增减不触发流程,AD-13) */ - @SaCheckPermission("oa:erp:tempTask:worklog") + @SaCheckPermission("oa/erp:tempTask:edit") @Log(title = "临时任务添加协作人", businessType = BusinessType.INSERT) @RepeatSubmit() @PostMapping("/member") @@ -189,7 +189,7 @@ public class ErpTempTaskController extends BaseController { /** * 移除协作人(主执行人不可移除,需走流程换人) */ - @SaCheckPermission("oa:erp:tempTask:worklog") + @SaCheckPermission("oa/erp:tempTask:edit") @Log(title = "临时任务移除协作人", businessType = BusinessType.DELETE) @DeleteMapping("/member/{memberId}") public R removeMember(@NotNull(message = "参与人ID不能为空") @@ -200,7 +200,7 @@ public class ErpTempTaskController extends BaseController { /** * 查询工时明细列表(按自然周,AD-07~AD-09) */ - @SaCheckPermission("oa:erp:tempTask:query") + @SaCheckPermission("oa/erp:tempTask:query") @GetMapping("/{tempTaskId}/worklogs") public R> worklogs(@NotNull(message = "主键不能为空") @PathVariable("tempTaskId") Long tempTaskId) { @@ -210,7 +210,7 @@ public class ErpTempTaskController extends BaseController { /** * 维护工时明细(新增/修改,仅执行中且未锁定) */ - @SaCheckPermission("oa:erp:tempTask:worklog") + @SaCheckPermission("oa/erp:tempTask:edit") @Log(title = "临时任务工时明细", businessType = BusinessType.UPDATE) @RepeatSubmit() @PostMapping("/worklog") @@ -221,7 +221,7 @@ public class ErpTempTaskController extends BaseController { /** * 删除工时明细(仅执行中、未锁定) */ - @SaCheckPermission("oa:erp:tempTask:worklog") + @SaCheckPermission("oa/erp:tempTask:edit") @Log(title = "临时任务删除工时明细", businessType = BusinessType.DELETE) @DeleteMapping("/worklog/{worklogId}") public R delWorklog(@NotNull(message = "工时明细ID不能为空") @@ -232,7 +232,7 @@ public class ErpTempTaskController extends BaseController { /** * 主执行人提交完成(整单锁定快照,AD-14/AD-15) */ - @SaCheckPermission("oa:erp:tempTask:submitFinish") + @SaCheckPermission("oa/erp:tempTask:edit") @Log(title = "临时任务提交完成", businessType = BusinessType.UPDATE) @RepeatSubmit() @PostMapping("/submitFinish") @@ -243,7 +243,7 @@ public class ErpTempTaskController extends BaseController { /** * 领导评分关闭(逐人评分,评分即关闭,AD-05/AD-16) */ - @SaCheckPermission("oa:erp:tempTask:score") + @SaCheckPermission("oa/erp:tempTask:edit") @Log(title = "临时任务评分关闭", businessType = BusinessType.UPDATE) @RepeatSubmit() @PostMapping("/scoreAndClose") @@ -254,7 +254,19 @@ public class ErpTempTaskController extends BaseController { /** * 查询可见评分(领导看全部,参与人仅看本人,AD-16) */ - @SaCheckPermission("oa:erp:tempTask:query") + /** + * 兜底同步任务状态至"待领导审核"。 + * 执行人绕过 submitFinish 直接推进到 leader_final 时,前端加载编辑页主动调此接口修复 taskStatus=3→4。 + */ + @SaCheckPermission("oa/erp:tempTask:list") + @PostMapping("/syncState/{tempTaskId}") + public R syncState(@NotNull(message = "主键不能为空") + @PathVariable("tempTaskId") Long tempTaskId) { + erpTempTaskService.syncStateToPendingFinal(tempTaskId); + return R.ok(); + } + + @SaCheckPermission("oa/erp:tempTask:query") @GetMapping("/{tempTaskId}/scores") public R> scores(@NotNull(message = "主键不能为空") @PathVariable("tempTaskId") Long tempTaskId) { diff --git a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpTempTask.java b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpTempTask.java index ee6e0d71..c3cf7ce4 100644 --- a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpTempTask.java +++ b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpTempTask.java @@ -15,7 +15,25 @@ import java.util.Date; /** * 软件部临时任务主实体对象 erp_temp_task - * 用于存储临时协作任务的整个生命周期状态及工时填报数据。 + * + *

业务定位:跨部门临时协作任务的全生命周期管理——从其他部门/本部门代发起,经主执行人审阅、 + * 软件部领导审批、执行(主+协作人按自然周记工时明细)、整单提交,到领导评分关闭。 + * 任务类型(部门/市场/项目)仅决定工时归集与报表统计维度,不参与流程路由。

+ * + *

核心设计决策(详见 soft.md AD-01~AD-16 与《跨部门临时任务需求》18条)

+ *
    + *
  • 任务类型不驱动流程路径(AD-01 / 需求1)——部门/市场/项目共走同一套 OATT 流程
  • + *
  • 双状态模式:taskStatus(业务阶段)+ flowStatus(流程运行态)(需求2-5 / 需求12)
  • + *
  • 区分流程发起人与实际需求来源(AD-10 / 需求11)——代发起场景下来源统计不污染
  • + *
  • 唯一主执行人 + 多协作人,协作人不触发流程流转(AD-13 / 需求8 / 需求15)
  • + *
  • 计划周期是排期/对比口径,不阻止提前完成(AD-06 / 需求6)
  • + *
  • 关闭时工时聚合写入 totalHours 冻结快照,关闭后永久锁定(AD-04 / AD-14 / 需求4 / 需求16)
  • + *
  • 执行中工时明细只暂存,整单提交时锁定,驳回后解冻(AD-03 / AD-15 / 需求3 / 需求17)
  • + *
+ * + *

工作流接入范式:严格遵循传统范式(参照 ErpContractInfoServiceImpl / ErpProjectInfoServiceImpl), + * flowCode='OATT',模块自建 submitAndFlowStart + @EventListener processHandler, + * 禁止使用 FlowConfigEnum / WorkflowStrategy / OaProcessEventHandler 配置驱动模式。

* * @author zch * @date 2026-06-17 @@ -29,88 +47,117 @@ public class ErpTempTask extends TenantEntity { private static final long serialVersionUID = 1L; /** - * 临时任务ID + * 临时任务唯一主键(雪花ID),也是工作流 businessId。 + * Why:在 Warm-Flow 流程实例与业务数据之间建立一一对应关系,流程回写时据此定位任务记录。 */ @TableId(value = "temp_task_id", type = IdType.ASSIGN_ID) private Long tempTaskId; /** - * 任务编号(LSRW+yyyyMMdd+4位,草稿即生成且全程不变) + * 任务编号(LSRW+yyyyMMdd+4位序列号,草稿保存时即生成且全程不变)。 + * Why:为临时任务提供稳定、全局唯一的业务编号,不随任务状态变更而变化,便于跨部门沟通引用与历史追溯; + * 编号由后端 codeRuleService 生成,前端不自行构造以防伪造。 */ private String tempTaskCode; /** - * 任务标题 + * 任务标题,简要概括任务内容。 + * Why:用于列表展示、审批中心待办标题、报表维度筛选;不参与流程路由决策。 */ private String taskTitle; /** - * 任务描述(发起必填) + * 任务描述(发起必填)。 + * Why:描述任务的业务背景、具体工作内容与期望产出——是审阅、审批、执行各节点的核心参考依据; + * 发起必填约束确保任务信息足够完整,避免信息缺失导致后续反复沟通返工。 */ private String taskDesc; /** - * 任务类型:1部门 2市场 3项目(仅用于工时归集与报表统计,不参与流程路由,AD-01) + * 任务类型:1=部门 / 2=市场 / 3=项目(仅用于工时归集与报表统计,不参与流程路由)。 + * Why(AD-01 / 需求1):三类共走同一套 OATT 审批流程,不按类型拆分流程路径—— + * 项目任务必须关联 projectId,部门任务必须明确 deptId,市场任务作为独立统计口径用于 + * 沉淀售前、市场支持、商机推进等暂未形成正式项目的工作量。报表可按此维度分别汇总各部门/市场/项目的临时人工时投入。 */ private String taskType; /** - * 计划开始时间,用于排期和计划/实际对比,不阻止提前完成。 + * 计划开始时间(领导审批确认的排期口径)。 + * Why(AD-06 / 需求6):用于预期排期与统计对比分析;不是硬性期限,不阻止执行人提前开始或提前完成—— + * 与 actualStartTime/actualFinishTime 配合形成计划 vs 实际的时间对比,支撑提前/按期/延期完成统计。 */ private Date planStartTime; /** - * 计划结束时间,用于排期和计划/实际对比,不阻止提前完成。 + * 计划完成时间(领导审批确认的排期口径)。 + * Why(AD-06 / 需求6):与 planStartTime 配套的排期终点;与 actualFinishTime 对比可区分提前完成、按期完成、延期完成三类统计。 + * 不构成"未到结束日不得提交完成"的硬限制。 */ private Date planEndTime; /** - * 实际开始时间(仅记周期,不计工时) + * 实际开始时间(流程进入执行节点时自动记录,仅记周期起点,不计工时)。 + * Why:与工时明细中的 workDate 区分——actualStartTime 是任务执行周期的起点标记, + * workDate 是具体某天某事项的工时耗费日期;两者口径不同,不能混淆。用于报表中的任务执行时段统计。 */ private Date actualStartTime; /** - * 实际结束/关闭时间(仅记周期,不计工时) + * 实际关闭时间(领导评分关闭时记录,即任务实际结束的时间口径)。 + * Why(AD-05 / 需求5):评分即关闭——领导提交评分时同步写入 actualFinishTime; + * 与 planEndTime 对比形成提前/按期/延期统计;关闭后全部记录永久锁定不可修改(AD-04 / 需求4)。 */ private Date actualFinishTime; /** - * 主报工项目ID + * 归集项目ID(任务类型=3 项目任务时必填,类型=1/2 时为 null)。 + * Why(AD-01):将临时任务工时归集到对应正式项目下——任务类型=项目时发起/审批阶段校验非空, + * 确保每条项目类临时任务工时都能归入正确的项目维度,避免工时"悬空"无法统计。 */ private Long projectId; /** - * 项目编号快照 + * 项目编号快照,数据冗余而非外键关联。 + * Why:快照策略——避免项目后续变更(如编号修改/项目合并/项目作废)影响历史临时任务记录的准确性; + * 报表按项目编号维度统计时以此为准。 */ private String projectCode; /** - * 项目名称快照 + * 项目名称快照,策略同 projectCode——确保历史数据稳定可读,不受项目主数据后续变更影响。 */ private String projectName; /** - * 部门归集ID,部门类任务用于工时归集;不等同于流程发起部门。 + * 部门归集ID(任务类型=1 部门任务时必填)。 + * Why(AD-01):部门类任务的工时归集目标部门——不等同于流程发起部门或实际需求部门(realRequestDeptId), + * 三个部门概念在同一任务中可各不相同(发起部门、需求来源部门、工时归集部门),各自服务于不同的统计口径。 */ private Long deptId; /** - * 部门归集名称快照。 + * 部门归集名称快照,快照策略确保历史数据不受部门主数据后续变更影响。 */ private String deptName; /** - * 发起人ID + * 流程发起人ID——记录"谁创建并提交了流程"。 + * Why(AD-10 / 需求11):代发起场景下不等于实际需求人(见 realRequesterId)—— + * requesterId 用于流程审计(谁在系统中完成了发起操作),realRequesterId 用于需求来源统计(谁真正需要这个任务); + * 其他部门用户自行发起时两者通常相同。 */ private Long requesterId; /** - * 发起人姓名快照 + * 流程发起人姓名快照,快照策略确保历史审计记录稳定。 */ private String requesterName; /** - * 实际需求人ID,代发起场景下用于区分流程发起人与真实需求来源。 + * 实际需求人ID——代发起场景下记录被代发起的业务人员。 + * Why(AD-10 / 需求11):区分"谁操作发起"与"谁需要任务"——本部门人员代市场/硬件/售后等部门发起任务时, + * requesterId=代发起人(软件部),realRequesterId=业务方人员(真实需求人);其他部门自发时 requesterId==realRequesterId。 + * 任务来源统计、需求追溯、通知对象应优先以实际需求人为准。 */ private Long realRequesterId; @@ -120,7 +167,9 @@ public class ErpTempTask extends TenantEntity { private String realRequesterName; /** - * 实际需求部门ID,用于需求来源统计与追溯。 + * 实际需求部门ID——记录真实提出需求的组织。 + * Why(AD-10 / 需求11):代发起场景下避免把代发起人所在部门误计为需求来源,确保跨部门需求统计准确; + * 例如软件部人员代市场部发起→realRequestDeptId=市场部ID,而非软件部ID。 */ private Long realRequestDeptId; @@ -130,75 +179,105 @@ public class ErpTempTask extends TenantEntity { private String realRequestDeptName; /** - * 软件部领导/审批人ID(审批、换人确认、最终审核与评分) + * 软件部领导/审批人ID——承担三个关键流程节点的办理角色。 + * Why:leader_review(准入审批+换人决策)、leader_final(最终审核+评分关闭)两个节点的办理人, + * 由工作流通过 ${softwareLeaderId} 变量解析路由,不写死个人——审批人变更时只需修改路由规则而非代码。 */ private Long softwareLeaderId; /** - * 软件部领导姓名快照 + * 软件部领导姓名快照,快照策略确保历史审批记录稳定。 */ private String softwareLeaderName; /** - * 主执行人ID(唯一,领导审核确定) + * 主执行人ID(唯一,由发起人可选指定或领导审批时重新分派)。 + * Why(AD-13 / 需求8):一个任务只有一个主执行人——是 assignee_review 审阅节点与 execute 执行节点的唯一办理人; + * 也是推进任务、协调协作人、整单提交完成的唯一责任人。协作人(member_type=2)不进入此字段, + * 不能成为流程办理人——主执行人是流程与业务之间的唯一责任锚点。 */ private Long assigneeId; /** - * 主执行人姓名快照 + * 主执行人姓名快照。 */ private String assigneeName; /** - * 主执行人审阅意见,流程变量承载,不落旧表。 + * 主执行人审阅意见(@TableField(exist=false),流程变量承载,不落主表)。 + * Why:assignee_review 节点中主执行人审阅确认时填写的意见——放入 Warm-Flow 流程变量表保留历史轨迹, + * 业务主表不冗余存储,避免与工作流变量表产生数据不一致;审批中心/流程记录中可查阅。 */ @TableField(exist = false) private String assigneeOpinion; /** - * 软件部领导审批意见,流程变量承载,不落旧表。 + * 软件部领导审批意见(@TableField(exist=false),流程变量承载,不落主表)。 + * Why:leader_review 节点中领导准入审批时的意见——含"通过/换人/驳回"的业务理由, + * 由工作流变量维护,便于在审批记录中回溯每一次审批决策依据。 */ @TableField(exist = false) private String leaderOpinion; /** - * 软件部领导最终审核意见,流程变量承载,不落旧表。 + * 软件部领导最终审核意见(@TableField(exist=false),流程变量承载,不落主表)。 + * Why:leader_final 节点中领导评分关闭或驳回时的最终意见——若驳回则说明驳回原因供执行人修改参考, + * 若评分关闭则说明总体评价;由工作流变量维护。 */ @TableField(exist = false) private String leaderFinalOpinion; /** - * 预估工时/小时(审阅/审批阶段填写) + * 预估工时/小时(审阅/审批阶段由主执行人或领导填写)。 + * Why:用于排期参考与工时预估 vs 实际对比分析;不参与实际工时统计(实际工时由 worklog 明细行累加), + * 仅作辅助决策——当预估与实际偏差较大时可作为复盘依据。 */ private BigDecimal estimateWorkload; /** - * 任务累计总工时(主+协作,关闭时由工时明细聚合冻结,AD-14) + * 任务累计总工时(主执行人+所有协作人有效工时之和,关闭时由 worklog 聚合写入的冗余快照)。 + * Why(AD-14 / 需求16):关闭时从 erp_temp_task_worklog 聚合计算后冻结写入—— + * 主执行人提交完成前校验 totalHours>0(AD-14),报表快速读取无需每次实时 SUM; + * 关闭后该值冻结不再变化,即使后续 worklog 行被逻辑删除也不影响已关闭任务的总工时口径。 */ private BigDecimal totalHours; /** - * 业务状态:1暂存 2审批中 3执行中 4待领导审核 5已关闭 6作废 + * 业务状态:1=暂存 / 2=审批中 / 3=执行中 / 4=待领导审核 / 5=已关闭 / 6=作废。 + * Why(需求2-5 / 需求17):粗粒度业务阶段,与 flowStatus 配合构成双状态模式—— + * 暂存(1):草稿可编辑,未发起流程,flowStatus=draft; + * 审批中(2):已发起流程但未进入执行,flowStatus=waiting; + * 执行中(3):可维护工时明细与协作人,flowStatus=waiting; + * 待领导审核(4):整单提交快照,主/协作人均不可修改,flowStatus=waiting(AD-15 / 需求17); + * 已关闭(5):领导评分关闭后永久锁定,flowStatus=finish(AD-04/AD-05 / 需求4/5); + * 作废(6):流程终止/撤回/撤销,flowStatus=invalid/termination/cancel。 + * 前端按钮显隐、编辑权限、工时明细可操作性均以此状态为准。 */ private String taskStatus; /** - * 流程状态:draft/waiting/finish/back/cancel/invalid/termination + * 流程状态:draft / waiting / finish / back / cancel / invalid / termination(Warm-Flow 回写)。 + * Why(需求12):由 @EventListener processHandler 根据 Warm-Flow ProcessEvent 驱动的双状态字段—— + * 流程状态反映工作流引擎实时运行态,是流程实例的生命周期表达; + * 与 taskStatus 各自独立演变(如 waiting 时 taskStatus 可为审批中/执行中/待审核的具体阶段), + * 两者共同支撑列表筛选、待办路由与权限控制。 */ private String flowStatus; /** - * 抄送人员用户ID(多个逗号分隔,实际抄送由工作流承载) + * 抄送人员用户ID(多个逗号分隔,实际抄送动作由 Warm-Flow 框架实现)。 + * Why(AD-11 / 需求12):业务只存人选名单不做推送——Warm-Flow 流程配置中的 CopySettingEnum + * 读取此字段值执行抄送;业务代码不重复设计消息触达机制,保持框架一致性。 */ private String ccUserIds; /** - * 备注 + * 备注——用于记录任务相关的补充说明、特殊事项、线下沟通结论等非结构化信息。 */ private String remark; /** - * 删除标志(0存在 1删除) + * 删除标志(0=存在 / 1=删除),MyBatis-Plus @TableLogic 逻辑删除字段。 */ @TableLogic private String delFlag; diff --git a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpTempTaskMember.java b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpTempTaskMember.java index 6f54e92a..09f5d640 100644 --- a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpTempTaskMember.java +++ b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpTempTaskMember.java @@ -12,7 +12,18 @@ import java.io.Serial; /** * 临时任务参与人实体对象 erp_temp_task_member - * Why:用 member_type 区分主执行人/协作人——主执行人随流程流转,协作人仅参与工时填报、增减不触发流程(AD-13)。 + * + *

业务定位:用 member_type 区分主执行人(1)与协作人(2)——主执行人随流程流转承担审阅/执行/提交职责, + * 协作人仅参与执行阶段的工时填报,增减不触发流程流转(AD-13 / 需求8 / 需求15)。

+ * + *

核心设计决策

+ *
    + *
  • 一个任务只有一个主执行人(memberType=1),是流程 assignee_review 与 execute 节点的唯一办理人
  • + *
  • 协作人(memberType=2)由主执行人在执行阶段动态添加,不触发流程流转,不进流程办理人
  • + *
  • 同一任务同一人只应有一条参与记录(uk_ttm_task_user 唯一约束),避免重复统计
  • + *
  • 协作人名单随整单提交一并进入领导审核(AD-15 / 需求17)
  • + *
  • userName/memberDeptId 为数据快照,确保历史参与记录不受人员离职/更名影响
  • + *
* * @author zch * @date 2026-06-22 @@ -26,43 +37,53 @@ public class ErpTempTaskMember extends TenantEntity { private static final long serialVersionUID = 1L; /** - * 参与人记录ID(雪花ID) + * 参与人记录ID(雪花ID),erp_temp_task_worklog.member_id 与 erp_temp_task_score.member_id 的外键来源。 */ @TableId(value = "member_id", type = IdType.ASSIGN_ID) private Long memberId; /** - * 临时任务ID + * 关联的临时任务ID,对应 erp_temp_task.temp_task_id。 */ private Long tempTaskId; /** - * 参与类型:1主执行人 2协作人 + * 参与类型:1=主执行人 / 2=协作人。 + * Why(AD-13 / 需求8 / 需求15):区分参与角色——主执行人(memberType=1)是流程 assignee_review 与 + * execute 节点的唯一办理人,承担审阅确认、推进执行、添加协作人、整单提交完成职责; + * 协作人(memberType=2)仅参与执行阶段填空工时明细,增减不触发流程流转,不成为流程办理人。 + * 该字段与 erp_temp_task.assigneeId 配合使用——主表 assigneeId 指向流程办理人,member 表记录完整参与人名单。 */ private String memberType; /** - * 参与人用户ID + * 参与人用户ID,与 sys_user 表的用户标识对应。 + * Why:worklog 和 score 子表通过此字段快速按用户维度查询工时与评分,无需连 member 表。 */ private Long userId; /** - * 参与人姓名快照 + * 参与人姓名快照。 + * Why:数据快照而非外键实时关联——任务关闭后即使人员离职或更名,历史参与记录仍可准确追溯; + * 评分表、工时报表中的人员姓名以此为准。 */ private String userName; /** - * 参与人部门ID快照 + * 参与人部门ID快照,记录参与人当时的所属部门——用于按部门维度的工时与绩效统计。 */ private Long memberDeptId; /** - * 协作事项说明(主执行人添加协作人时填写) + * 协作事项说明(主执行人添加协作人时必填)。 + * Why:说明该协作人的具体协作内容与分工边界——领导最终审核时据此判断协作人添加的合理性与工时投入的 + * 必要性;也是协作人本人了解自己职责范围的依据。 */ private String joinRemark; /** - * 删除标志(0存在 1删除) + * 删除标志(0=存在 / 1=删除),MyBatis-Plus @TableLogic 逻辑删除字段。 + * Why:协作人删除时逻辑删除而非物理删除——保留历史参与记录用于工时追溯与报表审计。 */ @TableLogic private String delFlag; diff --git a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpTempTaskScore.java b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpTempTaskScore.java index 1ca53a9e..d44e3d98 100644 --- a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpTempTaskScore.java +++ b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpTempTaskScore.java @@ -13,8 +13,19 @@ import java.util.Date; /** * 临时任务人员评分实体对象 erp_temp_task_score - * Why:领导关闭任务时分别给主执行人和每个协作人评分;可见性靠查询隔离—— - * 领导看全部、参与人只看到对自己的评分,参与人之间互不可见(AD-16)。 + * + *

业务定位:领导在 leader_final 节点评分关闭时,分别给主执行人和每个协作人逐人评分—— + * 评分等级走字典 temp_task_score(A++/A+/A/B/C),评分即关闭(AD-05 / 需求5), + * 可见性靠查询隔离——领导看全部、参与人只看到对自己那条评分,参与人之间互不可见(AD-16 / 需求18)。

+ * + *

核心设计决策

+ *
    + *
  • 一人一条评分记录——一个任务对一个被评分人只有一条评分(uk_tts_task_user 唯一约束)
  • + *
  • 评分等级字典化——A++非常卓越 / A+超出期望 / A达到期望 / B低于期望 / C不可接受,便于报表筛选与口径统一
  • + *
  • 可见性隔离——查询接口按 scorerId==当前用户 OR userId==当前用户 过滤,参与人之间不可互看
  • + *
  • scoreTime==actualFinishTime 口径——评分提交的时间即任务关闭时间
  • + *
  • C 评分是差评关闭而非驳回——若领导认为结果不可接受应使用驳回而非评 C
  • + *
* * @author zch * @date 2026-06-22 @@ -28,48 +39,61 @@ public class ErpTempTaskScore extends TenantEntity { private static final long serialVersionUID = 1L; /** - * 评分ID(雪花ID) + * 评分ID(雪花ID),每条评分的唯一标识。 */ @TableId(value = "score_id", type = IdType.ASSIGN_ID) private Long scoreId; /** - * 临时任务ID + * 关联的临时任务ID,对应 erp_temp_task.temp_task_id。 */ private Long tempTaskId; /** - * 被评分参与人记录ID + * 被评分参与人记录ID(关联 erp_temp_task_member.member_id)。 + * Why:通过 member 表可获取 memberType 区分主执行人评分与协作人评分,支撑按角色的评分报表分析。 */ private Long memberId; /** - * 被评分人用户ID + * 被评分人用户ID。 + * Why(AD-16 / 需求18):可见性隔离的关键过滤字段——查询评分列表时: + * scorerId==当前用户 → 返回全部评分(领导视角); + * userId==当前用户 → 仅返回自己那条(参与人视角,互不可见)。 */ private Long userId; /** - * 评分等级:A++ A+ A B C(temp_task_score 字典) + * 评分等级:A++ / A+ / A / B / C(走字典 temp_task_score)。 + * Why(AD-16 / 需求18):领导对任务完成质量的逐人评价—— + * A++=非常卓越(完成质量显著优于预期),A+=超出期望(高于预期),A=达到期望(符合预期), + * B=低于期望(存在不足但领导接受并关闭),C=不可接受(结果很差但以差评关闭; + * 若领导认为不能关闭应使用驳回功能而非评 C)。 */ private String scoreGrade; /** - * 评分说明 + * 评分说明——领导对评分等级的文字补充,说明评分依据、工作亮点或不足点。 + * Why:为评分提供业务语境,便于被评分人理解评价维度与改进方向。 */ private String scoreRemark; /** - * 评分人ID(软件部领导) + * 评分人ID(软件部领导,对应 erp_temp_task.softwareLeaderId)。 + * Why(AD-16):评分操作者的身份记录——用于评分审计追溯与可见性隔离; + * 与 userId 不同,scorerId 是评分的发出者,userId 是评分的接收者,两者不可混淆。 */ private Long scorerId; /** - * 评分时间(即任务关闭时间口径) + * 评分时间(即任务关闭时间口径)。 + * Why(AD-05 / 需求5):评分提交即关闭——scoreTime 与 erp_temp_task.actualFinishTime 一致, + * 作为提前/按期/延期完成统计的基准时间;评分后任务流程结束,全部记录锁定(AD-04 / 需求4)。 */ private Date scoreTime; /** - * 删除标志(0存在 1删除) + * 删除标志(0=存在 / 1=删除),MyBatis-Plus @TableLogic 逻辑删除字段。 */ @TableLogic private String delFlag; diff --git a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpTempTaskWorklog.java b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpTempTaskWorklog.java index dc25e1f7..1f9b86d9 100644 --- a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpTempTaskWorklog.java +++ b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpTempTaskWorklog.java @@ -14,8 +14,20 @@ import java.util.Date; /** * 临时任务工时明细实体对象 erp_temp_task_worklog - * Why:自然周(week_start)是统计归属维度,工作日期+事项是录入粒度,允许同周/同天多条稀疏记录; - * 附件挂明细而非主表,才能对应到具体事项与工时,便于领导审核(AD-07~AD-09)。 + * + *

业务定位:主执行人与协作人在任务执行阶段按自然周/工作日期/事项维护的实际工时投入明细—— + * 采用稀疏记录模式(AD-07 / 需求7),同一参与人同一自然周/同一天允许多条(AD-08 / 需求8/9), + * 附件挂明细而非主表(AD-09 / 需求10),执行中可暂存修改(AD-03 / 需求3), + * 整单提交后锁定、驳回后解冻、关闭后永久冻结(AD-15/AD-04 / 需求17/4)。

+ * + *

核心设计决策

+ *
    + *
  • weekStart/weekEnd 是统计归属维度,由 workDate 自动推导为所在自然周的周一~周日
  • + *
  • workDate+workContent 是录入粒度——同任务同人同天可按事项拆分多条(刻意不加唯一约束)
  • + *
  • ossId 挂明细而非主表——附件与具体日期/事项/工时一一对应,领导审核时可逐条核实
  • + *
  • lockFlag 三级锁定:执行中=0(可编辑)→ 整单提交=1(临时冻结)→ 关闭=1(永久冻结)
  • + *
  • 工时精确到最小0.5小时粒度——只填归属本任务的工时,不要求每周补0(稀疏记录)
  • + *
* * @author zch * @date 2026-06-22 @@ -29,63 +41,82 @@ public class ErpTempTaskWorklog extends TenantEntity { private static final long serialVersionUID = 1L; /** - * 工时明细ID(雪花ID) + * 工时明细ID(雪花ID),每条工时投入的唯一标识。 */ @TableId(value = "worklog_id", type = IdType.ASSIGN_ID) private Long worklogId; /** - * 临时任务ID + * 关联的临时任务ID,对应 erp_temp_task.temp_task_id。 */ private Long tempTaskId; /** - * 参与人记录ID(erp_temp_task_member.member_id) + * 参与人记录ID(关联 erp_temp_task_member.member_id)。 + * Why:明细归属到具体参与人——配合 member 表的 memberType 字段可按主执行人/协作人拆分统计工时, + * 支撑需求16要求的"任务总工时 + 按人员拆分"双维度报表。 */ private Long memberId; /** - * 填报人用户ID(冗余,便于查询) + * 填报人用户ID(冗余字段,与 member.userId 一致)。 + * Why:冗余存储——避免每次按用户查询工时明细时都需要连表 member 获取 userId, + * 提高工时明细列表查询性能。 */ private Long userId; /** - * 所属自然周周一(统计归属维度) + * 所属自然周周一(统计归属维度)。 + * Why(AD-07 / 需求7):由 workDate 自动推导为所在自然周的周一日期——自然周是工时统计的基础聚合单位, + * 报表按周汇总所有参与人投入;系统端推导而非前端传入以保证统计口径一致。 */ private Date weekStart; /** - * 所属自然周周日 + * 所属自然周周日,与 weekStart 配套明确自然周起止范围,便于跨周查询与周报表展示。 */ private Date weekEnd; /** - * 工作日期(录入粒度) + * 工作日期(录入粒度——具体某天)。 + * Why(AD-08 / 需求9):记录实际发生工时投入的具体日期——同一参与人同一天可有多条记录(按事项拆分), + * 表上刻意不在 (task,user,work_date) 上加唯一约束以支持此业务场景。 */ private Date workDate; /** - * 本条消耗工时/小时(最小0.5) + * 本条消耗工时/小时(最小0.5小时,精确到0.5)。 + * Why:满足工时统计精度要求——只填归属本任务的工时,不代表参与人当天全部工作量; + * 一个自然周一条任务可能有多条明细,报表按周/日/人汇总得出该自然周总投入。 */ private BigDecimal hours; /** - * 任务/事项描述 + * 任务/事项描述,具体记录本条工时投入所完成的工作内容。 + * Why:领导审核工时的核心依据——"某天做了什么事,花了多少工时,有附件佐证"的完整证据链; + * 申领工时必须有具体事项支撑,空描述或过于笼统的描述应被校验拦截。 */ private String workContent; /** - * 附件ID(可选,多个逗号分隔) + * 附件ID(可选,多个逗号分隔,对应 MinIO/S3 对象存储)。 + * Why(AD-09 / 需求10):附件挂明细而非主表——只有挂在具体工时明细上,才能将证明材料对应到 + * 具体日期、具体事项和具体工时,便于领导逐条审核工时真实性; + * 执行中可选上传,关闭后与明细一同锁定不可修改(AD-04 / 需求4)。 */ private String ossId; /** - * 锁定标志:0可编辑 1已锁定(整单提交锁定、驳回解锁、关闭永久锁定) + * 锁定标志:0=可编辑 / 1=已锁定。 + * Why(AD-03 / AD-15 / AD-04 / 需求3 / 需求17 / 需求4): + * 0→执行中可新增/修改/删除(暂存,不流转);1→主执行人提交完成时批量置1(整单临时冻结待审核); + * 1→领导驳回时批量置0回可编辑(解冻修改);1→领导评分关闭时批量置1(永久冻结不可再改)。 + * 三个写接口(saveWorklog/updateWorklog/delWorklog)均校验 lockFlag=0 与 taskStatus=3 才放行。 */ private String lockFlag; /** - * 删除标志(0存在 1删除) + * 删除标志(0=存在 / 1=删除),MyBatis-Plus @TableLogic 逻辑删除字段。 */ @TableLogic private String delFlag; diff --git a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/IErpTempTaskService.java b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/IErpTempTaskService.java index ad7e4a08..f8f66829 100644 --- a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/IErpTempTaskService.java +++ b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/IErpTempTaskService.java @@ -106,6 +106,14 @@ public interface IErpTempTaskService { */ Boolean leaderReviewAndComplete(ErpTempTaskBo bo); + /** + * 兜底同步:流程已在 leader_final 但 taskStatus 仍为 3 时, + * 补齐 totalHours 汇总 + 锁定工时明细 + taskStatus→4(待领导审核)。 + * + * @param tempTaskId 临时任务ID + */ + void syncStateToPendingFinal(Long tempTaskId); + /** * 查询任务参与人列表(主执行人+协作人) * diff --git a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/impl/ErpTempTaskServiceImpl.java b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/impl/ErpTempTaskServiceImpl.java index 641a9aed..68f6c483 100644 --- a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/impl/ErpTempTaskServiceImpl.java +++ b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/impl/ErpTempTaskServiceImpl.java @@ -208,10 +208,17 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { return params != null && Convert.toBool(params.get("includeDetail"), false); } + /** + * Why:批量聚合优化——分页列表查询后,一次性批量拉取所有记录的参与人/工时/评分明细, + * 而非在每条记录上逐个 query(N+1 问题)。通过 taskIds IN(...) 将三次 DB 查询降为三次, + * 然后用 Map 分组归集到各 record,大幅降低 DB 往返开销。 + * 评分可见性隔离在此循环内直接过滤,而非单独调用 listVisibleScore(避免二次 DB 查询)。 + */ private void fillDetailRows(List records) { if (records == null || records.isEmpty()) { return; } + // Why:提取所有 taskId,作为批量 IN 查询的 key 集合。 List taskIds = records.stream() .map(ErpTempTaskVo::getTempTaskId) .filter(Objects::nonNull) @@ -220,6 +227,7 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { return; } + // Why:一次 DB 查询拉取所有记录的参与人列表,构建 memberMap(按 taskId 分组)和 memberNameMap(按 memberId 映射姓名)。 List members = memberMapper.selectVoList(Wrappers.lambdaQuery() .in(ErpTempTaskMember::getTempTaskId, taskIds) .orderByAsc(ErpTempTaskMember::getMemberType)); @@ -230,6 +238,7 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { memberNameMap.computeIfAbsent(member.getTempTaskId(), key -> new HashMap<>()).put(member.getMemberId(), member.getUserName()); } + // Why:一次 DB 查询拉取所有工时明细,按 taskId 分组并回填填报人姓名(从 memberNameMap 取)。 List worklogs = worklogMapper.selectVoList(Wrappers.lambdaQuery() .in(ErpTempTaskWorklog::getTempTaskId, taskIds) .orderByAsc(ErpTempTaskWorklog::getWeekStart) @@ -242,14 +251,19 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { } Long uid = LoginHelper.getUserId(); + // Why:构建 leaderMap(taskId→softwareLeaderId),用于评分可见性判断。 Map leaderMap = new HashMap<>(taskIds.size()); for (ErpTempTaskVo record : records) { leaderMap.put(record.getTempTaskId(), record.getSoftwareLeaderId()); } + // Why:一次 DB 查询拉取所有评分明细,在应用层循环中进行**评分可见性过滤**: + // 领导(softwareLeaderId)看全部评分,被评分人自己只看到自己被评的分数,其他人看不到任何评分记录(AD-16)。 + // 此处 inline 过滤而非调用 listVisibleScore,避免对每条记录再次查 DB。 List scores = scoreMapper.selectVoList(Wrappers.lambdaQuery() .in(ErpTempTaskScore::getTempTaskId, taskIds)); Map> scoreMap = new HashMap<>(taskIds.size()); for (ErpTempTaskScoreVo score : scores) { + // Why:可见性隔离——只有领导(softwareLeaderId)或被评分人本人才能看到评分。 boolean visible = Objects.equals(uid, leaderMap.get(score.getTempTaskId())) || Objects.equals(uid, score.getUserId()); if (!visible) { continue; @@ -259,6 +273,7 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { scoreMap.computeIfAbsent(score.getTempTaskId(), key -> new ArrayList<>()).add(score); } + // Why:将已分组的数据 set 到每条 VO 上,供前端直接渲染。 for (ErpTempTaskVo record : records) { Long taskId = record.getTempTaskId(); record.setMembers(memberMap.getOrDefault(taskId, Collections.emptyList())); @@ -325,22 +340,23 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { * @return 发起后的最新任务详情 */ @Override + // Why:@GlobalTransactional 保证业务数据保存→流程发起→办结首任务节点在同一 Seata 分布式事务内原子提交或整体回滚, + // 避免出现”业务数据已入库但流程实例未创建”或”流程实例已创建但业务数据回滚”这类半一致状态。 @GlobalTransactional(rollbackFor = Exception.class) public ErpTempTaskVo tempTaskSubmitAndFlowStart(ErpTempTaskBo bo) { // 1. 发起提交前校验:任务描述与需求时间非空,且紧急优先级必须有紧急原因 validBeforeSubmit(bo); // 2. 补齐草稿字段,如果是首次直接提交则在此节点生成 LSRW 唯一编号 prepareDraft(bo); - // 双状态机制初始化:任务提交后先进入审批中,后续执行/待审核/关闭由业务接口或流程节点显式推进。 + // Why:双状态机制初始化——taskStatus(2-审批中) 控制前端页面交互与业务语义;flowStatus(waiting) 同步 Warm-Flow 引擎, + // 二者独立维护但联动,后续 execute/leader_final/关闭 由业务接口或流程事件精确推进,避免笼统写死。 bo.setTaskStatus(STATUS_APPROVING); bo.setFlowStatus(BusinessStatusEnum.WAITING.getStatus()); boolean saved; - // 3. 根据 ID 判断是首次直接提交还是基于已有草稿提交,执行相应的落库: - // - 若 tempTaskId 为 null:说明用户未存草稿,直接在页面点击了"提交审批"并发起流程。 - // 需将其转换为实体,进行入库前常规校验并插入数据库生成主键 ID,再反写回 BO。 - // - 若 tempTaskId 不为 null:说明是由已有草稿发起提交,或者是流程退回、撤销后重新提交。 - // 需校验编辑权限 checkEditable 避免越权与非法篡改,再进行合法性校验并更新数据库记录。 + // Why:两分支覆盖”直接提交”与”草稿后提交”: + // - tempTaskId==null:用户未存草稿直接点”提交审批”,需先 insert 获取主键 ID 作为流程 businessId; + // - tempTaskId!=null:基于已有草稿提交或退回/撤销后重新提交,走 update 保留原有 ID,但必须先 checkEditable 防止越权篡改。 if (bo.getTempTaskId() == null) { ErpTempTask add = MapstructUtils.convert(bo, ErpTempTask.class); validEntityBeforeSave(add); @@ -352,31 +368,54 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { validEntityBeforeSave(update); saved = baseMapper.updateById(update) > 0; } - // 如果数据未成功保存或未反写 ID,则直接抛出 ServiceException 异常回滚事务,防止没有绑定业务 ID 的“悬空流程实例”产生 + // Why:保存失败或无 businessId 时立即抛异常触发 Seata 回滚,防止”悬空流程实例”(没有绑定业务数据的流程记录,后续无法闭环)。 if (!saved || bo.getTempTaskId() == null) { throw new ServiceException("临时任务保存失败,无法发起流程"); } - // V3:发起时若已指定主执行人,同步参与人表主执行人记录(AD-13) + // Why:发起时若已指定主执行人,同步写入参与人表(AD-13)。放在落库后而非落库前,确保 taskId 已生成。 refreshMainAssigneeMember(bo.getTempTaskId(), bo.getAssigneeId(), bo.getAssigneeName()); // 4. 组装 Warm-Flow 远程流程启动对象 RemoteStartProcess startProcess = new RemoteStartProcess(); startProcess.setBusinessId(bo.getTempTaskId().toString()); startProcess.setFlowCode(FLOW_CODE); // 固定编码 OATT - // 构建用于流程走向/办理人解析的最小 variables Map + // Why:variables 只携带流程路由(has_assignee 网关)+ 办理人解析(assigneeId/softwareLeaderId/requesterId)所需最小字段, + // 不将完整业务对象塞入,避免流程变量表膨胀并降低序列化风险。ignore=true 仅在发起流程时使用,让引擎自动跳过已办完的申请人首节点。 startProcess.setVariables(buildWorkflowVariables(bo, true)); // 5. 补充待办中心/待办列表所需的业务展示扩展字段,包含编号和拼接后的标题 + // Why:bizExt 是审批中心/待办列表的展示用快照,存入流程实例表供前端列表渲染,不做流程路由决策。 bo.getBizExt().setBusinessId(startProcess.getBusinessId()); bo.getBizExt().setBusinessCode(bo.getTempTaskCode()); bo.getBizExt().setBusinessTitle(buildBusinessTitle(bo)); startProcess.setBizExt(bo.getBizExt()); // 6. 远程调用工作流服务发起并完成申请人节点 + // Why:startCompleteTask 一步完成"创建流程实例 → 自动完成首节点(申请人)",避免申请人看到自己的待办。 + // 失败直接抛业务异常触发全局回滚,确保不会产生半拉流程记录。 boolean started = remoteWorkflowService.startCompleteTask(startProcess); if (!started) { throw new ServiceException("流程发起异常"); } + // Why:Warm-Flow 的 anyNodeSkip:"submit" 机制——发起人同时是主执行人或软件部领导时, + // 工作流引擎会自动跳过 assignee_review 和/或 leader_review 节点直达 execute。 + // 自动跳过完全在引擎内部完成,绕过了 buildLeaderReviewVariables。 + // 因此必须在发起后补刀 taskStatus,否则前端 canExecute 不会出现。 + // 条件:①指定了主执行人 + 发起人==主执行人==领导 → 两节点全跳过 → 直达 execute + // ②未指定主执行人 + 发起人==领导 → 跳过 leader_review → 直达 execute + boolean initiatorIsAssignee = bo.getAssigneeId() != null + && Objects.equals(bo.getRequesterId(), bo.getAssigneeId()); + boolean initiatorIsLeader = Objects.equals(bo.getRequesterId(), bo.getSoftwareLeaderId()); + boolean autoPassToExecute = (initiatorIsAssignee && initiatorIsLeader) + || (bo.getAssigneeId() == null && initiatorIsLeader); + if (autoPassToExecute) { + ErpTempTask task = baseMapper.selectById(bo.getTempTaskId()); + if (task != null && !STATUS_RUNNING.equals(task.getTaskStatus())) { + task.setTaskStatus(STATUS_RUNNING); + task.setActualStartTime(new Date()); + baseMapper.updateById(task); + } + } return baseMapper.selectCustomErpTempTaskVoById(bo.getTempTaskId()); } @@ -442,8 +481,9 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { /** * 临时任务流程扭转事件监听 (传统范式回写) - * 使用 `@EventListener` 隔离工作流框架。仅在状态流转时,回写流程状态 `flow_status` 与粗粒度的业务状态 `task_status`。 - * 人员评分与工时冻结由业务接口显式控制,流程事件只做双状态同步。 + * Why:使用 @EventListener 而非实现接口,业务模块自建监听器精确匹配本模块 flowCode, + * 仅做双状态(flowStatus + taskStatus)回写,不耦合工作流框架内部实现。 + * 人员评分与工时冻结由业务接口显式控制,流程事件只做状态同步——单一职责。 */ @EventListener(condition = "#processEvent.flowCode == 'OATT'") public void processHandler(ProcessEvent processEvent) { @@ -458,11 +498,14 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { String status = processEvent.getStatus(); String nodeCode = processEvent.getNodeCode(); - // 2) 业务状态按"流程状态 + 当前节点"精确映射(AD-15):避免执行/待审核态被笼统回写成审批中 + // Why:WAITING 三路分叉——根据当前节点精确映射业务状态以避免"审批中"笼统覆盖: + // - execute 节点:流程流转到"执行"节点 → taskStatus=3(执行中) 让主执行人可填工时 + // - leader_final 节点:流程流转到"领导最终审核" → taskStatus=4(待领导审核) 冻结工时只读 + // - 其他审批节点(leader_review/cc):保持 taskStatus=2(审批中),前端只展示审批界面 if (Objects.equals(status, BusinessStatusEnum.WAITING.getStatus())) { - // Why:执行节点=执行中(3),领导评分节点=待领导审核(4),其余审批节点=审批中(2) if ("execute".equals(nodeCode)) { task.setTaskStatus(STATUS_RUNNING); + // Why:首次进入执行节点自动打上实际开始时间戳,用于后续提前/按期/延期口径计算(AD-06)。 if (task.getActualStartTime() == null) { task.setActualStartTime(new Date()); } @@ -472,7 +515,8 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { task.setTaskStatus(STATUS_APPROVING); } } else if (Objects.equals(status, BusinessStatusEnum.FINISH.getStatus())) { - // Why:流程结束=领导评分关闭;scoreAndClose 已显式置5,此处兜底防止漏写 + // Why:流程结束=领导评分关闭;scoreAndClose 已显式置 taskStatus=5,此处兜底防御 + // 防止流程因异常路径(如直接流程强制结束)FINISH 但 taskStatus 未闭环的情况。 if (!STATUS_CLOSED.equals(task.getTaskStatus())) { task.setTaskStatus(STATUS_CLOSED); } @@ -480,9 +524,15 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { || Objects.equals(status, BusinessStatusEnum.TERMINATION.getStatus())) { task.setTaskStatus(STATUS_INVALID); // 6 作废 } else if (Objects.equals(status, BusinessStatusEnum.BACK.getStatus())) { - // Why:领导最终审核驳回应回执行中并解锁工时,不能依赖 Warm-Flow 版本对 nodeCode 源/目标节点的取值差异。 + // Why:退回分支——根据当前 taskStatus 和 nodeCode 决定回退到哪个状态: + // - STATUS_PENDING_FINAL(=4) 或 nodeCode=execute:说明是从"领导最终审核"或"执行"节点退回, + // 应回到 taskStatus=3(执行中) 并 unlock 工时明细,让主执行人继续维护; + // - 其他情况(如从 leader_review 退回):回到 taskStatus=1(暂存) 让发起人重新编辑。 + // 注意:不能依赖 Warm-Flow 版本对 nodeCode 源/目标节点的取值差异, + // 需同时以 taskStatus 做辅助判断,确保倒退后的业务状态一致且工时锁释放。 if (STATUS_PENDING_FINAL.equals(task.getTaskStatus()) || "execute".equals(nodeCode)) { task.setTaskStatus(STATUS_RUNNING); + // Why:退回时必须 unlock 工时明细,否则提交完成时锁定的工时无法继续填写(AD-15)。 worklogMapper.lockByTask(task.getTempTaskId(), "0"); } else { task.setTaskStatus(STATUS_DRAFT); @@ -553,22 +603,31 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { /** * 构建用于发起流程及流转所携带的 variables 变量 Map - * variables 只存放新版 OATT 流程分支(has_assignee/reassigned)与办理人解析(softwareLeaderId/assigneeId)必须的属性。 + * Why:variables 只存放新版 OATT 流程分支(has_assignee/reassigned)与办理人解析(softwareLeaderId/assigneeId)必须的属性。 + * 变量来源优先级:BO 直接字段 > bo.sourceVariables(历史变量)。先取主后取备用。 */ private Map buildWorkflowVariables(ErpTempTaskBo bo) { return buildWorkflowVariables(bo, false); } + /** + * Why:ignorePermission 仅在流程发起(startCompleteTask)时设为 true,用于引擎自动完成申请人节点时 + * 跳过"办理人必须是当前登录人"的校验。后续真人办理(leader_review/execute/leader_final)的 completeTask + * 调用不传 ignore=true,以确保只有指定的办理人才能推进流程,防止越权操作。 + */ private Map buildWorkflowVariables(ErpTempTaskBo bo, boolean ignorePermission) { Map sourceVariables = bo.getVariables(); Map variables = new HashMap<>(); if (ignorePermission) { - variables.put("ignore", true); // 仅流程发起时自动完成申请人节点使用,真人办理节点不得绕过办理人校验 + // Why:仅流程发起时设为 true,让引擎自动完成申请人节点时绕过办理人权限校验。 + variables.put("ignore", true); } boolean hasAssignee = bo.getAssigneeId() != null || sourceVariables.get("assigneeId") != null; variables.put("has_assignee", hasAssignee ? "1" : "0"); variables.put("reassigned", StringUtils.blankToDefault(Convert.toStr(sourceVariables.get("reassigned")), "0")); + // Why:变量来源——先取 BO 直接字段(前端/业务接口最新值),为 null 时 fallback 到 sourceVariables 历史值, + // 保证变量不丢失且不覆盖为 null。 putIfPresent(variables, "taskTitle", firstNonNull(bo.getTaskTitle(), sourceVariables.get("taskTitle"))); putIfPresent(variables, "taskDesc", firstNonNull(bo.getTaskDesc(), sourceVariables.get("taskDesc"))); putIfPresent(variables, "taskType", firstNonNull(bo.getTaskType(), sourceVariables.get("taskType"))); @@ -586,8 +645,8 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { putIfPresent(variables, "leaderOpinion", firstNonNull(bo.getLeaderOpinion(), sourceVariables.get("leaderOpinion"))); putIfPresent(variables, "actualStartTime", firstNonNull(bo.getActualStartTime(), sourceVariables.get("actualStartTime"))); putIfPresent(variables, "leaderFinalOpinion", firstNonNull(bo.getLeaderFinalOpinion(), sourceVariables.get("leaderFinalOpinion"))); - putIfPresent(variables, "ccUserIds", firstNonNull(bo.getCcUserIds(), sourceVariables.get("ccUserIds"))); - // 清理空值项,防止序列化问题 +// putIfPresent(variables, "ccUserIds", firstNonNull(bo.getCcUserIds(), sourceVariables.get("ccUserIds"))); + // Why:清理 null 值条目,防止流程变量序列化/反序列化时 null 值导致类型推断错误或 JSON 清理逻辑不一致。 variables.entrySet().removeIf(entry -> Objects.isNull(entry.getValue())); return variables; } @@ -650,26 +709,34 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { /** * 同步参与人表中的"主执行人"记录 - * Why:主执行人随流程流转(发起指定/领导换人),需保证参与人表恒有且仅有一条主执行人记录, - * 且若新主执行人此前是协作人需先移除其协作记录,避免 (task,user) 唯一约束冲突(AD-12/AD-13)。 + * Why:主执行人随流程流转变化(发起指定/领导审批换人),需保证参与人表中始终有且仅有一条 memberType='1' 的主执行人记录。 + * 唯一性保证三步策略: + * 1. 若旧主执行人与新 assigneeId 相同 → 跳过处理(幂等,避免无效删除/插入)。 + * 2. 若不同 → 先 delete 旧主执行人记录。 + * 3. **关键**:在插入新主执行人前,必须先 delete 该用户在本任务下的协作记录(memberType='2')。 + * 因为参与人表存在 (tempTaskId, userId) 唯一约束,若新主执行人此前已是协作人, + * 直接 insert(memberType='1') 会违反 UK 冲突。先删协作记录再插主执行人 = 升级身份且不破坏唯一约束(AD-12/AD-13)。 */ private void refreshMainAssigneeMember(Long taskId, Long assigneeId, String assigneeName) { if (taskId == null || assigneeId == null) { return; } + // 1. 查询当前主执行人记录 ErpTempTaskMember existMain = memberMapper.selectOne(Wrappers.lambdaQuery() .eq(ErpTempTaskMember::getTempTaskId, taskId) .eq(ErpTempTaskMember::getMemberType, MEMBER_TYPE_MAIN)); if (existMain != null) { if (Objects.equals(existMain.getUserId(), assigneeId)) { - return; // 主执行人未变更,无需处理 + return; // Why:主执行人未变更,幂等跳过,避免不必要的 delete+insert。 } + // 2. 旧主执行人 ≠ 新主执行人,移除旧记录 memberMapper.deleteById(existMain.getMemberId()); } - // 若新主执行人此前以协作人身份参与,先移除其协作记录,规避唯一约束冲突 + // 3. Why:若新主执行人此前以协作人身份参与,先移除其协作记录,规避 (taskId,userId) 唯一约束冲突。 memberMapper.delete(Wrappers.lambdaQuery() .eq(ErpTempTaskMember::getTempTaskId, taskId) .eq(ErpTempTaskMember::getUserId, assigneeId)); + // 4. 插入新主执行人记录 ErpTempTaskMember main = new ErpTempTaskMember(); main.setTempTaskId(taskId); main.setMemberType(MEMBER_TYPE_MAIN); @@ -680,7 +747,11 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { /** * 主执行人审阅提交:无论是否修改,提交后都回领导审批(AD-12) - * Why:仅当前主执行人可审阅;用选择性回写而非整体覆盖,避免清空流程态字段。流程流转由前端 submitVerify→completeTask 承载。 + * Why:仅当前主执行人可审阅,避免协作人或发起人篡改。 + * Why:采用**选择性字段回写**而非整体 Mapstruct 覆盖——只更新任务描述/计划时间/预估工时等少量允许修改的字段, + * 保留 taskStatus/flowStatus/assigneeId/softwareLeaderId 等核心流程控制字段不变, + * 防止前端传入的 BO 对象意外覆盖掉这些由后端流程事件维护的状态字段。 + * 流程流转(退回领导审批)由前端 submitVerify 组件调用 completeTask 承载,本方法仅做业务数据保存。 */ @Override public Boolean assigneeReviewSubmit(ErpTempTaskBo bo) { @@ -691,6 +762,7 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { if (!Objects.equals(LoginHelper.getUserId(), task.getAssigneeId())) { throw new ServiceException("只有主执行人可以审阅提交"); } + // Why:逐一判断不为空再 set,仅回写主执行人有权修改的字段,其余字段(taskStatus/flowStatus/assigneeId 等)保持不变。 if (StringUtils.isNotBlank(bo.getTaskTitle())) { task.setTaskTitle(bo.getTaskTitle()); } @@ -712,7 +784,11 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { /** * 领导审批:回写领导可修改的任务信息,并计算是否换主执行人。 - * Why:reassigned 必须以后端持久化前后的主执行人差异为准,不能信任前端缓存变量。 + * Why:reassigned 必须以后端持久化前后的主执行人差异为准(oldAssigneeId != newAssigneeId), + * 不能信任前端传入的 variables.reassigned,防止客户端伪造或缓存滞后导致流程网关分支错判。 + * Why:此处用 @Transactional 而非 @GlobalTransactional,因为此方法是被 leaderReviewAndComplete 内部调用的私有步骤, + * leaderReviewAndComplete 已标注 @GlobalTransactional,内层 @Transactional 会渗入外层 Seata 全局事务, + * 无需重复标注避免事务管理器混淆(Spring 本地事务与 Seata 全局事务兼容嵌套,但外层已统一管控)。 */ @Override @Transactional(rollbackFor = Exception.class) @@ -731,21 +807,39 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { throw new ServiceException("进入执行前必须指定主执行人"); } + // Why:持久化前先快照旧主执行人 ID,用于后续对比判断 reassigned。 Long oldAssigneeId = task.getAssigneeId(); applyLeaderReviewFields(task, bo); + // Why:reassigned=0 意味着领导没有换主执行人,下一节点直接进入 execute(执行), + // 在此提前置 taskStatus=3(执行中),避免依赖跨 JVM 的 ProcessEvent 回写不及时。 + // processHandler 仍作为兜底,但此处是业务主路径。 + boolean willEnterExecute = Objects.equals(oldAssigneeId, task.getAssigneeId()); + if (willEnterExecute) { + task.setTaskStatus(STATUS_RUNNING); + if (task.getActualStartTime() == null) { + task.setActualStartTime(new Date()); + } + } validEntityBeforeSave(task); baseMapper.updateById(task); + // Why:任务类型变更后清理不匹配的聚合字段(如从"项目任务"改为"部门任务"时置空 projectId 等) clearAggregationFields(task.getTempTaskId(), task.getTaskType()); + // Why:领导可能换主执行人,同步更新参与人表保证一致性(AD-13) refreshMainAssigneeMember(task.getTempTaskId(), task.getAssigneeId(), task.getAssigneeName()); Map variables = buildWorkflowVariables(toBo(task)); + // Why:reassigned 必须基于持久化前后的 old vs new 主执行人 ID 对比计算, + // 确保流程网关(has_assignee=1 且 reassigned=1 时走"主执行人审阅"节点,否则直入 execute)准确无误。 variables.put("reassigned", Objects.equals(oldAssigneeId, task.getAssigneeId()) ? "0" : "1"); variables.put("has_assignee", "1"); return variables; } /** - * 领导审核原子提交:业务字段回写与 Warm-Flow 当前任务办理放在同一分布式事务里完成。 + * 领导审核原子提交:业务字段回写 + Warm-Flow 当前任务办理 放在同一个 Seata 分布式事务中完成。 + * Why:先 buildLeaderReviewVariables(内部已落库业务数据并构建 variables),再将 leaderOpinion 追加入 variables, + * 最后 completeTask 推进流程。三步一个 @GlobalTransactional,保证"业务数据变更"与"流程推进"的一致性—— + * 不会出现业务数据已更新但流程未推进(卡死),或流程已推进但业务数据未持久化(数据丢失)。 */ @Override @GlobalTransactional(rollbackFor = Exception.class) @@ -753,8 +847,10 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { if (bo == null) { throw new ServiceException("临时任务审批数据不能为空"); } + // Why:先校验 taskId 确实属于当前临时任务且 nodeCode=leader_review,防止前端传入篡改的 taskId 越权办理。 validateWorkflowTaskBelongsToBusiness(bo.getTempTaskId(), bo.getTaskId(), "leader_review", LoginHelper.getUserId()); Map variables = buildLeaderReviewVariables(bo); + // Why:leaderOpinion 由 buildLeaderReviewVariables 构建后再追加,确保审批意见同时用于 variables 流转和审批记录 message 展示。 putIfPresent(variables, "leaderOpinion", bo.getLeaderOpinion()); RemoteCompleteTask completeTask = new RemoteCompleteTask(); @@ -762,6 +858,7 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { completeTask.setMessage(StringUtils.blankToDefault(bo.getLeaderOpinion(), "领导审批")); completeTask.setMessageType(Collections.singletonList("1")); completeTask.setVariables(variables); + // Why:completeTask 带 variables 推进当前任务,variables 内的 has_assignee/reassigned 决定下一节点路由(主执行人审阅/直入执行)。 boolean completed = remoteWorkflowService.completeTask(completeTask); if (!completed) { throw new ServiceException("领导审批失败,流程未完成流转"); @@ -769,6 +866,13 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { return Boolean.TRUE; } + /** + * Why:领导审批时可能修改任务类型(taskType),任务类型决定了归集口径,因此需要 taskType 驱动的条件清空: + * - taskType='3'(项目任务):必须关联 projectId/projectCode/projectName,同时清空 deptId/deptName(因为项目任务不按部门归集)。 + * - taskType='1'(部门任务):必须关联 deptId/deptName,同时清空 projectId/projectCode/projectName(因为部门任务不按项目归集)。 + * - taskType='2'(公共任务):两者都清空,因为公共任务既不属于具体项目也不属于具体部门。 + * 这样做避免了"项目任务残留部门字段"或"部门任务残留项目字段"的数据不一致问题(AD-01)。 + */ private void applyLeaderReviewFields(ErpTempTask task, ErpTempTaskBo bo) { task.setTaskTitle(bo.getTaskTitle()); task.setTaskDesc(bo.getTaskDesc()); @@ -784,14 +888,16 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { task.setSoftwareLeaderName(bo.getSoftwareLeaderName()); task.setAssigneeId(bo.getAssigneeId()); task.setAssigneeName(bo.getAssigneeName()); - task.setCcUserIds(bo.getCcUserIds()); +// task.setCcUserIds(bo.getCcUserIds()); task.setRemark(bo.getRemark()); + // Why:taskType='3'(项目任务)时设置项目字段并清空部门字段,反之亦然,防止字段残留导致统计口径错误。 if ("3".equals(bo.getTaskType())) { task.setProjectId(bo.getProjectId()); task.setProjectCode(bo.getProjectCode()); task.setProjectName(bo.getProjectName()); } else { + // Why:非项目任务必须清空项目字段,避免报表查询按项目筛选时误命中。 task.setProjectId(null); task.setProjectCode(null); task.setProjectName(null); @@ -800,6 +906,7 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { task.setDeptId(bo.getDeptId()); task.setDeptName(bo.getDeptName()); } else { + // Why:非部门任务必须清空部门字段,避免报表查询按部门筛选时误命中。 task.setDeptId(null); task.setDeptName(null); } @@ -858,7 +965,7 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { bo.setTotalHours(task.getTotalHours()); bo.setTaskStatus(task.getTaskStatus()); bo.setFlowStatus(task.getFlowStatus()); - bo.setCcUserIds(task.getCcUserIds()); +// bo.setCcUserIds(task.getCcUserIds()); bo.setRemark(task.getRemark()); bo.setFlowCode(FLOW_CODE); bo.setVariables(new HashMap<>(16)); @@ -941,10 +1048,12 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { if (task == null) { throw new ServiceException("临时任务不存在"); } - // Why:待领导审核(4)/已关闭(5)禁止改明细,保证整单提交快照不被篡改(AD-15) + // Why:限制仅 STATUS_RUNNING(3-执行中) 可维护工时。待领导审核(4)/已关闭(5)禁止改明细, + // 保证整单提交后快照(工时总数+明细)不被后续篡改,领导审核看到的与主执行人提交的完全一致(AD-15)。 if (!STATUS_RUNNING.equals(task.getTaskStatus())) { throw new ServiceException("当前任务非执行中状态,不允许维护工时明细"); } + // Why:memberId 从参与人表查询,不以前端传入为准,防止非参与人伪造 memberId 写入工时。 Long memberId = memberMapper.selectMemberId(bo.getTempTaskId(), LoginHelper.getUserId()); if (memberId == null) { throw new ServiceException("您不是该任务参与人,无法填报工时"); @@ -954,16 +1063,19 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { } bo.setMemberId(memberId); bo.setUserId(LoginHelper.getUserId()); - // Why:自然周(周一~周日)是统计归属维度,由工作日期统一推导,保证口径一致(AD-07/AD-08) + // Why:自然周(周一~周日)是统计归属维度,由工作日期的 DateUtil.beginOfWeek/endOfWeek 统一推导, + // 保证同一周内所有工时明细的 weekStart/weekEnd 完全一致,避免跨口径统计偏差(AD-07/AD-08)。 bo.setWeekStart(DateUtil.beginOfWeek(bo.getWorkDate())); bo.setWeekEnd(DateUtil.endOfWeek(bo.getWorkDate())); ErpTempTaskWorklog entity = MapstructUtils.convert(bo, ErpTempTaskWorklog.class); entity.setUpdateBy(LoginHelper.getUserId()); if (entity.getWorklogId() == null) { + // Why:新增工时默认 lockFlag='0'(未锁定),待提交完成后统一 lockByTask 设为 '1'。 entity.setCreateBy(LoginHelper.getUserId()); entity.setLockFlag("0"); return worklogMapper.insert(entity) > 0; } + // Why:修改前必须检查 lockFlag,已锁定的工时明细拒绝修改,保护提交完成后的数据完整性。 ErpTempTaskWorklog old = worklogMapper.selectById(entity.getWorklogId()); if (old != null && "1".equals(old.getLockFlag())) { throw new ServiceException("该工时明细已锁定,不可修改"); @@ -997,7 +1109,10 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { } /** - * 主执行人提交完成:聚合校验 + 整单锁定快照(AD-14/AD-15) + * 主执行人提交完成:聚合工时快照 + 整单锁定 + 提交执行节点(AD-14/AD-15) + * Why:提交完成的本质是"快照凝固"——totalHours 一次性从所有明细 SUM 计算得出并固化为整单属性, + * 同时 lockFlag='1' 锁定全部工时明细,确保后续领导审核和评分基于提交时刻的完整快照,不会被新增/修改/删除破坏。 + * 状态从 STATUS_RUNNING(3)→STATUS_PENDING_FINAL(4),表示"暂时冻结待领导确认",不是一个闭环终态。 */ @Override @GlobalTransactional(rollbackFor = Exception.class) @@ -1013,15 +1128,20 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { if (!STATUS_RUNNING.equals(task.getTaskStatus())) { throw new ServiceException("仅执行中任务可提交完成"); } - // Why:提交完成前累计总工时必须>0,否则报表统计失去基础(AD-14) + // Why:提交前必须累计工时>0,至少一条工时明细。空提交会导致报表统计失去数据基础(AD-14)。 + // sumHoursByTask 使用数据库 SUM 聚合,避免应用层循环累加的精度/并发问题。 BigDecimal total = worklogMapper.sumHoursByTask(tempTaskId); if (total == null || total.compareTo(BigDecimal.ZERO) <= 0) { throw new ServiceException("提交完成前任务累计总工时必须大于0,且至少有一条工时明细"); } + // Why:totalHours 作为提交快照固化到主表,后续评分/报表直接读主表字段,无需再聚合明细(AD-14)。 task.setTotalHours(total); - task.setTaskStatus(STATUS_PENDING_FINAL); // 待领导审核(整单临时冻结) - worklogMapper.lockByTask(tempTaskId, "1"); // 锁定全部明细,待审核期间不可改(AD-15) + // Why:taskStatus=4(待领导审核) 是提交后的"临时冻结态",整单工时只读不可改(AD-15)。 + task.setTaskStatus(STATUS_PENDING_FINAL); + // Why:lockFlag='1' 一次性锁定全部工时明细,领导审核期间禁止任何人对工时增删改(AD-15)。 + worklogMapper.lockByTask(tempTaskId, "1"); baseMapper.updateById(task); + // Why:completeExecuteTask 在同一 Seata 事务内推进 execute 节点到 leader_final,确保锁与流程推进原子。 completeExecuteTask(bo, task, total); return Boolean.TRUE; } @@ -1049,8 +1169,32 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { } } + /** + * 兜底同步任务状态至"待领导审核"(taskStatus=4)。 + * + * Why:执行人可能绕过 submitFinish 直接通过工作流通用审批按钮将流程推到 leader_final, + * 此时 taskStatus 仍为 3(执行中)。本方法在编辑页加载时被前端主动调起, + * 补齐 submitFinish 缺失的聚合 totalHours + 锁定工时明细 + taskStatus→4。 + * + * 幂等保证:仅 STATUS_RUNNING(3) 执行同步,已为 STATUS_PENDING_FINAL(4) 或已关闭(5) 的不重复操作。 + */ + @Override + public void syncStateToPendingFinal(Long tempTaskId) { + ErpTempTask task = baseMapper.selectById(tempTaskId); + if (task == null || !STATUS_RUNNING.equals(task.getTaskStatus())) { + return; + } + BigDecimal total = worklogMapper.sumHoursByTask(tempTaskId); + task.setTotalHours(total != null ? total : BigDecimal.ZERO); + task.setTaskStatus(STATUS_PENDING_FINAL); + worklogMapper.lockByTask(tempTaskId, "1"); + baseMapper.updateById(task); + } + /** * 领导评分关闭:对主执行人和每个协作人分别评分,评分即关闭、永久冻结(AD-05/AD-16) + * Why:评分关闭是整个临时任务生命周期的最终闭环操作——评分落库 + taskStatus→5(已关闭) + 永久 lockFlag='1'。 + * 只有 leader_final 节点的办理人(softwareLeaderId)可以执行此操作。 */ @Override @GlobalTransactional(rollbackFor = Exception.class) @@ -1062,16 +1206,24 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { if (!Objects.equals(LoginHelper.getUserId(), task.getSoftwareLeaderId())) { throw new ServiceException("只有软件部领导可以评分关闭"); } - if (!STATUS_PENDING_FINAL.equals(task.getTaskStatus())) { - throw new ServiceException("仅待领导审核状态可评分关闭"); + // Why:正常情况下 taskStatus 应由 submitFinish 置为 4(待领导审核),但执行人可能绕过 submitFinish + // 直接通过工作流通用审批按钮将流程推到 leader_final,此时 taskStatus 仍为 3(执行中)。 + // 对于 taskStatus=3 但流程已在 leader_final 的情况,此处兜底补齐 submitFinish 的落库动作。 + if (STATUS_RUNNING.equals(task.getTaskStatus())) { + BigDecimal total = worklogMapper.sumHoursByTask(bo.getTempTaskId()); + task.setTotalHours(total != null ? total : BigDecimal.ZERO); + worklogMapper.lockByTask(bo.getTempTaskId(), "1"); + } else if (!STATUS_PENDING_FINAL.equals(task.getTaskStatus())) { + throw new ServiceException("仅待领导审核或执行中状态可评分关闭"); } - // Why:必须对全部参与人(主+协作)都给出评分等级,否则不允许关闭(AD-16) + // Why:必须对全部参与人(主执行人+协作人)都给出评分等级,不允许遗漏任何人。漏评会导致考核不公(AD-16)。 List members = memberMapper.selectList(Wrappers.lambdaQuery() .eq(ErpTempTaskMember::getTempTaskId, bo.getTempTaskId())); validateScoreCoverage(bo, members); Date now = new Date(); Long leaderId = LoginHelper.getUserId(); - // 幂等:清理旧评分再批量写入,避免重复关闭产生多条 + // Why:先 delete 旧评分再批量 insert,实现**幂等关闭**——若领导之前已提交但流程失败,本次重试不会产生重复评分记录。 + // delete+insert 在 @GlobalTransactional 内原子执行,中间不会出现"删了但没插"的不可见窗口。 scoreMapper.delete(Wrappers.lambdaQuery() .eq(ErpTempTaskScore::getTempTaskId, bo.getTempTaskId())); for (ErpTempTaskScoreBo s : bo.getScoreList()) { @@ -1084,10 +1236,14 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { score.setScoreTime(now); scoreMapper.insert(score); } - task.setActualFinishTime(now); // 实际关闭时间=评分时间(提前/按期/延期口径,AD-06) + // Why:actualFinishTime = 评分时间,作为提前/按期/延期的口径基准(AD-06)。 + task.setActualFinishTime(now); task.setTaskStatus(STATUS_CLOSED); - worklogMapper.lockByTask(bo.getTempTaskId(), "1"); // 永久锁定全部工时明细(AD-04) + // Why:永久锁定全部工时明细 lockFlag='1',关闭后任何人均不可修改工时(AD-04)。 + worklogMapper.lockByTask(bo.getTempTaskId(), "1"); baseMapper.updateById(task); + // Why:completeLeaderFinalTask 在同一事务内完成 leader_final 节点,流程随之结束(FINISH), + // processHandler 收到 FINISH 事件后会兜底确认 taskStatus 已置为 5。 completeLeaderFinalTask(bo); return Boolean.TRUE; } @@ -1143,11 +1299,23 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { } } + /** + * Why:校验前端传入的 taskId 确实属于当前临时任务、处于预期节点、且当前用户是办理人。 + * 通过 Warm-Flow 的 flowHisTaskList 查询给定 businessId 下的所有流程任务历史列表, + * 然后**线性搜索**匹配 taskId,找到后校验 nodeCode 和 approver 是否与预期一致。 + * 线性搜索而非 Map 的原因是:流程任务列表通常较短(10条以内),O(n) 足够且代码更清晰直接。 + * 三重校验: + * 1. taskId 必须存在于该临时任务的历史任务列表中(防止跨业务窜用 taskId) + * 2. nodeCode 必须匹配预期节点(防止在错误的审批节点办理,如本该 leader_review 却在 execute 节点调用) + * 3. 当前登录人必须在 approver 列表中(防止越权代办) + * 任意一项不匹配均抛 ServiceException,在 @GlobalTransactional 外层触发 Seata 回滚。 + */ @SuppressWarnings("unchecked") private void validateWorkflowTaskBelongsToBusiness(Long tempTaskId, Long taskId, String expectedNodeCode, Long expectedUserId) { if (tempTaskId == null || taskId == null) { throw new ServiceException("临时任务ID和流程任务ID不能为空"); } + // Why:flowHisTaskList 返回该业务 ID 下的所有流程任务记录(含历史),从中定位当前 taskId 并校验。 Map flowInfo = remoteWorkflowService.flowHisTaskList(String.valueOf(tempTaskId)); Object listObj = flowInfo == null ? null : flowInfo.get("list"); if (!(listObj instanceof Collection taskList)) { @@ -1155,6 +1323,7 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { } String taskIdText = Convert.toStr(taskId); String expectedUserIdText = Convert.toStr(expectedUserId); + // Why:线性遍历查找匹配的 taskId,找到后立即校验并 return;未找到则抛异常。 for (Object item : taskList) { if (!(item instanceof Map rawTask)) { continue; @@ -1163,10 +1332,14 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { if (!Objects.equals(taskIdText, Convert.toStr(taskMap.get("id")))) { continue; } + // Why:nodeCode 精确匹配——例如 scoreAndClose 要求 nodeCode="leader_final", + // submitFinish 要求 nodeCode="execute",防止在错误节点调用业务接口。 if (StringUtils.isNotBlank(expectedNodeCode) && !Objects.equals(expectedNodeCode, Convert.toStr(taskMap.get("nodeCode")))) { throw new ServiceException("流程任务节点与当前业务操作不匹配"); } + // Why:approver 是逗号分隔的办理人 ID 列表(如 "101,203"),需要解析后检查当前用户是否在其中。 + // 支持会签/或签场景同时传入多个办理人的情况。 String approver = Convert.toStr(taskMap.get("approver")); if (StringUtils.isNotBlank(approver) && StringUtils.isNotBlank(expectedUserIdText)) { List approvers = Arrays.stream(approver.split(",")) @@ -1176,8 +1349,10 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { throw new ServiceException("当前用户不是该流程任务办理人"); } } - return; + return; // 所有校验通过,找到匹配的流程任务 } + // Why:遍历完未找到匹配 taskId,说明前端传入的 taskId 不属于当前临时任务, + // 可能是跨业务窜用或者已过期的待办,直接拒绝。 throw new ServiceException("流程任务不属于当前临时任务"); }