Merge branch 'main' of http://192.168.1.22:3000/Microservice/cn-rdms
This commit is contained in:
@@ -25,7 +25,18 @@
|
||||
"PowerShell($env:JAVA_HOME = \"C:\\\\Program Files\\\\Java\\\\jdk-17\"; & \"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot -am test 2>&1 | Select-Object -Last 80)",
|
||||
"PowerShell($env:JAVA_HOME = \"C:\\\\Program Files\\\\Java\\\\jdk-17\"; & \"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot \"-Dtest=TaskAssigneeServiceImplTest,ProjectTaskServiceImplTest,ProjectTaskStatusViewServiceTest,ProjectExecutionServiceImplTest\" \"-Dsurefire.failIfNoSpecifiedTests=false\" test 2>&1 | Select-Object -Last 30)",
|
||||
"PowerShell($env:JAVA_HOME = \"C:\\\\Program Files\\\\Java\\\\jdk-17\"; & \"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot -am \"-Dtest=TaskAssigneeServiceImplTest,ProjectTaskServiceImplTest,ProjectTaskStatusViewServiceTest,ProjectExecutionServiceImplTest\" \"-Dsurefire.failIfNoSpecifiedTests=false\" test 2>&1 | Select-Object -Last 30)",
|
||||
"Bash(grep -rn \"INSERT INTO \\\\`system_menu\\\\`\\\\|INSERT INTO system_menu\" --include=\"*.sql\" rdms-project rdms-system)"
|
||||
"Bash(grep -rn \"INSERT INTO \\\\`system_menu\\\\`\\\\|INSERT INTO system_menu\" --include=\"*.sql\" rdms-project rdms-system)",
|
||||
"Bash(findstr /i \"plugin\")",
|
||||
"Bash(findstr *)",
|
||||
"Bash(awk END{print NR} *)",
|
||||
"PowerShell(Move-Item *)",
|
||||
"PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot -am test '-Dtest=VisibilityScopeResolverImplTest' -q 2>&1 | Select-Object -Last 80)",
|
||||
"PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot -am test '-Dtest=VisibilityScopeResolverImplTest' '-Dsurefire.failIfNoSpecifiedTests=false' -q 2>&1 | Select-Object -Last 60)",
|
||||
"PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot -am test '-Dtest=VisibilityScopeResolverImplTest' '-Dsurefire.failIfNoSpecifiedTests=false' -q | Select-Object -Last 60)",
|
||||
"PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot -am test '-Dtest=VisibilityScopeResolverImplTest' '-Dsurefire.failIfNoSpecifiedTests=false' | Select-String -Pattern 'Tests run|BUILD|ERROR|FAIL' | Select-Object -Last 40)",
|
||||
"PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot -am test '-Dsurefire.failIfNoSpecifiedTests=false' | Select-String -Pattern 'Tests run|BUILD|ERROR|FAILED|FAIL' | Select-Object -Last 100)",
|
||||
"PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot -am test-compile '-Dsurefire.failIfNoSpecifiedTests=false' | Select-String -Pattern 'ERROR|BUILD|FAIL' | Select-Object -Last 40)",
|
||||
"PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot test '-Dsurefire.failIfNoSpecifiedTests=false' | Select-String -Pattern 'Tests run|BUILD|FAILED|ERROR' | Select-Object -Last 80)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
默认回答保持精简,优先给结论、改动点和必要风险,不做过多展开;如果存在你关心但未展开的细节,由你继续追问后再补充。
|
||||
|
||||
回答问题时不要过多代码层面的描述:默认用自然语言给结论、判断、影响面;除非用户明确要看实现细节,不要大段贴代码片段、不要逐行解读、不要把分析写成"先看 xxx.java 第 N 行"的形式。涉及代码定位时用 `file_path:line_number` 引用即可。
|
||||
|
||||
## 交互原则
|
||||
|
||||
- 默认先给执行方案,说明目标、涉及模块、预计改动点和验证方式。
|
||||
|
||||
36
CLAUDE.md
36
CLAUDE.md
@@ -9,6 +9,7 @@
|
||||
- 描述仓库现状以**当前**代码、配置、文档可验证的事实为准;不要拿历史实现、过渡方案或已废弃模型解释当前状态。
|
||||
- 回答保持精简,先给结论、改动点、必要风险;细节等用户追问。
|
||||
- **不要废话**:默认极简输出,不展开背景、不复述需求、不堆叠章节标题;能用一两句讲清就别写成清单;用户主动追问再展开。
|
||||
- **回答问题时不要过多代码层面的描述**:默认用自然语言给结论、判断、影响面;除非用户明确要看实现细节,不要大段贴代码片段、不要逐行解读、不要把分析写成"先看 xxx.java 第 N 行"的形式。涉及代码定位时用 `file_path:line_number` 引用即可。
|
||||
|
||||
## 本机环境
|
||||
|
||||
@@ -64,9 +65,42 @@
|
||||
## 认证与跨模块调用
|
||||
|
||||
- 默认沿用 OAuth2 / Token / `LoginUser` / `login-user` 透传主链。**不要**另造 ThreadLocal / Session / 自定义 header。
|
||||
- 接口级权限走 `@PreAuthorize("@ss.hasPermission(...)")`,不要绕开。
|
||||
- 跨模块/跨服务必须通过 `*-api` 模块定义契约;不要直接依赖别人的 `*-boot`。
|
||||
|
||||
### 鉴权:必须按"全域 / 对象域"分通道挂
|
||||
|
||||
系统有**两条互不交叉**的权限通道,挂错通道 = 永远 403。新增/修改接口前必须先判断它属于哪一域:
|
||||
|
||||
| 通道 | 适用场景 | 注解 / 实现 | 角色与菜单 |
|
||||
|---|---|---|---|
|
||||
| **全域 global** | 传统 RBAC 顶层菜单与"项目管理界面"——选择对象**之前**的所有动作(建项目、列项目、菜单/角色/用户管理等) | Controller 上 `@PreAuthorize("@ss.hasPermission('xxx')")`,由 `PermissionServiceImpl.hasAnyPermissions` 处理 | `system_role.scope_type='global'` + `system_menu.scope_type='global'` |
|
||||
| **对象域 object** | 用户**已选择某个对象**(如某个项目/产品)后,对象内部的一切操作(任务、执行、工时、协办人、需求、成员维护等) | Service 上 `@CheckObjectPermission(objectType=..., objectId="#xxxId", permission="...")`,由 `ObjectPermissionAspect` → `ObjectPermissionService.checkPermission` 处理 | `system_role.scope_type='object'` + `system_menu.scope_type='object'` + `object_type` |
|
||||
|
||||
红线:
|
||||
|
||||
- **对象内接口绝不能挂 `@PreAuthorize("@ss.hasPermission(...)")`**。该注解走的链路在 `PermissionServiceImpl` 里强制按 GLOBAL 取角色(line 343-347)+ 强制按 GLOBAL 查菜单(line 92-94),对象域角色与对象域菜单都进不来,即使授权配置完全正确也必然 403。
|
||||
- **对象域权限校验必须落在 Service 层 `@CheckObjectPermission`**,原因:路径里 `objectId` 通常以 `#projectId`/`#productId` 等 SpEL 解析,Controller 的参数校验前置阶段不便复用;与同模块(`ProjectMemberServiceImpl` / `ProjectExecutionServiceImpl` / `ProjectExecutionAssigneeServiceImpl` / `ProjectTaskServiceImpl`)保持一致。
|
||||
- **同一接口不要两条通道叠加**。要么全域,要么对象域;叠加只会让对象域用户被全域那条卡死。
|
||||
- 列表/详情这类对象内**读路径**目前未挂 `@CheckObjectPermission`(属已识别负债,台账 TD-001),新增读接口暂沿用现状即可,不要顺手改造,等独立立项。
|
||||
|
||||
判定口诀:**URL 里有 `{projectId}` / `{productId}` 等对象 ID → 对象域;没有 → 全域**。
|
||||
|
||||
## 接口语义(HTTP 动词)
|
||||
|
||||
本仓库 update 类接口默认按 RESTful 标准用 HTTP 动词区分语义,前后端必须按下表对齐,避免"前端没传字段"和"前端想清空"在后端无法区分的歧义。
|
||||
|
||||
| 动词 | 语义 | 字段处理规则 |
|
||||
|---|---|---|
|
||||
| **PUT** | 全资源替换 | 前端必须把表单完整状态回传(读到的非必填字段也要原样回传)。后端按字段值落库:**有值=更新,`null`=清空**。DO 字段加 `@TableField(updateStrategy = FieldStrategy.ALWAYS)` 跳过全局 `NOT_NULL` 让 `null` 真的写库 |
|
||||
| **PATCH** | 部分字段更新 | **本仓库暂不引入 PATCH 接口**。如果有"只改一两个字段"的需求,用专门的子动作接口(参考 `assignees/{id}/inactive`、`status` 这种语义化路径),不要在 update 接口里靠旁路标记(如 `clearXxx: true`)模拟 PATCH |
|
||||
| **DELETE** | 资源删除 | 软删走全局 `deleted` 列,不需要参数 body |
|
||||
|
||||
红线:
|
||||
|
||||
- **不要在 update 类接口的 ReqVO 里加 `clearXxx: Boolean` 这种旁路标记**来模拟 PATCH —— 等于承认接口是"伪 PATCH",会让所有非必填字段都需要类似标记,长期污染 API 设计。需要部分更新就拆子动作接口。
|
||||
- 新增 update 接口时,必须在 API 文档对应章节明示"PUT 全字段回传"约定;DO 上对允许 null 的字段补 `FieldStrategy.ALWAYS` 注解,并加注释说明语义来源(指向本节)。
|
||||
- 历史接口若是稀疏 PATCH 风格(传 null = 不动),保留现状但不要拓展;遇到清空诉求时按 PUT 方向重构。
|
||||
|
||||
## 数据与 SQL
|
||||
|
||||
- 新表 DO 复用现有 `BaseDO` / 审计字段风格,不要再引一套审计基类(除非该表本身明确不需要逻辑删除)。
|
||||
|
||||
@@ -27,9 +27,8 @@ public class PageParam implements Serializable {
|
||||
@Min(value = 1, message = "页码最小值为 1")
|
||||
private Integer pageNo = PAGE_NO;
|
||||
|
||||
@Schema(description = "每页条数,最大值为 200", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
|
||||
@Schema(description = "每页条数,最大值为 200;传 -1 表示不分页(查询全部)", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
|
||||
@NotNull(message = "每页条数不能为空")
|
||||
@Min(value = 1, message = "每页条数最小值为 1")
|
||||
@Max(value = 200, message = "每页条数最大值为 200")
|
||||
private Integer pageSize = PAGE_SIZE;
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ public interface ErrorCodeConstants {
|
||||
ErrorCode PRODUCT_DELETE_CONFIRM_TEXT_INVALID = new ErrorCode(1_008_001_021, "删除确认口令不正确");
|
||||
ErrorCode PRODUCT_STATUS_CONCURRENT_MODIFIED = new ErrorCode(1_008_001_022, "产品状态已发生变化,请刷新后重试");
|
||||
ErrorCode PRODUCT_STATUS_MODEL_NOT_EXISTS_OR_DISABLED = new ErrorCode(1_008_001_023, "产品状态定义不存在或已停用");
|
||||
// 产品创建原子接口(/create-with-team)专用:初始团队相关校验
|
||||
ErrorCode PRODUCT_INITIAL_TEAM_MANAGER_REQUIRED = new ErrorCode(1_008_001_024, "初始团队必须包含产品经理");
|
||||
ErrorCode PRODUCT_INITIAL_TEAM_MEMBER_DUPLICATE = new ErrorCode(1_008_001_025, "初始团队成员存在重复");
|
||||
ErrorCode PRODUCT_INITIAL_TEAM_ROLE_INVALID = new ErrorCode(1_008_001_026, "初始团队中存在非法角色");
|
||||
|
||||
// ========== 产品需求 1-008-002-000 ==========
|
||||
ErrorCode REQUIREMENT_NOT_EXISTS = new ErrorCode(1_008_002_000, "产品需求不存在");
|
||||
@@ -89,15 +93,20 @@ public interface ErrorCodeConstants {
|
||||
ErrorCode PROJECT_MANAGER_TRANSFER_ROLE_INVALID = new ErrorCode(1_008_002_026, "原项目经理交接后的角色不能仍为项目经理");
|
||||
ErrorCode PROJECT_MANAGER_MEMBER_NOT_ALLOW_DOWNGRADE = new ErrorCode(1_008_002_027, "当前项目经理不能直接调整为非经理角色,请先完成经理转交");
|
||||
ErrorCode PROJECT_MAINLINE_DUPLICATE = new ErrorCode(1_008_002_028, "当前产品下已存在未作废的主线项目");
|
||||
// 项目创建原子接口(/create-with-team)专用:初始团队 + 方向一致性校验
|
||||
ErrorCode PROJECT_INITIAL_TEAM_MANAGER_REQUIRED = new ErrorCode(1_008_002_029, "初始团队必须包含项目经理");
|
||||
ErrorCode PROJECT_INITIAL_TEAM_MEMBER_DUPLICATE = new ErrorCode(1_008_002_030, "初始团队成员存在重复");
|
||||
ErrorCode PROJECT_INITIAL_TEAM_ROLE_INVALID = new ErrorCode(1_008_002_031, "初始团队中存在非法角色");
|
||||
ErrorCode PROJECT_DIRECTION_NOT_MATCH_PRODUCT = new ErrorCode(1_008_002_032, "项目方向与所属产品方向不一致");
|
||||
|
||||
// ========== 执行管理 1-008-003-000 ==========
|
||||
ErrorCode PROJECT_EXECUTION_NOT_EXISTS = new ErrorCode(1_008_003_000, "执行不存在");
|
||||
ErrorCode PROJECT_EXECUTION_NAME_DUPLICATE = new ErrorCode(1_008_003_001, "当前项目下已经存在名称为【{}】的执行");
|
||||
ErrorCode PROJECT_EXECUTION_OWNER_INVALID = new ErrorCode(1_008_003_002, "执行负责人必须是当前项目的有效成员");
|
||||
ErrorCode PROJECT_EXECUTION_MEMBER_INVALID = new ErrorCode(1_008_003_003, "执行成员必须是当前项目的有效成员");
|
||||
ErrorCode PROJECT_EXECUTION_MEMBER_ALREADY_EXISTS = new ErrorCode(1_008_003_004, "该用户已是当前执行的有效成员");
|
||||
ErrorCode PROJECT_EXECUTION_MEMBER_NOT_EXISTS = new ErrorCode(1_008_003_005, "执行成员不存在");
|
||||
ErrorCode PROJECT_EXECUTION_MEMBER_NOT_ACTIVE = new ErrorCode(1_008_003_006, "当前执行成员已失效");
|
||||
ErrorCode PROJECT_EXECUTION_ASSIGNEE_INVALID = new ErrorCode(1_008_003_003, "执行协办人必须是当前项目的有效成员");
|
||||
ErrorCode PROJECT_EXECUTION_ASSIGNEE_ALREADY_EXISTS = new ErrorCode(1_008_003_004, "该用户已是当前执行的有效协办人");
|
||||
ErrorCode PROJECT_EXECUTION_ASSIGNEE_NOT_EXISTS = new ErrorCode(1_008_003_005, "执行协办人不存在");
|
||||
ErrorCode PROJECT_EXECUTION_ASSIGNEE_NOT_ACTIVE = new ErrorCode(1_008_003_006, "当前执行协办人已失效");
|
||||
ErrorCode PROJECT_EXECUTION_REQUIREMENT_NOT_READY = new ErrorCode(1_008_003_007, "当前阶段不支持给执行绑定项目需求");
|
||||
ErrorCode PROJECT_EXECUTION_NOT_ALLOW_EDIT = new ErrorCode(1_008_003_008, "当前项目状态不允许维护执行");
|
||||
ErrorCode PROJECT_EXECUTION_OWNER_HANDOFF_REQUIRED = new ErrorCode(1_008_003_009, "该项目成员仍担任未终态执行负责人,请先完成执行负责人交接");
|
||||
@@ -107,7 +116,12 @@ public interface ErrorCodeConstants {
|
||||
ErrorCode PROJECT_EXECUTION_STATUS_CONCURRENT_MODIFIED = new ErrorCode(1_008_003_013, "执行状态已发生变化,请刷新后重试");
|
||||
ErrorCode PROJECT_EXECUTION_STATUS_NOT_ALLOW_EDIT = new ErrorCode(1_008_003_014, "当前执行状态不允许维护执行");
|
||||
ErrorCode PROJECT_EXECUTION_TYPE_INVALID = new ErrorCode(1_008_003_015, "执行类型不是有效字典值");
|
||||
ErrorCode PROJECT_EXECUTION_MEMBER_REQUIRED = new ErrorCode(1_008_003_016, "创建执行时必须至少选择一名执行成员");
|
||||
ErrorCode PROJECT_EXECUTION_ASSIGNEE_REQUIRED = new ErrorCode(1_008_003_016, "创建执行时必须至少选择一名执行协办人");
|
||||
ErrorCode PROJECT_EXECUTION_STATUS_OWNER_ONLY = new ErrorCode(1_008_003_017, "只有执行负责人才能执行【{}】动作");
|
||||
ErrorCode PROJECT_EXECUTION_COMPLETE_TASKS_REQUIRED = new ErrorCode(1_008_003_018, "完成执行前,执行下所有任务必须全部完成或取消");
|
||||
ErrorCode PROJECT_EXECUTION_NOT_ALLOW_DELETE = new ErrorCode(1_008_003_019, "仅初始态(待开始)的执行允许删除");
|
||||
ErrorCode PROJECT_EXECUTION_DELETE_NAME_MISMATCH = new ErrorCode(1_008_003_020, "确认执行名称与实际不一致");
|
||||
ErrorCode PROJECT_EXECUTION_DELETE_CONFIRM_TEXT_INVALID = new ErrorCode(1_008_003_021, "删除确认口令必须为 DELETE 或 删除");
|
||||
|
||||
// ========== 任务管理 1-008-004-000 ==========
|
||||
ErrorCode PROJECT_TASK_NOT_EXISTS = new ErrorCode(1_008_004_000, "任务不存在");
|
||||
@@ -120,7 +134,10 @@ public interface ErrorCodeConstants {
|
||||
ErrorCode PROJECT_TASK_STATUS_CONCURRENT_MODIFIED = new ErrorCode(1_008_004_007, "任务状态已发生变化,请刷新后重试");
|
||||
ErrorCode PROJECT_TASK_STATUS_NOT_ALLOW_EDIT = new ErrorCode(1_008_004_008, "当前任务状态不允许维护任务");
|
||||
ErrorCode PROJECT_TASK_COMPLETE_CHILDREN_REQUIRED = new ErrorCode(1_008_004_010, "父任务完成前,子任务必须全部完成或取消");
|
||||
ErrorCode PROJECT_TASK_PROGRESS_PARENT_NOT_EDITABLE = new ErrorCode(1_008_004_011, "父任务进度由子任务自动汇总,不允许手工修改");
|
||||
ErrorCode PROJECT_TASK_STATUS_OWNER_ONLY = new ErrorCode(1_008_004_011, "只有任务负责人才能执行【{}】动作");
|
||||
ErrorCode PROJECT_TASK_NOT_ALLOW_DELETE = new ErrorCode(1_008_004_012, "仅初始态(待开始)的任务允许删除");
|
||||
ErrorCode PROJECT_TASK_DELETE_NAME_MISMATCH = new ErrorCode(1_008_004_013, "确认任务名称与实际不一致");
|
||||
ErrorCode PROJECT_TASK_DELETE_CONFIRM_TEXT_INVALID = new ErrorCode(1_008_004_014, "删除确认口令必须为 DELETE 或 删除");
|
||||
ErrorCode PROJECT_TASK_LEAF_TO_PARENT_FORBIDDEN_PROGRESS = new ErrorCode(1_008_004_012, "拆子任务前请先将父任务进度清零");
|
||||
ErrorCode PROJECT_TASK_LEAF_TO_PARENT_FORBIDDEN_WORKLOG = new ErrorCode(1_008_004_013, "拆子任务前请先删除父任务下已填的工时记录");
|
||||
|
||||
@@ -135,10 +152,20 @@ public interface ErrorCodeConstants {
|
||||
// ========== 任务工时 1_008_006_xxx ==========
|
||||
ErrorCode PROJECT_TASK_WORKLOG_NOT_EXISTS = new ErrorCode(1_008_006_001, "任务工时记录不存在");
|
||||
ErrorCode PROJECT_TASK_WORKLOG_NOT_LEAF_TASK = new ErrorCode(1_008_006_002, "父任务不允许填报工时,请到具体子任务填报");
|
||||
ErrorCode PROJECT_TASK_WORKLOG_DURATION_INVALID = new ErrorCode(1_008_006_003, "工时时长必须大于 0 且为 30 分钟的整数倍");
|
||||
ErrorCode PROJECT_TASK_WORKLOG_DURATION_INVALID = new ErrorCode(1_008_006_003, "工时小时数必须大于 0 且为 0.5 的整数倍");
|
||||
ErrorCode PROJECT_TASK_WORKLOG_NOT_OWNER_OR_ASSIGNEE = new ErrorCode(1_008_006_004, "仅任务负责人或在岗协办人可填报工时");
|
||||
ErrorCode PROJECT_TASK_WORKLOG_EDIT_NOT_OWN = new ErrorCode(1_008_006_005, "只能修改自己填报的工时记录");
|
||||
ErrorCode PROJECT_TASK_WORKLOG_DELETE_FORBIDDEN = new ErrorCode(1_008_006_006, "仅记录填报人或任务负责人可删除该工时记录");
|
||||
ErrorCode PROJECT_TASK_WORKLOG_DATE_RANGE_INVALID = new ErrorCode(1_008_006_007, "段起始日期不能晚于段结束日期");
|
||||
ErrorCode PROJECT_TASK_WORKLOG_DATE_OVERLAP = new ErrorCode(1_008_006_008, "日期范围与该任务下您已有的工时记录重叠");
|
||||
ErrorCode PROJECT_TASK_WORKLOG_PROGRESS_NOT_MONOTONIC = new ErrorCode(1_008_006_010, "工时进度与日期顺序不一致:早段进度不得高于晚段、晚段进度不得低于早段");
|
||||
|
||||
// ========== 任务 / 工时附件 1_008_007_xxx ==========
|
||||
ErrorCode PROJECT_TASK_ATTACHMENT_TOO_MANY = new ErrorCode(1_008_007_001, "附件数量不能超过 {} 个");
|
||||
ErrorCode PROJECT_TASK_ATTACHMENT_URL_INVALID = new ErrorCode(1_008_007_002, "附件地址非法,必须为 http/https URL 且长度不超过 1024");
|
||||
ErrorCode PROJECT_TASK_ATTACHMENT_NAME_INVALID = new ErrorCode(1_008_007_003, "附件文件名不合法(必填且长度不超过 255)");
|
||||
ErrorCode PROJECT_TASK_ATTACHMENT_TYPE_NOT_ALLOWED = new ErrorCode(1_008_007_004, "附件扩展名【{}】不在允许列表内");
|
||||
ErrorCode PROJECT_TASK_ATTACHMENT_TYPE_BLOCKED = new ErrorCode(1_008_007_005, "附件类型【{}】被禁止上传");
|
||||
|
||||
// ========== 项目需求 1_008_007_xxx ==========
|
||||
ErrorCode PROJECT_REQUIREMENT_NOT_EXISTS = new ErrorCode(1_008_007_000, "项目需求不存在");
|
||||
|
||||
@@ -44,6 +44,22 @@ public final class ObjectActivityConstants {
|
||||
public static final String PROJECT_TRIGGER_CREATE_EXECUTION = "create_execution";
|
||||
public static final String PROJECT_TRIGGER_CREATE_TASK = "create_task";
|
||||
public static final String PROJECT_TRIGGER_SCHEDULE_REQUIREMENT = "schedule_requirement";
|
||||
/**
|
||||
* 项目自动开始触发动作 —— 由执行 auto_start 反向触发。
|
||||
* 与 {@link #EXECUTION_TRIGGER_AUTO_START} 同语义;项目侧用此名走 AUTO_START_TRIGGERS 白名单。
|
||||
*/
|
||||
public static final String PROJECT_TRIGGER_EXECUTION_AUTO_START = "execution_auto_start";
|
||||
|
||||
// ========== 任务自动推进触发动作 ==========
|
||||
/**
|
||||
* 任务自动开始动作编码 —— owner 或协办人填报工时时由后端自动触发的状态流转动作。
|
||||
* 对应 rdms_object_status_transition (object_type=task, action_code=auto_start,
|
||||
* from=pending → to=active)。
|
||||
* 修改 DB 该条记录的 action_code 时必须同步修改本常量;本常量被
|
||||
* ProjectTaskServiceImpl#internalAutoStartByWorklog 引用。
|
||||
* 后续若再增加"任务自动完成 / 执行自动开始"等同类语义,按 *_ACTION_AUTO_* 命名加在本分组。
|
||||
*/
|
||||
public static final String TASK_ACTION_AUTO_START = "auto_start";
|
||||
|
||||
// ========== 状态动作 ==========
|
||||
public static final String STATUS_ACTION_PAUSE = "pause";
|
||||
@@ -57,21 +73,23 @@ public final class ObjectActivityConstants {
|
||||
public static final String MEMBER_ACTION_REMOVE = "remove_member";
|
||||
public static final String EXECUTION_ACTION_CREATE = "create_execution_entity";
|
||||
public static final String EXECUTION_ACTION_UPDATE = "update_execution_entity";
|
||||
public static final String EXECUTION_ACTION_DELETE = "delete_execution_entity";
|
||||
public static final String EXECUTION_ACTION_CHANGE_OWNER = "change_execution_owner";
|
||||
public static final String EXECUTION_MEMBER_ACTION_ADD = "add_execution_member";
|
||||
public static final String EXECUTION_MEMBER_ACTION_REMOVE = "remove_execution_member";
|
||||
public static final String EXECUTION_ASSIGNEE_ACTION_ADD = "add_execution_assignee";
|
||||
public static final String EXECUTION_ASSIGNEE_ACTION_REMOVE = "remove_execution_assignee";
|
||||
public static final String TASK_ACTION_CREATE = "create_task_entity";
|
||||
public static final String TASK_ACTION_UPDATE = "update_task_entity";
|
||||
public static final String TASK_ACTION_DELETE = "delete_task_entity";
|
||||
|
||||
// ========== 任务协办人事件类型(B 模型 - 多行周期记录) ==========
|
||||
public static final String TASK_ASSIGNEE_ACTION_JOIN = "join";
|
||||
public static final String TASK_ASSIGNEE_ACTION_INACTIVE = "inactive";
|
||||
|
||||
// ========== 执行成员事件类型(B 模型 - 多行周期记录) ==========
|
||||
public static final String EXECUTION_MEMBER_LOG_ACTION_JOIN = "join";
|
||||
public static final String EXECUTION_MEMBER_LOG_ACTION_INACTIVE = "inactive";
|
||||
public static final String EXECUTION_MEMBER_LOG_ACTION_OWNER_TRANSFER_IN = "owner_transfer_in";
|
||||
public static final String EXECUTION_MEMBER_LOG_ACTION_OWNER_TRANSFER_OUT = "owner_transfer_out";
|
||||
// ========== 执行协办人事件类型(B 模型 - 多行周期记录) ==========
|
||||
public static final String EXECUTION_ASSIGNEE_LOG_ACTION_JOIN = "join";
|
||||
public static final String EXECUTION_ASSIGNEE_LOG_ACTION_INACTIVE = "inactive";
|
||||
public static final String EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_IN = "owner_transfer_in";
|
||||
public static final String EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_OUT = "owner_transfer_out";
|
||||
|
||||
public static final List<String> STATUS_ACTION_TYPES = List.of(
|
||||
STATUS_ACTION_PAUSE, STATUS_ACTION_RESUME, STATUS_ACTION_ARCHIVE, STATUS_ACTION_ABANDON);
|
||||
@@ -97,21 +115,24 @@ public final class ObjectActivityConstants {
|
||||
case PRODUCT_ACTION_CREATE -> "创建";
|
||||
case PRODUCT_ACTION_UPDATE -> "更新";
|
||||
case PRODUCT_ACTION_DELETE -> "删除";
|
||||
case PROJECT_ACTION_AUTO_START -> "自动进入进行中";
|
||||
case PROJECT_ACTION_AUTO_START -> "开始推进";
|
||||
case PROJECT_TRIGGER_CREATE_EXECUTION -> "创建执行";
|
||||
case PROJECT_TRIGGER_CREATE_TASK -> "创建任务";
|
||||
case PROJECT_TRIGGER_SCHEDULE_REQUIREMENT -> "项目需求排期";
|
||||
case PROJECT_TRIGGER_EXECUTION_AUTO_START -> "执行自动开始";
|
||||
case EXECUTION_ACTION_CREATE -> "创建执行";
|
||||
case EXECUTION_ACTION_UPDATE -> "更新执行";
|
||||
case EXECUTION_ACTION_DELETE -> "删除执行";
|
||||
case EXECUTION_ACTION_CHANGE_OWNER -> "变更执行负责人";
|
||||
case EXECUTION_MEMBER_ACTION_ADD -> "新增执行成员";
|
||||
case EXECUTION_MEMBER_ACTION_REMOVE -> "移出执行成员";
|
||||
case EXECUTION_ASSIGNEE_ACTION_ADD -> "新增执行协办人";
|
||||
case EXECUTION_ASSIGNEE_ACTION_REMOVE -> "移出执行协办人";
|
||||
case TASK_ACTION_CREATE -> "创建任务";
|
||||
case TASK_ACTION_UPDATE -> "更新任务";
|
||||
case TASK_ACTION_DELETE -> "删除任务";
|
||||
case TASK_ASSIGNEE_ACTION_JOIN -> "加入";
|
||||
case TASK_ASSIGNEE_ACTION_INACTIVE -> "退出";
|
||||
case EXECUTION_MEMBER_LOG_ACTION_OWNER_TRANSFER_IN -> "转入负责人";
|
||||
case EXECUTION_MEMBER_LOG_ACTION_OWNER_TRANSFER_OUT -> "转出负责人";
|
||||
case EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_IN -> "转入负责人";
|
||||
case EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_OUT -> "转出负责人";
|
||||
case "start" -> "开始";
|
||||
case "block" -> "阻塞";
|
||||
case "complete" -> "完成";
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.njcn.rdms.module.project.constant;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 执行对象常量。
|
||||
*/
|
||||
@@ -34,13 +36,25 @@ public final class ProjectExecutionConstants {
|
||||
public static final String PERMISSION_OWNER = "project:execution:owner";
|
||||
|
||||
/**
|
||||
* 执行成员治理权限码。
|
||||
* 执行协办人治理权限码。
|
||||
*/
|
||||
public static final String PERMISSION_MEMBER = "project:execution:member";
|
||||
public static final String PERMISSION_ASSIGNEE = "project:execution:assignee";
|
||||
|
||||
/**
|
||||
* 执行状态动作权限码。
|
||||
*/
|
||||
public static final String PERMISSION_STATUS = "project:execution:status";
|
||||
|
||||
/**
|
||||
* 删除执行权限码。
|
||||
* 推荐挂"项目负责人"角色(参见 docs/项目/2026-05-11-执行按钮可见度对齐设计.md §6.4)。
|
||||
*/
|
||||
public static final String PERMISSION_DELETE = "project:execution:delete";
|
||||
|
||||
/**
|
||||
* 删除确认口令合法值集合;兼容大写英文 "DELETE" 与中文 "删除",前端可纯中文文案。
|
||||
* 校验时精确匹配(trim 后比对)。
|
||||
*/
|
||||
public static final Set<String> DELETE_CONFIRM_TEXTS = Set.of("DELETE", "删除");
|
||||
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ public final class ProjectObjectConstants {
|
||||
public static final Set<String> AUTO_START_TRIGGERS = Set.of(
|
||||
ObjectActivityConstants.PROJECT_TRIGGER_CREATE_EXECUTION,
|
||||
ObjectActivityConstants.PROJECT_TRIGGER_CREATE_TASK,
|
||||
ObjectActivityConstants.PROJECT_TRIGGER_SCHEDULE_REQUIREMENT);
|
||||
ObjectActivityConstants.PROJECT_TRIGGER_SCHEDULE_REQUIREMENT,
|
||||
ObjectActivityConstants.PROJECT_TRIGGER_EXECUTION_AUTO_START);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.njcn.rdms.module.project.constant;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 任务对象常量。
|
||||
*/
|
||||
@@ -43,4 +45,18 @@ public final class ProjectTaskConstants {
|
||||
*/
|
||||
public static final String PERMISSION_WORKLOG = "project:task:worklog";
|
||||
|
||||
/**
|
||||
* 删除任务权限码。
|
||||
* 推荐挂"项目负责人"角色(参见 docs/项目/2026-05-11-执行按钮可见度对齐设计.md §6.4)。
|
||||
* 实际拦截:service 内按 task.parentTaskId 分流走 checkOwnerOrProjectPermission
|
||||
* (一级任务 → execution.ownerId 字段身份;子任务 → parentTask.ownerId 字段身份;不命中字段时回落本权限码)。
|
||||
*/
|
||||
public static final String PERMISSION_DELETE = "project:task:delete";
|
||||
|
||||
/**
|
||||
* 删除确认口令合法值集合;兼容大写英文 "DELETE" 与中文 "删除",前端可纯中文文案。
|
||||
* 校验时精确匹配(trim 后比对)。
|
||||
*/
|
||||
public static final Set<String> DELETE_CONFIRM_TEXTS = Set.of("DELETE", "删除");
|
||||
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.framework.common.util.object.BeanUtils;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductCreateWithTeamReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductDeleteReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductOverviewSummaryRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductPageReqVO;
|
||||
@@ -39,6 +40,13 @@ public class ProductController {
|
||||
return success(productService.createProduct(createReqVO));
|
||||
}
|
||||
|
||||
@PostMapping("/create-with-team")
|
||||
@Operation(summary = "创建产品并初始化团队(原子接口)")
|
||||
@PreAuthorize("@ss.hasPermission('project:product:create')")
|
||||
public CommonResult<Long> createProductWithTeam(@Valid @RequestBody ProductCreateWithTeamReqVO reqVO) {
|
||||
return success(productService.createProductWithTeam(reqVO));
|
||||
}
|
||||
|
||||
@PutMapping("/update")
|
||||
@Operation(summary = "更新产品")
|
||||
public CommonResult<Boolean> updateProduct(@Valid @RequestBody ProductSaveReqVO updateReqVO) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.njcn.rdms.module.project.controller.admin.product.vo.product;
|
||||
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberSaveReqVO;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 产品创建原子接口请求 VO(POST /project/product/create-with-team)。
|
||||
*
|
||||
* <p>由前端"产品两步向导"一次性提交:产品基础资料 + 初始团队成员。
|
||||
* 后端必须在同一事务内完成全部写入,任一步失败整体回滚。
|
||||
*/
|
||||
@Schema(description = "管理后台 - 产品创建(含初始团队)Request VO")
|
||||
@Data
|
||||
public class ProductCreateWithTeamReqVO {
|
||||
|
||||
/**
|
||||
* 产品基础资料。沿用 {@link ProductSaveReqVO} 字段约束(同 POST /create)。
|
||||
*/
|
||||
@Schema(description = "产品基础资料", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull(message = "产品基础资料不能为空")
|
||||
@Valid
|
||||
private ProductSaveReqVO product;
|
||||
|
||||
/**
|
||||
* 初始团队成员列表。
|
||||
*
|
||||
* <p>必须包含一条 {@code userId == product.managerUserId} 的产品经理成员,
|
||||
* 由前端在打开第 2 步时按 role code = {@code product_manager} 反查 roleId 后聚合提交。
|
||||
* 后端不再根据 {@code product.managerUserId} 自动追加经理成员。
|
||||
*/
|
||||
@Schema(description = "初始团队成员(含产品经理本人)", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotEmpty(message = "初始团队成员不能为空")
|
||||
@Valid
|
||||
private List<ProductMemberSaveReqVO> members;
|
||||
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.framework.common.util.object.BeanUtils;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectCreateWithTeamReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectDeleteReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectOverviewSummaryRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectPageReqVO;
|
||||
@@ -41,6 +42,13 @@ public class ProjectController {
|
||||
return success(projectService.createProject(createReqVO));
|
||||
}
|
||||
|
||||
@PostMapping("/create-with-team")
|
||||
@Operation(summary = "创建项目并初始化团队(原子接口)")
|
||||
@PreAuthorize("@ss.hasPermission('project:project:create')")
|
||||
public CommonResult<Long> createProjectWithTeam(@Valid @RequestBody ProjectCreateWithTeamReqVO reqVO) {
|
||||
return success(projectService.createProjectWithTeam(reqVO));
|
||||
}
|
||||
|
||||
@PutMapping("/update")
|
||||
@Operation(summary = "更新项目")
|
||||
public CommonResult<Boolean> updateProject(@Valid @RequestBody ProjectSaveReqVO updateReqVO) {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.njcn.rdms.module.project.controller.admin.project.execution;
|
||||
|
||||
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeInactiveReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeLogPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeLogRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeSaveReqVO;
|
||||
import com.njcn.rdms.module.project.service.project.execution.ProjectExecutionAssigneeService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
||||
|
||||
@Tag(name = "管理后台 - 执行协办人")
|
||||
@RestController
|
||||
@RequestMapping("/project/project/{projectId}/executions/{executionId}")
|
||||
@Validated
|
||||
public class ProjectExecutionAssigneeController {
|
||||
|
||||
@Resource
|
||||
private ProjectExecutionAssigneeService projectExecutionAssigneeService;
|
||||
|
||||
@GetMapping("/assignees")
|
||||
@Operation(summary = "获取执行协办人列表(仅当前活跃)")
|
||||
public CommonResult<List<ExecutionAssigneeRespVO>> getExecutionAssigneeList(@PathVariable("projectId") Long projectId,
|
||||
@PathVariable("executionId") Long executionId) {
|
||||
return success(projectExecutionAssigneeService.getExecutionAssigneeList(projectId, executionId));
|
||||
}
|
||||
|
||||
@PostMapping("/assignees")
|
||||
@Operation(summary = "新增执行协办人(B 模型 - 每次新增一段)")
|
||||
public CommonResult<Long> createExecutionAssignee(@PathVariable("projectId") Long projectId,
|
||||
@PathVariable("executionId") Long executionId,
|
||||
@Valid @RequestBody ExecutionAssigneeSaveReqVO reqVO) {
|
||||
return success(projectExecutionAssigneeService.createExecutionAssignee(projectId, executionId, reqVO));
|
||||
}
|
||||
|
||||
@PostMapping("/assignees/{assigneeId}/inactive")
|
||||
@Operation(summary = "失效执行协办人(永久保留 removedReason)")
|
||||
public CommonResult<Boolean> inactiveExecutionAssignee(@PathVariable("projectId") Long projectId,
|
||||
@PathVariable("executionId") Long executionId,
|
||||
@PathVariable("assigneeId") Long assigneeId,
|
||||
@Valid @RequestBody ExecutionAssigneeInactiveReqVO reqVO) {
|
||||
projectExecutionAssigneeService.inactiveExecutionAssignee(projectId, executionId, assigneeId, reqVO);
|
||||
return success(true);
|
||||
}
|
||||
|
||||
@GetMapping("/assignee-logs")
|
||||
@Operation(summary = "获取执行协办人变更历史(分页)")
|
||||
public CommonResult<PageResult<ExecutionAssigneeLogRespVO>> getExecutionAssigneeLogPage(
|
||||
@PathVariable("projectId") Long projectId,
|
||||
@PathVariable("executionId") Long executionId,
|
||||
@Valid ExecutionAssigneeLogPageReqVO reqVO) {
|
||||
return success(projectExecutionAssigneeService.getExecutionAssigneeLogPage(projectId, executionId, reqVO));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execut
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionSaveReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionCreateReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionDeleteReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionUpdateReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusActionReqVO;
|
||||
import com.njcn.rdms.module.project.service.project.ProjectStatusBoardService;
|
||||
import com.njcn.rdms.module.project.service.project.execution.ProjectExecutionService;
|
||||
@@ -34,7 +36,7 @@ public class ProjectExecutionController {
|
||||
@PostMapping
|
||||
@Operation(summary = "创建执行")
|
||||
public CommonResult<Long> createExecution(@PathVariable("projectId") Long projectId,
|
||||
@Valid @RequestBody ProjectExecutionSaveReqVO reqVO) {
|
||||
@Valid @RequestBody ProjectExecutionCreateReqVO reqVO) {
|
||||
return success(projectExecutionService.createExecution(projectId, reqVO));
|
||||
}
|
||||
|
||||
@@ -42,7 +44,7 @@ public class ProjectExecutionController {
|
||||
@Operation(summary = "编辑执行")
|
||||
public CommonResult<Boolean> updateExecution(@PathVariable("projectId") Long projectId,
|
||||
@PathVariable("executionId") Long executionId,
|
||||
@Valid @RequestBody ProjectExecutionSaveReqVO reqVO) {
|
||||
@Valid @RequestBody ProjectExecutionUpdateReqVO reqVO) {
|
||||
reqVO.setId(executionId);
|
||||
projectExecutionService.updateExecution(projectId, reqVO);
|
||||
return success(true);
|
||||
@@ -87,4 +89,13 @@ public class ProjectExecutionController {
|
||||
return success(true);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{executionId}")
|
||||
@Operation(summary = "删除执行(仅初始态可删,三重确认)")
|
||||
public CommonResult<Boolean> deleteExecution(@PathVariable("projectId") Long projectId,
|
||||
@PathVariable("executionId") Long executionId,
|
||||
@Valid @RequestBody ProjectExecutionDeleteReqVO reqVO) {
|
||||
projectExecutionService.deleteExecution(projectId, executionId, reqVO);
|
||||
return success(true);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
package com.njcn.rdms.module.project.controller.admin.project.execution;
|
||||
|
||||
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberInactiveReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberLogPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberLogRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberSaveReqVO;
|
||||
import com.njcn.rdms.module.project.service.project.execution.ProjectExecutionMemberService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
||||
|
||||
@Tag(name = "管理后台 - 执行成员")
|
||||
@RestController
|
||||
@RequestMapping("/project/project/{projectId}/executions/{executionId}")
|
||||
@Validated
|
||||
public class ProjectExecutionMemberController {
|
||||
|
||||
@Resource
|
||||
private ProjectExecutionMemberService projectExecutionMemberService;
|
||||
|
||||
@GetMapping("/members")
|
||||
@Operation(summary = "获取执行成员列表(仅当前活跃)")
|
||||
public CommonResult<List<ExecutionMemberRespVO>> getExecutionMemberList(@PathVariable("projectId") Long projectId,
|
||||
@PathVariable("executionId") Long executionId) {
|
||||
return success(projectExecutionMemberService.getExecutionMemberList(projectId, executionId));
|
||||
}
|
||||
|
||||
@PostMapping("/members")
|
||||
@Operation(summary = "新增执行成员(B 模型 - 每次新增一段)")
|
||||
public CommonResult<Long> createExecutionMember(@PathVariable("projectId") Long projectId,
|
||||
@PathVariable("executionId") Long executionId,
|
||||
@Valid @RequestBody ExecutionMemberSaveReqVO reqVO) {
|
||||
return success(projectExecutionMemberService.createExecutionMember(projectId, executionId, reqVO));
|
||||
}
|
||||
|
||||
@PostMapping("/members/{memberId}/inactive")
|
||||
@Operation(summary = "失效执行成员(永久保留 removedReason)")
|
||||
public CommonResult<Boolean> inactiveExecutionMember(@PathVariable("projectId") Long projectId,
|
||||
@PathVariable("executionId") Long executionId,
|
||||
@PathVariable("memberId") Long memberId,
|
||||
@Valid @RequestBody ExecutionMemberInactiveReqVO reqVO) {
|
||||
projectExecutionMemberService.inactiveExecutionMember(projectId, executionId, memberId, reqVO);
|
||||
return success(true);
|
||||
}
|
||||
|
||||
@GetMapping("/member-logs")
|
||||
@Operation(summary = "获取执行成员变更历史(分页)")
|
||||
public CommonResult<PageResult<ExecutionMemberLogRespVO>> getExecutionMemberLogPage(
|
||||
@PathVariable("projectId") Long projectId,
|
||||
@PathVariable("executionId") Long executionId,
|
||||
@Valid ExecutionMemberLogPageReqVO reqVO) {
|
||||
return success(projectExecutionMemberService.getExecutionMemberLogPage(projectId, executionId, reqVO));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
package com.njcn.rdms.module.project.controller.admin.project.execution.vo.member;
|
||||
package com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
@Schema(description = "管理后台 - 执行成员失效 Request VO")
|
||||
@Schema(description = "管理后台 - 执行协办人失效 Request VO")
|
||||
@Data
|
||||
public class ExecutionMemberInactiveReqVO {
|
||||
public class ExecutionAssigneeInactiveReqVO {
|
||||
|
||||
@Schema(description = "失效原因", requiredMode = Schema.RequiredMode.REQUIRED, example = "阶段性退出")
|
||||
@NotBlank(message = "失效原因不能为空")
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.njcn.rdms.module.project.controller.admin.project.execution.vo.member;
|
||||
package com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee;
|
||||
|
||||
import com.njcn.rdms.framework.common.pojo.PageParam;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
@@ -8,16 +8,16 @@ import lombok.EqualsAndHashCode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Schema(description = "管理后台 - 执行成员变更历史分页 Request VO")
|
||||
@Schema(description = "管理后台 - 执行协办人变更历史分页 Request VO")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class ExecutionMemberLogPageReqVO extends PageParam {
|
||||
public class ExecutionAssigneeLogPageReqVO extends PageParam {
|
||||
|
||||
@Schema(description = "事件类型多选;不传表示全部",
|
||||
example = "[\"join\",\"inactive\",\"owner_transfer_in\",\"owner_transfer_out\"]")
|
||||
private List<String> actionTypes;
|
||||
|
||||
@Schema(description = "成员用户编号;不传表示全部")
|
||||
@Schema(description = "协办人用户编号;不传表示全部")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "起始时间(含),按 actionTime 比较")
|
||||
@@ -1,13 +1,13 @@
|
||||
package com.njcn.rdms.module.project.controller.admin.project.execution.vo.member;
|
||||
package com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Schema(description = "管理后台 - 执行成员变更历史 Response VO")
|
||||
@Schema(description = "管理后台 - 执行协办人变更历史 Response VO")
|
||||
@Data
|
||||
public class ExecutionMemberLogRespVO {
|
||||
public class ExecutionAssigneeLogRespVO {
|
||||
|
||||
@Schema(description = "日志编号", example = "12001")
|
||||
private Long id;
|
||||
@@ -1,21 +1,21 @@
|
||||
package com.njcn.rdms.module.project.controller.admin.project.execution.vo.member;
|
||||
package com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Schema(description = "管理后台 - 执行成员 Response VO")
|
||||
@Schema(description = "管理后台 - 执行协办人 Response VO")
|
||||
@Data
|
||||
public class ExecutionMemberRespVO {
|
||||
public class ExecutionAssigneeRespVO {
|
||||
|
||||
@Schema(description = "成员关系编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "7001")
|
||||
@Schema(description = "协办人关系编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "7001")
|
||||
private Long id;
|
||||
@Schema(description = "所属执行编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "5001")
|
||||
private Long executionId;
|
||||
@Schema(description = "成员用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3002")
|
||||
@Schema(description = "协办人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3002")
|
||||
private Long userId;
|
||||
@Schema(description = "成员用户昵称")
|
||||
@Schema(description = "协办人用户昵称")
|
||||
private String userNickname;
|
||||
@Schema(description = "加入时间", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime joinedAt;
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
@Schema(description = "管理后台 - 执行协办人新增 Request VO")
|
||||
@Data
|
||||
public class ExecutionAssigneeSaveReqVO {
|
||||
|
||||
@Schema(description = "协办人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3002")
|
||||
@NotNull(message = "协办人用户不能为空")
|
||||
private Long userId;
|
||||
|
||||
}
|
||||
@@ -9,12 +9,15 @@ import lombok.Data;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
@Schema(description = "管理后台 - 执行保存 Request VO")
|
||||
/**
|
||||
* 创建执行 Request VO。
|
||||
* <p>
|
||||
* 含 ownerId(必填)+ assigneeUserIds(创建时同步装配协办人)。
|
||||
* 后续编辑主数据走 PUT + {@link ProjectExecutionUpdateReqVO}(不含 ownerId / 协办人字段,避免越权裂缝)。
|
||||
*/
|
||||
@Schema(description = "管理后台 - 执行创建 Request VO")
|
||||
@Data
|
||||
public class ProjectExecutionSaveReqVO {
|
||||
|
||||
@Schema(description = "执行编号", example = "5001")
|
||||
private Long id;
|
||||
public class ProjectExecutionCreateReqVO {
|
||||
|
||||
@Schema(description = "执行名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "后端接口联调")
|
||||
@NotBlank(message = "执行名称不能为空")
|
||||
@@ -44,7 +47,7 @@ public class ProjectExecutionSaveReqVO {
|
||||
@Size(max = 200000, message = "执行说明长度不能超过200000个字符")
|
||||
private String executionDesc;
|
||||
|
||||
@Schema(description = "创建执行时同步写入的成员用户编号列表;编辑执行主数据时不维护成员", example = "[3002,3003]")
|
||||
private List<Long> memberUserIds;
|
||||
@Schema(description = "创建执行时同步写入的协办人用户编号列表", example = "[3002,3003]")
|
||||
private List<Long> assigneeUserIds;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 删除执行 Request VO。与项目侧 ProjectDeleteReqVO 同款三重确认(confirmText / executionName / reason)。
|
||||
* 详见 docs/项目/2026-05-11-执行按钮可见度对齐设计.md §6.2.1。
|
||||
*/
|
||||
@Schema(description = "管理后台 - 执行删除 Request VO")
|
||||
@Data
|
||||
public class ProjectExecutionDeleteReqVO {
|
||||
|
||||
@Schema(description = "确认输入的执行名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "后端接口联调")
|
||||
@NotBlank(message = "确认执行名称不能为空")
|
||||
@Size(max = 200, message = "确认执行名称长度不能超过200个字符")
|
||||
private String executionName;
|
||||
|
||||
@Schema(description = "删除确认口令,当前固定输入 DELETE", requiredMode = Schema.RequiredMode.REQUIRED, example = "DELETE")
|
||||
@NotBlank(message = "删除确认口令不能为空")
|
||||
@Size(max = 32, message = "删除确认口令长度不能超过32个字符")
|
||||
private String confirmText;
|
||||
|
||||
@Schema(description = "删除原因", requiredMode = Schema.RequiredMode.REQUIRED, example = "执行录入错误")
|
||||
@NotBlank(message = "删除原因不能为空")
|
||||
@Size(max = 500, message = "删除原因长度不能超过500个字符")
|
||||
private String reason;
|
||||
|
||||
}
|
||||
@@ -44,7 +44,7 @@ public class ProjectExecutionRespVO {
|
||||
private LocalDate actualStartDate;
|
||||
@Schema(description = "实际结束日期")
|
||||
private LocalDate actualEndDate;
|
||||
@Schema(description = "执行进度缓存值")
|
||||
@Schema(description = "执行进度")
|
||||
private BigDecimal progressRate;
|
||||
@Schema(description = "执行说明")
|
||||
private String executionDesc;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 编辑执行 Request VO(PUT 全字段语义)。
|
||||
* <p>
|
||||
* 不含 ownerId / assigneeUserIds:换负责人走 /change-owner 独立端点,协办人维护走 /assignees 独立端点。
|
||||
* 详见 docs/项目/2026-05-11-执行按钮可见度对齐设计.md §6.3。
|
||||
*/
|
||||
@Schema(description = "管理后台 - 执行编辑 Request VO")
|
||||
@Data
|
||||
public class ProjectExecutionUpdateReqVO {
|
||||
|
||||
@Schema(description = "执行编号", example = "5001")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "执行名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "后端接口联调")
|
||||
@NotBlank(message = "执行名称不能为空")
|
||||
@Size(max = 200, message = "执行名称长度不能超过200个字符")
|
||||
private String executionName;
|
||||
|
||||
@Schema(description = "执行类型,取值来自字典 rdms_project_execution_type", requiredMode = Schema.RequiredMode.REQUIRED, example = "feature")
|
||||
@NotBlank(message = "执行类型不能为空")
|
||||
@Size(max = 32, message = "执行类型长度不能超过32个字符")
|
||||
private String executionType;
|
||||
|
||||
@Schema(description = "关联项目需求编号,第一阶段只接受空值", example = "9001")
|
||||
private Long projectRequirementId;
|
||||
|
||||
@Schema(description = "计划开始日期")
|
||||
private LocalDate plannedStartDate;
|
||||
|
||||
@Schema(description = "计划结束日期")
|
||||
private LocalDate plannedEndDate;
|
||||
|
||||
@Schema(description = "执行说明(接受 HTML 富文本,图片走 URL 引用;后端经全局 XSS Safelist 自动净化)",
|
||||
example = "接口联调与问题跟踪")
|
||||
@Size(max = 200000, message = "执行说明长度不能超过200000个字符")
|
||||
private String executionDesc;
|
||||
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package com.njcn.rdms.module.project.controller.admin.project.execution.vo.member;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
@Schema(description = "管理后台 - 执行成员新增 Request VO")
|
||||
@Data
|
||||
public class ExecutionMemberSaveReqVO {
|
||||
|
||||
@Schema(description = "成员用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3002")
|
||||
@NotNull(message = "成员用户不能为空")
|
||||
private Long userId;
|
||||
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.njcn.rdms.module.project.controller.admin.project.task;
|
||||
|
||||
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskDeleteReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardRespVO;
|
||||
@@ -83,4 +84,14 @@ public class ProjectTaskController {
|
||||
return success(true);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{taskId}")
|
||||
@Operation(summary = "删除任务(仅初始态可删,三重确认 + 执行 owner 硬卡)")
|
||||
public CommonResult<Boolean> deleteTask(@PathVariable("projectId") Long projectId,
|
||||
@PathVariable("executionId") Long executionId,
|
||||
@PathVariable("taskId") Long taskId,
|
||||
@Valid @RequestBody ProjectTaskDeleteReqVO reqVO) {
|
||||
projectTaskService.deleteTask(projectId, executionId, taskId, reqVO);
|
||||
return success(true);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@@ -44,7 +43,6 @@ public class TaskAssigneeController {
|
||||
|
||||
@PostMapping("/assignees")
|
||||
@Operation(summary = "加入任务协办人")
|
||||
@PreAuthorize("@ss.hasPermission('project:task:assignee')")
|
||||
public CommonResult<Long> createAssignee(@PathVariable("projectId") Long projectId,
|
||||
@PathVariable("executionId") Long executionId,
|
||||
@PathVariable("taskId") Long taskId,
|
||||
@@ -54,7 +52,6 @@ public class TaskAssigneeController {
|
||||
|
||||
@PostMapping("/assignees/{assigneeId}/inactive")
|
||||
@Operation(summary = "退出任务协办人")
|
||||
@PreAuthorize("@ss.hasPermission('project:task:assignee')")
|
||||
public CommonResult<Boolean> inactiveAssignee(@PathVariable("projectId") Long projectId,
|
||||
@PathVariable("executionId") Long executionId,
|
||||
@PathVariable("taskId") Long taskId,
|
||||
|
||||
@@ -10,7 +10,6 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -43,7 +42,6 @@ public class TaskWorklogController {
|
||||
|
||||
@PostMapping("/worklogs")
|
||||
@Operation(summary = "新增任务工时")
|
||||
@PreAuthorize("@ss.hasPermission('project:task:worklog')")
|
||||
public CommonResult<Long> createWorklog(@PathVariable("projectId") Long projectId,
|
||||
@PathVariable("executionId") Long executionId,
|
||||
@PathVariable("taskId") Long taskId,
|
||||
@@ -53,7 +51,6 @@ public class TaskWorklogController {
|
||||
|
||||
@PutMapping("/worklogs/{worklogId}")
|
||||
@Operation(summary = "修改任务工时(仅自己)")
|
||||
@PreAuthorize("@ss.hasPermission('project:task:worklog')")
|
||||
public CommonResult<Boolean> updateWorklog(@PathVariable("projectId") Long projectId,
|
||||
@PathVariable("executionId") Long executionId,
|
||||
@PathVariable("taskId") Long taskId,
|
||||
@@ -65,7 +62,6 @@ public class TaskWorklogController {
|
||||
|
||||
@DeleteMapping("/worklogs/{worklogId}")
|
||||
@Operation(summary = "删除任务工时(自己或任务负责人)")
|
||||
@PreAuthorize("@ss.hasPermission('project:task:worklog')")
|
||||
public CommonResult<Boolean> deleteWorklog(@PathVariable("projectId") Long projectId,
|
||||
@PathVariable("executionId") Long executionId,
|
||||
@PathVariable("taskId") Long taskId,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.njcn.rdms.module.project.controller.admin.project.task.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 删除任务 Request VO。与执行侧 / 项目侧同款三重确认(confirmText / taskName / reason)。
|
||||
* 详见 docs/项目/2026-05-11-执行按钮可见度对齐设计.md §6.2.2。
|
||||
*/
|
||||
@Schema(description = "管理后台 - 任务删除 Request VO")
|
||||
@Data
|
||||
public class ProjectTaskDeleteReqVO {
|
||||
|
||||
@Schema(description = "确认输入的任务名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "接口契约对齐")
|
||||
@NotBlank(message = "确认任务名称不能为空")
|
||||
@Size(max = 200, message = "确认任务名称长度不能超过200个字符")
|
||||
private String taskName;
|
||||
|
||||
@Schema(description = "删除确认口令,当前固定输入 DELETE", requiredMode = Schema.RequiredMode.REQUIRED, example = "DELETE")
|
||||
@NotBlank(message = "删除确认口令不能为空")
|
||||
@Size(max = 32, message = "删除确认口令长度不能超过32个字符")
|
||||
private String confirmText;
|
||||
|
||||
@Schema(description = "删除原因", requiredMode = Schema.RequiredMode.REQUIRED, example = "任务录入错误")
|
||||
@NotBlank(message = "删除原因不能为空")
|
||||
@Size(max = 500, message = "删除原因长度不能超过500个字符")
|
||||
private String reason;
|
||||
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.njcn.rdms.module.project.controller.admin.project.task.vo;
|
||||
|
||||
import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@@ -8,6 +9,8 @@ import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
|
||||
@Schema(description = "管理后台 - 任务 Response VO")
|
||||
@Data
|
||||
public class ProjectTaskRespVO {
|
||||
@@ -20,6 +23,12 @@ public class ProjectTaskRespVO {
|
||||
private Long executionId;
|
||||
@Schema(description = "父任务编号")
|
||||
private Long parentTaskId;
|
||||
@Schema(description = "父任务负责人用户编号;一级任务为 null,子任务用于前端判断"
|
||||
+ "新增子任务/编辑/删除按钮显隐(详见前端联调清单 §4.3)", example = "3002")
|
||||
private Long parentTaskOwnerId;
|
||||
@Schema(description = "所属执行的负责人用户编号;前端判断一级任务"
|
||||
+ "新增/编辑/删除按钮显隐(一级任务执行负责人可自行调整)", example = "3001")
|
||||
private Long executionOwnerId;
|
||||
@Schema(description = "任务标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "接口联调任务")
|
||||
private String taskTitle;
|
||||
@Schema(description = "任务负责人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3002")
|
||||
@@ -52,9 +61,11 @@ public class ProjectTaskRespVO {
|
||||
private String lastStatusReason;
|
||||
@Schema(description = "当前活跃协办人列表;详细变更历史见 assignee-logs 接口")
|
||||
private List<TaskAssigneeView> assignees;
|
||||
@Schema(description = "已填报工时合计(分钟);逻辑删除的工时记录不计入。无记录默认为 0",
|
||||
example = "300")
|
||||
private Long totalSpentMinutes;
|
||||
@Schema(description = "已填报工时合计(小时,0.5 颗粒);逻辑删除的工时记录不计入。无记录默认为 0",
|
||||
example = "8.0")
|
||||
private BigDecimal totalSpentHours;
|
||||
@Schema(description = "附件列表")
|
||||
private List<AttachmentItem> attachments;
|
||||
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime createTime;
|
||||
@Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
package com.njcn.rdms.module.project.controller.admin.project.task.vo;
|
||||
|
||||
import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.DecimalMax;
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
@@ -29,11 +28,6 @@ public class ProjectTaskSaveReqVO {
|
||||
@Schema(description = "任务负责人用户编号;子任务不传时继承父任务负责人", example = "3002")
|
||||
private Long ownerId;
|
||||
|
||||
@Schema(description = "任务进度", example = "0.00")
|
||||
@DecimalMin(value = "0.00", message = "任务进度不能小于0")
|
||||
@DecimalMax(value = "100.00", message = "任务进度不能大于100")
|
||||
private BigDecimal progressRate;
|
||||
|
||||
@Schema(description = "计划开始日期")
|
||||
private LocalDate plannedStartDate;
|
||||
|
||||
@@ -49,5 +43,9 @@ public class ProjectTaskSaveReqVO {
|
||||
+ "协办人通过独立接口管理,详见 /tasks/{id}/assignees")
|
||||
private List<Long> assigneeUserIds;
|
||||
|
||||
@Schema(description = "附件列表;规则与限制详见 AttachmentValidator(数量上限、扩展名白/黑名单、URL 协议)")
|
||||
@Valid
|
||||
private List<AttachmentItem> attachments;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import lombok.Data;
|
||||
@Data
|
||||
public class ProjectTaskStatusActionReqVO {
|
||||
|
||||
@Schema(description = "动作编码,如 start、block、resume、complete、cancel", requiredMode = Schema.RequiredMode.REQUIRED, example = "complete")
|
||||
@Schema(description = "动作编码,如 auto_start、pause、resume、complete、cancel", requiredMode = Schema.RequiredMode.REQUIRED, example = "complete")
|
||||
@NotBlank(message = "动作编码不能为空")
|
||||
@Size(max = 32, message = "动作编码长度不能超过32个字符")
|
||||
private String actionCode;
|
||||
|
||||
@@ -15,10 +15,10 @@ public class TaskWorklogPageReqVO extends PageParam {
|
||||
@Schema(description = "填报人用户编号;不传表示全部")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "起始日期(含),按 workDate 比较")
|
||||
@Schema(description = "查询区间起始日期(含),按段相交过滤(record.endDate >= startDate)")
|
||||
private LocalDate startDate;
|
||||
|
||||
@Schema(description = "截止日期(含),按 workDate 比较")
|
||||
@Schema(description = "查询区间截止日期(含),按段相交过滤(record.startDate <= endDate)")
|
||||
private LocalDate endDate;
|
||||
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog;
|
||||
|
||||
import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Schema(description = "管理后台 - 任务工时 Response VO")
|
||||
@Data
|
||||
@@ -22,15 +25,24 @@ public class TaskWorklogRespVO {
|
||||
@Schema(description = "填报人昵称", example = "张三")
|
||||
private String userNickname;
|
||||
|
||||
@Schema(description = "工作日期", example = "2026-05-08")
|
||||
private LocalDate workDate;
|
||||
@Schema(description = "段起始日期(含),单天=与 endDate 相等", example = "2026-05-04")
|
||||
private LocalDate startDate;
|
||||
|
||||
@Schema(description = "时长(分钟)", example = "150")
|
||||
private Integer durationMinutes;
|
||||
@Schema(description = "段结束日期(含),单天=与 startDate 相等", example = "2026-05-08")
|
||||
private LocalDate endDate;
|
||||
|
||||
@Schema(description = "本次填报小时数(0.5 颗粒)", example = "8.0")
|
||||
private BigDecimal durationHours;
|
||||
|
||||
@Schema(description = "本次填报进度(0~100)", example = "60.00")
|
||||
private BigDecimal progressRate;
|
||||
|
||||
@Schema(description = "工作内容描述")
|
||||
private String workContent;
|
||||
|
||||
@Schema(description = "附件列表")
|
||||
private List<AttachmentItem> attachments;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
|
||||
@@ -1,33 +1,57 @@
|
||||
package com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog;
|
||||
|
||||
import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.DecimalMax;
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 任务工时新增/更新请求。同表共用:updateWorklog 不接受 taskId / userId 切换,前端无需也无法传。
|
||||
* 时长颗粒(30 分钟整数倍)由 Service 层校验。
|
||||
* 段语义:startDate / endDate 必填,单天 = 二者相等;同人同任务下日期范围禁止重叠(Service 层校验)。
|
||||
* 颗粒:durationHours 必须 > 0 且为 0.5 的整数倍(Service 层校验)。
|
||||
*/
|
||||
@Schema(description = "管理后台 - 任务工时 Save Request VO")
|
||||
@Data
|
||||
public class TaskWorklogSaveReqVO {
|
||||
|
||||
@Schema(description = "工作日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-05-08")
|
||||
@NotNull(message = "工作日期不能为空")
|
||||
private LocalDate workDate;
|
||||
@Schema(description = "段起始日期(含),单天=与 endDate 相等",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-05-04")
|
||||
@NotNull(message = "段起始日期不能为空")
|
||||
private LocalDate startDate;
|
||||
|
||||
@Schema(description = "时长(分钟),> 0 且必须为 30 的整数倍",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED, example = "150")
|
||||
@NotNull(message = "工时时长不能为空")
|
||||
@Min(value = 30, message = "工时时长必须大于 0 且为 30 分钟的整数倍")
|
||||
private Integer durationMinutes;
|
||||
@Schema(description = "段结束日期(含),单天=与 startDate 相等",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-05-08")
|
||||
@NotNull(message = "段结束日期不能为空")
|
||||
private LocalDate endDate;
|
||||
|
||||
@Schema(description = "本次填报小时数,> 0 且必须为 0.5 的整数倍",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED, example = "8.0")
|
||||
@NotNull(message = "工时小时数不能为空")
|
||||
@DecimalMin(value = "0.5", message = "工时小时数必须大于 0 且为 0.5 的整数倍")
|
||||
private BigDecimal durationHours;
|
||||
|
||||
@Schema(description = "本次填报进度(0~100,必填)。owner 填报:以 owner 本人最新一条工时(按 end_date 排序)"
|
||||
+ "为准同步任务进度;协作人填报:仅作为本人自评个人完成度,不影响任务/父任务进度",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED, example = "60.00")
|
||||
@NotNull(message = "本次填报进度不能为空")
|
||||
@DecimalMin(value = "0.00", message = "本次填报进度不能小于 0")
|
||||
@DecimalMax(value = "100.00", message = "本次填报进度不能大于 100")
|
||||
private BigDecimal progressRate;
|
||||
|
||||
@Schema(description = "工作内容描述", example = "完成接口联调与冒烟测试")
|
||||
@Size(max = 2000, message = "工作内容长度不能超过 2000 个字符")
|
||||
private String workContent;
|
||||
|
||||
@Schema(description = "附件列表;规则与限制详见 AttachmentValidator(数量上限、扩展名白/黑名单、URL 协议)")
|
||||
@Valid
|
||||
private List<AttachmentItem> attachments;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.njcn.rdms.module.project.controller.admin.project.vo.project;
|
||||
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberSaveReqVO;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 项目创建原子接口请求 VO(POST /project/project/create-with-team)。
|
||||
*
|
||||
* <p>由前端"项目两步向导"一次性提交:项目基础资料 + 初始团队成员。
|
||||
* 后端必须在同一事务内完成全部写入,任一步失败整体回滚。
|
||||
*/
|
||||
@Schema(description = "管理后台 - 项目创建(含初始团队)Request VO")
|
||||
@Data
|
||||
public class ProjectCreateWithTeamReqVO {
|
||||
|
||||
/**
|
||||
* 项目基础资料。沿用 {@link ProjectSaveReqVO} 字段约束(同 POST /create)。
|
||||
* 新增场景前端不传 {@code actualStartDate / actualEndDate}(实际日期由项目执行阶段才有值)。
|
||||
*/
|
||||
@Schema(description = "项目基础资料", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull(message = "项目基础资料不能为空")
|
||||
@Valid
|
||||
private ProjectSaveReqVO project;
|
||||
|
||||
/**
|
||||
* 初始团队成员列表。
|
||||
*
|
||||
* <p>必须包含一条 {@code userId == project.managerUserId} 的项目经理成员,
|
||||
* 由前端在打开第 2 步时按 role code = {@code project_manager} 反查 roleId 后聚合提交。
|
||||
* 后端不再根据 {@code project.managerUserId} 自动追加经理成员。
|
||||
*/
|
||||
@Schema(description = "初始团队成员(含项目经理本人)", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotEmpty(message = "初始团队成员不能为空")
|
||||
@Valid
|
||||
private List<ProjectMemberSaveReqVO> members;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.njcn.rdms.module.project.dal.dataobject.attachment;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 附件项。
|
||||
* <p>
|
||||
* 既作为业务表 {@code attachments} JSON 列的元素类型,也直接用于 ReqVO/RespVO 透传。
|
||||
* 文件本身由 rdms-system 的 file 模块上传到对象存储,本对象仅记录元数据。
|
||||
*/
|
||||
@Schema(description = "附件项")
|
||||
@Data
|
||||
public class AttachmentItem {
|
||||
|
||||
@Schema(description = "文件编号,来自 rdms-system 文件上传返回的 data.id。Long 以字符串保存,避免前端精度丢失",
|
||||
example = "10001")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "文件访问 URL(http/https,长度 ≤ 1024)",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
example = "http://oss.example.com/task/2026/05/abc.docx")
|
||||
private String url;
|
||||
|
||||
@Schema(description = "原文件名(含扩展名,长度 ≤ 255)",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
example = "需求评审记录.docx")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "文件大小(字节)", example = "245760")
|
||||
private Long size;
|
||||
|
||||
@Schema(description = "MIME Content-Type(建议长度 ≤ 128)",
|
||||
example = "application/vnd.openxmlformats-officedocument.wordprocessingml.document")
|
||||
private String contentType;
|
||||
|
||||
}
|
||||
@@ -9,12 +9,12 @@ import lombok.EqualsAndHashCode;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 执行成员关系表。
|
||||
* 执行协办人关系表。
|
||||
*/
|
||||
@TableName("rdms_execution_member")
|
||||
@TableName("rdms_execution_assignee")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class ExecutionMemberDO extends BaseDO {
|
||||
public class ExecutionAssigneeDO extends BaseDO {
|
||||
|
||||
/**
|
||||
* 主键编号
|
||||
@@ -26,7 +26,7 @@ public class ExecutionMemberDO extends BaseDO {
|
||||
*/
|
||||
private Long executionId;
|
||||
/**
|
||||
* 成员用户编号
|
||||
* 协办人用户编号
|
||||
*/
|
||||
private Long userId;
|
||||
/**
|
||||
@@ -9,13 +9,13 @@ import lombok.EqualsAndHashCode;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 执行成员变更历史日志(B 模型 - 全量事件记录)。
|
||||
* 执行协办人变更历史日志(B 模型 - 全量事件记录)。
|
||||
* 每次 join / inactive / owner_transfer_in / owner_transfer_out 独立成一条记录,昵称展示由查询阶段按用户编号回填。
|
||||
*/
|
||||
@TableName("rdms_execution_member_log")
|
||||
@TableName("rdms_execution_assignee_log")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class ExecutionMemberLogDO extends BaseDO {
|
||||
public class ExecutionAssigneeLogDO extends BaseDO {
|
||||
|
||||
/**
|
||||
* 主键 ID
|
||||
@@ -27,7 +27,7 @@ public class ExecutionMemberLogDO extends BaseDO {
|
||||
*/
|
||||
private Long executionId;
|
||||
/**
|
||||
* 被操作的成员用户编号
|
||||
* 被操作的协办人用户编号
|
||||
*/
|
||||
private Long userId;
|
||||
/**
|
||||
@@ -1,18 +1,22 @@
|
||||
package com.njcn.rdms.module.project.dal.dataobject.project.task;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
|
||||
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 项目任务主表。
|
||||
*/
|
||||
@TableName("rdms_task")
|
||||
@TableName(value = "rdms_task", autoResultMap = true)
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class ProjectTaskDO extends BaseDO {
|
||||
@@ -74,5 +78,11 @@ public class ProjectTaskDO extends BaseDO {
|
||||
* 最近一次状态动作原因
|
||||
*/
|
||||
private String lastStatusReason;
|
||||
/**
|
||||
* 附件列表(JSON)。元素 {@link AttachmentItem}:id / url / name / size / contentType。
|
||||
* 校验由 {@code AttachmentValidator} 在 Service 入口完成。
|
||||
*/
|
||||
@TableField(typeHandler = JacksonTypeHandler.class)
|
||||
private List<AttachmentItem> attachments;
|
||||
|
||||
}
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
package com.njcn.rdms.module.project.dal.dataobject.project.task;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
|
||||
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 任务工时记录表。仅挂在叶子任务上;同一 user × task × work_date 允许多条。
|
||||
* 时长按分钟存(duration_minutes 必须 > 0 且为 30 的整数倍),前端展示为小时。
|
||||
* 任务工时记录表。仅挂在叶子任务上;按段记录(start_date/end_date 必填,单天=二者相等)。
|
||||
* 同人同任务下日期范围禁止重叠。
|
||||
* 颗粒:duration_hours 必须 > 0 且为 0.5 的整数倍。
|
||||
*/
|
||||
@TableName("rdms_task_worklog")
|
||||
@TableName(value = "rdms_task_worklog", autoResultMap = true)
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class TaskWorklogDO extends BaseDO {
|
||||
@@ -31,16 +38,37 @@ public class TaskWorklogDO extends BaseDO {
|
||||
*/
|
||||
private Long userId;
|
||||
/**
|
||||
* 工作日期
|
||||
* 段起始日期(含),单天 = 与 endDate 相等
|
||||
*/
|
||||
private LocalDate workDate;
|
||||
private LocalDate startDate;
|
||||
/**
|
||||
* 时长(分钟为单位),必须 > 0 且为 30 的整数倍
|
||||
* 段结束日期(含),单天 = 与 startDate 相等
|
||||
*/
|
||||
private Integer durationMinutes;
|
||||
private LocalDate endDate;
|
||||
/**
|
||||
* 工作内容描述
|
||||
* 本次填报小时数,必须 > 0 且为 0.5 的整数倍
|
||||
*/
|
||||
private BigDecimal durationHours;
|
||||
/**
|
||||
* 本次填报进度(0~100,必填)。
|
||||
* <ul>
|
||||
* <li>owner 填报:以 owner 本人最新一条工时(按 end_date desc, create_time desc, id desc)为准,
|
||||
* 同步写入 {@code rdms_task.progress_rate} 并触发父任务 AVG 重算。</li>
|
||||
* <li>协作人填报:仅作为本人自评个人完成度,不影响任务/父任务进度。</li>
|
||||
* </ul>
|
||||
*/
|
||||
private BigDecimal progressRate;
|
||||
/**
|
||||
* 工作内容描述。允许在 update 时传 null 清空(updateStrategy=ALWAYS 跳过全局 NOT_NULL 策略,
|
||||
* 始终参与 SQL 拼接,包括 null)。调用方约定:update 必须全字段回传,不能用 null 表示"未改动"。
|
||||
*/
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String workContent;
|
||||
/**
|
||||
* 附件列表(JSON)。元素 {@link AttachmentItem}:id / url / name / size / contentType。
|
||||
* 校验由 {@code AttachmentValidator} 在 Service 入口完成。
|
||||
*/
|
||||
@TableField(typeHandler = JacksonTypeHandler.class)
|
||||
private List<AttachmentItem> attachments;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.njcn.rdms.module.project.dal.mysql.project.execution;
|
||||
|
||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeLogPageReqVO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeLogDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface ExecutionAssigneeLogMapper extends BaseMapperX<ExecutionAssigneeLogDO> {
|
||||
|
||||
/**
|
||||
* 分页查询执行协办人变更历史,按 actionTime DESC, id DESC 排序。
|
||||
* 支持按 actionType[] / userId / 时间范围筛选。
|
||||
*/
|
||||
default PageResult<ExecutionAssigneeLogDO> selectPageByExecutionId(Long executionId,
|
||||
ExecutionAssigneeLogPageReqVO reqVO) {
|
||||
LambdaQueryWrapperX<ExecutionAssigneeLogDO> queryWrapper = new LambdaQueryWrapperX<ExecutionAssigneeLogDO>()
|
||||
.eq(ExecutionAssigneeLogDO::getExecutionId, executionId)
|
||||
.inIfPresent(ExecutionAssigneeLogDO::getActionType, reqVO.getActionTypes())
|
||||
.eqIfPresent(ExecutionAssigneeLogDO::getUserId, reqVO.getUserId())
|
||||
.geIfPresent(ExecutionAssigneeLogDO::getActionTime, reqVO.getStartTime())
|
||||
.leIfPresent(ExecutionAssigneeLogDO::getActionTime, reqVO.getEndTime())
|
||||
.orderByDesc(ExecutionAssigneeLogDO::getActionTime)
|
||||
.orderByDesc(ExecutionAssigneeLogDO::getId);
|
||||
return selectPage(reqVO, queryWrapper);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.njcn.rdms.module.project.dal.mysql.project.execution;
|
||||
|
||||
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ExecutionAssigneeMapper extends BaseMapperX<ExecutionAssigneeDO> {
|
||||
|
||||
default List<ExecutionAssigneeDO> selectListByExecutionId(Long executionId) {
|
||||
return selectList(new LambdaQueryWrapperX<ExecutionAssigneeDO>()
|
||||
.eq(ExecutionAssigneeDO::getExecutionId, executionId)
|
||||
.orderByAsc(ExecutionAssigneeDO::getRemovedAt)
|
||||
.orderByAsc(ExecutionAssigneeDO::getJoinedAt)
|
||||
.orderByAsc(ExecutionAssigneeDO::getId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅返当前活跃协办人(removed_at IS NULL)。B 模型下同 userId 至多一段未失效。
|
||||
*/
|
||||
default List<ExecutionAssigneeDO> selectActiveListByExecutionId(Long executionId) {
|
||||
return selectList(new LambdaQueryWrapperX<ExecutionAssigneeDO>()
|
||||
.eq(ExecutionAssigneeDO::getExecutionId, executionId)
|
||||
.isNull(ExecutionAssigneeDO::getRemovedAt)
|
||||
.orderByAsc(ExecutionAssigneeDO::getJoinedAt)
|
||||
.orderByAsc(ExecutionAssigneeDO::getId));
|
||||
}
|
||||
|
||||
default ExecutionAssigneeDO selectByExecutionIdAndUserId(Long executionId, Long userId) {
|
||||
return selectOne(new LambdaQueryWrapperX<ExecutionAssigneeDO>()
|
||||
.eq(ExecutionAssigneeDO::getExecutionId, executionId)
|
||||
.eq(ExecutionAssigneeDO::getUserId, userId));
|
||||
}
|
||||
|
||||
default ExecutionAssigneeDO selectByIdAndExecutionId(Long id, Long executionId) {
|
||||
return selectOne(new LambdaQueryWrapperX<ExecutionAssigneeDO>()
|
||||
.eq(ExecutionAssigneeDO::getId, id)
|
||||
.eq(ExecutionAssigneeDO::getExecutionId, executionId));
|
||||
}
|
||||
|
||||
default ExecutionAssigneeDO selectActiveByExecutionIdAndUserId(Long executionId, Long userId) {
|
||||
return selectOne(new LambdaQueryWrapperX<ExecutionAssigneeDO>()
|
||||
.eq(ExecutionAssigneeDO::getExecutionId, executionId)
|
||||
.eq(ExecutionAssigneeDO::getUserId, userId)
|
||||
.isNull(ExecutionAssigneeDO::getRemovedAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查 userId 当前在指定项目下,活跃协办的所有执行 ID(removed_at IS NULL)。
|
||||
* 走 JOIN 是因为 execution_assignee 表没有 project_id 冗余字段。
|
||||
* 用于 VisibilityScopeResolver 收集"我是执行协办人"的 scope 来源。
|
||||
*/
|
||||
@Select("""
|
||||
SELECT a.execution_id
|
||||
FROM rdms_execution_assignee a
|
||||
JOIN rdms_project_execution e ON e.id = a.execution_id AND e.deleted = b'0'
|
||||
WHERE a.deleted = b'0'
|
||||
AND a.removed_at IS NULL
|
||||
AND e.project_id = #{projectId}
|
||||
AND a.user_id = #{userId}
|
||||
""")
|
||||
List<Long> selectActiveExecutionIdsByProjectIdAndUserId(@Param("projectId") Long projectId,
|
||||
@Param("userId") Long userId);
|
||||
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package com.njcn.rdms.module.project.dal.mysql.project.execution;
|
||||
|
||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberLogPageReqVO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionMemberLogDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface ExecutionMemberLogMapper extends BaseMapperX<ExecutionMemberLogDO> {
|
||||
|
||||
/**
|
||||
* 分页查询执行成员变更历史,按 actionTime DESC, id DESC 排序。
|
||||
* 支持按 actionType[] / userId / 时间范围筛选。
|
||||
*/
|
||||
default PageResult<ExecutionMemberLogDO> selectPageByExecutionId(Long executionId,
|
||||
ExecutionMemberLogPageReqVO reqVO) {
|
||||
LambdaQueryWrapperX<ExecutionMemberLogDO> queryWrapper = new LambdaQueryWrapperX<ExecutionMemberLogDO>()
|
||||
.eq(ExecutionMemberLogDO::getExecutionId, executionId)
|
||||
.inIfPresent(ExecutionMemberLogDO::getActionType, reqVO.getActionTypes())
|
||||
.eqIfPresent(ExecutionMemberLogDO::getUserId, reqVO.getUserId())
|
||||
.geIfPresent(ExecutionMemberLogDO::getActionTime, reqVO.getStartTime())
|
||||
.leIfPresent(ExecutionMemberLogDO::getActionTime, reqVO.getEndTime())
|
||||
.orderByDesc(ExecutionMemberLogDO::getActionTime)
|
||||
.orderByDesc(ExecutionMemberLogDO::getId);
|
||||
return selectPage(reqVO, queryWrapper);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package com.njcn.rdms.module.project.dal.mysql.project.execution;
|
||||
|
||||
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionMemberDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ExecutionMemberMapper extends BaseMapperX<ExecutionMemberDO> {
|
||||
|
||||
default List<ExecutionMemberDO> selectListByExecutionId(Long executionId) {
|
||||
return selectList(new LambdaQueryWrapperX<ExecutionMemberDO>()
|
||||
.eq(ExecutionMemberDO::getExecutionId, executionId)
|
||||
.orderByAsc(ExecutionMemberDO::getRemovedAt)
|
||||
.orderByAsc(ExecutionMemberDO::getJoinedAt)
|
||||
.orderByAsc(ExecutionMemberDO::getId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅返当前活跃成员(removed_at IS NULL)。B 模型下同 userId 至多一段未失效。
|
||||
*/
|
||||
default List<ExecutionMemberDO> selectActiveListByExecutionId(Long executionId) {
|
||||
return selectList(new LambdaQueryWrapperX<ExecutionMemberDO>()
|
||||
.eq(ExecutionMemberDO::getExecutionId, executionId)
|
||||
.isNull(ExecutionMemberDO::getRemovedAt)
|
||||
.orderByAsc(ExecutionMemberDO::getJoinedAt)
|
||||
.orderByAsc(ExecutionMemberDO::getId));
|
||||
}
|
||||
|
||||
default ExecutionMemberDO selectByExecutionIdAndUserId(Long executionId, Long userId) {
|
||||
return selectOne(new LambdaQueryWrapperX<ExecutionMemberDO>()
|
||||
.eq(ExecutionMemberDO::getExecutionId, executionId)
|
||||
.eq(ExecutionMemberDO::getUserId, userId));
|
||||
}
|
||||
|
||||
default ExecutionMemberDO selectByIdAndExecutionId(Long id, Long executionId) {
|
||||
return selectOne(new LambdaQueryWrapperX<ExecutionMemberDO>()
|
||||
.eq(ExecutionMemberDO::getId, id)
|
||||
.eq(ExecutionMemberDO::getExecutionId, executionId));
|
||||
}
|
||||
|
||||
default ExecutionMemberDO selectActiveByExecutionIdAndUserId(Long executionId, Long userId) {
|
||||
return selectOne(new LambdaQueryWrapperX<ExecutionMemberDO>()
|
||||
.eq(ExecutionMemberDO::getExecutionId, executionId)
|
||||
.eq(ExecutionMemberDO::getUserId, userId)
|
||||
.isNull(ExecutionMemberDO::getRemovedAt));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionPageReqVO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO;
|
||||
import com.njcn.rdms.module.project.service.project.permission.VisibilityScope;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@@ -27,21 +28,44 @@ public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO>
|
||||
.eq(ProjectExecutionDO::getExecutionName, executionName));
|
||||
}
|
||||
|
||||
default PageResult<ProjectExecutionDO> selectPageByProjectId(Long projectId, ProjectExecutionPageReqVO reqVO) {
|
||||
default PageResult<ProjectExecutionDO> selectPageByProjectId(Long projectId,
|
||||
VisibilityScope scope,
|
||||
ProjectExecutionPageReqVO reqVO) {
|
||||
// 可见性短路:非 seesAll 且无任何可见执行 → 空页,避免后续 IN () SQL
|
||||
if (!scope.seesAll() && scope.executionIds().isEmpty()) {
|
||||
return PageResult.empty();
|
||||
}
|
||||
LambdaQueryWrapperX<ProjectExecutionDO> queryWrapper = new LambdaQueryWrapperX<ProjectExecutionDO>()
|
||||
.eq(ProjectExecutionDO::getProjectId, projectId)
|
||||
.eqIfPresent(ProjectExecutionDO::getExecutionType, reqVO.getExecutionType())
|
||||
.eqIfPresent(ProjectExecutionDO::getOwnerId, reqVO.getOwnerId())
|
||||
.eqIfPresent(ProjectExecutionDO::getStatusCode, reqVO.getStatusCode())
|
||||
.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime())
|
||||
.orderByDesc(BaseDO::getUpdateTime)
|
||||
.orderByDesc(BaseDO::getCreateTime)
|
||||
.orderByDesc(ProjectExecutionDO::getId);
|
||||
if (StringUtils.hasText(reqVO.getKeyword())) {
|
||||
queryWrapper.and(wrapper -> wrapper.like(ProjectExecutionDO::getExecutionName, reqVO.getKeyword()));
|
||||
}
|
||||
if (!scope.seesAll()) {
|
||||
queryWrapper.in(ProjectExecutionDO::getId, scope.executionIds());
|
||||
}
|
||||
return selectPage(reqVO, queryWrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查 userId 在指定项目下,作为 owner 的所有执行 ID。
|
||||
* 用于 VisibilityScopeResolver 收集"我是执行负责人"的 scope 来源。
|
||||
*/
|
||||
default List<Long> selectIdsByProjectIdAndOwnerId(Long projectId, Long userId) {
|
||||
return selectList(new LambdaQueryWrapperX<ProjectExecutionDO>()
|
||||
.select(ProjectExecutionDO::getId)
|
||||
.eq(ProjectExecutionDO::getProjectId, projectId)
|
||||
.eq(ProjectExecutionDO::getOwnerId, userId))
|
||||
.stream()
|
||||
.map(ProjectExecutionDO::getId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
default Integer countNonTerminalByProjectIdAndOwnerId(Long projectId, Long ownerId, List<String> terminalStatusCodes) {
|
||||
LambdaQueryWrapperX<ProjectExecutionDO> queryWrapper = new LambdaQueryWrapperX<ProjectExecutionDO>()
|
||||
.eq(ProjectExecutionDO::getProjectId, projectId)
|
||||
@@ -52,7 +76,14 @@ public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO>
|
||||
return Math.toIntExact(selectCount(queryWrapper));
|
||||
}
|
||||
|
||||
default Integer countByProjectIdAndStatusCode(Long projectId, ProjectExecutionStatusBoardReqVO reqVO, String statusCode) {
|
||||
default Integer countByProjectIdAndStatusCode(Long projectId,
|
||||
VisibilityScope scope,
|
||||
ProjectExecutionStatusBoardReqVO reqVO,
|
||||
String statusCode) {
|
||||
// 可见性短路:非 seesAll 且无任何可见执行 → 计数 0
|
||||
if (!scope.seesAll() && scope.executionIds().isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
LambdaQueryWrapperX<ProjectExecutionDO> queryWrapper = new LambdaQueryWrapperX<ProjectExecutionDO>()
|
||||
.eq(ProjectExecutionDO::getProjectId, projectId)
|
||||
.eq(ProjectExecutionDO::getStatusCode, statusCode)
|
||||
@@ -62,6 +93,9 @@ public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO>
|
||||
if (StringUtils.hasText(reqVO.getKeyword())) {
|
||||
queryWrapper.and(wrapper -> wrapper.like(ProjectExecutionDO::getExecutionName, reqVO.getKeyword()));
|
||||
}
|
||||
if (!scope.seesAll()) {
|
||||
queryWrapper.in(ProjectExecutionDO::getId, scope.executionIds());
|
||||
}
|
||||
return Math.toIntExact(selectCount(queryWrapper));
|
||||
}
|
||||
|
||||
@@ -74,4 +108,14 @@ public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO>
|
||||
.eq(ProjectExecutionDO::getStatusCode, fromStatus));
|
||||
}
|
||||
|
||||
/**
|
||||
* 软删执行(按状态 CAS)。仅在 statusCode 与传入 fromStatus 匹配时才标 deleted=1。
|
||||
* 返回 1 表示成功;返回 0 视为并发修改。
|
||||
*/
|
||||
default int deleteByIdAndStatus(Long id, String fromStatus) {
|
||||
return delete(new LambdaQueryWrapperX<ProjectExecutionDO>()
|
||||
.eq(ProjectExecutionDO::getId, id)
|
||||
.eq(ProjectExecutionDO::getStatusCode, fromStatus));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -7,13 +7,18 @@ import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskPageReqVO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO;
|
||||
import com.njcn.rdms.module.project.service.project.permission.VisibilityScope;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Mapper
|
||||
public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
|
||||
@@ -25,7 +30,13 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
|
||||
.eq(ProjectTaskDO::getId, taskId));
|
||||
}
|
||||
|
||||
default PageResult<ProjectTaskDO> selectPageByExecutionId(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) {
|
||||
default PageResult<ProjectTaskDO> selectPageByExecutionId(Long projectId, Long executionId,
|
||||
VisibilityScope scope,
|
||||
ProjectTaskPageReqVO reqVO) {
|
||||
// 可见性短路:非 seesAll 且无任何可见任务 → 空页
|
||||
if (!scope.seesAll() && scope.taskIds().isEmpty()) {
|
||||
return PageResult.empty();
|
||||
}
|
||||
LambdaQueryWrapperX<ProjectTaskDO> queryWrapper = new LambdaQueryWrapperX<>();
|
||||
queryWrapper.eq(ProjectTaskDO::getProjectId, projectId);
|
||||
queryWrapper.eq(ProjectTaskDO::getExecutionId, executionId);
|
||||
@@ -34,11 +45,14 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
|
||||
queryWrapper.eqIfPresent(ProjectTaskDO::getStatusCode, reqVO.getStatusCode());
|
||||
queryWrapper.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime());
|
||||
queryWrapper.orderByAsc(ProjectTaskDO::getParentTaskId);
|
||||
queryWrapper.orderByDesc(BaseDO::getUpdateTime);
|
||||
queryWrapper.orderByDesc(BaseDO::getCreateTime);
|
||||
queryWrapper.orderByDesc(ProjectTaskDO::getId);
|
||||
if (StringUtils.hasText(reqVO.getKeyword())) {
|
||||
queryWrapper.and(wrapper -> wrapper.like(ProjectTaskDO::getTaskTitle, reqVO.getKeyword()));
|
||||
}
|
||||
if (!scope.seesAll()) {
|
||||
queryWrapper.in(ProjectTaskDO::getId, scope.taskIds());
|
||||
}
|
||||
return selectPage(reqVO, queryWrapper);
|
||||
}
|
||||
|
||||
@@ -51,6 +65,16 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
|
||||
.eq(ProjectTaskDO::getStatusCode, fromStatus));
|
||||
}
|
||||
|
||||
/**
|
||||
* 软删任务(按状态 CAS)。仅在 statusCode 与传入 fromStatus 匹配时才标 deleted=1。
|
||||
* 返回 1 表示成功;返回 0 视为并发修改。
|
||||
*/
|
||||
default int deleteByIdAndStatus(Long id, String fromStatus) {
|
||||
return delete(new LambdaQueryWrapperX<ProjectTaskDO>()
|
||||
.eq(ProjectTaskDO::getId, id)
|
||||
.eq(ProjectTaskDO::getStatusCode, fromStatus));
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅更新实际开始/结束日期。null 字段依据全局 FieldStrategy 不会被覆盖。
|
||||
*/
|
||||
@@ -74,6 +98,18 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
|
||||
return Math.toIntExact(selectCount(queryWrapper));
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计指定执行下处于非终态的任务数。用于执行 complete 前置校验(要求所有任务必须终态)。
|
||||
*/
|
||||
default Integer countByExecutionIdNotInStatus(Long executionId, List<String> terminalStatusCodes) {
|
||||
LambdaQueryWrapperX<ProjectTaskDO> queryWrapper = new LambdaQueryWrapperX<ProjectTaskDO>()
|
||||
.eq(ProjectTaskDO::getExecutionId, executionId);
|
||||
if (terminalStatusCodes != null && !terminalStatusCodes.isEmpty()) {
|
||||
queryWrapper.notIn(ProjectTaskDO::getStatusCode, terminalStatusCodes);
|
||||
}
|
||||
return Math.toIntExact(selectCount(queryWrapper));
|
||||
}
|
||||
|
||||
/**
|
||||
* 数指定父任务下的直接子任务(不区分状态)。用于"是否叶子任务"判定与进度汇总。
|
||||
*/
|
||||
@@ -98,6 +134,39 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
|
||||
.eq(ProjectTaskDO::getParentTaskId, parentTaskId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行详情进度:按当前执行下一级任务 progressRate 简单平均;无一级任务时 SQL 返回 null。
|
||||
*/
|
||||
@Select("""
|
||||
SELECT AVG(COALESCE(progress_rate, 0))
|
||||
FROM rdms_task
|
||||
WHERE deleted = b'0'
|
||||
AND project_id = #{projectId}
|
||||
AND execution_id = #{executionId}
|
||||
AND parent_task_id IS NULL
|
||||
""")
|
||||
BigDecimal selectRootTaskAvgProgressByExecutionId(@Param("projectId") Long projectId,
|
||||
@Param("executionId") Long executionId);
|
||||
|
||||
/**
|
||||
* 执行分页进度:按当前页 executionId 批量聚合一级任务 progressRate,避免列表 N+1。
|
||||
*/
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT execution_id AS executionId, AVG(COALESCE(progress_rate, 0)) AS progressRate
|
||||
FROM rdms_task
|
||||
WHERE deleted = b'0'
|
||||
AND project_id = #{projectId}
|
||||
AND execution_id IN
|
||||
<foreach collection="executionIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
AND parent_task_id IS NULL
|
||||
GROUP BY execution_id
|
||||
</script>
|
||||
""")
|
||||
List<Map<String, Object>> selectRootTaskAvgProgressGroupByExecutionIds(
|
||||
@Param("projectId") Long projectId,
|
||||
@Param("executionIds") Collection<Long> executionIds);
|
||||
|
||||
/**
|
||||
* 仅更新单个任务的 progressRate,不动其他字段(避免污染 lastStatusReason 等)。
|
||||
*/
|
||||
@@ -108,9 +177,62 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
|
||||
.eq(ProjectTaskDO::getId, id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归 CTE:从"userId 在指定项目下作为 owner_id 的全部任务"出发,向下展开包含所有子孙的任务 ID。
|
||||
* 用于 VisibilityScopeResolver 中"任务负责人 → 自己 + 全部子孙"规则的 scope 收集。
|
||||
*
|
||||
* 任务表已逻辑删除的行不参与递归(WHERE 子句过滤 deleted)。
|
||||
* 单棵子树最大深度受 MySQL `cte_max_recursion_depth`(默认 1000)限制,业务实际任务树远低于此。
|
||||
*/
|
||||
@Select("""
|
||||
WITH RECURSIVE owned (id) AS (
|
||||
SELECT id FROM rdms_task
|
||||
WHERE deleted = b'0'
|
||||
AND project_id = #{projectId}
|
||||
AND owner_id = #{userId}
|
||||
UNION ALL
|
||||
SELECT t.id FROM rdms_task t
|
||||
JOIN owned o ON t.parent_task_id = o.id
|
||||
WHERE t.deleted = b'0'
|
||||
)
|
||||
SELECT id FROM owned
|
||||
""")
|
||||
List<Long> selectOwnedTaskAndDescendantIdsByProjectIdAndUserId(
|
||||
@Param("projectId") Long projectId,
|
||||
@Param("userId") Long userId);
|
||||
|
||||
/**
|
||||
* 同 selectOwnedTaskAndDescendantIdsByProjectIdAndUserId 但再加 execution_id 维度。
|
||||
* 注意:递归向下展开只跟着 parent_task_id,子任务必然与父任务在同一 execution 下,
|
||||
* 因此 execution_id 过滤仅作用于种子(owned)那一步即可。
|
||||
*/
|
||||
@Select("""
|
||||
WITH RECURSIVE owned (id) AS (
|
||||
SELECT id FROM rdms_task
|
||||
WHERE deleted = b'0'
|
||||
AND project_id = #{projectId}
|
||||
AND execution_id = #{executionId}
|
||||
AND owner_id = #{userId}
|
||||
UNION ALL
|
||||
SELECT t.id FROM rdms_task t
|
||||
JOIN owned o ON t.parent_task_id = o.id
|
||||
WHERE t.deleted = b'0'
|
||||
)
|
||||
SELECT id FROM owned
|
||||
""")
|
||||
List<Long> selectOwnedTaskAndDescendantIdsByExecutionIdAndUserId(
|
||||
@Param("projectId") Long projectId,
|
||||
@Param("executionId") Long executionId,
|
||||
@Param("userId") Long userId);
|
||||
|
||||
default Integer countByProjectIdAndExecutionIdAndStatusCode(Long projectId, Long executionId,
|
||||
VisibilityScope scope,
|
||||
ProjectTaskStatusBoardReqVO reqVO,
|
||||
String statusCode) {
|
||||
// 可见性短路:非 seesAll 且无任何可见任务 → 0
|
||||
if (!scope.seesAll() && scope.taskIds().isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
LambdaQueryWrapperX<ProjectTaskDO> queryWrapper = new LambdaQueryWrapperX<ProjectTaskDO>()
|
||||
.eq(ProjectTaskDO::getProjectId, projectId)
|
||||
.eq(ProjectTaskDO::getExecutionId, executionId)
|
||||
@@ -121,6 +243,9 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
|
||||
if (StringUtils.hasText(reqVO.getKeyword())) {
|
||||
queryWrapper.and(wrapper -> wrapper.like(ProjectTaskDO::getTaskTitle, reqVO.getKeyword()));
|
||||
}
|
||||
if (!scope.seesAll()) {
|
||||
queryWrapper.in(ProjectTaskDO::getId, scope.taskIds());
|
||||
}
|
||||
return Math.toIntExact(selectCount(queryWrapper));
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskAssigneeDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
@@ -48,6 +50,41 @@ public interface TaskAssigneeMapper extends BaseMapperX<TaskAssigneeDO> {
|
||||
.orderByAsc(TaskAssigneeDO::getId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查 userId 在指定项目下,当前活跃协办的所有任务 ID(removed_at IS NULL)。
|
||||
* 走 JOIN 是因为 task_assignee 表没有 project_id 冗余字段。
|
||||
* 用于 VisibilityScopeResolver 收集"我是任务协办人"的 scope 来源(项目维度)。
|
||||
*/
|
||||
@Select("""
|
||||
SELECT a.task_id
|
||||
FROM rdms_task_assignee a
|
||||
JOIN rdms_task t ON t.id = a.task_id AND t.deleted = b'0'
|
||||
WHERE a.deleted = b'0'
|
||||
AND a.removed_at IS NULL
|
||||
AND t.project_id = #{projectId}
|
||||
AND a.user_id = #{userId}
|
||||
""")
|
||||
List<Long> selectActiveTaskIdsByProjectIdAndUserId(@Param("projectId") Long projectId,
|
||||
@Param("userId") Long userId);
|
||||
|
||||
/**
|
||||
* 同上,但再加 execution_id 维度,用于"任务分页(执行内)"的 scope。
|
||||
*/
|
||||
@Select("""
|
||||
SELECT a.task_id
|
||||
FROM rdms_task_assignee a
|
||||
JOIN rdms_task t ON t.id = a.task_id AND t.deleted = b'0'
|
||||
WHERE a.deleted = b'0'
|
||||
AND a.removed_at IS NULL
|
||||
AND t.project_id = #{projectId}
|
||||
AND t.execution_id = #{executionId}
|
||||
AND a.user_id = #{userId}
|
||||
""")
|
||||
List<Long> selectActiveTaskIdsByProjectIdAndExecutionIdAndUserId(
|
||||
@Param("projectId") Long projectId,
|
||||
@Param("executionId") Long executionId,
|
||||
@Param("userId") Long userId);
|
||||
|
||||
/**
|
||||
* 按主键 + 任务 ID 双键查;返回的记录可能已失效(removed_at != null),由调用方判断。
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,8 @@ import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -27,35 +29,40 @@ public interface TaskWorklogMapper extends BaseMapperX<TaskWorklogDO> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务工时分页:按 workDate DESC, id DESC;支持按填报人 / 日期区间筛选。
|
||||
* 任务工时分页:按 endDate DESC, id DESC;支持按填报人 / 段相交过滤。
|
||||
* 段相交语义:record.startDate <= filter.endDate AND record.endDate >= filter.startDate。
|
||||
*/
|
||||
default PageResult<TaskWorklogDO> selectPageByTaskId(Long taskId, TaskWorklogPageReqVO reqVO) {
|
||||
LambdaQueryWrapperX<TaskWorklogDO> queryWrapper = new LambdaQueryWrapperX<TaskWorklogDO>()
|
||||
.eq(TaskWorklogDO::getTaskId, taskId)
|
||||
.eqIfPresent(TaskWorklogDO::getUserId, reqVO.getUserId())
|
||||
.geIfPresent(TaskWorklogDO::getWorkDate, reqVO.getStartDate())
|
||||
.leIfPresent(TaskWorklogDO::getWorkDate, reqVO.getEndDate())
|
||||
.orderByDesc(TaskWorklogDO::getWorkDate)
|
||||
.eqIfPresent(TaskWorklogDO::getUserId, reqVO.getUserId());
|
||||
if (reqVO.getEndDate() != null) {
|
||||
queryWrapper.le(TaskWorklogDO::getStartDate, reqVO.getEndDate());
|
||||
}
|
||||
if (reqVO.getStartDate() != null) {
|
||||
queryWrapper.ge(TaskWorklogDO::getEndDate, reqVO.getStartDate());
|
||||
}
|
||||
queryWrapper.orderByDesc(TaskWorklogDO::getEndDate)
|
||||
.orderByDesc(TaskWorklogDO::getId);
|
||||
return selectPage(reqVO, queryWrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单任务工时汇总(分钟)。无记录时返回 0;逻辑删除的记录不参与汇总。
|
||||
* 单任务工时小时数汇总。无记录时返回 0;逻辑删除的记录不参与汇总。
|
||||
*/
|
||||
@Select("""
|
||||
SELECT COALESCE(SUM(duration_minutes), 0)
|
||||
SELECT COALESCE(SUM(duration_hours), 0)
|
||||
FROM rdms_task_worklog
|
||||
WHERE deleted = b'0' AND task_id = #{taskId}
|
||||
""")
|
||||
Long sumDurationByTaskId(@Param("taskId") Long taskId);
|
||||
BigDecimal sumDurationByTaskId(@Param("taskId") Long taskId);
|
||||
|
||||
/**
|
||||
* 批量任务工时汇总(分钟),返回 [{taskId, total}]。用于详情/分页装配避免 N+1。
|
||||
* 批量任务工时小时数汇总,返回 [{taskId, total}]。用于详情/分页装配避免 N+1。
|
||||
*/
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT task_id AS taskId, COALESCE(SUM(duration_minutes), 0) AS total
|
||||
SELECT task_id AS taskId, COALESCE(SUM(duration_hours), 0) AS total
|
||||
FROM rdms_task_worklog
|
||||
WHERE deleted = b'0' AND task_id IN
|
||||
<foreach collection="taskIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
@@ -75,4 +82,87 @@ public interface TaskWorklogMapper extends BaseMapperX<TaskWorklogDO> {
|
||||
.eq(TaskWorklogDO::getTaskId, taskId)) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取指定用户在该任务下最新的一条工时(按 end_date desc, create_time desc, id desc)。
|
||||
* 用于 owner 填报后回查"本人最新一条"以同步任务进度。
|
||||
* 逻辑删除的记录由 BaseMapper 自动过滤;无记录返回 null。
|
||||
*/
|
||||
default TaskWorklogDO selectLatestByTaskIdAndUserId(Long taskId, Long userId) {
|
||||
if (taskId == null || userId == null) {
|
||||
return null;
|
||||
}
|
||||
List<TaskWorklogDO> list = selectList(new LambdaQueryWrapperX<TaskWorklogDO>()
|
||||
.eq(TaskWorklogDO::getTaskId, taskId)
|
||||
.eq(TaskWorklogDO::getUserId, userId)
|
||||
.orderByDesc(TaskWorklogDO::getEndDate)
|
||||
.orderByDesc(TaskWorklogDO::getCreateTime)
|
||||
.orderByDesc(TaskWorklogDO::getId)
|
||||
.last("LIMIT 1"));
|
||||
return list.isEmpty() ? null : list.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取该 (taskId, userId) 下 endDate 严格早于给定值的"最近一条",按 end_date desc, id desc 取首行。
|
||||
* 用于进度单调性校验(与"前一段"比较)。excludeId 不为 null 时排除自身(更新场景)。
|
||||
*/
|
||||
default TaskWorklogDO selectPrevByEndDate(Long taskId, Long userId, LocalDate endDate, Long excludeId) {
|
||||
if (taskId == null || userId == null || endDate == null) {
|
||||
return null;
|
||||
}
|
||||
LambdaQueryWrapperX<TaskWorklogDO> queryWrapper = new LambdaQueryWrapperX<>();
|
||||
queryWrapper.eq(TaskWorklogDO::getTaskId, taskId)
|
||||
.eq(TaskWorklogDO::getUserId, userId)
|
||||
.lt(TaskWorklogDO::getEndDate, endDate);
|
||||
if (excludeId != null) {
|
||||
queryWrapper.ne(TaskWorklogDO::getId, excludeId);
|
||||
}
|
||||
queryWrapper.orderByDesc(TaskWorklogDO::getEndDate)
|
||||
.orderByDesc(TaskWorklogDO::getId)
|
||||
.last("LIMIT 1");
|
||||
List<TaskWorklogDO> list = selectList(queryWrapper);
|
||||
return list.isEmpty() ? null : list.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取该 (taskId, userId) 下 endDate 严格晚于给定值的"最近一条",按 end_date asc, id asc 取首行。
|
||||
* 用于进度单调性校验(与"后一段"比较)。excludeId 不为 null 时排除自身(更新场景)。
|
||||
*/
|
||||
default TaskWorklogDO selectNextByEndDate(Long taskId, Long userId, LocalDate endDate, Long excludeId) {
|
||||
if (taskId == null || userId == null || endDate == null) {
|
||||
return null;
|
||||
}
|
||||
LambdaQueryWrapperX<TaskWorklogDO> queryWrapper = new LambdaQueryWrapperX<>();
|
||||
queryWrapper.eq(TaskWorklogDO::getTaskId, taskId)
|
||||
.eq(TaskWorklogDO::getUserId, userId)
|
||||
.gt(TaskWorklogDO::getEndDate, endDate);
|
||||
if (excludeId != null) {
|
||||
queryWrapper.ne(TaskWorklogDO::getId, excludeId);
|
||||
}
|
||||
queryWrapper.orderByAsc(TaskWorklogDO::getEndDate)
|
||||
.orderByAsc(TaskWorklogDO::getId)
|
||||
.last("LIMIT 1");
|
||||
List<TaskWorklogDO> list = selectList(queryWrapper);
|
||||
return list.isEmpty() ? null : list.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否存在与指定 [startDate, endDate] 区间相交的、属于该 task × user 的工时记录。
|
||||
* 段相交语义:record.startDate <= endDate AND record.endDate >= startDate。
|
||||
* 用于禁止重叠校验;excludeId 不为 null 时排除该记录自身(更新场景用)。
|
||||
*/
|
||||
default boolean existsOverlapping(Long taskId, Long userId, LocalDate startDate, LocalDate endDate, Long excludeId) {
|
||||
if (taskId == null || userId == null || startDate == null || endDate == null) {
|
||||
return false;
|
||||
}
|
||||
LambdaQueryWrapperX<TaskWorklogDO> queryWrapper = new LambdaQueryWrapperX<>();
|
||||
queryWrapper.eq(TaskWorklogDO::getTaskId, taskId)
|
||||
.eq(TaskWorklogDO::getUserId, userId)
|
||||
.le(TaskWorklogDO::getStartDate, endDate)
|
||||
.ge(TaskWorklogDO::getEndDate, startDate);
|
||||
if (excludeId != null) {
|
||||
queryWrapper.ne(TaskWorklogDO::getId, excludeId);
|
||||
}
|
||||
return selectCount(queryWrapper) > 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.njcn.rdms.module.project.framework.attachment;
|
||||
|
||||
import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem;
|
||||
import com.njcn.rdms.module.system.api.file.FileApi;
|
||||
import com.njcn.rdms.module.system.api.file.dto.FileRespDTO;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 附件文件编号补齐器。
|
||||
*
|
||||
* <p>业务附件 JSON 以 rdms-system 的文件记录 ID 作为下载、删除的稳定入口。
|
||||
* 为兼容前端只回传 url 的场景,保存前尝试按 url 反查文件记录并补齐 id。</p>
|
||||
*/
|
||||
@Component
|
||||
public class AttachmentFileIdResolver {
|
||||
|
||||
@Resource
|
||||
private FileApi fileApi;
|
||||
|
||||
public void resolve(List<AttachmentItem> attachments) {
|
||||
if (attachments == null || attachments.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (AttachmentItem attachment : attachments) {
|
||||
if (attachment == null || StringUtils.hasText(attachment.getId())
|
||||
|| !StringUtils.hasText(attachment.getUrl())) {
|
||||
continue;
|
||||
}
|
||||
FileRespDTO file = fileApi.getFileByUrl(attachment.getUrl()).getCheckedData();
|
||||
if (file != null && file.getId() != null) {
|
||||
attachment.setId(String.valueOf(file.getId()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.njcn.rdms.module.project.framework.attachment;
|
||||
|
||||
import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem;
|
||||
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
|
||||
/**
|
||||
* 附件统一校验。任务、工时(以及未来的产品需求 / 项目需求 / 工单)共用。
|
||||
* <p>
|
||||
* 规则:
|
||||
* <ul>
|
||||
* <li>列表数量 ≤ {@link #MAX_COUNT}</li>
|
||||
* <li>id:允许为空;新上传文件建议传 rdms-system 文件上传返回的 data.id</li>
|
||||
* <li>url:非空、{@code http://} 或 {@code https://} 开头、长度 ≤ {@link #MAX_URL_LENGTH}</li>
|
||||
* <li>name:非空、长度 ≤ {@link #MAX_NAME_LENGTH}</li>
|
||||
* <li>扩展名(取自 name 最末一段,小写比对)必须在 {@link #ALLOWED_EXTENSIONS},且不能在 {@link #BLOCKED_EXTENSIONS}</li>
|
||||
* </ul>
|
||||
* 单文件大小限制依赖 {@code /system/file/upload} 上传时拦截,本类不复核。
|
||||
*/
|
||||
public final class AttachmentValidator {
|
||||
|
||||
public static final int MAX_COUNT = 20;
|
||||
public static final int MAX_URL_LENGTH = 1024;
|
||||
public static final int MAX_NAME_LENGTH = 255;
|
||||
|
||||
/** 允许的扩展名白名单(小写,无 {@code .})。 */
|
||||
public static final Set<String> ALLOWED_EXTENSIONS = Set.of(
|
||||
// 文档
|
||||
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "md", "csv",
|
||||
// 图片
|
||||
"jpg", "jpeg", "png", "gif", "webp", "bmp",
|
||||
// 压缩
|
||||
"zip", "rar", "7z",
|
||||
// 媒体
|
||||
"mp4", "mp3"
|
||||
);
|
||||
|
||||
/** 禁止的扩展名黑名单(即使在白名单也禁,兜底防可执行 / 脚本类)。 */
|
||||
public static final Set<String> BLOCKED_EXTENSIONS = Set.of(
|
||||
"exe", "bat", "cmd", "sh", "ps1", "msi", "dll", "jar", "war",
|
||||
"php", "jsp", "asp", "aspx", "py", "rb", "pl",
|
||||
"com", "scr", "vbs", "js"
|
||||
);
|
||||
|
||||
private AttachmentValidator() {
|
||||
}
|
||||
|
||||
public static void validate(List<AttachmentItem> attachments) {
|
||||
if (attachments == null || attachments.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (attachments.size() > MAX_COUNT) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_ATTACHMENT_TOO_MANY, MAX_COUNT);
|
||||
}
|
||||
for (AttachmentItem item : attachments) {
|
||||
validateItem(item);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateItem(AttachmentItem item) {
|
||||
if (item == null) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_ATTACHMENT_NAME_INVALID);
|
||||
}
|
||||
// url:协议 + 长度
|
||||
String url = item.getUrl();
|
||||
if (!StringUtils.hasText(url) || url.length() > MAX_URL_LENGTH
|
||||
|| !(url.startsWith("http://") || url.startsWith("https://"))) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_ATTACHMENT_URL_INVALID);
|
||||
}
|
||||
// name:非空 + 长度
|
||||
String name = item.getName();
|
||||
if (!StringUtils.hasText(name) || name.length() > MAX_NAME_LENGTH) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_ATTACHMENT_NAME_INVALID);
|
||||
}
|
||||
// 扩展名:黑名单优先,再过白名单
|
||||
String ext = extractExtension(name);
|
||||
if (BLOCKED_EXTENSIONS.contains(ext)) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_ATTACHMENT_TYPE_BLOCKED, ext);
|
||||
}
|
||||
if (!ALLOWED_EXTENSIONS.contains(ext)) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_ATTACHMENT_TYPE_NOT_ALLOWED, ext);
|
||||
}
|
||||
}
|
||||
|
||||
private static String extractExtension(String name) {
|
||||
int dot = name.lastIndexOf('.');
|
||||
if (dot < 0 || dot == name.length() - 1) {
|
||||
return "";
|
||||
}
|
||||
return name.substring(dot + 1).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.njcn.rdms.module.project.framework.rpc.config;
|
||||
|
||||
import com.njcn.rdms.module.system.api.dict.DictDataApi;
|
||||
import com.njcn.rdms.module.system.api.file.FileApi;
|
||||
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
|
||||
import com.njcn.rdms.module.system.api.user.AdminUserApi;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
@@ -10,6 +11,6 @@ import org.springframework.context.annotation.Configuration;
|
||||
* Project 模块的 RPC 配置
|
||||
*/
|
||||
@Configuration(value = "projectRpcConfiguration", proxyBeanMethods = false)
|
||||
@EnableFeignClients(clients = {AdminUserApi.class, ObjectPermissionApi.class, DictDataApi.class})
|
||||
@EnableFeignClients(clients = {AdminUserApi.class, ObjectPermissionApi.class, DictDataApi.class, FileApi.class})
|
||||
public class RpcConfiguration {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.njcn.rdms.module.project.framework.security.service;
|
||||
|
||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 项目对象业务授权服务。
|
||||
*/
|
||||
@Service
|
||||
public class ProjectObjectAuthorizationService {
|
||||
|
||||
@Resource
|
||||
private ProjectObjectPermissionService projectObjectPermissionService;
|
||||
|
||||
/**
|
||||
* 当前登录人是业务负责人时直接放行,否则回落到项目对象角色权限校验。
|
||||
*/
|
||||
public void checkOwnerOrProjectPermission(Long projectId, Long ownerUserId, String permission) {
|
||||
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
if (Objects.equals(loginUserId, ownerUserId)) {
|
||||
return;
|
||||
}
|
||||
projectObjectPermissionService.checkPermission(projectId, permission, false);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.njcn.rdms.module.project.service.product;
|
||||
|
||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductCreateWithTeamReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductDeleteReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductOverviewSummaryRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductPageReqVO;
|
||||
@@ -23,6 +24,22 @@ public interface ProductService {
|
||||
*/
|
||||
Long createProduct(ProductSaveReqVO createReqVO);
|
||||
|
||||
/**
|
||||
* 创建产品并初始化团队(原子接口)。
|
||||
*
|
||||
* <p>由前端"产品两步向导"一次性提交,产品基础资料与全部初始团队成员
|
||||
* 必须在同一事务内完成写入;任一步失败整体回滚。
|
||||
*
|
||||
* <p>与 {@link #createProduct(ProductSaveReqVO)} 的关键差异:经理成员
|
||||
* 由前端在 {@code members} 中显式提交,后端不再根据
|
||||
* {@code product.managerUserId} 自动追加,因此需要校验
|
||||
* {@code members} 中必须存在 userId 等于 managerUserId 的记录。
|
||||
*
|
||||
* @param reqVO 创建请求(产品 + 初始团队)
|
||||
* @return 产品编号
|
||||
*/
|
||||
Long createProductWithTeam(ProductCreateWithTeamReqVO reqVO);
|
||||
|
||||
/**
|
||||
* 更新产品
|
||||
*
|
||||
|
||||
@@ -8,10 +8,12 @@ import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
|
||||
import com.njcn.rdms.module.project.constant.ObjectRoleConstants;
|
||||
import com.njcn.rdms.module.project.constant.ProductObjectConstants;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberSaveReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextNavRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextProductRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextRoleRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductCreateWithTeamReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductDeleteReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductOverviewSummaryRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductPageReqVO;
|
||||
@@ -103,6 +105,109 @@ public class ProductServiceImpl implements ProductService {
|
||||
return product.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long createProductWithTeam(ProductCreateWithTeamReqVO reqVO) {
|
||||
ProductSaveReqVO productReq = reqVO.getProduct();
|
||||
List<ProductMemberSaveReqVO> members = reqVO.getMembers();
|
||||
|
||||
// 1) 复用旧 create 接口的产品资料校验(名称 / 编码唯一性、产品经理用户存在)
|
||||
validateCreateReqVO(productReq);
|
||||
validateManagerUser(productReq.getManagerUserId());
|
||||
|
||||
// 2) 初始团队校验:包含经理、userId 不重复、roleId 合法(含经理那条必须为产品经理角色)
|
||||
validateInitialMembers(productReq.getManagerUserId(), members);
|
||||
|
||||
// 3) 写入产品基础资料:与 createProduct 完全一致,唯一差异是不调用 initManagerMemberRelation —— 经理成员由 members 显式提供,避免重复插入
|
||||
ProductDO product = new ProductDO();
|
||||
product.setCode(generateProductCode(productReq.getCode()));
|
||||
product.setDirectionCode(productReq.getDirectionCode());
|
||||
String initialStatus = getInitialStatusCode();
|
||||
product.setStatusCode(initialStatus);
|
||||
product.setName(productReq.getName().trim());
|
||||
product.setManagerUserId(productReq.getManagerUserId());
|
||||
product.setDescription(normalizeNullableText(productReq.getDescription()));
|
||||
productMapper.insert(product);
|
||||
|
||||
initDefaultRequirementModule(product);
|
||||
|
||||
// 4) 遍历写入初始成员,含成员维度审计;roleMap 在校验阶段已确保合法
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (ProductMemberSaveReqVO memberReq : members) {
|
||||
UserObjectRoleDO member = new UserObjectRoleDO();
|
||||
member.setUserId(memberReq.getUserId());
|
||||
member.setObjectType(ProductObjectConstants.OBJECT_TYPE);
|
||||
member.setObjectId(product.getId());
|
||||
member.setRoleId(memberReq.getRoleId());
|
||||
member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
|
||||
member.setJoinedTime(now);
|
||||
member.setLeftTime(null);
|
||||
member.setRemark(normalizeNullableText(memberReq.getRemark()));
|
||||
userObjectRoleMapper.insert(member);
|
||||
writeMemberInitAuditLog(member);
|
||||
}
|
||||
|
||||
// 5) 产品维度的"指定经理"审计:与旧 initManagerMemberRelation 末尾一致,保留活动时间线一致性
|
||||
writeManagerInitAuditLog(product.getId(), product.getManagerUserId());
|
||||
|
||||
// 6) 产品创建审计
|
||||
writeBizAuditLog(product, ObjectActivityConstants.PRODUCT_ACTION_CREATE, null, initialStatus,
|
||||
buildProductFieldChanges(null, product), null);
|
||||
return product.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验初始团队成员列表。
|
||||
*
|
||||
* <p>校验维度:
|
||||
* <ol>
|
||||
* <li>members 中 userId 不可重复</li>
|
||||
* <li>members 必须包含产品经理本人(userId == managerUserId)</li>
|
||||
* <li>所有 roleId 必须落在 scopeType=object, objectType=product 且启用</li>
|
||||
* <li>经理那条 members 的 roleId 必须是 {@code product_manager} 角色(数据一致性兜底)</li>
|
||||
* </ol>
|
||||
*/
|
||||
@VisibleForTesting
|
||||
void validateInitialMembers(Long managerUserId, List<ProductMemberSaveReqVO> members) {
|
||||
if (members == null || members.isEmpty()) {
|
||||
// @NotEmpty 已挡,此处兜底
|
||||
throw exception(ErrorCodeConstants.PRODUCT_INITIAL_TEAM_MANAGER_REQUIRED);
|
||||
}
|
||||
|
||||
Set<Long> userIds = new HashSet<>();
|
||||
for (ProductMemberSaveReqVO m : members) {
|
||||
if (!userIds.add(m.getUserId())) {
|
||||
throw exception(ErrorCodeConstants.PRODUCT_INITIAL_TEAM_MEMBER_DUPLICATE);
|
||||
}
|
||||
}
|
||||
if (!userIds.contains(managerUserId)) {
|
||||
throw exception(ErrorCodeConstants.PRODUCT_INITIAL_TEAM_MANAGER_REQUIRED);
|
||||
}
|
||||
|
||||
Set<Long> roleIds = members.stream().map(ProductMemberSaveReqVO::getRoleId).collect(Collectors.toSet());
|
||||
List<ObjectRoleRespDTO> roles = objectPermissionApi
|
||||
.getObjectRoleList(roleIds, ObjectRoleConstants.ROLE_SCOPE_OBJECT, ProductObjectConstants.OBJECT_TYPE)
|
||||
.getCheckedData();
|
||||
Map<Long, ObjectRoleRespDTO> roleMap = roles == null ? Collections.emptyMap()
|
||||
: roles.stream().collect(Collectors.toMap(ObjectRoleRespDTO::getId, Function.identity()));
|
||||
for (Long roleId : roleIds) {
|
||||
if (!roleMap.containsKey(roleId)) {
|
||||
throw exception(ErrorCodeConstants.PRODUCT_INITIAL_TEAM_ROLE_INVALID);
|
||||
}
|
||||
}
|
||||
|
||||
// 经理那条 members 的 roleId 必须对应 product_manager 角色,否则 product.managerUserId 与成员表角色语义会错乱
|
||||
ProductMemberSaveReqVO managerMember = members.stream()
|
||||
.filter(m -> Objects.equals(m.getUserId(), managerUserId))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> exception(ErrorCodeConstants.PRODUCT_INITIAL_TEAM_MANAGER_REQUIRED));
|
||||
ObjectRoleRespDTO managerRole = roleMap.get(managerMember.getRoleId());
|
||||
if (managerRole == null
|
||||
|| !Objects.equals(ProductObjectConstants.MANAGER_ROLE_CODE, managerRole.getCode())) {
|
||||
throw exception(ErrorCodeConstants.PRODUCT_INITIAL_TEAM_ROLE_INVALID);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#updateReqVO.id",
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.njcn.rdms.module.project.service.project;
|
||||
|
||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectCreateWithTeamReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectDeleteReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectOverviewSummaryRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectPageReqVO;
|
||||
@@ -19,6 +20,26 @@ public interface ProjectService {
|
||||
|
||||
Long createProject(ProjectSaveReqVO createReqVO);
|
||||
|
||||
/**
|
||||
* 创建项目并初始化团队(原子接口)。
|
||||
*
|
||||
* <p>由前端"项目两步向导"一次性提交,项目基础资料与全部初始团队成员
|
||||
* 必须在同一事务内完成写入;任一步失败整体回滚。
|
||||
*
|
||||
* <p>与 {@link #createProject(ProjectSaveReqVO)} 的关键差异:
|
||||
* <ul>
|
||||
* <li>经理成员由前端在 {@code members} 中显式提交,后端不再根据
|
||||
* {@code project.managerUserId} 自动追加;并校验 {@code members}
|
||||
* 中必须存在 userId 等于 managerUserId 的记录;</li>
|
||||
* <li>{@code productId} 非空时,{@code directionCode} 必须与所属产品方向
|
||||
* 一致(防御性校验),不一致直接报错而非覆盖。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param reqVO 创建请求(项目 + 初始团队)
|
||||
* @return 项目编号
|
||||
*/
|
||||
Long createProjectWithTeam(ProjectCreateWithTeamReqVO reqVO);
|
||||
|
||||
void updateProject(ProjectSaveReqVO updateReqVO);
|
||||
|
||||
ProjectDO getProject(Long id);
|
||||
|
||||
@@ -9,10 +9,12 @@ import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
|
||||
import com.njcn.rdms.module.project.constant.ObjectRoleConstants;
|
||||
import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberSaveReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextNavRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextProjectRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextRoleRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectCreateWithTeamReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectDeleteReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectOverviewSummaryRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectPageReqVO;
|
||||
@@ -57,10 +59,12 @@ import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -71,7 +75,8 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
|
||||
* 项目 Service 实现类
|
||||
*/
|
||||
@Service
|
||||
public class ProjectServiceImpl implements ProjectService {
|
||||
public
|
||||
class ProjectServiceImpl implements ProjectService {
|
||||
|
||||
@Resource
|
||||
private ProjectMapper projectMapper;
|
||||
@@ -133,6 +138,155 @@ public class ProjectServiceImpl implements ProjectService {
|
||||
return project.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long createProjectWithTeam(ProjectCreateWithTeamReqVO reqVO) {
|
||||
ProjectSaveReqVO projectReq = reqVO.getProject();
|
||||
List<ProjectMemberSaveReqVO> members = reqVO.getMembers();
|
||||
|
||||
// 1) 复用旧 create 接口的项目资料校验(name/code 唯一、计划/实际日期范围、字典类型、主线唯一、负责人有效)
|
||||
validateCreateReqVO(projectReq);
|
||||
validateProjectType(projectReq.getProjectType());
|
||||
// 注意:新接口对方向使用严格校验——productId 非空时 directionCode 必须与产品方向一致;旧 create 是覆盖式,保留不动
|
||||
String finalDirectionCode = resolveDirectionCodeStrict(projectReq.getProductId(), projectReq.getDirectionCode());
|
||||
validateMainlineProjectUnique(null, projectReq.getProductId(), projectReq.getProjectType());
|
||||
validateManagerUser(projectReq.getManagerUserId());
|
||||
|
||||
// 2) 初始团队校验:包含经理、userId 不重复、roleId 合法(含经理那条必须为项目经理角色)
|
||||
validateInitialMembers(projectReq.getManagerUserId(), members);
|
||||
|
||||
// 3) 写入项目基础资料:与 createProject 完全一致,唯一差异是不调用 initManagerMemberRelation —— 经理成员由 members 显式提供,避免重复插入
|
||||
ProjectDO project = new ProjectDO();
|
||||
project.setProjectCode(generateProjectCode(projectReq.getProjectCode()));
|
||||
project.setProjectName(projectReq.getProjectName().trim());
|
||||
project.setProjectType(projectReq.getProjectType().trim());
|
||||
project.setDirectionCode(finalDirectionCode);
|
||||
project.setProjectSetId(projectReq.getProjectSetId());
|
||||
project.setProductId(projectReq.getProductId());
|
||||
project.setProductVersionId(projectReq.getProductVersionId());
|
||||
project.setManagerUserId(projectReq.getManagerUserId());
|
||||
String initialStatus = getInitialStatusCode();
|
||||
project.setStatusCode(initialStatus);
|
||||
project.setPlannedStartDate(projectReq.getPlannedStartDate());
|
||||
project.setPlannedEndDate(projectReq.getPlannedEndDate());
|
||||
// 新增模式不应携带 actualStartDate / actualEndDate;前端不传时即为 null,无需特殊处理
|
||||
project.setActualStartDate(projectReq.getActualStartDate());
|
||||
project.setActualEndDate(projectReq.getActualEndDate());
|
||||
project.setProgressRate(BigDecimal.ZERO);
|
||||
project.setProjectDesc(normalizeNullableText(projectReq.getProjectDesc()));
|
||||
projectMapper.insert(project);
|
||||
|
||||
// 4) 遍历写入初始成员,含成员维度审计
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (ProjectMemberSaveReqVO memberReq : members) {
|
||||
UserObjectRoleDO member = new UserObjectRoleDO();
|
||||
member.setUserId(memberReq.getUserId());
|
||||
member.setObjectType(ProjectObjectConstants.OBJECT_TYPE);
|
||||
member.setObjectId(project.getId());
|
||||
member.setRoleId(memberReq.getRoleId());
|
||||
member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
|
||||
member.setJoinedTime(now);
|
||||
member.setLeftTime(null);
|
||||
member.setRemark(normalizeNullableText(memberReq.getRemark()));
|
||||
userObjectRoleMapper.insert(member);
|
||||
writeMemberAuditLog(member, ObjectActivityConstants.MEMBER_ACTION_ADD, null, member, null);
|
||||
}
|
||||
|
||||
// 5) 项目维度的"指定经理"审计:与旧 initManagerMemberRelation 末尾一致,保留活动时间线一致性
|
||||
writeManagerChangeAuditLog(project.getId(), null, project.getManagerUserId(), null);
|
||||
|
||||
// 6) 项目创建审计
|
||||
writeBizAuditLog(project, ObjectActivityConstants.PROJECT_ACTION_CREATE, null, initialStatus,
|
||||
buildProjectFieldChanges(null, project), null);
|
||||
return project.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 新接口专用:严格的方向解析。
|
||||
*
|
||||
* <p>与 {@link #resolveProjectDirectionCode(Long, String)} 的差异:当 {@code productId}
|
||||
* 非空且前端显式传入 {@code directionCode} 时,必须与所属产品方向一致;
|
||||
* 不一致直接抛 {@link ErrorCodeConstants#PROJECT_DIRECTION_NOT_MATCH_PRODUCT},
|
||||
* 而非旧接口的覆盖式行为。前端正常流程会强制把 directionCode 设为产品方向后锁定,
|
||||
* 此校验仅作为防御,防止接口被绕过前端直接调用。
|
||||
*/
|
||||
@VisibleForTesting
|
||||
String resolveDirectionCodeStrict(Long productId, String directionCode) {
|
||||
if (productId == null) {
|
||||
if (!StringUtils.hasText(directionCode)) {
|
||||
throw invalidParamException("项目方向不能为空");
|
||||
}
|
||||
String normalizedDirectionCode = directionCode.trim();
|
||||
if (normalizedDirectionCode.length() > 32) {
|
||||
throw invalidParamException("项目方向长度不能超过32个字符");
|
||||
}
|
||||
validateProjectDirection(normalizedDirectionCode);
|
||||
return normalizedDirectionCode;
|
||||
}
|
||||
ProductDO product = validateProductUsable(productId);
|
||||
String productDirectionCode = normalizeNullableText(product == null ? null : product.getDirectionCode());
|
||||
if (!StringUtils.hasText(productDirectionCode)) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_DIRECTION_INVALID);
|
||||
}
|
||||
if (StringUtils.hasText(directionCode)
|
||||
&& !Objects.equals(directionCode.trim(), productDirectionCode)) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_DIRECTION_NOT_MATCH_PRODUCT);
|
||||
}
|
||||
return productDirectionCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验初始团队成员列表。
|
||||
*
|
||||
* <p>校验维度:
|
||||
* <ol>
|
||||
* <li>members 中 userId 不可重复</li>
|
||||
* <li>members 必须包含项目经理本人(userId == managerUserId)</li>
|
||||
* <li>所有 roleId 必须落在 scopeType=object, objectType=project 且启用</li>
|
||||
* <li>经理那条 members 的 roleId 必须是 {@code project_manager} 角色(数据一致性兜底)</li>
|
||||
* </ol>
|
||||
*/
|
||||
@VisibleForTesting
|
||||
void validateInitialMembers(Long managerUserId, List<ProjectMemberSaveReqVO> members) {
|
||||
if (members == null || members.isEmpty()) {
|
||||
// @NotEmpty 已挡,此处兜底
|
||||
throw exception(ErrorCodeConstants.PROJECT_INITIAL_TEAM_MANAGER_REQUIRED);
|
||||
}
|
||||
|
||||
Set<Long> userIds = new HashSet<>();
|
||||
for (ProjectMemberSaveReqVO m : members) {
|
||||
if (!userIds.add(m.getUserId())) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_INITIAL_TEAM_MEMBER_DUPLICATE);
|
||||
}
|
||||
}
|
||||
if (!userIds.contains(managerUserId)) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_INITIAL_TEAM_MANAGER_REQUIRED);
|
||||
}
|
||||
|
||||
Set<Long> roleIds = members.stream().map(ProjectMemberSaveReqVO::getRoleId).collect(Collectors.toSet());
|
||||
List<ObjectRoleRespDTO> roles = objectPermissionApi
|
||||
.getObjectRoleList(roleIds, ObjectRoleConstants.ROLE_SCOPE_OBJECT, ProjectObjectConstants.OBJECT_TYPE)
|
||||
.getCheckedData();
|
||||
Map<Long, ObjectRoleRespDTO> roleMap = roles == null ? Collections.emptyMap()
|
||||
: roles.stream().collect(Collectors.toMap(ObjectRoleRespDTO::getId, Function.identity()));
|
||||
for (Long roleId : roleIds) {
|
||||
if (!roleMap.containsKey(roleId)) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_INITIAL_TEAM_ROLE_INVALID);
|
||||
}
|
||||
}
|
||||
|
||||
// 经理那条 members 的 roleId 必须对应 project_manager 角色,否则 project.managerUserId 与成员表角色语义会错乱
|
||||
ProjectMemberSaveReqVO managerMember = members.stream()
|
||||
.filter(m -> Objects.equals(m.getUserId(), managerUserId))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> exception(ErrorCodeConstants.PROJECT_INITIAL_TEAM_MANAGER_REQUIRED));
|
||||
ObjectRoleRespDTO managerRole = roleMap.get(managerMember.getRoleId());
|
||||
if (managerRole == null
|
||||
|| !Objects.equals(ProjectObjectConstants.MANAGER_ROLE_CODE, managerRole.getCode())) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_INITIAL_TEAM_ROLE_INVALID);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE,
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
package com.njcn.rdms.module.project.service.project;
|
||||
|
||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import com.njcn.rdms.module.project.constant.ProjectExecutionConstants;
|
||||
import com.njcn.rdms.module.project.constant.ProjectTaskConstants;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardRespVO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
||||
import com.njcn.rdms.module.project.service.project.permission.VisibilityScope;
|
||||
import com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
@Service
|
||||
public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService {
|
||||
@@ -24,20 +29,35 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
|
||||
private ProjectExecutionMapper projectExecutionMapper;
|
||||
@Resource
|
||||
private ProjectTaskMapper projectTaskMapper;
|
||||
@Resource
|
||||
private VisibilityScopeResolver visibilityScopeResolver;
|
||||
|
||||
@Override
|
||||
public ProjectExecutionStatusBoardRespVO getExecutionStatusBoard(Long projectId, ProjectExecutionStatusBoardReqVO reqVO) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
VisibilityScope scope = visibilityScopeResolver.resolveForProject(projectId, userId);
|
||||
List<ObjectStatusModelDO> statusModels = objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE);
|
||||
return buildExecutionStatusBoard(projectId, reqVO, statusModels);
|
||||
return buildExecutionStatusBoard(projectId, scope, reqVO, statusModels);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProjectTaskStatusBoardRespVO getTaskStatusBoard(Long projectId, Long executionId, ProjectTaskStatusBoardReqVO reqVO) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
VisibilityScope scope = visibilityScopeResolver.resolveForExecution(projectId, executionId, userId);
|
||||
// 执行 owner = 当前用户 → 看本执行下全部任务,等价于 seesAll。
|
||||
if (!scope.seesAll()) {
|
||||
ProjectExecutionDO exec = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId);
|
||||
if (exec != null && Objects.equals(exec.getOwnerId(), userId)) {
|
||||
scope = VisibilityScope.all();
|
||||
}
|
||||
}
|
||||
List<ObjectStatusModelDO> statusModels = objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
|
||||
return buildTaskStatusBoard(projectId, executionId, reqVO, statusModels);
|
||||
return buildTaskStatusBoard(projectId, executionId, scope, reqVO, statusModels);
|
||||
}
|
||||
|
||||
private ProjectExecutionStatusBoardRespVO buildExecutionStatusBoard(Long projectId, ProjectExecutionStatusBoardReqVO reqVO,
|
||||
private ProjectExecutionStatusBoardRespVO buildExecutionStatusBoard(Long projectId,
|
||||
VisibilityScope scope,
|
||||
ProjectExecutionStatusBoardReqVO reqVO,
|
||||
List<ObjectStatusModelDO> statusModels) {
|
||||
ProjectExecutionStatusBoardRespVO respVO = new ProjectExecutionStatusBoardRespVO();
|
||||
List<ProjectExecutionStatusBoardRespVO.ProjectStatusBoardItemVO> items = statusModels.stream().map(statusModel -> {
|
||||
@@ -45,7 +65,7 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
|
||||
new ProjectExecutionStatusBoardRespVO.ProjectStatusBoardItemVO();
|
||||
item.setStatusCode(statusModel.getStatusCode());
|
||||
item.setStatusName(statusModel.getStatusName());
|
||||
item.setCount(projectExecutionMapper.countByProjectIdAndStatusCode(projectId, reqVO, statusModel.getStatusCode()).longValue());
|
||||
item.setCount(projectExecutionMapper.countByProjectIdAndStatusCode(projectId, scope, reqVO, statusModel.getStatusCode()).longValue());
|
||||
item.setSort(statusModel.getSort());
|
||||
item.setTerminal(statusModel.getTerminalFlag());
|
||||
return item;
|
||||
@@ -55,14 +75,16 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
|
||||
return respVO;
|
||||
}
|
||||
|
||||
private ProjectTaskStatusBoardRespVO buildTaskStatusBoard(Long projectId, Long executionId, ProjectTaskStatusBoardReqVO reqVO,
|
||||
private ProjectTaskStatusBoardRespVO buildTaskStatusBoard(Long projectId, Long executionId,
|
||||
VisibilityScope scope,
|
||||
ProjectTaskStatusBoardReqVO reqVO,
|
||||
List<ObjectStatusModelDO> statusModels) {
|
||||
ProjectTaskStatusBoardRespVO respVO = new ProjectTaskStatusBoardRespVO();
|
||||
List<ProjectTaskStatusBoardRespVO.ProjectStatusBoardItemVO> items = statusModels.stream().map(statusModel -> {
|
||||
ProjectTaskStatusBoardRespVO.ProjectStatusBoardItemVO item = new ProjectTaskStatusBoardRespVO.ProjectStatusBoardItemVO();
|
||||
item.setStatusCode(statusModel.getStatusCode());
|
||||
item.setStatusName(statusModel.getStatusName());
|
||||
item.setCount(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(projectId, executionId, reqVO,
|
||||
item.setCount(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(projectId, executionId, scope, reqVO,
|
||||
statusModel.getStatusCode()).longValue());
|
||||
item.setSort(statusModel.getSort());
|
||||
item.setTerminal(statusModel.getTerminalFlag());
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.njcn.rdms.module.project.service.project.execution;
|
||||
|
||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeInactiveReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeLogPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeLogRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeSaveReqVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 执行协办人 Service(B 模型 - 多行周期记录)。
|
||||
* <p>
|
||||
* 加入:每次 INSERT 一条新的活跃段(不复用旧段)+ 一条 join 日志。
|
||||
* 失效:当前活跃段 setRemovedAt + 永久保留 removedReason;写一条 inactive 日志。
|
||||
* 负责人转移:在 ProjectExecutionService.changeOwner 内同步写 owner_transfer_in/out 双向事件。
|
||||
*/
|
||||
public interface ProjectExecutionAssigneeService {
|
||||
|
||||
/**
|
||||
* 获取当前活跃协办人列表(仅 removed_at IS NULL 段)。
|
||||
*/
|
||||
List<ExecutionAssigneeRespVO> getExecutionAssigneeList(Long projectId, Long executionId);
|
||||
|
||||
/**
|
||||
* 加入执行协办人。同 userId 当前已活跃则抛 ALREADY_EXISTS;否则 INSERT 新活跃段,写 join 日志。
|
||||
*/
|
||||
Long createExecutionAssignee(Long projectId, Long executionId, ExecutionAssigneeSaveReqVO reqVO);
|
||||
|
||||
/**
|
||||
* 失效执行协办人。当前段必须存在且活跃;reason 必填(@NotBlank 已校验,service 不再重复)。
|
||||
* 失效后 removedReason 永久保留;写 inactive 日志。
|
||||
*/
|
||||
void inactiveExecutionAssignee(Long projectId, Long executionId, Long assigneeId, ExecutionAssigneeInactiveReqVO reqVO);
|
||||
|
||||
/**
|
||||
* 分页查询执行协办人变更历史,按 actionTime DESC, id DESC 排序。
|
||||
*/
|
||||
PageResult<ExecutionAssigneeLogRespVO> getExecutionAssigneeLogPage(Long projectId, Long executionId,
|
||||
ExecutionAssigneeLogPageReqVO reqVO);
|
||||
|
||||
/**
|
||||
* 写一条执行协办人变更日志(供 ProjectExecutionServiceImpl.changeOwner 调用)。
|
||||
* 只写用户编号与事件语义;昵称由查询阶段通过 AdminUserApi 按当前用户信息回填。
|
||||
*/
|
||||
void writeAssigneeLog(Long executionId, Long userId, String actionType, String reason);
|
||||
|
||||
}
|
||||
@@ -8,23 +8,23 @@ import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
|
||||
import com.njcn.rdms.module.project.constant.ProjectExecutionConstants;
|
||||
import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberInactiveReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberLogPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberLogRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberSaveReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeInactiveReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeLogPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeLogRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeSaveReqVO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionMemberDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionMemberLogDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeLogDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
|
||||
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionMemberLogMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionMemberMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeLogMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
||||
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
||||
@@ -52,22 +52,22 @@ import java.util.stream.Collectors;
|
||||
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
|
||||
/**
|
||||
* 执行成员 Service 实现类(B 模型 - 多行周期记录)。
|
||||
* 执行协办人 Service 实现类(B 模型 - 多行周期记录)。
|
||||
* <p>
|
||||
* 同 userId 在同执行内任意时刻只允许一段未失效;重新加入新插一条独立段,
|
||||
* 旧段的 removed_reason 永久保留不再覆盖。每次状态变更同步落 rdms_execution_member_log。
|
||||
* 旧段的 removed_reason 永久保留不再覆盖。每次状态变更同步落 rdms_execution_assignee_log。
|
||||
*/
|
||||
@Service
|
||||
public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMemberService {
|
||||
public class ProjectExecutionAssigneeServiceImpl implements ProjectExecutionAssigneeService {
|
||||
|
||||
@Resource
|
||||
private ProjectMapper projectMapper;
|
||||
@Resource
|
||||
private ProjectExecutionMapper projectExecutionMapper;
|
||||
@Resource
|
||||
private ExecutionMemberMapper executionMemberMapper;
|
||||
private ExecutionAssigneeMapper executionAssigneeMapper;
|
||||
@Resource
|
||||
private ExecutionMemberLogMapper executionMemberLogMapper;
|
||||
private ExecutionAssigneeLogMapper executionAssigneeLogMapper;
|
||||
@Resource
|
||||
private UserObjectRoleMapper userObjectRoleMapper;
|
||||
@Resource
|
||||
@@ -78,20 +78,20 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
|
||||
private AdminUserApi adminUserApi;
|
||||
|
||||
@Override
|
||||
public List<ExecutionMemberRespVO> getExecutionMemberList(Long projectId, Long executionId) {
|
||||
public List<ExecutionAssigneeRespVO> getExecutionAssigneeList(Long projectId, Long executionId) {
|
||||
validateProjectExists(projectId);
|
||||
validateExecutionExists(projectId, executionId);
|
||||
// 仅返当前活跃段(removed_at IS NULL);B 模型下同 userId 至多一段
|
||||
List<ExecutionMemberDO> activeList = executionMemberMapper.selectActiveListByExecutionId(executionId);
|
||||
List<ExecutionAssigneeDO> activeList = executionAssigneeMapper.selectActiveListByExecutionId(executionId);
|
||||
if (activeList.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
// 批量回查昵称,避免 N+1
|
||||
Map<Long, String> nicknameMap = loadUserNicknameMap(activeList.stream()
|
||||
.map(ExecutionMemberDO::getUserId).collect(Collectors.toCollection(LinkedHashSet::new)));
|
||||
return activeList.stream().map(member -> {
|
||||
ExecutionMemberRespVO respVO = buildMemberRespVO(member);
|
||||
respVO.setUserNickname(nicknameMap.get(member.getUserId()));
|
||||
.map(ExecutionAssigneeDO::getUserId).collect(Collectors.toCollection(LinkedHashSet::new)));
|
||||
return activeList.stream().map(assignee -> {
|
||||
ExecutionAssigneeRespVO respVO = buildAssigneeRespVO(assignee);
|
||||
respVO.setUserNickname(nicknameMap.get(assignee.getUserId()));
|
||||
return respVO;
|
||||
}).collect(Collectors.toList());
|
||||
}
|
||||
@@ -99,70 +99,70 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
|
||||
permission = ProjectExecutionConstants.PERMISSION_MEMBER)
|
||||
public Long createExecutionMember(Long projectId, Long executionId, ExecutionMemberSaveReqVO reqVO) {
|
||||
permission = ProjectExecutionConstants.PERMISSION_ASSIGNEE)
|
||||
public Long createExecutionAssignee(Long projectId, Long executionId, ExecutionAssigneeSaveReqVO reqVO) {
|
||||
validateEditableExecution(projectId, executionId);
|
||||
validateProjectMember(projectId, reqVO.getUserId());
|
||||
// B 模型:只看是否有当前活跃段;旧的失效段允许"重新加入"(新插一行)
|
||||
ExecutionMemberDO active = executionMemberMapper
|
||||
ExecutionAssigneeDO active = executionAssigneeMapper
|
||||
.selectActiveByExecutionIdAndUserId(executionId, reqVO.getUserId());
|
||||
if (active != null) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_ALREADY_EXISTS);
|
||||
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_ALREADY_EXISTS);
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
ExecutionMemberDO member = new ExecutionMemberDO();
|
||||
member.setExecutionId(executionId);
|
||||
member.setUserId(reqVO.getUserId());
|
||||
member.setJoinedAt(now);
|
||||
member.setRemovedAt(null);
|
||||
member.setRemovedReason(null);
|
||||
executionMemberMapper.insert(member);
|
||||
ExecutionAssigneeDO assignee = new ExecutionAssigneeDO();
|
||||
assignee.setExecutionId(executionId);
|
||||
assignee.setUserId(reqVO.getUserId());
|
||||
assignee.setJoinedAt(now);
|
||||
assignee.setRemovedAt(null);
|
||||
assignee.setRemovedReason(null);
|
||||
executionAssigneeMapper.insert(assignee);
|
||||
|
||||
// 双写:旧 BizAuditLog 通用审计 + 新 rdms_execution_member_log 业务事件流
|
||||
writeExecutionMemberAuditLog(executionId, ObjectActivityConstants.EXECUTION_MEMBER_ACTION_ADD, null, member, null);
|
||||
writeMemberLogInternal(executionId, reqVO.getUserId(),
|
||||
ObjectActivityConstants.EXECUTION_MEMBER_LOG_ACTION_JOIN, null, now);
|
||||
return member.getId();
|
||||
// 双写:旧 BizAuditLog 通用审计 + 新 rdms_execution_assignee_log 业务事件流
|
||||
writeExecutionAssigneeAuditLog(executionId, ObjectActivityConstants.EXECUTION_ASSIGNEE_ACTION_ADD, null, assignee, null);
|
||||
writeAssigneeLogInternal(executionId, reqVO.getUserId(),
|
||||
ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_JOIN, null, now);
|
||||
return assignee.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
|
||||
permission = ProjectExecutionConstants.PERMISSION_MEMBER)
|
||||
public void inactiveExecutionMember(Long projectId, Long executionId, Long memberId, ExecutionMemberInactiveReqVO reqVO) {
|
||||
permission = ProjectExecutionConstants.PERMISSION_ASSIGNEE)
|
||||
public void inactiveExecutionAssignee(Long projectId, Long executionId, Long assigneeId, ExecutionAssigneeInactiveReqVO reqVO) {
|
||||
validateEditableExecution(projectId, executionId);
|
||||
ExecutionMemberDO member = validateExecutionMemberExists(executionId, memberId);
|
||||
if (member.getRemovedAt() != null) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_NOT_ACTIVE);
|
||||
ExecutionAssigneeDO assignee = validateExecutionAssigneeExists(executionId, assigneeId);
|
||||
if (assignee.getRemovedAt() != null) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_NOT_ACTIVE);
|
||||
}
|
||||
ExecutionMemberDO before = cloneExecutionMember(member);
|
||||
ExecutionAssigneeDO before = cloneExecutionAssignee(assignee);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String reason = normalizeNullableText(reqVO.getReason());
|
||||
member.setRemovedAt(now);
|
||||
member.setRemovedReason(reason);
|
||||
executionMemberMapper.updateById(member);
|
||||
assignee.setRemovedAt(now);
|
||||
assignee.setRemovedReason(reason);
|
||||
executionAssigneeMapper.updateById(assignee);
|
||||
|
||||
writeExecutionMemberAuditLog(executionId, ObjectActivityConstants.EXECUTION_MEMBER_ACTION_REMOVE,
|
||||
before, member, reason);
|
||||
writeMemberLogInternal(executionId, member.getUserId(),
|
||||
ObjectActivityConstants.EXECUTION_MEMBER_LOG_ACTION_INACTIVE, reason, now);
|
||||
writeExecutionAssigneeAuditLog(executionId, ObjectActivityConstants.EXECUTION_ASSIGNEE_ACTION_REMOVE,
|
||||
before, assignee, reason);
|
||||
writeAssigneeLogInternal(executionId, assignee.getUserId(),
|
||||
ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_INACTIVE, reason, now);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<ExecutionMemberLogRespVO> getExecutionMemberLogPage(Long projectId, Long executionId,
|
||||
ExecutionMemberLogPageReqVO reqVO) {
|
||||
public PageResult<ExecutionAssigneeLogRespVO> getExecutionAssigneeLogPage(Long projectId, Long executionId,
|
||||
ExecutionAssigneeLogPageReqVO reqVO) {
|
||||
validateProjectExists(projectId);
|
||||
validateExecutionExists(projectId, executionId);
|
||||
PageResult<ExecutionMemberLogDO> page = executionMemberLogMapper.selectPageByExecutionId(executionId, reqVO);
|
||||
PageResult<ExecutionMemberLogRespVO> result = BeanUtils.toBean(page, ExecutionMemberLogRespVO.class);
|
||||
fillMemberLogNicknames(result.getList());
|
||||
PageResult<ExecutionAssigneeLogDO> page = executionAssigneeLogMapper.selectPageByExecutionId(executionId, reqVO);
|
||||
PageResult<ExecutionAssigneeLogRespVO> result = BeanUtils.toBean(page, ExecutionAssigneeLogRespVO.class);
|
||||
fillAssigneeLogNicknames(result.getList());
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeMemberLog(Long executionId, Long userId, String actionType, String reason) {
|
||||
writeMemberLogInternal(executionId, userId, actionType, reason, LocalDateTime.now());
|
||||
public void writeAssigneeLog(Long executionId, Long userId, String actionType, String reason) {
|
||||
writeAssigneeLogInternal(executionId, userId, actionType, reason, LocalDateTime.now());
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
@@ -207,10 +207,10 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
|
||||
|
||||
@VisibleForTesting
|
||||
void validateProjectMember(Long projectId, Long userId) {
|
||||
UserObjectRoleDO member = userObjectRoleMapper
|
||||
UserObjectRoleDO projectMember = userObjectRoleMapper
|
||||
.selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, userId);
|
||||
if (member == null) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_INVALID);
|
||||
if (projectMember == null) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_INVALID);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,22 +227,22 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
ExecutionMemberDO validateExecutionMemberExists(Long executionId, Long memberId) {
|
||||
ExecutionMemberDO member = executionMemberMapper.selectByIdAndExecutionId(memberId, executionId);
|
||||
if (member == null) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_NOT_EXISTS);
|
||||
ExecutionAssigneeDO validateExecutionAssigneeExists(Long executionId, Long assigneeId) {
|
||||
ExecutionAssigneeDO assignee = executionAssigneeMapper.selectByIdAndExecutionId(assigneeId, executionId);
|
||||
if (assignee == null) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_NOT_EXISTS);
|
||||
}
|
||||
return member;
|
||||
return assignee;
|
||||
}
|
||||
|
||||
private ExecutionMemberRespVO buildMemberRespVO(ExecutionMemberDO member) {
|
||||
ExecutionMemberRespVO respVO = new ExecutionMemberRespVO();
|
||||
respVO.setId(member.getId());
|
||||
respVO.setExecutionId(member.getExecutionId());
|
||||
respVO.setUserId(member.getUserId());
|
||||
respVO.setJoinedAt(member.getJoinedAt());
|
||||
respVO.setRemovedAt(member.getRemovedAt());
|
||||
respVO.setRemovedReason(member.getRemovedReason());
|
||||
private ExecutionAssigneeRespVO buildAssigneeRespVO(ExecutionAssigneeDO assignee) {
|
||||
ExecutionAssigneeRespVO respVO = new ExecutionAssigneeRespVO();
|
||||
respVO.setId(assignee.getId());
|
||||
respVO.setExecutionId(assignee.getExecutionId());
|
||||
respVO.setUserId(assignee.getUserId());
|
||||
respVO.setJoinedAt(assignee.getJoinedAt());
|
||||
respVO.setRemovedAt(assignee.getRemovedAt());
|
||||
respVO.setRemovedReason(assignee.getRemovedReason());
|
||||
return respVO;
|
||||
}
|
||||
|
||||
@@ -251,12 +251,12 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
|
||||
* <p>
|
||||
* 这里只用 userId / operatorUserId 查当前昵称,不依赖历史快照,避免用户改名后前后展示不一致。
|
||||
*/
|
||||
private void fillMemberLogNicknames(List<ExecutionMemberLogRespVO> logs) {
|
||||
private void fillAssigneeLogNicknames(List<ExecutionAssigneeLogRespVO> logs) {
|
||||
if (logs == null || logs.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Set<Long> userIds = new LinkedHashSet<>();
|
||||
for (ExecutionMemberLogRespVO log : logs) {
|
||||
for (ExecutionAssigneeLogRespVO log : logs) {
|
||||
if (log == null) {
|
||||
continue;
|
||||
}
|
||||
@@ -271,7 +271,7 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
|
||||
return;
|
||||
}
|
||||
Map<Long, String> nicknameMap = loadUserNicknameMap(userIds);
|
||||
for (ExecutionMemberLogRespVO log : logs) {
|
||||
for (ExecutionAssigneeLogRespVO log : logs) {
|
||||
if (log == null) {
|
||||
continue;
|
||||
}
|
||||
@@ -280,16 +280,16 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
|
||||
}
|
||||
}
|
||||
|
||||
private void writeExecutionMemberAuditLog(Long executionId,
|
||||
String actionType,
|
||||
ExecutionMemberDO before,
|
||||
ExecutionMemberDO after,
|
||||
String reason) {
|
||||
private void writeExecutionAssigneeAuditLog(Long executionId,
|
||||
String actionType,
|
||||
ExecutionAssigneeDO before,
|
||||
ExecutionAssigneeDO after,
|
||||
String reason) {
|
||||
BizAuditLogDO auditLog = new BizAuditLogDO();
|
||||
auditLog.setBizType(ProjectExecutionConstants.BIZ_TYPE);
|
||||
auditLog.setBizId(executionId);
|
||||
auditLog.setActionType(actionType);
|
||||
auditLog.setFieldChanges(buildExecutionMemberFieldChanges(before, after));
|
||||
auditLog.setFieldChanges(buildExecutionAssigneeFieldChanges(before, after));
|
||||
auditLog.setReason(reason);
|
||||
auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId());
|
||||
auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
|
||||
@@ -297,19 +297,19 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
|
||||
}
|
||||
|
||||
/**
|
||||
* 写一条 rdms_execution_member_log 事件。
|
||||
* 写一条 rdms_execution_assignee_log 事件。
|
||||
* 只写 ID 和事件语义字段,昵称展示由查询阶段按当前系统用户信息回填。
|
||||
*/
|
||||
private void writeMemberLogInternal(Long executionId, Long userId,
|
||||
String actionType, String reason, LocalDateTime when) {
|
||||
ExecutionMemberLogDO log = new ExecutionMemberLogDO();
|
||||
private void writeAssigneeLogInternal(Long executionId, Long userId,
|
||||
String actionType, String reason, LocalDateTime when) {
|
||||
ExecutionAssigneeLogDO log = new ExecutionAssigneeLogDO();
|
||||
log.setExecutionId(executionId);
|
||||
log.setUserId(userId);
|
||||
log.setActionType(actionType);
|
||||
log.setReason(reason);
|
||||
log.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId());
|
||||
log.setActionTime(when);
|
||||
executionMemberLogMapper.insert(log);
|
||||
executionAssigneeLogMapper.insert(log);
|
||||
}
|
||||
|
||||
private Map<Long, String> loadUserNicknameMap(Collection<Long> userIds) {
|
||||
@@ -325,23 +325,23 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
|
||||
return nicknameMap;
|
||||
}
|
||||
|
||||
private String buildExecutionMemberFieldChanges(ExecutionMemberDO before, ExecutionMemberDO after) {
|
||||
private String buildExecutionAssigneeFieldChanges(ExecutionAssigneeDO before, ExecutionAssigneeDO after) {
|
||||
Map<String, Object> fieldChanges = new LinkedHashMap<>();
|
||||
appendFieldChange(fieldChanges, "executionId", valueOf(before, ExecutionMemberDO::getExecutionId),
|
||||
valueOf(after, ExecutionMemberDO::getExecutionId));
|
||||
appendFieldChange(fieldChanges, "userId", valueOf(before, ExecutionMemberDO::getUserId),
|
||||
valueOf(after, ExecutionMemberDO::getUserId));
|
||||
appendFieldChange(fieldChanges, "joinedAt", valueOf(before, ExecutionMemberDO::getJoinedAt),
|
||||
valueOf(after, ExecutionMemberDO::getJoinedAt));
|
||||
appendFieldChange(fieldChanges, "removedAt", valueOf(before, ExecutionMemberDO::getRemovedAt),
|
||||
valueOf(after, ExecutionMemberDO::getRemovedAt));
|
||||
appendFieldChange(fieldChanges, "removedReason", valueOf(before, ExecutionMemberDO::getRemovedReason),
|
||||
valueOf(after, ExecutionMemberDO::getRemovedReason));
|
||||
appendFieldChange(fieldChanges, "executionId", valueOf(before, ExecutionAssigneeDO::getExecutionId),
|
||||
valueOf(after, ExecutionAssigneeDO::getExecutionId));
|
||||
appendFieldChange(fieldChanges, "userId", valueOf(before, ExecutionAssigneeDO::getUserId),
|
||||
valueOf(after, ExecutionAssigneeDO::getUserId));
|
||||
appendFieldChange(fieldChanges, "joinedAt", valueOf(before, ExecutionAssigneeDO::getJoinedAt),
|
||||
valueOf(after, ExecutionAssigneeDO::getJoinedAt));
|
||||
appendFieldChange(fieldChanges, "removedAt", valueOf(before, ExecutionAssigneeDO::getRemovedAt),
|
||||
valueOf(after, ExecutionAssigneeDO::getRemovedAt));
|
||||
appendFieldChange(fieldChanges, "removedReason", valueOf(before, ExecutionAssigneeDO::getRemovedReason),
|
||||
valueOf(after, ExecutionAssigneeDO::getRemovedReason));
|
||||
return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges);
|
||||
}
|
||||
|
||||
private ExecutionMemberDO cloneExecutionMember(ExecutionMemberDO source) {
|
||||
ExecutionMemberDO target = new ExecutionMemberDO();
|
||||
private ExecutionAssigneeDO cloneExecutionAssignee(ExecutionAssigneeDO source) {
|
||||
ExecutionAssigneeDO target = new ExecutionAssigneeDO();
|
||||
target.setId(source.getId());
|
||||
target.setExecutionId(source.getExecutionId());
|
||||
target.setUserId(source.getUserId());
|
||||
@@ -351,8 +351,8 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
|
||||
return target;
|
||||
}
|
||||
|
||||
private <T> T valueOf(ExecutionMemberDO member, Function<ExecutionMemberDO, T> getter) {
|
||||
return member == null ? null : getter.apply(member);
|
||||
private <T> T valueOf(ExecutionAssigneeDO assignee, Function<ExecutionAssigneeDO, T> getter) {
|
||||
return assignee == null ? null : getter.apply(assignee);
|
||||
}
|
||||
|
||||
private void appendFieldChange(Map<String, Object> fieldChanges, String fieldName, Object before, Object after) {
|
||||
@@ -1,49 +0,0 @@
|
||||
package com.njcn.rdms.module.project.service.project.execution;
|
||||
|
||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberInactiveReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberLogPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberLogRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberSaveReqVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 执行成员 Service(B 模型 - 多行周期记录)。
|
||||
* <p>
|
||||
* 加入:每次 INSERT 一条新的活跃段(不复用旧段)+ 一条 join 日志。
|
||||
* 失效:当前活跃段 setRemovedAt + 永久保留 removedReason;写一条 inactive 日志。
|
||||
* 负责人转移:在 ProjectExecutionService.changeOwner 内同步写 owner_transfer_in/out 双向事件。
|
||||
*/
|
||||
public interface ProjectExecutionMemberService {
|
||||
|
||||
/**
|
||||
* 获取当前活跃成员列表(仅 removed_at IS NULL 段)。
|
||||
*/
|
||||
List<ExecutionMemberRespVO> getExecutionMemberList(Long projectId, Long executionId);
|
||||
|
||||
/**
|
||||
* 加入执行成员。同 userId 当前已活跃则抛 ALREADY_EXISTS;否则 INSERT 新活跃段,写 join 日志。
|
||||
*/
|
||||
Long createExecutionMember(Long projectId, Long executionId, ExecutionMemberSaveReqVO reqVO);
|
||||
|
||||
/**
|
||||
* 失效执行成员。当前段必须存在且活跃;reason 必填(@NotBlank 已校验,service 不再重复)。
|
||||
* 失效后 removedReason 永久保留;写 inactive 日志。
|
||||
*/
|
||||
void inactiveExecutionMember(Long projectId, Long executionId, Long memberId, ExecutionMemberInactiveReqVO reqVO);
|
||||
|
||||
/**
|
||||
* 分页查询执行成员变更历史,按 actionTime DESC, id DESC 排序。
|
||||
*/
|
||||
PageResult<ExecutionMemberLogRespVO> getExecutionMemberLogPage(Long projectId, Long executionId,
|
||||
ExecutionMemberLogPageReqVO reqVO);
|
||||
|
||||
/**
|
||||
* 写一条执行成员变更日志(供 ProjectExecutionServiceImpl.changeOwner 调用)。
|
||||
* 只写用户编号与事件语义;昵称由查询阶段通过 AdminUserApi 按当前用户信息回填。
|
||||
*/
|
||||
void writeMemberLog(Long executionId, Long userId, String actionType, String reason);
|
||||
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionOwnerChangeReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionSaveReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionCreateReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionDeleteReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionUpdateReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusActionReqVO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO;
|
||||
|
||||
@@ -13,9 +15,9 @@ import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExec
|
||||
*/
|
||||
public interface ProjectExecutionService {
|
||||
|
||||
Long createExecution(Long projectId, ProjectExecutionSaveReqVO reqVO);
|
||||
Long createExecution(Long projectId, ProjectExecutionCreateReqVO reqVO);
|
||||
|
||||
void updateExecution(Long projectId, ProjectExecutionSaveReqVO reqVO);
|
||||
void updateExecution(Long projectId, ProjectExecutionUpdateReqVO reqVO);
|
||||
|
||||
ProjectExecutionDO getExecution(Long projectId, Long executionId);
|
||||
|
||||
@@ -33,6 +35,23 @@ public interface ProjectExecutionService {
|
||||
|
||||
void changeOwner(Long projectId, Long executionId, ProjectExecutionOwnerChangeReqVO reqVO);
|
||||
|
||||
/**
|
||||
* 删除执行(软删 + CAS 状态校验 + 三重确认)。
|
||||
* <p>仅初始态 pending 可删;权限码 project:execution:delete 推荐挂"项目负责人"角色。
|
||||
* <p>不级联软删 rdms_execution_assignee / rdms_task(与项目侧 deleteProject 风格一致,下挂记录通过 deleted=0 自然不可见)。
|
||||
*/
|
||||
void deleteExecution(Long projectId, Long executionId, ProjectExecutionDeleteReqVO reqVO);
|
||||
|
||||
void changeExecutionStatus(Long projectId, Long executionId, ProjectExecutionStatusActionReqVO reqVO);
|
||||
|
||||
/**
|
||||
* 任务状态变更回调(事件驱动)。
|
||||
* 任务侧在 auto_start / complete / cancel 三个动作的成功落库后调用本方法;pause / resume 不调用。
|
||||
* 执行侧据此自行分发响应(如自身 auto_start 联动、完成校验链、向项目 / 需求继续传播等)。
|
||||
* 调用规则见 docs/项目/任务状态流转设计.md §6.4 触发表。
|
||||
* 当前为预埋接口;具体响应在《执行闭环设计》中落地。
|
||||
*/
|
||||
void onTaskStatusChanged(Long executionId, Long taskId, String fromStatus, String toStatus,
|
||||
String actionCode, String reason);
|
||||
|
||||
}
|
||||
|
||||
@@ -12,36 +12,47 @@ import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionOwnerChangeReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionSaveReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionCreateReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionDeleteReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionUpdateReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusActionReqVO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionMemberDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionStatusLogDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
|
||||
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
|
||||
import com.njcn.rdms.module.project.constant.ProjectTaskConstants;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionMemberMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionStatusLogMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper;
|
||||
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
||||
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
|
||||
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
|
||||
import com.njcn.rdms.module.project.service.project.ProjectService;
|
||||
import com.njcn.rdms.module.project.service.project.permission.VisibilityScope;
|
||||
import com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver;
|
||||
import com.njcn.rdms.module.project.service.project.task.ProjectTaskService;
|
||||
import com.njcn.rdms.module.system.api.dict.DictDataApi;
|
||||
import com.njcn.rdms.module.system.api.user.AdminUserApi;
|
||||
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
@@ -63,6 +74,7 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
|
||||
* 执行主数据 Service 实现类。
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
|
||||
@Resource
|
||||
@@ -70,7 +82,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
@Resource
|
||||
private ProjectExecutionMapper projectExecutionMapper;
|
||||
@Resource
|
||||
private ExecutionMemberMapper executionMemberMapper;
|
||||
private ExecutionAssigneeMapper executionAssigneeMapper;
|
||||
@Resource
|
||||
private UserObjectRoleMapper userObjectRoleMapper;
|
||||
@Resource
|
||||
@@ -86,15 +98,28 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
@Resource
|
||||
private ProjectExecutionStatusViewService projectExecutionStatusViewService;
|
||||
@Resource
|
||||
private ProjectExecutionMemberService projectExecutionMemberService;
|
||||
private ProjectExecutionAssigneeService projectExecutionAssigneeService;
|
||||
@Resource
|
||||
private AdminUserApi adminUserApi;
|
||||
@Resource
|
||||
private ProjectTaskMapper projectTaskMapper;
|
||||
@Resource
|
||||
private ProjectService projectService;
|
||||
@Resource
|
||||
private VisibilityScopeResolver visibilityScopeResolver;
|
||||
/**
|
||||
* 任务服务:执行 cancel / pause / resume 时级联调任务侧 internal 入口。
|
||||
* 与 ProjectTaskService 互相依赖(任务侧已注入 ProjectExecutionService 用于通知执行),用 @Lazy 打破循环。
|
||||
*/
|
||||
@Resource
|
||||
@Lazy
|
||||
private ProjectTaskService projectTaskService;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
|
||||
permission = ProjectExecutionConstants.PERMISSION_CREATE)
|
||||
public Long createExecution(Long projectId, ProjectExecutionSaveReqVO reqVO) {
|
||||
public Long createExecution(Long projectId, ProjectExecutionCreateReqVO reqVO) {
|
||||
validateEditableProject(projectId);
|
||||
validateRequirementIdPhaseOne(reqVO.getProjectRequirementId());
|
||||
String executionName = normalizeRequiredName(reqVO.getExecutionName());
|
||||
@@ -102,7 +127,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
validateOwnerIsActiveProjectMember(projectId, reqVO.getOwnerId());
|
||||
String executionType = normalizeRequiredExecutionType(reqVO.getExecutionType());
|
||||
validateExecutionType(executionType);
|
||||
Set<Long> memberUserIds = normalizeRequiredMemberUserIds(reqVO.getMemberUserIds());
|
||||
Set<Long> assigneeUserIds = normalizeRequiredAssigneeUserIds(reqVO.getAssigneeUserIds());
|
||||
validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期");
|
||||
|
||||
ProjectExecutionDO execution = new ProjectExecutionDO();
|
||||
@@ -123,7 +148,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
|
||||
writeExecutionAuditLog(execution, ObjectActivityConstants.EXECUTION_ACTION_CREATE, null,
|
||||
initialStatusCode, buildExecutionFieldChanges(null, execution), null);
|
||||
createExecutionMembers(execution.getId(), projectId, memberUserIds);
|
||||
createExecutionAssignees(execution.getId(), projectId, assigneeUserIds);
|
||||
return execution.getId();
|
||||
}
|
||||
|
||||
@@ -131,7 +156,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
|
||||
permission = ProjectExecutionConstants.PERMISSION_UPDATE)
|
||||
public void updateExecution(Long projectId, ProjectExecutionSaveReqVO reqVO) {
|
||||
public void updateExecution(Long projectId, ProjectExecutionUpdateReqVO reqVO) {
|
||||
if (reqVO.getId() == null) {
|
||||
throw invalidParamException("执行编号不能为空");
|
||||
}
|
||||
@@ -141,15 +166,14 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
validateRequirementIdPhaseOne(reqVO.getProjectRequirementId());
|
||||
String executionName = normalizeRequiredName(reqVO.getExecutionName());
|
||||
validateExecutionNameUnique(projectId, execution.getId(), executionName);
|
||||
validateOwnerIsActiveProjectMember(projectId, reqVO.getOwnerId());
|
||||
String executionType = normalizeRequiredExecutionType(reqVO.getExecutionType());
|
||||
validateExecutionType(executionType);
|
||||
validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期");
|
||||
|
||||
// 不再处理 ownerId:换负责人必须走 /change-owner 端点(spec §6.3 契约收口)
|
||||
ProjectExecutionDO before = cloneExecution(execution);
|
||||
execution.setExecutionName(executionName);
|
||||
execution.setExecutionType(executionType);
|
||||
execution.setOwnerId(reqVO.getOwnerId());
|
||||
execution.setProjectRequirementId(null);
|
||||
execution.setPlannedStartDate(reqVO.getPlannedStartDate());
|
||||
execution.setPlannedEndDate(reqVO.getPlannedEndDate());
|
||||
@@ -169,13 +193,24 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
@Override
|
||||
public PageResult<ProjectExecutionDO> getExecutionPage(Long projectId, ProjectExecutionPageReqVO reqVO) {
|
||||
validateProjectExists(projectId);
|
||||
return projectExecutionMapper.selectPageByProjectId(projectId, reqVO);
|
||||
// 数据可见性:项目经理看全部;非经理按"我 owner 的执行 ∪ 我活跃协办的执行"过滤
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
VisibilityScope scope = visibilityScopeResolver.resolveForProject(projectId, userId);
|
||||
return projectExecutionMapper.selectPageByProjectId(projectId, scope, reqVO);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProjectExecutionRespVO getExecutionRespVO(Long projectId, Long executionId) {
|
||||
ProjectExecutionDO execution = getExecution(projectId, executionId);
|
||||
// 可见性卡断:项目经理放行;否则 executionId 必须在 scope.executionIds 中。
|
||||
// 未命中按"执行不存在"语义返回,不暴露存在性。
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
VisibilityScope scope = visibilityScopeResolver.resolveForProject(projectId, userId);
|
||||
if (!scope.seesAll() && !scope.executionIds().contains(executionId)) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_NOT_EXISTS);
|
||||
}
|
||||
ProjectExecutionRespVO respVO = BeanUtils.toBean(execution, ProjectExecutionRespVO.class);
|
||||
respVO.setProgressRate(loadExecutionProgress(projectId, executionId));
|
||||
applyLifecycle(respVO);
|
||||
respVO.setOwnerNickname(loadOwnerNickname(execution.getOwnerId()));
|
||||
return respVO;
|
||||
@@ -189,13 +224,24 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
if (list == null || list.isEmpty()) {
|
||||
return voPageResult;
|
||||
}
|
||||
// 批量补负责人昵称,避免 N+1(lifecycle 字段保持原行为不在分页装配)
|
||||
fillExecutionProgress(projectId, list);
|
||||
// 批量补负责人昵称,避免 N+1
|
||||
Set<Long> ownerIds = list.stream()
|
||||
.map(ProjectExecutionRespVO::getOwnerId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
Map<Long, String> nicknameMap = loadOwnerNicknameMap(ownerIds);
|
||||
list.forEach(vo -> vo.setOwnerNickname(nicknameMap.get(vo.getOwnerId())));
|
||||
list.forEach(vo -> {
|
||||
vo.setOwnerNickname(nicknameMap.get(vo.getOwnerId()));
|
||||
// 列表行 cancel/pause/resume/complete 按钮依赖 availableActions,与详情同款装配 lifecycle。
|
||||
// 单行装配失败做兜底降级(status_model 缺失等脏数据),避免影响整页返回。
|
||||
try {
|
||||
applyLifecycle(vo);
|
||||
} catch (Exception e) {
|
||||
log.warn("execution lifecycle apply failed in page assembly. executionId={}, statusCode={}, error={}",
|
||||
vo.getId(), vo.getStatusCode(), e.getMessage());
|
||||
}
|
||||
});
|
||||
return voPageResult;
|
||||
}
|
||||
|
||||
@@ -220,11 +266,44 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
execution.getStatusCode(), buildExecutionFieldChanges(before, execution), reason);
|
||||
// 双向写入成员变更历史:原 owner 转出 / 新 owner 转入;oldOwnerId 为 null 时跳过转出(首次设负责人场景)
|
||||
if (oldOwnerId != null) {
|
||||
projectExecutionMemberService.writeMemberLog(executionId, oldOwnerId,
|
||||
ObjectActivityConstants.EXECUTION_MEMBER_LOG_ACTION_OWNER_TRANSFER_OUT, reason);
|
||||
projectExecutionAssigneeService.writeAssigneeLog(executionId, oldOwnerId,
|
||||
ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_OUT, reason);
|
||||
}
|
||||
projectExecutionAssigneeService.writeAssigneeLog(executionId, reqVO.getNewOwnerId(),
|
||||
ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_IN, reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
|
||||
permission = ProjectExecutionConstants.PERMISSION_DELETE)
|
||||
public void deleteExecution(Long projectId, Long executionId, ProjectExecutionDeleteReqVO reqVO) {
|
||||
validateEditableProject(projectId);
|
||||
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
|
||||
validateDeleteConfirmText(reqVO.getConfirmText());
|
||||
if (!Objects.equals(execution.getExecutionName(), reqVO.getExecutionName().trim())) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_DELETE_NAME_MISMATCH);
|
||||
}
|
||||
// 仅初始态可删(与项目侧 deleteProject 同款规则)
|
||||
String initialStatusCode = getInitialExecutionStatusCode();
|
||||
String fromStatus = execution.getStatusCode();
|
||||
if (!Objects.equals(fromStatus, initialStatusCode)) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_NOT_ALLOW_DELETE);
|
||||
}
|
||||
String reason = reqVO.getReason().trim();
|
||||
int deleteCount = projectExecutionMapper.deleteByIdAndStatus(executionId, fromStatus);
|
||||
if (deleteCount != 1) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_CONCURRENT_MODIFIED);
|
||||
}
|
||||
writeExecutionAuditLog(execution, ObjectActivityConstants.EXECUTION_ACTION_DELETE, fromStatus, null, null, reason);
|
||||
}
|
||||
|
||||
private void validateDeleteConfirmText(String confirmText) {
|
||||
String normalizedConfirmText = normalizeNullableText(confirmText);
|
||||
if (normalizedConfirmText == null
|
||||
|| !ProjectExecutionConstants.DELETE_CONFIRM_TEXTS.contains(normalizedConfirmText)) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_DELETE_CONFIRM_TEXT_INVALID);
|
||||
}
|
||||
projectExecutionMemberService.writeMemberLog(executionId, reqVO.getNewOwnerId(),
|
||||
ObjectActivityConstants.EXECUTION_MEMBER_LOG_ACTION_OWNER_TRANSFER_IN, reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -236,9 +315,15 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
|
||||
String actionCode = normalizeRequiredActionCode(reqVO.getActionCode());
|
||||
String reason = normalizeNullableText(reqVO.getReason());
|
||||
// 负责人独占动作:complete / cancel / pause / resume 必须由执行负责人触发
|
||||
validateOwnerForAction(execution, actionCode);
|
||||
String fromStatus = execution.getStatusCode();
|
||||
ObjectStatusTransitionDO transition = validateExecutionTransition(fromStatus, actionCode, reason);
|
||||
String toStatus = transition.getToStatusCode();
|
||||
// complete 前置:执行下所有任务必须终态
|
||||
if ("complete".equals(actionCode)) {
|
||||
validateAllTasksTerminal(executionId);
|
||||
}
|
||||
|
||||
int updateCount = projectExecutionMapper.updateStatusByIdAndStatus(executionId, fromStatus, toStatus, reason);
|
||||
if (updateCount != 1) {
|
||||
@@ -246,10 +331,18 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
}
|
||||
execution.setStatusCode(toStatus);
|
||||
execution.setLastStatusReason(reason);
|
||||
applyExecutionActualDateByStatusAction(execution, actionCode, toStatus);
|
||||
|
||||
writeExecutionStatusLog(execution, actionCode, fromStatus, toStatus, reason);
|
||||
writeExecutionAuditLog(execution, actionCode, fromStatus, toStatus, null, reason);
|
||||
maybeFillActualDates(execution, fromStatus, toStatus);
|
||||
|
||||
// 完成动作:兜底把执行进度刷到 100%
|
||||
if ("complete".equals(actionCode)) {
|
||||
forceCompleteProgress(execution);
|
||||
}
|
||||
|
||||
// 取消 / 暂停 / 恢复:级联执行下任务
|
||||
cascadeTasksIfNeeded(executionId, actionCode, reason);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
@@ -376,50 +469,50 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
}
|
||||
}
|
||||
|
||||
private void createExecutionMembers(Long executionId, Long projectId, Set<Long> memberUserIds) {
|
||||
private void createExecutionAssignees(Long executionId, Long projectId, Set<Long> assigneeUserIds) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (Long memberUserId : memberUserIds) {
|
||||
validateExecutionMemberProjectScope(projectId, memberUserId);
|
||||
ExecutionMemberDO member = new ExecutionMemberDO();
|
||||
for (Long assigneeUserId : assigneeUserIds) {
|
||||
validateExecutionAssigneeProjectScope(projectId, assigneeUserId);
|
||||
ExecutionAssigneeDO member = new ExecutionAssigneeDO();
|
||||
member.setExecutionId(executionId);
|
||||
member.setUserId(memberUserId);
|
||||
member.setUserId(assigneeUserId);
|
||||
member.setJoinedAt(now);
|
||||
member.setRemovedAt(null);
|
||||
member.setRemovedReason(null);
|
||||
executionMemberMapper.insert(member);
|
||||
writeExecutionAuditLogByBizId(executionId, ObjectActivityConstants.EXECUTION_MEMBER_ACTION_ADD,
|
||||
null, null, buildExecutionMemberFieldChanges(null, member), null);
|
||||
executionAssigneeMapper.insert(member);
|
||||
writeExecutionAuditLogByBizId(executionId, ObjectActivityConstants.EXECUTION_ASSIGNEE_ACTION_ADD,
|
||||
null, null, buildExecutionAssigneeFieldChanges(null, member), null);
|
||||
// B 模型:每次创建活跃段同步写一条 join 事件,确保活跃成员都能在变更历史中追溯加入时间
|
||||
projectExecutionMemberService.writeMemberLog(executionId, memberUserId,
|
||||
ObjectActivityConstants.EXECUTION_MEMBER_LOG_ACTION_JOIN, null);
|
||||
projectExecutionAssigneeService.writeAssigneeLog(executionId, assigneeUserId,
|
||||
ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_JOIN, null);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateExecutionMemberProjectScope(Long projectId, Long userId) {
|
||||
private void validateExecutionAssigneeProjectScope(Long projectId, Long userId) {
|
||||
UserObjectRoleDO member = userObjectRoleMapper
|
||||
.selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, userId);
|
||||
if (member == null) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_INVALID);
|
||||
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_INVALID);
|
||||
}
|
||||
}
|
||||
|
||||
private Set<Long> normalizeMemberUserIds(List<Long> memberUserIds) {
|
||||
if (memberUserIds == null || memberUserIds.isEmpty()) {
|
||||
private Set<Long> normalizeAssigneeUserIds(List<Long> assigneeUserIds) {
|
||||
if (assigneeUserIds == null || assigneeUserIds.isEmpty()) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
Set<Long> normalizedUserIds = new LinkedHashSet<>();
|
||||
for (Long memberUserId : memberUserIds) {
|
||||
if (memberUserId != null) {
|
||||
normalizedUserIds.add(memberUserId);
|
||||
for (Long assigneeUserId : assigneeUserIds) {
|
||||
if (assigneeUserId != null) {
|
||||
normalizedUserIds.add(assigneeUserId);
|
||||
}
|
||||
}
|
||||
return normalizedUserIds;
|
||||
}
|
||||
|
||||
private Set<Long> normalizeRequiredMemberUserIds(List<Long> memberUserIds) {
|
||||
Set<Long> normalizedUserIds = normalizeMemberUserIds(memberUserIds);
|
||||
private Set<Long> normalizeRequiredAssigneeUserIds(List<Long> assigneeUserIds) {
|
||||
Set<Long> normalizedUserIds = normalizeAssigneeUserIds(assigneeUserIds);
|
||||
if (normalizedUserIds.isEmpty()) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_REQUIRED);
|
||||
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_REQUIRED);
|
||||
}
|
||||
return normalizedUserIds;
|
||||
}
|
||||
@@ -512,18 +605,18 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges);
|
||||
}
|
||||
|
||||
private String buildExecutionMemberFieldChanges(ExecutionMemberDO before, ExecutionMemberDO after) {
|
||||
private String buildExecutionAssigneeFieldChanges(ExecutionAssigneeDO before, ExecutionAssigneeDO after) {
|
||||
Map<String, Object> fieldChanges = new LinkedHashMap<>();
|
||||
appendFieldChange(fieldChanges, "executionId", valueOf(before, ExecutionMemberDO::getExecutionId),
|
||||
valueOf(after, ExecutionMemberDO::getExecutionId));
|
||||
appendFieldChange(fieldChanges, "userId", valueOf(before, ExecutionMemberDO::getUserId),
|
||||
valueOf(after, ExecutionMemberDO::getUserId));
|
||||
appendFieldChange(fieldChanges, "joinedAt", valueOf(before, ExecutionMemberDO::getJoinedAt),
|
||||
valueOf(after, ExecutionMemberDO::getJoinedAt));
|
||||
appendFieldChange(fieldChanges, "removedAt", valueOf(before, ExecutionMemberDO::getRemovedAt),
|
||||
valueOf(after, ExecutionMemberDO::getRemovedAt));
|
||||
appendFieldChange(fieldChanges, "removedReason", valueOf(before, ExecutionMemberDO::getRemovedReason),
|
||||
valueOf(after, ExecutionMemberDO::getRemovedReason));
|
||||
appendFieldChange(fieldChanges, "executionId", valueOf(before, ExecutionAssigneeDO::getExecutionId),
|
||||
valueOf(after, ExecutionAssigneeDO::getExecutionId));
|
||||
appendFieldChange(fieldChanges, "userId", valueOf(before, ExecutionAssigneeDO::getUserId),
|
||||
valueOf(after, ExecutionAssigneeDO::getUserId));
|
||||
appendFieldChange(fieldChanges, "joinedAt", valueOf(before, ExecutionAssigneeDO::getJoinedAt),
|
||||
valueOf(after, ExecutionAssigneeDO::getJoinedAt));
|
||||
appendFieldChange(fieldChanges, "removedAt", valueOf(before, ExecutionAssigneeDO::getRemovedAt),
|
||||
valueOf(after, ExecutionAssigneeDO::getRemovedAt));
|
||||
appendFieldChange(fieldChanges, "removedReason", valueOf(before, ExecutionAssigneeDO::getRemovedReason),
|
||||
valueOf(after, ExecutionAssigneeDO::getRemovedReason));
|
||||
return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges);
|
||||
}
|
||||
|
||||
@@ -531,7 +624,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
return execution == null ? null : getter.apply(execution);
|
||||
}
|
||||
|
||||
private <T> T valueOf(ExecutionMemberDO member, Function<ExecutionMemberDO, T> getter) {
|
||||
private <T> T valueOf(ExecutionAssigneeDO member, Function<ExecutionAssigneeDO, T> getter) {
|
||||
return member == null ? null : getter.apply(member);
|
||||
}
|
||||
|
||||
@@ -578,8 +671,9 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
}
|
||||
|
||||
private void applyLifecycle(ProjectExecutionRespVO respVO) {
|
||||
// 传入 ownerId 用于 availableActions 的 owner-only 字段硬卡过滤(spec §7.1)
|
||||
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView lifecycle =
|
||||
projectExecutionStatusViewService.getLifecycle(respVO.getStatusCode());
|
||||
projectExecutionStatusViewService.getLifecycle(respVO.getStatusCode(), respVO.getOwnerId());
|
||||
respVO.setStatusName(lifecycle.statusName());
|
||||
respVO.setTerminal(lifecycle.terminal());
|
||||
respVO.setAllowEdit(lifecycle.allowEdit());
|
||||
@@ -595,6 +689,76 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
return user == null ? null : user.getNickname();
|
||||
}
|
||||
|
||||
private BigDecimal loadExecutionProgress(Long projectId, Long executionId) {
|
||||
return normalizeProgress(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId));
|
||||
}
|
||||
|
||||
private void fillExecutionProgress(Long projectId, List<ProjectExecutionRespVO> list) {
|
||||
Set<Long> executionIds = list.stream()
|
||||
.map(ProjectExecutionRespVO::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
Map<Long, BigDecimal> progressMap = loadExecutionProgressMap(projectId, executionIds);
|
||||
list.forEach(vo -> vo.setProgressRate(progressMap.getOrDefault(vo.getId(), normalizeProgress(null))));
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表口径批量聚合:一次 GROUP BY 取本页所有执行的一级任务平均进度。
|
||||
* 未命中的 executionId(执行下无一级任务)不入 map,由调用方 normalizeProgress 兜底为 0.00。
|
||||
*/
|
||||
private Map<Long, BigDecimal> loadExecutionProgressMap(Long projectId, Collection<Long> executionIds) {
|
||||
if (executionIds == null || executionIds.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
List<Map<String, Object>> rows = projectTaskMapper
|
||||
.selectRootTaskAvgProgressGroupByExecutionIds(projectId, executionIds);
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
Map<Long, BigDecimal> result = new HashMap<>(rows.size());
|
||||
for (Map<String, Object> row : rows) {
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
Long executionId = toLong(row.getOrDefault("executionId", row.get("execution_id")));
|
||||
BigDecimal progress = toBigDecimal(row.getOrDefault("progressRate", row.get("progress_rate")));
|
||||
if (executionId != null) {
|
||||
result.put(executionId, normalizeProgress(progress));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Long toLong(Object value) {
|
||||
if (value instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
if (value instanceof String text && StringUtils.hasText(text)) {
|
||||
return Long.valueOf(text.trim());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private BigDecimal toBigDecimal(Object value) {
|
||||
if (value instanceof BigDecimal decimal) {
|
||||
return decimal;
|
||||
}
|
||||
if (value instanceof Number number) {
|
||||
return new BigDecimal(number.toString());
|
||||
}
|
||||
if (value instanceof String text && StringUtils.hasText(text)) {
|
||||
return new BigDecimal(text.trim());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private BigDecimal normalizeProgress(BigDecimal progress) {
|
||||
if (progress == null) {
|
||||
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
return progress.setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private Map<Long, String> loadOwnerNicknameMap(Collection<Long> ownerIds) {
|
||||
if (ownerIds == null || ownerIds.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
@@ -608,20 +772,173 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
return nicknameMap;
|
||||
}
|
||||
|
||||
private void applyExecutionActualDateByStatusAction(ProjectExecutionDO execution, String actionCode, String toStatus) {
|
||||
/**
|
||||
* 通用语义位驱动的实际日期回填:
|
||||
* - 首次离开初始态(fromStatus.initialFlag=true)且 actualStartDate 未填时,写今天
|
||||
* - 进入终态(toStatus.terminalFlag=true)且 actualEndDate 未填时,写今天
|
||||
* 与任务侧 maybeFillActualDates 同款风格,消除原 applyExecutionActualDateByStatusAction 的硬编码 actionCode 判断。
|
||||
*/
|
||||
private void maybeFillActualDates(ProjectExecutionDO execution, String fromStatus, String toStatus) {
|
||||
LocalDate today = LocalDate.now();
|
||||
boolean changed = false;
|
||||
if ("start".equals(actionCode) && execution.getActualStartDate() == null) {
|
||||
execution.setActualStartDate(today);
|
||||
changed = true;
|
||||
if (execution.getActualStartDate() == null) {
|
||||
ObjectStatusModelDO fromModel = objectStatusModelMapper
|
||||
.selectByObjectTypeAndStatusCodeEnabled(ProjectExecutionConstants.OBJECT_TYPE, fromStatus);
|
||||
if (fromModel != null && Boolean.TRUE.equals(fromModel.getInitialFlag())) {
|
||||
execution.setActualStartDate(today);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if ("completed".equals(toStatus) && execution.getActualEndDate() == null) {
|
||||
execution.setActualEndDate(today);
|
||||
changed = true;
|
||||
if (execution.getActualEndDate() == null) {
|
||||
ObjectStatusModelDO toModel = objectStatusModelMapper
|
||||
.selectByObjectTypeAndStatusCodeEnabled(ProjectExecutionConstants.OBJECT_TYPE, toStatus);
|
||||
if (toModel != null && Boolean.TRUE.equals(toModel.getTerminalFlag())) {
|
||||
execution.setActualEndDate(today);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
projectExecutionMapper.updateById(execution);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成动作兜底:把执行进度刷到 100%。
|
||||
* 正常流程下前端会确保点完成时进度已经是 100%;此处为防止前端绕过 / bug 导致脏数据。
|
||||
*/
|
||||
private void forceCompleteProgress(ProjectExecutionDO execution) {
|
||||
BigDecimal full = BigDecimal.valueOf(100);
|
||||
BigDecimal current = execution.getProgressRate() == null ? BigDecimal.ZERO : execution.getProgressRate();
|
||||
if (full.compareTo(current) == 0) {
|
||||
return;
|
||||
}
|
||||
execution.setProgressRate(full);
|
||||
projectExecutionMapper.updateById(execution);
|
||||
}
|
||||
|
||||
/**
|
||||
* 负责人独占动作校验:complete / cancel / pause / resume 必须由执行负责人触发。
|
||||
* 系统级别的 internalAutoStartExecutionByTask 不经此校验。
|
||||
*/
|
||||
private void validateOwnerForAction(ProjectExecutionDO execution, String actionCode) {
|
||||
if (!isOwnerOnlyAction(actionCode)) {
|
||||
return;
|
||||
}
|
||||
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
if (!Objects.equals(loginUserId, execution.getOwnerId())) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_OWNER_ONLY,
|
||||
resolveActionDisplayName(actionCode));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isOwnerOnlyAction(String actionCode) {
|
||||
return "complete".equals(actionCode)
|
||||
|| "cancel".equals(actionCode)
|
||||
|| "pause".equals(actionCode)
|
||||
|| "resume".equals(actionCode);
|
||||
}
|
||||
|
||||
private String resolveActionDisplayName(String actionCode) {
|
||||
return switch (actionCode) {
|
||||
case "complete" -> "完成";
|
||||
case "cancel" -> "取消";
|
||||
case "pause" -> "暂停";
|
||||
case "resume" -> "恢复";
|
||||
default -> actionCode;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成执行前置校验:执行下所有任务必须已经进入终态(completed / cancelled)。
|
||||
*/
|
||||
private void validateAllTasksTerminal(Long executionId) {
|
||||
List<String> terminalStatusCodes = objectStatusModelMapper
|
||||
.selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
|
||||
Integer openTaskCount = projectTaskMapper.countByExecutionIdNotInStatus(executionId, terminalStatusCodes);
|
||||
if (openTaskCount != null && openTaskCount > 0) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_COMPLETE_TASKS_REQUIRED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 级联触发:根据 actionCode 调任务侧 internal 入口批量处理执行下任务。
|
||||
*/
|
||||
private void cascadeTasksIfNeeded(Long executionId, String actionCode, String executionReason) {
|
||||
switch (actionCode) {
|
||||
case "cancel" -> projectTaskService.cascadeCancelTasksByExecutionId(executionId, executionReason);
|
||||
case "pause" -> projectTaskService.cascadePauseTasksByExecutionId(executionId, executionReason);
|
||||
case "resume" -> projectTaskService.cascadeResumeTasksByExecutionId(executionId, executionReason);
|
||||
default -> {
|
||||
// 其他动作不级联
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 由任务 auto_start 事件驱动的执行自动开始。
|
||||
* 仅在执行处于初始态时触发;失败均 WARN 静默 return,不抛错(保证不阻断任务事件主路径)。
|
||||
* 成功后联动项目 auto_start(同事务,若项目联动抛错则整体回滚 —— 与任务侧 createTask 调用 autoStartProjectIfPending 同款)。
|
||||
*/
|
||||
private void internalAutoStartExecutionByTask(Long executionId) {
|
||||
if (executionId == null) {
|
||||
return;
|
||||
}
|
||||
ProjectExecutionDO current = projectExecutionMapper.selectById(executionId);
|
||||
if (current == null) {
|
||||
return;
|
||||
}
|
||||
String fromStatus = current.getStatusCode();
|
||||
ObjectStatusModelDO fromModel = objectStatusModelMapper
|
||||
.selectByObjectTypeAndStatusCodeEnabled(ProjectExecutionConstants.OBJECT_TYPE, fromStatus);
|
||||
if (fromModel == null || !Boolean.TRUE.equals(fromModel.getInitialFlag())) {
|
||||
return;
|
||||
}
|
||||
String actionCode = "auto_start";
|
||||
ObjectStatusTransitionDO transition = objectStatusTransitionMapper
|
||||
.selectByObjectTypeAndFromStatusAndAction(ProjectExecutionConstants.OBJECT_TYPE, fromStatus, actionCode);
|
||||
if (transition == null) {
|
||||
log.warn("execution auto-start skipped: transition not found. executionId={}, fromStatus={}",
|
||||
executionId, fromStatus);
|
||||
return;
|
||||
}
|
||||
String toStatus = transition.getToStatusCode();
|
||||
String reason = "由任务推进自动触发";
|
||||
int updateCount = projectExecutionMapper.updateStatusByIdAndStatus(executionId, fromStatus, toStatus, reason);
|
||||
if (updateCount != 1) {
|
||||
log.warn("execution auto-start skipped: concurrent modification. executionId={}", executionId);
|
||||
return;
|
||||
}
|
||||
current.setStatusCode(toStatus);
|
||||
current.setLastStatusReason(reason);
|
||||
|
||||
writeExecutionStatusLog(current, actionCode, fromStatus, toStatus, reason);
|
||||
writeExecutionAuditLog(current, actionCode, fromStatus, toStatus, null, reason);
|
||||
maybeFillActualDates(current, fromStatus, toStatus);
|
||||
|
||||
// 联动项目 auto_start(项目仍在 pending 时跟着进 active)
|
||||
projectService.autoStartProjectIfPending(current.getProjectId(),
|
||||
ObjectActivityConstants.PROJECT_TRIGGER_EXECUTION_AUTO_START);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTaskStatusChanged(Long executionId, Long taskId, String fromStatus, String toStatus,
|
||||
String actionCode, String reason) {
|
||||
log.info("[onTaskStatusChanged] executionId={}, taskId={}, action={}, status: {} -> {}, reason={}",
|
||||
executionId, taskId, actionCode, fromStatus, toStatus, reason);
|
||||
if (executionId == null) {
|
||||
return;
|
||||
}
|
||||
// 任务 auto_start:执行如果还在 pending,自动跟着进入进行中(事件驱动,§8.1)
|
||||
if ("auto_start".equals(actionCode)) {
|
||||
try {
|
||||
internalAutoStartExecutionByTask(executionId);
|
||||
} catch (Exception e) {
|
||||
// 副作用失败不阻断任务事件主路径
|
||||
log.warn("execution auto-start failed during onTaskStatusChanged. executionId={}, error={}",
|
||||
executionId, e.getMessage());
|
||||
}
|
||||
}
|
||||
// complete / cancel:当前仅 INFO 日志,不立即响应(§8.1 拉模式:项目主动点完成时统一扫表校验)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.njcn.rdms.module.project.service.project.execution;
|
||||
|
||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
|
||||
import com.njcn.rdms.module.project.constant.ProjectExecutionConstants;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionLifecycleActionRespVO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
|
||||
@@ -12,18 +14,38 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
|
||||
/**
|
||||
* 执行状态生命周期视图。
|
||||
* <p>
|
||||
* {@code getLifecycle} 返回的 {@code availableActions} 按 spec §7.1 实现约束过滤:
|
||||
* <ol>
|
||||
* <li>剔除系统级动作 {@code auto_start}(不出现在 UI 按钮)。</li>
|
||||
* <li>对 owner-only 状态推进动作(complete / cancel / pause / resume),按 {@code execution.ownerId == currentUserId} 字段硬卡过滤,
|
||||
* 与 {@link ProjectExecutionServiceImpl} 中 {@code validateOwnerForAction} 同款判定。</li>
|
||||
* </ol>
|
||||
* 非状态动作(delete / change-owner / update / assignee)的权限码 / 字段过滤未纳入本字段,
|
||||
* 前端按各动作对应权限码与 owner 字段独立判断(spec §6.5 允许条件矩阵);
|
||||
* 全量化纳入 {@code availableActions} 留待后续阶段统一改造。
|
||||
*/
|
||||
@Service
|
||||
public class ProjectExecutionStatusViewService {
|
||||
|
||||
/**
|
||||
* 状态推进动作中要求 owner-only 字段硬卡的集合,与 ProjectExecutionServiceImpl#validateOwnerForAction 一致。
|
||||
*/
|
||||
private static final Set<String> OWNER_ONLY_ACTIONS = Set.of("complete", "cancel", "pause", "resume");
|
||||
|
||||
@Resource
|
||||
private ObjectStatusModelMapper objectStatusModelMapper;
|
||||
@Resource
|
||||
private ObjectStatusTransitionMapper objectStatusTransitionMapper;
|
||||
|
||||
public ProjectExecutionLifecycleView getLifecycle(String statusCode) {
|
||||
public ProjectExecutionLifecycleView getLifecycle(String statusCode, Long ownerId) {
|
||||
ObjectStatusModelDO statusModel = objectStatusModelMapper
|
||||
.selectByObjectTypeAndStatusCodeEnabled(ProjectExecutionConstants.OBJECT_TYPE, statusCode);
|
||||
if (statusModel == null) {
|
||||
@@ -33,23 +55,31 @@ public class ProjectExecutionStatusViewService {
|
||||
statusModel.getStatusName(),
|
||||
statusModel.getTerminalFlag(),
|
||||
statusModel.getAllowEdit(),
|
||||
buildAvailableActions(statusCode)
|
||||
buildAvailableActions(statusCode, ownerId)
|
||||
);
|
||||
}
|
||||
|
||||
private List<ProjectExecutionLifecycleActionRespVO> buildAvailableActions(String statusCode) {
|
||||
private List<ProjectExecutionLifecycleActionRespVO> buildAvailableActions(String statusCode, Long ownerId) {
|
||||
List<ObjectStatusTransitionDO> transitions = objectStatusTransitionMapper
|
||||
.selectListByObjectTypeAndFromStatus(ProjectExecutionConstants.OBJECT_TYPE, statusCode);
|
||||
if (transitions == null || transitions.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return transitions.stream().map(transition -> {
|
||||
ProjectExecutionLifecycleActionRespVO action = new ProjectExecutionLifecycleActionRespVO();
|
||||
action.setActionCode(transition.getActionCode());
|
||||
action.setActionName(transition.getActionName());
|
||||
action.setNeedReason(transition.getNeedReason());
|
||||
return action;
|
||||
}).toList();
|
||||
Long currentUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
return transitions.stream()
|
||||
// 剔除系统级动作 auto_start:由后端业务事件触发,不暴露给前端按钮
|
||||
.filter(transition -> !ObjectActivityConstants.PROJECT_ACTION_AUTO_START.equals(transition.getActionCode()))
|
||||
// owner-only 字段硬卡:非负责人本人时不返回 complete / cancel / pause / resume 动作
|
||||
.filter(transition -> !OWNER_ONLY_ACTIONS.contains(transition.getActionCode())
|
||||
|| (currentUserId != null && Objects.equals(ownerId, currentUserId)))
|
||||
.map(transition -> {
|
||||
ProjectExecutionLifecycleActionRespVO action = new ProjectExecutionLifecycleActionRespVO();
|
||||
action.setActionCode(transition.getActionCode());
|
||||
action.setActionName(transition.getActionName());
|
||||
action.setNeedReason(transition.getNeedReason());
|
||||
return action;
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
public record ProjectExecutionLifecycleView(String statusName,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.njcn.rdms.module.project.service.project.permission;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 数据可见性 scope,由 VisibilityScopeResolver 计算得出。
|
||||
* - seesAll=true:项目经理等"看全部"角色,分页/计数 SQL 跳过任何 ID 过滤
|
||||
* - seesAll=false:仅命中 executionIds / taskIds 的数据可见;集合为空 = 完全不可见
|
||||
*
|
||||
* 实例不可变;空集合用 Set.of() 表达,调用方不得修改。
|
||||
*/
|
||||
public record VisibilityScope(
|
||||
boolean seesAll,
|
||||
Set<Long> executionIds,
|
||||
Set<Long> taskIds
|
||||
) {
|
||||
|
||||
public static VisibilityScope all() {
|
||||
return new VisibilityScope(true, Set.of(), Set.of());
|
||||
}
|
||||
|
||||
public static VisibilityScope of(Set<Long> executionIds, Set<Long> taskIds) {
|
||||
return new VisibilityScope(false,
|
||||
executionIds == null ? Set.of() : Set.copyOf(executionIds),
|
||||
taskIds == null ? Set.of() : Set.copyOf(taskIds));
|
||||
}
|
||||
|
||||
public static VisibilityScope empty() {
|
||||
return new VisibilityScope(false, Set.of(), Set.of());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.njcn.rdms.module.project.service.project.permission;
|
||||
|
||||
/**
|
||||
* 计算当前登录用户在某项目 / 某执行下的数据可见性 scope。
|
||||
*
|
||||
* 规则:
|
||||
* - 项目经理(project.manager_user_id == userId)→ seesAll=true
|
||||
* - 非项目经理 → 取以下 4 项的并集,构成 (executionIds, taskIds):
|
||||
* 1. 我作为 execution.owner_id 的执行 ID
|
||||
* 2. 我作为 execution_assignee 活跃协办的执行 ID(removed_at IS NULL)
|
||||
* 3. 我作为 task.owner_id 的任务 ID 及其全部子孙 ID(递归 CTE 一次展开)
|
||||
* 4. 我作为 task_assignee 活跃协办的任务 ID(removed_at IS NULL)
|
||||
*
|
||||
* 任务参与者集合 ⊆ 执行参与者集合(业务约束:任务负责人/协办人必须从执行团队挑选)。
|
||||
*/
|
||||
public interface VisibilityScopeResolver {
|
||||
|
||||
/**
|
||||
* 项目维度 scope(用于执行分页 / 执行看板)。
|
||||
*/
|
||||
VisibilityScope resolveForProject(Long projectId, Long userId);
|
||||
|
||||
/**
|
||||
* 执行维度 scope(用于任务分页 / 任务看板 / 任务详情)。
|
||||
* 调用方需先保证 executionId 属于 projectId(由 URL 路径约束)。
|
||||
*/
|
||||
VisibilityScope resolveForExecution(Long projectId, Long executionId, Long userId);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.njcn.rdms.module.project.service.project.permission;
|
||||
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* VisibilityScopeResolver 实现。
|
||||
*
|
||||
* 短路:project.manager_user_id == userId → seesAll=true,跳过任何 ID 过滤。
|
||||
* 非项目经理:并集 4 个 Mapper 来源得到可见的 executionIds / taskIds。
|
||||
*
|
||||
* 任务的"执行 owner 看执行下所有任务"短路不在此处实现,
|
||||
* 由 ProjectTaskServiceImpl / ProjectStatusBoardServiceImpl 在调用本 Resolver 前自行判定。
|
||||
* 本 Resolver 仅负责"参与者 → 可见 ID 集合"的纯查询。
|
||||
*/
|
||||
@Service
|
||||
public class VisibilityScopeResolverImpl implements VisibilityScopeResolver {
|
||||
|
||||
@Resource
|
||||
private ProjectMapper projectMapper;
|
||||
@Resource
|
||||
private ProjectExecutionMapper projectExecutionMapper;
|
||||
@Resource
|
||||
private ExecutionAssigneeMapper executionAssigneeMapper;
|
||||
@Resource
|
||||
private ProjectTaskMapper projectTaskMapper;
|
||||
@Resource
|
||||
private TaskAssigneeMapper taskAssigneeMapper;
|
||||
|
||||
@Override
|
||||
public VisibilityScope resolveForProject(Long projectId, Long userId) {
|
||||
if (isProjectManager(projectId, userId)) {
|
||||
return VisibilityScope.all();
|
||||
}
|
||||
Set<Long> executionIds = new LinkedHashSet<>();
|
||||
executionIds.addAll(projectExecutionMapper.selectIdsByProjectIdAndOwnerId(projectId, userId));
|
||||
executionIds.addAll(executionAssigneeMapper.selectActiveExecutionIdsByProjectIdAndUserId(projectId, userId));
|
||||
|
||||
Set<Long> taskIds = new LinkedHashSet<>();
|
||||
taskIds.addAll(projectTaskMapper.selectOwnedTaskAndDescendantIdsByProjectIdAndUserId(projectId, userId));
|
||||
taskIds.addAll(taskAssigneeMapper.selectActiveTaskIdsByProjectIdAndUserId(projectId, userId));
|
||||
|
||||
return VisibilityScope.of(executionIds, taskIds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public VisibilityScope resolveForExecution(Long projectId, Long executionId, Long userId) {
|
||||
if (isProjectManager(projectId, userId)) {
|
||||
return VisibilityScope.all();
|
||||
}
|
||||
// executionIds 在执行维度无用,统一传空集;调用方靠 taskIds 过滤分页/计数。
|
||||
Set<Long> taskIds = new LinkedHashSet<>();
|
||||
taskIds.addAll(projectTaskMapper.selectOwnedTaskAndDescendantIdsByExecutionIdAndUserId(projectId, executionId, userId));
|
||||
taskIds.addAll(taskAssigneeMapper.selectActiveTaskIdsByProjectIdAndExecutionIdAndUserId(projectId, executionId, userId));
|
||||
|
||||
return VisibilityScope.of(Set.of(), taskIds);
|
||||
}
|
||||
|
||||
private boolean isProjectManager(Long projectId, Long userId) {
|
||||
if (projectId == null || userId == null) {
|
||||
return false;
|
||||
}
|
||||
ProjectDO project = projectMapper.selectById(projectId);
|
||||
return project != null && Objects.equals(project.getManagerUserId(), userId);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.njcn.rdms.module.project.service.project.task;
|
||||
|
||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskDeleteReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskSaveReqVO;
|
||||
@@ -32,4 +33,54 @@ public interface ProjectTaskService {
|
||||
|
||||
void changeTaskStatus(Long projectId, Long executionId, Long taskId, ProjectTaskStatusActionReqVO reqVO);
|
||||
|
||||
/**
|
||||
* 删除任务(软删 + CAS 状态校验 + 三重确认 + 执行 owner 字段硬卡)。
|
||||
* <p>仅初始态可删;权限码 project:task:delete 作菜单入口可见度;实际拦截以 execution.ownerId == currentUserId 为准(spec §5.1 上级硬卡范式)。
|
||||
* <p>不级联软删任务下挂的工时 / 协办人 / 子任务(通过 deleted=0 自然不可见)。
|
||||
*/
|
||||
void deleteTask(Long projectId, Long executionId, Long taskId, ProjectTaskDeleteReqVO reqVO);
|
||||
|
||||
/**
|
||||
* 以"任务负责人本人最新一条工时"的进度为准,同步到任务自身 progressRate 并触发父任务 AVG 重算。
|
||||
* 由工时模块在 owner 维度的 worklog create/update/delete 后调用。
|
||||
* 若 owner 当前在该任务下不存在任何工时记录,则保留 task.progress_rate 原值不动(避免删除场景下意外归零)。
|
||||
* 仅对叶子任务生效;父任务由子任务汇总驱动,不走该路径。
|
||||
*/
|
||||
void syncOwnerProgressFromLatestWorklog(Long taskId);
|
||||
|
||||
/**
|
||||
* 由工时填报(5.11)触发的任务自动开始:当任务当前处于初始态(initialFlag=true)时,
|
||||
* 走一次 {@link com.njcn.rdms.module.project.constant.ObjectActivityConstants#TASK_ACTION_AUTO_START}
|
||||
* 动作把任务推进到下一状态,并复用 5.5 的钩子写入 actualStartDate / 状态日志 / 审计日志。
|
||||
* <p>
|
||||
* 调用方必须已自行确认登录人是 task.ownerId(仅 owner 工时驱动状态推进,与 progressRate 同步口径一致)。
|
||||
* 本方法**不走** @CheckObjectPermission,因为 5.11 持有的是 PERMISSION_WORKLOG,
|
||||
* 而 5.5 changeTaskStatus 持有的是 PERMISSION_STATUS,两者权限边界不同。
|
||||
* <p>
|
||||
* 容错:若任务当前状态非初始态、transition 配置缺失/禁用、并发被抢占(affected=0)等情况,
|
||||
* 本方法静默 return(仅打 WARN 日志),不影响工时主流程入库。
|
||||
*/
|
||||
void internalAutoStartByWorklog(ProjectTaskDO task);
|
||||
|
||||
/**
|
||||
* 由执行 cancel 触发的级联:取消指定执行下所有未终态的顶层任务(parentTaskId IS NULL)。
|
||||
* 顶层任务自身的内部链路会再级联自己的子任务,整棵子树通过链式实现。
|
||||
* 跳过任务侧的 owner 校验(系统行为),但复用 doInternalChangeTaskStatus 的事务 / CAS / 审计 / 实际日期回填等。
|
||||
* 调用方:ProjectExecutionServiceImpl 在 cancel 成功后追加。
|
||||
*/
|
||||
void cascadeCancelTasksByExecutionId(Long executionId, String executionReason);
|
||||
|
||||
/**
|
||||
* 由执行 pause 触发的级联:暂停指定执行下所有顶层任务中状态为 active 的节点。
|
||||
* 顶层任务自身会再级联子任务,整棵子树同步暂停。
|
||||
*/
|
||||
void cascadePauseTasksByExecutionId(Long executionId, String executionReason);
|
||||
|
||||
/**
|
||||
* 由执行 resume 触发的级联:恢复指定执行下所有顶层任务中状态为 paused 的节点。
|
||||
* 顶层任务自身会再级联子任务,整棵子树同步恢复。
|
||||
* "误恢复"边界(任务级 paused 与父级 paused 不区分)与任务侧 §4.4 同款,已识别并接受。
|
||||
*/
|
||||
void cascadeResumeTasksByExecutionId(Long executionId, String executionReason);
|
||||
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.framework.common.util.json.JsonUtils;
|
||||
import com.njcn.rdms.framework.common.util.object.BeanUtils;
|
||||
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
|
||||
import com.njcn.rdms.module.project.constant.ProjectExecutionConstants;
|
||||
import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
|
||||
import com.njcn.rdms.module.project.constant.ProjectTaskConstants;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskDeleteReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskSaveReqVO;
|
||||
@@ -19,11 +21,12 @@ import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExec
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskStatusLogDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskAssigneeDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskWorklogDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
|
||||
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionMemberMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskStatusLogMapper;
|
||||
@@ -31,13 +34,20 @@ import com.njcn.rdms.module.project.dal.mysql.project.task.TaskWorklogMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper;
|
||||
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
||||
import com.njcn.rdms.module.project.framework.attachment.AttachmentFileIdResolver;
|
||||
import com.njcn.rdms.module.project.framework.attachment.AttachmentValidator;
|
||||
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
|
||||
import com.njcn.rdms.module.project.framework.security.service.ProjectObjectAuthorizationService;
|
||||
import com.njcn.rdms.module.project.service.project.ProjectService;
|
||||
import com.njcn.rdms.module.project.service.project.execution.ProjectExecutionService;
|
||||
import com.njcn.rdms.module.project.service.project.permission.VisibilityScope;
|
||||
import com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver;
|
||||
import com.njcn.rdms.module.project.service.project.task.assignee.TaskAssigneeService;
|
||||
import com.njcn.rdms.module.project.service.project.task.worklog.TaskWorklogService;
|
||||
import com.njcn.rdms.module.system.api.user.AdminUserApi;
|
||||
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -65,6 +75,7 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
|
||||
* 项目任务 Service 实现类。
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
|
||||
@Resource
|
||||
@@ -72,7 +83,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
@Resource
|
||||
private ProjectExecutionMapper projectExecutionMapper;
|
||||
@Resource
|
||||
private ExecutionMemberMapper executionMemberMapper;
|
||||
private ExecutionAssigneeMapper executionAssigneeMapper;
|
||||
@Resource
|
||||
private ProjectTaskMapper projectTaskMapper;
|
||||
@Resource
|
||||
@@ -88,6 +99,8 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
@Resource
|
||||
private ProjectService projectService;
|
||||
@Resource
|
||||
private ProjectExecutionService projectExecutionService;
|
||||
@Resource
|
||||
private ProjectTaskStatusViewService projectTaskStatusViewService;
|
||||
@Resource
|
||||
private TaskAssigneeService taskAssigneeService;
|
||||
@@ -95,21 +108,31 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
private TaskWorklogService taskWorklogService;
|
||||
@Resource
|
||||
private AdminUserApi adminUserApi;
|
||||
@Resource
|
||||
private AttachmentFileIdResolver attachmentFileIdResolver;
|
||||
@Resource
|
||||
private ProjectObjectAuthorizationService projectObjectAuthorizationService;
|
||||
@Resource
|
||||
private VisibilityScopeResolver visibilityScopeResolver;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
|
||||
permission = ProjectTaskConstants.PERMISSION_CREATE)
|
||||
public Long createTask(Long projectId, Long executionId, ProjectTaskSaveReqVO reqVO) {
|
||||
validateEditableProject(projectId);
|
||||
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
|
||||
validateExecutionAllowEdit(execution);
|
||||
ProjectTaskDO parentTask = validateParentTask(projectId, executionId, reqVO.getParentTaskId());
|
||||
// 上级硬卡:一级任务由"执行负责人 OR 项目负责人(权限码)"建;子任务由"父任务负责人 OR 项目负责人(权限码)"建
|
||||
Long upperOwnerId = parentTask == null ? execution.getOwnerId() : parentTask.getOwnerId();
|
||||
projectObjectAuthorizationService.checkOwnerOrProjectPermission(
|
||||
projectId, upperOwnerId, ProjectTaskConstants.PERMISSION_CREATE);
|
||||
Long ownerId = resolveTaskOwner(reqVO.getOwnerId(), parentTask);
|
||||
validateOwnerIsActiveExecutionMember(executionId, ownerId);
|
||||
validateOwnerIsActiveExecutionAssignee(executionId, ownerId);
|
||||
validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期");
|
||||
// 叶子转父校验:当前 parentTask 还是叶子(无任何子任务)时,要求其进度=0 且无工时记录
|
||||
validateLeafToParentSplit(parentTask);
|
||||
AttachmentValidator.validate(reqVO.getAttachments());
|
||||
attachmentFileIdResolver.resolve(reqVO.getAttachments());
|
||||
|
||||
ProjectTaskDO task = new ProjectTaskDO();
|
||||
task.setProjectId(projectId);
|
||||
@@ -118,12 +141,13 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
task.setTaskTitle(normalizeRequiredTitle(reqVO.getTaskTitle()));
|
||||
task.setOwnerId(ownerId);
|
||||
task.setStatusCode(getInitialTaskStatusCode());
|
||||
// 新建任务自身一定是叶子(尚无子任务),按 owner 手填值落库(默认 0)
|
||||
task.setProgressRate(normalizeProgress(reqVO.getProgressRate()));
|
||||
// 任务进度统一由 worklog 驱动;新建任务强制为 0
|
||||
task.setProgressRate(BigDecimal.ZERO);
|
||||
task.setPlannedStartDate(reqVO.getPlannedStartDate());
|
||||
task.setPlannedEndDate(reqVO.getPlannedEndDate());
|
||||
// 实际开始/结束日期不允许人工填写,由 changeTaskStatus 在状态流转时推导
|
||||
task.setTaskDesc(normalizeNullableText(reqVO.getTaskDesc()));
|
||||
task.setAttachments(reqVO.getAttachments());
|
||||
projectTaskMapper.insert(task);
|
||||
|
||||
// 创建任务时初始化协办人列表(同事务,任一项失败整笔回滚;列表为空跳过)
|
||||
@@ -142,8 +166,6 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
|
||||
permission = ProjectTaskConstants.PERMISSION_UPDATE)
|
||||
public void updateTask(Long projectId, Long executionId, ProjectTaskSaveReqVO reqVO) {
|
||||
if (reqVO.getId() == null) {
|
||||
throw invalidParamException("任务编号不能为空");
|
||||
@@ -153,12 +175,19 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
validateExecutionAllowEdit(execution);
|
||||
ProjectTaskDO task = validateTaskExists(projectId, executionId, reqVO.getId());
|
||||
validateTaskAllowEdit(task);
|
||||
// 上级硬卡:一级任务由"执行负责人 OR 项目负责人(权限码)"改;子任务由"父任务负责人 OR 项目负责人(权限码)"改
|
||||
// 按当前 task 的 parentTaskId(修改前)判定;改父亲属于改动结果,授权按改动前形态裁决
|
||||
Long upperOwnerId = task.getParentTaskId() == null
|
||||
? execution.getOwnerId()
|
||||
: projectTaskMapper.selectById(task.getParentTaskId()).getOwnerId();
|
||||
projectObjectAuthorizationService.checkOwnerOrProjectPermission(
|
||||
projectId, upperOwnerId, ProjectTaskConstants.PERMISSION_UPDATE);
|
||||
ProjectTaskDO parentTask = validateParentTask(projectId, executionId, reqVO.getParentTaskId());
|
||||
if (parentTask != null && Objects.equals(parentTask.getId(), task.getId())) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_PARENT_INVALID);
|
||||
}
|
||||
Long ownerId = resolveTaskOwner(reqVO.getOwnerId(), parentTask);
|
||||
validateOwnerIsActiveExecutionMember(executionId, ownerId);
|
||||
validateOwnerIsActiveExecutionAssignee(executionId, ownerId);
|
||||
validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期");
|
||||
// 父任务变更校验:迁到新父时,新父若仍是叶子(即将变父),要求新父进度=0 且无工时
|
||||
Long oldParentId = task.getParentTaskId();
|
||||
@@ -166,18 +195,19 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
if (!Objects.equals(oldParentId, newParentId)) {
|
||||
validateLeafToParentSplit(parentTask);
|
||||
}
|
||||
// 进度入参根据"当前任务是否已是父任务"决定:父任务拒绝手填变更,叶子任务沿用原规则
|
||||
BigDecimal resolvedProgress = resolveProgressRateOnUpdate(task, reqVO.getProgressRate());
|
||||
AttachmentValidator.validate(reqVO.getAttachments());
|
||||
attachmentFileIdResolver.resolve(reqVO.getAttachments());
|
||||
// 任务进度由 worklog 驱动(owner 填工时回写 + 父任务 AVG 汇总),编辑任务接口不接受进度入参
|
||||
|
||||
ProjectTaskDO before = cloneTask(task);
|
||||
task.setParentTaskId(newParentId);
|
||||
task.setTaskTitle(normalizeRequiredTitle(reqVO.getTaskTitle()));
|
||||
task.setOwnerId(ownerId);
|
||||
task.setProgressRate(resolvedProgress);
|
||||
task.setPlannedStartDate(reqVO.getPlannedStartDate());
|
||||
task.setPlannedEndDate(reqVO.getPlannedEndDate());
|
||||
// 实际开始/结束日期不允许人工编辑,保留原值由状态流转维护
|
||||
task.setTaskDesc(normalizeNullableText(reqVO.getTaskDesc()));
|
||||
task.setAttachments(reqVO.getAttachments());
|
||||
projectTaskMapper.updateById(task);
|
||||
|
||||
// 进度联动:旧父链 + 新父链都需要重算(父任务不可手填进度,由本逻辑统一刷新)
|
||||
@@ -194,6 +224,122 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
task.getStatusCode(), buildTaskFieldChanges(before, task), null);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void deleteTask(Long projectId, Long executionId, Long taskId, ProjectTaskDeleteReqVO reqVO) {
|
||||
validateEditableProject(projectId);
|
||||
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
|
||||
validateExecutionAllowEdit(execution);
|
||||
ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId);
|
||||
// 上级硬卡:一级任务由"执行负责人 OR 项目负责人(权限码)"删;子任务由"父任务负责人 OR 项目负责人(权限码)"删
|
||||
Long upperOwnerId = task.getParentTaskId() == null
|
||||
? execution.getOwnerId()
|
||||
: projectTaskMapper.selectById(task.getParentTaskId()).getOwnerId();
|
||||
projectObjectAuthorizationService.checkOwnerOrProjectPermission(
|
||||
projectId, upperOwnerId, ProjectTaskConstants.PERMISSION_DELETE);
|
||||
validateDeleteConfirmText(reqVO.getConfirmText());
|
||||
if (!Objects.equals(task.getTaskTitle(), reqVO.getTaskName().trim())) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_DELETE_NAME_MISMATCH);
|
||||
}
|
||||
// 仅初始态可删
|
||||
String fromStatus = task.getStatusCode();
|
||||
ObjectStatusModelDO statusModel = objectStatusModelMapper
|
||||
.selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, fromStatus);
|
||||
if (statusModel == null || !Boolean.TRUE.equals(statusModel.getInitialFlag())) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_NOT_ALLOW_DELETE);
|
||||
}
|
||||
String reason = reqVO.getReason().trim();
|
||||
int deleteCount = projectTaskMapper.deleteByIdAndStatus(taskId, fromStatus);
|
||||
if (deleteCount != 1) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_CONCURRENT_MODIFIED);
|
||||
}
|
||||
writeTaskAuditLog(task, ObjectActivityConstants.TASK_ACTION_DELETE, fromStatus, null, null, reason);
|
||||
}
|
||||
|
||||
private void validateDeleteConfirmText(String confirmText) {
|
||||
String normalizedConfirmText = normalizeNullableText(confirmText);
|
||||
if (normalizedConfirmText == null
|
||||
|| !ProjectTaskConstants.DELETE_CONFIRM_TEXTS.contains(normalizedConfirmText)) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_DELETE_CONFIRM_TEXT_INVALID);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void syncOwnerProgressFromLatestWorklog(Long taskId) {
|
||||
if (taskId == null) {
|
||||
return;
|
||||
}
|
||||
ProjectTaskDO task = projectTaskMapper.selectById(taskId);
|
||||
if (task == null || task.getOwnerId() == null) {
|
||||
return;
|
||||
}
|
||||
// 仅对叶子任务生效:父任务进度由子任务 AVG 汇总,不接受 worklog 驱动
|
||||
if (projectTaskMapper.countChildrenByParentTaskId(taskId) > 0) {
|
||||
return;
|
||||
}
|
||||
TaskWorklogDO latest = taskWorklogMapper.selectLatestByTaskIdAndUserId(taskId, task.getOwnerId());
|
||||
// owner 已无任何工时记录:保留 task.progress_rate 原值不动(不归零)
|
||||
if (latest == null) {
|
||||
return;
|
||||
}
|
||||
BigDecimal newProgress = normalizeProgress(latest.getProgressRate());
|
||||
if (progressNumericallyEquals(newProgress, task.getProgressRate())) {
|
||||
return;
|
||||
}
|
||||
projectTaskMapper.updateProgressRateById(taskId, newProgress);
|
||||
if (task.getParentTaskId() != null) {
|
||||
recalcParentProgressFrom(task.getParentTaskId());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void internalAutoStartByWorklog(ProjectTaskDO task) {
|
||||
if (task == null || task.getId() == null) {
|
||||
return;
|
||||
}
|
||||
// 二次 reload:调用方传入的是工时入库前的快照,以 DB 现状为准判定是否需要自动开始
|
||||
ProjectTaskDO current = projectTaskMapper.selectById(task.getId());
|
||||
if (current == null) {
|
||||
return;
|
||||
}
|
||||
String fromStatus = current.getStatusCode();
|
||||
// 仅在初始态触发:避免对 active/paused/已结束等状态产生副作用
|
||||
ObjectStatusModelDO fromModel = objectStatusModelMapper
|
||||
.selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, fromStatus);
|
||||
if (fromModel == null || !Boolean.TRUE.equals(fromModel.getInitialFlag())) {
|
||||
return;
|
||||
}
|
||||
String actionCode = ObjectActivityConstants.TASK_ACTION_AUTO_START;
|
||||
ObjectStatusTransitionDO transition = objectStatusTransitionMapper
|
||||
.selectByObjectTypeAndFromStatusAndAction(ProjectTaskConstants.OBJECT_TYPE, fromStatus, actionCode);
|
||||
if (transition == null) {
|
||||
// transition 配置缺失/被禁用/已删除:静默不影响工时入库,仅 WARN 让运维感知配置漂移
|
||||
log.warn("task auto-start by worklog skipped: transition not found or disabled. taskId={}, fromStatus={}, actionCode={}",
|
||||
current.getId(), fromStatus, actionCode);
|
||||
return;
|
||||
}
|
||||
String toStatus = transition.getToStatusCode();
|
||||
String reason = "由工时填报自动触发";
|
||||
int updateCount = projectTaskMapper.updateStatusByIdAndStatus(current.getId(), fromStatus, toStatus, reason);
|
||||
if (updateCount != 1) {
|
||||
// 并发抢占(已被别的链路推进):静默 return
|
||||
log.warn("task auto-start by worklog skipped: concurrent modification on status. taskId={}, expectedFromStatus={}",
|
||||
current.getId(), fromStatus);
|
||||
return;
|
||||
}
|
||||
current.setStatusCode(toStatus);
|
||||
current.setLastStatusReason(reason);
|
||||
|
||||
writeTaskStatusLog(current, actionCode, fromStatus, toStatus, reason);
|
||||
writeTaskAuditLog(current, actionCode, fromStatus, toStatus, null, reason);
|
||||
maybeFillActualDates(current, fromStatus, toStatus);
|
||||
// 通知执行:任务自动开始也属于"任务状态变更"事件,按 §6.4 触发表上报
|
||||
projectExecutionService.onTaskStatusChanged(
|
||||
current.getExecutionId(), current.getId(), fromStatus, toStatus, actionCode, reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProjectTaskDO getTask(Long projectId, Long executionId, Long taskId) {
|
||||
validateExecutionExists(projectId, executionId);
|
||||
@@ -202,19 +348,54 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
|
||||
@Override
|
||||
public PageResult<ProjectTaskDO> getTaskPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) {
|
||||
validateExecutionExists(projectId, executionId);
|
||||
return projectTaskMapper.selectPageByExecutionId(projectId, executionId, reqVO);
|
||||
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
|
||||
VisibilityScope scope = computeTaskScope(projectId, executionId, execution);
|
||||
return projectTaskMapper.selectPageByExecutionId(projectId, executionId, scope, reqVO);
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务可见性计算:
|
||||
* - 项目经理 → seesAll(由 Resolver 内置判定)
|
||||
* - 执行负责人 = 当前用户 → seesAll(看本执行下全部任务)
|
||||
* - 否则 → resolveForExecution 求并集(我 owner 的任务及子孙 ∪ 我活跃协办的任务)
|
||||
*/
|
||||
private VisibilityScope computeTaskScope(Long projectId, Long executionId, ProjectExecutionDO execution) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
VisibilityScope scope = visibilityScopeResolver.resolveForExecution(projectId, executionId, userId);
|
||||
if (scope.seesAll()) {
|
||||
return scope;
|
||||
}
|
||||
if (execution != null && Objects.equals(execution.getOwnerId(), userId)) {
|
||||
return VisibilityScope.all();
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProjectTaskRespVO getTaskRespVO(Long projectId, Long executionId, Long taskId) {
|
||||
ProjectTaskDO task = getTask(projectId, executionId, taskId);
|
||||
// 内联 validate,便于接住 execution 供前端 executionOwnerId 字段使用
|
||||
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
|
||||
ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId);
|
||||
// 可见性卡断:执行 owner / 项目经理直接放行;否则 taskId 必须在 scope.taskIds 中。
|
||||
// 未命中按"任务不存在"语义返回,不暴露存在性。
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
if (!Objects.equals(execution.getOwnerId(), userId)) {
|
||||
VisibilityScope scope = visibilityScopeResolver.resolveForExecution(projectId, executionId, userId);
|
||||
if (!scope.seesAll() && !scope.taskIds().contains(taskId)) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_NOT_EXISTS);
|
||||
}
|
||||
}
|
||||
ProjectTaskRespVO respVO = BeanUtils.toBean(task, ProjectTaskRespVO.class);
|
||||
applyLifecycle(respVO);
|
||||
respVO.setOwnerNickname(loadOwnerNickname(task.getOwnerId()));
|
||||
respVO.setAssignees(buildAssigneeViews(taskAssigneeService
|
||||
.loadActiveAssigneesGroupedByTaskId(List.of(task.getId())).getOrDefault(task.getId(), List.of())));
|
||||
respVO.setTotalSpentMinutes(taskWorklogService.sumDurationByTaskId(task.getId()));
|
||||
respVO.setTotalSpentHours(taskWorklogService.sumDurationByTaskId(task.getId()));
|
||||
respVO.setExecutionOwnerId(execution.getOwnerId());
|
||||
if (task.getParentTaskId() != null) {
|
||||
ProjectTaskDO parentTask = projectTaskMapper.selectById(task.getParentTaskId());
|
||||
respVO.setParentTaskOwnerId(parentTask == null ? null : parentTask.getOwnerId());
|
||||
}
|
||||
return respVO;
|
||||
}
|
||||
|
||||
@@ -231,20 +412,42 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
.filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
Map<Long, List<TaskAssigneeDO>> assigneeMap = taskAssigneeService
|
||||
.loadActiveAssigneesGroupedByTaskId(taskIds);
|
||||
Map<Long, Long> spentMinutesMap = taskWorklogService.sumDurationGroupedByTaskIds(taskIds);
|
||||
Map<Long, BigDecimal> spentHoursMap = taskWorklogService.sumDurationGroupedByTaskIds(taskIds);
|
||||
Set<Long> userIdsToResolve = list.stream()
|
||||
.map(ProjectTaskRespVO::getOwnerId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
assigneeMap.values().forEach(items -> items.forEach(a -> userIdsToResolve.add(a.getUserId())));
|
||||
Map<Long, String> nicknameMap = loadOwnerNicknameMap(userIdsToResolve);
|
||||
// 批量查父任务 owner,避免按 list 循环 N+1
|
||||
Set<Long> parentTaskIds = list.stream().map(ProjectTaskRespVO::getParentTaskId)
|
||||
.filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
Map<Long, Long> parentTaskOwnerMap = parentTaskIds.isEmpty()
|
||||
? Map.of()
|
||||
: projectTaskMapper.selectBatchIds(parentTaskIds).stream()
|
||||
.collect(Collectors.toMap(ProjectTaskDO::getId, ProjectTaskDO::getOwnerId));
|
||||
// 执行 owner 单条查询,整页共享(URL 路径定 executionId)
|
||||
ProjectExecutionDO execution = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId);
|
||||
Long executionOwnerId = execution == null ? null : execution.getOwnerId();
|
||||
list.forEach(vo -> {
|
||||
vo.setOwnerNickname(nicknameMap.get(vo.getOwnerId()));
|
||||
List<TaskAssigneeDO> activeList = assigneeMap.getOrDefault(vo.getId(), List.of());
|
||||
vo.setAssignees(activeList.stream()
|
||||
.map(a -> toAssigneeView(a, nicknameMap.get(a.getUserId())))
|
||||
.collect(Collectors.toList()));
|
||||
vo.setTotalSpentMinutes(spentMinutesMap.getOrDefault(vo.getId(), 0L));
|
||||
vo.setTotalSpentHours(spentHoursMap.getOrDefault(vo.getId(), BigDecimal.ZERO));
|
||||
vo.setExecutionOwnerId(executionOwnerId);
|
||||
if (vo.getParentTaskId() != null) {
|
||||
vo.setParentTaskOwnerId(parentTaskOwnerMap.get(vo.getParentTaskId()));
|
||||
}
|
||||
// 列表行 cancel/pause/resume/complete 按钮依赖 availableActions,与详情同款装配 lifecycle。
|
||||
// 单行装配失败做兜底降级(status_model 缺失等脏数据),避免影响整页返回。
|
||||
try {
|
||||
applyLifecycle(vo);
|
||||
} catch (Exception e) {
|
||||
log.warn("task lifecycle apply failed in page assembly. taskId={}, statusCode={}, error={}",
|
||||
vo.getId(), vo.getStatusCode(), e.getMessage());
|
||||
}
|
||||
});
|
||||
return voPageResult;
|
||||
}
|
||||
@@ -277,21 +480,41 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId);
|
||||
String actionCode = normalizeRequiredActionCode(reqVO.getActionCode());
|
||||
String reason = normalizeNullableText(reqVO.getReason());
|
||||
String fromStatus = task.getStatusCode();
|
||||
ObjectStatusTransitionDO transition = validateTaskTransition(fromStatus, actionCode, reason);
|
||||
String toStatus = transition.getToStatusCode();
|
||||
validateBeforeStatusChange(task, toStatus);
|
||||
// owner-only 字段硬卡:cancel / pause / resume / complete 必须任务负责人本人触发(与执行同款,不接受角色权限码兜底)
|
||||
validateOwnerForAction(task, actionCode);
|
||||
doInternalChangeTaskStatus(task, actionCode, reason);
|
||||
}
|
||||
|
||||
int updateCount = projectTaskMapper.updateStatusByIdAndStatus(taskId, fromStatus, toStatus, reason);
|
||||
if (updateCount != 1) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_CONCURRENT_MODIFIED);
|
||||
/**
|
||||
* 负责人独占动作校验:complete / cancel / pause / resume 必须由任务负责人触发。
|
||||
* 系统级别的 internalAutoStartByWorklog 不经此校验。
|
||||
*/
|
||||
private void validateOwnerForAction(ProjectTaskDO task, String actionCode) {
|
||||
if (!isOwnerOnlyAction(actionCode)) {
|
||||
return;
|
||||
}
|
||||
task.setStatusCode(toStatus);
|
||||
task.setLastStatusReason(reason);
|
||||
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
if (!Objects.equals(loginUserId, task.getOwnerId())) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_OWNER_ONLY,
|
||||
resolveActionDisplayName(actionCode));
|
||||
}
|
||||
}
|
||||
|
||||
writeTaskStatusLog(task, actionCode, fromStatus, toStatus, reason);
|
||||
writeTaskAuditLog(task, actionCode, fromStatus, toStatus, null, reason);
|
||||
maybeFillActualDates(task, fromStatus, toStatus);
|
||||
private boolean isOwnerOnlyAction(String actionCode) {
|
||||
return "complete".equals(actionCode)
|
||||
|| "cancel".equals(actionCode)
|
||||
|| "pause".equals(actionCode)
|
||||
|| "resume".equals(actionCode);
|
||||
}
|
||||
|
||||
private String resolveActionDisplayName(String actionCode) {
|
||||
return switch (actionCode) {
|
||||
case "complete" -> "完成";
|
||||
case "cancel" -> "取消";
|
||||
case "pause" -> "暂停";
|
||||
case "resume" -> "恢复";
|
||||
default -> actionCode;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -408,8 +631,8 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
}
|
||||
}
|
||||
|
||||
private void validateOwnerIsActiveExecutionMember(Long executionId, Long ownerId) {
|
||||
if (ownerId == null || executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, ownerId) == null) {
|
||||
private void validateOwnerIsActiveExecutionAssignee(Long executionId, Long ownerId) {
|
||||
if (ownerId == null || executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, ownerId) == null) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_OWNER_INVALID);
|
||||
}
|
||||
}
|
||||
@@ -439,9 +662,14 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
/**
|
||||
* 进入终态前置校验:当目标状态属于终态(terminalFlag=true)时,要求所有子任务已进入终态,
|
||||
* 避免父任务关闭后子任务仍处于未结束状态。判定基于通用语义位,不识别具体 statusCode。
|
||||
* 进度合理性(如完成时进度 100%)由前端在动作触发前的交互层把关。
|
||||
* cancel 动作走"自动级联取消"路径(cascadeCancelChildren),跳过本校验。
|
||||
* 进度合理性(如完成时进度 100%)由前端在动作触发前的交互层把关,doInternalChangeTaskStatus
|
||||
* 在 complete 落库后会兜底刷一次进度到 100%。
|
||||
*/
|
||||
private void validateBeforeStatusChange(ProjectTaskDO task, String toStatus) {
|
||||
private void validateBeforeStatusChange(ProjectTaskDO task, String actionCode, String toStatus) {
|
||||
if ("cancel".equals(actionCode)) {
|
||||
return;
|
||||
}
|
||||
ObjectStatusModelDO toModel = objectStatusModelMapper
|
||||
.selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, toStatus);
|
||||
if (toModel == null || !Boolean.TRUE.equals(toModel.getTerminalFlag())) {
|
||||
@@ -455,6 +683,200 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务状态变更核心逻辑(不做手工入口授权校验)。
|
||||
* 手工入口 changeTaskStatus 在做完 owner/项目角色权限校验后调用;
|
||||
* 系统级联(cascade*Children)跳过授权校验直接调用,整棵子树状态变更通过链式触发实现。
|
||||
*/
|
||||
private void doInternalChangeTaskStatus(ProjectTaskDO task, String actionCode, String reason) {
|
||||
String fromStatus = task.getStatusCode();
|
||||
ObjectStatusTransitionDO transition = validateTaskTransition(fromStatus, actionCode, reason);
|
||||
String toStatus = transition.getToStatusCode();
|
||||
validateBeforeStatusChange(task, actionCode, toStatus);
|
||||
|
||||
int updateCount = projectTaskMapper.updateStatusByIdAndStatus(task.getId(), fromStatus, toStatus, reason);
|
||||
if (updateCount != 1) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_CONCURRENT_MODIFIED);
|
||||
}
|
||||
task.setStatusCode(toStatus);
|
||||
task.setLastStatusReason(reason);
|
||||
|
||||
writeTaskStatusLog(task, actionCode, fromStatus, toStatus, reason);
|
||||
writeTaskAuditLog(task, actionCode, fromStatus, toStatus, null, reason);
|
||||
maybeFillActualDates(task, fromStatus, toStatus);
|
||||
|
||||
// 完成动作:兜底把任务进度刷到 100%,并触发父任务 AVG 重算
|
||||
if ("complete".equals(actionCode)) {
|
||||
forceCompleteProgress(task);
|
||||
}
|
||||
|
||||
// 取消 / 暂停 / 恢复:触发子任务级联(每个子任务的内部链路自身会再级联自己的子,链式实现整棵子树)
|
||||
cascadeIfNeeded(task, actionCode, reason);
|
||||
|
||||
// 通知执行(仅 complete / cancel;pause / resume 不通知;auto_start 在 internalAutoStartByWorklog 单独调用)
|
||||
if ("complete".equals(actionCode) || "cancel".equals(actionCode)) {
|
||||
projectExecutionService.onTaskStatusChanged(
|
||||
task.getExecutionId(), task.getId(), fromStatus, toStatus, actionCode, reason);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成动作后将任务进度兜底置为 100%,并触发父任务 AVG 重算。
|
||||
* 正常流程下前端会确保点完成时进度已经是 100%,此处为兜底(防止前端绕过 / bug 导致脏数据)。
|
||||
*/
|
||||
private void forceCompleteProgress(ProjectTaskDO task) {
|
||||
BigDecimal full = BigDecimal.valueOf(100);
|
||||
if (progressNumericallyEquals(task.getProgressRate(), full)) {
|
||||
return;
|
||||
}
|
||||
projectTaskMapper.updateProgressRateById(task.getId(), full);
|
||||
task.setProgressRate(full);
|
||||
if (task.getParentTaskId() != null) {
|
||||
recalcParentProgressFrom(task.getParentTaskId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 级联触发:根据 actionCode 对子任务批量执行对应动作。
|
||||
* - cancel:所有未终态的直接子任务自动取消
|
||||
* - pause:所有 active 的直接子任务自动暂停
|
||||
* - resume:所有 paused 的直接子任务自动恢复
|
||||
* 其他动作不级联。
|
||||
*/
|
||||
private void cascadeIfNeeded(ProjectTaskDO task, String actionCode, String parentReason) {
|
||||
switch (actionCode) {
|
||||
case "cancel" -> cascadeCancelChildren(task.getId(), parentReason);
|
||||
case "pause" -> cascadePauseChildren(task.getId(), parentReason);
|
||||
case "resume" -> cascadeResumeChildren(task.getId(), parentReason);
|
||||
default -> {
|
||||
// 其他动作不级联
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void cascadeCancelChildren(Long parentTaskId, String parentReason) {
|
||||
List<ProjectTaskDO> children = loadDirectChildren(parentTaskId);
|
||||
if (children.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<String> terminalStatusCodes = objectStatusModelMapper
|
||||
.selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
|
||||
for (ProjectTaskDO child : children) {
|
||||
// 已终态的子任务跳过(cancelled / completed)
|
||||
if (terminalStatusCodes.contains(child.getStatusCode())) {
|
||||
continue;
|
||||
}
|
||||
doInternalChangeTaskStatus(child, "cancel",
|
||||
formatCascadeReason("取消", parentTaskId, parentReason));
|
||||
}
|
||||
}
|
||||
|
||||
private void cascadePauseChildren(Long parentTaskId, String parentReason) {
|
||||
List<ProjectTaskDO> children = loadDirectChildren(parentTaskId);
|
||||
if (children.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (ProjectTaskDO child : children) {
|
||||
// 只对 active 子任务级联暂停;其他状态(pending / paused / 终态)按设计跳过
|
||||
if (!"active".equals(child.getStatusCode())) {
|
||||
continue;
|
||||
}
|
||||
doInternalChangeTaskStatus(child, "pause",
|
||||
formatCascadeReason("暂停", parentTaskId, parentReason));
|
||||
}
|
||||
}
|
||||
|
||||
private void cascadeResumeChildren(Long parentTaskId, String parentReason) {
|
||||
List<ProjectTaskDO> children = loadDirectChildren(parentTaskId);
|
||||
if (children.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (ProjectTaskDO child : children) {
|
||||
// 只对 paused 子任务级联恢复(含父任务暂停前已主动 paused 的——A 方案接受的"误恢复"边界)
|
||||
if (!"paused".equals(child.getStatusCode())) {
|
||||
continue;
|
||||
}
|
||||
doInternalChangeTaskStatus(child, "resume",
|
||||
formatCascadeReason("恢复", parentTaskId, parentReason));
|
||||
}
|
||||
}
|
||||
|
||||
private List<ProjectTaskDO> loadDirectChildren(Long parentTaskId) {
|
||||
return projectTaskMapper.selectList(new LambdaQueryWrapperX<ProjectTaskDO>()
|
||||
.eq(ProjectTaskDO::getParentTaskId, parentTaskId));
|
||||
}
|
||||
|
||||
private String formatCascadeReason(String actionDisplay, Long parentTaskId, String parentReason) {
|
||||
if (StringUtils.hasText(parentReason)) {
|
||||
return String.format("父任务%s时自动级联(父任务ID: %d,父任务原因: %s)",
|
||||
actionDisplay, parentTaskId, parentReason);
|
||||
}
|
||||
return String.format("父任务%s时自动级联(父任务ID: %d)", actionDisplay, parentTaskId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void cascadeCancelTasksByExecutionId(Long executionId, String executionReason) {
|
||||
cascadeAllRootTasksByExecutionId(executionId, "cancel", "取消", executionReason);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void cascadePauseTasksByExecutionId(Long executionId, String executionReason) {
|
||||
cascadeAllRootTasksByExecutionId(executionId, "pause", "暂停", executionReason);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void cascadeResumeTasksByExecutionId(Long executionId, String executionReason) {
|
||||
cascadeAllRootTasksByExecutionId(executionId, "resume", "恢复", executionReason);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按动作类型级联执行下所有顶层任务(parentTaskId IS NULL);顶层任务自身的内部链路会再级联子任务。
|
||||
* 跳过 owner 校验,复用 doInternalChangeTaskStatus 的事务 / CAS / 审计 / 日期回填。
|
||||
*/
|
||||
private void cascadeAllRootTasksByExecutionId(Long executionId, String actionCode,
|
||||
String actionDisplay, String executionReason) {
|
||||
if (executionId == null) {
|
||||
return;
|
||||
}
|
||||
List<ProjectTaskDO> rootTasks = projectTaskMapper.selectList(new LambdaQueryWrapperX<ProjectTaskDO>()
|
||||
.eq(ProjectTaskDO::getExecutionId, executionId)
|
||||
.isNull(ProjectTaskDO::getParentTaskId));
|
||||
if (rootTasks.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<String> terminalStatusCodes = "cancel".equals(actionCode)
|
||||
? objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE)
|
||||
: null;
|
||||
for (ProjectTaskDO task : rootTasks) {
|
||||
if (!isTaskEligibleForExecutionCascade(task, actionCode, terminalStatusCodes)) {
|
||||
continue;
|
||||
}
|
||||
doInternalChangeTaskStatus(task, actionCode,
|
||||
formatExecutionCascadeReason(actionDisplay, executionId, executionReason));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isTaskEligibleForExecutionCascade(ProjectTaskDO task, String actionCode,
|
||||
List<String> terminalStatusCodes) {
|
||||
return switch (actionCode) {
|
||||
case "cancel" -> terminalStatusCodes != null && !terminalStatusCodes.contains(task.getStatusCode());
|
||||
case "pause" -> "active".equals(task.getStatusCode());
|
||||
case "resume" -> "paused".equals(task.getStatusCode());
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private String formatExecutionCascadeReason(String actionDisplay, Long executionId, String executionReason) {
|
||||
if (StringUtils.hasText(executionReason)) {
|
||||
return String.format("执行%s时自动级联(执行ID: %d,执行原因: %s)",
|
||||
actionDisplay, executionId, executionReason);
|
||||
}
|
||||
return String.format("执行%s时自动级联(执行ID: %d)", actionDisplay, executionId);
|
||||
}
|
||||
|
||||
private String getInitialTaskStatusCode() {
|
||||
ObjectStatusModelDO statusModel = objectStatusModelMapper
|
||||
.selectInitialByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
|
||||
@@ -574,25 +996,6 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
return value == null ? BigDecimal.ZERO : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 父任务进度由子任务自动汇总,不接受手工修改。
|
||||
* <ul>
|
||||
* <li>当前任务是叶子(无子):返回 normalizeProgress 后的入参,沿用 owner 手填模式。</li>
|
||||
* <li>当前任务是父(≥1 个子):忽略入参变更,保留数据库原值;若入参与原值数值不一致则抛错。
|
||||
* 这样前端把读取到的 progressRate 原样回传不会触发拒错。</li>
|
||||
* </ul>
|
||||
*/
|
||||
private BigDecimal resolveProgressRateOnUpdate(ProjectTaskDO existing, BigDecimal requested) {
|
||||
boolean isParent = projectTaskMapper.countChildrenByParentTaskId(existing.getId()) > 0;
|
||||
if (!isParent) {
|
||||
return normalizeProgress(requested);
|
||||
}
|
||||
if (requested != null && !progressNumericallyEquals(requested, existing.getProgressRate())) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_PROGRESS_PARENT_NOT_EDITABLE);
|
||||
}
|
||||
return existing.getProgressRate();
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验"叶子转父"前置条件:仅当 parentTask 当前还是叶子(无任何子任务)时校验
|
||||
* 自身进度=0 且没有任何工时记录。已是父任务则直接放行。
|
||||
@@ -680,8 +1083,9 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
}
|
||||
|
||||
private void applyLifecycle(ProjectTaskRespVO respVO) {
|
||||
// 传入 ownerId 用于 availableActions 的 owner-only 字段硬卡过滤(spec §7.1)
|
||||
ProjectTaskStatusViewService.ProjectTaskLifecycleView lifecycle =
|
||||
projectTaskStatusViewService.getLifecycle(respVO.getStatusCode());
|
||||
projectTaskStatusViewService.getLifecycle(respVO.getStatusCode(), respVO.getOwnerId());
|
||||
respVO.setStatusName(lifecycle.statusName());
|
||||
respVO.setTerminal(lifecycle.terminal());
|
||||
respVO.setAllowEdit(lifecycle.allowEdit());
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.njcn.rdms.module.project.service.project.task;
|
||||
|
||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
|
||||
import com.njcn.rdms.module.project.constant.ProjectTaskConstants;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskLifecycleActionRespVO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
|
||||
@@ -12,18 +14,37 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
|
||||
/**
|
||||
* 任务状态生命周期视图。
|
||||
* <p>
|
||||
* {@code getLifecycle} 返回的 {@code availableActions} 按 spec §7.1 实现约束过滤:
|
||||
* <ol>
|
||||
* <li>剔除系统级动作 {@code auto_start}(由工时填报内部触发,不出现在 UI 按钮)。</li>
|
||||
* <li>对 owner-only 状态推进动作(complete / cancel / pause / resume),按 {@code task.ownerId == currentUserId} 字段硬卡过滤,
|
||||
* 与 {@link ProjectTaskServiceImpl#validateOwnerForAction} 同款判定。</li>
|
||||
* </ol>
|
||||
* 非状态动作(delete / 加协办人 / 工时填报等)的权限码 / 字段过滤未纳入本字段,前端按各动作对应权限码与 owner 字段独立判断;
|
||||
* 全量化纳入 {@code availableActions} 留待后续阶段统一改造。
|
||||
*/
|
||||
@Service
|
||||
public class ProjectTaskStatusViewService {
|
||||
|
||||
/**
|
||||
* 状态推进动作中要求 owner-only 字段硬卡的集合,与 ProjectTaskServiceImpl#validateOwnerForAction 一致。
|
||||
*/
|
||||
private static final Set<String> OWNER_ONLY_ACTIONS = Set.of("complete", "cancel", "pause", "resume");
|
||||
|
||||
@Resource
|
||||
private ObjectStatusModelMapper objectStatusModelMapper;
|
||||
@Resource
|
||||
private ObjectStatusTransitionMapper objectStatusTransitionMapper;
|
||||
|
||||
public ProjectTaskLifecycleView getLifecycle(String statusCode) {
|
||||
public ProjectTaskLifecycleView getLifecycle(String statusCode, Long ownerId) {
|
||||
ObjectStatusModelDO statusModel = objectStatusModelMapper
|
||||
.selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, statusCode);
|
||||
if (statusModel == null) {
|
||||
@@ -33,23 +54,31 @@ public class ProjectTaskStatusViewService {
|
||||
statusModel.getStatusName(),
|
||||
statusModel.getTerminalFlag(),
|
||||
statusModel.getAllowEdit(),
|
||||
buildAvailableActions(statusCode)
|
||||
buildAvailableActions(statusCode, ownerId)
|
||||
);
|
||||
}
|
||||
|
||||
private List<ProjectTaskLifecycleActionRespVO> buildAvailableActions(String statusCode) {
|
||||
private List<ProjectTaskLifecycleActionRespVO> buildAvailableActions(String statusCode, Long ownerId) {
|
||||
List<ObjectStatusTransitionDO> transitions = objectStatusTransitionMapper
|
||||
.selectListByObjectTypeAndFromStatus(ProjectTaskConstants.OBJECT_TYPE, statusCode);
|
||||
if (transitions == null || transitions.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return transitions.stream().map(transition -> {
|
||||
ProjectTaskLifecycleActionRespVO action = new ProjectTaskLifecycleActionRespVO();
|
||||
action.setActionCode(transition.getActionCode());
|
||||
action.setActionName(transition.getActionName());
|
||||
action.setNeedReason(transition.getNeedReason());
|
||||
return action;
|
||||
}).toList();
|
||||
Long currentUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
return transitions.stream()
|
||||
// 剔除系统级动作 auto_start:由工时填报触发,不暴露给前端按钮
|
||||
.filter(transition -> !ObjectActivityConstants.TASK_ACTION_AUTO_START.equals(transition.getActionCode()))
|
||||
// owner-only 字段硬卡:非负责人本人时不返回 complete / cancel / pause / resume 动作
|
||||
.filter(transition -> !OWNER_ONLY_ACTIONS.contains(transition.getActionCode())
|
||||
|| (currentUserId != null && Objects.equals(ownerId, currentUserId)))
|
||||
.map(transition -> {
|
||||
ProjectTaskLifecycleActionRespVO action = new ProjectTaskLifecycleActionRespVO();
|
||||
action.setActionCode(transition.getActionCode());
|
||||
action.setActionName(transition.getActionName());
|
||||
action.setNeedReason(transition.getNeedReason());
|
||||
return action;
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
public record ProjectTaskLifecycleView(String statusName,
|
||||
|
||||
@@ -20,13 +20,14 @@ import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskAssigneeDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskAssigneeLogDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionMemberMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeLogMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
||||
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
||||
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
|
||||
import com.njcn.rdms.module.system.api.user.AdminUserApi;
|
||||
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -57,7 +58,7 @@ public class TaskAssigneeServiceImpl implements TaskAssigneeService {
|
||||
@Resource
|
||||
private ProjectTaskMapper projectTaskMapper;
|
||||
@Resource
|
||||
private ExecutionMemberMapper executionMemberMapper;
|
||||
private ExecutionAssigneeMapper executionAssigneeMapper;
|
||||
@Resource
|
||||
private TaskAssigneeMapper taskAssigneeMapper;
|
||||
@Resource
|
||||
@@ -85,6 +86,8 @@ public class TaskAssigneeServiceImpl implements TaskAssigneeService {
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
|
||||
permission = ProjectTaskConstants.PERMISSION_ASSIGNEE)
|
||||
public Long createAssignee(Long projectId, Long executionId, Long taskId, TaskAssigneeSaveReqVO reqVO) {
|
||||
ProjectTaskDO task = validateEditableContext(projectId, executionId, taskId);
|
||||
Long userId = reqVO.getUserId();
|
||||
@@ -94,6 +97,8 @@ public class TaskAssigneeServiceImpl implements TaskAssigneeService {
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
|
||||
permission = ProjectTaskConstants.PERMISSION_ASSIGNEE)
|
||||
public void inactiveAssignee(Long projectId, Long executionId, Long taskId, Long assigneeId,
|
||||
TaskAssigneeInactiveReqVO reqVO) {
|
||||
validateEditableContext(projectId, executionId, taskId);
|
||||
@@ -218,7 +223,7 @@ public class TaskAssigneeServiceImpl implements TaskAssigneeService {
|
||||
if (Objects.equals(userId, ownerId)) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_ASSIGNEE_OWNER_CONFLICT);
|
||||
}
|
||||
if (executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, userId) == null) {
|
||||
if (executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, userId) == null) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_ASSIGNEE_INVALID_MEMBER);
|
||||
}
|
||||
if (taskAssigneeMapper.selectActiveByTaskIdAndUserId(taskId, userId) != null) {
|
||||
|
||||
@@ -5,34 +5,40 @@ import com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog.Tas
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog.TaskWorklogRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog.TaskWorklogSaveReqVO;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 任务工时 Service。
|
||||
* <p>
|
||||
* 决策来源:docs/任务工时与进度模型_业内标杆调研.md。
|
||||
* 核心约束:仅叶子任务可挂工时;填报人限制为 owner + 在岗协办人;时长按分钟存(30 分钟整数倍);
|
||||
* 同一 user × task × work_date 允许多条;改/删自己的,owner 可删别人的。
|
||||
* 决策来源:docs/项目/2026-05-09-task-worklog-design.md。
|
||||
* 核心约束:仅叶子任务可挂工时;填报人限制为 owner + 在岗协办人;
|
||||
* 按段记录(startDate/endDate 必填,单天=二者相等);同人同任务下日期范围禁止重叠;
|
||||
* 颗粒:durationHours 必须 > 0 且为 0.5 的整数倍;
|
||||
* 进度:owner 填报触发任务进度同步(按 endDate 排序取本人最新一条),协作人填报仅本人自评。
|
||||
*/
|
||||
public interface TaskWorklogService {
|
||||
|
||||
/**
|
||||
* 任务工时分页(按 workDate DESC, id DESC)。
|
||||
* 支持按填报人 / 日期区间筛选;上下文校验仅要求项目/执行/任务存在,不要求允许编辑。
|
||||
* 任务工时分页(按 endDate DESC, id DESC)。
|
||||
* 支持按填报人 / 段相交过滤;上下文校验仅要求项目/执行/任务存在,不要求允许编辑。
|
||||
*/
|
||||
PageResult<TaskWorklogRespVO> getWorklogPage(Long projectId, Long executionId, Long taskId,
|
||||
TaskWorklogPageReqVO reqVO);
|
||||
|
||||
/**
|
||||
* 新增工时记录。校验:上下文可编辑、任务为叶子、登录人是 owner 或在岗协办人、时长合法。
|
||||
* 新增工时记录。校验:上下文可编辑、任务为叶子、登录人是 owner 或在岗协办人、
|
||||
* 段日期合法、与已有记录不重叠、时长合法。
|
||||
* 填报人 userId 取登录用户,前端不传。返回新建记录编号。
|
||||
*/
|
||||
Long createWorklog(Long projectId, Long executionId, Long taskId, TaskWorklogSaveReqVO reqVO);
|
||||
|
||||
/**
|
||||
* 修改工时记录(含工作日期/时长/工作内容)。校验:记录归属任务、登录人是该记录原填报人、时长合法。
|
||||
* 修改工时记录(含起止日期/时长/进度/工作内容/附件)。校验:记录归属任务、登录人是该记录原填报人、
|
||||
* 段日期合法、与该用户其他记录不重叠(排除自身)、时长合法。
|
||||
* 不允许修改 taskId / userId(前端不可传)。
|
||||
*/
|
||||
void updateWorklog(Long projectId, Long executionId, Long taskId, Long worklogId,
|
||||
@@ -44,19 +50,20 @@ public interface TaskWorklogService {
|
||||
void deleteWorklog(Long projectId, Long executionId, Long taskId, Long worklogId);
|
||||
|
||||
/**
|
||||
* 批量任务工时汇总(分钟),用于详情/分页装配 totalSpentMinutes,避免 N+1。
|
||||
* 批量任务工时小时数汇总,用于详情/分页装配 totalSpentHours,避免 N+1。
|
||||
* 任务无任何工时时不会出现在结果中(调用方需用默认 0 兜底)。
|
||||
*/
|
||||
Map<Long, Long> sumDurationGroupedByTaskIds(Collection<Long> taskIds);
|
||||
Map<Long, BigDecimal> sumDurationGroupedByTaskIds(Collection<Long> taskIds);
|
||||
|
||||
/**
|
||||
* 单任务工时汇总(分钟)。无记录返回 0。
|
||||
* 单任务工时小时数汇总。无记录返回 0。
|
||||
*/
|
||||
default long sumDurationByTaskId(Long taskId) {
|
||||
default BigDecimal sumDurationByTaskId(Long taskId) {
|
||||
if (taskId == null) {
|
||||
return 0L;
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
return sumDurationGroupedByTaskIds(Collections.singletonList(taskId)).getOrDefault(taskId, 0L);
|
||||
return sumDurationGroupedByTaskIds(Collections.singletonList(taskId))
|
||||
.getOrDefault(taskId, BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -22,13 +22,20 @@ import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskWorklogMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
||||
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
||||
import com.njcn.rdms.module.project.framework.attachment.AttachmentFileIdResolver;
|
||||
import com.njcn.rdms.module.project.framework.attachment.AttachmentValidator;
|
||||
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
|
||||
import com.njcn.rdms.module.project.service.project.task.ProjectTaskService;
|
||||
import com.njcn.rdms.module.system.api.user.AdminUserApi;
|
||||
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
@@ -44,9 +51,9 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
|
||||
public class TaskWorklogServiceImpl implements TaskWorklogService {
|
||||
|
||||
/**
|
||||
* 工时颗粒:30 分钟(0.5 小时)。duration_minutes 必须 > 0 且为本值的整数倍。
|
||||
* 工时颗粒:0.5 小时。durationHours 必须 > 0 且为本值的整数倍。
|
||||
*/
|
||||
private static final int DURATION_GRANULARITY_MINUTES = 30;
|
||||
private static final BigDecimal DURATION_GRANULARITY_HOURS = new BigDecimal("0.5");
|
||||
|
||||
@Resource
|
||||
private ProjectMapper projectMapper;
|
||||
@@ -62,6 +69,15 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
|
||||
private ObjectStatusModelMapper objectStatusModelMapper;
|
||||
@Resource
|
||||
private AdminUserApi adminUserApi;
|
||||
@Resource
|
||||
private AttachmentFileIdResolver attachmentFileIdResolver;
|
||||
/**
|
||||
* 与 ProjectTaskService 互相依赖(ProjectTaskService 也注入本类),用 @Lazy 打破循环。
|
||||
* 仅用于 owner 工时变更后同步任务自身进度并触发父任务汇总。
|
||||
*/
|
||||
@Resource
|
||||
@Lazy
|
||||
private ProjectTaskService projectTaskService;
|
||||
|
||||
@Override
|
||||
public PageResult<TaskWorklogRespVO> getWorklogPage(Long projectId, Long executionId, Long taskId,
|
||||
@@ -84,46 +100,73 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
|
||||
permission = ProjectTaskConstants.PERMISSION_WORKLOG)
|
||||
public Long createWorklog(Long projectId, Long executionId, Long taskId, TaskWorklogSaveReqVO reqVO) {
|
||||
ProjectTaskDO task = validateEditableContext(projectId, executionId, taskId);
|
||||
validateLeafTask(taskId);
|
||||
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
validateFileWorklogPermission(taskId, task.getOwnerId(), loginUserId);
|
||||
validateDurationGranularity(reqVO.getDurationMinutes());
|
||||
validateDateRange(reqVO.getStartDate(), reqVO.getEndDate());
|
||||
validateDurationGranularity(reqVO.getDurationHours());
|
||||
validateNoOverlap(taskId, loginUserId, reqVO.getStartDate(), reqVO.getEndDate(), null);
|
||||
validateProgressMonotonicity(taskId, loginUserId, reqVO.getEndDate(), reqVO.getProgressRate(), null);
|
||||
AttachmentValidator.validate(reqVO.getAttachments());
|
||||
attachmentFileIdResolver.resolve(reqVO.getAttachments());
|
||||
|
||||
TaskWorklogDO worklog = new TaskWorklogDO();
|
||||
worklog.setTaskId(taskId);
|
||||
worklog.setUserId(loginUserId);
|
||||
worklog.setWorkDate(reqVO.getWorkDate());
|
||||
worklog.setDurationMinutes(reqVO.getDurationMinutes());
|
||||
worklog.setWorkContent(normalizeNullableText(reqVO.getWorkContent()));
|
||||
TaskWorklogDO worklog = buildWorklog(taskId, loginUserId, reqVO);
|
||||
taskWorklogMapper.insert(worklog);
|
||||
// 任意填报人(owner / 协办人)都触发任务自动开始(仅当任务仍处初始态时生效),并写入 actualStartDate。
|
||||
// 任务"是否开始"是客观事实,协办人开工同样代表任务已开始,不应等 owner 补工时才反映。
|
||||
projectTaskService.internalAutoStartByWorklog(task);
|
||||
// 仅 owner 填报触发任务进度同步:任务整体进度以 owner 本人最新一条工时为权威源,
|
||||
// 协办人填报的是个体进度,不参与任务整体进度计算。
|
||||
if (Objects.equals(loginUserId, task.getOwnerId())) {
|
||||
projectTaskService.syncOwnerProgressFromLatestWorklog(taskId);
|
||||
}
|
||||
return worklog.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
|
||||
permission = ProjectTaskConstants.PERMISSION_WORKLOG)
|
||||
public void updateWorklog(Long projectId, Long executionId, Long taskId, Long worklogId,
|
||||
TaskWorklogSaveReqVO reqVO) {
|
||||
validateEditableContext(projectId, executionId, taskId);
|
||||
ProjectTaskDO task = validateEditableContext(projectId, executionId, taskId);
|
||||
TaskWorklogDO worklog = loadWorklog(worklogId, taskId);
|
||||
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
// 仅原填报人可改;任务负责人也不能改协办人的记录(避免争议)
|
||||
if (!Objects.equals(worklog.getUserId(), loginUserId)) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_EDIT_NOT_OWN);
|
||||
}
|
||||
validateDurationGranularity(reqVO.getDurationMinutes());
|
||||
validateDateRange(reqVO.getStartDate(), reqVO.getEndDate());
|
||||
validateDurationGranularity(reqVO.getDurationHours());
|
||||
// 与该用户其他工时记录不可重叠(排除自身)
|
||||
validateNoOverlap(taskId, worklog.getUserId(), reqVO.getStartDate(), reqVO.getEndDate(), worklog.getId());
|
||||
validateProgressMonotonicity(taskId, worklog.getUserId(), reqVO.getEndDate(), reqVO.getProgressRate(), worklog.getId());
|
||||
AttachmentValidator.validate(reqVO.getAttachments());
|
||||
attachmentFileIdResolver.resolve(reqVO.getAttachments());
|
||||
|
||||
TaskWorklogDO update = new TaskWorklogDO();
|
||||
update.setId(worklog.getId());
|
||||
update.setWorkDate(reqVO.getWorkDate());
|
||||
update.setDurationMinutes(reqVO.getDurationMinutes());
|
||||
update.setStartDate(reqVO.getStartDate());
|
||||
update.setEndDate(reqVO.getEndDate());
|
||||
update.setDurationHours(reqVO.getDurationHours());
|
||||
update.setProgressRate(reqVO.getProgressRate());
|
||||
update.setWorkContent(normalizeNullableText(reqVO.getWorkContent()));
|
||||
update.setAttachments(reqVO.getAttachments());
|
||||
taskWorklogMapper.updateById(update);
|
||||
// owner 改自己工时:按"owner 最新一条"重算任务进度
|
||||
if (Objects.equals(worklog.getUserId(), task.getOwnerId())) {
|
||||
projectTaskService.syncOwnerProgressFromLatestWorklog(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
|
||||
permission = ProjectTaskConstants.PERMISSION_WORKLOG)
|
||||
public void deleteWorklog(Long projectId, Long executionId, Long taskId, Long worklogId) {
|
||||
ProjectTaskDO task = validateEditableContext(projectId, executionId, taskId);
|
||||
TaskWorklogDO worklog = loadWorklog(worklogId, taskId);
|
||||
@@ -134,10 +177,15 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DELETE_FORBIDDEN);
|
||||
}
|
||||
taskWorklogMapper.deleteById(worklog.getId());
|
||||
// 删的是 owner 自己的工时:按"owner 剩余最新一条"重算任务进度;
|
||||
// 若 owner 已无任何工时,syncOwnerProgressFromLatestWorklog 内部会保留原值不动(不归零)
|
||||
if (Objects.equals(worklog.getUserId(), task.getOwnerId())) {
|
||||
projectTaskService.syncOwnerProgressFromLatestWorklog(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<Long, Long> sumDurationGroupedByTaskIds(Collection<Long> taskIds) {
|
||||
public Map<Long, BigDecimal> sumDurationGroupedByTaskIds(Collection<Long> taskIds) {
|
||||
if (taskIds == null || taskIds.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
@@ -145,12 +193,14 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
Map<Long, Long> result = new HashMap<>(rows.size());
|
||||
Map<Long, BigDecimal> result = new HashMap<>(rows.size());
|
||||
for (Map<String, Object> row : rows) {
|
||||
Object idValue = row.getOrDefault("taskId", row.get("task_id"));
|
||||
Object totalValue = row.get("total");
|
||||
if (idValue instanceof Number idNum && totalValue instanceof Number totalNum) {
|
||||
result.put(idNum.longValue(), totalNum.longValue());
|
||||
if (idValue instanceof Number idNum && totalValue != null) {
|
||||
BigDecimal totalBd = totalValue instanceof BigDecimal bd
|
||||
? bd : new BigDecimal(totalValue.toString());
|
||||
result.put(idNum.longValue(), totalBd);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -158,6 +208,19 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
|
||||
|
||||
// -------------------- 内部辅助 --------------------
|
||||
|
||||
private TaskWorklogDO buildWorklog(Long taskId, Long userId, TaskWorklogSaveReqVO reqVO) {
|
||||
TaskWorklogDO worklog = new TaskWorklogDO();
|
||||
worklog.setTaskId(taskId);
|
||||
worklog.setUserId(userId);
|
||||
worklog.setStartDate(reqVO.getStartDate());
|
||||
worklog.setEndDate(reqVO.getEndDate());
|
||||
worklog.setDurationHours(reqVO.getDurationHours());
|
||||
worklog.setProgressRate(reqVO.getProgressRate());
|
||||
worklog.setWorkContent(normalizeNullableText(reqVO.getWorkContent()));
|
||||
worklog.setAttachments(reqVO.getAttachments());
|
||||
return worklog;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验项目/执行/任务都允许编辑,并返回任务实体。与 TaskAssigneeServiceImpl 同口径。
|
||||
*/
|
||||
@@ -167,10 +230,26 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
|
||||
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
|
||||
validateAllowEdit(ProjectExecutionConstants.OBJECT_TYPE, execution.getStatusCode());
|
||||
ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId);
|
||||
validateAllowEdit(ProjectTaskConstants.OBJECT_TYPE, task.getStatusCode());
|
||||
// 任务层放宽:completed 状态下非 owner(即协办人)允许继续维护自己的工时(§4.2.4 矩阵)。
|
||||
// 其他状态(pending / active / paused / cancelled)仍按 allow_edit 判定,owner 在 completed 下被拦截,
|
||||
// 避免与"完成时硬置进度 100%"冲突。
|
||||
if (!isCompletedAssigneeWorklogContext(task)) {
|
||||
validateAllowEdit(ProjectTaskConstants.OBJECT_TYPE, task.getStatusCode());
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否处于"任务已完成、协办人维护工时"的放行场景。
|
||||
*/
|
||||
private boolean isCompletedAssigneeWorklogContext(ProjectTaskDO task) {
|
||||
if (!"completed".equals(task.getStatusCode())) {
|
||||
return false;
|
||||
}
|
||||
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
return loginUserId != null && !Objects.equals(loginUserId, task.getOwnerId());
|
||||
}
|
||||
|
||||
private void validateExecutionAndTaskExists(Long projectId, Long executionId, Long taskId) {
|
||||
validateExecutionExists(projectId, executionId);
|
||||
validateTaskExists(projectId, executionId, taskId);
|
||||
@@ -244,14 +323,59 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_NOT_OWNER_OR_ASSIGNEE);
|
||||
}
|
||||
|
||||
private void validateDurationGranularity(Integer durationMinutes) {
|
||||
if (durationMinutes == null
|
||||
|| durationMinutes <= 0
|
||||
|| durationMinutes % DURATION_GRANULARITY_MINUTES != 0) {
|
||||
/**
|
||||
* 校验段日期:startDate 不能晚于 endDate(NotNull 已由 Bean Validation 保证)。
|
||||
*/
|
||||
private void validateDateRange(LocalDate startDate, LocalDate endDate) {
|
||||
if (startDate == null || endDate == null || startDate.isAfter(endDate)) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DATE_RANGE_INVALID);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验小时颗粒:> 0 且为 0.5 的整数倍(用 remainder 处理 scale 差异)。
|
||||
*/
|
||||
private void validateDurationGranularity(BigDecimal durationHours) {
|
||||
if (durationHours == null
|
||||
|| durationHours.compareTo(BigDecimal.ZERO) <= 0
|
||||
|| durationHours.remainder(DURATION_GRANULARITY_HOURS).compareTo(BigDecimal.ZERO) != 0) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DURATION_INVALID);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验本次提交的段与该 task × user 的已有工时记录无日期重叠(更新场景需排除自身)。
|
||||
*/
|
||||
private void validateNoOverlap(Long taskId, Long userId, LocalDate startDate, LocalDate endDate, Long excludeId) {
|
||||
if (taskWorklogMapper.existsOverlapping(taskId, userId, startDate, endDate, excludeId)) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DATE_OVERLAP);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验"按 endDate 排序后进度非递减"。仅与目标段相邻的前/后一条比较:
|
||||
* - 早段(endDate 更小)的 progressRate 不得大于本段
|
||||
* - 晚段(endDate 更大)的 progressRate 不得小于本段
|
||||
* progressRate 为 null 时跳过比较;excludeId 用于更新场景排除自身。
|
||||
* 仅校验邻段以避开历史脏数据:库内若已有违反单调的记录,本次更新只要不再扩大违反范围即可放过。
|
||||
*/
|
||||
private void validateProgressMonotonicity(Long taskId, Long userId, LocalDate endDate,
|
||||
BigDecimal progressRate, Long excludeId) {
|
||||
if (progressRate == null || endDate == null) {
|
||||
return;
|
||||
}
|
||||
TaskWorklogDO prev = taskWorklogMapper.selectPrevByEndDate(taskId, userId, endDate, excludeId);
|
||||
if (prev != null && prev.getProgressRate() != null
|
||||
&& prev.getProgressRate().compareTo(progressRate) > 0) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_PROGRESS_NOT_MONOTONIC);
|
||||
}
|
||||
TaskWorklogDO next = taskWorklogMapper.selectNextByEndDate(taskId, userId, endDate, excludeId);
|
||||
if (next != null && next.getProgressRate() != null
|
||||
&& next.getProgressRate().compareTo(progressRate) < 0) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_PROGRESS_NOT_MONOTONIC);
|
||||
}
|
||||
}
|
||||
|
||||
private TaskWorklogDO loadWorklog(Long worklogId, Long taskId) {
|
||||
if (worklogId == null) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_NOT_EXISTS);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.njcn.rdms.module.project.framework.attachment;
|
||||
|
||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem;
|
||||
import com.njcn.rdms.module.system.api.file.FileApi;
|
||||
import com.njcn.rdms.module.system.api.file.dto.FileRespDTO;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class AttachmentFileIdResolverTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private AttachmentFileIdResolver resolver;
|
||||
@Mock
|
||||
private FileApi fileApi;
|
||||
|
||||
@Test
|
||||
void resolve_whenAttachmentIdBlank_shouldFillByUrl() {
|
||||
String url = "http://oss.example.com/task/20260511/test.txt?X-Amz-Signature=abc";
|
||||
AttachmentItem attachment = new AttachmentItem();
|
||||
attachment.setUrl(url);
|
||||
FileRespDTO file = new FileRespDTO();
|
||||
file.setId(2053737548535996418L);
|
||||
when(fileApi.getFileByUrl(url)).thenReturn(success(file));
|
||||
|
||||
resolver.resolve(List.of(attachment));
|
||||
|
||||
assertEquals("2053737548535996418", attachment.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_whenAttachmentIdPresent_shouldNotQueryFileApi() {
|
||||
AttachmentItem attachment = new AttachmentItem();
|
||||
attachment.setId("10001");
|
||||
attachment.setUrl("http://oss.example.com/task/20260511/test.txt");
|
||||
|
||||
resolver.resolve(List.of(attachment));
|
||||
|
||||
verify(fileApi, never()).getFileByUrl(attachment.getUrl());
|
||||
assertEquals("10001", attachment.getId());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.njcn.rdms.module.project.framework.security.service;
|
||||
|
||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
class ProjectObjectAuthorizationServiceTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private ProjectObjectAuthorizationService authorizationService;
|
||||
@Mock
|
||||
private ProjectObjectPermissionService projectObjectPermissionService;
|
||||
|
||||
@Test
|
||||
void checkOwnerOrProjectPermission_whenCurrentUserIsOwner_shouldSkipProjectPermissionCheck() {
|
||||
Long projectId = 1001L;
|
||||
Long ownerUserId = 2001L;
|
||||
|
||||
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(ownerUserId)) {
|
||||
authorizationService.checkOwnerOrProjectPermission(projectId, ownerUserId, "project:task:status");
|
||||
}
|
||||
|
||||
verify(projectObjectPermissionService, never()).checkPermission(projectId, "project:task:status", false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkOwnerOrProjectPermission_whenCurrentUserIsNotOwner_shouldCheckProjectPermission() {
|
||||
Long projectId = 1002L;
|
||||
Long ownerUserId = 2002L;
|
||||
|
||||
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(2999L)) {
|
||||
authorizationService.checkOwnerOrProjectPermission(projectId, ownerUserId, "project:task:status");
|
||||
}
|
||||
|
||||
verify(projectObjectPermissionService).checkPermission(projectId, "project:task:status", false);
|
||||
}
|
||||
|
||||
private MockedStatic<SecurityFrameworkUtils> mockLoginUser(Long loginUserId) {
|
||||
MockedStatic<SecurityFrameworkUtils> mockedStatic = mockStatic(SecurityFrameworkUtils.class);
|
||||
mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);
|
||||
return mockedStatic;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
||||
import com.njcn.rdms.module.project.service.project.permission.VisibilityScope;
|
||||
import com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
@@ -21,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ProjectStatusBoardServiceTest extends BaseMockitoUnitTest {
|
||||
@@ -33,6 +37,17 @@ class ProjectStatusBoardServiceTest extends BaseMockitoUnitTest {
|
||||
private ProjectExecutionMapper projectExecutionMapper;
|
||||
@Mock
|
||||
private ProjectTaskMapper projectTaskMapper;
|
||||
@Mock
|
||||
private VisibilityScopeResolver visibilityScopeResolver;
|
||||
|
||||
/**
|
||||
* 默认让 VisibilityScopeResolver 放行(seesAll=true),既有看板用例不关心 scope。
|
||||
*/
|
||||
@BeforeEach
|
||||
void setupVisibilityScopeAll() {
|
||||
lenient().when(visibilityScopeResolver.resolveForProject(any(), any())).thenReturn(VisibilityScope.all());
|
||||
lenient().when(visibilityScopeResolver.resolveForExecution(any(), any(), any())).thenReturn(VisibilityScope.all());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getExecutionStatusBoard_shouldReturnEnabledStatusesInSortOrderAndSumCounts() {
|
||||
@@ -44,16 +59,16 @@ class ProjectStatusBoardServiceTest extends BaseMockitoUnitTest {
|
||||
createStatus("cancelled", "已取消", 50, true),
|
||||
createStatus("disabled", "已停用", 60, false, 1)
|
||||
));
|
||||
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(ProjectExecutionStatusBoardReqVO.class), eq("pending")))
|
||||
.thenReturn(3);
|
||||
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(ProjectExecutionStatusBoardReqVO.class), eq("active")))
|
||||
.thenReturn(8);
|
||||
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(ProjectExecutionStatusBoardReqVO.class), eq("paused")))
|
||||
.thenReturn(2);
|
||||
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(ProjectExecutionStatusBoardReqVO.class), eq("completed")))
|
||||
.thenReturn(4);
|
||||
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(ProjectExecutionStatusBoardReqVO.class), eq("cancelled")))
|
||||
.thenReturn(1);
|
||||
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(VisibilityScope.class),
|
||||
any(ProjectExecutionStatusBoardReqVO.class), eq("pending"))).thenReturn(3);
|
||||
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(VisibilityScope.class),
|
||||
any(ProjectExecutionStatusBoardReqVO.class), eq("active"))).thenReturn(8);
|
||||
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(VisibilityScope.class),
|
||||
any(ProjectExecutionStatusBoardReqVO.class), eq("paused"))).thenReturn(2);
|
||||
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(VisibilityScope.class),
|
||||
any(ProjectExecutionStatusBoardReqVO.class), eq("completed"))).thenReturn(4);
|
||||
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(VisibilityScope.class),
|
||||
any(ProjectExecutionStatusBoardReqVO.class), eq("cancelled"))).thenReturn(1);
|
||||
|
||||
ProjectExecutionStatusBoardReqVO reqVO = new ProjectExecutionStatusBoardReqVO();
|
||||
reqVO.setKeyword("接口");
|
||||
@@ -80,20 +95,20 @@ class ProjectStatusBoardServiceTest extends BaseMockitoUnitTest {
|
||||
when(objectStatusModelMapper.selectListByObjectTypeEnabled("task")).thenReturn(List.of(
|
||||
createStatus("pending", "待开始", 10, false),
|
||||
createStatus("active", "进行中", 20, false),
|
||||
createStatus("blocked", "已阻塞", 30, false),
|
||||
createStatus("paused", "已暂停", 30, false),
|
||||
createStatus("completed", "已完成", 40, true),
|
||||
createStatus("cancelled", "已取消", 50, true)
|
||||
));
|
||||
when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L),
|
||||
any(ProjectTaskStatusBoardReqVO.class), eq("pending"))).thenReturn(5);
|
||||
any(VisibilityScope.class), any(ProjectTaskStatusBoardReqVO.class), eq("pending"))).thenReturn(5);
|
||||
when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L),
|
||||
any(ProjectTaskStatusBoardReqVO.class), eq("active"))).thenReturn(12);
|
||||
any(VisibilityScope.class), any(ProjectTaskStatusBoardReqVO.class), eq("active"))).thenReturn(12);
|
||||
when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L),
|
||||
any(ProjectTaskStatusBoardReqVO.class), eq("blocked"))).thenReturn(2);
|
||||
any(VisibilityScope.class), any(ProjectTaskStatusBoardReqVO.class), eq("paused"))).thenReturn(2);
|
||||
when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L),
|
||||
any(ProjectTaskStatusBoardReqVO.class), eq("completed"))).thenReturn(4);
|
||||
any(VisibilityScope.class), any(ProjectTaskStatusBoardReqVO.class), eq("completed"))).thenReturn(4);
|
||||
when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L),
|
||||
any(ProjectTaskStatusBoardReqVO.class), eq("cancelled"))).thenReturn(1);
|
||||
any(VisibilityScope.class), any(ProjectTaskStatusBoardReqVO.class), eq("cancelled"))).thenReturn(1);
|
||||
|
||||
ProjectTaskStatusBoardReqVO reqVO = new ProjectTaskStatusBoardReqVO();
|
||||
reqVO.setKeyword("任务");
|
||||
@@ -107,7 +122,7 @@ class ProjectStatusBoardServiceTest extends BaseMockitoUnitTest {
|
||||
assertEquals(5, result.getItems().size());
|
||||
assertEquals("pending", result.getItems().get(0).getStatusCode());
|
||||
assertEquals("active", result.getItems().get(1).getStatusCode());
|
||||
assertEquals("blocked", result.getItems().get(2).getStatusCode());
|
||||
assertEquals("paused", result.getItems().get(2).getStatusCode());
|
||||
assertEquals("completed", result.getItems().get(3).getStatusCode());
|
||||
assertEquals("cancelled", result.getItems().get(4).getStatusCode());
|
||||
}
|
||||
|
||||
@@ -3,23 +3,23 @@ package com.njcn.rdms.module.project.service.project.execution;
|
||||
import com.njcn.rdms.framework.common.exception.ServiceException;
|
||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberInactiveReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberLogPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberLogRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberSaveReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeInactiveReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeLogPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeLogRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeSaveReqVO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionMemberDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionMemberLogDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeLogDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
|
||||
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionMemberLogMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionMemberMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeLogMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
||||
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
||||
@@ -46,18 +46,18 @@ import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest {
|
||||
class ProjectExecutionAssigneeServiceImplTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private ProjectExecutionMemberServiceImpl projectExecutionMemberService;
|
||||
private ProjectExecutionAssigneeServiceImpl projectExecutionAssigneeService;
|
||||
@Mock
|
||||
private ProjectMapper projectMapper;
|
||||
@Mock
|
||||
private ProjectExecutionMapper projectExecutionMapper;
|
||||
@Mock
|
||||
private ExecutionMemberMapper executionMemberMapper;
|
||||
private ExecutionAssigneeMapper executionAssigneeMapper;
|
||||
@Mock
|
||||
private ExecutionMemberLogMapper executionMemberLogMapper;
|
||||
private ExecutionAssigneeLogMapper executionAssigneeLogMapper;
|
||||
@Mock
|
||||
private UserObjectRoleMapper userObjectRoleMapper;
|
||||
@Mock
|
||||
@@ -72,51 +72,51 @@ class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest {
|
||||
private static final Long OWNER_ID = 3001L;
|
||||
private static final Long USER_ID = 3002L;
|
||||
|
||||
// -------------------- getExecutionMemberList --------------------
|
||||
// -------------------- getExecutionAssigneeList --------------------
|
||||
|
||||
@Test
|
||||
void getExecutionMemberList_shouldReturnOnlyActiveSegments() {
|
||||
void getExecutionAssigneeList_shouldReturnOnlyActiveSegments() {
|
||||
when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject());
|
||||
when(projectExecutionMapper.selectByProjectIdAndId(PROJECT_ID, EXECUTION_ID))
|
||||
.thenReturn(createExecution());
|
||||
when(executionMemberMapper.selectActiveListByExecutionId(EXECUTION_ID))
|
||||
when(executionAssigneeMapper.selectActiveListByExecutionId(EXECUTION_ID))
|
||||
.thenReturn(List.of(createMember(7001L, USER_ID, null)));
|
||||
|
||||
List<ExecutionMemberRespVO> respVOList = projectExecutionMemberService
|
||||
.getExecutionMemberList(PROJECT_ID, EXECUTION_ID);
|
||||
List<ExecutionAssigneeRespVO> respVOList = projectExecutionAssigneeService
|
||||
.getExecutionAssigneeList(PROJECT_ID, EXECUTION_ID);
|
||||
|
||||
assertEquals(1, respVOList.size());
|
||||
assertEquals(USER_ID, respVOList.get(0).getUserId());
|
||||
assertNull(respVOList.get(0).getRemovedAt());
|
||||
verify(executionMemberMapper, never()).selectListByExecutionId(any());
|
||||
verify(executionAssigneeMapper, never()).selectListByExecutionId(any());
|
||||
}
|
||||
|
||||
// -------------------- createExecutionMember --------------------
|
||||
// -------------------- createExecutionAssignee --------------------
|
||||
|
||||
@Test
|
||||
void createExecutionMember_byUserNeverJoined_shouldInsertActiveAndWriteJoinLog() {
|
||||
void createExecutionAssignee_byUserNeverJoined_shouldInsertActiveAndWriteJoinLog() {
|
||||
stubEditableContext();
|
||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", PROJECT_ID, USER_ID))
|
||||
.thenReturn(createProjectMember());
|
||||
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID)).thenReturn(null);
|
||||
when(executionMemberMapper.insert(any(ExecutionMemberDO.class))).thenAnswer(inv -> {
|
||||
ExecutionMemberDO m = inv.getArgument(0);
|
||||
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID)).thenReturn(null);
|
||||
when(executionAssigneeMapper.insert(any(ExecutionAssigneeDO.class))).thenAnswer(inv -> {
|
||||
ExecutionAssigneeDO m = inv.getArgument(0);
|
||||
m.setId(7001L);
|
||||
return 1;
|
||||
});
|
||||
|
||||
ExecutionMemberSaveReqVO reqVO = new ExecutionMemberSaveReqVO();
|
||||
ExecutionAssigneeSaveReqVO reqVO = new ExecutionAssigneeSaveReqVO();
|
||||
reqVO.setUserId(USER_ID);
|
||||
|
||||
Long memberId = projectExecutionMemberService.createExecutionMember(PROJECT_ID, EXECUTION_ID, reqVO);
|
||||
Long memberId = projectExecutionAssigneeService.createExecutionAssignee(PROJECT_ID, EXECUTION_ID, reqVO);
|
||||
|
||||
assertEquals(7001L, memberId);
|
||||
ArgumentCaptor<ExecutionMemberDO> activeCaptor = ArgumentCaptor.forClass(ExecutionMemberDO.class);
|
||||
verify(executionMemberMapper).insert(activeCaptor.capture());
|
||||
ArgumentCaptor<ExecutionAssigneeDO> activeCaptor = ArgumentCaptor.forClass(ExecutionAssigneeDO.class);
|
||||
verify(executionAssigneeMapper).insert(activeCaptor.capture());
|
||||
assertNull(activeCaptor.getValue().getRemovedAt());
|
||||
assertNull(activeCaptor.getValue().getRemovedReason());
|
||||
ArgumentCaptor<ExecutionMemberLogDO> logCaptor = ArgumentCaptor.forClass(ExecutionMemberLogDO.class);
|
||||
verify(executionMemberLogMapper).insert(logCaptor.capture());
|
||||
ArgumentCaptor<ExecutionAssigneeLogDO> logCaptor = ArgumentCaptor.forClass(ExecutionAssigneeLogDO.class);
|
||||
verify(executionAssigneeLogMapper).insert(logCaptor.capture());
|
||||
assertEquals("join", logCaptor.getValue().getActionType());
|
||||
assertEquals(USER_ID, logCaptor.getValue().getUserId());
|
||||
assertNull(logCaptor.getValue().getReason());
|
||||
@@ -125,49 +125,49 @@ class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void createExecutionMember_whenInactiveSegmentExists_shouldInsertNewSegmentAndPreserveHistoricalReason() {
|
||||
void createExecutionAssignee_whenInactiveSegmentExists_shouldInsertNewSegmentAndPreserveHistoricalReason() {
|
||||
// B 模型:用户曾失效,重新加入新插一段,旧段不动
|
||||
stubEditableContext();
|
||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", PROJECT_ID, USER_ID))
|
||||
.thenReturn(createProjectMember());
|
||||
// 当前没有活跃段(旧段已失效),通过 active-only 校验
|
||||
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID)).thenReturn(null);
|
||||
when(executionMemberMapper.insert(any(ExecutionMemberDO.class))).thenAnswer(inv -> {
|
||||
ExecutionMemberDO m = inv.getArgument(0);
|
||||
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID)).thenReturn(null);
|
||||
when(executionAssigneeMapper.insert(any(ExecutionAssigneeDO.class))).thenAnswer(inv -> {
|
||||
ExecutionAssigneeDO m = inv.getArgument(0);
|
||||
m.setId(7002L);
|
||||
return 1;
|
||||
});
|
||||
|
||||
ExecutionMemberSaveReqVO reqVO = new ExecutionMemberSaveReqVO();
|
||||
ExecutionAssigneeSaveReqVO reqVO = new ExecutionAssigneeSaveReqVO();
|
||||
reqVO.setUserId(USER_ID);
|
||||
|
||||
Long memberId = projectExecutionMemberService.createExecutionMember(PROJECT_ID, EXECUTION_ID, reqVO);
|
||||
Long memberId = projectExecutionAssigneeService.createExecutionAssignee(PROJECT_ID, EXECUTION_ID, reqVO);
|
||||
|
||||
assertEquals(7002L, memberId);
|
||||
// 不应触碰旧段(updateById 不应被调用)
|
||||
verify(executionMemberMapper, never()).updateById(any(ExecutionMemberDO.class));
|
||||
verify(executionMemberMapper).insert(any(ExecutionMemberDO.class));
|
||||
verify(executionAssigneeMapper, never()).updateById(any(ExecutionAssigneeDO.class));
|
||||
verify(executionAssigneeMapper).insert(any(ExecutionAssigneeDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createExecutionMember_whenAlreadyActive_shouldThrowAlreadyExists() {
|
||||
void createExecutionAssignee_whenAlreadyActive_shouldThrowAlreadyExists() {
|
||||
stubEditableContext();
|
||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", PROJECT_ID, USER_ID))
|
||||
.thenReturn(createProjectMember());
|
||||
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID))
|
||||
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID))
|
||||
.thenReturn(createMember(7001L, USER_ID, null));
|
||||
|
||||
ExecutionMemberSaveReqVO reqVO = new ExecutionMemberSaveReqVO();
|
||||
ExecutionAssigneeSaveReqVO reqVO = new ExecutionAssigneeSaveReqVO();
|
||||
reqVO.setUserId(USER_ID);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> projectExecutionMemberService.createExecutionMember(PROJECT_ID, EXECUTION_ID, reqVO));
|
||||
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_ALREADY_EXISTS.getCode(), ex.getCode());
|
||||
verify(executionMemberMapper, never()).insert(any(ExecutionMemberDO.class));
|
||||
() -> projectExecutionAssigneeService.createExecutionAssignee(PROJECT_ID, EXECUTION_ID, reqVO));
|
||||
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_ALREADY_EXISTS.getCode(), ex.getCode());
|
||||
verify(executionAssigneeMapper, never()).insert(any(ExecutionAssigneeDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createExecutionMember_whenExecutionPaused_shouldThrowNotAllowEdit() {
|
||||
void createExecutionAssignee_whenExecutionPaused_shouldThrowNotAllowEdit() {
|
||||
ProjectExecutionDO execution = createExecution();
|
||||
execution.setStatusCode("paused");
|
||||
when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject());
|
||||
@@ -177,32 +177,32 @@ class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest {
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "paused"))
|
||||
.thenReturn(createStatus("execution", "paused", false));
|
||||
|
||||
ExecutionMemberSaveReqVO reqVO = new ExecutionMemberSaveReqVO();
|
||||
ExecutionAssigneeSaveReqVO reqVO = new ExecutionAssigneeSaveReqVO();
|
||||
reqVO.setUserId(USER_ID);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> projectExecutionMemberService.createExecutionMember(PROJECT_ID, EXECUTION_ID, reqVO));
|
||||
() -> projectExecutionAssigneeService.createExecutionAssignee(PROJECT_ID, EXECUTION_ID, reqVO));
|
||||
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_NOT_ALLOW_EDIT.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
// -------------------- inactiveExecutionMember --------------------
|
||||
// -------------------- inactiveExecutionAssignee --------------------
|
||||
|
||||
@Test
|
||||
void inactiveExecutionMember_shouldSetRemovedAtAndWriteInactiveLog() {
|
||||
ExecutionMemberDO member = createMember(7001L, USER_ID, null);
|
||||
void inactiveExecutionAssignee_shouldSetRemovedAtAndWriteInactiveLog() {
|
||||
ExecutionAssigneeDO member = createMember(7001L, USER_ID, null);
|
||||
stubEditableContext();
|
||||
when(executionMemberMapper.selectByIdAndExecutionId(7001L, EXECUTION_ID)).thenReturn(member);
|
||||
when(executionAssigneeMapper.selectByIdAndExecutionId(7001L, EXECUTION_ID)).thenReturn(member);
|
||||
|
||||
ExecutionMemberInactiveReqVO reqVO = new ExecutionMemberInactiveReqVO();
|
||||
ExecutionAssigneeInactiveReqVO reqVO = new ExecutionAssigneeInactiveReqVO();
|
||||
reqVO.setReason("阶段性退出");
|
||||
|
||||
projectExecutionMemberService.inactiveExecutionMember(PROJECT_ID, EXECUTION_ID, 7001L, reqVO);
|
||||
projectExecutionAssigneeService.inactiveExecutionAssignee(PROJECT_ID, EXECUTION_ID, 7001L, reqVO);
|
||||
|
||||
assertNotNull(member.getRemovedAt());
|
||||
assertEquals("阶段性退出", member.getRemovedReason());
|
||||
verify(executionMemberMapper).updateById(member);
|
||||
ArgumentCaptor<ExecutionMemberLogDO> logCaptor = ArgumentCaptor.forClass(ExecutionMemberLogDO.class);
|
||||
verify(executionMemberLogMapper).insert(logCaptor.capture());
|
||||
verify(executionAssigneeMapper).updateById(member);
|
||||
ArgumentCaptor<ExecutionAssigneeLogDO> logCaptor = ArgumentCaptor.forClass(ExecutionAssigneeLogDO.class);
|
||||
verify(executionAssigneeLogMapper).insert(logCaptor.capture());
|
||||
assertEquals("inactive", logCaptor.getValue().getActionType());
|
||||
assertEquals(USER_ID, logCaptor.getValue().getUserId());
|
||||
assertEquals("阶段性退出", logCaptor.getValue().getReason());
|
||||
@@ -210,22 +210,22 @@ class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void inactiveExecutionMember_whenAlreadyInactive_shouldThrowNotActive() {
|
||||
ExecutionMemberDO inactiveMember = createMember(7001L, USER_ID, LocalDateTime.now().minusDays(1));
|
||||
void inactiveExecutionAssignee_whenAlreadyInactive_shouldThrowNotActive() {
|
||||
ExecutionAssigneeDO inactiveMember = createMember(7001L, USER_ID, LocalDateTime.now().minusDays(1));
|
||||
stubEditableContext();
|
||||
when(executionMemberMapper.selectByIdAndExecutionId(7001L, EXECUTION_ID)).thenReturn(inactiveMember);
|
||||
when(executionAssigneeMapper.selectByIdAndExecutionId(7001L, EXECUTION_ID)).thenReturn(inactiveMember);
|
||||
|
||||
ExecutionMemberInactiveReqVO reqVO = new ExecutionMemberInactiveReqVO();
|
||||
ExecutionAssigneeInactiveReqVO reqVO = new ExecutionAssigneeInactiveReqVO();
|
||||
reqVO.setReason("再次失效");
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> projectExecutionMemberService.inactiveExecutionMember(PROJECT_ID, EXECUTION_ID, 7001L, reqVO));
|
||||
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_NOT_ACTIVE.getCode(), ex.getCode());
|
||||
verify(executionMemberMapper, never()).updateById(any(ExecutionMemberDO.class));
|
||||
() -> projectExecutionAssigneeService.inactiveExecutionAssignee(PROJECT_ID, EXECUTION_ID, 7001L, reqVO));
|
||||
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_NOT_ACTIVE.getCode(), ex.getCode());
|
||||
verify(executionAssigneeMapper, never()).updateById(any(ExecutionAssigneeDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void inactiveExecutionMember_whenExecutionPaused_shouldThrowNotAllowEdit() {
|
||||
void inactiveExecutionAssignee_whenExecutionPaused_shouldThrowNotAllowEdit() {
|
||||
ProjectExecutionDO execution = createExecution();
|
||||
execution.setStatusCode("paused");
|
||||
when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject());
|
||||
@@ -235,33 +235,33 @@ class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest {
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "paused"))
|
||||
.thenReturn(createStatus("execution", "paused", false));
|
||||
|
||||
ExecutionMemberInactiveReqVO reqVO = new ExecutionMemberInactiveReqVO();
|
||||
ExecutionAssigneeInactiveReqVO reqVO = new ExecutionAssigneeInactiveReqVO();
|
||||
reqVO.setReason("暂停态退出");
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> projectExecutionMemberService.inactiveExecutionMember(PROJECT_ID, EXECUTION_ID, 7001L, reqVO));
|
||||
() -> projectExecutionAssigneeService.inactiveExecutionAssignee(PROJECT_ID, EXECUTION_ID, 7001L, reqVO));
|
||||
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_NOT_ALLOW_EDIT.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
// -------------------- getExecutionMemberLogPage --------------------
|
||||
// -------------------- getExecutionAssigneeLogPage --------------------
|
||||
|
||||
@Test
|
||||
void getExecutionMemberLogPage_shouldDelegateToMapperAndConvertVO() {
|
||||
void getExecutionAssigneeLogPage_shouldDelegateToMapperAndConvertVO() {
|
||||
when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject());
|
||||
when(projectExecutionMapper.selectByProjectIdAndId(PROJECT_ID, EXECUTION_ID))
|
||||
.thenReturn(createExecution());
|
||||
ExecutionMemberLogDO logRow = new ExecutionMemberLogDO();
|
||||
ExecutionAssigneeLogDO logRow = new ExecutionAssigneeLogDO();
|
||||
logRow.setId(12001L);
|
||||
logRow.setExecutionId(EXECUTION_ID);
|
||||
logRow.setUserId(USER_ID);
|
||||
logRow.setActionType("join");
|
||||
logRow.setActionTime(LocalDateTime.now());
|
||||
when(executionMemberLogMapper.selectPageByExecutionId(eq(EXECUTION_ID), any(ExecutionMemberLogPageReqVO.class)))
|
||||
when(executionAssigneeLogMapper.selectPageByExecutionId(eq(EXECUTION_ID), any(ExecutionAssigneeLogPageReqVO.class)))
|
||||
.thenReturn(new PageResult<>(List.of(logRow), 1L));
|
||||
|
||||
ExecutionMemberLogPageReqVO reqVO = new ExecutionMemberLogPageReqVO();
|
||||
PageResult<ExecutionMemberLogRespVO> page = projectExecutionMemberService
|
||||
.getExecutionMemberLogPage(PROJECT_ID, EXECUTION_ID, reqVO);
|
||||
ExecutionAssigneeLogPageReqVO reqVO = new ExecutionAssigneeLogPageReqVO();
|
||||
PageResult<ExecutionAssigneeLogRespVO> page = projectExecutionAssigneeService
|
||||
.getExecutionAssigneeLogPage(PROJECT_ID, EXECUTION_ID, reqVO);
|
||||
|
||||
assertEquals(1L, page.getTotal());
|
||||
assertEquals("join", page.getList().get(0).getActionType());
|
||||
@@ -269,11 +269,11 @@ class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void getExecutionMemberLogPage_whenSnapshotExists_shouldUseCurrentNicknamesByAdminUserApi() {
|
||||
void getExecutionAssigneeLogPage_whenSnapshotExists_shouldUseCurrentNicknamesByAdminUserApi() {
|
||||
when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject());
|
||||
when(projectExecutionMapper.selectByProjectIdAndId(PROJECT_ID, EXECUTION_ID))
|
||||
.thenReturn(createExecution());
|
||||
ExecutionMemberLogDO logRow = new ExecutionMemberLogDO();
|
||||
ExecutionAssigneeLogDO logRow = new ExecutionAssigneeLogDO();
|
||||
logRow.setId(12002L);
|
||||
logRow.setExecutionId(EXECUTION_ID);
|
||||
logRow.setUserId(USER_ID);
|
||||
@@ -282,29 +282,29 @@ class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest {
|
||||
logRow.setOperatorUserId(OWNER_ID);
|
||||
logRow.setOperatorNicknameSnapshot("旧操作人名");
|
||||
logRow.setActionTime(LocalDateTime.now());
|
||||
when(executionMemberLogMapper.selectPageByExecutionId(eq(EXECUTION_ID), any(ExecutionMemberLogPageReqVO.class)))
|
||||
when(executionAssigneeLogMapper.selectPageByExecutionId(eq(EXECUTION_ID), any(ExecutionAssigneeLogPageReqVO.class)))
|
||||
.thenReturn(new PageResult<>(List.of(logRow), 1L));
|
||||
when(adminUserApi.getUserMap(anyCollection())).thenReturn(Map.of(
|
||||
USER_ID, createUser("陈道飞"),
|
||||
OWNER_ID, createUser("灿能源码")));
|
||||
|
||||
ExecutionMemberLogPageReqVO reqVO = new ExecutionMemberLogPageReqVO();
|
||||
PageResult<ExecutionMemberLogRespVO> page = projectExecutionMemberService
|
||||
.getExecutionMemberLogPage(PROJECT_ID, EXECUTION_ID, reqVO);
|
||||
ExecutionAssigneeLogPageReqVO reqVO = new ExecutionAssigneeLogPageReqVO();
|
||||
PageResult<ExecutionAssigneeLogRespVO> page = projectExecutionAssigneeService
|
||||
.getExecutionAssigneeLogPage(PROJECT_ID, EXECUTION_ID, reqVO);
|
||||
|
||||
assertEquals("陈道飞", page.getList().get(0).getUserNicknameSnapshot());
|
||||
assertEquals("灿能源码", page.getList().get(0).getOperatorNicknameSnapshot());
|
||||
verify(adminUserApi).getUserMap(anyCollection());
|
||||
}
|
||||
|
||||
// -------------------- writeMemberLog 直接调用 --------------------
|
||||
// -------------------- writeAssigneeLog 直接调用 --------------------
|
||||
|
||||
@Test
|
||||
void writeMemberLog_shouldInsertWithProvidedFields() {
|
||||
projectExecutionMemberService.writeMemberLog(EXECUTION_ID, USER_ID, "owner_transfer_in", "test reason");
|
||||
void writeAssigneeLog_shouldInsertWithProvidedFields() {
|
||||
projectExecutionAssigneeService.writeAssigneeLog(EXECUTION_ID, USER_ID, "owner_transfer_in", "test reason");
|
||||
|
||||
ArgumentCaptor<ExecutionMemberLogDO> captor = ArgumentCaptor.forClass(ExecutionMemberLogDO.class);
|
||||
verify(executionMemberLogMapper).insert(captor.capture());
|
||||
ArgumentCaptor<ExecutionAssigneeLogDO> captor = ArgumentCaptor.forClass(ExecutionAssigneeLogDO.class);
|
||||
verify(executionAssigneeLogMapper).insert(captor.capture());
|
||||
assertEquals(EXECUTION_ID, captor.getValue().getExecutionId());
|
||||
assertEquals(USER_ID, captor.getValue().getUserId());
|
||||
assertEquals("owner_transfer_in", captor.getValue().getActionType());
|
||||
@@ -346,8 +346,8 @@ class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest {
|
||||
return execution;
|
||||
}
|
||||
|
||||
private ExecutionMemberDO createMember(Long id, Long userId, LocalDateTime removedAt) {
|
||||
ExecutionMemberDO member = new ExecutionMemberDO();
|
||||
private ExecutionAssigneeDO createMember(Long id, Long userId, LocalDateTime removedAt) {
|
||||
ExecutionAssigneeDO member = new ExecutionAssigneeDO();
|
||||
member.setId(id);
|
||||
member.setExecutionId(EXECUTION_ID);
|
||||
member.setUserId(userId);
|
||||
@@ -5,13 +5,15 @@ import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionOwnerChangeReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionSaveReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionCreateReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionUpdateReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusActionReqVO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
|
||||
import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionMemberDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionStatusLogDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
|
||||
@@ -19,14 +21,17 @@ import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransition
|
||||
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionMemberMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionStatusLogMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper;
|
||||
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
||||
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
|
||||
import com.njcn.rdms.module.system.api.dict.DictDataApi;
|
||||
import com.njcn.rdms.module.system.api.user.AdminUserApi;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
@@ -34,7 +39,11 @@ import org.mockito.Mock;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
@@ -42,7 +51,10 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -56,7 +68,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
@Mock
|
||||
private ProjectExecutionMapper projectExecutionMapper;
|
||||
@Mock
|
||||
private ExecutionMemberMapper executionMemberMapper;
|
||||
private ExecutionAssigneeMapper executionAssigneeMapper;
|
||||
@Mock
|
||||
private UserObjectRoleMapper userObjectRoleMapper;
|
||||
@Mock
|
||||
@@ -68,19 +80,39 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
@Mock
|
||||
private ProjectExecutionStatusLogMapper projectExecutionStatusLogMapper;
|
||||
@Mock
|
||||
private ProjectTaskMapper projectTaskMapper;
|
||||
@Mock
|
||||
private DictDataApi dictDataApi;
|
||||
@Mock
|
||||
private ProjectExecutionMemberService projectExecutionMemberService;
|
||||
private AdminUserApi adminUserApi;
|
||||
@Mock
|
||||
private ProjectExecutionStatusViewService projectExecutionStatusViewService;
|
||||
@Mock
|
||||
private ProjectExecutionAssigneeService projectExecutionAssigneeService;
|
||||
@Mock
|
||||
private com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver visibilityScopeResolver;
|
||||
|
||||
/**
|
||||
* 默认让 VisibilityScopeResolver 放行(seesAll=true),既有测试无需关心 scope。
|
||||
* 真正需要测试 scope 行为的用例可在方法内显式覆盖。
|
||||
*/
|
||||
@BeforeEach
|
||||
void setupVisibilityScopeAll() {
|
||||
lenient().when(visibilityScopeResolver.resolveForProject(any(), any()))
|
||||
.thenReturn(com.njcn.rdms.module.project.service.project.permission.VisibilityScope.all());
|
||||
lenient().when(visibilityScopeResolver.resolveForExecution(any(), any(), any()))
|
||||
.thenReturn(com.njcn.rdms.module.project.service.project.permission.VisibilityScope.all());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createExecution_shouldInsertPendingExecutionAndMembers() {
|
||||
Long projectId = 2001L;
|
||||
ProjectExecutionSaveReqVO reqVO = new ProjectExecutionSaveReqVO();
|
||||
ProjectExecutionCreateReqVO reqVO = new ProjectExecutionCreateReqVO();
|
||||
reqVO.setExecutionName("后端接口联调");
|
||||
reqVO.setExecutionType("feature");
|
||||
reqVO.setOwnerId(3001L);
|
||||
reqVO.setProjectRequirementId(null);
|
||||
reqVO.setMemberUserIds(List.of(3002L, 3003L));
|
||||
reqVO.setAssigneeUserIds(List.of(3002L, 3003L));
|
||||
|
||||
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
|
||||
@@ -113,14 +145,14 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
assertNull(executionCaptor.getValue().getProjectRequirementId());
|
||||
assertNull(executionCaptor.getValue().getActualStartDate());
|
||||
assertNull(executionCaptor.getValue().getActualEndDate());
|
||||
verify(executionMemberMapper, times(2)).insert(any(ExecutionMemberDO.class));
|
||||
verify(executionAssigneeMapper, times(2)).insert(any(ExecutionAssigneeDO.class));
|
||||
verify(bizAuditLogMapper, atLeastOnce()).insert(any(BizAuditLogDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createExecution_whenRequirementIdNotNull_shouldThrowNotReady() {
|
||||
Long projectId = 2001L;
|
||||
ProjectExecutionSaveReqVO reqVO = new ProjectExecutionSaveReqVO();
|
||||
ProjectExecutionCreateReqVO reqVO = new ProjectExecutionCreateReqVO();
|
||||
reqVO.setExecutionName("后端接口联调");
|
||||
reqVO.setOwnerId(3001L);
|
||||
reqVO.setProjectRequirementId(9001L);
|
||||
@@ -138,11 +170,11 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
@Test
|
||||
void createExecution_whenExecutionTypeInvalid_shouldThrow() {
|
||||
Long projectId = 2001L;
|
||||
ProjectExecutionSaveReqVO reqVO = new ProjectExecutionSaveReqVO();
|
||||
ProjectExecutionCreateReqVO reqVO = new ProjectExecutionCreateReqVO();
|
||||
reqVO.setExecutionName("后端接口联调");
|
||||
reqVO.setExecutionType("unknown");
|
||||
reqVO.setOwnerId(3001L);
|
||||
reqVO.setMemberUserIds(List.of(3002L));
|
||||
reqVO.setAssigneeUserIds(List.of(3002L));
|
||||
|
||||
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
|
||||
@@ -162,11 +194,11 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
@Test
|
||||
void createExecution_whenMemberListEmpty_shouldThrowRequired() {
|
||||
Long projectId = 2001L;
|
||||
ProjectExecutionSaveReqVO reqVO = new ProjectExecutionSaveReqVO();
|
||||
ProjectExecutionCreateReqVO reqVO = new ProjectExecutionCreateReqVO();
|
||||
reqVO.setExecutionName("后端接口联调");
|
||||
reqVO.setExecutionType("feature");
|
||||
reqVO.setOwnerId(3001L);
|
||||
reqVO.setMemberUserIds(List.of());
|
||||
reqVO.setAssigneeUserIds(List.of());
|
||||
|
||||
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
|
||||
@@ -180,7 +212,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> projectExecutionService.createExecution(projectId, reqVO));
|
||||
|
||||
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_REQUIRED.getCode(), ex.getCode());
|
||||
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_REQUIRED.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -188,11 +220,10 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
Long projectId = 2001L;
|
||||
Long executionId = 5001L;
|
||||
ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
|
||||
ProjectExecutionSaveReqVO reqVO = new ProjectExecutionSaveReqVO();
|
||||
ProjectExecutionUpdateReqVO reqVO = new ProjectExecutionUpdateReqVO();
|
||||
reqVO.setId(executionId);
|
||||
reqVO.setExecutionName("接口联调-修订");
|
||||
reqVO.setExecutionType("feature");
|
||||
reqVO.setOwnerId(3001L);
|
||||
reqVO.setProjectRequirementId(null);
|
||||
|
||||
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
||||
@@ -237,10 +268,10 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
verify(projectExecutionMapper).updateById(execution);
|
||||
verify(bizAuditLogMapper).insert(any(BizAuditLogDO.class));
|
||||
// 双向写入成员变更历史:原 owner 转出 + 新 owner 转入
|
||||
verify(projectExecutionMemberService).writeMemberLog(executionId, 3001L,
|
||||
ObjectActivityConstants.EXECUTION_MEMBER_LOG_ACTION_OWNER_TRANSFER_OUT, "负责人调整");
|
||||
verify(projectExecutionMemberService).writeMemberLog(executionId, 3002L,
|
||||
ObjectActivityConstants.EXECUTION_MEMBER_LOG_ACTION_OWNER_TRANSFER_IN, "负责人调整");
|
||||
verify(projectExecutionAssigneeService).writeAssigneeLog(executionId, 3001L,
|
||||
ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_OUT, "负责人调整");
|
||||
verify(projectExecutionAssigneeService).writeAssigneeLog(executionId, 3002L,
|
||||
ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_IN, "负责人调整");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -338,10 +369,9 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
Long executionId = 5001L;
|
||||
ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
|
||||
execution.setStatusCode("paused");
|
||||
ProjectExecutionSaveReqVO reqVO = new ProjectExecutionSaveReqVO();
|
||||
ProjectExecutionUpdateReqVO reqVO = new ProjectExecutionUpdateReqVO();
|
||||
reqVO.setId(executionId);
|
||||
reqVO.setExecutionName("接口联调-修订");
|
||||
reqVO.setOwnerId(3001L);
|
||||
|
||||
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
|
||||
@@ -378,6 +408,104 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_NOT_ALLOW_EDIT.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getExecutionRespVO_shouldOverwriteCachedProgressWithAggregatedRootTaskProgress() {
|
||||
Long projectId = 2001L;
|
||||
Long executionId = 5001L;
|
||||
ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
|
||||
execution.setProgressRate(BigDecimal.ZERO);
|
||||
|
||||
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
||||
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
|
||||
when(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId))
|
||||
.thenReturn(new BigDecimal("66.666"));
|
||||
when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
|
||||
.thenReturn(createLifecycleView());
|
||||
|
||||
ProjectExecutionRespVO result = projectExecutionService.getExecutionRespVO(projectId, executionId);
|
||||
|
||||
assertEquals(new BigDecimal("66.67"), result.getProgressRate());
|
||||
verify(projectTaskMapper).selectRootTaskAvgProgressByExecutionId(projectId, executionId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getExecutionRespVO_whenExecutionHasNoRootTasks_shouldReturnZeroProgress() {
|
||||
Long projectId = 2001L;
|
||||
Long executionId = 5001L;
|
||||
ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
|
||||
execution.setProgressRate(new BigDecimal("100.00"));
|
||||
|
||||
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
||||
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
|
||||
when(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId)).thenReturn(null);
|
||||
when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
|
||||
.thenReturn(createLifecycleView());
|
||||
|
||||
ProjectExecutionRespVO result = projectExecutionService.getExecutionRespVO(projectId, executionId);
|
||||
|
||||
assertEquals(new BigDecimal("0.00"), result.getProgressRate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getExecutionRespVOPage_shouldBatchOverwriteCachedProgress() {
|
||||
Long projectId = 2001L;
|
||||
ProjectExecutionPageReqVO reqVO = new ProjectExecutionPageReqVO();
|
||||
reqVO.setPageNo(1);
|
||||
reqVO.setPageSize(10);
|
||||
ProjectExecutionDO first = createExecution(projectId, 5001L, 3001L);
|
||||
first.setProgressRate(BigDecimal.ZERO);
|
||||
ProjectExecutionDO second = createExecution(projectId, 5002L, 3002L);
|
||||
second.setProgressRate(new BigDecimal("100.00"));
|
||||
|
||||
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
||||
when(projectExecutionMapper.selectPageByProjectId(eq(projectId), any(), eq(reqVO)))
|
||||
.thenReturn(new PageResult<>(List.of(first, second), 2L));
|
||||
when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), anyCollection()))
|
||||
.thenReturn(List.of(Map.of("execution_id", 5001L, "progress_rate", new BigDecimal("25.555"))));
|
||||
when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
|
||||
.thenReturn(createLifecycleView());
|
||||
when(projectExecutionStatusViewService.getLifecycle("pending", 3002L))
|
||||
.thenReturn(createLifecycleView());
|
||||
|
||||
PageResult<ProjectExecutionRespVO> result = projectExecutionService.getExecutionRespVOPage(projectId, reqVO);
|
||||
|
||||
assertEquals(new BigDecimal("25.56"), result.getList().get(0).getProgressRate());
|
||||
assertEquals(new BigDecimal("0.00"), result.getList().get(1).getProgressRate());
|
||||
ArgumentCaptor<Collection<Long>> executionIdsCaptor = ArgumentCaptor.forClass(Collection.class);
|
||||
verify(projectTaskMapper).selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), executionIdsCaptor.capture());
|
||||
assertEquals(List.of(5001L, 5002L), List.copyOf(executionIdsCaptor.getValue()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getExecutionRespVOPage_shouldTolerateLooseAggregationRows() {
|
||||
Long projectId = 2001L;
|
||||
ProjectExecutionPageReqVO reqVO = new ProjectExecutionPageReqVO();
|
||||
reqVO.setPageNo(1);
|
||||
reqVO.setPageSize(10);
|
||||
ProjectExecutionDO first = createExecution(projectId, 5001L, 3001L);
|
||||
ProjectExecutionDO second = createExecution(projectId, 5002L, 3002L);
|
||||
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
rows.add(null);
|
||||
rows.add(Map.of("executionId", " 5001 ", "progressRate", " 25.555 "));
|
||||
rows.add(Map.of("executionId", 5002L, "progressRate", 10));
|
||||
|
||||
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
||||
when(projectExecutionMapper.selectPageByProjectId(eq(projectId), any(), eq(reqVO)))
|
||||
.thenReturn(new PageResult<>(List.of(first, second), 2L));
|
||||
when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), anyCollection()))
|
||||
.thenReturn(rows);
|
||||
when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
|
||||
.thenReturn(createLifecycleView());
|
||||
when(projectExecutionStatusViewService.getLifecycle("pending", 3002L))
|
||||
.thenReturn(createLifecycleView());
|
||||
|
||||
PageResult<ProjectExecutionRespVO> result = projectExecutionService.getExecutionRespVOPage(projectId, reqVO);
|
||||
|
||||
assertEquals(new BigDecimal("25.56"), result.getList().get(0).getProgressRate());
|
||||
assertEquals(new BigDecimal("10.00"), result.getList().get(1).getProgressRate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getExecutionPage_shouldDelegateMapper() {
|
||||
Long projectId = 2001L;
|
||||
@@ -385,7 +513,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
reqVO.setPageNo(1);
|
||||
reqVO.setPageSize(20);
|
||||
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
||||
when(projectExecutionMapper.selectPageByProjectId(projectId, reqVO))
|
||||
when(projectExecutionMapper.selectPageByProjectId(eq(projectId), any(), eq(reqVO)))
|
||||
.thenReturn(new PageResult<>(List.of(), 0L));
|
||||
|
||||
PageResult<ProjectExecutionDO> result = projectExecutionService.getExecutionPage(projectId, reqVO);
|
||||
@@ -428,6 +556,11 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
return transition;
|
||||
}
|
||||
|
||||
private ProjectExecutionStatusViewService.ProjectExecutionLifecycleView createLifecycleView() {
|
||||
return new ProjectExecutionStatusViewService.ProjectExecutionLifecycleView(
|
||||
"待处理", false, true, Collections.emptyList());
|
||||
}
|
||||
|
||||
private UserObjectRoleDO createProjectMember(Long projectId, Long userId, Long roleId) {
|
||||
UserObjectRoleDO member = new UserObjectRoleDO();
|
||||
member.setId(projectId + userId);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.njcn.rdms.module.project.service.project.execution;
|
||||
|
||||
import com.njcn.rdms.framework.common.exception.ServiceException;
|
||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
|
||||
@@ -10,6 +11,7 @@ import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -17,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ProjectExecutionStatusViewServiceTest extends BaseMockitoUnitTest {
|
||||
@@ -29,34 +32,76 @@ class ProjectExecutionStatusViewServiceTest extends BaseMockitoUnitTest {
|
||||
private ObjectStatusTransitionMapper objectStatusTransitionMapper;
|
||||
|
||||
@Test
|
||||
void getLifecycle_shouldReturnExecutionStatusMetadataAndActions() {
|
||||
ObjectStatusModelDO statusModel = new ObjectStatusModelDO();
|
||||
statusModel.setObjectType("execution");
|
||||
statusModel.setStatusCode("active");
|
||||
statusModel.setStatusName("进行中");
|
||||
statusModel.setTerminalFlag(false);
|
||||
statusModel.setAllowEdit(true);
|
||||
|
||||
ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO();
|
||||
transition.setActionCode("pause");
|
||||
transition.setActionName("暂停");
|
||||
transition.setNeedReason(true);
|
||||
|
||||
void getLifecycle_whenLoginUserIsOwner_shouldReturnOwnerOnlyAction() {
|
||||
Long ownerId = 3001L;
|
||||
ObjectStatusModelDO statusModel = createStatusModel();
|
||||
ObjectStatusTransitionDO pause = createTransition("pause", "暂停", true);
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "active"))
|
||||
.thenReturn(statusModel);
|
||||
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("execution", "active"))
|
||||
.thenReturn(List.of(transition));
|
||||
.thenReturn(List.of(pause));
|
||||
|
||||
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
|
||||
projectExecutionStatusViewService.getLifecycle("active");
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
|
||||
|
||||
assertEquals("进行中", result.statusName());
|
||||
assertFalse(result.terminal());
|
||||
assertTrue(result.allowEdit());
|
||||
assertEquals(1, result.availableActions().size());
|
||||
assertEquals("pause", result.availableActions().get(0).getActionCode());
|
||||
assertEquals("暂停", result.availableActions().get(0).getActionName());
|
||||
assertTrue(result.availableActions().get(0).getNeedReason());
|
||||
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
|
||||
projectExecutionStatusViewService.getLifecycle("active", ownerId);
|
||||
|
||||
assertEquals("进行中", result.statusName());
|
||||
assertFalse(result.terminal());
|
||||
assertTrue(result.allowEdit());
|
||||
assertEquals(1, result.availableActions().size());
|
||||
assertEquals("pause", result.availableActions().get(0).getActionCode());
|
||||
assertEquals("暂停", result.availableActions().get(0).getActionName());
|
||||
assertTrue(result.availableActions().get(0).getNeedReason());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getLifecycle_whenLoginUserNotOwner_shouldFilterOwnerOnlyActions() {
|
||||
Long ownerId = 3001L;
|
||||
Long otherUserId = 3002L;
|
||||
ObjectStatusModelDO statusModel = createStatusModel();
|
||||
ObjectStatusTransitionDO pause = createTransition("pause", "暂停", true);
|
||||
ObjectStatusTransitionDO complete = createTransition("complete", "完成", false);
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "active"))
|
||||
.thenReturn(statusModel);
|
||||
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("execution", "active"))
|
||||
.thenReturn(List.of(pause, complete));
|
||||
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(otherUserId);
|
||||
|
||||
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
|
||||
projectExecutionStatusViewService.getLifecycle("active", ownerId);
|
||||
|
||||
assertTrue(result.availableActions().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getLifecycle_shouldExcludeAutoStartAction() {
|
||||
Long ownerId = 3001L;
|
||||
ObjectStatusModelDO statusModel = new ObjectStatusModelDO();
|
||||
statusModel.setObjectType("execution");
|
||||
statusModel.setStatusCode("pending");
|
||||
statusModel.setStatusName("待开始");
|
||||
statusModel.setTerminalFlag(false);
|
||||
statusModel.setAllowEdit(true);
|
||||
ObjectStatusTransitionDO autoStart = createTransition("auto_start", "开始推进", false);
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
|
||||
.thenReturn(statusModel);
|
||||
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("execution", "pending"))
|
||||
.thenReturn(List.of(autoStart));
|
||||
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
|
||||
|
||||
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
|
||||
projectExecutionStatusViewService.getLifecycle("pending", ownerId);
|
||||
|
||||
assertTrue(result.availableActions().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -65,8 +110,26 @@ class ProjectExecutionStatusViewServiceTest extends BaseMockitoUnitTest {
|
||||
.thenReturn(null);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> projectExecutionStatusViewService.getLifecycle("active"));
|
||||
() -> projectExecutionStatusViewService.getLifecycle("active", 3001L));
|
||||
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_MODEL_NOT_EXISTS_OR_DISABLED.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
private ObjectStatusModelDO createStatusModel() {
|
||||
ObjectStatusModelDO statusModel = new ObjectStatusModelDO();
|
||||
statusModel.setObjectType("execution");
|
||||
statusModel.setStatusCode("active");
|
||||
statusModel.setStatusName("进行中");
|
||||
statusModel.setTerminalFlag(false);
|
||||
statusModel.setAllowEdit(true);
|
||||
return statusModel;
|
||||
}
|
||||
|
||||
private ObjectStatusTransitionDO createTransition(String actionCode, String actionName, boolean needReason) {
|
||||
ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO();
|
||||
transition.setActionCode(actionCode);
|
||||
transition.setActionName(actionName);
|
||||
transition.setNeedReason(needReason);
|
||||
return transition;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.njcn.rdms.module.project.service.project.permission;
|
||||
|
||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* VisibilityScopeResolverImpl 单元测试。覆盖角色矩阵:
|
||||
* - 项目经理 → seesAll
|
||||
* - 非项目经理 → 4 源并集
|
||||
* - 非项目经理且无任何参与 → 空集合
|
||||
* - 执行维度同上
|
||||
*/
|
||||
class VisibilityScopeResolverImplTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private VisibilityScopeResolverImpl resolver;
|
||||
|
||||
@Mock private ProjectMapper projectMapper;
|
||||
@Mock private ProjectExecutionMapper projectExecutionMapper;
|
||||
@Mock private ExecutionAssigneeMapper executionAssigneeMapper;
|
||||
@Mock private ProjectTaskMapper projectTaskMapper;
|
||||
@Mock private TaskAssigneeMapper taskAssigneeMapper;
|
||||
|
||||
@Test
|
||||
void resolveForProject_managerShouldSeeAll() {
|
||||
Long projectId = 2001L, userId = 3001L;
|
||||
ProjectDO project = new ProjectDO();
|
||||
project.setId(projectId);
|
||||
project.setManagerUserId(userId);
|
||||
when(projectMapper.selectById(projectId)).thenReturn(project);
|
||||
|
||||
VisibilityScope scope = resolver.resolveForProject(projectId, userId);
|
||||
|
||||
assertTrue(scope.seesAll());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveForProject_nonManagerUnionsFourSources() {
|
||||
Long projectId = 2001L, userId = 3002L;
|
||||
ProjectDO project = new ProjectDO();
|
||||
project.setId(projectId);
|
||||
project.setManagerUserId(9999L);
|
||||
when(projectMapper.selectById(projectId)).thenReturn(project);
|
||||
|
||||
when(projectExecutionMapper.selectIdsByProjectIdAndOwnerId(projectId, userId))
|
||||
.thenReturn(List.of(5001L));
|
||||
when(executionAssigneeMapper.selectActiveExecutionIdsByProjectIdAndUserId(projectId, userId))
|
||||
.thenReturn(List.of(5002L));
|
||||
when(projectTaskMapper.selectOwnedTaskAndDescendantIdsByProjectIdAndUserId(projectId, userId))
|
||||
.thenReturn(List.of(9001L, 9002L));
|
||||
when(taskAssigneeMapper.selectActiveTaskIdsByProjectIdAndUserId(projectId, userId))
|
||||
.thenReturn(List.of(9003L));
|
||||
|
||||
VisibilityScope scope = resolver.resolveForProject(projectId, userId);
|
||||
|
||||
assertFalse(scope.seesAll());
|
||||
assertEquals(Set.of(5001L, 5002L), scope.executionIds());
|
||||
assertEquals(Set.of(9001L, 9002L, 9003L), scope.taskIds());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveForProject_nonParticipantReturnsEmpty() {
|
||||
Long projectId = 2001L, userId = 3099L;
|
||||
ProjectDO project = new ProjectDO();
|
||||
project.setId(projectId);
|
||||
project.setManagerUserId(9999L);
|
||||
when(projectMapper.selectById(projectId)).thenReturn(project);
|
||||
when(projectExecutionMapper.selectIdsByProjectIdAndOwnerId(projectId, userId)).thenReturn(List.of());
|
||||
when(executionAssigneeMapper.selectActiveExecutionIdsByProjectIdAndUserId(projectId, userId)).thenReturn(List.of());
|
||||
when(projectTaskMapper.selectOwnedTaskAndDescendantIdsByProjectIdAndUserId(projectId, userId)).thenReturn(List.of());
|
||||
when(taskAssigneeMapper.selectActiveTaskIdsByProjectIdAndUserId(projectId, userId)).thenReturn(List.of());
|
||||
|
||||
VisibilityScope scope = resolver.resolveForProject(projectId, userId);
|
||||
|
||||
assertFalse(scope.seesAll());
|
||||
assertTrue(scope.executionIds().isEmpty());
|
||||
assertTrue(scope.taskIds().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveForExecution_managerShouldSeeAll() {
|
||||
Long projectId = 2001L, executionId = 5001L, userId = 3001L;
|
||||
ProjectDO project = new ProjectDO();
|
||||
project.setManagerUserId(userId);
|
||||
when(projectMapper.selectById(projectId)).thenReturn(project);
|
||||
|
||||
VisibilityScope scope = resolver.resolveForExecution(projectId, executionId, userId);
|
||||
|
||||
assertTrue(scope.seesAll());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveForExecution_nonManagerScopedToThatExecution() {
|
||||
Long projectId = 2001L, executionId = 5001L, userId = 3002L;
|
||||
ProjectDO project = new ProjectDO();
|
||||
project.setManagerUserId(9999L);
|
||||
when(projectMapper.selectById(projectId)).thenReturn(project);
|
||||
|
||||
when(projectTaskMapper.selectOwnedTaskAndDescendantIdsByExecutionIdAndUserId(projectId, executionId, userId))
|
||||
.thenReturn(List.of(9001L));
|
||||
when(taskAssigneeMapper.selectActiveTaskIdsByProjectIdAndExecutionIdAndUserId(projectId, executionId, userId))
|
||||
.thenReturn(List.of(9002L));
|
||||
|
||||
VisibilityScope scope = resolver.resolveForExecution(projectId, executionId, userId);
|
||||
|
||||
assertFalse(scope.seesAll());
|
||||
assertTrue(scope.executionIds().isEmpty());
|
||||
assertEquals(Set.of(9001L, 9002L), scope.taskIds());
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,9 @@ import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskSaveReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusActionReqVO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionMemberDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskStatusLogDO;
|
||||
@@ -14,7 +15,7 @@ import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
|
||||
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionMemberMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskStatusLogMapper;
|
||||
@@ -22,8 +23,11 @@ import com.njcn.rdms.module.project.dal.mysql.project.task.TaskWorklogMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper;
|
||||
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
||||
import com.njcn.rdms.module.project.framework.attachment.AttachmentFileIdResolver;
|
||||
import com.njcn.rdms.module.project.framework.security.service.ProjectObjectAuthorizationService;
|
||||
import com.njcn.rdms.module.project.service.project.ProjectService;
|
||||
import com.njcn.rdms.module.project.service.project.task.assignee.TaskAssigneeService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
@@ -38,6 +42,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -51,7 +56,7 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
@Mock
|
||||
private ProjectExecutionMapper projectExecutionMapper;
|
||||
@Mock
|
||||
private ExecutionMemberMapper executionMemberMapper;
|
||||
private ExecutionAssigneeMapper executionAssigneeMapper;
|
||||
@Mock
|
||||
private ProjectTaskMapper projectTaskMapper;
|
||||
@Mock
|
||||
@@ -68,6 +73,23 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
private ProjectService projectService;
|
||||
@Mock
|
||||
private TaskAssigneeService taskAssigneeService;
|
||||
@Mock
|
||||
private AttachmentFileIdResolver attachmentFileIdResolver;
|
||||
@Mock
|
||||
private ProjectObjectAuthorizationService projectObjectAuthorizationService;
|
||||
@Mock
|
||||
private com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver visibilityScopeResolver;
|
||||
|
||||
/**
|
||||
* 默认让 VisibilityScopeResolver 放行(seesAll=true),既有测试无需关心 scope。
|
||||
*/
|
||||
@BeforeEach
|
||||
void setupVisibilityScopeAll() {
|
||||
lenient().when(visibilityScopeResolver.resolveForProject(any(), any()))
|
||||
.thenReturn(com.njcn.rdms.module.project.service.project.permission.VisibilityScope.all());
|
||||
lenient().when(visibilityScopeResolver.resolveForExecution(any(), any(), any()))
|
||||
.thenReturn(com.njcn.rdms.module.project.service.project.permission.VisibilityScope.all());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createTask_shouldInsertPendingTaskAndAutoStartProject() {
|
||||
@@ -85,8 +107,8 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
.thenReturn(createStatus("execution", "pending", true));
|
||||
when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("task"))
|
||||
.thenReturn(createStatus("task", "pending", true));
|
||||
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
|
||||
.thenReturn(createExecutionMember(executionId, 3002L));
|
||||
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
|
||||
.thenReturn(createExecutionAssignee(executionId, 3002L));
|
||||
when(projectTaskMapper.insert(any(ProjectTaskDO.class))).thenAnswer(invocation -> {
|
||||
ProjectTaskDO task = invocation.getArgument(0);
|
||||
task.setId(9001L);
|
||||
@@ -108,6 +130,45 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
verify(bizAuditLogMapper).insert(any(BizAuditLogDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createTask_shouldPersistAttachmentFileId() {
|
||||
Long projectId = 2001L;
|
||||
Long executionId = 5001L;
|
||||
ProjectTaskSaveReqVO reqVO = createTaskReqVO();
|
||||
reqVO.setOwnerId(3002L);
|
||||
AttachmentItem attachment = new AttachmentItem();
|
||||
attachment.setId("10001");
|
||||
attachment.setUrl("http://oss.example.com/task/2026/05/report.txt");
|
||||
attachment.setName("report.txt");
|
||||
attachment.setSize(3164L);
|
||||
attachment.setContentType("text/plain");
|
||||
reqVO.setAttachments(List.of(attachment));
|
||||
|
||||
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
||||
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
|
||||
.thenReturn(createEditableExecution(projectId, executionId));
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
|
||||
.thenReturn(createStatus("project", "pending", true));
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
|
||||
.thenReturn(createStatus("execution", "pending", true));
|
||||
when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("task"))
|
||||
.thenReturn(createStatus("task", "pending", true));
|
||||
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
|
||||
.thenReturn(createExecutionAssignee(executionId, 3002L));
|
||||
when(projectTaskMapper.insert(any(ProjectTaskDO.class))).thenAnswer(invocation -> {
|
||||
ProjectTaskDO task = invocation.getArgument(0);
|
||||
task.setId(9001L);
|
||||
return 1;
|
||||
});
|
||||
|
||||
projectTaskService.createTask(projectId, executionId, reqVO);
|
||||
|
||||
ArgumentCaptor<ProjectTaskDO> taskCaptor = ArgumentCaptor.forClass(ProjectTaskDO.class);
|
||||
verify(projectTaskMapper).insert(taskCaptor.capture());
|
||||
verify(attachmentFileIdResolver).resolve(reqVO.getAttachments());
|
||||
assertEquals("10001", taskCaptor.getValue().getAttachments().get(0).getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createSubTask_whenOwnerBlank_shouldInheritParentOwner() {
|
||||
Long projectId = 2001L;
|
||||
@@ -128,8 +189,8 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
.thenReturn(createStatus("execution", "pending", true));
|
||||
when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("task"))
|
||||
.thenReturn(createStatus("task", "pending", true));
|
||||
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
|
||||
.thenReturn(createExecutionMember(executionId, 3002L));
|
||||
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
|
||||
.thenReturn(createExecutionAssignee(executionId, 3002L));
|
||||
when(projectTaskMapper.insert(any(ProjectTaskDO.class))).thenAnswer(invocation -> {
|
||||
ProjectTaskDO task = invocation.getArgument(0);
|
||||
task.setId(9002L);
|
||||
@@ -145,7 +206,7 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void createTask_whenOwnerNotActiveExecutionMember_shouldThrow() {
|
||||
void createTask_whenOwnerNotActiveExecutionAssignee_shouldThrow() {
|
||||
Long projectId = 2001L;
|
||||
Long executionId = 5001L;
|
||||
ProjectTaskSaveReqVO reqVO = createTaskReqVO();
|
||||
@@ -158,7 +219,7 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
.thenReturn(createStatus("project", "pending", true));
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
|
||||
.thenReturn(createStatus("execution", "pending", true));
|
||||
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3999L)).thenReturn(null);
|
||||
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3999L)).thenReturn(null);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> projectTaskService.createTask(projectId, executionId, reqVO));
|
||||
@@ -205,6 +266,8 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
|
||||
projectTaskService.changeTaskStatus(projectId, executionId, taskId, reqVO);
|
||||
|
||||
verify(projectObjectAuthorizationService).checkOwnerOrProjectPermission(projectId, 3002L,
|
||||
"project:task:status");
|
||||
verify(projectTaskMapper).updateStatusByIdAndStatus(taskId, "pending", "active", null);
|
||||
ArgumentCaptor<ProjectTaskStatusLogDO> statusCaptor = ArgumentCaptor.forClass(ProjectTaskStatusLogDO.class);
|
||||
verify(projectTaskStatusLogMapper).insert(statusCaptor.capture());
|
||||
@@ -276,8 +339,8 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
.thenReturn(createStatus("execution", "pending", true));
|
||||
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId))
|
||||
.thenReturn(parentTask);
|
||||
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
|
||||
.thenReturn(createExecutionMember(executionId, 3002L));
|
||||
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
|
||||
.thenReturn(createExecutionAssignee(executionId, 3002L));
|
||||
when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(0);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
@@ -306,8 +369,8 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
.thenReturn(createStatus("execution", "pending", true));
|
||||
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId))
|
||||
.thenReturn(parentTask);
|
||||
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
|
||||
.thenReturn(createExecutionMember(executionId, 3002L));
|
||||
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
|
||||
.thenReturn(createExecutionAssignee(executionId, 3002L));
|
||||
when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(0);
|
||||
when(taskWorklogMapper.existsByTaskId(parentTaskId)).thenReturn(true);
|
||||
|
||||
@@ -340,8 +403,8 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
.thenReturn(createStatus("task", "pending", true));
|
||||
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId))
|
||||
.thenReturn(parentTask);
|
||||
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
|
||||
.thenReturn(createExecutionMember(executionId, 3002L));
|
||||
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
|
||||
.thenReturn(createExecutionAssignee(executionId, 3002L));
|
||||
// 已有 1 个子任务 → 跳过叶子转父校验
|
||||
when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(1);
|
||||
// 插入新任务(id=9002)后,父任务下有 2 个子,进度分别 60.00 与 0.00 → AVG 30.00
|
||||
@@ -379,7 +442,6 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
ProjectTaskDO grandparent = createTask(projectId, executionId, grandparentId, 3002L);
|
||||
grandparent.setProgressRate(new BigDecimal("50.00"));
|
||||
ProjectTaskSaveReqVO reqVO = createTaskReqVO();
|
||||
reqVO.setProgressRate(new BigDecimal("80.00"));
|
||||
reqVO.setParentTaskId(parentTaskId);
|
||||
reqVO.setOwnerId(3002L);
|
||||
|
||||
@@ -394,8 +456,8 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
.thenReturn(createStatus("task", "pending", true));
|
||||
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId))
|
||||
.thenReturn(parentTask);
|
||||
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
|
||||
.thenReturn(createExecutionMember(executionId, 3002L));
|
||||
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
|
||||
.thenReturn(createExecutionAssignee(executionId, 3002L));
|
||||
when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(0);
|
||||
when(taskWorklogMapper.existsByTaskId(parentTaskId)).thenReturn(false);
|
||||
when(projectTaskMapper.insert(any(ProjectTaskDO.class))).thenAnswer(inv -> {
|
||||
@@ -423,117 +485,6 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
verify(projectTaskMapper).updateProgressRateById(eq(grandparentId), any(BigDecimal.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateTask_whenTaskIsParent_progressManualEditDifferent_shouldThrow() {
|
||||
Long projectId = 2001L;
|
||||
Long executionId = 5001L;
|
||||
Long taskId = 9001L;
|
||||
ProjectTaskDO task = createTask(projectId, executionId, taskId, 3002L);
|
||||
task.setProgressRate(new BigDecimal("40.00"));
|
||||
ProjectTaskSaveReqVO reqVO = createTaskReqVO();
|
||||
reqVO.setId(taskId);
|
||||
reqVO.setOwnerId(3002L);
|
||||
reqVO.setProgressRate(new BigDecimal("90.00"));
|
||||
|
||||
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
||||
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
|
||||
.thenReturn(createEditableExecution(projectId, executionId));
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
|
||||
.thenReturn(createStatus("project", "pending", true));
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
|
||||
.thenReturn(createStatus("execution", "pending", true));
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "pending"))
|
||||
.thenReturn(createStatus("task", "pending", true));
|
||||
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task);
|
||||
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
|
||||
.thenReturn(createExecutionMember(executionId, 3002L));
|
||||
// 当前任务有子 → 是父任务
|
||||
when(projectTaskMapper.countChildrenByParentTaskId(taskId)).thenReturn(2);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> projectTaskService.updateTask(projectId, executionId, reqVO));
|
||||
assertEquals(ErrorCodeConstants.PROJECT_TASK_PROGRESS_PARENT_NOT_EDITABLE.getCode(), ex.getCode());
|
||||
verify(projectTaskMapper, never()).updateById(any(ProjectTaskDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateTask_whenTaskIsParent_progressEqualOriginal_shouldKeepValue() {
|
||||
Long projectId = 2001L;
|
||||
Long executionId = 5001L;
|
||||
Long taskId = 9001L;
|
||||
ProjectTaskDO task = createTask(projectId, executionId, taskId, 3002L);
|
||||
task.setProgressRate(new BigDecimal("40.00"));
|
||||
ProjectTaskSaveReqVO reqVO = createTaskReqVO();
|
||||
reqVO.setId(taskId);
|
||||
reqVO.setOwnerId(3002L);
|
||||
// 前端把当前值原样回传 → 不应抛错,且 progressRate 保留为父任务原值
|
||||
reqVO.setProgressRate(new BigDecimal("40.00"));
|
||||
|
||||
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
||||
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
|
||||
.thenReturn(createEditableExecution(projectId, executionId));
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
|
||||
.thenReturn(createStatus("project", "pending", true));
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
|
||||
.thenReturn(createStatus("execution", "pending", true));
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "pending"))
|
||||
.thenReturn(createStatus("task", "pending", true));
|
||||
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task);
|
||||
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
|
||||
.thenReturn(createExecutionMember(executionId, 3002L));
|
||||
when(projectTaskMapper.countChildrenByParentTaskId(taskId)).thenReturn(2);
|
||||
|
||||
projectTaskService.updateTask(projectId, executionId, reqVO);
|
||||
|
||||
ArgumentCaptor<ProjectTaskDO> taskCaptor = ArgumentCaptor.forClass(ProjectTaskDO.class);
|
||||
verify(projectTaskMapper).updateById(taskCaptor.capture());
|
||||
assertEquals(0, new BigDecimal("40.00").compareTo(taskCaptor.getValue().getProgressRate()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateTask_leafProgressChange_shouldRecalcParent() {
|
||||
Long projectId = 2001L;
|
||||
Long executionId = 5001L;
|
||||
Long taskId = 9001L;
|
||||
Long parentTaskId = 8001L;
|
||||
ProjectTaskDO task = createTask(projectId, executionId, taskId, 3002L);
|
||||
task.setParentTaskId(parentTaskId);
|
||||
task.setProgressRate(new BigDecimal("20.00"));
|
||||
ProjectTaskDO parentTask = createTask(projectId, executionId, parentTaskId, 3002L);
|
||||
parentTask.setProgressRate(new BigDecimal("20.00"));
|
||||
|
||||
ProjectTaskSaveReqVO reqVO = createTaskReqVO();
|
||||
reqVO.setId(taskId);
|
||||
reqVO.setParentTaskId(parentTaskId);
|
||||
reqVO.setOwnerId(3002L);
|
||||
reqVO.setProgressRate(new BigDecimal("60.00"));
|
||||
|
||||
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
||||
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
|
||||
.thenReturn(createEditableExecution(projectId, executionId));
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
|
||||
.thenReturn(createStatus("project", "pending", true));
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
|
||||
.thenReturn(createStatus("execution", "pending", true));
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "pending"))
|
||||
.thenReturn(createStatus("task", "pending", true));
|
||||
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task);
|
||||
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId))
|
||||
.thenReturn(parentTask);
|
||||
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
|
||||
.thenReturn(createExecutionMember(executionId, 3002L));
|
||||
// 当前任务无子 → 是叶子
|
||||
when(projectTaskMapper.countChildrenByParentTaskId(taskId)).thenReturn(0);
|
||||
when(projectTaskMapper.selectById(parentTaskId)).thenReturn(parentTask);
|
||||
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId))
|
||||
.thenReturn(List.of(task));
|
||||
|
||||
projectTaskService.updateTask(projectId, executionId, reqVO);
|
||||
|
||||
verify(projectTaskMapper).updateProgressRateById(eq(parentTaskId),
|
||||
argThat(v -> new BigDecimal("60.00").compareTo(v) == 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateTask_movedToNewParent_shouldValidateAndRecalcBothChains() {
|
||||
Long projectId = 2001L;
|
||||
@@ -552,7 +503,6 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
reqVO.setId(taskId);
|
||||
reqVO.setParentTaskId(newParentId);
|
||||
reqVO.setOwnerId(3002L);
|
||||
reqVO.setProgressRate(new BigDecimal("70.00"));
|
||||
|
||||
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
||||
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
|
||||
@@ -566,8 +516,8 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task);
|
||||
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, newParentId))
|
||||
.thenReturn(newParent);
|
||||
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
|
||||
.thenReturn(createExecutionMember(executionId, 3002L));
|
||||
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
|
||||
.thenReturn(createExecutionAssignee(executionId, 3002L));
|
||||
// 校验"叶子转父":新父 8002 当前是叶子,进度=0,无工时 → 通过
|
||||
when(projectTaskMapper.countChildrenByParentTaskId(newParentId)).thenReturn(0);
|
||||
when(taskWorklogMapper.existsByTaskId(newParentId)).thenReturn(false);
|
||||
@@ -592,7 +542,6 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
private ProjectTaskSaveReqVO createTaskReqVO() {
|
||||
ProjectTaskSaveReqVO reqVO = new ProjectTaskSaveReqVO();
|
||||
reqVO.setTaskTitle("接口联调任务");
|
||||
reqVO.setProgressRate(BigDecimal.ZERO);
|
||||
reqVO.setTaskDesc("完成接口联调");
|
||||
return reqVO;
|
||||
}
|
||||
@@ -626,8 +575,8 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
return task;
|
||||
}
|
||||
|
||||
private ExecutionMemberDO createExecutionMember(Long executionId, Long userId) {
|
||||
ExecutionMemberDO member = new ExecutionMemberDO();
|
||||
private ExecutionAssigneeDO createExecutionAssignee(Long executionId, Long userId) {
|
||||
ExecutionAssigneeDO member = new ExecutionAssigneeDO();
|
||||
member.setExecutionId(executionId);
|
||||
member.setUserId(userId);
|
||||
return member;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.njcn.rdms.module.project.service.project.task;
|
||||
|
||||
import com.njcn.rdms.framework.common.exception.ServiceException;
|
||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
|
||||
@@ -10,6 +11,7 @@ import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -17,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest {
|
||||
@@ -29,29 +32,73 @@ class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest {
|
||||
private ObjectStatusTransitionMapper objectStatusTransitionMapper;
|
||||
|
||||
@Test
|
||||
void getLifecycle_shouldReturnStatusModelAndAvailableActions() {
|
||||
ObjectStatusModelDO statusModel = new ObjectStatusModelDO();
|
||||
statusModel.setObjectType("task");
|
||||
statusModel.setStatusCode("active");
|
||||
statusModel.setStatusName("进行中");
|
||||
statusModel.setTerminalFlag(false);
|
||||
statusModel.setAllowEdit(true);
|
||||
|
||||
void getLifecycle_whenLoginUserIsOwner_shouldReturnOwnerOnlyActions() {
|
||||
Long ownerId = 3001L;
|
||||
ObjectStatusModelDO statusModel = createStatusModel();
|
||||
ObjectStatusTransitionDO complete = createTransition("complete", "完成");
|
||||
ObjectStatusTransitionDO block = createTransition("block", "阻塞");
|
||||
ObjectStatusTransitionDO pause = createTransition("pause", "暂停");
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "active")).thenReturn(statusModel);
|
||||
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("task", "active"))
|
||||
.thenReturn(List.of(complete, block));
|
||||
.thenReturn(List.of(complete, pause));
|
||||
|
||||
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
|
||||
projectTaskStatusViewService.getLifecycle("active");
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
|
||||
|
||||
assertEquals("进行中", view.statusName());
|
||||
assertFalse(view.terminal());
|
||||
assertTrue(view.allowEdit());
|
||||
assertEquals(2, view.availableActions().size());
|
||||
assertEquals("complete", view.availableActions().get(0).getActionCode());
|
||||
assertEquals("完成", view.availableActions().get(0).getActionName());
|
||||
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
|
||||
projectTaskStatusViewService.getLifecycle("active", ownerId);
|
||||
|
||||
assertEquals("进行中", view.statusName());
|
||||
assertFalse(view.terminal());
|
||||
assertTrue(view.allowEdit());
|
||||
assertEquals(2, view.availableActions().size());
|
||||
assertEquals("complete", view.availableActions().get(0).getActionCode());
|
||||
assertEquals("完成", view.availableActions().get(0).getActionName());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getLifecycle_whenLoginUserNotOwner_shouldFilterOwnerOnlyActions() {
|
||||
Long ownerId = 3001L;
|
||||
Long otherUserId = 3002L;
|
||||
ObjectStatusModelDO statusModel = createStatusModel();
|
||||
ObjectStatusTransitionDO complete = createTransition("complete", "完成");
|
||||
ObjectStatusTransitionDO pause = createTransition("pause", "暂停");
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "active")).thenReturn(statusModel);
|
||||
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("task", "active"))
|
||||
.thenReturn(List.of(complete, pause));
|
||||
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(otherUserId);
|
||||
|
||||
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
|
||||
projectTaskStatusViewService.getLifecycle("active", ownerId);
|
||||
|
||||
assertTrue(view.availableActions().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getLifecycle_shouldExcludeAutoStartAction() {
|
||||
Long ownerId = 3001L;
|
||||
ObjectStatusModelDO statusModel = new ObjectStatusModelDO();
|
||||
statusModel.setObjectType("task");
|
||||
statusModel.setStatusCode("pending");
|
||||
statusModel.setStatusName("待开始");
|
||||
statusModel.setTerminalFlag(false);
|
||||
statusModel.setAllowEdit(true);
|
||||
ObjectStatusTransitionDO autoStart = createTransition("auto_start", "开始推进");
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "pending")).thenReturn(statusModel);
|
||||
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("task", "pending"))
|
||||
.thenReturn(List.of(autoStart));
|
||||
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
|
||||
|
||||
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
|
||||
projectTaskStatusViewService.getLifecycle("pending", ownerId);
|
||||
|
||||
assertTrue(view.availableActions().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -59,11 +106,21 @@ class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest {
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "missing")).thenReturn(null);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> projectTaskStatusViewService.getLifecycle("missing"));
|
||||
() -> projectTaskStatusViewService.getLifecycle("missing", 3001L));
|
||||
|
||||
assertEquals(ErrorCodeConstants.PROJECT_TASK_STATUS_MODEL_NOT_EXISTS_OR_DISABLED.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
private ObjectStatusModelDO createStatusModel() {
|
||||
ObjectStatusModelDO statusModel = new ObjectStatusModelDO();
|
||||
statusModel.setObjectType("task");
|
||||
statusModel.setStatusCode("active");
|
||||
statusModel.setStatusName("进行中");
|
||||
statusModel.setTerminalFlag(false);
|
||||
statusModel.setAllowEdit(true);
|
||||
return statusModel;
|
||||
}
|
||||
|
||||
private ObjectStatusTransitionDO createTransition(String actionCode, String actionName) {
|
||||
ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO();
|
||||
transition.setActionCode(actionCode);
|
||||
|
||||
@@ -10,7 +10,7 @@ import com.njcn.rdms.module.project.controller.admin.project.task.vo.assignee.Ta
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.assignee.TaskAssigneeSaveReqVO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionMemberDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskAssigneeDO;
|
||||
@@ -18,7 +18,7 @@ import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskAssigneeLogD
|
||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
|
||||
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionMemberMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeLogMapper;
|
||||
@@ -60,7 +60,7 @@ class TaskAssigneeServiceImplTest extends BaseMockitoUnitTest {
|
||||
@Mock
|
||||
private ProjectTaskMapper projectTaskMapper;
|
||||
@Mock
|
||||
private ExecutionMemberMapper executionMemberMapper;
|
||||
private ExecutionAssigneeMapper executionAssigneeMapper;
|
||||
@Mock
|
||||
private TaskAssigneeMapper taskAssigneeMapper;
|
||||
@Mock
|
||||
@@ -88,8 +88,8 @@ class TaskAssigneeServiceImplTest extends BaseMockitoUnitTest {
|
||||
stubEditableContext();
|
||||
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
|
||||
.thenReturn(createTask(OWNER_ID));
|
||||
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID))
|
||||
.thenReturn(createExecutionMember(EXECUTION_ID, USER_ID));
|
||||
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID))
|
||||
.thenReturn(createExecutionAssignee(EXECUTION_ID, USER_ID));
|
||||
when(taskAssigneeMapper.selectActiveByTaskIdAndUserId(TASK_ID, USER_ID)).thenReturn(null);
|
||||
when(taskAssigneeMapper.insert(any(TaskAssigneeDO.class))).thenAnswer(invocation -> {
|
||||
TaskAssigneeDO assignee = invocation.getArgument(0);
|
||||
@@ -131,14 +131,14 @@ class TaskAssigneeServiceImplTest extends BaseMockitoUnitTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void createAssignee_whenUserNotActiveExecutionMember_shouldThrow() {
|
||||
void createAssignee_whenUserNotActiveExecutionAssignee_shouldThrow() {
|
||||
TaskAssigneeSaveReqVO reqVO = new TaskAssigneeSaveReqVO();
|
||||
reqVO.setUserId(USER_ID);
|
||||
|
||||
stubEditableContext();
|
||||
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
|
||||
.thenReturn(createTask(OWNER_ID));
|
||||
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID))
|
||||
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID))
|
||||
.thenReturn(null);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
@@ -155,8 +155,8 @@ class TaskAssigneeServiceImplTest extends BaseMockitoUnitTest {
|
||||
stubEditableContext();
|
||||
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
|
||||
.thenReturn(createTask(OWNER_ID));
|
||||
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID))
|
||||
.thenReturn(createExecutionMember(EXECUTION_ID, USER_ID));
|
||||
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID))
|
||||
.thenReturn(createExecutionAssignee(EXECUTION_ID, USER_ID));
|
||||
when(taskAssigneeMapper.selectActiveByTaskIdAndUserId(TASK_ID, USER_ID))
|
||||
.thenReturn(createActiveAssignee(7001L, TASK_ID, USER_ID));
|
||||
|
||||
@@ -308,10 +308,10 @@ class TaskAssigneeServiceImplTest extends BaseMockitoUnitTest {
|
||||
void initializeAssignees_shouldDedupAndWriteAll() {
|
||||
Long userA = 4001L;
|
||||
Long userB = 4002L;
|
||||
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, userA))
|
||||
.thenReturn(createExecutionMember(EXECUTION_ID, userA));
|
||||
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, userB))
|
||||
.thenReturn(createExecutionMember(EXECUTION_ID, userB));
|
||||
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, userA))
|
||||
.thenReturn(createExecutionAssignee(EXECUTION_ID, userA));
|
||||
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, userB))
|
||||
.thenReturn(createExecutionAssignee(EXECUTION_ID, userB));
|
||||
when(taskAssigneeMapper.selectActiveByTaskIdAndUserId(TASK_ID, userA)).thenReturn(null);
|
||||
when(taskAssigneeMapper.selectActiveByTaskIdAndUserId(TASK_ID, userB)).thenReturn(null);
|
||||
when(taskAssigneeMapper.insert(any(TaskAssigneeDO.class))).thenReturn(1);
|
||||
@@ -370,8 +370,8 @@ class TaskAssigneeServiceImplTest extends BaseMockitoUnitTest {
|
||||
return task;
|
||||
}
|
||||
|
||||
private ExecutionMemberDO createExecutionMember(Long executionId, Long userId) {
|
||||
ExecutionMemberDO member = new ExecutionMemberDO();
|
||||
private ExecutionAssigneeDO createExecutionAssignee(Long executionId, Long userId) {
|
||||
ExecutionAssigneeDO member = new ExecutionAssigneeDO();
|
||||
member.setExecutionId(executionId);
|
||||
member.setUserId(userId);
|
||||
return member;
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
package com.njcn.rdms.module.project.service.project.task.worklog;
|
||||
|
||||
import com.njcn.rdms.framework.common.exception.ServiceException;
|
||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog.TaskWorklogPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog.TaskWorklogRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog.TaskWorklogSaveReqVO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskAssigneeDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskWorklogDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskWorklogMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
||||
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
||||
import com.njcn.rdms.module.system.api.user.AdminUserApi;
|
||||
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class TaskWorklogServiceImplTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private TaskWorklogServiceImpl taskWorklogService;
|
||||
|
||||
@Mock
|
||||
private ProjectMapper projectMapper;
|
||||
@Mock
|
||||
private ProjectExecutionMapper projectExecutionMapper;
|
||||
@Mock
|
||||
private ProjectTaskMapper projectTaskMapper;
|
||||
@Mock
|
||||
private TaskAssigneeMapper taskAssigneeMapper;
|
||||
@Mock
|
||||
private TaskWorklogMapper taskWorklogMapper;
|
||||
@Mock
|
||||
private ObjectStatusModelMapper objectStatusModelMapper;
|
||||
@Mock
|
||||
private AdminUserApi adminUserApi;
|
||||
|
||||
private static final Long PROJECT_ID = 2001L;
|
||||
private static final Long EXECUTION_ID = 5001L;
|
||||
private static final Long TASK_ID = 9001L;
|
||||
private static final Long OWNER_ID = 3001L;
|
||||
private static final Long ASSIGNEE_USER_ID = 3002L;
|
||||
private static final Long OUTSIDER_USER_ID = 3003L;
|
||||
|
||||
// -------------------- createWorklog --------------------
|
||||
|
||||
@Test
|
||||
void createWorklog_byOwner_shouldInsertWithLoginUserId() {
|
||||
TaskWorklogSaveReqVO reqVO = saveReq(LocalDate.of(2026, 5, 8), 150, "完成接口联调");
|
||||
stubEditableContext();
|
||||
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
|
||||
.thenReturn(createTask(OWNER_ID));
|
||||
when(projectTaskMapper.selectCount(any())).thenReturn(0L);
|
||||
when(taskWorklogMapper.insert(any(TaskWorklogDO.class))).thenAnswer(inv -> {
|
||||
TaskWorklogDO worklog = inv.getArgument(0);
|
||||
worklog.setId(11001L);
|
||||
return 1;
|
||||
});
|
||||
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockLoginUser(OWNER_ID)) {
|
||||
Long worklogId = taskWorklogService.createWorklog(PROJECT_ID, EXECUTION_ID, TASK_ID, reqVO);
|
||||
assertEquals(11001L, worklogId);
|
||||
}
|
||||
|
||||
ArgumentCaptor<TaskWorklogDO> captor = ArgumentCaptor.forClass(TaskWorklogDO.class);
|
||||
verify(taskWorklogMapper).insert(captor.capture());
|
||||
assertEquals(TASK_ID, captor.getValue().getTaskId());
|
||||
assertEquals(OWNER_ID, captor.getValue().getUserId());
|
||||
assertEquals(150, captor.getValue().getDurationMinutes());
|
||||
assertEquals(LocalDate.of(2026, 5, 8), captor.getValue().getWorkDate());
|
||||
assertEquals("完成接口联调", captor.getValue().getWorkContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createWorklog_byActiveAssignee_shouldInsert() {
|
||||
TaskWorklogSaveReqVO reqVO = saveReq(LocalDate.of(2026, 5, 8), 60, null);
|
||||
stubEditableContext();
|
||||
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
|
||||
.thenReturn(createTask(OWNER_ID));
|
||||
when(projectTaskMapper.selectCount(any())).thenReturn(0L);
|
||||
when(taskAssigneeMapper.selectActiveByTaskIdAndUserId(TASK_ID, ASSIGNEE_USER_ID))
|
||||
.thenReturn(activeAssignee(ASSIGNEE_USER_ID));
|
||||
when(taskWorklogMapper.insert(any(TaskWorklogDO.class))).thenAnswer(inv -> {
|
||||
TaskWorklogDO worklog = inv.getArgument(0);
|
||||
worklog.setId(11002L);
|
||||
return 1;
|
||||
});
|
||||
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockLoginUser(ASSIGNEE_USER_ID)) {
|
||||
Long id = taskWorklogService.createWorklog(PROJECT_ID, EXECUTION_ID, TASK_ID, reqVO);
|
||||
assertEquals(11002L, id);
|
||||
}
|
||||
|
||||
ArgumentCaptor<TaskWorklogDO> captor = ArgumentCaptor.forClass(TaskWorklogDO.class);
|
||||
verify(taskWorklogMapper).insert(captor.capture());
|
||||
assertEquals(ASSIGNEE_USER_ID, captor.getValue().getUserId());
|
||||
assertNull(captor.getValue().getWorkContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createWorklog_whenNotLeaf_shouldThrowNotLeafTask() {
|
||||
TaskWorklogSaveReqVO reqVO = saveReq(LocalDate.of(2026, 5, 8), 60, "x");
|
||||
stubEditableContext();
|
||||
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
|
||||
.thenReturn(createTask(OWNER_ID));
|
||||
when(projectTaskMapper.selectCount(any())).thenReturn(2L);
|
||||
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockLoginUser(OWNER_ID)) {
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> taskWorklogService.createWorklog(PROJECT_ID, EXECUTION_ID, TASK_ID, reqVO));
|
||||
assertEquals(ErrorCodeConstants.PROJECT_TASK_WORKLOG_NOT_LEAF_TASK.getCode(), ex.getCode());
|
||||
}
|
||||
verify(taskWorklogMapper, never()).insert(any(TaskWorklogDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createWorklog_byOutsider_shouldThrowNotOwnerOrAssignee() {
|
||||
TaskWorklogSaveReqVO reqVO = saveReq(LocalDate.of(2026, 5, 8), 60, "x");
|
||||
stubEditableContext();
|
||||
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
|
||||
.thenReturn(createTask(OWNER_ID));
|
||||
when(projectTaskMapper.selectCount(any())).thenReturn(0L);
|
||||
when(taskAssigneeMapper.selectActiveByTaskIdAndUserId(TASK_ID, OUTSIDER_USER_ID)).thenReturn(null);
|
||||
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockLoginUser(OUTSIDER_USER_ID)) {
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> taskWorklogService.createWorklog(PROJECT_ID, EXECUTION_ID, TASK_ID, reqVO));
|
||||
assertEquals(ErrorCodeConstants.PROJECT_TASK_WORKLOG_NOT_OWNER_OR_ASSIGNEE.getCode(), ex.getCode());
|
||||
}
|
||||
verify(taskWorklogMapper, never()).insert(any(TaskWorklogDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createWorklog_whenDurationNotMultipleOf30_shouldThrow() {
|
||||
TaskWorklogSaveReqVO reqVO = saveReq(LocalDate.of(2026, 5, 8), 45, "x");
|
||||
stubEditableContext();
|
||||
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
|
||||
.thenReturn(createTask(OWNER_ID));
|
||||
when(projectTaskMapper.selectCount(any())).thenReturn(0L);
|
||||
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockLoginUser(OWNER_ID)) {
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> taskWorklogService.createWorklog(PROJECT_ID, EXECUTION_ID, TASK_ID, reqVO));
|
||||
assertEquals(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DURATION_INVALID.getCode(), ex.getCode());
|
||||
}
|
||||
verify(taskWorklogMapper, never()).insert(any(TaskWorklogDO.class));
|
||||
}
|
||||
|
||||
// -------------------- updateWorklog --------------------
|
||||
|
||||
@Test
|
||||
void updateWorklog_byOriginalFiler_shouldPatchFields() {
|
||||
TaskWorklogSaveReqVO reqVO = saveReq(LocalDate.of(2026, 5, 9), 90, "改后的内容");
|
||||
stubEditableContext();
|
||||
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
|
||||
.thenReturn(createTask(OWNER_ID));
|
||||
when(taskWorklogMapper.selectByIdAndTaskId(11001L, TASK_ID))
|
||||
.thenReturn(createWorklog(11001L, ASSIGNEE_USER_ID, 60));
|
||||
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockLoginUser(ASSIGNEE_USER_ID)) {
|
||||
taskWorklogService.updateWorklog(PROJECT_ID, EXECUTION_ID, TASK_ID, 11001L, reqVO);
|
||||
}
|
||||
|
||||
ArgumentCaptor<TaskWorklogDO> captor = ArgumentCaptor.forClass(TaskWorklogDO.class);
|
||||
verify(taskWorklogMapper).updateById(captor.capture());
|
||||
assertEquals(11001L, captor.getValue().getId());
|
||||
assertEquals(LocalDate.of(2026, 5, 9), captor.getValue().getWorkDate());
|
||||
assertEquals(90, captor.getValue().getDurationMinutes());
|
||||
assertEquals("改后的内容", captor.getValue().getWorkContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateWorklog_byNonFiler_shouldThrowEditNotOwn() {
|
||||
TaskWorklogSaveReqVO reqVO = saveReq(LocalDate.of(2026, 5, 9), 60, "x");
|
||||
stubEditableContext();
|
||||
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
|
||||
.thenReturn(createTask(OWNER_ID));
|
||||
when(taskWorklogMapper.selectByIdAndTaskId(11001L, TASK_ID))
|
||||
.thenReturn(createWorklog(11001L, ASSIGNEE_USER_ID, 60));
|
||||
|
||||
// Owner 也不允许改协办人的工时
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockLoginUser(OWNER_ID)) {
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> taskWorklogService.updateWorklog(PROJECT_ID, EXECUTION_ID, TASK_ID, 11001L, reqVO));
|
||||
assertEquals(ErrorCodeConstants.PROJECT_TASK_WORKLOG_EDIT_NOT_OWN.getCode(), ex.getCode());
|
||||
}
|
||||
verify(taskWorklogMapper, never()).updateById(any(TaskWorklogDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateWorklog_whenWorklogMissing_shouldThrowNotExists() {
|
||||
TaskWorklogSaveReqVO reqVO = saveReq(LocalDate.of(2026, 5, 9), 60, "x");
|
||||
stubEditableContext();
|
||||
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
|
||||
.thenReturn(createTask(OWNER_ID));
|
||||
when(taskWorklogMapper.selectByIdAndTaskId(99999L, TASK_ID)).thenReturn(null);
|
||||
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockLoginUser(OWNER_ID)) {
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> taskWorklogService.updateWorklog(PROJECT_ID, EXECUTION_ID, TASK_ID, 99999L, reqVO));
|
||||
assertEquals(ErrorCodeConstants.PROJECT_TASK_WORKLOG_NOT_EXISTS.getCode(), ex.getCode());
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- deleteWorklog --------------------
|
||||
|
||||
@Test
|
||||
void deleteWorklog_byFiler_shouldDelete() {
|
||||
stubEditableContext();
|
||||
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
|
||||
.thenReturn(createTask(OWNER_ID));
|
||||
when(taskWorklogMapper.selectByIdAndTaskId(11001L, TASK_ID))
|
||||
.thenReturn(createWorklog(11001L, ASSIGNEE_USER_ID, 60));
|
||||
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockLoginUser(ASSIGNEE_USER_ID)) {
|
||||
taskWorklogService.deleteWorklog(PROJECT_ID, EXECUTION_ID, TASK_ID, 11001L);
|
||||
}
|
||||
verify(taskWorklogMapper).deleteById(11001L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteWorklog_byOwner_shouldDeleteOthers() {
|
||||
// 任务负责人删除协办人的工时记录(处理离职/误填)
|
||||
stubEditableContext();
|
||||
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
|
||||
.thenReturn(createTask(OWNER_ID));
|
||||
when(taskWorklogMapper.selectByIdAndTaskId(11001L, TASK_ID))
|
||||
.thenReturn(createWorklog(11001L, ASSIGNEE_USER_ID, 60));
|
||||
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockLoginUser(OWNER_ID)) {
|
||||
taskWorklogService.deleteWorklog(PROJECT_ID, EXECUTION_ID, TASK_ID, 11001L);
|
||||
}
|
||||
verify(taskWorklogMapper).deleteById(11001L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteWorklog_byOutsider_shouldThrowDeleteForbidden() {
|
||||
stubEditableContext();
|
||||
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
|
||||
.thenReturn(createTask(OWNER_ID));
|
||||
when(taskWorklogMapper.selectByIdAndTaskId(11001L, TASK_ID))
|
||||
.thenReturn(createWorklog(11001L, ASSIGNEE_USER_ID, 60));
|
||||
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockLoginUser(OUTSIDER_USER_ID)) {
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> taskWorklogService.deleteWorklog(PROJECT_ID, EXECUTION_ID, TASK_ID, 11001L));
|
||||
assertEquals(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DELETE_FORBIDDEN.getCode(), ex.getCode());
|
||||
}
|
||||
verify(taskWorklogMapper, never()).deleteById(any(Long.class));
|
||||
}
|
||||
|
||||
// -------------------- pagination & summary --------------------
|
||||
|
||||
@Test
|
||||
void getWorklogPage_shouldAttachUserNickname() {
|
||||
TaskWorklogPageReqVO reqVO = new TaskWorklogPageReqVO();
|
||||
when(projectExecutionMapper.selectByProjectIdAndId(PROJECT_ID, EXECUTION_ID))
|
||||
.thenReturn(createExecution());
|
||||
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
|
||||
.thenReturn(createTask(OWNER_ID));
|
||||
TaskWorklogDO row = createWorklog(11001L, ASSIGNEE_USER_ID, 90);
|
||||
when(taskWorklogMapper.selectPageByTaskId(eq(TASK_ID), any()))
|
||||
.thenReturn(new PageResult<>(List.of(row), 1L));
|
||||
AdminUserRespDTO user = new AdminUserRespDTO();
|
||||
user.setId(ASSIGNEE_USER_ID);
|
||||
user.setNickname("张三");
|
||||
when(adminUserApi.getUserMap(anyCollection())).thenReturn(Map.of(ASSIGNEE_USER_ID, user));
|
||||
|
||||
PageResult<TaskWorklogRespVO> page = taskWorklogService
|
||||
.getWorklogPage(PROJECT_ID, EXECUTION_ID, TASK_ID, reqVO);
|
||||
|
||||
assertEquals(1L, page.getTotal());
|
||||
assertEquals(1, page.getList().size());
|
||||
assertEquals("张三", page.getList().get(0).getUserNickname());
|
||||
assertEquals(90, page.getList().get(0).getDurationMinutes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sumDurationGroupedByTaskIds_shouldFoldRowsToMap() {
|
||||
when(taskWorklogMapper.sumDurationGroupByTaskIds(anyCollection())).thenReturn(List.of(
|
||||
Map.of("taskId", 9001L, "total", 150L),
|
||||
Map.of("taskId", 9002L, "total", 30L)
|
||||
));
|
||||
Map<Long, Long> result = taskWorklogService.sumDurationGroupedByTaskIds(List.of(9001L, 9002L));
|
||||
assertEquals(150L, result.get(9001L));
|
||||
assertEquals(30L, result.get(9002L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sumDurationGroupedByTaskIds_whenEmpty_shouldReturnEmptyMap() {
|
||||
Map<Long, Long> result = taskWorklogService.sumDurationGroupedByTaskIds(Collections.emptyList());
|
||||
assertEquals(Collections.emptyMap(), result);
|
||||
verify(taskWorklogMapper, never()).sumDurationGroupByTaskIds(anyCollection());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sumDurationByTaskId_defaultMethod_shouldFallbackToZero() {
|
||||
when(taskWorklogMapper.sumDurationGroupByTaskIds(anyCollection())).thenReturn(Collections.emptyList());
|
||||
assertEquals(0L, taskWorklogService.sumDurationByTaskId(9001L));
|
||||
verify(taskWorklogMapper, times(1)).sumDurationGroupByTaskIds(anyCollection());
|
||||
}
|
||||
|
||||
// -------------------- helpers --------------------
|
||||
|
||||
private void stubEditableContext() {
|
||||
when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject());
|
||||
when(projectExecutionMapper.selectByProjectIdAndId(PROJECT_ID, EXECUTION_ID))
|
||||
.thenReturn(createExecution());
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
|
||||
.thenReturn(createStatus("project", "pending", true));
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
|
||||
.thenReturn(createStatus("execution", "pending", true));
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "pending"))
|
||||
.thenReturn(createStatus("task", "pending", true));
|
||||
}
|
||||
|
||||
private MockedStatic<SecurityFrameworkUtils> mockLoginUser(Long loginUserId) {
|
||||
MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class);
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);
|
||||
return mocked;
|
||||
}
|
||||
|
||||
private TaskWorklogSaveReqVO saveReq(LocalDate workDate, Integer durationMinutes, String workContent) {
|
||||
TaskWorklogSaveReqVO req = new TaskWorklogSaveReqVO();
|
||||
req.setWorkDate(workDate);
|
||||
req.setDurationMinutes(durationMinutes);
|
||||
req.setWorkContent(workContent);
|
||||
return req;
|
||||
}
|
||||
|
||||
private ProjectDO createEditableProject() {
|
||||
ProjectDO project = new ProjectDO();
|
||||
project.setId(PROJECT_ID);
|
||||
project.setStatusCode("pending");
|
||||
return project;
|
||||
}
|
||||
|
||||
private ProjectExecutionDO createExecution() {
|
||||
ProjectExecutionDO execution = new ProjectExecutionDO();
|
||||
execution.setId(EXECUTION_ID);
|
||||
execution.setProjectId(PROJECT_ID);
|
||||
execution.setStatusCode("pending");
|
||||
return execution;
|
||||
}
|
||||
|
||||
private ProjectTaskDO createTask(Long ownerId) {
|
||||
ProjectTaskDO task = new ProjectTaskDO();
|
||||
task.setId(TASK_ID);
|
||||
task.setProjectId(PROJECT_ID);
|
||||
task.setExecutionId(EXECUTION_ID);
|
||||
task.setOwnerId(ownerId);
|
||||
task.setStatusCode("pending");
|
||||
return task;
|
||||
}
|
||||
|
||||
private TaskWorklogDO createWorklog(Long id, Long userId, Integer durationMinutes) {
|
||||
TaskWorklogDO worklog = new TaskWorklogDO();
|
||||
worklog.setId(id);
|
||||
worklog.setTaskId(TASK_ID);
|
||||
worklog.setUserId(userId);
|
||||
worklog.setWorkDate(LocalDate.of(2026, 5, 8));
|
||||
worklog.setDurationMinutes(durationMinutes);
|
||||
return worklog;
|
||||
}
|
||||
|
||||
private TaskAssigneeDO activeAssignee(Long userId) {
|
||||
TaskAssigneeDO assignee = new TaskAssigneeDO();
|
||||
assignee.setId(7001L);
|
||||
assignee.setTaskId(TASK_ID);
|
||||
assignee.setUserId(userId);
|
||||
return assignee;
|
||||
}
|
||||
|
||||
private ObjectStatusModelDO createStatus(String objectType, String statusCode, boolean allowEdit) {
|
||||
ObjectStatusModelDO status = new ObjectStatusModelDO();
|
||||
status.setObjectType(objectType);
|
||||
status.setStatusCode(statusCode);
|
||||
status.setAllowEdit(allowEdit);
|
||||
return status;
|
||||
}
|
||||
|
||||
}
|
||||
98
rdms-system/2026-05-11-file-upload-api-改造需求.md
Normal file
98
rdms-system/2026-05-11-file-upload-api-改造需求.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# 文件上传接口改造需求(给后端)
|
||||
|
||||
> 提单时间:2026-05-11
|
||||
> 提单方:前端
|
||||
> 涉及模块:`rdms-system-boot` 文件模块
|
||||
> 背景:业务表单(如新建/编辑任务)的附件采用「即传即存」交互,但表单可能被用户取消或关闭。前端需要在合适时机调用删除接口清理孤儿文件。当前 `/system/file/upload` 只返回 url,前端拿不到 fileId,无法触发删除。
|
||||
|
||||
---
|
||||
|
||||
## 1. 改造:`POST /system/file/upload`
|
||||
|
||||
**路径 / 方法 / 入参全部保持不变**,仅修改返回结构。
|
||||
|
||||
### 1.1 现状返回
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": "http://192.168.1.107:9009/rdms/20260508/xxx.jpg?X-Amz-..."
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 目标返回
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"id": "10001",
|
||||
"url": "http://192.168.1.107:9009/rdms/20260508/xxx.jpg?X-Amz-..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| id | string | 是 | 即 `infra_file.id`,**必须以字符串形式返回**,不要返回 number |
|
||||
| url | string | 是 | 文件访问 URL,含义与现状完全一致(私有桶带签名,公开桶裸 URL) |
|
||||
|
||||
### 1.4 关键约束
|
||||
|
||||
- **`id` 必须是 string,不是 number**。原因:`infra_file.id` 是 Long 类型,超过 JS 安全整数(2^53)时 JSON number 会精度丢失。前端项目 ID 统一按字符串接收。
|
||||
- 该改动**不兼容老返回结构**,前端会同步切换。
|
||||
- App 端 `/app-api/system/file/upload` 前端目前未使用,可暂不改;若希望对称建议一起改。
|
||||
|
||||
---
|
||||
|
||||
## 2. 确认:`DELETE /system/file/delete`
|
||||
|
||||
接口已存在(见 `system-file-api.md` §4.3.5),无需新建,但需确认以下两点:
|
||||
|
||||
### 2.1 权限
|
||||
|
||||
- 业务用户角色需具备 `system:file:delete` 权限码,**或**该接口允许"删除自己上传的文件"无需该权限码。
|
||||
- 期望:当前业务用户能直接调通 `DELETE /admin-api/system/file/delete?id=xxx`。
|
||||
|
||||
### 2.2 幂等性(删不存在的文件)
|
||||
|
||||
- 当前文档里删不存在的文件返回错误码 `1001003001 / 文件不存在`。
|
||||
- 期望:**幂等返回 `code: 0`**(更佳),或维持现状(前端会把该错误码当作"已删除"吞掉,也能接受)。
|
||||
|
||||
### 2.3 入参
|
||||
|
||||
保持现状不变:
|
||||
|
||||
```
|
||||
DELETE /admin-api/system/file/delete?id=10001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 不在本次范围(避免误解)
|
||||
|
||||
以下事项**本次不做**,属于后续「治本方案」范畴,请勿顺手改动:
|
||||
|
||||
- 不需要在 `infra_file` 表加 `status`(temp / committed)字段
|
||||
- 不需要加业务引用关系表
|
||||
- 不需要做孤儿文件定时清理 cron
|
||||
- 不需要改 `/presigned-url` 与 `/create`
|
||||
- 不需要改 App 端 `/upload` 鉴权策略
|
||||
|
||||
---
|
||||
|
||||
## 4. 联调要点
|
||||
|
||||
1. 改造完成后请提供测试环境,前端会同步发版。
|
||||
2. 接口返回的 `id` 请确保转为字符串再 JSON 序列化(不要直接序列化 Long)。
|
||||
3. 若返回里 `id` 是 number 类型,前端视为联调未通过。
|
||||
|
||||
---
|
||||
|
||||
## 5. 一句话总结
|
||||
|
||||
> `/system/file/upload` 返回结构从 `string` 改成 `{ id: string, url: string }`,`id` 是 `infra_file.id` 的字符串形式。其它接口不动。
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.njcn.rdms.module.system.api.file;
|
||||
|
||||
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||
import com.njcn.rdms.module.system.api.file.dto.FileRespDTO;
|
||||
import com.njcn.rdms.module.system.enums.ApiConstants;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
@FeignClient(name = ApiConstants.NAME)
|
||||
@Tag(name = "RPC 服务 - 文件")
|
||||
public interface FileApi {
|
||||
|
||||
String PREFIX = ApiConstants.PREFIX + "/file";
|
||||
|
||||
@GetMapping(PREFIX + "/get-by-url")
|
||||
@Operation(summary = "通过文件 URL 查询文件")
|
||||
@Parameter(name = "url", description = "文件 URL", required = true)
|
||||
CommonResult<FileRespDTO> getFileByUrl(@RequestParam("url") String url);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.njcn.rdms.module.system.api.file.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class FileRespDTO {
|
||||
|
||||
/**
|
||||
* 文件编号。
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 文件名称。
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 文件路径。
|
||||
*/
|
||||
private String path;
|
||||
|
||||
/**
|
||||
* 文件 URL。
|
||||
*/
|
||||
private String url;
|
||||
|
||||
/**
|
||||
* 文件类型。
|
||||
*/
|
||||
private String type;
|
||||
|
||||
/**
|
||||
* 文件大小。
|
||||
*/
|
||||
private Long size;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.njcn.rdms.module.system.api.file;
|
||||
|
||||
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||
import com.njcn.rdms.framework.common.util.object.BeanUtils;
|
||||
import com.njcn.rdms.module.system.api.file.dto.FileRespDTO;
|
||||
import com.njcn.rdms.module.system.dal.dataobject.file.FileDO;
|
||||
import com.njcn.rdms.module.system.service.file.FileService;
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
||||
|
||||
@RestController
|
||||
@Validated
|
||||
@Hidden
|
||||
public class FileApiImpl implements FileApi {
|
||||
|
||||
@Resource
|
||||
private FileService fileService;
|
||||
|
||||
@Override
|
||||
public CommonResult<FileRespDTO> getFileByUrl(String url) {
|
||||
FileDO file = fileService.getFileByUrl(url);
|
||||
return success(BeanUtils.toBean(file, FileRespDTO.class));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -21,6 +21,12 @@ public class AuthUserInfoRespVO {
|
||||
@Schema(description = "用户账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "admin")
|
||||
private String userName;
|
||||
|
||||
@Schema(description = "用户昵称", example = "灿能")
|
||||
private String nickname;
|
||||
|
||||
@Schema(description = "用户头像", example = "https://www.iocoder.cn/xx.png")
|
||||
private String avatar;
|
||||
|
||||
@Schema(description = "所属公司", example = "灿能")
|
||||
private String company;
|
||||
|
||||
|
||||
@@ -46,11 +46,12 @@ public class FileController {
|
||||
@Operation(summary = "上传文件", description = "模式一:后端上传文件")
|
||||
@Parameter(name = "file", description = "文件附件", required = true,
|
||||
schema = @Schema(type = "string", format = "binary"))
|
||||
public CommonResult<String> uploadFile(@Valid FileUploadReqVO uploadReqVO) throws Exception {
|
||||
public CommonResult<FileUploadRespVO> uploadFile(@Valid FileUploadReqVO uploadReqVO) throws Exception {
|
||||
MultipartFile file = uploadReqVO.getFile();
|
||||
byte[] content = IoUtil.readBytes(file.getInputStream());
|
||||
return success(fileService.createFile(content, file.getOriginalFilename(),
|
||||
uploadReqVO.getDirectory(), file.getContentType()));
|
||||
FileDO fileDO = fileService.createFile(content, file.getOriginalFilename(),
|
||||
uploadReqVO.getDirectory(), file.getContentType());
|
||||
return success(new FileUploadRespVO(String.valueOf(fileDO.getId()), fileDO.getUrl()));
|
||||
}
|
||||
|
||||
@GetMapping("/presigned-url")
|
||||
@@ -97,6 +98,21 @@ public class FileController {
|
||||
return success(true);
|
||||
}
|
||||
|
||||
@GetMapping("/download")
|
||||
@Operation(summary = "下载文件")
|
||||
@Parameter(name = "id", description = "编号", required = true)
|
||||
@PreAuthorize("@ss.hasPermission('system:file:query')")
|
||||
public void downloadFile(@RequestParam("id") Long id, HttpServletResponse response) throws Exception {
|
||||
FileDO file = fileService.getFile(id);
|
||||
byte[] content = fileService.getFileContent(file.getConfigId(), file.getPath());
|
||||
if (content == null) {
|
||||
log.warn("[downloadFile][id({}) configId({}) path({}) 文件不存在]", id, file.getConfigId(), file.getPath());
|
||||
response.setStatus(HttpStatus.NOT_FOUND.value());
|
||||
return;
|
||||
}
|
||||
writeAttachment(response, StrUtil.blankToDefault(file.getName(), file.getPath()), content);
|
||||
}
|
||||
|
||||
@GetMapping("/{configId}/get/**")
|
||||
@PermitAll
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.njcn.rdms.module.system.controller.admin.file.vo.file;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Schema(description = "管理后台 - 上传文件 Response VO")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class FileUploadRespVO {
|
||||
|
||||
@Schema(description = "文件编号,Long 以字符串返回,避免前端精度丢失", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "文件 URL", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://www.iocoder.cn/rdms.jpg")
|
||||
private String url;
|
||||
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import cn.hutool.core.io.IoUtil;
|
||||
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||
import com.njcn.rdms.module.system.controller.admin.file.vo.file.FileCreateReqVO;
|
||||
import com.njcn.rdms.module.system.controller.admin.file.vo.file.FilePresignedUrlRespVO;
|
||||
import com.njcn.rdms.module.system.dal.dataobject.file.FileDO;
|
||||
import com.njcn.rdms.module.system.controller.app.file.vo.AppFileUploadReqVO;
|
||||
import com.njcn.rdms.module.system.service.file.FileService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -39,8 +40,9 @@ public class AppFileController {
|
||||
public CommonResult<String> uploadFile(AppFileUploadReqVO uploadReqVO) throws Exception {
|
||||
MultipartFile file = uploadReqVO.getFile();
|
||||
byte[] content = IoUtil.readBytes(file.getInputStream());
|
||||
return success(fileService.createFile(content, file.getOriginalFilename(),
|
||||
uploadReqVO.getDirectory(), file.getContentType()));
|
||||
FileDO fileDO = fileService.createFile(content, file.getOriginalFilename(),
|
||||
uploadReqVO.getDirectory(), file.getContentType());
|
||||
return success(fileDO.getUrl());
|
||||
}
|
||||
|
||||
@GetMapping("/presigned-url")
|
||||
|
||||
@@ -57,6 +57,8 @@ public interface AuthConvert {
|
||||
return AuthUserInfoRespVO.builder()
|
||||
.userId(String.valueOf(user.getId()))
|
||||
.userName(user.getUsername())
|
||||
.nickname(user.getNickname())
|
||||
.avatar(user.getAvatar())
|
||||
.company(user.getCompany())
|
||||
.roles(sortDistinctStrings(convertList(roleList, RoleDO::getCode)))
|
||||
.buttons(sortDistinctStrings(convertList(menuList, MenuDO::getPermission,
|
||||
|
||||
@@ -7,6 +7,10 @@ import com.njcn.rdms.module.system.controller.admin.file.vo.file.FilePageReqVO;
|
||||
import com.njcn.rdms.module.system.dal.dataobject.file.FileDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import static com.njcn.rdms.framework.common.util.http.HttpUtils.removeUrlQuery;
|
||||
|
||||
/**
|
||||
* 文件操作 Mapper
|
||||
*
|
||||
@@ -23,4 +27,18 @@ public interface FileMapper extends BaseMapperX<FileDO> {
|
||||
.orderByDesc(FileDO::getId));
|
||||
}
|
||||
|
||||
default FileDO selectByUrl(String url) {
|
||||
FileDO file = selectFirstOne(FileDO::getUrl, url);
|
||||
String urlWithoutQuery = removeUrlQuery(url);
|
||||
if (file == null && !Objects.equals(urlWithoutQuery, url)) {
|
||||
file = selectOne(new LambdaQueryWrapperX<FileDO>()
|
||||
.likeRight(FileDO::getUrl, urlWithoutQuery)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
if (file == null && !Objects.equals(urlWithoutQuery, url)) {
|
||||
file = selectFirstOne(FileDO::getUrl, urlWithoutQuery);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -25,15 +25,15 @@ public interface FileService {
|
||||
PageResult<FileDO> getFilePage(FilePageReqVO pageReqVO);
|
||||
|
||||
/**
|
||||
* 保存文件,并返回文件的访问路径
|
||||
* 保存文件,并返回文件记录
|
||||
*
|
||||
* @param content 文件内容
|
||||
* @param name 文件名称,允许空
|
||||
* @param directory 目录,允许空
|
||||
* @param type 文件的 MIME 类型,允许空
|
||||
* @return 文件路径
|
||||
* @return 文件记录
|
||||
*/
|
||||
String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content,
|
||||
FileDO createFile(@NotEmpty(message = "文件内容不能为空") byte[] content,
|
||||
String name, String directory, String type);
|
||||
|
||||
/**
|
||||
@@ -63,6 +63,14 @@ public interface FileService {
|
||||
Long createFile(FileCreateReqVO createReqVO);
|
||||
FileDO getFile(Long id);
|
||||
|
||||
/**
|
||||
* 通过文件 URL 获得文件。
|
||||
*
|
||||
* @param url 文件 URL
|
||||
* @return 文件记录,不存在返回 null
|
||||
*/
|
||||
FileDO getFileByUrl(String url);
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*
|
||||
|
||||
@@ -61,7 +61,7 @@ public class FileServiceImpl implements FileService {
|
||||
|
||||
@Override
|
||||
@SneakyThrows
|
||||
public String createFile(byte[] content, String name, String directory, String type) {
|
||||
public FileDO createFile(byte[] content, String name, String directory, String type) {
|
||||
// 1.1 处理 type 为空的情况
|
||||
if (StrUtil.isEmpty(type)) {
|
||||
type = FileTypeUtils.getMineType(content, name);
|
||||
@@ -86,10 +86,11 @@ public class FileServiceImpl implements FileService {
|
||||
String url = client.upload(content, path, type);
|
||||
|
||||
// 3. 保存到数据库
|
||||
fileMapper.insert(new FileDO().setConfigId(client.getId())
|
||||
FileDO file = new FileDO().setConfigId(client.getId())
|
||||
.setName(name).setPath(path).setUrl(url)
|
||||
.setType(type).setSize((long) content.length));
|
||||
return url;
|
||||
.setType(type).setSize((long) content.length);
|
||||
fileMapper.insert(file);
|
||||
return file;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
@@ -157,6 +158,14 @@ public class FileServiceImpl implements FileService {
|
||||
return validateFileExists(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileDO getFileByUrl(String url) {
|
||||
if (StrUtil.isBlank(url)) {
|
||||
return null;
|
||||
}
|
||||
return fileMapper.selectByUrl(url);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteFile(Long id) throws Exception {
|
||||
// 校验存在
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.njcn.rdms.module.system.controller.admin.file;
|
||||
|
||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.njcn.rdms.module.system.dal.dataobject.file.FileDO;
|
||||
import com.njcn.rdms.module.system.service.file.FileService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class FileControllerTest extends BaseMockitoUnitTest {
|
||||
|
||||
@Mock
|
||||
private FileService fileService;
|
||||
|
||||
@InjectMocks
|
||||
private FileController fileController;
|
||||
|
||||
@Test
|
||||
void downloadFile_shouldReadContentByFileIdAndWriteAttachment() throws Exception {
|
||||
Long fileId = 10001L;
|
||||
FileDO file = new FileDO()
|
||||
.setId(fileId)
|
||||
.setConfigId(100L)
|
||||
.setPath("task/20260509/report_1778307118261.txt")
|
||||
.setName("report.txt");
|
||||
byte[] content = "hello".getBytes(StandardCharsets.UTF_8);
|
||||
when(fileService.getFile(fileId)).thenReturn(file);
|
||||
when(fileService.getFileContent(file.getConfigId(), file.getPath())).thenReturn(content);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
fileController.downloadFile(fileId, response);
|
||||
|
||||
verify(fileService).getFile(fileId);
|
||||
verify(fileService).getFileContent(100L, "task/20260509/report_1778307118261.txt");
|
||||
assertEquals("hello", response.getContentAsString(StandardCharsets.UTF_8));
|
||||
assertEquals("attachment;filename=report.txt", response.getHeader("Content-Disposition"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.njcn.rdms.module.system.dal.mysql.file;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.njcn.rdms.module.system.dal.dataobject.file.FileDO;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
class FileMapperTest {
|
||||
|
||||
@Test
|
||||
void selectByUrl_whenSignedUrl_shouldTryUnsignedPrefix() {
|
||||
FileMapper mapper = mock(FileMapper.class, invocation -> invocation.callRealMethod());
|
||||
String signedUrl = "http://oss.example.com/rdms/task/test.txt?X-Amz-Signature=abc";
|
||||
doReturn(null).when(mapper).selectFirstOne(any(), eq(signedUrl));
|
||||
doReturn(new FileDO().setId(10001L)).when(mapper).selectOne(any(Wrapper.class));
|
||||
|
||||
FileDO file = mapper.selectByUrl(signedUrl);
|
||||
|
||||
assertEquals(10001L, file.getId());
|
||||
verify(mapper).selectFirstOne(any(), eq(signedUrl));
|
||||
verify(mapper).selectOne(any(Wrapper.class));
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user