diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..bbb4ad5 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,31 @@ +{ + "permissions": { + "allow": [ + "Bash(git *)", + "Bash(cmd *)", + "PowerShell('--rdms-project--'; Get-ChildItem -Force -Name 'C:\\\\code\\\\gitea\\\\rdms\\\\cn-rdms\\\\rdms-project' | Sort-Object; '--rdms-system--'; Get-ChildItem -Force -Name 'C:\\\\code\\\\gitea\\\\rdms\\\\cn-rdms\\\\rdms-system' | Sort-Object; '--rdms-framework--'; Get-ChildItem -Force -Name 'C:\\\\code\\\\gitea\\\\rdms\\\\cn-rdms\\\\rdms-framework' | Sort-Object; '--rdms-gateway--'; Get-ChildItem -Force -Name 'C:\\\\code\\\\gitea\\\\rdms\\\\cn-rdms\\\\rdms-gateway' | Sort-Object)", + "PowerShell('--docs--'; Get-ChildItem -Force -Name 'C:\\\\code\\\\gitea\\\\rdms\\\\cn-rdms\\\\docs' -Recurse -Depth 2 | Sort-Object)", + "PowerShell('--project-boot src--'; Get-ChildItem -Force -Name 'C:\\\\code\\\\gitea\\\\rdms\\\\cn-rdms\\\\rdms-project\\\\rdms-project-boot\\\\src\\\\main\\\\java\\\\com\\\\njcn\\\\rdms\\\\module\\\\project' -Recurse -Depth 2 | Sort-Object)", + "PowerShell('--project-api src--'; Get-ChildItem -Force -Name 'C:\\\\code\\\\gitea\\\\rdms\\\\cn-rdms\\\\rdms-project\\\\rdms-project-api\\\\src\\\\main\\\\java' -Recurse -Depth 5 | Sort-Object)", + "PowerShell('--system-boot src--'; Get-ChildItem -Force -Name 'C:\\\\code\\\\gitea\\\\rdms\\\\cn-rdms\\\\rdms-system\\\\rdms-system-boot\\\\src\\\\main\\\\java\\\\com\\\\njcn\\\\rdms\\\\module\\\\system' -Recurse -Depth 2 | Sort-Object)", + "PowerShell('--project-boot service/project--'; Get-ChildItem -Force -Name 'C:\\\\code\\\\gitea\\\\rdms\\\\cn-rdms\\\\rdms-project\\\\rdms-project-boot\\\\src\\\\main\\\\java\\\\com\\\\njcn\\\\rdms\\\\module\\\\project\\\\service\\\\project' -Recurse -Depth 3 | Sort-Object)", + "PowerShell('--project-boot controller--'; Get-ChildItem -Force -Name 'C:\\\\code\\\\gitea\\\\rdms\\\\cn-rdms\\\\rdms-project\\\\rdms-project-boot\\\\src\\\\main\\\\java\\\\com\\\\njcn\\\\rdms\\\\module\\\\project\\\\controller' -Recurse -Depth 4 | Sort-Object)", + "PowerShell('--project-api full--'; Get-ChildItem -Force -Name 'C:\\\\code\\\\gitea\\\\rdms\\\\cn-rdms\\\\rdms-project\\\\rdms-project-api\\\\src\\\\main\\\\java\\\\com\\\\njcn\\\\rdms\\\\module\\\\project' -Recurse | Sort-Object)", + "PowerShell('--project-boot dal--'; Get-ChildItem -Force -Name 'C:\\\\code\\\\gitea\\\\rdms\\\\cn-rdms\\\\rdms-project\\\\rdms-project-boot\\\\src\\\\main\\\\java\\\\com\\\\njcn\\\\rdms\\\\module\\\\project\\\\dal' -Recurse | Sort-Object)", + "PowerShell('--project-api enums--'; Get-ChildItem -Force -Name 'C:\\\\code\\\\gitea\\\\rdms\\\\cn-rdms\\\\rdms-project\\\\rdms-project-api\\\\src\\\\main\\\\java\\\\com\\\\njcn\\\\rdms\\\\module\\\\project\\\\enums' -Recurse | Sort-Object; '--system-api full--'; Get-ChildItem -Force -Name 'C:\\\\code\\\\gitea\\\\rdms\\\\cn-rdms\\\\rdms-system\\\\rdms-system-api\\\\src\\\\main\\\\java' -Recurse -Depth 6 | Sort-Object)", + "PowerShell('--project-boot framework--'; Get-ChildItem -Force -Name 'C:\\\\code\\\\gitea\\\\rdms\\\\cn-rdms\\\\rdms-project\\\\rdms-project-boot\\\\src\\\\main\\\\java\\\\com\\\\njcn\\\\rdms\\\\module\\\\project\\\\framework' -Recurse | Sort-Object)", + "PowerShell('--sql resources--'; Get-ChildItem -Force -Name 'C:\\\\code\\\\gitea\\\\rdms\\\\cn-rdms\\\\rdms-project\\\\rdms-project-boot\\\\src\\\\main\\\\resources' -Recurse | Sort-Object; '--system sql--'; Get-ChildItem -Force -Name 'C:\\\\code\\\\gitea\\\\rdms\\\\cn-rdms\\\\rdms-system\\\\rdms-system-boot\\\\src\\\\main\\\\resources\\\\sql' -Recurse | Sort-Object)", + "PowerShell('--project-boot resources sql--'; Get-ChildItem -Force -Name 'C:\\\\code\\\\gitea\\\\rdms\\\\cn-rdms\\\\rdms-project\\\\rdms-project-boot\\\\src\\\\main\\\\resources\\\\sql' -Recurse | Sort-Object)", + "WebSearch", + "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 test 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 -Dtest=TaskAssigneeServiceImplTest 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 -am -Dtest=TaskAssigneeServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false 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 -am \"-Dtest=TaskAssigneeServiceImplTest\" \"-Dsurefire.failIfNoSpecifiedTests=false\" 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 -am \"-Dtest=TaskAssigneeServiceImplTest\" \"-Dsurefire.failIfNoSpecifiedTests=false\" test 2>&1 | Select-Object -Last 50)", + "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)" + ] + } +} diff --git a/AGENTS.md b/AGENTS.md index c6680a5..b6b62fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,9 +25,13 @@ ## 本地环境约定 +- 本仓库要求使用 `JDK 17`;不要使用 `JDK 8`、`JDK 11` 或其他版本执行编译、测试、启动、打包。 +- 本机 JDK 17 路径:`C:\Program Files\Java\jdk-17` +- 如需执行 Maven、Java、测试、启动等命令,应先确认当前 `JAVA_HOME` 指向 `C:\Program Files\Java\jdk-17`,并确保 `java -version` 实际输出为 `17` - 本机 Maven 安装路径:`C:\software\apache-maven-3.8.9` - 如需执行 Maven 命令,优先使用完整路径:`C:\software\apache-maven-3.8.9\bin\mvn.cmd` - 不要假设 `mvn` 已加入 PATH +- 不要假设系统默认 `JAVA_HOME` 已正确指向 JDK 17;如果当前 shell 不是 JDK 17,先在当前命令上下文显式切换后再执行 Maven - 只有在用户已明确同意执行编译、测试、打包等 Maven 命令时,才使用上述路径执行 顶层模块: diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b683703 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,110 @@ +# CLAUDE.md + +本文件为 Claude Code 在 `C:\code\gitea\rdms\cn-rdms` 仓库工作时的常驻指引,等价并补充 `AGENTS.md`。两份文件冲突时以本文件为准;本文件未覆盖的细节回退到 `AGENTS.md`。 + +## 工作方式 + +- 默认先给执行方案:目标、涉及模块、改动点、验证方式。用户明确同意前不要直接动手修改、编译、测试、打包。 +- 用户只要分析或评审时,停在分析层;不要顺手开工。 +- 描述仓库现状以**当前**代码、配置、文档可验证的事实为准;不要拿历史实现、过渡方案或已废弃模型解释当前状态。 +- 回答保持精简,先给结论、改动点、必要风险;细节等用户追问。 +- **不要废话**:默认极简输出,不展开背景、不复述需求、不堆叠章节标题;能用一两句讲清就别写成清单;用户主动追问再展开。 + +## 本机环境 + +- JDK:必须使用 `JDK 17`,路径 `C:\Program Files\Java\jdk-17`。不要使用 JDK 8 / 11 / 其他版本。 +- Maven:`C:\software\apache-maven-3.8.9`,命令优先用完整路径 `C:\software\apache-maven-3.8.9\bin\mvn.cmd`。不要假设 `mvn` 在 PATH。 +- 执行任何 Maven / java 命令前,先确认当前 shell 的 `JAVA_HOME` 指向 JDK 17,且 `java -version` 输出 17;否则在该命令上下文中显式切换。 +- **只有在用户已明确同意执行编译/测试/打包等命令时**,才使用上述路径执行。日常默认不跑任何会实际运行项目的命令。 + +## 仓库结构 + +多模块 Maven 单仓库,Java 17,Spring Boot 3.5.9,根模块打包 `pom`。 + +顶层模块: +1. `rdms-system` — 系统域(用户/组织/岗位/菜单/角色/权限) +2. `rdms-project` — RDMS 核心交付域(项目集/项目/产品/需求/任务/工单/执行) +3. `rdms-framework` — 共享框架与内部 starter +4. `rdms-gateway` — Spring Cloud Gateway 网关 + +每个业务模块按 `xxx-api` + `xxx-boot` 拆分: +- `*-api`:对外 RPC/Feign 接口、DTO、错误码、枚举、常量 +- `*-boot`:启动类、controller、service、dal、convert、api 实现、模块级 framework 配置 + +主包/启动类: +- `rdms-system-boot`:`com.njcn.rdms.module.system.SystemServerApplication` +- `rdms-project-boot`:`com.njcn.rdms.module.project.ProjectServerApplication` +- `rdms-gateway`:`com.njcn.rdms.gateway.GatewayServerApplication` + +`rdms-framework` 子模块:`rdms-common` 及一组 `rdms-spring-boot-starter-*`(`env`、`web`、`rpc`、`security`、`mybatis`、`redis`、`mq`、`websocket`、`excel`、`protection`、`test`、`biz-ip`)。 + +## 模块演进判断 + +新增能力时**先判断落点**: +- 落在现有 `rdms-system` 子域 → 沿用 `controller/admin|app`、`service`、`dal/dataobject`、`dal/mysql`、`convert` 的现有结构,跨模块暴露时在 `rdms-system-api` 补 API/DTO/错误码/枚举。 +- 落在现有 `rdms-project` 子域 → 同理,跨模块走 `rdms-project-api`。 +- 已具备独立服务边界(如未来的 `rdms-workflow`)→ 新建 `rdms-xxx` + `rdms-xxx-api` + `rdms-xxx-boot`,根 `pom.xml` 加聚合,包路径 / `spring.application.name` / `ApiConstants` / `RpcConstants` / `rdms.info.base-package` 保持一致。 + +不要: +- 把后续业务长期堆进 `rdms-system`。 +- 为新增子域引入一套平行的 `application/domain/infrastructure/adapter` 分层。 +- 让外部模块直接依赖 `*-boot` 的 service 或 mapper(必须走 `*-api`)。 + +## 分层职责 + +| 层 | 职责 | 红线 | +|---|---|---| +| `rdms-framework` | 基础能力 | 不承载业务语义;除非框架级缺陷或全局基础设施,不要把业务判断塞进来 | +| `rdms-gateway` | 入口、令牌校验、登录用户透传、路由、横切 | 不要在这里承载组织/成员/负责人/项目/产品/工作流状态/数据可见性 | +| Controller | HTTP 暴露、参数校验、权限注解、结果封装 | 不要编排复杂业务流程,不要直接操作多个 mapper;用 `ReqVO`/`RespVO`,不要直接暴露 DO | +| Service | 业务规则、事务、缓存、领域编排 | 已有领域优先扩展,不要为"整齐"平移;不要把规则散到 controller / mapper / util | +| DAL | DO + Mapper | Mapper 继承 `BaseMapperX`;查询优先 `LambdaQueryWrapperX` 与默认方法封装;非必要不回退 XML;不承担领域校验 | +| Convert | 已有 `convert` 风格继续沿用,简单场景直接 `BeanUtils` | 不要强推全员 MapStruct,也不要反过来把已有 convert 全删 | + +## 认证与跨模块调用 + +- 默认沿用 OAuth2 / Token / `LoginUser` / `login-user` 透传主链。**不要**另造 ThreadLocal / Session / 自定义 header。 +- 接口级权限走 `@PreAuthorize("@ss.hasPermission(...)")`,不要绕开。 +- 跨模块/跨服务必须通过 `*-api` 模块定义契约;不要直接依赖别人的 `*-boot`。 + +## 数据与 SQL + +- 新表 DO 复用现有 `BaseDO` / 审计字段风格,不要再引一套审计基类(除非该表本身明确不需要逻辑删除)。 +- **不要假设运行时自动数据库迁移**:依赖新表/新字段/新索引时,必须同步补 SQL 脚本与文档。 +- SQL 放在目标模块 `src/main/resources/sql/...`,可审阅、可单独执行。 +- 缓存/日志/审计变更优先沿用既有机制,不要绕开登录上下文与审计字段填充。 + +## 注释与编码 + +- 关键字段、关键分支、关键约束、非直观实现补**简洁中文**注释。 +- 不要为省事删除原有有效注释;也不要写无信息量的注释。 +- 中文写入必须 UTF-8,并自查显示是否正常;不要用"改成英文"规避乱码。 +- superpowers 产出的功能文档(设计/实施/联调)默认中文落地;代码标识、文件路径、接口路径、SQL、命令保持原样不意译。 + +## 工作规则(执行前对照) + +1. 优先做有边界的模块内改动,避免跨模块扩散。 +2. 业务逻辑落 `*-boot`;可复用契约落 `*-api`;可复用框架能力落 `rdms-framework`。 +3. **不要修改** `application-local.yaml` / `application-dev.yaml`,除非任务本身就是环境配置调整。 +4. 把本地 YAML 当作可能带机器差异的文件,改前先查 git 状态。 +5. 包结构:`controller` / `service` / `dal` / `convert`,保持不变。 +6. 新增共享能力优先扩展现有 `rdms-spring-boot-starter-*`,不要在业务服务里重复堆配置。 +7. 改跨模块 API 时,提供方实现与对应 `*-api` 契约同步更新。 +8. **未经用户明确同意,不执行任何 `mvn`、启动命令、脚本等会实际运行项目的命令。** + +## 验证默认动作 + +先定义验证方式,再做修改。默认静态验证: +- 调用链是否闭环、是否符合模块边界 +- 配置项 / 接口契约 / 权限标识 / 路由 / 资源注册前后是否一致 +- 改动是否控制在最小集合 +- 文档 / SQL / 配置 / 接口说明是否需要同步更新 + +如果改动涉及 Spring 配置、序列化、安全、路由、RPC 契约、MyBatis 行为或跨模块 API,必须**显式区分**哪些是已静态检查、哪些尚未实际运行验证。 + +## 给后续我自己的提醒 + +- 仓库可能有未提交的本地改动,不要顺手覆盖与当前任务无关的编辑。 +- `docs/` 是当前工作上下文的一部分,不是归档;架构级修改前先查阅。 +- 根 `pom.xml` 统一版本与依赖;版本调整改根 pom,不要散落到子模块。 +- 推荐使用 `Glob` / `Grep` / `Read` 等专用工具,避免用 Bash 做文件搜索/读取/编辑。 diff --git a/rdms-gateway/src/main/resources/application-dev.yaml b/rdms-gateway/src/main/resources/application-dev.yaml index a93a128..7a2d55a 100644 --- a/rdms-gateway/src/main/resources/application-dev.yaml +++ b/rdms-gateway/src/main/resources/application-dev.yaml @@ -6,10 +6,10 @@ spring: username: # Nacos 账号 password: # Nacos 密码 discovery: # 【配置中心】配置项 - namespace: dev # 命名空间。这里使用 dev 开发环境 + namespace: 1924bcfb-4eab-4c58-9003-4a37d5fc2949 # 命名空间。这里使用 dev 开发环境 group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP config: # 【注册中心】配置项 - namespace: dev # 命名空间。这里使用 dev 开发环境 + namespace: 1924bcfb-4eab-4c58-9003-4a37d5fc2949 # 命名空间。这里使用 dev 开发环境 group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP #################### 监控相关配置 #################### diff --git a/rdms-gateway/src/main/resources/application-local.yaml b/rdms-gateway/src/main/resources/application-local.yaml index 5d2e345..c1e4b08 100644 --- a/rdms-gateway/src/main/resources/application-local.yaml +++ b/rdms-gateway/src/main/resources/application-local.yaml @@ -6,10 +6,10 @@ spring: username: # Nacos 账号 password: # Nacos 密码 discovery: # 【配置中心】配置项 - namespace: dev # 命名空间。这里使用 dev 开发环境 + namespace: 1924bcfb-4eab-4c58-9003-4a37d5fc2949 # 命名空间。这里使用 dev 开发环境 group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP config: # 【注册中心】配置项 - namespace: dev # 命名空间。这里使用 dev 开发环境 + namespace: 1924bcfb-4eab-4c58-9003-4a37d5fc2949 # 命名空间。这里使用 dev 开发环境 group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP #################### 监控相关配置 #################### diff --git a/rdms-project/2026-04-23-product-activity-timeline-api-design.md b/rdms-project/2026-04-23-product-activity-timeline-api-design.md deleted file mode 100644 index 14a6631..0000000 --- a/rdms-project/2026-04-23-product-activity-timeline-api-design.md +++ /dev/null @@ -1,270 +0,0 @@ -# 产品动态时间线后端接口需求说明 - -日期:2026-04-23 - -## 1. 背景 - -当前产品对象首页中的“产品动态时间线”模块,用户期望不是展示几条前端拼装的摘要,而是一个可以在首页内直接查询的正式动态模块。 - -本轮已确认的目标能力包括: - -- 首页内直接展示产品动态时间线 -- 默认查询最近 `30 天` -- 支持自定义起止时间筛选 -- 支持事件类型多选筛选 -- 支持分页 -- 第一版事件范围收敛在产品对象与团队关系,不混入需求池事件 - -## 2. 当前前端现状 - -当前前端可直接使用的真实接口只有: - -- `GET /project/product/get` -- `GET /project/product/{id}/settings` -- `GET /project/product/{id}/members` - -这些接口目前只能提供: - -- 产品当前状态 -- 最近一次状态原因 -- 产品经理 -- 成员加入时间 -- 成员退出时间 -- 当前成员角色 - -它们可以勉强拼出少量“最近动态摘要”,但不足以支撑正式时间线查询模块。 - -## 3. 当前接口为什么不够 - -如果继续只依赖现有接口,前端存在以下硬缺口: - -1. 没有统一的产品动态分页接口 -2. 没有事件类型维度,无法支持类型筛选 -3. 没有统一发生时间字段集合,无法稳定支持时间筛选 -4. 没有事件操作人字段,无法明确展示“谁做了什么” -5. 没有统一的事件摘要字段,前端只能自己硬拼文案 -6. 没有产品状态变更前后值,无法展示“从什么状态变更为什么状态” -7. 没有产品经理变更前后值,无法展示交接关系 -8. 没有分页总数字段,无法做首页内翻页 - -因此,现有接口只适合做“概览摘要”,不适合做“可查询产品动态时间线”。 - -## 4. 后端接口交付要求 - -后端必须新增一条专用分页接口: - -`GET /project/product/{id}/activities/page` - -这条接口只服务“产品动态时间线”能力,不承担需求池动态,不承担首页其它概览指标。 - -前端要求后端单独提供这条接口,原因如下: - -- 语义清晰,前后端都容易维护 -- 首页时间线可直接消费 -- 后续如果要做独立的产品动态页,也可以继续复用这条接口 -- 不需要继续让前端从多个接口里拼装事件 - -## 5. 第一版事件范围 - -第一版事件类型确认收敛为 5 类: - -- `product_created`:产品创建 -- `product_status_changed`:产品状态变更 -- `product_manager_changed`:产品经理变更 -- `product_member_joined`:成员加入 -- `product_member_removed`:成员移出 - -第一版明确不纳入: - -- 成员角色调整 -- 需求新增 -- 需求状态流转 -- 需求关闭 -- 里程碑事件 -- 风险点事件 - -## 6. 查询参数要求 - -接口必须支持以下查询参数: - -- `pageNo`:页码 -- `pageSize`:每页数量 -- `startTime`:开始时间 -- `endTime`:结束时间 -- `types`:事件类型数组,多选 - -补充要求: - -- 当前端未传时间范围时,后端默认按最近 `30 天` 返回 -- 返回结果按 `occurredAt desc` 倒序排列 -- `types` 支持多选,不要求前端单选 - -示例: - -```http -GET /project/product/1001/activities/page?pageNo=1&pageSize=10&startTime=2026-03-24 00:00:00&endTime=2026-04-23 23:59:59&types=product_status_changed&types=product_manager_changed -``` - -## 7. 返回结构要求 - -接口返回必须支持分页,分页结构至少应满足: - -```json -{ - "total": 128, - "list": [ - { - "id": "act_001", - "type": "product_status_changed", - "title": "产品状态变更", - "operatorId": "10001", - "operatorName": "张敏", - "occurredAt": "2026-04-23 10:32:15", - "summary": "产品状态由暂停变更为启用", - "reason": "测试恢复", - "beforeStatus": "paused", - "afterStatus": "active" - } - ] -} -``` - -分页顶层字段至少包括: - -- `total` -- `list` - -如果后端已有统一分页模型,可以沿用现有分页结构,但前端必须能稳定拿到总数和列表。 - -## 8. 事件通用字段要求 - -无论哪种事件类型,后端都应统一返回以下字段: - -- `id`:事件唯一 ID -- `type`:事件类型编码 -- `title`:事件标题 -- `operatorId`:操作人 ID -- `operatorName`:操作人名称 -- `occurredAt`:发生时间 -- `summary`:事件摘要 -- `reason`:原因或备注,可为空 - -这些字段是首页时间线最小可展示集合。 - -## 9. 各事件类型的专属字段要求 - -### 9.1 产品创建 `product_created` - -必须补充: - -- `creatorUserId` -- `creatorUserName` - -### 9.2 产品状态变更 `product_status_changed` - -这是当前最关键的一类事件,后端必须返回: - -- `beforeStatus` -- `afterStatus` -- `reason` - -前端需要用这组字段明确表达: - -- 变更前状态 -- 变更后状态 -- 本次变更原因 - -例如: - -- `暂停 -> 启用` -- `启用 -> 归档` - -### 9.3 产品经理变更 `product_manager_changed` - -必须返回: - -- `beforeManagerUserId` -- `beforeManagerUserName` -- `afterManagerUserId` -- `afterManagerUserName` -- `reason` - -否则前端无法准确展示交接关系,只能看到当前经理,不能看到变更前后关系。 - -### 9.4 成员加入 `product_member_joined` - -必须返回: - -- `memberUserId` -- `memberUserName` -- `roleId` -- `roleName` -- `remark` - -### 9.5 成员移出 `product_member_removed` - -必须返回: - -- `memberUserId` -- `memberUserName` -- `roleId` -- `roleName` -- `reason` - -## 10. 前端展示口径 - -前端首页时间线模块第一版会直接基于这条接口支持: - -- 默认最近 `30 天` -- 自定义时间范围筛选 -- 事件类型多选筛选 -- 分页切换 - -每条记录最少展示: - -- 事件类型 -- 事件标题 -- 操作人 -- 发生时间 -- 变更摘要 -- 原因/备注 - -其中“产品状态变更”需要明确体现: - -- 变更前状态 -- 变更后状态 -- 变更原因 - -## 11. 为什么不建议继续让前端拼装 - -如果继续沿用当前前端拼装方案,会有这些问题: - -- 产品状态变更前后值无法补齐 -- 产品经理变更前后值无法补齐 -- 无法支持分页 -- 无法支持统一时间筛选 -- 无法支持统一类型筛选 -- 不同事件文案会在前端散落拼装,长期维护成本高 - -因此这里的前后端边界应明确为: - -- 后端提供统一产品动态分页接口 -- 前端负责筛选条件组织、分页交互和时间线展示 - -## 12. 本轮需求结论 - -本轮给后端的结论可以直接收敛为: - -1. 当前前端已有接口不满足正式产品动态时间线需求 -2. 后端新增 `GET /project/product/{id}/activities/page` -3. 接口必须支持分页、默认最近 `30 天`、自定义时间范围、事件类型多选 -4. 第一版事件类型只做: - - 产品创建 - - 产品状态变更 - - 产品经理变更 - - 成员加入 - - 成员移出 -5. 产品状态变更必须提供前后状态和原因 -6. 产品经理变更必须提供前后经理信息 - -这条接口交付后,前端才能把当前“产品动态时间线”从拼装摘要升级成正式可查询模块。 diff --git a/rdms-project/2026-04-23-product-overview-homepage-design.md b/rdms-project/2026-04-23-product-overview-homepage-design.md deleted file mode 100644 index 58da0c6..0000000 --- a/rdms-project/2026-04-23-product-overview-homepage-design.md +++ /dev/null @@ -1,292 +0,0 @@ -# 产品对象首页改版设计说明 - -日期:2026-04-23 - -## 1. 目标 - -本设计用于收敛 RDMS 产品对象上下文默认首页的改版方向。 - -本轮目标不是继续做“说明型占位页”,而是明确把当前 `/product/dashboard?objectId=...` 改成一个真正可用的产品对象首页: - -- 第一眼先让用户知道当前看的是什么产品 -- 第二眼能快速判断对象最近发生了什么 -- 第三眼能看出需求池现在的经营状态和最近变化 -- 底部为后续业务模块保留正式挂载位,而不是临时拼接入口 - -## 2. 已确认诉求 - -基于本轮对话,已确认以下用户诉求: - -1. 首页顶部必须先展示产品基础概述,而不是先铺统计卡片 -2. 基础概述至少包含:名称、编号、团队、产品经理等对象基础信息 -3. 页面需要一块明显的时间线,用于承接产品对象与团队变更动态 -4. 页面需要承接需求池管理情况,重点看总量、状态、待处理等统计信息 -5. 需求相关事件不要混入对象时间线,应单独作为需求池最近变化区域 -6. 快捷入口不要保留 -7. 底部允许保留后续扩展区,重点预留给里程碑、风险点管理、产品资料等模块 -8. 能接真实接口就接真实接口,当前没有稳定接口的区域允许先用假数据,但结构必须按正式首页来设计 - -## 3. 首页定位结论 - -本页定位不是: - -- 纯报表看板 -- 纯审计日志页 -- 设置页搬运版 -- 导航入口集合页 - -本页定位应当是: - -- 产品对象首页 -- 偏统计,也带审计 -- 但页面主语始终是“当前产品对象” - -换句话说,这个页面要同时回答三个问题: - -1. 我现在看的是什么产品? -2. 这个产品对象最近发生了什么? -3. 这个产品的需求池现在处于什么状态? - -## 4. 页面结构 - -### 4.1 桌面端结构 - -桌面端建议采用三层结构: - -1. 顶部 `对象基础概述横幅` -2. 中部 `左时间线 + 右需求池双模块` -3. 底部 `扩展信息区` - -推荐布局比例: - -- 顶部横幅:`24 / 24` -- 中部主区:左 `16 / 24`,右 `8 / 24` -- 底部扩展区:`24 / 24` - -中部左侧时间线高度应明显高于右侧任一单模块,形成首页主阅读区。 - -### 4.2 移动端结构 - -移动端统一退化为单列纵向布局,顺序为: - -1. 对象基础概述横幅 -2. 对象 / 团队动态时间线 -3. 需求池管理概览 -4. 需求池最近变化 -5. 扩展信息区 - -移动端不强撑左右栏并排,不做卡片墙式压缩。 - -## 5. 模块设计 - -### 5.1 对象基础概述横幅 - -顶部采用“档案横幅型”,不采用纯指标卡片型。 - -横幅左侧承接对象身份信息: - -- 产品名称 -- 产品编号 -- 当前状态标签 -- 产品经理 -- 团队规模 -- 团队角色摘要 -- 简短描述或备注 - -横幅右侧承接 4 个摘要指标: - -- 团队人数 -- 需求总量 -- 待处理需求 -- 最近动态时间 - -设计原则: - -- 左侧负责建立对象识别 -- 右侧负责快速判断当前概况 -- 右侧指标只保留 4 项,不堆成报表卡片墙 - -### 5.2 对象 / 团队动态时间线 - -该区域位于中部左侧,是首页的主阅读区。 - -这条时间线只承接对象与团队变化,不承接需求事件。 - -第一版事件范围收敛为: - -- 产品创建 -- 产品状态变更 -- 产品经理变更 -- 成员加入 -- 成员移出 -- 成员角色调整 - -每条时间线建议展示: - -- 事件标题 -- 事件类型标签 -- 发生时间 -- 操作摘要 -- 必要时展示原因或备注 - -表达目标是“业务时间线”,不是后台审计表格。 - -### 5.3 需求池管理概览 - -该区域位于中部右侧上半块,用于表达需求池的经营状态。 - -第一版首页需要优先看到的内容: - -- 需求总量 -- 各状态数量 -- 待处理数量 -- 高优先级待处理数量 - -展示方式建议为“摘要指标 + 状态分布列表”,不直接在首页展开完整需求表格。 - -这一块回答的是: - -- 需求池是否健康 -- 当前待处理压力大不大 -- 是否存在需要优先关注的积压 - -### 5.4 需求池最近变化 - -该区域位于中部右侧下半块,与需求池管理概览上下分层,但属于同一侧栏语义。 - -该区域不重复展示总量,而是展示需求池最近发生的变化。 - -第一版建议承接: - -- 最近新增需求 -- 最近状态流转 -- 最近关闭或完成 - -每条记录建议至少展示: - -- 需求标题 -- 动作类型 -- 时间 -- 当前状态或状态变更摘要 - -若当前没有真实数据,仍保留正式模块壳,不退化成“待开发”一句话。 - -### 5.5 扩展信息区 - -底部不再保留快捷入口,改为正式扩展信息区。 - -当前优先预留 3 类模块位: - -- 里程碑 -- 风险点管理 -- 产品资料 - -这一层的作用是: - -- 为后续对象级信息继续扩展留下稳定挂载位 -- 不把中部主结构挤成信息大杂烩 -- 避免为了未来模块提前做假导航入口 - -如果当前没有稳定接口,可先保留正式卡片结构与空态说明。 - -## 6. 数据策略 - -### 6.1 真实接口优先 - -当前首页优先消费现有真实接口: - -- `fetchGetProduct` -- `fetchGetProductSettings` -- `fetchGetProductMembers` - -这些接口足以支撑: - -- 对象基础概述中的名称、编号、状态、产品经理、描述 -- 团队人数与角色摘要 -- 最近动态中的产品创建、状态变化、成员加入/移出 - -### 6.2 假数据使用边界 - -当前没有稳定真实接口的区域,允许先用假数据,但边界必须明确: - -- 需求池管理概览 -- 需求池最近变化 -- 扩展信息区中的里程碑、风险点管理、产品资料摘要 - -假数据的使用原则: - -1. 只补“当前没有稳定接口”的区域 -2. 不反向污染对象基础信息 -3. 不把假数据混入对象上下文 store -4. 数据源要集中放在概览页自己的 mock 模块中,方便后续替换 - -### 6.3 不推荐的做法 - -以下做法应避免: - -- 把需求假数据散落写进页面组件 -- 用对象 demo 数据冒充真实产品详情 -- 把对象时间线和需求时间线混成一条 -- 用快捷入口伪装成首页内容 - -## 7. 空态规则 - -首页至少要区分三种状态: - -1. 能力未接入,只能先显示正式占位信息 -2. 能力已接入,但当前该产品暂无业务数据 -3. 当前用户无权限查看该模块 - -这三种状态不能共用一套模糊文案。 - -对需求池和扩展信息区,当前阶段更推荐“正式空态”而不是“待开发”。 - -## 8. 页面边界 - -首页明确不承接以下内容: - -- 快捷入口导航区 -- 完整团队成员表格 -- 完整需求列表表格 -- 设置页重表单 -- 完整审计日志明细页 - -首页要做的是概述、判断与阅读,不是重操作页。 - -## 9. 实施建议 - -第一阶段建议先完成结构性改造: - -1. 重做顶部横幅,建立对象档案感 -2. 保留中部左高右双块结构 -3. 用真实接口接通对象概述与对象 / 团队时间线 -4. 用局部 mock 数据先接通需求池两块和底部扩展区 - -第二阶段再逐步替换需求池与扩展区数据源: - -- 接真实需求池统计接口 -- 接真实需求动态接口 -- 接里程碑、风险点、产品资料摘要接口 - -## 10. 验证标准 - -本设计是否成立,可按以下标准判断: - -1. 进入首页后,第一眼能认出当前产品对象 -2. 用户能自然读到对象 / 团队最近发生了什么 -3. 右侧能快速判断需求池当前压力与最近变化 -4. 页面看起来像“对象首页”,而不是“普通后台卡片堆叠页” -5. 当前没有真实接口的区域也保留正式结构,不显得像临时占位 -6. 后续新增里程碑、风险点管理、产品资料等能力时,不需要推翻整页结构 - -## 11. 本轮设计结论 - -本轮最终设计结论如下: - -- 首页定位为“产品对象首页”,偏统计,也带审计,但不做纯报表页 -- 顶部采用档案横幅型,先立住对象身份信息 -- 中部左侧是高权重的对象 / 团队动态时间线 -- 中部右侧拆为“需求池管理概览 + 需求池最近变化”上下两块 -- 底部去掉快捷入口,改为正式扩展信息区 -- 当前有真实接口的模块优先接真实接口 -- 当前没有稳定接口的区域允许先用假数据,但必须隔离在概览页局部 mock 数据源中 diff --git a/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ErrorCodeConstants.java b/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ErrorCodeConstants.java index 0ff5b20..f85a7c9 100644 --- a/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ErrorCodeConstants.java +++ b/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ErrorCodeConstants.java @@ -53,4 +53,85 @@ public interface ErrorCodeConstants { ErrorCode REQUIREMENT_MODULE_HAS_CHILDREN = new ErrorCode(1_008_002_015, "存在子模块,请先删除子模块"); ErrorCode REQUIREMENT_MODULE_HAS_REQUIREMENTS = new ErrorCode(1_008_002_016, "模块下存在需求,请先删除需求"); + // ========== 项目管理 1-008-002-000 ========== + ErrorCode PROJECT_NOT_EXISTS = new ErrorCode(1_008_002_000, "项目不存在"); + ErrorCode PROJECT_CODE_DUPLICATE = new ErrorCode(1_008_002_001, "已经存在编码为【{}】的项目"); + ErrorCode PROJECT_NAME_DUPLICATE = new ErrorCode(1_008_002_002, "当前产品下已经存在名称为【{}】的项目"); + ErrorCode PROJECT_CODE_NOT_MODIFIABLE = new ErrorCode(1_008_002_003, "项目编码创建后不允许修改"); + ErrorCode PROJECT_PRODUCT_NOT_MODIFIABLE = new ErrorCode(1_008_002_004, "项目所属产品第一期不允许直接修改"); + ErrorCode PROJECT_PRODUCT_NOT_EXISTS = new ErrorCode(1_008_002_005, "所属产品不存在或不可用"); + ErrorCode PROJECT_TYPE_INVALID = new ErrorCode(1_008_002_006, "项目类型不是有效字典值"); + ErrorCode PROJECT_MANAGER_USER_INVALID = new ErrorCode(1_008_002_007, "项目负责人不是有效系统用户"); + ErrorCode PROJECT_MEMBER_USER_INVALID = new ErrorCode(1_008_002_008, "项目成员不是有效系统用户"); + ErrorCode PROJECT_STATUS_ACTION_NOT_ALLOWED = new ErrorCode(1_008_002_009, "当前项目状态不支持动作【{}】"); + ErrorCode PROJECT_STATUS_ACTION_REASON_REQUIRED = new ErrorCode(1_008_002_010, "动作【{}】必须填写原因"); + ErrorCode PROJECT_STATUS_CONCURRENT_MODIFIED = new ErrorCode(1_008_002_011, "项目状态已发生变化,请刷新后重试"); + ErrorCode PROJECT_STATUS_NOT_ALLOW_EDIT = new ErrorCode(1_008_002_012, "当前项目状态不允许编辑"); + ErrorCode PROJECT_MEMBER_NOT_EXISTS = new ErrorCode(1_008_002_013, "项目成员不存在"); + ErrorCode PROJECT_MEMBER_ALREADY_EXISTS = new ErrorCode(1_008_002_014, "该用户已是当前项目的有效成员"); + ErrorCode PROJECT_MEMBER_ROLE_INVALID = new ErrorCode(1_008_002_015, "角色不存在或不属于项目对象角色"); + ErrorCode PROJECT_MANAGER_MEMBER_NOT_ALLOW_REMOVE = new ErrorCode(1_008_002_016, "当前项目负责人不能移出项目团队,请先完成负责人变更"); + ErrorCode PROJECT_MEMBER_NOT_ACTIVE = new ErrorCode(1_008_002_017, "当前项目成员已失效"); + ErrorCode PROJECT_DELETE_CONFIRM_TEXT_INVALID = new ErrorCode(1_008_002_018, "删除确认口令不正确"); + ErrorCode PROJECT_DELETE_NAME_MISMATCH = new ErrorCode(1_008_002_019, "删除确认名称与当前项目名称不一致"); + ErrorCode PROJECT_NOT_ALLOW_DELETE = new ErrorCode(1_008_002_020, "当前项目不允许删除"); + ErrorCode PROJECT_OBJECT_PERMISSION_DENIED = new ErrorCode(1_008_002_021, "当前用户不具备该项目的操作权限【{}】"); + ErrorCode PROJECT_STATUS_MODEL_NOT_EXISTS_OR_DISABLED = new ErrorCode(1_008_002_022, "项目状态定义不存在或已停用"); + ErrorCode PROJECT_DIRECTION_INVALID = new ErrorCode(1_008_002_023, "项目方向不是有效字典值"); + ErrorCode PROJECT_MANAGER_TRANSFER_INFO_REQUIRED = new ErrorCode(1_008_002_024, "切换项目经理时必须同时传入原项目经理用户和交接后角色"); + ErrorCode PROJECT_MANAGER_TRANSFER_SOURCE_INVALID = new ErrorCode(1_008_002_025, "原项目经理信息与当前项目经理不一致"); + 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, "当前产品下已存在未作废的主线项目"); + + // ========== 执行管理 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_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, "该项目成员仍担任未终态执行负责人,请先完成执行负责人交接"); + ErrorCode PROJECT_EXECUTION_STATUS_MODEL_NOT_EXISTS_OR_DISABLED = new ErrorCode(1_008_003_010, "执行状态定义不存在或已停用"); + ErrorCode PROJECT_EXECUTION_STATUS_ACTION_NOT_ALLOWED = new ErrorCode(1_008_003_011, "当前执行状态不支持动作【{}】"); + ErrorCode PROJECT_EXECUTION_STATUS_ACTION_REASON_REQUIRED = new ErrorCode(1_008_003_012, "动作【{}】必须填写原因"); + 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, "创建执行时必须至少选择一名执行成员"); + + // ========== 任务管理 1-008-004-000 ========== + ErrorCode PROJECT_TASK_NOT_EXISTS = new ErrorCode(1_008_004_000, "任务不存在"); + ErrorCode PROJECT_TASK_OWNER_INVALID = new ErrorCode(1_008_004_001, "任务负责人必须是当前执行的有效成员"); + ErrorCode PROJECT_TASK_PARENT_INVALID = new ErrorCode(1_008_004_002, "父任务必须属于当前项目和执行"); + ErrorCode PROJECT_TASK_NOT_ALLOW_EDIT = new ErrorCode(1_008_004_003, "当前项目或执行状态不允许维护任务"); + ErrorCode PROJECT_TASK_STATUS_MODEL_NOT_EXISTS_OR_DISABLED = new ErrorCode(1_008_004_004, "任务状态定义不存在或已停用"); + ErrorCode PROJECT_TASK_STATUS_ACTION_NOT_ALLOWED = new ErrorCode(1_008_004_005, "当前任务状态不支持动作【{}】"); + ErrorCode PROJECT_TASK_STATUS_ACTION_REASON_REQUIRED = new ErrorCode(1_008_004_006, "动作【{}】必须填写原因"); + 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_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, "拆子任务前请先删除父任务下已填的工时记录"); + + // ========== 任务协办人 1_008_005_xxx ========== + ErrorCode PROJECT_TASK_ASSIGNEE_NOT_EXISTS = new ErrorCode(1_008_005_001, "任务协办人记录不存在"); + ErrorCode PROJECT_TASK_ASSIGNEE_INVALID_MEMBER = new ErrorCode(1_008_005_002, "任务协办人必须是当前有效的执行成员"); + ErrorCode PROJECT_TASK_ASSIGNEE_OWNER_CONFLICT = new ErrorCode(1_008_005_003, "任务协办人不能与任务负责人重复"); + ErrorCode PROJECT_TASK_ASSIGNEE_ALREADY_ACTIVE = new ErrorCode(1_008_005_004, "该用户已是当前任务的活跃协办人"); + ErrorCode PROJECT_TASK_ASSIGNEE_NOT_ACTIVE = new ErrorCode(1_008_005_005, "任务协办人记录已失效,无需重复操作"); + ErrorCode PROJECT_TASK_ASSIGNEE_REASON_REQUIRED = new ErrorCode(1_008_005_006, "退出协办必须填写原因"); + + // ========== 任务工时 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_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, "仅记录填报人或任务负责人可删除该工时记录"); + } diff --git a/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ProjectDictTypeConstants.java b/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ProjectDictTypeConstants.java new file mode 100644 index 0000000..6420b9f --- /dev/null +++ b/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ProjectDictTypeConstants.java @@ -0,0 +1,18 @@ +package com.njcn.rdms.module.project.enums; + +/** + * 项目域字典类型常量。 + */ +public interface ProjectDictTypeConstants { + + /** + * 项目类型。 + */ + String PROJECT_TYPE = "rdms_project_type"; + + /** + * 执行类型。 + */ + String EXECUTION_TYPE = "rdms_project_execution_type"; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ObjectActivityConstants.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ObjectActivityConstants.java index 930e09e..99c1b61 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ObjectActivityConstants.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ObjectActivityConstants.java @@ -24,6 +24,7 @@ public final class ObjectActivityConstants { // ========== 审计业务类型 ========== public static final String PRODUCT_BIZ_TYPE = "product"; + public static final String PROJECT_BIZ_TYPE = "project"; public static final String MEMBER_BIZ_TYPE = "rdms_user_object_role"; // ========== 产品对象动作 ========== @@ -32,6 +33,18 @@ public final class ObjectActivityConstants { public static final String PRODUCT_ACTION_DELETE = "delete"; public static final String PRODUCT_ACTION_CHANGE_MANAGER = "change_manager"; + // ========== 项目对象动作 ========== + public static final String PROJECT_ACTION_CREATE = "create"; + public static final String PROJECT_ACTION_UPDATE = "update"; + public static final String PROJECT_ACTION_DELETE = "delete"; + public static final String PROJECT_ACTION_CHANGE_MANAGER = "change_manager"; + public static final String PROJECT_ACTION_AUTO_START = "auto_start"; + + // ========== 项目自动推进触发动作 ========== + 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"; + // ========== 状态动作 ========== public static final String STATUS_ACTION_PAUSE = "pause"; public static final String STATUS_ACTION_RESUME = "resume"; @@ -42,6 +55,23 @@ public final class ObjectActivityConstants { public static final String MEMBER_ACTION_ADD = "add_member"; public static final String MEMBER_ACTION_UPDATE = "update_member"; 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_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 TASK_ACTION_CREATE = "create_task_entity"; + public static final String TASK_ACTION_UPDATE = "update_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"; public static final List STATUS_ACTION_TYPES = List.of( STATUS_ACTION_PAUSE, STATUS_ACTION_RESUME, STATUS_ACTION_ARCHIVE, STATUS_ACTION_ABANDON); @@ -67,11 +97,30 @@ public final class ObjectActivityConstants { case PRODUCT_ACTION_CREATE -> "创建"; case PRODUCT_ACTION_UPDATE -> "更新"; case PRODUCT_ACTION_DELETE -> "删除"; + case PROJECT_ACTION_AUTO_START -> "自动进入进行中"; + case PROJECT_TRIGGER_CREATE_EXECUTION -> "创建执行"; + case PROJECT_TRIGGER_CREATE_TASK -> "创建任务"; + case PROJECT_TRIGGER_SCHEDULE_REQUIREMENT -> "项目需求排期"; + case EXECUTION_ACTION_CREATE -> "创建执行"; + case EXECUTION_ACTION_UPDATE -> "更新执行"; + case EXECUTION_ACTION_CHANGE_OWNER -> "变更执行负责人"; + case EXECUTION_MEMBER_ACTION_ADD -> "新增执行成员"; + case EXECUTION_MEMBER_ACTION_REMOVE -> "移出执行成员"; + case TASK_ACTION_CREATE -> "创建任务"; + case TASK_ACTION_UPDATE -> "更新任务"; + 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 "start" -> "开始"; + case "block" -> "阻塞"; + case "complete" -> "完成"; + case "cancel" -> "取消"; case STATUS_ACTION_PAUSE -> "暂停"; case STATUS_ACTION_RESUME -> "恢复"; case STATUS_ACTION_ARCHIVE -> "归档"; case STATUS_ACTION_ABANDON -> "废弃"; - case PRODUCT_ACTION_CHANGE_MANAGER -> "切换产品经理"; + case PRODUCT_ACTION_CHANGE_MANAGER -> "切换负责人"; case MEMBER_ACTION_ADD -> "新增成员"; case MEMBER_ACTION_UPDATE -> "调整成员"; case MEMBER_ACTION_REMOVE -> "移出成员"; diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ObjectRoleConstants.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ObjectRoleConstants.java new file mode 100644 index 0000000..c74d8d9 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ObjectRoleConstants.java @@ -0,0 +1,28 @@ +package com.njcn.rdms.module.project.constant; + +import com.njcn.rdms.module.system.enums.permission.PermissionScopeTypeEnum; + +/** + * RDMS 对象角色与成员关系常量。 + */ +public final class ObjectRoleConstants { + + private ObjectRoleConstants() { + } + + /** + * 对象级权限作用域,对应 sys_menu.scope_type = object。 + */ + public static final String ROLE_SCOPE_OBJECT = PermissionScopeTypeEnum.OBJECT.getScopeType(); + + /** + * 对象成员有效状态,对应 rdms_user_object_role.status = 0。 + */ + public static final Integer MEMBER_STATUS_ACTIVE = 0; + + /** + * 对象成员失效状态,对应 rdms_user_object_role.status = 1。 + */ + public static final Integer MEMBER_STATUS_INACTIVE = 1; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProductObjectConstants.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProductObjectConstants.java new file mode 100644 index 0000000..e13de37 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProductObjectConstants.java @@ -0,0 +1,51 @@ +package com.njcn.rdms.module.project.constant; + +/** + * 产品对象常量。 + */ +public final class ProductObjectConstants { + + private ProductObjectConstants() { + } + + /** + * 产品对象类型,对应 rdms_user_object_role.object_type、rdms_object_status_model.object_type。 + */ + public static final String OBJECT_TYPE = "product"; + + /** + * 产品经理对象角色编码。该角色用于初始化与识别产品负责人。 + */ + public static final String MANAGER_ROLE_CODE = "product_manager"; + + /** + * 产品暂停状态。 + */ + public static final String STATUS_PAUSED = "paused"; + + /** + * 产品自动编码前缀。 + */ + public static final String CODE_PREFIX = "CNPD"; + + /** + * 产品编辑权限码。 + */ + public static final String PERMISSION_UPDATE = "project:product:update"; + + /** + * 产品状态动作权限码。 + */ + public static final String PERMISSION_STATUS = "project:product:status"; + + /** + * 产品删除权限码。 + */ + public static final String PERMISSION_DELETE = "project:product:delete"; + + /** + * 产品删除确认口令。 + */ + public static final String DELETE_CONFIRM_TEXT = "DELETE"; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectExecutionConstants.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectExecutionConstants.java new file mode 100644 index 0000000..bf74a22 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectExecutionConstants.java @@ -0,0 +1,46 @@ +package com.njcn.rdms.module.project.constant; + +/** + * 执行对象常量。 + */ +public final class ProjectExecutionConstants { + + private ProjectExecutionConstants() { + } + + /** + * 执行对象类型,对应 rdms_object_status_model.object_type。 + */ + public static final String OBJECT_TYPE = "execution"; + + /** + * 执行业务类型。 + */ + public static final String BIZ_TYPE = "project_execution"; + + /** + * 创建执行权限码。 + */ + public static final String PERMISSION_CREATE = "project:execution:create"; + + /** + * 编辑执行权限码。 + */ + public static final String PERMISSION_UPDATE = "project:execution:update"; + + /** + * 执行负责人治理权限码。 + */ + public static final String PERMISSION_OWNER = "project:execution:owner"; + + /** + * 执行成员治理权限码。 + */ + public static final String PERMISSION_MEMBER = "project:execution:member"; + + /** + * 执行状态动作权限码。 + */ + public static final String PERMISSION_STATUS = "project:execution:status"; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectObjectConstants.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectObjectConstants.java new file mode 100644 index 0000000..096ea8e --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectObjectConstants.java @@ -0,0 +1,76 @@ +package com.njcn.rdms.module.project.constant; + +import java.util.Set; + +/** + * 项目对象常量。 + */ +public final class ProjectObjectConstants { + + private ProjectObjectConstants() { + } + + /** + * 项目对象类型,对应 rdms_user_object_role.object_type、rdms_object_status_model.object_type。 + */ + public static final String OBJECT_TYPE = "project"; + + /** + * 项目经理对象角色编码。该角色用于初始化与识别项目负责人。 + */ + public static final String MANAGER_ROLE_CODE = "project_manager"; + + /** + * 项目游客对象角色编码。创建人未成为项目成员时,用于返回只读上下文菜单。 + */ + public static final String VISITOR_ROLE_CODE = "visitor"; + + /** + * 产品主线项目类型字典值。后续字典值收敛后,只需调整这里。 + */ + public static final Set MAINLINE_PROJECT_TYPE_CODES = Set.of("mainline", "main", "product_mainline", "主线"); + + /** + * 已作废项目状态编码。当前作废项目不占用产品主线项目名额。 + */ + public static final String STATUS_CANCELLED = "cancelled"; + + /** + * 项目自动编码前缀。 + */ + public static final String CODE_PREFIX = "CNPJ"; + + /** + * 项目编辑权限码。 + */ + public static final String PERMISSION_UPDATE = "project:project:update"; + + /** + * 项目成员维护权限码。 + */ + public static final String PERMISSION_MEMBER = "project:project:member"; + + /** + * 项目状态动作权限码。 + */ + public static final String PERMISSION_STATUS = "project:project:status"; + + /** + * 项目删除权限码。 + */ + public static final String PERMISSION_DELETE = "project:project:delete"; + + /** + * 项目删除确认口令。 + */ + public static final String DELETE_CONFIRM_TEXT = "DELETE"; + + /** + * 可触发项目从待开始自动进入进行中的后端业务动作。 + */ + public static final Set AUTO_START_TRIGGERS = Set.of( + ObjectActivityConstants.PROJECT_TRIGGER_CREATE_EXECUTION, + ObjectActivityConstants.PROJECT_TRIGGER_CREATE_TASK, + ObjectActivityConstants.PROJECT_TRIGGER_SCHEDULE_REQUIREMENT); + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectTaskConstants.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectTaskConstants.java new file mode 100644 index 0000000..9f60dca --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectTaskConstants.java @@ -0,0 +1,46 @@ +package com.njcn.rdms.module.project.constant; + +/** + * 任务对象常量。 + */ +public final class ProjectTaskConstants { + + private ProjectTaskConstants() { + } + + /** + * 任务对象类型,对应 rdms_object_status_model.object_type。 + */ + public static final String OBJECT_TYPE = "task"; + + /** + * 任务业务类型。 + */ + public static final String BIZ_TYPE = "project_task"; + + /** + * 创建任务权限码。 + */ + public static final String PERMISSION_CREATE = "project:task:create"; + + /** + * 编辑任务权限码。 + */ + public static final String PERMISSION_UPDATE = "project:task:update"; + + /** + * 任务状态动作权限码。 + */ + public static final String PERMISSION_STATUS = "project:task:status"; + + /** + * 任务协办人维护权限码。 + */ + public static final String PERMISSION_ASSIGNEE = "project:task:assignee"; + + /** + * 任务工时维护权限码。 + */ + public static final String PERMISSION_WORKLOG = "project:task:worklog"; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/ProductController.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/ProductController.java index 1b200a8..a6b8e10 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/ProductController.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/ProductController.java @@ -5,6 +5,7 @@ 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.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; import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductRespVO; import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductSaveReqVO; @@ -62,12 +63,17 @@ public class ProductController { @GetMapping("/page") @Operation(summary = "获取产品分页") - @PreAuthorize("@ss.hasPermission('project:product:query')") public CommonResult> getProductPage(@Valid ProductPageReqVO pageReqVO) { PageResult pageResult = productService.getProductPage(pageReqVO); return success(BeanUtils.toBean(pageResult, ProductRespVO.class)); } + @GetMapping("/overview-summary") + @Operation(summary = "获取产品入口页概览统计") + public CommonResult getProductOverviewSummary() { + return success(productService.getProductOverviewSummary()); + } + @PostMapping("/change-status") @Operation(summary = "变更产品状态") public CommonResult changeProductStatus(@Valid @RequestBody ProductStatusActionReqVO reqVO) { diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductOverviewSummaryRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductOverviewSummaryRespVO.java new file mode 100644 index 0000000..ddc2f3c --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductOverviewSummaryRespVO.java @@ -0,0 +1,15 @@ +package com.njcn.rdms.module.project.controller.admin.product.vo.product; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.Map; + +@Schema(description = "管理后台 - 产品入口页概览统计 Response VO") +@Data +public class ProductOverviewSummaryRespVO { + + @Schema(description = "产品状态数量,按当前启用的产品状态模型返回") + private Map statusCounts; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/ProjectController.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/ProjectController.java new file mode 100644 index 0000000..d4974b2 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/ProjectController.java @@ -0,0 +1,90 @@ +package com.njcn.rdms.module.project.controller.admin.project; + +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.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; +import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectRespVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectSaveReqVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectStatusActionReqVO; +import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO; +import com.njcn.rdms.module.project.service.project.ProjectService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +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.*; + +import static com.njcn.rdms.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - 项目管理") +@RestController +@RequestMapping("/project/project") +@Validated +public class ProjectController { + + @Resource + private ProjectService projectService; + + @PostMapping("/create") + @Operation(summary = "创建项目") + @PreAuthorize("@ss.hasPermission('project:project:create')") + public CommonResult createProject(@Valid @RequestBody ProjectSaveReqVO createReqVO) { + return success(projectService.createProject(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新项目") + public CommonResult updateProject(@Valid @RequestBody ProjectSaveReqVO updateReqVO) { + projectService.updateProject(updateReqVO); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获取项目详情") + @Parameter(name = "id", description = "项目编号", required = true, example = "1024") + public CommonResult getProject(@RequestParam("id") Long id) { + return success(projectService.getProjectDetail(id)); + } + + @GetMapping("/{id}/context") + @Operation(summary = "获取项目上下文") + @Parameter(name = "id", description = "项目编号", required = true, example = "1024") + public CommonResult getProjectContext(@PathVariable("id") Long id) { + return success(projectService.getProjectContext(id)); + } + + @GetMapping("/page") + @Operation(summary = "获取项目分页") + public CommonResult> getProjectPage(@Valid ProjectPageReqVO pageReqVO) { + PageResult pageResult = projectService.getProjectPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, ProjectRespVO.class)); + } + + @GetMapping("/overview-summary") + @Operation(summary = "获取项目入口页概览统计") + public CommonResult getProjectOverviewSummary() { + return success(projectService.getProjectOverviewSummary()); + } + + @PostMapping("/change-status") + @Operation(summary = "变更项目状态") + public CommonResult changeProjectStatus(@Valid @RequestBody ProjectStatusActionReqVO reqVO) { + projectService.changeProjectStatus(reqVO); + return success(true); + } + + @PostMapping("/delete") + @Operation(summary = "删除项目") + public CommonResult deleteProject(@Valid @RequestBody ProjectDeleteReqVO reqVO) { + projectService.deleteProject(reqVO); + return success(true); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/ProjectMemberController.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/ProjectMemberController.java new file mode 100644 index 0000000..520b3e6 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/ProjectMemberController.java @@ -0,0 +1,63 @@ +package com.njcn.rdms.module.project.controller.admin.project; + +import com.njcn.rdms.framework.common.pojo.CommonResult; +import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberInactiveReqVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberRespVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberSaveReqVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberUpdateReqVO; +import com.njcn.rdms.module.project.service.project.ProjectMemberService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +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.*; + +import java.util.List; + +import static com.njcn.rdms.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - 项目成员") +@RestController +@RequestMapping("/project/project") +@Validated +public class ProjectMemberController { + + @Resource + private ProjectMemberService projectMemberService; + + @GetMapping("/{id}/members") + @Operation(summary = "获取项目成员列表") + @Parameter(name = "id", description = "项目编号", required = true, example = "1024") + public CommonResult> getProjectMemberList(@PathVariable("id") Long projectId) { + return success(projectMemberService.getProjectMemberList(projectId)); + } + + @PostMapping("/{id}/members") + @Operation(summary = "新增项目成员") + @Parameter(name = "id", description = "项目编号", required = true, example = "1024") + public CommonResult createProjectMember(@PathVariable("id") Long projectId, + @Valid @RequestBody ProjectMemberSaveReqVO reqVO) { + return success(projectMemberService.createProjectMember(projectId, reqVO)); + } + + @PutMapping("/{id}/members/{memberId}") + @Operation(summary = "调整项目成员角色") + public CommonResult updateProjectMember(@PathVariable("id") Long projectId, + @PathVariable("memberId") Long memberId, + @Valid @RequestBody ProjectMemberUpdateReqVO reqVO) { + projectMemberService.updateProjectMember(projectId, memberId, reqVO); + return success(true); + } + + @PostMapping("/{id}/members/{memberId}/inactive") + @Operation(summary = "移出项目成员") + public CommonResult inactiveProjectMember(@PathVariable("id") Long projectId, + @PathVariable("memberId") Long memberId, + @Valid @RequestBody ProjectMemberInactiveReqVO reqVO) { + projectMemberService.inactiveProjectMember(projectId, memberId, reqVO); + return success(true); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/ProjectExecutionController.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/ProjectExecutionController.java new file mode 100644 index 0000000..09331ce --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/ProjectExecutionController.java @@ -0,0 +1,90 @@ +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.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.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.ProjectExecutionStatusActionReqVO; +import com.njcn.rdms.module.project.service.project.ProjectStatusBoardService; +import com.njcn.rdms.module.project.service.project.execution.ProjectExecutionService; +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.*; + +import static com.njcn.rdms.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - 项目执行") +@RestController +@RequestMapping("/project/project/{projectId}/executions") +@Validated +public class ProjectExecutionController { + + @Resource + private ProjectExecutionService projectExecutionService; + @Resource + private ProjectStatusBoardService projectStatusBoardService; + + @PostMapping + @Operation(summary = "创建执行") + public CommonResult createExecution(@PathVariable("projectId") Long projectId, + @Valid @RequestBody ProjectExecutionSaveReqVO reqVO) { + return success(projectExecutionService.createExecution(projectId, reqVO)); + } + + @PutMapping("/{executionId}") + @Operation(summary = "编辑执行") + public CommonResult updateExecution(@PathVariable("projectId") Long projectId, + @PathVariable("executionId") Long executionId, + @Valid @RequestBody ProjectExecutionSaveReqVO reqVO) { + reqVO.setId(executionId); + projectExecutionService.updateExecution(projectId, reqVO); + return success(true); + } + + @GetMapping("/{executionId}") + @Operation(summary = "获取执行详情") + public CommonResult getExecution(@PathVariable("projectId") Long projectId, + @PathVariable("executionId") Long executionId) { + return success(projectExecutionService.getExecutionRespVO(projectId, executionId)); + } + + @GetMapping("/page") + @Operation(summary = "获取执行分页") + public CommonResult> getExecutionPage(@PathVariable("projectId") Long projectId, + @Valid ProjectExecutionPageReqVO reqVO) { + return success(projectExecutionService.getExecutionRespVOPage(projectId, reqVO)); + } + + @GetMapping("/status-board") + @Operation(summary = "获取执行状态看板") + public CommonResult getExecutionStatusBoard(@PathVariable("projectId") Long projectId, + @Valid ProjectExecutionStatusBoardReqVO reqVO) { + return success(projectStatusBoardService.getExecutionStatusBoard(projectId, reqVO)); + } + + @PostMapping("/{executionId}/change-owner") + @Operation(summary = "变更执行负责人") + public CommonResult changeOwner(@PathVariable("projectId") Long projectId, + @PathVariable("executionId") Long executionId, + @Valid @RequestBody ProjectExecutionOwnerChangeReqVO reqVO) { + projectExecutionService.changeOwner(projectId, executionId, reqVO); + return success(true); + } + + @PostMapping("/{executionId}/change-status") + @Operation(summary = "变更执行状态") + public CommonResult changeStatus(@PathVariable("projectId") Long projectId, + @PathVariable("executionId") Long executionId, + @Valid @RequestBody ProjectExecutionStatusActionReqVO reqVO) { + projectExecutionService.changeExecutionStatus(projectId, executionId, reqVO); + return success(true); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/ProjectExecutionMemberController.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/ProjectExecutionMemberController.java new file mode 100644 index 0000000..2931ba4 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/ProjectExecutionMemberController.java @@ -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.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> getExecutionMemberList(@PathVariable("projectId") Long projectId, + @PathVariable("executionId") Long executionId) { + return success(projectExecutionMemberService.getExecutionMemberList(projectId, executionId)); + } + + @PostMapping("/members") + @Operation(summary = "新增执行成员(B 模型 - 每次新增一段)") + public CommonResult 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 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> getExecutionMemberLogPage( + @PathVariable("projectId") Long projectId, + @PathVariable("executionId") Long executionId, + @Valid ExecutionMemberLogPageReqVO reqVO) { + return success(projectExecutionMemberService.getExecutionMemberLogPage(projectId, executionId, reqVO)); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionLifecycleActionRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionLifecycleActionRespVO.java new file mode 100644 index 0000000..d2cbdbe --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionLifecycleActionRespVO.java @@ -0,0 +1,21 @@ +package com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Schema(description = "管理后台 - 执行生命周期动作 Response VO") +@Data +@NoArgsConstructor +public class ProjectExecutionLifecycleActionRespVO { + + @Schema(description = "动作编码", example = "pause") + private String actionCode; + + @Schema(description = "动作名称", example = "暂停") + private String actionName; + + @Schema(description = "是否必须填写原因", example = "true") + private Boolean needReason; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionOwnerChangeReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionOwnerChangeReqVO.java new file mode 100644 index 0000000..6aaef70 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionOwnerChangeReqVO.java @@ -0,0 +1,20 @@ +package com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - 执行负责人变更 Request VO") +@Data +public class ProjectExecutionOwnerChangeReqVO { + + @Schema(description = "新负责人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3002") + @NotNull(message = "新负责人不能为空") + private Long newOwnerId; + + @Schema(description = "变更原因", example = "负责人调整") + @Size(max = 500, message = "变更原因长度不能超过500个字符") + private String reason; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionPageReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionPageReqVO.java new file mode 100644 index 0000000..6459b9a --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionPageReqVO.java @@ -0,0 +1,37 @@ +package com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution; + +import com.njcn.rdms.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static com.njcn.rdms.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - 执行分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class ProjectExecutionPageReqVO extends PageParam { + + @Schema(description = "关键词,匹配执行名称", example = "联调") + private String keyword; + + @Schema(description = "执行类型", example = "feature") + @Size(max = 32, message = "执行类型长度不能超过32个字符") + private String executionType; + + @Schema(description = "执行负责人用户编号", example = "3001") + private Long ownerId; + + @Schema(description = "执行状态编码", example = "pending") + @Size(max = 32, message = "执行状态编码长度不能超过32个字符") + private String statusCode; + + @Schema(description = "更新时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] updateTime; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionRespVO.java new file mode 100644 index 0000000..93a3339 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionRespVO.java @@ -0,0 +1,58 @@ +package com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution; + +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 +public class ProjectExecutionRespVO { + + @Schema(description = "执行编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "5001") + private Long id; + @Schema(description = "所属项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2001") + private Long projectId; + @Schema(description = "关联项目需求编号") + private Long projectRequirementId; + @Schema(description = "执行名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "后端接口联调") + private String executionName; + @Schema(description = "执行类型", example = "feature") + private String executionType; + @Schema(description = "执行负责人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3001") + private Long ownerId; + @Schema(description = "执行负责人昵称", example = "张三") + private String ownerNickname; + @Schema(description = "执行状态编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "pending") + private String statusCode; + @Schema(description = "执行状态名称", example = "待开始") + private String statusName; + @Schema(description = "是否终态", example = "false") + private Boolean terminal; + @Schema(description = "当前状态是否允许编辑", example = "true") + private Boolean allowEdit; + @Schema(description = "当前状态可执行动作") + private List availableActions; + @Schema(description = "计划开始日期") + private LocalDate plannedStartDate; + @Schema(description = "计划结束日期") + private LocalDate plannedEndDate; + @Schema(description = "实际开始日期") + private LocalDate actualStartDate; + @Schema(description = "实际结束日期") + private LocalDate actualEndDate; + @Schema(description = "执行进度缓存值") + private BigDecimal progressRate; + @Schema(description = "执行说明") + private String executionDesc; + @Schema(description = "最近一次状态动作原因") + private String lastStatusReason; + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionSaveReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionSaveReqVO.java new file mode 100644 index 0000000..8f723e6 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionSaveReqVO.java @@ -0,0 +1,50 @@ +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.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.time.LocalDate; +import java.util.List; + +@Schema(description = "管理后台 - 执行保存 Request VO") +@Data +public class ProjectExecutionSaveReqVO { + + @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 = "执行负责人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3001") + @NotNull(message = "执行负责人不能为空") + private Long ownerId; + + @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; + + @Schema(description = "创建执行时同步写入的成员用户编号列表;编辑执行主数据时不维护成员", example = "[3002,3003]") + private List memberUserIds; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionStatusActionReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionStatusActionReqVO.java new file mode 100644 index 0000000..c5de06b --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionStatusActionReqVO.java @@ -0,0 +1,21 @@ +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; + +@Schema(description = "管理后台 - 执行状态动作 Request VO") +@Data +public class ProjectExecutionStatusActionReqVO { + + @Schema(description = "动作编码,如 start、pause、resume、cancel", requiredMode = Schema.RequiredMode.REQUIRED, example = "pause") + @NotBlank(message = "动作编码不能为空") + @Size(max = 32, message = "动作编码长度不能超过32个字符") + private String actionCode; + + @Schema(description = "动作原因;是否必填由状态流转配置决定", example = "依赖环境暂不可用") + @Size(max = 500, message = "动作原因长度不能超过500个字符") + private String reason; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionStatusBoardReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionStatusBoardReqVO.java new file mode 100644 index 0000000..8749b0d --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionStatusBoardReqVO.java @@ -0,0 +1,32 @@ +package com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static com.njcn.rdms.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - 执行状态看板 Request VO") +@Data +@EqualsAndHashCode(callSuper = false) +public class ProjectExecutionStatusBoardReqVO { + + @Schema(description = "关键字,匹配执行名称", example = "联调") + private String keyword; + + @Schema(description = "执行类型", example = "feature") + @Size(max = 32, message = "执行类型长度不能超过32个字符") + private String executionType; + + @Schema(description = "执行负责人用户编号", example = "3001") + private Long ownerId; + + @Schema(description = "更新时间", example = "[2026-05-01 00:00:00, 2026-05-31 23:59:59]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] updateTime; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionStatusBoardRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionStatusBoardRespVO.java new file mode 100644 index 0000000..2490982 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionStatusBoardRespVO.java @@ -0,0 +1,35 @@ +package com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - 执行状态看板 Response VO") +@Data +public class ProjectExecutionStatusBoardRespVO { + + @Schema(description = "当前筛选条件下的执行总数", requiredMode = Schema.RequiredMode.REQUIRED, example = "18") + private Long total; + + @Schema(description = "状态项列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List items; + + @Schema(description = "项目状态项") + @Data + public static class ProjectStatusBoardItemVO { + + @Schema(description = "状态编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "pending") + private String statusCode; + @Schema(description = "状态名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "待开始") + private String statusName; + @Schema(description = "数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Long count; + @Schema(description = "排序", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + private Integer sort; + @Schema(description = "是否终态", example = "false") + private Boolean terminal; + + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/member/ExecutionMemberInactiveReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/member/ExecutionMemberInactiveReqVO.java new file mode 100644 index 0000000..63f2642 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/member/ExecutionMemberInactiveReqVO.java @@ -0,0 +1,17 @@ +package com.njcn.rdms.module.project.controller.admin.project.execution.vo.member; + +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") +@Data +public class ExecutionMemberInactiveReqVO { + + @Schema(description = "失效原因", requiredMode = Schema.RequiredMode.REQUIRED, example = "阶段性退出") + @NotBlank(message = "失效原因不能为空") + @Size(max = 500, message = "失效原因长度不能超过500个字符") + private String reason; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/member/ExecutionMemberLogPageReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/member/ExecutionMemberLogPageReqVO.java new file mode 100644 index 0000000..29c40ce --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/member/ExecutionMemberLogPageReqVO.java @@ -0,0 +1,29 @@ +package com.njcn.rdms.module.project.controller.admin.project.execution.vo.member; + +import com.njcn.rdms.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - 执行成员变更历史分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class ExecutionMemberLogPageReqVO extends PageParam { + + @Schema(description = "事件类型多选;不传表示全部", + example = "[\"join\",\"inactive\",\"owner_transfer_in\",\"owner_transfer_out\"]") + private List actionTypes; + + @Schema(description = "成员用户编号;不传表示全部") + private Long userId; + + @Schema(description = "起始时间(含),按 actionTime 比较") + private LocalDateTime startTime; + + @Schema(description = "截止时间(含),按 actionTime 比较") + private LocalDateTime endTime; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/member/ExecutionMemberLogRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/member/ExecutionMemberLogRespVO.java new file mode 100644 index 0000000..716264a --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/member/ExecutionMemberLogRespVO.java @@ -0,0 +1,39 @@ +package com.njcn.rdms.module.project.controller.admin.project.execution.vo.member; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 执行成员变更历史 Response VO") +@Data +public class ExecutionMemberLogRespVO { + + @Schema(description = "日志编号", example = "12001") + private Long id; + + @Schema(description = "所属执行编号", example = "5001") + private Long executionId; + + @Schema(description = "事件类型:join / inactive / owner_transfer_in / owner_transfer_out", example = "join") + private String actionType; + + @Schema(description = "被操作人用户编号", example = "3002") + private Long userId; + + @Schema(description = "被操作人昵称,返回时按当前用户信息回填") + private String userNicknameSnapshot; + + @Schema(description = "操作人用户编号", example = "3001") + private Long operatorUserId; + + @Schema(description = "操作人昵称,返回时按当前用户信息回填") + private String operatorNicknameSnapshot; + + @Schema(description = "事件时间") + private LocalDateTime actionTime; + + @Schema(description = "原因;inactive 必填,其余可空") + private String reason; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/member/ExecutionMemberRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/member/ExecutionMemberRespVO.java new file mode 100644 index 0000000..3149def --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/member/ExecutionMemberRespVO.java @@ -0,0 +1,27 @@ +package com.njcn.rdms.module.project.controller.admin.project.execution.vo.member; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 执行成员 Response VO") +@Data +public class ExecutionMemberRespVO { + + @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") + private Long userId; + @Schema(description = "成员用户昵称") + private String userNickname; + @Schema(description = "加入时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime joinedAt; + @Schema(description = "失效时间") + private LocalDateTime removedAt; + @Schema(description = "失效原因") + private String removedReason; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/member/ExecutionMemberSaveReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/member/ExecutionMemberSaveReqVO.java new file mode 100644 index 0000000..5324cb7 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/member/ExecutionMemberSaveReqVO.java @@ -0,0 +1,15 @@ +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; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/ProjectTaskController.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/ProjectTaskController.java new file mode 100644 index 0000000..f5c246e --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/ProjectTaskController.java @@ -0,0 +1,86 @@ +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.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; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskRespVO; +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.service.project.ProjectStatusBoardService; +import com.njcn.rdms.module.project.service.project.task.ProjectTaskService; +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.*; + +import static com.njcn.rdms.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - 项目任务") +@RestController +@RequestMapping("/project/project/{projectId}/executions/{executionId}/tasks") +@Validated +public class ProjectTaskController { + + @Resource + private ProjectTaskService projectTaskService; + @Resource + private ProjectStatusBoardService projectStatusBoardService; + + @PostMapping + @Operation(summary = "创建任务") + public CommonResult createTask(@PathVariable("projectId") Long projectId, + @PathVariable("executionId") Long executionId, + @Valid @RequestBody ProjectTaskSaveReqVO reqVO) { + return success(projectTaskService.createTask(projectId, executionId, reqVO)); + } + + @PutMapping("/{taskId}") + @Operation(summary = "编辑任务") + public CommonResult updateTask(@PathVariable("projectId") Long projectId, + @PathVariable("executionId") Long executionId, + @PathVariable("taskId") Long taskId, + @Valid @RequestBody ProjectTaskSaveReqVO reqVO) { + reqVO.setId(taskId); + projectTaskService.updateTask(projectId, executionId, reqVO); + return success(true); + } + + @GetMapping("/{taskId}") + @Operation(summary = "获取任务详情") + public CommonResult getTask(@PathVariable("projectId") Long projectId, + @PathVariable("executionId") Long executionId, + @PathVariable("taskId") Long taskId) { + return success(projectTaskService.getTaskRespVO(projectId, executionId, taskId)); + } + + @GetMapping("/page") + @Operation(summary = "获取任务分页") + public CommonResult> getTaskPage(@PathVariable("projectId") Long projectId, + @PathVariable("executionId") Long executionId, + @Valid ProjectTaskPageReqVO reqVO) { + return success(projectTaskService.getTaskRespVOPage(projectId, executionId, reqVO)); + } + + @GetMapping("/status-board") + @Operation(summary = "获取任务状态看板") + public CommonResult getTaskStatusBoard(@PathVariable("projectId") Long projectId, + @PathVariable("executionId") Long executionId, + @Valid ProjectTaskStatusBoardReqVO reqVO) { + return success(projectStatusBoardService.getTaskStatusBoard(projectId, executionId, reqVO)); + } + + @PostMapping("/{taskId}/change-status") + @Operation(summary = "变更任务状态") + public CommonResult changeStatus(@PathVariable("projectId") Long projectId, + @PathVariable("executionId") Long executionId, + @PathVariable("taskId") Long taskId, + @Valid @RequestBody ProjectTaskStatusActionReqVO reqVO) { + projectTaskService.changeTaskStatus(projectId, executionId, taskId, reqVO); + return success(true); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/TaskAssigneeController.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/TaskAssigneeController.java new file mode 100644 index 0000000..584e71c --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/TaskAssigneeController.java @@ -0,0 +1,76 @@ +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.assignee.TaskAssigneeInactiveReqVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.assignee.TaskAssigneeLogPageReqVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.assignee.TaskAssigneeLogRespVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.assignee.TaskAssigneeRespVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.assignee.TaskAssigneeSaveReqVO; +import com.njcn.rdms.module.project.service.project.task.assignee.TaskAssigneeService; +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; +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}/tasks/{taskId}") +@Validated +public class TaskAssigneeController { + + @Resource + private TaskAssigneeService taskAssigneeService; + + @GetMapping("/assignees") + @Operation(summary = "获取任务协办人列表(仅当前活跃)") + public CommonResult> getAssigneeList(@PathVariable("projectId") Long projectId, + @PathVariable("executionId") Long executionId, + @PathVariable("taskId") Long taskId) { + return success(taskAssigneeService.getAssigneeList(projectId, executionId, taskId)); + } + + @PostMapping("/assignees") + @Operation(summary = "加入任务协办人") + @PreAuthorize("@ss.hasPermission('project:task:assignee')") + public CommonResult createAssignee(@PathVariable("projectId") Long projectId, + @PathVariable("executionId") Long executionId, + @PathVariable("taskId") Long taskId, + @Valid @RequestBody TaskAssigneeSaveReqVO reqVO) { + return success(taskAssigneeService.createAssignee(projectId, executionId, taskId, reqVO)); + } + + @PostMapping("/assignees/{assigneeId}/inactive") + @Operation(summary = "退出任务协办人") + @PreAuthorize("@ss.hasPermission('project:task:assignee')") + public CommonResult inactiveAssignee(@PathVariable("projectId") Long projectId, + @PathVariable("executionId") Long executionId, + @PathVariable("taskId") Long taskId, + @PathVariable("assigneeId") Long assigneeId, + @Valid @RequestBody TaskAssigneeInactiveReqVO reqVO) { + taskAssigneeService.inactiveAssignee(projectId, executionId, taskId, assigneeId, reqVO); + return success(true); + } + + @GetMapping("/assignee-logs") + @Operation(summary = "获取任务协办人变更历史(分页)") + public CommonResult> getAssigneeLogPage(@PathVariable("projectId") Long projectId, + @PathVariable("executionId") Long executionId, + @PathVariable("taskId") Long taskId, + @Valid TaskAssigneeLogPageReqVO reqVO) { + return success(taskAssigneeService.getAssigneeLogPage(projectId, executionId, taskId, reqVO)); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/TaskWorklogController.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/TaskWorklogController.java new file mode 100644 index 0000000..83f06e5 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/TaskWorklogController.java @@ -0,0 +1,77 @@ +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.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.service.project.task.worklog.TaskWorklogService; +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; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static com.njcn.rdms.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - 任务工时") +@RestController +@RequestMapping("/project/project/{projectId}/executions/{executionId}/tasks/{taskId}") +@Validated +public class TaskWorklogController { + + @Resource + private TaskWorklogService taskWorklogService; + + @GetMapping("/worklogs") + @Operation(summary = "获取任务工时分页") + public CommonResult> getWorklogPage(@PathVariable("projectId") Long projectId, + @PathVariable("executionId") Long executionId, + @PathVariable("taskId") Long taskId, + @Valid TaskWorklogPageReqVO reqVO) { + return success(taskWorklogService.getWorklogPage(projectId, executionId, taskId, reqVO)); + } + + @PostMapping("/worklogs") + @Operation(summary = "新增任务工时") + @PreAuthorize("@ss.hasPermission('project:task:worklog')") + public CommonResult createWorklog(@PathVariable("projectId") Long projectId, + @PathVariable("executionId") Long executionId, + @PathVariable("taskId") Long taskId, + @Valid @RequestBody TaskWorklogSaveReqVO reqVO) { + return success(taskWorklogService.createWorklog(projectId, executionId, taskId, reqVO)); + } + + @PutMapping("/worklogs/{worklogId}") + @Operation(summary = "修改任务工时(仅自己)") + @PreAuthorize("@ss.hasPermission('project:task:worklog')") + public CommonResult updateWorklog(@PathVariable("projectId") Long projectId, + @PathVariable("executionId") Long executionId, + @PathVariable("taskId") Long taskId, + @PathVariable("worklogId") Long worklogId, + @Valid @RequestBody TaskWorklogSaveReqVO reqVO) { + taskWorklogService.updateWorklog(projectId, executionId, taskId, worklogId, reqVO); + return success(true); + } + + @DeleteMapping("/worklogs/{worklogId}") + @Operation(summary = "删除任务工时(自己或任务负责人)") + @PreAuthorize("@ss.hasPermission('project:task:worklog')") + public CommonResult deleteWorklog(@PathVariable("projectId") Long projectId, + @PathVariable("executionId") Long executionId, + @PathVariable("taskId") Long taskId, + @PathVariable("worklogId") Long worklogId) { + taskWorklogService.deleteWorklog(projectId, executionId, taskId, worklogId); + return success(true); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskLifecycleActionRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskLifecycleActionRespVO.java new file mode 100644 index 0000000..dad332a --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskLifecycleActionRespVO.java @@ -0,0 +1,21 @@ +package com.njcn.rdms.module.project.controller.admin.project.task.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Schema(description = "管理后台 - 任务生命周期动作 Response VO") +@Data +@NoArgsConstructor +public class ProjectTaskLifecycleActionRespVO { + + @Schema(description = "动作编码", example = "complete") + private String actionCode; + + @Schema(description = "动作名称", example = "完成") + private String actionName; + + @Schema(description = "是否必须填写原因", example = "true") + private Boolean needReason; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskPageReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskPageReqVO.java new file mode 100644 index 0000000..c84b2c1 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskPageReqVO.java @@ -0,0 +1,36 @@ +package com.njcn.rdms.module.project.controller.admin.project.task.vo; + +import com.njcn.rdms.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static com.njcn.rdms.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - 任务分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class ProjectTaskPageReqVO extends PageParam { + + @Schema(description = "关键词,匹配任务标题", example = "联调") + private String keyword; + + @Schema(description = "父任务编号") + private Long parentTaskId; + + @Schema(description = "任务负责人用户编号", example = "3002") + private Long ownerId; + + @Schema(description = "任务状态编码", example = "pending") + @Size(max = 32, message = "任务状态编码长度不能超过32个字符") + private String statusCode; + + @Schema(description = "更新时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] updateTime; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskRespVO.java new file mode 100644 index 0000000..661b972 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskRespVO.java @@ -0,0 +1,74 @@ +package com.njcn.rdms.module.project.controller.admin.project.task.vo; + +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 +public class ProjectTaskRespVO { + + @Schema(description = "任务编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "9001") + private Long id; + @Schema(description = "所属项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2001") + private Long projectId; + @Schema(description = "所属执行编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "5001") + private Long executionId; + @Schema(description = "父任务编号") + private Long parentTaskId; + @Schema(description = "任务标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "接口联调任务") + private String taskTitle; + @Schema(description = "任务负责人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3002") + private Long ownerId; + @Schema(description = "任务负责人昵称", example = "李四") + private String ownerNickname; + @Schema(description = "任务状态编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "pending") + private String statusCode; + @Schema(description = "任务状态名称", example = "待开始") + private String statusName; + @Schema(description = "是否终态", example = "false") + private Boolean terminal; + @Schema(description = "当前状态是否允许编辑", example = "true") + private Boolean allowEdit; + @Schema(description = "当前状态可执行动作") + private List availableActions; + @Schema(description = "任务进度") + private BigDecimal progressRate; + @Schema(description = "计划开始日期") + private LocalDate plannedStartDate; + @Schema(description = "计划结束日期") + private LocalDate plannedEndDate; + @Schema(description = "实际开始日期") + private LocalDate actualStartDate; + @Schema(description = "实际结束日期") + private LocalDate actualEndDate; + @Schema(description = "任务说明") + private String taskDesc; + @Schema(description = "最近一次状态动作原因") + private String lastStatusReason; + @Schema(description = "当前活跃协办人列表;详细变更历史见 assignee-logs 接口") + private List assignees; + @Schema(description = "已填报工时合计(分钟);逻辑删除的工时记录不计入。无记录默认为 0", + example = "300") + private Long totalSpentMinutes; + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + + @Schema(description = "任务协办人轻量视图") + @Data + public static class TaskAssigneeView { + @Schema(description = "协办关系编号", example = "7001") + private Long id; + @Schema(description = "协办人用户编号", example = "3002") + private Long userId; + @Schema(description = "协办人昵称", example = "张三") + private String nickname; + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskSaveReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskSaveReqVO.java new file mode 100644 index 0000000..bd3df2a --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskSaveReqVO.java @@ -0,0 +1,53 @@ +package com.njcn.rdms.module.project.controller.admin.project.task.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +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; + +@Schema(description = "管理后台 - 任务保存 Request VO") +@Data +public class ProjectTaskSaveReqVO { + + @Schema(description = "任务编号", example = "9001") + private Long id; + + @Schema(description = "父任务编号") + private Long parentTaskId; + + @Schema(description = "任务标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "接口联调任务") + @NotBlank(message = "任务标题不能为空") + @Size(max = 300, message = "任务标题长度不能超过300个字符") + private String taskTitle; + + @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; + + @Schema(description = "计划结束日期") + private LocalDate plannedEndDate; + + @Schema(description = "任务说明(接受 HTML 富文本,图片走 URL 引用;后端经全局 XSS Safelist 自动净化)", + example = "完成接口联调") + @Size(max = 200000, message = "任务说明长度不能超过200000个字符") + private String taskDesc; + + @Schema(description = "初始协办人用户编号列表;仅在创建任务时生效,编辑任务时静默忽略。" + + "协办人通过独立接口管理,详见 /tasks/{id}/assignees") + private List assigneeUserIds; + +} + diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskStatusActionReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskStatusActionReqVO.java new file mode 100644 index 0000000..c65baed --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskStatusActionReqVO.java @@ -0,0 +1,21 @@ +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; + +@Schema(description = "管理后台 - 任务状态动作 Request VO") +@Data +public class ProjectTaskStatusActionReqVO { + + @Schema(description = "动作编码,如 start、block、resume、complete、cancel", requiredMode = Schema.RequiredMode.REQUIRED, example = "complete") + @NotBlank(message = "动作编码不能为空") + @Size(max = 32, message = "动作编码长度不能超过32个字符") + private String actionCode; + + @Schema(description = "动作原因;是否必填由状态流转配置决定", example = "任务取消") + @Size(max = 500, message = "动作原因长度不能超过500个字符") + private String reason; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskStatusBoardReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskStatusBoardReqVO.java new file mode 100644 index 0000000..c6ee197 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskStatusBoardReqVO.java @@ -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.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static com.njcn.rdms.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - 任务状态看板 Request VO") +@Data +@EqualsAndHashCode(callSuper = false) +public class ProjectTaskStatusBoardReqVO { + + @Schema(description = "关键字,匹配任务标题", example = "联调") + private String keyword; + + @Schema(description = "父任务编号", example = "9001") + private Long parentTaskId; + + @Schema(description = "任务负责人用户编号", example = "3002") + private Long ownerId; + + @Schema(description = "更新时间", example = "[2026-05-01 00:00:00, 2026-05-31 23:59:59]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] updateTime; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskStatusBoardRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskStatusBoardRespVO.java new file mode 100644 index 0000000..73016c3 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskStatusBoardRespVO.java @@ -0,0 +1,35 @@ +package com.njcn.rdms.module.project.controller.admin.project.task.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - 任务状态看板 Response VO") +@Data +public class ProjectTaskStatusBoardRespVO { + + @Schema(description = "当前筛选条件下的任务总数", requiredMode = Schema.RequiredMode.REQUIRED, example = "24") + private Long total; + + @Schema(description = "状态项列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List items; + + @Schema(description = "任务状态项") + @Data + public static class ProjectStatusBoardItemVO { + + @Schema(description = "状态编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "pending") + private String statusCode; + @Schema(description = "状态名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "待开始") + private String statusName; + @Schema(description = "数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "5") + private Long count; + @Schema(description = "排序", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + private Integer sort; + @Schema(description = "是否终态", example = "false") + private Boolean terminal; + + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/assignee/TaskAssigneeInactiveReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/assignee/TaskAssigneeInactiveReqVO.java new file mode 100644 index 0000000..822f2c2 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/assignee/TaskAssigneeInactiveReqVO.java @@ -0,0 +1,17 @@ +package com.njcn.rdms.module.project.controller.admin.project.task.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") +@Data +public class TaskAssigneeInactiveReqVO { + + @Schema(description = "退出协办的原因", requiredMode = Schema.RequiredMode.REQUIRED, example = "调整到其他任务") + @NotBlank(message = "退出协办必须填写原因") + @Size(max = 500, message = "原因长度不能超过500个字符") + private String reason; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/assignee/TaskAssigneeLogPageReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/assignee/TaskAssigneeLogPageReqVO.java new file mode 100644 index 0000000..6cd9564 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/assignee/TaskAssigneeLogPageReqVO.java @@ -0,0 +1,28 @@ +package com.njcn.rdms.module.project.controller.admin.project.task.vo.assignee; + +import com.njcn.rdms.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - 任务协办人变更历史分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class TaskAssigneeLogPageReqVO extends PageParam { + + @Schema(description = "事件类型多选;不传表示全部") + private List actionTypes; + + @Schema(description = "成员用户编号;不传表示全部") + private Long userId; + + @Schema(description = "起始时间(含),按 actionTime 比较") + private LocalDateTime startTime; + + @Schema(description = "截止时间(含),按 actionTime 比较") + private LocalDateTime endTime; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/assignee/TaskAssigneeLogRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/assignee/TaskAssigneeLogRespVO.java new file mode 100644 index 0000000..191db8d --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/assignee/TaskAssigneeLogRespVO.java @@ -0,0 +1,39 @@ +package com.njcn.rdms.module.project.controller.admin.project.task.vo.assignee; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 任务协办人变更历史 Response VO") +@Data +public class TaskAssigneeLogRespVO { + + @Schema(description = "日志编号", example = "8001") + private Long id; + + @Schema(description = "任务编号", example = "9001") + private Long taskId; + + @Schema(description = "事件类型:join / inactive", example = "join") + private String actionType; + + @Schema(description = "被操作人用户编号", example = "3002") + private Long userId; + + @Schema(description = "被操作人昵称快照") + private String userNicknameSnapshot; + + @Schema(description = "操作人用户编号", example = "3001") + private Long operatorUserId; + + @Schema(description = "操作人昵称快照") + private String operatorNicknameSnapshot; + + @Schema(description = "事件时间") + private LocalDateTime actionTime; + + @Schema(description = "原因;inactive 必填,join 可空") + private String reason; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/assignee/TaskAssigneeRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/assignee/TaskAssigneeRespVO.java new file mode 100644 index 0000000..222166b --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/assignee/TaskAssigneeRespVO.java @@ -0,0 +1,27 @@ +package com.njcn.rdms.module.project.controller.admin.project.task.vo.assignee; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 任务协办人 Response VO") +@Data +public class TaskAssigneeRespVO { + + @Schema(description = "协办关系编号", example = "7001") + private Long id; + + @Schema(description = "任务编号", example = "9001") + private Long taskId; + + @Schema(description = "协办人用户编号", example = "3002") + private Long userId; + + @Schema(description = "协办人昵称") + private String userNickname; + + @Schema(description = "加入时间") + private LocalDateTime joinedAt; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/assignee/TaskAssigneeSaveReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/assignee/TaskAssigneeSaveReqVO.java new file mode 100644 index 0000000..36e04a0 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/assignee/TaskAssigneeSaveReqVO.java @@ -0,0 +1,15 @@ +package com.njcn.rdms.module.project.controller.admin.project.task.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 TaskAssigneeSaveReqVO { + + @Schema(description = "协办人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3002") + @NotNull(message = "协办人用户编号不能为空") + private Long userId; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/worklog/TaskWorklogPageReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/worklog/TaskWorklogPageReqVO.java new file mode 100644 index 0000000..550165e --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/worklog/TaskWorklogPageReqVO.java @@ -0,0 +1,24 @@ +package com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog; + +import com.njcn.rdms.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDate; + +@Schema(description = "管理后台 - 任务工时分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class TaskWorklogPageReqVO extends PageParam { + + @Schema(description = "填报人用户编号;不传表示全部") + private Long userId; + + @Schema(description = "起始日期(含),按 workDate 比较") + private LocalDate startDate; + + @Schema(description = "截止日期(含),按 workDate 比较") + private LocalDate endDate; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/worklog/TaskWorklogRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/worklog/TaskWorklogRespVO.java new file mode 100644 index 0000000..5898306 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/worklog/TaskWorklogRespVO.java @@ -0,0 +1,40 @@ +package com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 任务工时 Response VO") +@Data +public class TaskWorklogRespVO { + + @Schema(description = "工时记录编号", example = "11001") + private Long id; + + @Schema(description = "任务编号", example = "9001") + private Long taskId; + + @Schema(description = "填报人用户编号", example = "3002") + private Long userId; + + @Schema(description = "填报人昵称", example = "张三") + private String userNickname; + + @Schema(description = "工作日期", example = "2026-05-08") + private LocalDate workDate; + + @Schema(description = "时长(分钟)", example = "150") + private Integer durationMinutes; + + @Schema(description = "工作内容描述") + private String workContent; + + @Schema(description = "创建时间") + private LocalDateTime createTime; + + @Schema(description = "更新时间") + private LocalDateTime updateTime; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/worklog/TaskWorklogSaveReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/worklog/TaskWorklogSaveReqVO.java new file mode 100644 index 0000000..bb5b272 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/worklog/TaskWorklogSaveReqVO.java @@ -0,0 +1,33 @@ +package com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.time.LocalDate; + +/** + * 任务工时新增/更新请求。同表共用:updateWorklog 不接受 taskId / userId 切换,前端无需也无法传。 + * 时长颗粒(30 分钟整数倍)由 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 = "时长(分钟),> 0 且必须为 30 的整数倍", + requiredMode = Schema.RequiredMode.REQUIRED, example = "150") + @NotNull(message = "工时时长不能为空") + @Min(value = 30, message = "工时时长必须大于 0 且为 30 分钟的整数倍") + private Integer durationMinutes; + + @Schema(description = "工作内容描述", example = "完成接口联调与冒烟测试") + @Size(max = 2000, message = "工作内容长度不能超过 2000 个字符") + private String workContent; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/member/ProjectMemberInactiveReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/member/ProjectMemberInactiveReqVO.java new file mode 100644 index 0000000..a2c82df --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/member/ProjectMemberInactiveReqVO.java @@ -0,0 +1,17 @@ +package com.njcn.rdms.module.project.controller.admin.project.vo.member; + +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") +@Data +public class ProjectMemberInactiveReqVO { + + @Schema(description = "移出原因", requiredMode = Schema.RequiredMode.REQUIRED, example = "已退出当前项目协作") + @NotBlank(message = "移出原因不能为空") + @Size(max = 500, message = "移出原因长度不能超过500个字符") + private String reason; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/member/ProjectMemberRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/member/ProjectMemberRespVO.java new file mode 100644 index 0000000..0d7dea7 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/member/ProjectMemberRespVO.java @@ -0,0 +1,35 @@ +package com.njcn.rdms.module.project.controller.admin.project.vo.member; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 项目成员 Response VO") +@Data +public class ProjectMemberRespVO { + + @Schema(description = "成员关系编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + private Long userId; + @Schema(description = "用户昵称", example = "小王") + private String userNickname; + @Schema(description = "角色编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3100000002001") + private Long roleId; + @Schema(description = "角色名称", example = "项目经理") + private String roleName; + @Schema(description = "角色编码", example = "project_manager") + private String roleCode; + @Schema(description = "是否当前项目负责人", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean managerFlag; + @Schema(description = "状态(0有效 1失效)", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; + @Schema(description = "加入时间") + private LocalDateTime joinedTime; + @Schema(description = "退出时间") + private LocalDateTime leftTime; + @Schema(description = "备注", example = "当前负责需求收敛") + private String remark; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/member/ProjectMemberSaveReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/member/ProjectMemberSaveReqVO.java new file mode 100644 index 0000000..63450d4 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/member/ProjectMemberSaveReqVO.java @@ -0,0 +1,30 @@ +package com.njcn.rdms.module.project.controller.admin.project.vo.member; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - 项目成员新增 Request VO") +@Data +public class ProjectMemberSaveReqVO { + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "用户编号不能为空") + private Long userId; + + @Schema(description = "角色编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3100000002001") + @NotNull(message = "角色编号不能为空") + private Long roleId; + + @Schema(description = "备注", example = "加入项目交付团队") + @Size(max = 500, message = "备注长度不能超过500个字符") + private String remark; + + @Schema(description = "原项目经理用户编号,仅切换项目经理时传递", example = "2048") + private Long previousManagerUserId; + + @Schema(description = "原项目经理交接后的角色编号,仅切换项目经理时传递", example = "3100000002002") + private Long previousManagerRoleId; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/member/ProjectMemberUpdateReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/member/ProjectMemberUpdateReqVO.java new file mode 100644 index 0000000..7436fb8 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/member/ProjectMemberUpdateReqVO.java @@ -0,0 +1,30 @@ +package com.njcn.rdms.module.project.controller.admin.project.vo.member; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - 项目成员更新 Request VO") +@Data +public class ProjectMemberUpdateReqVO { + + @Schema(description = "角色编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3100000002002") + @NotNull(message = "角色编号不能为空") + private Long roleId; + + @Schema(description = "变更原因", example = "职责调整") + @Size(max = 500, message = "变更原因长度不能超过500个字符") + private String reason; + + @Schema(description = "备注", example = "调整为项目观察者") + @Size(max = 500, message = "备注长度不能超过500个字符") + private String remark; + + @Schema(description = "原项目经理用户编号,仅切换项目经理时传递", example = "2048") + private Long previousManagerUserId; + + @Schema(description = "原项目经理交接后的角色编号,仅切换项目经理时传递", example = "3100000002002") + private Long previousManagerRoleId; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectContextNavRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectContextNavRespVO.java new file mode 100644 index 0000000..43f5ddb --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectContextNavRespVO.java @@ -0,0 +1,21 @@ +package com.njcn.rdms.module.project.controller.admin.project.vo.project; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - 项目上下文导航 Response VO") +@Data +public class ProjectContextNavRespVO { + + @Schema(description = "菜单编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3201") + private Long id; + @Schema(description = "菜单名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "概览") + private String name; + @Schema(description = "菜单路径", example = "/project/project/overview") + private String path; + @Schema(description = "菜单图标", example = "mdi:view-dashboard-outline") + private String icon; + @Schema(description = "显示顺序", example = "10") + private Integer sort; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectContextProjectRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectContextProjectRespVO.java new file mode 100644 index 0000000..4dd101c --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectContextProjectRespVO.java @@ -0,0 +1,37 @@ +package com.njcn.rdms.module.project.controller.admin.project.vo.project; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - 项目上下文中的当前项目摘要 Response VO") +@Data +public class ProjectContextProjectRespVO { + + @Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + @Schema(description = "项目编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "CNPJ2026001") + private String projectCode; + @Schema(description = "项目名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "客户交付项目") + private String projectName; + @Schema(description = "项目类型字典值", requiredMode = Schema.RequiredMode.REQUIRED, example = "delivery") + private String projectType; + @Schema(description = "项目方向字典值", requiredMode = Schema.RequiredMode.REQUIRED, example = "direction_value") + private String directionCode; + @Schema(description = "所属产品编号") + private Long productId; + @Schema(description = "项目负责人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long managerUserId; + @Schema(description = "项目状态编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "pending") + private String statusCode; + @Schema(description = "项目状态名称", example = "待开始") + private String statusName; + @Schema(description = "是否终态", example = "false") + private Boolean terminal; + @Schema(description = "当前状态是否允许编辑项目主数据", example = "true") + private Boolean allowEdit; + @Schema(description = "当前状态可执行动作") + private List availableActions; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectContextRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectContextRespVO.java new file mode 100644 index 0000000..4747da9 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectContextRespVO.java @@ -0,0 +1,23 @@ +package com.njcn.rdms.module.project.controller.admin.project.vo.project; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Schema(description = "管理后台 - 项目上下文 Response VO") +@Data +@NoArgsConstructor +public class ProjectContextRespVO { + + @Schema(description = "当前项目摘要") + private ProjectContextProjectRespVO currentProject; + @Schema(description = "当前用户在该项目下的角色信息") + private ProjectContextRoleRespVO currentRole; + @Schema(description = "当前项目下可见导航集合") + private List navs; + @Schema(description = "当前项目下按钮权限码集合") + private List buttons; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectContextRoleRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectContextRoleRespVO.java new file mode 100644 index 0000000..b1d7884 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectContextRoleRespVO.java @@ -0,0 +1,19 @@ +package com.njcn.rdms.module.project.controller.admin.project.vo.project; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - 项目上下文中的当前角色 Response VO") +@Data +public class ProjectContextRoleRespVO { + + @Schema(description = "对象角色编号", example = "3201") + private Long roleId; + @Schema(description = "对象角色编码", example = "project_manager") + private String roleCode; + @Schema(description = "对象角色名称", example = "项目经理") + private String roleName; + @Schema(description = "是否游客上下文", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + private Boolean guestFlag; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectDeleteReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectDeleteReqVO.java new file mode 100644 index 0000000..8419975 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectDeleteReqVO.java @@ -0,0 +1,32 @@ +package com.njcn.rdms.module.project.controller.admin.project.vo.project; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - 项目删除 Request VO") +@Data +public class ProjectDeleteReqVO { + + @Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "项目编号不能为空") + private Long id; + + @Schema(description = "确认输入的项目名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "客户交付项目") + @NotBlank(message = "确认项目名称不能为空") + @Size(max = 200, message = "确认项目名称长度不能超过200个字符") + private String projectName; + + @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; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectLifecycleActionRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectLifecycleActionRespVO.java new file mode 100644 index 0000000..d0c1349 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectLifecycleActionRespVO.java @@ -0,0 +1,21 @@ +package com.njcn.rdms.module.project.controller.admin.project.vo.project; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Schema(description = "管理后台 - 项目生命周期动作 Response VO") +@Data +@NoArgsConstructor +public class ProjectLifecycleActionRespVO { + + @Schema(description = "动作编码", example = "archive") + private String actionCode; + + @Schema(description = "动作名称", example = "归档") + private String actionName; + + @Schema(description = "是否必须填写原因", example = "true") + private Boolean needReason; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectOverviewSummaryRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectOverviewSummaryRespVO.java new file mode 100644 index 0000000..43e4208 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectOverviewSummaryRespVO.java @@ -0,0 +1,15 @@ +package com.njcn.rdms.module.project.controller.admin.project.vo.project; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.Map; + +@Schema(description = "管理后台 - 项目入口页概览统计 Response VO") +@Data +public class ProjectOverviewSummaryRespVO { + + @Schema(description = "项目状态数量,按当前启用的项目状态模型返回") + private Map statusCounts; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectPageReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectPageReqVO.java new file mode 100644 index 0000000..5baaf11 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectPageReqVO.java @@ -0,0 +1,46 @@ +package com.njcn.rdms.module.project.controller.admin.project.vo.project; + +import com.njcn.rdms.framework.common.pojo.PageParam; +import com.njcn.rdms.framework.dict.validation.InDict; +import com.njcn.rdms.module.system.enums.DictTypeConstants; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static com.njcn.rdms.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - 项目分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class ProjectPageReqVO extends PageParam { + + @Schema(description = "关键词,匹配项目编码或项目名称", example = "CNPJ2026001") + private String keyword; + + @Schema(description = "项目类型字典值", example = "delivery") + @Size(max = 32, message = "项目类型长度不能超过32个字符") + private String projectType; + + @Schema(description = "项目方向字典值", example = "direction_value") + @InDict(type = DictTypeConstants.OBJECT_DIRECTION) + private String directionCode; + + @Schema(description = "所属产品编号", example = "1001") + private Long productId; + + @Schema(description = "项目负责人用户编号", example = "1024") + private Long managerUserId; + + @Schema(description = "项目状态编码", example = "active") + @Size(max = 32, message = "项目状态编码长度不能超过32个字符") + private String statusCode; + + @Schema(description = "更新时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] updateTime; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectRespVO.java new file mode 100644 index 0000000..2803b17 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectRespVO.java @@ -0,0 +1,57 @@ +package com.njcn.rdms.module.project.controller.admin.project.vo.project; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 项目 Response VO") +@Data +public class ProjectRespVO { + + @Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + @Schema(description = "项目编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "CNPJ2026001") + private String projectCode; + @Schema(description = "项目名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "客户交付项目") + private String projectName; + @Schema(description = "项目类型字典值", requiredMode = Schema.RequiredMode.REQUIRED, example = "delivery") + private String projectType; + @Schema(description = "项目方向字典值", requiredMode = Schema.RequiredMode.REQUIRED, example = "direction_value") + private String directionCode; + @Schema(description = "所属项目集编号") + private Long projectSetId; + @Schema(description = "所属产品编号") + private Long productId; + @Schema(description = "所属产品名称", example = "统一交付平台") + private String productName; + @Schema(description = "所属产品版本编号") + private Long productVersionId; + @Schema(description = "项目负责人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long managerUserId; + @Schema(description = "项目负责人昵称", example = "张三") + private String managerUserNickname; + @Schema(description = "项目状态编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "pending") + private String statusCode; + @Schema(description = "计划开始日期") + private LocalDate plannedStartDate; + @Schema(description = "计划结束日期") + private LocalDate plannedEndDate; + @Schema(description = "实际开始日期") + private LocalDate actualStartDate; + @Schema(description = "实际结束日期") + private LocalDate actualEndDate; + @Schema(description = "项目进度缓存值") + private BigDecimal progressRate; + @Schema(description = "项目说明") + private String projectDesc; + @Schema(description = "最近一次状态动作原因") + private String lastStatusReason; + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectSaveReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectSaveReqVO.java new file mode 100644 index 0000000..6babd02 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectSaveReqVO.java @@ -0,0 +1,64 @@ +package com.njcn.rdms.module.project.controller.admin.project.vo.project; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.time.LocalDate; + +@Schema(description = "管理后台 - 项目保存 Request VO") +@Data +public class ProjectSaveReqVO { + + @Schema(description = "项目编号", example = "1024") + private Long id; + + @Schema(description = "项目编码,为空时由系统自动生成", example = "CNPJ2026001") + @Size(max = 64, message = "项目编码长度不能超过64个字符") + private String projectCode; + + @Schema(description = "项目名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "客户交付项目") + @NotBlank(message = "项目名称不能为空") + @Size(max = 200, message = "项目名称长度不能超过200个字符") + private String projectName; + + @Schema(description = "项目类型字典值", requiredMode = Schema.RequiredMode.REQUIRED, example = "delivery") + @NotBlank(message = "项目类型不能为空") + @Size(max = 32, message = "项目类型长度不能超过32个字符") + private String projectType; + + @Schema(description = "项目方向字典值;未选产品时必填,选产品时以后端产品方向为准", example = "direction_value") + private String directionCode; + + @Schema(description = "所属项目集编号,第一期预留", example = "1001") + private Long projectSetId; + + @Schema(description = "所属产品编号,可为空", example = "1001") + private Long productId; + + @Schema(description = "所属产品版本编号,第一期预留", example = "1001") + private Long productVersionId; + + @Schema(description = "项目负责人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "项目负责人不能为空") + private Long managerUserId; + + @Schema(description = "计划开始日期") + private LocalDate plannedStartDate; + + @Schema(description = "计划结束日期") + private LocalDate plannedEndDate; + + @Schema(description = "实际开始日期") + private LocalDate actualStartDate; + + @Schema(description = "实际结束日期") + private LocalDate actualEndDate; + + @Schema(description = "项目说明", example = "客户定制交付") + @Size(max = 4000, message = "项目说明长度不能超过4000个字符") + private String projectDesc; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectStatusActionReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectStatusActionReqVO.java new file mode 100644 index 0000000..24ddcab --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectStatusActionReqVO.java @@ -0,0 +1,26 @@ +package com.njcn.rdms.module.project.controller.admin.project.vo.project; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - 项目状态动作 Request VO") +@Data +public class ProjectStatusActionReqVO { + + @Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "项目编号不能为空") + private Long id; + + @Schema(description = "动作编码,如 pause、resume、complete、cancel、reopen、archive", requiredMode = Schema.RequiredMode.REQUIRED, example = "pause") + @NotBlank(message = "动作编码不能为空") + @Size(max = 32, message = "动作编码长度不能超过32个字符") + private String actionCode; + + @Schema(description = "动作原因;是否必填由状态流转配置决定", example = "当前阶段受环境限制暂停推进") + @Size(max = 500, message = "动作原因长度不能超过500个字符") + private String reason; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/ProjectDO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/ProjectDO.java new file mode 100644 index 0000000..698a5d9 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/ProjectDO.java @@ -0,0 +1,90 @@ +package com.njcn.rdms.module.project.dal.dataobject.project; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * 项目主表 + */ +@TableName("rdms_project") +@Data +@EqualsAndHashCode(callSuper = true) +public class ProjectDO extends BaseDO { + + /** + * 项目编号 + */ + @TableId + private Long id; + /** + * 项目编码 + */ + private String projectCode; + /** + * 项目名称 + */ + private String projectName; + /** + * 项目类型字典值 + */ + private String projectType; + /** + * 项目方向字典值 + */ + private String directionCode; + /** + * 所属项目集编号,第一期预留 + */ + private Long projectSetId; + /** + * 所属产品编号,可为空 + */ + private Long productId; + /** + * 所属产品版本编号,第一期预留 + */ + private Long productVersionId; + /** + * 项目负责人用户编号 + */ + private Long managerUserId; + /** + * 项目状态编码 + */ + private String statusCode; + /** + * 计划开始日期 + */ + private LocalDate plannedStartDate; + /** + * 计划结束日期 + */ + private LocalDate plannedEndDate; + /** + * 实际开始日期 + */ + private LocalDate actualStartDate; + /** + * 实际结束日期 + */ + private LocalDate actualEndDate; + /** + * 项目进度缓存值 + */ + private BigDecimal progressRate; + /** + * 项目说明 + */ + private String projectDesc; + /** + * 最近一次状态动作原因 + */ + private String lastStatusReason; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/ProjectStatusLogDO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/ProjectStatusLogDO.java new file mode 100644 index 0000000..da45d41 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/ProjectStatusLogDO.java @@ -0,0 +1,63 @@ +package com.njcn.rdms.module.project.dal.dataobject.project; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 项目状态日志表 + */ +@TableName("rdms_project_status_log") +@Data +@EqualsAndHashCode(callSuper = true) +public class ProjectStatusLogDO extends BaseDO { + + /** + * 主键ID + */ + @TableId + private Long id; + /** + * 项目ID + */ + private Long projectId; + /** + * 动作类型 + */ + private String actionType; + /** + * 变更前状态编码 + */ + private String fromStatus; + /** + * 变更后状态编码 + */ + private String toStatus; + /** + * 动作原因 + */ + private String reason; + /** + * 操作人用户ID + */ + private Long operatorUserId; + /** + * 操作人名称快照 + */ + private String operatorName; + /** + * 项目编码快照 + */ + private String projectCodeSnapshot; + /** + * 项目名称快照 + */ + private String projectNameSnapshot; + /** + * 备注 + */ + private String remark; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/execution/ExecutionMemberDO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/execution/ExecutionMemberDO.java new file mode 100644 index 0000000..5d9fa72 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/execution/ExecutionMemberDO.java @@ -0,0 +1,45 @@ +package com.njcn.rdms.module.project.dal.dataobject.project.execution; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * 执行成员关系表。 + */ +@TableName("rdms_execution_member") +@Data +@EqualsAndHashCode(callSuper = true) +public class ExecutionMemberDO extends BaseDO { + + /** + * 主键编号 + */ + @TableId + private Long id; + /** + * 所属执行编号 + */ + private Long executionId; + /** + * 成员用户编号 + */ + private Long userId; + /** + * 加入时间 + */ + private LocalDateTime joinedAt; + /** + * 失效时间,为空表示当前有效 + */ + private LocalDateTime removedAt; + /** + * 失效原因 + */ + private String removedReason; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/execution/ExecutionMemberLogDO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/execution/ExecutionMemberLogDO.java new file mode 100644 index 0000000..0cc224f --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/execution/ExecutionMemberLogDO.java @@ -0,0 +1,58 @@ +package com.njcn.rdms.module.project.dal.dataobject.project.execution; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * 执行成员变更历史日志(B 模型 - 全量事件记录)。 + * 每次 join / inactive / owner_transfer_in / owner_transfer_out 独立成一条记录,昵称展示由查询阶段按用户编号回填。 + */ +@TableName("rdms_execution_member_log") +@Data +@EqualsAndHashCode(callSuper = true) +public class ExecutionMemberLogDO extends BaseDO { + + /** + * 主键 ID + */ + @TableId + private Long id; + /** + * 所属执行编号 + */ + private Long executionId; + /** + * 被操作的成员用户编号 + */ + private Long userId; + /** + * 被操作人昵称冗余字段。当前业务写入不再落昵称,返回时按 userId 实时回填。 + */ + private String userNicknameSnapshot; + /** + * 事件类型:join / inactive / owner_transfer_in / owner_transfer_out + */ + private String actionType; + /** + * 事件原因,inactive 必填,其余可空 + */ + private String reason; + /** + * 操作人用户编号 + */ + private Long operatorUserId; + /** + * 操作人昵称冗余字段。当前业务写入不再落昵称,返回时按 operatorUserId 实时回填。 + */ + private String operatorNicknameSnapshot; + /** + * 事件时间 + */ + private LocalDateTime actionTime; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/execution/ProjectExecutionDO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/execution/ProjectExecutionDO.java new file mode 100644 index 0000000..b0d78b3 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/execution/ProjectExecutionDO.java @@ -0,0 +1,78 @@ +package com.njcn.rdms.module.project.dal.dataobject.project.execution; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * 项目执行主表。 + */ +@TableName("rdms_project_execution") +@Data +@EqualsAndHashCode(callSuper = true) +public class ProjectExecutionDO extends BaseDO { + + /** + * 执行编号 + */ + @TableId + private Long id; + /** + * 所属项目编号 + */ + private Long projectId; + /** + * 关联项目需求编号,第一阶段仅保留字段 + */ + private Long projectRequirementId; + /** + * 执行名称 + */ + private String executionName; + /** + * 执行类型 + */ + private String executionType; + /** + * 执行负责人用户编号 + */ + private Long ownerId; + /** + * 执行状态编码 + */ + private String statusCode; + /** + * 计划开始日期 + */ + private LocalDate plannedStartDate; + /** + * 计划结束日期 + */ + private LocalDate plannedEndDate; + /** + * 实际开始日期 + */ + private LocalDate actualStartDate; + /** + * 实际结束日期 + */ + private LocalDate actualEndDate; + /** + * 执行进度缓存值 + */ + private BigDecimal progressRate; + /** + * 执行说明 + */ + private String executionDesc; + /** + * 最近一次状态动作原因 + */ + private String lastStatusReason; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/execution/ProjectExecutionStatusLogDO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/execution/ProjectExecutionStatusLogDO.java new file mode 100644 index 0000000..96cbef5 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/execution/ProjectExecutionStatusLogDO.java @@ -0,0 +1,59 @@ +package com.njcn.rdms.module.project.dal.dataobject.project.execution; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 执行状态日志表。 + */ +@TableName("rdms_execution_status_log") +@Data +@EqualsAndHashCode(callSuper = true) +public class ProjectExecutionStatusLogDO extends BaseDO { + + /** + * 主键ID + */ + @TableId + private Long id; + /** + * 执行ID + */ + private Long executionId; + /** + * 动作编码 + */ + private String actionType; + /** + * 变更前状态编码 + */ + private String fromStatus; + /** + * 变更后状态编码 + */ + private String toStatus; + /** + * 动作原因 + */ + private String reason; + /** + * 操作人用户ID + */ + private Long operatorUserId; + /** + * 操作人名称快照 + */ + private String operatorName; + /** + * 执行名称快照 + */ + private String executionNameSnapshot; + /** + * 备注 + */ + private String remark; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/task/ProjectTaskDO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/task/ProjectTaskDO.java new file mode 100644 index 0000000..da72639 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/task/ProjectTaskDO.java @@ -0,0 +1,78 @@ +package com.njcn.rdms.module.project.dal.dataobject.project.task; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * 项目任务主表。 + */ +@TableName("rdms_task") +@Data +@EqualsAndHashCode(callSuper = true) +public class ProjectTaskDO extends BaseDO { + + /** + * 任务编号 + */ + @TableId + private Long id; + /** + * 所属项目编号 + */ + private Long projectId; + /** + * 所属执行编号 + */ + private Long executionId; + /** + * 父任务编号 + */ + private Long parentTaskId; + /** + * 任务标题 + */ + private String taskTitle; + /** + * 任务负责人用户编号 + */ + private Long ownerId; + /** + * 任务状态编码 + */ + private String statusCode; + /** + * 任务进度 + */ + private BigDecimal progressRate; + /** + * 计划开始日期 + */ + private LocalDate plannedStartDate; + /** + * 计划结束日期 + */ + private LocalDate plannedEndDate; + /** + * 实际开始日期 + */ + private LocalDate actualStartDate; + /** + * 实际结束日期 + */ + private LocalDate actualEndDate; + /** + * 任务说明 + */ + private String taskDesc; + /** + * 最近一次状态动作原因 + */ + private String lastStatusReason; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/task/ProjectTaskStatusLogDO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/task/ProjectTaskStatusLogDO.java new file mode 100644 index 0000000..86dadae --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/task/ProjectTaskStatusLogDO.java @@ -0,0 +1,59 @@ +package com.njcn.rdms.module.project.dal.dataobject.project.task; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 任务状态日志表。 + */ +@TableName("rdms_task_status_log") +@Data +@EqualsAndHashCode(callSuper = true) +public class ProjectTaskStatusLogDO extends BaseDO { + + /** + * 主键ID + */ + @TableId + private Long id; + /** + * 任务ID + */ + private Long taskId; + /** + * 动作编码 + */ + private String actionType; + /** + * 变更前状态编码 + */ + private String fromStatus; + /** + * 变更后状态编码 + */ + private String toStatus; + /** + * 动作原因 + */ + private String reason; + /** + * 操作人用户ID + */ + private Long operatorUserId; + /** + * 操作人名称快照 + */ + private String operatorName; + /** + * 任务标题快照 + */ + private String taskTitleSnapshot; + /** + * 备注 + */ + private String remark; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/task/TaskAssigneeDO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/task/TaskAssigneeDO.java new file mode 100644 index 0000000..29553ba --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/task/TaskAssigneeDO.java @@ -0,0 +1,43 @@ +package com.njcn.rdms.module.project.dal.dataobject.project.task; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * 任务协办人活跃记录表(B 模型 - 多行周期记录)。 + * 同一 userId 在同一任务内允许多条历史记录,但任意时刻只有一段 removedAt 为 null(活跃)。 + * 失效时设置 removedAt(不动 BaseDO.deleted),失效原因和时间快照同步写入 {@link TaskAssigneeLogDO}。 + */ +@TableName("rdms_task_assignee") +@Data +@EqualsAndHashCode(callSuper = true) +public class TaskAssigneeDO extends BaseDO { + + /** + * 主键 ID + */ + @TableId + private Long id; + /** + * 任务编号 + */ + private Long taskId; + /** + * 协办人用户编号 + */ + private Long userId; + /** + * 加入时间 + */ + private LocalDateTime joinedAt; + /** + * 失效时间,为 null 表示当前活跃 + */ + private LocalDateTime removedAt; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/task/TaskAssigneeLogDO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/task/TaskAssigneeLogDO.java new file mode 100644 index 0000000..128c2f1 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/task/TaskAssigneeLogDO.java @@ -0,0 +1,58 @@ +package com.njcn.rdms.module.project.dal.dataobject.project.task; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * 任务协办人变更历史日志表(B 模型 - 全量事件记录)。 + * 每次 join / inactive 都独立成一条记录,nickname 在写入时快照不再回查。 + */ +@TableName("rdms_task_assignee_log") +@Data +@EqualsAndHashCode(callSuper = true) +public class TaskAssigneeLogDO extends BaseDO { + + /** + * 主键 ID + */ + @TableId + private Long id; + /** + * 任务编号 + */ + private Long taskId; + /** + * 被操作的协办人用户编号 + */ + private Long userId; + /** + * 被操作人昵称快照 + */ + private String userNicknameSnapshot; + /** + * 事件类型:join / inactive + */ + private String actionType; + /** + * 原因:inactive 必填,join 可空 + */ + private String reason; + /** + * 操作人用户编号 + */ + private Long operatorUserId; + /** + * 操作人昵称快照 + */ + private String operatorNicknameSnapshot; + /** + * 事件时间 + */ + private LocalDateTime actionTime; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/task/TaskWorklogDO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/task/TaskWorklogDO.java new file mode 100644 index 0000000..1c61c9a --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/task/TaskWorklogDO.java @@ -0,0 +1,46 @@ +package com.njcn.rdms.module.project.dal.dataobject.project.task; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDate; + +/** + * 任务工时记录表。仅挂在叶子任务上;同一 user × task × work_date 允许多条。 + * 时长按分钟存(duration_minutes 必须 > 0 且为 30 的整数倍),前端展示为小时。 + */ +@TableName("rdms_task_worklog") +@Data +@EqualsAndHashCode(callSuper = true) +public class TaskWorklogDO extends BaseDO { + + /** + * 主键 ID + */ + @TableId + private Long id; + /** + * 任务编号(必须为叶子任务) + */ + private Long taskId; + /** + * 填报人用户编号(owner 或在岗协办人) + */ + private Long userId; + /** + * 工作日期 + */ + private LocalDate workDate; + /** + * 时长(分钟为单位),必须 > 0 且为 30 的整数倍 + */ + private Integer durationMinutes; + /** + * 工作内容描述 + */ + private String workContent; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/member/UserObjectRoleMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/member/UserObjectRoleMapper.java index a9b3571..df95124 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/member/UserObjectRoleMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/member/UserObjectRoleMapper.java @@ -52,4 +52,11 @@ public interface UserObjectRoleMapper extends BaseMapperX { .eq(UserObjectRoleDO::getObjectId, objectId)); } + default List selectActiveListByObjectTypeAndUserId(String objectType, Long userId) { + return selectList(new LambdaQueryWrapperX() + .eq(UserObjectRoleDO::getObjectType, objectType) + .eq(UserObjectRoleDO::getUserId, userId) + .eq(UserObjectRoleDO::getStatus, 0)); + } + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/product/ProductMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/product/ProductMapper.java index beda994..0b54ee1 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/product/ProductMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/product/ProductMapper.java @@ -7,9 +7,11 @@ import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX; import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductPageReqVO; import com.njcn.rdms.module.project.dal.dataobject.product.ProductDO; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; import org.springframework.util.StringUtils; import java.util.List; +import java.util.Map; @Mapper public interface ProductMapper extends BaseMapperX { @@ -25,7 +27,7 @@ public interface ProductMapper extends BaseMapperX { .eqIfPresent(ProductDO::getManagerUserId, reqVO.getManagerUserId()) .eqIfPresent(ProductDO::getStatusCode, reqVO.getStatusCode()) .betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime()) - .orderByDesc(BaseDO::getUpdateTime); + .orderByDesc(BaseDO::getCreateTime); return selectPage(reqVO, queryWrapper); } @@ -43,6 +45,14 @@ public interface ProductMapper extends BaseMapperX { .orderByDesc(ProductDO::getCode)); } + @Select(""" + SELECT status_code AS statusCode, COUNT(*) AS countValue + FROM rdms_product + WHERE deleted = b'0' + GROUP BY status_code + """) + List> selectStatusCountList(); + default int updateStatusByIdAndStatus(Long id, String fromStatus, String toStatus, String lastStatusReason) { ProductDO update = new ProductDO(); update.setStatusCode(toStatus); diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/ProjectMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/ProjectMapper.java new file mode 100644 index 0000000..d77ff46 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/ProjectMapper.java @@ -0,0 +1,90 @@ +package com.njcn.rdms.module.project.dal.mysql.project; + +import com.njcn.rdms.framework.common.pojo.PageResult; +import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO; +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.vo.project.ProjectPageReqVO; +import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; +import org.springframework.util.StringUtils; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +@Mapper +public interface ProjectMapper extends BaseMapperX { + + default PageResult selectPage(ProjectPageReqVO reqVO) { + LambdaQueryWrapperX queryWrapper = new LambdaQueryWrapperX<>(); + if (StringUtils.hasText(reqVO.getKeyword())) { + queryWrapper.and(wrapper -> wrapper.like(ProjectDO::getProjectCode, reqVO.getKeyword()) + .or() + .like(ProjectDO::getProjectName, reqVO.getKeyword())); + } + queryWrapper.eqIfPresent(ProjectDO::getProjectType, reqVO.getProjectType()) + .eqIfPresent(ProjectDO::getDirectionCode, reqVO.getDirectionCode()) + .eqIfPresent(ProjectDO::getProductId, reqVO.getProductId()) + .eqIfPresent(ProjectDO::getManagerUserId, reqVO.getManagerUserId()) + .eqIfPresent(ProjectDO::getStatusCode, reqVO.getStatusCode()) + .betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime()) + .orderByDesc(BaseDO::getCreateTime); + return selectPage(reqVO, queryWrapper); + } + + default ProjectDO selectByCode(String projectCode) { + return selectOne(ProjectDO::getProjectCode, projectCode); + } + + default ProjectDO selectActiveByProductIdAndName(Long productId, String projectName, String excludedStatusCode) { + LambdaQueryWrapperX queryWrapper = new LambdaQueryWrapperX<>(); + queryWrapper.eq(ProjectDO::getProjectName, projectName) + .ne(ProjectDO::getStatusCode, excludedStatusCode); + if (productId == null) { + queryWrapper.isNull(ProjectDO::getProductId); + } else { + queryWrapper.eq(ProjectDO::getProductId, productId); + } + return selectOne(queryWrapper); + } + + default List selectActiveMainlineListByProductId(Long productId, Collection projectTypes, + String excludedStatusCode) { + return selectList(new LambdaQueryWrapperX() + .eq(ProjectDO::getProductId, productId) + .in(ProjectDO::getProjectType, projectTypes) + .ne(ProjectDO::getStatusCode, excludedStatusCode)); + } + + @Select(""" + SELECT status_code AS statusCode, COUNT(*) AS countValue + FROM rdms_project + WHERE deleted = b'0' + GROUP BY status_code + """) + List> selectStatusCountList(); + + default List selectListByCodePrefix(String codePrefix) { + return selectList(new LambdaQueryWrapperX() + .likeRight(ProjectDO::getProjectCode, codePrefix) + .orderByDesc(ProjectDO::getProjectCode)); + } + + default int updateStatusByIdAndStatus(Long id, String fromStatus, String toStatus, String lastStatusReason) { + ProjectDO update = new ProjectDO(); + update.setStatusCode(toStatus); + update.setLastStatusReason(lastStatusReason); + return update(update, new LambdaQueryWrapperX() + .eq(ProjectDO::getId, id) + .eq(ProjectDO::getStatusCode, fromStatus)); + } + + default int deleteByIdAndStatus(Long id, String statusCode) { + return delete(new LambdaQueryWrapperX() + .eq(ProjectDO::getId, id) + .eq(ProjectDO::getStatusCode, statusCode)); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/ProjectStatusLogMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/ProjectStatusLogMapper.java new file mode 100644 index 0000000..3a03e7b --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/ProjectStatusLogMapper.java @@ -0,0 +1,24 @@ +package com.njcn.rdms.module.project.dal.mysql.project; + +import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO; +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.ProjectStatusLogDO; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.List; + +@Mapper +public interface ProjectStatusLogMapper extends BaseMapperX { + + default List selectListByProjectId(Long projectId, String actionType, LocalDateTime[] operateTime) { + return selectList(new LambdaQueryWrapperX() + .eq(ProjectStatusLogDO::getProjectId, projectId) + .eqIfPresent(ProjectStatusLogDO::getActionType, actionType) + .betweenIfPresent(BaseDO::getCreateTime, operateTime) + .orderByDesc(BaseDO::getCreateTime) + .orderByDesc(ProjectStatusLogDO::getId)); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ExecutionMemberLogMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ExecutionMemberLogMapper.java new file mode 100644 index 0000000..6157124 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ExecutionMemberLogMapper.java @@ -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.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 { + + /** + * 分页查询执行成员变更历史,按 actionTime DESC, id DESC 排序。 + * 支持按 actionType[] / userId / 时间范围筛选。 + */ + default PageResult selectPageByExecutionId(Long executionId, + ExecutionMemberLogPageReqVO reqVO) { + LambdaQueryWrapperX queryWrapper = new LambdaQueryWrapperX() + .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); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ExecutionMemberMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ExecutionMemberMapper.java new file mode 100644 index 0000000..b3592ee --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ExecutionMemberMapper.java @@ -0,0 +1,51 @@ +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 { + + default List selectListByExecutionId(Long executionId) { + return selectList(new LambdaQueryWrapperX() + .eq(ExecutionMemberDO::getExecutionId, executionId) + .orderByAsc(ExecutionMemberDO::getRemovedAt) + .orderByAsc(ExecutionMemberDO::getJoinedAt) + .orderByAsc(ExecutionMemberDO::getId)); + } + + /** + * 仅返当前活跃成员(removed_at IS NULL)。B 模型下同 userId 至多一段未失效。 + */ + default List selectActiveListByExecutionId(Long executionId) { + return selectList(new LambdaQueryWrapperX() + .eq(ExecutionMemberDO::getExecutionId, executionId) + .isNull(ExecutionMemberDO::getRemovedAt) + .orderByAsc(ExecutionMemberDO::getJoinedAt) + .orderByAsc(ExecutionMemberDO::getId)); + } + + default ExecutionMemberDO selectByExecutionIdAndUserId(Long executionId, Long userId) { + return selectOne(new LambdaQueryWrapperX() + .eq(ExecutionMemberDO::getExecutionId, executionId) + .eq(ExecutionMemberDO::getUserId, userId)); + } + + default ExecutionMemberDO selectByIdAndExecutionId(Long id, Long executionId) { + return selectOne(new LambdaQueryWrapperX() + .eq(ExecutionMemberDO::getId, id) + .eq(ExecutionMemberDO::getExecutionId, executionId)); + } + + default ExecutionMemberDO selectActiveByExecutionIdAndUserId(Long executionId, Long userId) { + return selectOne(new LambdaQueryWrapperX() + .eq(ExecutionMemberDO::getExecutionId, executionId) + .eq(ExecutionMemberDO::getUserId, userId) + .isNull(ExecutionMemberDO::getRemovedAt)); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ProjectExecutionMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ProjectExecutionMapper.java new file mode 100644 index 0000000..6af5bcd --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ProjectExecutionMapper.java @@ -0,0 +1,77 @@ +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.dataobject.BaseDO; +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.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 org.apache.ibatis.annotations.Mapper; +import org.springframework.util.StringUtils; + +import java.util.List; + +@Mapper +public interface ProjectExecutionMapper extends BaseMapperX { + + default ProjectExecutionDO selectByProjectIdAndId(Long projectId, Long executionId) { + return selectOne(new LambdaQueryWrapperX() + .eq(ProjectExecutionDO::getProjectId, projectId) + .eq(ProjectExecutionDO::getId, executionId)); + } + + default ProjectExecutionDO selectByProjectIdAndName(Long projectId, String executionName) { + return selectOne(new LambdaQueryWrapperX() + .eq(ProjectExecutionDO::getProjectId, projectId) + .eq(ProjectExecutionDO::getExecutionName, executionName)); + } + + default PageResult selectPageByProjectId(Long projectId, ProjectExecutionPageReqVO reqVO) { + LambdaQueryWrapperX queryWrapper = new LambdaQueryWrapperX() + .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(ProjectExecutionDO::getId); + if (StringUtils.hasText(reqVO.getKeyword())) { + queryWrapper.and(wrapper -> wrapper.like(ProjectExecutionDO::getExecutionName, reqVO.getKeyword())); + } + return selectPage(reqVO, queryWrapper); + } + + default Integer countNonTerminalByProjectIdAndOwnerId(Long projectId, Long ownerId, List terminalStatusCodes) { + LambdaQueryWrapperX queryWrapper = new LambdaQueryWrapperX() + .eq(ProjectExecutionDO::getProjectId, projectId) + .eq(ProjectExecutionDO::getOwnerId, ownerId); + if (terminalStatusCodes != null && !terminalStatusCodes.isEmpty()) { + queryWrapper.notIn(ProjectExecutionDO::getStatusCode, terminalStatusCodes); + } + return Math.toIntExact(selectCount(queryWrapper)); + } + + default Integer countByProjectIdAndStatusCode(Long projectId, ProjectExecutionStatusBoardReqVO reqVO, String statusCode) { + LambdaQueryWrapperX queryWrapper = new LambdaQueryWrapperX() + .eq(ProjectExecutionDO::getProjectId, projectId) + .eq(ProjectExecutionDO::getStatusCode, statusCode) + .eqIfPresent(ProjectExecutionDO::getExecutionType, reqVO.getExecutionType()) + .eqIfPresent(ProjectExecutionDO::getOwnerId, reqVO.getOwnerId()) + .betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime()); + if (StringUtils.hasText(reqVO.getKeyword())) { + queryWrapper.and(wrapper -> wrapper.like(ProjectExecutionDO::getExecutionName, reqVO.getKeyword())); + } + return Math.toIntExact(selectCount(queryWrapper)); + } + + default int updateStatusByIdAndStatus(Long id, String fromStatus, String toStatus, String lastStatusReason) { + ProjectExecutionDO update = new ProjectExecutionDO(); + update.setStatusCode(toStatus); + update.setLastStatusReason(lastStatusReason); + return update(update, new LambdaQueryWrapperX() + .eq(ProjectExecutionDO::getId, id) + .eq(ProjectExecutionDO::getStatusCode, fromStatus)); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ProjectExecutionStatusLogMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ProjectExecutionStatusLogMapper.java new file mode 100644 index 0000000..965fffb --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ProjectExecutionStatusLogMapper.java @@ -0,0 +1,25 @@ +package com.njcn.rdms.module.project.dal.mysql.project.execution; + +import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO; +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.ProjectExecutionStatusLogDO; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.List; + +@Mapper +public interface ProjectExecutionStatusLogMapper extends BaseMapperX { + + default List selectListByExecutionId(Long executionId, String actionType, + LocalDateTime[] operateTime) { + return selectList(new LambdaQueryWrapperX() + .eq(ProjectExecutionStatusLogDO::getExecutionId, executionId) + .eqIfPresent(ProjectExecutionStatusLogDO::getActionType, actionType) + .betweenIfPresent(BaseDO::getCreateTime, operateTime) + .orderByDesc(BaseDO::getCreateTime) + .orderByDesc(ProjectExecutionStatusLogDO::getId)); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskMapper.java new file mode 100644 index 0000000..48753cc --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskMapper.java @@ -0,0 +1,127 @@ +package com.njcn.rdms.module.project.dal.mysql.project.task; + +import com.njcn.rdms.framework.common.pojo.PageResult; +import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO; +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.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 org.apache.ibatis.annotations.Mapper; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; + +@Mapper +public interface ProjectTaskMapper extends BaseMapperX { + + default ProjectTaskDO selectByProjectIdAndExecutionIdAndId(Long projectId, Long executionId, Long taskId) { + return selectOne(new LambdaQueryWrapperX() + .eq(ProjectTaskDO::getProjectId, projectId) + .eq(ProjectTaskDO::getExecutionId, executionId) + .eq(ProjectTaskDO::getId, taskId)); + } + + default PageResult selectPageByExecutionId(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) { + LambdaQueryWrapperX queryWrapper = new LambdaQueryWrapperX<>(); + queryWrapper.eq(ProjectTaskDO::getProjectId, projectId); + queryWrapper.eq(ProjectTaskDO::getExecutionId, executionId); + queryWrapper.eqIfPresent(ProjectTaskDO::getParentTaskId, reqVO.getParentTaskId()); + queryWrapper.eqIfPresent(ProjectTaskDO::getOwnerId, reqVO.getOwnerId()); + queryWrapper.eqIfPresent(ProjectTaskDO::getStatusCode, reqVO.getStatusCode()); + queryWrapper.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime()); + queryWrapper.orderByAsc(ProjectTaskDO::getParentTaskId); + queryWrapper.orderByDesc(BaseDO::getUpdateTime); + queryWrapper.orderByDesc(ProjectTaskDO::getId); + if (StringUtils.hasText(reqVO.getKeyword())) { + queryWrapper.and(wrapper -> wrapper.like(ProjectTaskDO::getTaskTitle, reqVO.getKeyword())); + } + return selectPage(reqVO, queryWrapper); + } + + default int updateStatusByIdAndStatus(Long id, String fromStatus, String toStatus, String lastStatusReason) { + ProjectTaskDO update = new ProjectTaskDO(); + update.setStatusCode(toStatus); + update.setLastStatusReason(lastStatusReason); + return update(update, new LambdaQueryWrapperX() + .eq(ProjectTaskDO::getId, id) + .eq(ProjectTaskDO::getStatusCode, fromStatus)); + } + + /** + * 仅更新实际开始/结束日期。null 字段依据全局 FieldStrategy 不会被覆盖。 + */ + default int updateActualDatesById(Long id, LocalDate actualStartDate, LocalDate actualEndDate) { + if (actualStartDate == null && actualEndDate == null) { + return 0; + } + ProjectTaskDO update = new ProjectTaskDO(); + update.setActualStartDate(actualStartDate); + update.setActualEndDate(actualEndDate); + return update(update, new LambdaQueryWrapperX() + .eq(ProjectTaskDO::getId, id)); + } + + default Integer countChildrenNotInStatus(Long parentTaskId, List terminalStatusCodes) { + LambdaQueryWrapperX queryWrapper = new LambdaQueryWrapperX() + .eq(ProjectTaskDO::getParentTaskId, parentTaskId); + if (terminalStatusCodes != null && !terminalStatusCodes.isEmpty()) { + queryWrapper.notIn(ProjectTaskDO::getStatusCode, terminalStatusCodes); + } + return Math.toIntExact(selectCount(queryWrapper)); + } + + /** + * 数指定父任务下的直接子任务(不区分状态)。用于"是否叶子任务"判定与进度汇总。 + */ + default int countChildrenByParentTaskId(Long parentTaskId) { + if (parentTaskId == null) { + return 0; + } + return Math.toIntExact(selectCount(new LambdaQueryWrapperX() + .eq(ProjectTaskDO::getParentTaskId, parentTaskId))); + } + + /** + * 取直接子任务的 progressRate 列表(用于父任务进度简单平均汇总)。 + * 只读取必要字段,避免拉回整行;逻辑删除的子任务由 BaseMapper 自动过滤。 + */ + default List selectChildrenProgressByParentTaskId(Long parentTaskId) { + if (parentTaskId == null) { + return Collections.emptyList(); + } + return selectList(new LambdaQueryWrapperX() + .select(ProjectTaskDO::getId, ProjectTaskDO::getProgressRate) + .eq(ProjectTaskDO::getParentTaskId, parentTaskId)); + } + + /** + * 仅更新单个任务的 progressRate,不动其他字段(避免污染 lastStatusReason 等)。 + */ + default int updateProgressRateById(Long id, BigDecimal progressRate) { + ProjectTaskDO update = new ProjectTaskDO(); + update.setProgressRate(progressRate); + return update(update, new LambdaQueryWrapperX() + .eq(ProjectTaskDO::getId, id)); + } + + default Integer countByProjectIdAndExecutionIdAndStatusCode(Long projectId, Long executionId, + ProjectTaskStatusBoardReqVO reqVO, + String statusCode) { + LambdaQueryWrapperX queryWrapper = new LambdaQueryWrapperX() + .eq(ProjectTaskDO::getProjectId, projectId) + .eq(ProjectTaskDO::getExecutionId, executionId) + .eq(ProjectTaskDO::getStatusCode, statusCode) + .eqIfPresent(ProjectTaskDO::getParentTaskId, reqVO.getParentTaskId()) + .eqIfPresent(ProjectTaskDO::getOwnerId, reqVO.getOwnerId()) + .betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime()); + if (StringUtils.hasText(reqVO.getKeyword())) { + queryWrapper.and(wrapper -> wrapper.like(ProjectTaskDO::getTaskTitle, reqVO.getKeyword())); + } + return Math.toIntExact(selectCount(queryWrapper)); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskStatusLogMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskStatusLogMapper.java new file mode 100644 index 0000000..72545fb --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskStatusLogMapper.java @@ -0,0 +1,9 @@ +package com.njcn.rdms.module.project.dal.mysql.project.task; + +import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX; +import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskStatusLogDO; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ProjectTaskStatusLogMapper extends BaseMapperX { +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/TaskAssigneeLogMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/TaskAssigneeLogMapper.java new file mode 100644 index 0000000..27aac7b --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/TaskAssigneeLogMapper.java @@ -0,0 +1,29 @@ +package com.njcn.rdms.module.project.dal.mysql.project.task; + +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.task.vo.assignee.TaskAssigneeLogPageReqVO; +import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskAssigneeLogDO; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface TaskAssigneeLogMapper extends BaseMapperX { + + /** + * 分页查询协办人变更历史,按 actionTime DESC, id DESC 排序。 + * 支持按 actionType[] / userId / 时间范围筛选。 + */ + default PageResult selectPageByTaskId(Long taskId, TaskAssigneeLogPageReqVO reqVO) { + LambdaQueryWrapperX queryWrapper = new LambdaQueryWrapperX() + .eq(TaskAssigneeLogDO::getTaskId, taskId) + .inIfPresent(TaskAssigneeLogDO::getActionType, reqVO.getActionTypes()) + .eqIfPresent(TaskAssigneeLogDO::getUserId, reqVO.getUserId()) + .geIfPresent(TaskAssigneeLogDO::getActionTime, reqVO.getStartTime()) + .leIfPresent(TaskAssigneeLogDO::getActionTime, reqVO.getEndTime()) + .orderByDesc(TaskAssigneeLogDO::getActionTime) + .orderByDesc(TaskAssigneeLogDO::getId); + return selectPage(reqVO, queryWrapper); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/TaskAssigneeMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/TaskAssigneeMapper.java new file mode 100644 index 0000000..e864735 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/TaskAssigneeMapper.java @@ -0,0 +1,60 @@ +package com.njcn.rdms.module.project.dal.mysql.project.task; + +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 java.util.Collection; +import java.util.Collections; +import java.util.List; + +@Mapper +public interface TaskAssigneeMapper extends BaseMapperX { + + /** + * 查同一任务下指定 userId 当前活跃的协办段(removed_at IS NULL)。失效段不会命中。 + */ + default TaskAssigneeDO selectActiveByTaskIdAndUserId(Long taskId, Long userId) { + return selectOne(new LambdaQueryWrapperX() + .eq(TaskAssigneeDO::getTaskId, taskId) + .eq(TaskAssigneeDO::getUserId, userId) + .isNull(TaskAssigneeDO::getRemovedAt)); + } + + /** + * 查指定任务下所有当前活跃协办,按加入时间正序。 + */ + default List selectActiveListByTaskId(Long taskId) { + return selectList(new LambdaQueryWrapperX() + .eq(TaskAssigneeDO::getTaskId, taskId) + .isNull(TaskAssigneeDO::getRemovedAt) + .orderByAsc(TaskAssigneeDO::getJoinedAt) + .orderByAsc(TaskAssigneeDO::getId)); + } + + /** + * 批量查多个任务的活跃协办(分页装配 N+1 优化)。 + */ + default List selectActiveListByTaskIds(Collection taskIds) { + if (taskIds == null || taskIds.isEmpty()) { + return Collections.emptyList(); + } + return selectList(new LambdaQueryWrapperX() + .in(TaskAssigneeDO::getTaskId, taskIds) + .isNull(TaskAssigneeDO::getRemovedAt) + .orderByAsc(TaskAssigneeDO::getTaskId) + .orderByAsc(TaskAssigneeDO::getJoinedAt) + .orderByAsc(TaskAssigneeDO::getId)); + } + + /** + * 按主键 + 任务 ID 双键查;返回的记录可能已失效(removed_at != null),由调用方判断。 + */ + default TaskAssigneeDO selectByIdAndTaskId(Long id, Long taskId) { + return selectOne(new LambdaQueryWrapperX() + .eq(TaskAssigneeDO::getId, id) + .eq(TaskAssigneeDO::getTaskId, taskId)); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/TaskWorklogMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/TaskWorklogMapper.java new file mode 100644 index 0000000..d0dca64 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/TaskWorklogMapper.java @@ -0,0 +1,78 @@ +package com.njcn.rdms.module.project.dal.mysql.project.task; + +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.task.vo.worklog.TaskWorklogPageReqVO; +import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskWorklogDO; +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.List; +import java.util.Map; + +@Mapper +public interface TaskWorklogMapper extends BaseMapperX { + + /** + * 按主键 + 任务 ID 双键查;逻辑删除的记录不会命中(依赖 BaseMapper 自带的 deleted=0 过滤)。 + * 用于 update / delete 前的归属校验。 + */ + default TaskWorklogDO selectByIdAndTaskId(Long id, Long taskId) { + return selectOne(new LambdaQueryWrapperX() + .eq(TaskWorklogDO::getId, id) + .eq(TaskWorklogDO::getTaskId, taskId)); + } + + /** + * 任务工时分页:按 workDate DESC, id DESC;支持按填报人 / 日期区间筛选。 + */ + default PageResult selectPageByTaskId(Long taskId, TaskWorklogPageReqVO reqVO) { + LambdaQueryWrapperX queryWrapper = new LambdaQueryWrapperX() + .eq(TaskWorklogDO::getTaskId, taskId) + .eqIfPresent(TaskWorklogDO::getUserId, reqVO.getUserId()) + .geIfPresent(TaskWorklogDO::getWorkDate, reqVO.getStartDate()) + .leIfPresent(TaskWorklogDO::getWorkDate, reqVO.getEndDate()) + .orderByDesc(TaskWorklogDO::getWorkDate) + .orderByDesc(TaskWorklogDO::getId); + return selectPage(reqVO, queryWrapper); + } + + /** + * 单任务工时汇总(分钟)。无记录时返回 0;逻辑删除的记录不参与汇总。 + */ + @Select(""" + SELECT COALESCE(SUM(duration_minutes), 0) + FROM rdms_task_worklog + WHERE deleted = b'0' AND task_id = #{taskId} + """) + Long sumDurationByTaskId(@Param("taskId") Long taskId); + + /** + * 批量任务工时汇总(分钟),返回 [{taskId, total}]。用于详情/分页装配避免 N+1。 + */ + @Select(""" + + """) + List> sumDurationGroupByTaskIds(@Param("taskIds") Collection taskIds); + + /** + * 是否存在任意工时记录。叶子转父校验用(拆子任务前必须删完工时)。 + */ + default boolean existsByTaskId(Long taskId) { + if (taskId == null) { + return false; + } + return selectCount(new LambdaQueryWrapperX() + .eq(TaskWorklogDO::getTaskId, taskId)) > 0; + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/status/ObjectStatusModelMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/status/ObjectStatusModelMapper.java index 068473c..a8ff065 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/status/ObjectStatusModelMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/status/ObjectStatusModelMapper.java @@ -6,6 +6,7 @@ import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO; import org.apache.ibatis.annotations.Mapper; import java.util.List; +import java.util.stream.Collectors; @Mapper public interface ObjectStatusModelMapper extends BaseMapperX { @@ -23,10 +24,37 @@ public interface ObjectStatusModelMapper extends BaseMapperX() + .eq(ObjectStatusModelDO::getObjectType, objectType) + .eq(ObjectStatusModelDO::getInitialFlag, true) + .eq(ObjectStatusModelDO::getStatus, 0)); + } + default List selectListByObjectType(String objectType) { return selectList(new LambdaQueryWrapperX() .eq(ObjectStatusModelDO::getObjectType, objectType) .orderByAsc(ObjectStatusModelDO::getSort)); } + default List selectListByObjectTypeEnabled(String objectType) { + return selectList(new LambdaQueryWrapperX() + .eq(ObjectStatusModelDO::getObjectType, objectType) + .eq(ObjectStatusModelDO::getStatus, 0) + .orderByAsc(ObjectStatusModelDO::getSort)); + } + + /** + * 查询某对象类型下所有已启用的终态状态码。 + */ + default List selectTerminalStatusCodesByObjectTypeEnabled(String objectType) { + return selectList(new LambdaQueryWrapperX() + .eq(ObjectStatusModelDO::getObjectType, objectType) + .eq(ObjectStatusModelDO::getStatus, 0) + .eq(ObjectStatusModelDO::getTerminalFlag, true)) + .stream() + .map(ObjectStatusModelDO::getStatusCode) + .collect(Collectors.toList()); + } + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/rpc/config/RpcConfiguration.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/rpc/config/RpcConfiguration.java index ef53f69..0c85402 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/rpc/config/RpcConfiguration.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/rpc/config/RpcConfiguration.java @@ -1,5 +1,6 @@ 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.permission.ObjectPermissionApi; import com.njcn.rdms.module.system.api.user.AdminUserApi; import org.springframework.cloud.openfeign.EnableFeignClients; @@ -9,6 +10,6 @@ import org.springframework.context.annotation.Configuration; * Project 模块的 RPC 配置 */ @Configuration(value = "projectRpcConfiguration", proxyBeanMethods = false) -@EnableFeignClients(clients = {AdminUserApi.class, ObjectPermissionApi.class}) +@EnableFeignClients(clients = {AdminUserApi.class, ObjectPermissionApi.class, DictDataApi.class}) public class RpcConfiguration { } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProductObjectPermissionService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProductObjectPermissionService.java index 438df97..2227974 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProductObjectPermissionService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProductObjectPermissionService.java @@ -1,11 +1,12 @@ package com.njcn.rdms.module.project.framework.security.service; import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; +import com.njcn.rdms.module.project.constant.ObjectRoleConstants; +import com.njcn.rdms.module.project.constant.ProductObjectConstants; import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO; import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper; import com.njcn.rdms.module.project.enums.ErrorCodeConstants; import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi; -import com.njcn.rdms.module.system.enums.permission.PermissionScopeTypeEnum; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -24,9 +25,6 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil @Service public class ProductObjectPermissionService implements ObjectPermissionService { - private static final String PRODUCT_OBJECT_TYPE = "product"; - private static final String ROLE_SCOPE_OBJECT = PermissionScopeTypeEnum.OBJECT.getScopeType(); - @Resource private UserObjectRoleMapper userObjectRoleMapper; @Resource @@ -34,7 +32,7 @@ public class ProductObjectPermissionService implements ObjectPermissionService { @Override public String getObjectType() { - return PRODUCT_OBJECT_TYPE; + return ProductObjectConstants.OBJECT_TYPE; } @Override @@ -44,7 +42,7 @@ public class ProductObjectPermissionService implements ObjectPermissionService { } Long loginUserId = SecurityFrameworkUtils.getLoginUserId(); UserObjectRoleDO currentMember = userObjectRoleMapper - .selectActiveByObjectAndUserId(PRODUCT_OBJECT_TYPE, objectId, loginUserId); + .selectActiveByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, objectId, loginUserId); if (currentMember == null) { throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED, buildDeniedPermission(permission, memberOnly)); @@ -60,7 +58,8 @@ public class ProductObjectPermissionService implements ObjectPermissionService { private Set getRolePermissions(Long roleId) { Set permissions = objectPermissionApi - .getObjectRolePermissions(roleId, ROLE_SCOPE_OBJECT, PRODUCT_OBJECT_TYPE) + .getObjectRolePermissions(roleId, ObjectRoleConstants.ROLE_SCOPE_OBJECT, + ProductObjectConstants.OBJECT_TYPE) .getCheckedData(); if (permissions == null || permissions.isEmpty()) { return Collections.emptySet(); diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectPermissionService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectPermissionService.java new file mode 100644 index 0000000..cf2b5e2 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectPermissionService.java @@ -0,0 +1,83 @@ +package com.njcn.rdms.module.project.framework.security.service; + +import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; +import com.njcn.rdms.module.project.constant.ObjectRoleConstants; +import com.njcn.rdms.module.project.constant.ProjectObjectConstants; +import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO; +import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper; +import com.njcn.rdms.module.project.enums.ErrorCodeConstants; +import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.stream.Collectors; + +import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception; +import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; + +/** + * 项目对象权限服务。 + */ +@Service +public class ProjectObjectPermissionService implements ObjectPermissionService { + + @Resource + private UserObjectRoleMapper userObjectRoleMapper; + @Resource + private ObjectPermissionApi objectPermissionApi; + + @Override + public String getObjectType() { + return ProjectObjectConstants.OBJECT_TYPE; + } + + @Override + public void checkPermission(Long objectId, String permission, boolean memberOnly) { + if (objectId == null) { + throw invalidParamException("对象编号不能为空"); + } + Long loginUserId = SecurityFrameworkUtils.getLoginUserId(); + UserObjectRoleDO currentMember = userObjectRoleMapper + .selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, objectId, loginUserId); + if (currentMember == null) { + throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED, + buildDeniedPermission(permission, memberOnly)); + } + if (memberOnly) { + return; + } + String normalizedPermission = normalizePermission(permission); + if (!getRolePermissions(currentMember.getRoleId()).contains(normalizedPermission)) { + throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED, normalizedPermission); + } + } + + private Set getRolePermissions(Long roleId) { + Set permissions = objectPermissionApi + .getObjectRolePermissions(roleId, ObjectRoleConstants.ROLE_SCOPE_OBJECT, ProjectObjectConstants.OBJECT_TYPE) + .getCheckedData(); + if (permissions == null || permissions.isEmpty()) { + return Collections.emptySet(); + } + return permissions.stream() + .filter(StringUtils::hasText) + .map(String::trim) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + private String normalizePermission(String permission) { + if (!StringUtils.hasText(permission)) { + throw invalidParamException("对象权限码不能为空"); + } + return permission.trim(); + } + + private String buildDeniedPermission(String permission, boolean memberOnly) { + return memberOnly ? "member" : normalizePermission(permission); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityQueryService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityQueryService.java index 2e42f91..8483048 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityQueryService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityQueryService.java @@ -6,6 +6,8 @@ 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.PageUtils; 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.activity.ProductActivityPageReqVO; import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityRespVO; import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO; @@ -37,10 +39,8 @@ import java.util.stream.Collectors; @Service public class ProductActivityQueryService { - private static final String PRODUCT_OBJECT_TYPE = ObjectActivityConstants.PRODUCT_BIZ_TYPE; private static final String MEMBER_BIZ_TYPE = ObjectActivityConstants.MEMBER_BIZ_TYPE; private static final String ACTIVITY_ROLE_NAME_CACHE = "project_activity_role_name#10m"; - private static final String ROLE_SCOPE_OBJECT = "object"; @Resource private ProductStatusLogMapper productStatusLogMapper; @@ -60,7 +60,8 @@ public class ProductActivityQueryService { .forEach(log -> items.add(new ActivityItem(log.getId(), log.getCreateTime(), toStatusActivity(log)))); } if (includeType(reqVO.getActivityType(), ObjectActivityConstants.ACTIVITY_TYPE_PRODUCT)) { - bizAuditLogMapper.selectListByBiz(PRODUCT_OBJECT_TYPE, productId, reqVO.getActionType(), reqVO.getOperateTime()) + bizAuditLogMapper.selectListByBiz(ProductObjectConstants.OBJECT_TYPE, productId, + reqVO.getActionType(), reqVO.getOperateTime()) .forEach(log -> items.add(new ActivityItem(log.getId(), log.getCreateTime(), toProductActivity(log)))); } if (includeType(reqVO.getActivityType(), ObjectActivityConstants.ACTIVITY_TYPE_MEMBER)) { @@ -96,7 +97,7 @@ public class ProductActivityQueryService { } Map memberMap = userObjectRoleMapper - .selectListByIdsAndObject(memberIds, PRODUCT_OBJECT_TYPE, productId) + .selectListByIdsAndObject(memberIds, ProductObjectConstants.OBJECT_TYPE, productId) .stream() .collect(Collectors.toMap(UserObjectRoleDO::getId, Function.identity())); memberLogs.stream() @@ -166,7 +167,8 @@ public class ProductActivityQueryService { return roleNameMap; } Map roleMap = objectPermissionApi == null ? Map.of() - : objectPermissionApi.getObjectRoleMap(missIds, ROLE_SCOPE_OBJECT, PRODUCT_OBJECT_TYPE); + : objectPermissionApi.getObjectRoleMap(missIds, ObjectRoleConstants.ROLE_SCOPE_OBJECT, + ProductObjectConstants.OBJECT_TYPE); if (roleMap == null || roleMap.isEmpty()) { return roleNameMap; } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityTimelineQueryService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityTimelineQueryService.java index 2119171..57ca49e 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityTimelineQueryService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityTimelineQueryService.java @@ -6,6 +6,8 @@ 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.PageUtils; 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.activity.ProductActivityTimelinePageReqVO; import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityTimelineRespVO; import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO; @@ -44,8 +46,6 @@ public class ProductActivityTimelineQueryService { */ private static final String TIMELINE_USER_NICKNAME_CACHE = "project_timeline_user_nickname#10m"; private static final String TIMELINE_ROLE_NAME_CACHE = "project_timeline_role_name#10m"; - private static final String ROLE_SCOPE_OBJECT = "object"; - private static final String PRODUCT_OBJECT_TYPE = ObjectActivityConstants.PRODUCT_BIZ_TYPE; @Resource private ProductStatusLogMapper productStatusLogMapper; @@ -85,10 +85,7 @@ public class ProductActivityTimelineQueryService { if (!includeType(activityType, ObjectActivityConstants.ACTIVITY_TYPE_STATUS)) { return; } - List statusActions = limitActions(actionTypes, ObjectActivityConstants.STATUS_ACTION_TYPES); - if (shouldSkipByIntersection(actionTypes, statusActions)) { - return; - } + List statusActions = actionTypes == null || actionTypes.isEmpty() ? null : actionTypes; productStatusLogMapper.selectListByProductIdAndActions(productId, statusActions, timeRange[0], timeRange[1]) .forEach(log -> items.add(new ActivityItem(log.getId(), log.getCreateTime(), toStatusTimeline(log)))); } @@ -103,7 +100,7 @@ public class ProductActivityTimelineQueryService { return; } List productLogs = bizAuditLogMapper.selectListByBizAndActions( - ObjectActivityConstants.PRODUCT_BIZ_TYPE, productId, productActions, timeRange[0], timeRange[1]); + ProductObjectConstants.OBJECT_TYPE, productId, productActions, timeRange[0], timeRange[1]); Set createSignatures = buildCreateSignatures(productLogs); for (BizAuditLogDO log : productLogs) { if (ObjectActivityConstants.isStatusAction(log.getActionType())) { @@ -205,7 +202,7 @@ public class ProductActivityTimelineQueryService { } Map memberMap = new LinkedHashMap<>(); for (UserObjectRoleDO member : userObjectRoleMapper.selectListByIdsAndObject( - memberIds, ObjectActivityConstants.PRODUCT_BIZ_TYPE, productId)) { + memberIds, ProductObjectConstants.OBJECT_TYPE, productId)) { memberMap.put(member.getId(), member); } return memberMap; @@ -213,7 +210,7 @@ public class ProductActivityTimelineQueryService { private Set loadCreateSignatures(Long productId, LocalDateTime[] timeRange) { List productLogs = bizAuditLogMapper.selectListByBizAndActions( - ObjectActivityConstants.PRODUCT_BIZ_TYPE, productId, + ProductObjectConstants.OBJECT_TYPE, productId, ObjectActivityConstants.PRODUCT_TIMELINE_ACTION_TYPES, timeRange[0], timeRange[1]); return buildCreateSignatures(productLogs); } @@ -402,7 +399,8 @@ public class ProductActivityTimelineQueryService { return roleNameMap; } Map roleMap = objectPermissionApi == null ? Map.of() - : objectPermissionApi.getObjectRoleMap(missIds, ROLE_SCOPE_OBJECT, PRODUCT_OBJECT_TYPE); + : objectPermissionApi.getObjectRoleMap(missIds, ObjectRoleConstants.ROLE_SCOPE_OBJECT, + ProductObjectConstants.OBJECT_TYPE); if (roleMap == null || roleMap.isEmpty()) { return roleNameMap; } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductMemberServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductMemberServiceImpl.java index 7473980..99753ec 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductMemberServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductMemberServiceImpl.java @@ -3,6 +3,8 @@ package com.njcn.rdms.module.project.service.product; import com.njcn.rdms.framework.common.util.json.JsonUtils; 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.ProductMemberInactiveReqVO; import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberRespVO; import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberSaveReqVO; @@ -11,9 +13,11 @@ import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPer 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.product.ProductDO; +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.product.ProductMapper; +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.permission.ObjectPermissionApi; import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO; @@ -42,16 +46,6 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil @Service public class ProductMemberServiceImpl implements ProductMemberService { - private static final String PRODUCT_OBJECT_TYPE = "product"; - private static final String ROLE_SCOPE_OBJECT = "object"; - private static final String PRODUCT_QUERY_PERMISSION = "project:product:query"; - private static final String PRODUCT_UPDATE_PERMISSION = "project:product:update"; - - private static final Integer MEMBER_STATUS_ACTIVE = 0; - private static final Integer MEMBER_STATUS_INACTIVE = 1; - - private static final String PRODUCT_MANAGER_ROLE_CODE = "product_manager"; - @Resource private ProductMapper productMapper; @Resource @@ -61,14 +55,14 @@ public class ProductMemberServiceImpl implements ProductMemberService { @Resource private BizAuditLogMapper bizAuditLogMapper; @Resource + private ObjectStatusModelMapper objectStatusModelMapper; + @Resource private AdminUserApi adminUserApi; @Override - @CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId", - permission = PRODUCT_QUERY_PERMISSION) public List getProductMemberList(Long productId) { ProductDO product = validateProductExists(productId); - List members = userObjectRoleMapper.selectListByObject(PRODUCT_OBJECT_TYPE, productId); + List members = userObjectRoleMapper.selectListByObject(ProductObjectConstants.OBJECT_TYPE, productId); Map roleMap = getRoleMap(members.stream().map(UserObjectRoleDO::getRoleId).collect(Collectors.toSet())); Map userMap = getUserMap(members.stream().map(UserObjectRoleDO::getUserId).collect(Collectors.toSet())); return members.stream().map(member -> { @@ -82,7 +76,7 @@ public class ProductMemberServiceImpl implements ProductMemberService { respVO.setRoleName(role == null ? null : role.getName()); respVO.setRoleCode(role == null ? null : role.getCode()); respVO.setManagerFlag(Objects.equals(member.getUserId(), product.getManagerUserId()) - && Objects.equals(member.getStatus(), MEMBER_STATUS_ACTIVE)); + && Objects.equals(member.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)); respVO.setStatus(member.getStatus()); respVO.setJoinedTime(member.getJoinedTime()); respVO.setLeftTime(member.getLeftTime()); @@ -93,14 +87,14 @@ public class ProductMemberServiceImpl implements ProductMemberService { @Override @Transactional(rollbackFor = Exception.class) - @CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId", - permission = PRODUCT_UPDATE_PERMISSION) + @CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#productId", + permission = ProductObjectConstants.PERMISSION_UPDATE) public Long createProductMember(Long productId, ProductMemberSaveReqVO reqVO) { - ProductDO product = validateProductExists(productId); + ProductDO product = validateProductEditable(productId); ObjectRoleRespDTO targetRole = validateProductRole(reqVO.getRoleId()); UserObjectRoleDO existingMember = userObjectRoleMapper - .selectByObjectAndUserId(PRODUCT_OBJECT_TYPE, productId, reqVO.getUserId()); - if (existingMember != null && Objects.equals(existingMember.getStatus(), MEMBER_STATUS_ACTIVE)) { + .selectByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, productId, reqVO.getUserId()); + if (existingMember != null && Objects.equals(existingMember.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) { throw exception(ErrorCodeConstants.PRODUCT_MEMBER_ALREADY_EXISTS); } @@ -110,10 +104,10 @@ public class ProductMemberServiceImpl implements ProductMemberService { if (existingMember == null) { member = new UserObjectRoleDO(); member.setUserId(reqVO.getUserId()); - member.setObjectType(PRODUCT_OBJECT_TYPE); + member.setObjectType(ProductObjectConstants.OBJECT_TYPE); member.setObjectId(productId); member.setRoleId(targetRole.getId()); - member.setStatus(MEMBER_STATUS_ACTIVE); + member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE); member.setJoinedTime(now); member.setLeftTime(null); member.setRemark(normalizeNullableText(reqVO.getRemark())); @@ -122,7 +116,7 @@ public class ProductMemberServiceImpl implements ProductMemberService { before = cloneMember(existingMember); member = existingMember; member.setRoleId(targetRole.getId()); - member.setStatus(MEMBER_STATUS_ACTIVE); + member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE); member.setJoinedTime(now); member.setLeftTime(null); member.setRemark(normalizeNullableText(reqVO.getRemark())); @@ -138,12 +132,12 @@ public class ProductMemberServiceImpl implements ProductMemberService { @Override @Transactional(rollbackFor = Exception.class) - @CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId", - permission = PRODUCT_UPDATE_PERMISSION) + @CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#productId", + permission = ProductObjectConstants.PERMISSION_UPDATE) public void updateProductMember(Long productId, Long memberId, ProductMemberUpdateReqVO reqVO) { - ProductDO product = validateProductExists(productId); + ProductDO product = validateProductEditable(productId); UserObjectRoleDO member = validateMemberExists(productId, memberId); - if (!Objects.equals(member.getStatus(), MEMBER_STATUS_ACTIVE)) { + if (!Objects.equals(member.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) { throw exception(ErrorCodeConstants.PRODUCT_MEMBER_NOT_ACTIVE); } @@ -170,12 +164,12 @@ public class ProductMemberServiceImpl implements ProductMemberService { @Override @Transactional(rollbackFor = Exception.class) - @CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId", - permission = PRODUCT_UPDATE_PERMISSION) + @CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#productId", + permission = ProductObjectConstants.PERMISSION_UPDATE) public void inactiveProductMember(Long productId, Long memberId, ProductMemberInactiveReqVO reqVO) { - ProductDO product = validateProductExists(productId); + ProductDO product = validateProductEditable(productId); UserObjectRoleDO member = validateMemberExists(productId, memberId); - if (!Objects.equals(member.getStatus(), MEMBER_STATUS_ACTIVE)) { + if (!Objects.equals(member.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) { throw exception(ErrorCodeConstants.PRODUCT_MEMBER_NOT_ACTIVE); } if (Objects.equals(member.getUserId(), product.getManagerUserId())) { @@ -183,7 +177,7 @@ public class ProductMemberServiceImpl implements ProductMemberService { } UserObjectRoleDO before = cloneMember(member); - member.setStatus(MEMBER_STATUS_INACTIVE); + member.setStatus(ObjectRoleConstants.MEMBER_STATUS_INACTIVE); member.setLeftTime(LocalDateTime.now()); userObjectRoleMapper.updateById(member); @@ -202,8 +196,21 @@ public class ProductMemberServiceImpl implements ProductMemberService { return product; } + private ProductDO validateProductEditable(Long productId) { + ProductDO product = validateProductExists(productId); + ObjectStatusModelDO statusModel = objectStatusModelMapper + .selectByObjectTypeAndStatusCodeEnabled(ProductObjectConstants.OBJECT_TYPE, product.getStatusCode()); + if (statusModel == null) { + throw exception(ErrorCodeConstants.PRODUCT_STATUS_MODEL_NOT_EXISTS_OR_DISABLED); + } + if (!Boolean.TRUE.equals(statusModel.getAllowEdit())) { + throw exception(ErrorCodeConstants.PRODUCT_STATUS_NOT_ALLOW_EDIT); + } + return product; + } + private UserObjectRoleDO validateMemberExists(Long productId, Long memberId) { - UserObjectRoleDO member = userObjectRoleMapper.selectByIdAndObject(memberId, PRODUCT_OBJECT_TYPE, productId); + UserObjectRoleDO member = userObjectRoleMapper.selectByIdAndObject(memberId, ProductObjectConstants.OBJECT_TYPE, productId); if (member == null) { throw exception(ErrorCodeConstants.PRODUCT_MEMBER_NOT_EXISTS); } @@ -212,7 +219,7 @@ public class ProductMemberServiceImpl implements ProductMemberService { private ObjectRoleRespDTO validateProductRole(Long roleId) { ObjectRoleRespDTO role = objectPermissionApi - .getObjectRoleById(roleId, ROLE_SCOPE_OBJECT, PRODUCT_OBJECT_TYPE) + .getObjectRoleById(roleId, ObjectRoleConstants.ROLE_SCOPE_OBJECT, ProductObjectConstants.OBJECT_TYPE) .getCheckedData(); if (role == null) { throw exception(ErrorCodeConstants.PRODUCT_MEMBER_ROLE_INVALID); @@ -262,7 +269,7 @@ public class ProductMemberServiceImpl implements ProductMemberService { private void transferPreviousManager(Long productId, Long previousManagerUserId, Long previousManagerRoleId, String reason) { UserObjectRoleDO existingMember = userObjectRoleMapper - .selectByObjectAndUserId(PRODUCT_OBJECT_TYPE, productId, previousManagerUserId); + .selectByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, productId, previousManagerUserId); UserObjectRoleDO before = existingMember == null ? null : cloneMember(existingMember); LocalDateTime now = LocalDateTime.now(); UserObjectRoleDO member; @@ -270,10 +277,10 @@ public class ProductMemberServiceImpl implements ProductMemberService { if (existingMember == null) { member = new UserObjectRoleDO(); member.setUserId(previousManagerUserId); - member.setObjectType(PRODUCT_OBJECT_TYPE); + member.setObjectType(ProductObjectConstants.OBJECT_TYPE); member.setObjectId(productId); member.setRoleId(previousManagerRoleId); - member.setStatus(MEMBER_STATUS_ACTIVE); + member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE); member.setJoinedTime(now); member.setLeftTime(null); member.setRemark(null); @@ -282,9 +289,9 @@ public class ProductMemberServiceImpl implements ProductMemberService { } else { member = existingMember; member.setRoleId(previousManagerRoleId); - member.setStatus(MEMBER_STATUS_ACTIVE); + member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE); member.setLeftTime(null); - if (!Objects.equals(before.getStatus(), MEMBER_STATUS_ACTIVE)) { + if (!Objects.equals(before.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) { member.setJoinedTime(now); actionType = ObjectActivityConstants.MEMBER_ACTION_ADD; } else { @@ -296,7 +303,7 @@ public class ProductMemberServiceImpl implements ProductMemberService { } private boolean isManagerRole(ObjectRoleRespDTO role) { - return Objects.equals(PRODUCT_MANAGER_ROLE_CODE, role.getCode()); + return Objects.equals(ProductObjectConstants.MANAGER_ROLE_CODE, role.getCode()); } private Map getRoleMap(Set roleIds) { @@ -304,7 +311,7 @@ public class ProductMemberServiceImpl implements ProductMemberService { return Collections.emptyMap(); } List roles = objectPermissionApi - .getObjectRoleList(roleIds, ROLE_SCOPE_OBJECT, PRODUCT_OBJECT_TYPE) + .getObjectRoleList(roleIds, ObjectRoleConstants.ROLE_SCOPE_OBJECT, ProductObjectConstants.OBJECT_TYPE) .getCheckedData(); if (roles == null || roles.isEmpty()) { return Collections.emptyMap(); @@ -340,7 +347,7 @@ public class ProductMemberServiceImpl implements ProductMemberService { return; } BizAuditLogDO auditLog = new BizAuditLogDO(); - auditLog.setBizType(PRODUCT_OBJECT_TYPE); + auditLog.setBizType(ProductObjectConstants.OBJECT_TYPE); auditLog.setBizId(productId); auditLog.setActionType(ObjectActivityConstants.PRODUCT_ACTION_CHANGE_MANAGER); auditLog.setFieldChanges(buildManagerFieldChanges(beforeManagerUserId, afterManagerUserId)); diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductService.java index 24747f5..0295918 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductService.java @@ -3,6 +3,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.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; import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductSaveReqVO; import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductStatusActionReqVO; @@ -61,6 +62,13 @@ public interface ProductService { */ PageResult getProductPage(ProductPageReqVO pageReqVO); + /** + * 获取产品入口页概览统计 + * + * @return 产品入口页概览统计 + */ + ProductOverviewSummaryRespVO getProductOverviewSummary(); + /** * 变更产品状态 * diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductServiceImpl.java index 3af5efd..95cc36a 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductServiceImpl.java @@ -6,18 +6,31 @@ import com.njcn.rdms.framework.common.util.json.JsonUtils; import com.njcn.rdms.framework.common.util.object.BeanUtils; 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.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.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; +import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductSaveReqVO; +import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductStatusActionReqVO; import com.njcn.rdms.module.project.controller.admin.product.vo.product.*; import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingBaseInfoUpdateReqVO; 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.product.ProductDO; import com.njcn.rdms.module.project.dal.dataobject.product.ProductStatusLogDO; +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.dal.mysql.product.ProductMapper; import com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementModuleMapper; import com.njcn.rdms.module.project.dal.mysql.product.ProductStatusLogMapper; +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.security.annotation.CheckObjectPermission; @@ -48,24 +61,6 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil @Service public class ProductServiceImpl implements ProductService { - private static final String PRODUCT_OBJECT_TYPE = "product"; - private static final String ROLE_SCOPE_OBJECT = PermissionScopeTypeEnum.OBJECT.getScopeType(); - private static final String PRODUCT_MANAGER_ROLE_CODE = "product_manager"; - - private static final String PRODUCT_ACTIVE_STATUS = "active"; - private static final String PRODUCT_PAUSED_STATUS = "paused"; - private static final String PRODUCT_ARCHIVED_STATUS = "archived"; - private static final String PRODUCT_ABANDONED_STATUS = "abandoned"; - - private static final Integer MEMBER_STATUS_ACTIVE = 0; - - private static final String PRODUCT_CODE_PREFIX = "CNPD"; - private static final String PRODUCT_QUERY_PERMISSION = "project:product:query"; - private static final String PRODUCT_UPDATE_PERMISSION = "project:product:update"; - private static final String PRODUCT_STATUS_PERMISSION = "project:product:status"; - private static final String PRODUCT_DELETE_PERMISSION = "project:product:delete"; - private static final String PRODUCT_DELETE_CONFIRM_TEXT = "DELETE"; - @Resource private ProductMapper productMapper; @Resource @@ -75,6 +70,8 @@ public class ProductServiceImpl implements ProductService { @Resource private ObjectStatusTransitionMapper objectStatusTransitionMapper; @Resource + private ObjectStatusModelMapper objectStatusModelMapper; + @Resource private UserObjectRoleMapper userObjectRoleMapper; @Resource private ObjectPermissionApi objectPermissionApi; @@ -92,7 +89,8 @@ public class ProductServiceImpl implements ProductService { ProductDO product = new ProductDO(); product.setCode(generateProductCode(createReqVO.getCode())); product.setDirectionCode(createReqVO.getDirectionCode()); - product.setStatusCode(PRODUCT_ACTIVE_STATUS); + String initialStatus = getInitialStatusCode(); + product.setStatusCode(initialStatus); product.setName(createReqVO.getName().trim()); product.setManagerUserId(createReqVO.getManagerUserId()); product.setDescription(normalizeNullableText(createReqVO.getDescription())); @@ -100,15 +98,15 @@ public class ProductServiceImpl implements ProductService { initManagerMemberRelation(product); initDefaultRequirementModule(product); - writeBizAuditLog(product, ObjectActivityConstants.PRODUCT_ACTION_CREATE, null, PRODUCT_ACTIVE_STATUS, + writeBizAuditLog(product, ObjectActivityConstants.PRODUCT_ACTION_CREATE, null, initialStatus, buildProductFieldChanges(null, product), null); return product.getId(); } @Override @Transactional(rollbackFor = Exception.class) - @CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#updateReqVO.id", - permission = PRODUCT_UPDATE_PERMISSION) + @CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#updateReqVO.id", + permission = ProductObjectConstants.PERMISSION_UPDATE) public void updateProduct(ProductSaveReqVO updateReqVO) { if (updateReqVO.getId() == null) { throw invalidParamException("产品编号不能为空"); @@ -121,28 +119,25 @@ public class ProductServiceImpl implements ProductService { @Override @Transactional(rollbackFor = Exception.class) - @CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId", - permission = PRODUCT_UPDATE_PERMISSION) + @CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#productId", + permission = ProductObjectConstants.PERMISSION_UPDATE) public void updateProductBaseInfo(Long productId, ProductSettingBaseInfoUpdateReqVO reqVO) { ProductDO product = validateProductExists(productId); applyProductBaseInfoUpdate(product, reqVO.getDirectionCode(), reqVO.getName(), reqVO.getDescription()); } @Override - @CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#id", - permission = PRODUCT_QUERY_PERMISSION) public ProductDO getProduct(Long id) { return validateProductExists(id); } @Override - @CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#id", memberOnly = true) public ProductContextRespVO getProductContext(Long id) { ProductDO product = validateProductExists(id); Long loginUserId = SecurityFrameworkUtils.getLoginUserId(); UserObjectRoleDO currentMember = userObjectRoleMapper - .selectActiveByObjectAndUserId(PRODUCT_OBJECT_TYPE, id, loginUserId); + .selectActiveByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, id, loginUserId); ProductContextRespVO respVO = new ProductContextRespVO(); respVO.setCurrentProduct(buildCurrentProduct(product)); if (currentMember == null) { @@ -152,7 +147,8 @@ public class ProductServiceImpl implements ProductService { } ObjectRolePermissionRespDTO permissionDetail = objectPermissionApi - .getObjectRolePermissionDetail(currentMember.getRoleId(), ROLE_SCOPE_OBJECT, PRODUCT_OBJECT_TYPE) + .getObjectRolePermissionDetail(currentMember.getRoleId(), ObjectRoleConstants.ROLE_SCOPE_OBJECT, + ProductObjectConstants.OBJECT_TYPE) .getCheckedData(); ObjectRoleRespDTO currentRole = permissionDetail == null ? null : permissionDetail.getCurrentRole(); List menus = permissionDetail == null || permissionDetail.getMenus() == null @@ -169,10 +165,18 @@ public class ProductServiceImpl implements ProductService { return productMapper.selectPage(pageReqVO); } + @Override + public ProductOverviewSummaryRespVO getProductOverviewSummary() { + ProductOverviewSummaryRespVO respVO = new ProductOverviewSummaryRespVO(); + respVO.setStatusCounts(buildProductStatusCounts(objectStatusModelMapper + .selectListByObjectType(ProductObjectConstants.OBJECT_TYPE), productMapper.selectStatusCountList())); + return respVO; + } + @Override @Transactional(rollbackFor = Exception.class) - @CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#reqVO.id", - permission = PRODUCT_STATUS_PERMISSION) + @CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#reqVO.id", + permission = ProductObjectConstants.PERMISSION_STATUS) public void changeProductStatus(ProductStatusActionReqVO reqVO) { ProductDO product = validateProductExists(reqVO.getId()); String actionCode = reqVO.getActionCode().trim(); @@ -195,8 +199,8 @@ public class ProductServiceImpl implements ProductService { @Override @Transactional(rollbackFor = Exception.class) - @CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#reqVO.id", - permission = PRODUCT_DELETE_PERMISSION) + @CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#reqVO.id", + permission = ProductObjectConstants.PERMISSION_DELETE) public void deleteProduct(ProductDeleteReqVO reqVO) { ProductDO product = validateProductExists(reqVO.getId()); validateDeleteConfirmText(reqVO.getConfirmText()); @@ -289,11 +293,11 @@ public class ProductServiceImpl implements ProductService { @VisibleForTesting void validateProductEditable(ProductDO product, String directionCode, String name) { - if (PRODUCT_ARCHIVED_STATUS.equals(product.getStatusCode()) - || PRODUCT_ABANDONED_STATUS.equals(product.getStatusCode())) { + ObjectStatusModelDO statusModel = validateEnabledStatusModel(product.getStatusCode()); + if (!Boolean.TRUE.equals(statusModel.getAllowEdit())) { throw exception(ErrorCodeConstants.PRODUCT_STATUS_NOT_ALLOW_EDIT); } - if (!PRODUCT_PAUSED_STATUS.equals(product.getStatusCode())) { + if (!ProductObjectConstants.STATUS_PAUSED.equals(product.getStatusCode())) { return; } if (!Objects.equals(product.getDirectionCode(), directionCode) @@ -305,7 +309,7 @@ public class ProductServiceImpl implements ProductService { @VisibleForTesting ObjectStatusTransitionDO validateProductTransition(String fromStatusCode, String actionCode) { ObjectStatusTransitionDO transition = objectStatusTransitionMapper - .selectByObjectTypeAndFromStatusAndAction(PRODUCT_OBJECT_TYPE, fromStatusCode, actionCode); + .selectByObjectTypeAndFromStatusAndAction(ProductObjectConstants.OBJECT_TYPE, fromStatusCode, actionCode); if (transition == null) { throw exception(ErrorCodeConstants.PRODUCT_STATUS_ACTION_NOT_ALLOWED, actionCode); } @@ -319,10 +323,30 @@ public class ProductServiceImpl implements ProductService { } } + @VisibleForTesting + ObjectStatusModelDO validateEnabledStatusModel(String statusCode) { + ObjectStatusModelDO statusModel = objectStatusModelMapper + .selectByObjectTypeAndStatusCodeEnabled(ProductObjectConstants.OBJECT_TYPE, statusCode); + if (statusModel == null) { + throw exception(ErrorCodeConstants.PRODUCT_STATUS_MODEL_NOT_EXISTS_OR_DISABLED); + } + return statusModel; + } + + @VisibleForTesting + String getInitialStatusCode() { + ObjectStatusModelDO statusModel = objectStatusModelMapper + .selectInitialByObjectTypeEnabled(ProductObjectConstants.OBJECT_TYPE); + if (statusModel == null || !StringUtils.hasText(statusModel.getStatusCode())) { + throw exception(ErrorCodeConstants.PRODUCT_STATUS_MODEL_NOT_EXISTS_OR_DISABLED); + } + return statusModel.getStatusCode(); + } + @VisibleForTesting void validateDeleteConfirmText(String confirmText) { String normalizedConfirmText = normalizeNullableText(confirmText); - if (!Objects.equals(PRODUCT_DELETE_CONFIRM_TEXT, normalizedConfirmText)) { + if (!Objects.equals(ProductObjectConstants.DELETE_CONFIRM_TEXT, normalizedConfirmText)) { throw exception(ErrorCodeConstants.PRODUCT_DELETE_CONFIRM_TEXT_INVALID); } } @@ -335,7 +359,7 @@ public class ProductServiceImpl implements ProductService { } String year = String.valueOf(LocalDate.now().getYear()); - String codePrefix = PRODUCT_CODE_PREFIX + year; + String codePrefix = ProductObjectConstants.CODE_PREFIX + year; int nextSequence = 1; for (ProductDO product : productMapper.selectListByCodePrefix(codePrefix)) { String existedCode = product.getCode(); @@ -366,18 +390,19 @@ public class ProductServiceImpl implements ProductService { private void initManagerMemberRelation(ProductDO product) { ObjectRoleRespDTO managerRole = objectPermissionApi - .getObjectRoleByCode(PRODUCT_MANAGER_ROLE_CODE, ROLE_SCOPE_OBJECT, PRODUCT_OBJECT_TYPE) + .getObjectRoleByCode(ProductObjectConstants.MANAGER_ROLE_CODE, ObjectRoleConstants.ROLE_SCOPE_OBJECT, + ProductObjectConstants.OBJECT_TYPE) .getCheckedData(); if (managerRole == null) { - throw invalidParamException("未找到产品经理对象角色配置:{}", PRODUCT_MANAGER_ROLE_CODE); + throw invalidParamException("未找到产品经理对象角色配置:{}", ProductObjectConstants.MANAGER_ROLE_CODE); } UserObjectRoleDO member = new UserObjectRoleDO(); member.setUserId(product.getManagerUserId()); - member.setObjectType(PRODUCT_OBJECT_TYPE); + member.setObjectType(ProductObjectConstants.OBJECT_TYPE); member.setObjectId(product.getId()); member.setRoleId(managerRole.getId()); - member.setStatus(MEMBER_STATUS_ACTIVE); + member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE); member.setJoinedTime(LocalDateTime.now()); member.setLeftTime(null); userObjectRoleMapper.insert(member); @@ -438,6 +463,35 @@ public class ProductServiceImpl implements ProductService { .collect(Collectors.toList()); } + private Map buildProductStatusCounts(List statusModels, + List> rows) { + Map statusCounts = new LinkedHashMap<>(); + if (statusModels == null || statusModels.isEmpty()) { + return statusCounts; + } + statusModels.stream() + .filter(statusModel -> Objects.equals(statusModel.getStatus(), 0)) + .filter(statusModel -> StringUtils.hasText(statusModel.getStatusCode())) + .sorted(Comparator.comparing(ObjectStatusModelDO::getSort, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(ObjectStatusModelDO::getStatusCode)) + .forEach(statusModel -> statusCounts.put(statusModel.getStatusCode(), 0L)); + if (rows == null || rows.isEmpty()) { + return statusCounts; + } + for (Map row : rows) { + Object statusValue = row.getOrDefault("statusCode", row.get("status_code")); + String statusCode = Objects.toString(statusValue, null); + if (!statusCounts.containsKey(statusCode)) { + continue; + } + Object countValue = row.getOrDefault("countValue", row.get("count_value")); + if (countValue instanceof Number number) { + statusCounts.put(statusCode, number.longValue()); + } + } + return statusCounts; + } + private void writeProductStatusLog(ProductDO product, String actionType, String fromStatus, String toStatus, String reason) { ProductStatusLogDO statusLog = new ProductStatusLogDO(); @@ -456,7 +510,7 @@ public class ProductServiceImpl implements ProductService { private void writeBizAuditLog(ProductDO product, String actionType, String fromStatus, String toStatus, String fieldChanges, String reason) { BizAuditLogDO auditLog = new BizAuditLogDO(); - auditLog.setBizType(PRODUCT_OBJECT_TYPE); + auditLog.setBizType(ProductObjectConstants.OBJECT_TYPE); auditLog.setBizId(product.getId()); auditLog.setActionType(actionType); auditLog.setFromStatus(fromStatus); @@ -481,7 +535,7 @@ public class ProductServiceImpl implements ProductService { private void writeManagerInitAuditLog(Long productId, Long managerUserId) { BizAuditLogDO auditLog = new BizAuditLogDO(); - auditLog.setBizType(PRODUCT_OBJECT_TYPE); + auditLog.setBizType(ProductObjectConstants.OBJECT_TYPE); auditLog.setBizId(productId); auditLog.setActionType(ObjectActivityConstants.PRODUCT_ACTION_CHANGE_MANAGER); auditLog.setFieldChanges(buildManagerFieldChanges(null, managerUserId)); diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductSettingServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductSettingServiceImpl.java index 5324cb3..6162acd 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductSettingServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductSettingServiceImpl.java @@ -2,7 +2,6 @@ package com.njcn.rdms.module.project.service.product; import com.njcn.rdms.framework.common.pojo.CommonResult; import com.njcn.rdms.framework.common.pojo.PageResult; -import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission; import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityPageReqVO; import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityRespVO; import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityTimelinePageReqVO; @@ -23,9 +22,6 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil @Service public class ProductSettingServiceImpl implements ProductSettingService { - private static final String PRODUCT_OBJECT_TYPE = "product"; - private static final String PRODUCT_QUERY_PERMISSION = "project:product:query"; - @Resource private ProductMapper productMapper; @Resource @@ -38,8 +34,6 @@ public class ProductSettingServiceImpl implements ProductSettingService { private ProductActivityTimelineQueryService productActivityTimelineQueryService; @Override - @CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId", - permission = PRODUCT_QUERY_PERMISSION) public ProductSettingRespVO getProductSettings(Long productId) { ProductDO product = validateProductExists(productId); ProductSettingRespVO respVO = new ProductSettingRespVO(); @@ -49,16 +43,12 @@ public class ProductSettingServiceImpl implements ProductSettingService { } @Override - @CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId", - permission = PRODUCT_QUERY_PERMISSION) public PageResult getProductActivities(Long productId, ProductActivityPageReqVO reqVO) { validateProductExists(productId); return productActivityQueryService.getProductActivities(productId, reqVO); } @Override - @CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId", - permission = PRODUCT_QUERY_PERMISSION) public PageResult getProductActivityTimelinePage( Long productId, ProductActivityTimelinePageReqVO reqVO) { validateProductExists(productId); diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductStatusViewService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductStatusViewService.java index d5bd432..53f243e 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductStatusViewService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductStatusViewService.java @@ -1,5 +1,6 @@ package com.njcn.rdms.module.project.service.product; +import com.njcn.rdms.module.project.constant.ProductObjectConstants; import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingActionRespVO; import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingLifecycleRespVO; import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO; @@ -18,8 +19,6 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil @Service public class ProductStatusViewService { - private static final String PRODUCT_OBJECT_TYPE = "product"; - @Resource private ObjectStatusModelMapper objectStatusModelMapper; @Resource @@ -27,7 +26,7 @@ public class ProductStatusViewService { public ProductSettingLifecycleRespVO getLifecycle(String statusCode, String lastStatusReason) { ObjectStatusModelDO statusModel = objectStatusModelMapper - .selectByObjectTypeAndStatusCodeEnabled(PRODUCT_OBJECT_TYPE, statusCode); + .selectByObjectTypeAndStatusCodeEnabled(ProductObjectConstants.OBJECT_TYPE, statusCode); if (statusModel == null) { throw exception(ErrorCodeConstants.PRODUCT_STATUS_MODEL_NOT_EXISTS_OR_DISABLED); } @@ -44,7 +43,7 @@ public class ProductStatusViewService { private List buildAvailableActions(String statusCode) { List transitions = objectStatusTransitionMapper - .selectListByObjectTypeAndFromStatus(PRODUCT_OBJECT_TYPE, statusCode); + .selectListByObjectTypeAndFromStatus(ProductObjectConstants.OBJECT_TYPE, statusCode); if (transitions == null || transitions.isEmpty()) { return Collections.emptyList(); } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectMemberService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectMemberService.java new file mode 100644 index 0000000..e5184cc --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectMemberService.java @@ -0,0 +1,23 @@ +package com.njcn.rdms.module.project.service.project; + +import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberInactiveReqVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberRespVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberSaveReqVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberUpdateReqVO; + +import java.util.List; + +/** + * 项目成员 Service 接口 + */ +public interface ProjectMemberService { + + List getProjectMemberList(Long projectId); + + Long createProjectMember(Long projectId, ProjectMemberSaveReqVO reqVO); + + void updateProjectMember(Long projectId, Long memberId, ProjectMemberUpdateReqVO reqVO); + + void inactiveProjectMember(Long projectId, Long memberId, ProjectMemberInactiveReqVO reqVO); + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectMemberServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectMemberServiceImpl.java new file mode 100644 index 0000000..c268bce --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectMemberServiceImpl.java @@ -0,0 +1,456 @@ +package com.njcn.rdms.module.project.service.project; + +import com.njcn.rdms.framework.common.util.json.JsonUtils; +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.ProjectExecutionConstants; +import com.njcn.rdms.module.project.constant.ProjectObjectConstants; +import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberInactiveReqVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberRespVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberSaveReqVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberUpdateReqVO; +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.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.ProjectExecutionMapper; +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.permission.ObjectPermissionApi; +import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO; +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.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.Collections; +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; + +import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception; + +/** + * 项目成员 Service 实现类 + */ +@Service +public class ProjectMemberServiceImpl implements ProjectMemberService { + + @Resource + private ProjectMapper projectMapper; + @Resource + private UserObjectRoleMapper userObjectRoleMapper; + @Resource + private ObjectPermissionApi objectPermissionApi; + @Resource + private BizAuditLogMapper bizAuditLogMapper; + @Resource + private ObjectStatusModelMapper objectStatusModelMapper; + @Resource + private AdminUserApi adminUserApi; + @Resource + private ProjectExecutionMapper projectExecutionMapper; + + @Override + public List getProjectMemberList(Long projectId) { + ProjectDO project = validateProjectExists(projectId); + List members = userObjectRoleMapper.selectListByObject(ProjectObjectConstants.OBJECT_TYPE, projectId); + Map roleMap = getRoleMap(members.stream().map(UserObjectRoleDO::getRoleId).collect(Collectors.toSet())); + Map userMap = getUserMap(members.stream().map(UserObjectRoleDO::getUserId).collect(Collectors.toSet())); + return members.stream().map(member -> { + ProjectMemberRespVO respVO = new ProjectMemberRespVO(); + respVO.setId(member.getId()); + respVO.setUserId(member.getUserId()); + AdminUserRespDTO user = userMap.get(member.getUserId()); + respVO.setUserNickname(user == null ? null : user.getNickname()); + respVO.setRoleId(member.getRoleId()); + ObjectRoleRespDTO role = roleMap.get(member.getRoleId()); + respVO.setRoleName(role == null ? null : role.getName()); + respVO.setRoleCode(role == null ? null : role.getCode()); + respVO.setManagerFlag(Objects.equals(member.getUserId(), project.getManagerUserId()) + && Objects.equals(member.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)); + respVO.setStatus(member.getStatus()); + respVO.setJoinedTime(member.getJoinedTime()); + respVO.setLeftTime(member.getLeftTime()); + respVO.setRemark(member.getRemark()); + return respVO; + }).collect(Collectors.toList()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", + permission = ProjectObjectConstants.PERMISSION_MEMBER) + public Long createProjectMember(Long projectId, ProjectMemberSaveReqVO reqVO) { + ProjectDO project = validateProjectEditable(projectId); + validateMemberUser(reqVO.getUserId()); + ObjectRoleRespDTO targetRole = validateProjectRole(reqVO.getRoleId()); + UserObjectRoleDO existingMember = userObjectRoleMapper + .selectByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, reqVO.getUserId()); + if (existingMember != null && Objects.equals(existingMember.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) { + throw exception(ErrorCodeConstants.PROJECT_MEMBER_ALREADY_EXISTS); + } + + UserObjectRoleDO before = existingMember == null ? null : cloneMember(existingMember); + UserObjectRoleDO member = existingMember == null ? new UserObjectRoleDO() : existingMember; + member.setUserId(reqVO.getUserId()); + member.setObjectType(ProjectObjectConstants.OBJECT_TYPE); + member.setObjectId(projectId); + member.setRoleId(targetRole.getId()); + member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE); + member.setJoinedTime(LocalDateTime.now()); + member.setLeftTime(null); + member.setRemark(normalizeNullableText(reqVO.getRemark())); + if (existingMember == null) { + userObjectRoleMapper.insert(member); + } else { + userObjectRoleMapper.updateById(member); + } + writeMemberAuditLog(member, ObjectActivityConstants.MEMBER_ACTION_ADD, before, member, null); + + if (isManagerRole(targetRole)) { + transferManager(project, member, reqVO.getPreviousManagerUserId(), reqVO.getPreviousManagerRoleId(), null); + } + return member.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", + permission = ProjectObjectConstants.PERMISSION_MEMBER) + public void updateProjectMember(Long projectId, Long memberId, ProjectMemberUpdateReqVO reqVO) { + ProjectDO project = validateProjectEditable(projectId); + UserObjectRoleDO member = validateMemberExists(projectId, memberId); + if (!Objects.equals(member.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) { + throw exception(ErrorCodeConstants.PROJECT_MEMBER_NOT_ACTIVE); + } + ObjectRoleRespDTO targetRole = validateProjectRole(reqVO.getRoleId()); + UserObjectRoleDO before = cloneMember(member); + member.setRemark(normalizeNullableText(reqVO.getRemark())); + + if (isManagerRole(targetRole)) { + // 项目经理交接只切换负责人并调整原经理角色,不再把原经理自动移出项目团队。 + member.setRoleId(targetRole.getId()); + userObjectRoleMapper.updateById(member); + transferManager(project, member, reqVO.getPreviousManagerUserId(), + reqVO.getPreviousManagerRoleId(), normalizeNullableText(reqVO.getReason())); + } else { + if (Objects.equals(member.getUserId(), project.getManagerUserId())) { + throw exception(ErrorCodeConstants.PROJECT_MANAGER_MEMBER_NOT_ALLOW_DOWNGRADE); + } + member.setRoleId(targetRole.getId()); + userObjectRoleMapper.updateById(member); + } + + writeMemberAuditLog(member, ObjectActivityConstants.MEMBER_ACTION_UPDATE, + before, member, normalizeNullableText(reqVO.getReason())); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", + permission = ProjectObjectConstants.PERMISSION_MEMBER) + public void inactiveProjectMember(Long projectId, Long memberId, ProjectMemberInactiveReqVO reqVO) { + ProjectDO project = validateProjectEditable(projectId); + UserObjectRoleDO member = validateMemberExists(projectId, memberId); + if (!Objects.equals(member.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) { + throw exception(ErrorCodeConstants.PROJECT_MEMBER_NOT_ACTIVE); + } + if (Objects.equals(member.getUserId(), project.getManagerUserId())) { + throw exception(ErrorCodeConstants.PROJECT_MANAGER_MEMBER_NOT_ALLOW_REMOVE); + } + validateNoOpenOwnedExecutions(projectId, member.getUserId()); + + UserObjectRoleDO before = cloneMember(member); + member.setStatus(ObjectRoleConstants.MEMBER_STATUS_INACTIVE); + member.setLeftTime(LocalDateTime.now()); + userObjectRoleMapper.updateById(member); + writeMemberAuditLog(member, ObjectActivityConstants.MEMBER_ACTION_REMOVE, before, member, + normalizeNullableText(reqVO.getReason())); + } + + private ProjectDO validateProjectExists(Long projectId) { + if (projectId == null) { + throw exception(ErrorCodeConstants.PROJECT_NOT_EXISTS); + } + ProjectDO project = projectMapper.selectById(projectId); + if (project == null) { + throw exception(ErrorCodeConstants.PROJECT_NOT_EXISTS); + } + return project; + } + + private ProjectDO validateProjectEditable(Long projectId) { + ProjectDO project = validateProjectExists(projectId); + // 项目成员维护当前完全跟随状态模型 allow_edit,不再按具体状态编码写死可编辑范围。 + ObjectStatusModelDO statusModel = objectStatusModelMapper + .selectByObjectTypeAndStatusCodeEnabled(ProjectObjectConstants.OBJECT_TYPE, project.getStatusCode()); + if (statusModel == null) { + throw exception(ErrorCodeConstants.PROJECT_STATUS_MODEL_NOT_EXISTS_OR_DISABLED); + } + if (!Boolean.TRUE.equals(statusModel.getAllowEdit())) { + throw exception(ErrorCodeConstants.PROJECT_STATUS_NOT_ALLOW_EDIT); + } + return project; + } + + private void validateMemberUser(Long userId) { + try { + Boolean valid = adminUserApi.validateUserList(List.of(userId)).getCheckedData(); + if (Boolean.TRUE.equals(valid)) { + return; + } + } catch (RuntimeException ex) { + throw exception(ErrorCodeConstants.PROJECT_MEMBER_USER_INVALID); + } + throw exception(ErrorCodeConstants.PROJECT_MEMBER_USER_INVALID); + } + + private UserObjectRoleDO validateMemberExists(Long projectId, Long memberId) { + UserObjectRoleDO member = userObjectRoleMapper.selectByIdAndObject(memberId, ProjectObjectConstants.OBJECT_TYPE, projectId); + if (member == null) { + throw exception(ErrorCodeConstants.PROJECT_MEMBER_NOT_EXISTS); + } + return member; + } + + private void validateNoOpenOwnedExecutions(Long projectId, Long userId) { + Integer count = projectExecutionMapper.countNonTerminalByProjectIdAndOwnerId(projectId, userId, + getExecutionTerminalStatusCodes()); + if (count != null && count > 0) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_OWNER_HANDOFF_REQUIRED); + } + } + + private List getExecutionTerminalStatusCodes() { + List statusModels = objectStatusModelMapper + .selectListByObjectType(ProjectExecutionConstants.OBJECT_TYPE); + if (statusModels == null || statusModels.isEmpty()) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_MODEL_NOT_EXISTS_OR_DISABLED); + } + List terminalStatusCodes = statusModels.stream() + .filter(statusModel -> Boolean.TRUE.equals(statusModel.getTerminalFlag())) + .map(ObjectStatusModelDO::getStatusCode) + .filter(StringUtils::hasText) + .toList(); + if (terminalStatusCodes.isEmpty()) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_MODEL_NOT_EXISTS_OR_DISABLED); + } + return terminalStatusCodes; + } + + private ObjectRoleRespDTO validateProjectRole(Long roleId) { + ObjectRoleRespDTO role = objectPermissionApi + .getObjectRoleById(roleId, ObjectRoleConstants.ROLE_SCOPE_OBJECT, ProjectObjectConstants.OBJECT_TYPE) + .getCheckedData(); + if (role == null) { + throw exception(ErrorCodeConstants.PROJECT_MEMBER_ROLE_INVALID); + } + return role; + } + + private void transferManager(ProjectDO project, + UserObjectRoleDO targetManagerMember, + Long previousManagerUserId, + Long previousManagerRoleId, + String reason) { + Long currentManagerUserId = project.getManagerUserId(); + Long targetManagerUserId = targetManagerMember.getUserId(); + if (Objects.equals(currentManagerUserId, targetManagerUserId)) { + project.setManagerUserId(targetManagerUserId); + projectMapper.updateById(project); + return; + } + + ObjectRoleRespDTO previousManagerRole = validatePreviousManagerTransfer(currentManagerUserId, + previousManagerUserId, previousManagerRoleId); + transferPreviousManager(project.getId(), previousManagerUserId, previousManagerRole.getId(), reason); + + project.setManagerUserId(targetManagerUserId); + projectMapper.updateById(project); + writeManagerChangeAuditLog(project.getId(), currentManagerUserId, targetManagerUserId, reason); + } + + private ObjectRoleRespDTO validatePreviousManagerTransfer(Long currentManagerUserId, + Long previousManagerUserId, + Long previousManagerRoleId) { + if (currentManagerUserId == null + || previousManagerUserId == null + || previousManagerRoleId == null) { + throw exception(ErrorCodeConstants.PROJECT_MANAGER_TRANSFER_INFO_REQUIRED); + } + if (!Objects.equals(currentManagerUserId, previousManagerUserId)) { + throw exception(ErrorCodeConstants.PROJECT_MANAGER_TRANSFER_SOURCE_INVALID); + } + ObjectRoleRespDTO previousManagerRole = validateProjectRole(previousManagerRoleId); + if (isManagerRole(previousManagerRole)) { + throw exception(ErrorCodeConstants.PROJECT_MANAGER_TRANSFER_ROLE_INVALID); + } + return previousManagerRole; + } + + private void transferPreviousManager(Long projectId, Long previousManagerUserId, Long previousManagerRoleId, String reason) { + UserObjectRoleDO existingMember = userObjectRoleMapper + .selectByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, previousManagerUserId); + UserObjectRoleDO before = existingMember == null ? null : cloneMember(existingMember); + LocalDateTime now = LocalDateTime.now(); + UserObjectRoleDO member; + String actionType; + if (existingMember == null) { + member = new UserObjectRoleDO(); + member.setUserId(previousManagerUserId); + member.setObjectType(ProjectObjectConstants.OBJECT_TYPE); + member.setObjectId(projectId); + member.setRoleId(previousManagerRoleId); + member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE); + member.setJoinedTime(now); + member.setLeftTime(null); + member.setRemark(null); + userObjectRoleMapper.insert(member); + actionType = ObjectActivityConstants.MEMBER_ACTION_ADD; + } else { + member = existingMember; + member.setRoleId(previousManagerRoleId); + member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE); + member.setLeftTime(null); + if (!Objects.equals(before.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) { + member.setJoinedTime(now); + actionType = ObjectActivityConstants.MEMBER_ACTION_ADD; + } else { + actionType = ObjectActivityConstants.MEMBER_ACTION_UPDATE; + } + userObjectRoleMapper.updateById(member); + } + writeMemberAuditLog(member, actionType, before, member, reason); + } + + private boolean isManagerRole(ObjectRoleRespDTO role) { + return Objects.equals(ProjectObjectConstants.MANAGER_ROLE_CODE, role.getCode()); + } + + private Map getRoleMap(Set roleIds) { + if (roleIds.isEmpty()) { + return Collections.emptyMap(); + } + List roles = objectPermissionApi + .getObjectRoleList(roleIds, ObjectRoleConstants.ROLE_SCOPE_OBJECT, ProjectObjectConstants.OBJECT_TYPE) + .getCheckedData(); + if (roles == null || roles.isEmpty()) { + return Collections.emptyMap(); + } + return roles.stream().collect(Collectors.toMap(ObjectRoleRespDTO::getId, Function.identity())); + } + + private Map getUserMap(Set userIds) { + if (userIds.isEmpty()) { + return Collections.emptyMap(); + } + return adminUserApi.getUserMap(userIds); + } + + private void writeMemberAuditLog(UserObjectRoleDO member, + String actionType, + UserObjectRoleDO before, + UserObjectRoleDO after, + String reason) { + BizAuditLogDO auditLog = new BizAuditLogDO(); + auditLog.setBizType(ObjectActivityConstants.MEMBER_BIZ_TYPE); + auditLog.setBizId(member.getId()); + auditLog.setActionType(actionType); + auditLog.setFieldChanges(buildMemberFieldChanges(before, after)); + auditLog.setReason(reason); + auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId()); + auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname())); + bizAuditLogMapper.insert(auditLog); + } + + private void writeManagerChangeAuditLog(Long projectId, Long beforeManagerUserId, Long afterManagerUserId, String reason) { + if (Objects.equals(beforeManagerUserId, afterManagerUserId)) { + return; + } + BizAuditLogDO auditLog = new BizAuditLogDO(); + auditLog.setBizType(ProjectObjectConstants.OBJECT_TYPE); + auditLog.setBizId(projectId); + auditLog.setActionType(ObjectActivityConstants.PROJECT_ACTION_CHANGE_MANAGER); + auditLog.setFieldChanges(buildManagerFieldChanges(beforeManagerUserId, afterManagerUserId)); + auditLog.setReason(reason); + auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId()); + auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname())); + bizAuditLogMapper.insert(auditLog); + } + + private String buildMemberFieldChanges(UserObjectRoleDO before, UserObjectRoleDO after) { + Map fieldChanges = new LinkedHashMap<>(); + appendFieldChange(fieldChanges, "userId", valueOf(before, UserObjectRoleDO::getUserId), + valueOf(after, UserObjectRoleDO::getUserId)); + appendFieldChange(fieldChanges, "roleId", valueOf(before, UserObjectRoleDO::getRoleId), + valueOf(after, UserObjectRoleDO::getRoleId)); + appendFieldChange(fieldChanges, "status", valueOf(before, UserObjectRoleDO::getStatus), + valueOf(after, UserObjectRoleDO::getStatus)); + appendFieldChange(fieldChanges, "joinedTime", valueOf(before, UserObjectRoleDO::getJoinedTime), + valueOf(after, UserObjectRoleDO::getJoinedTime)); + appendFieldChange(fieldChanges, "leftTime", valueOf(before, UserObjectRoleDO::getLeftTime), + valueOf(after, UserObjectRoleDO::getLeftTime)); + appendFieldChange(fieldChanges, "remark", valueOf(before, UserObjectRoleDO::getRemark), + valueOf(after, UserObjectRoleDO::getRemark)); + return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges); + } + + private String buildManagerFieldChanges(Long beforeManagerUserId, Long afterManagerUserId) { + Map fieldChanges = new LinkedHashMap<>(); + appendFieldChange(fieldChanges, "managerUserId", beforeManagerUserId, afterManagerUserId); + return JsonUtils.toJsonString(fieldChanges); + } + + private UserObjectRoleDO cloneMember(UserObjectRoleDO source) { + UserObjectRoleDO clone = new UserObjectRoleDO(); + clone.setId(source.getId()); + clone.setUserId(source.getUserId()); + clone.setObjectType(source.getObjectType()); + clone.setObjectId(source.getObjectId()); + clone.setRoleId(source.getRoleId()); + clone.setStatus(source.getStatus()); + clone.setJoinedTime(source.getJoinedTime()); + clone.setLeftTime(source.getLeftTime()); + clone.setRemark(source.getRemark()); + return clone; + } + + private T valueOf(UserObjectRoleDO member, Function getter) { + return member == null ? null : getter.apply(member); + } + + private void appendFieldChange(Map fieldChanges, String fieldName, Object before, Object after) { + if (Objects.equals(before, after)) { + return; + } + Map value = new LinkedHashMap<>(); + value.put("before", before); + value.put("after", after); + fieldChanges.put(fieldName, value); + } + + private String normalizeNullableText(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + return value.trim(); + } + + private String defaultText(String value) { + return StringUtils.hasText(value) ? value : ""; + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectService.java new file mode 100644 index 0000000..15a1925 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectService.java @@ -0,0 +1,38 @@ +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.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; +import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectRespVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectSaveReqVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectStatusActionReqVO; +import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO; + +/** + * 项目 Service 接口 + */ +public interface ProjectService { + + Long createProject(ProjectSaveReqVO createReqVO); + + void updateProject(ProjectSaveReqVO updateReqVO); + + ProjectDO getProject(Long id); + + ProjectRespVO getProjectDetail(Long id); + + ProjectContextRespVO getProjectContext(Long id); + + PageResult getProjectPage(ProjectPageReqVO pageReqVO); + + ProjectOverviewSummaryRespVO getProjectOverviewSummary(); + + void changeProjectStatus(ProjectStatusActionReqVO reqVO); + + void deleteProject(ProjectDeleteReqVO reqVO); + + void autoStartProjectIfPending(Long projectId, String triggerAction); + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectServiceImpl.java new file mode 100644 index 0000000..551e98f --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectServiceImpl.java @@ -0,0 +1,930 @@ +package com.njcn.rdms.module.project.service.project; + +import com.google.common.annotations.VisibleForTesting; +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.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.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.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; +import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectRespVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectSaveReqVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectStatusActionReqVO; +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.product.ProductDO; +import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO; +import com.njcn.rdms.module.project.dal.dataobject.project.ProjectStatusLogDO; +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.dal.mysql.product.ProductMapper; +import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper; +import com.njcn.rdms.module.project.dal.mysql.project.ProjectStatusLogMapper; +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.permission.ObjectPermissionApi; +import com.njcn.rdms.module.system.api.permission.dto.ObjectMenuRespDTO; +import com.njcn.rdms.module.system.api.permission.dto.ObjectRolePermissionRespDTO; +import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO; +import com.njcn.rdms.module.system.api.user.AdminUserApi; +import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO; +import com.njcn.rdms.module.system.enums.DictTypeConstants; +import com.njcn.rdms.module.system.enums.permission.MenuTypeEnum; +import jakarta.annotation.Resource; +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.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception; +import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; + +/** + * 项目 Service 实现类 + */ +@Service +public class ProjectServiceImpl implements ProjectService { + + @Resource + private ProjectMapper projectMapper; + @Resource + private ProjectStatusLogMapper projectStatusLogMapper; + @Resource + private ProductMapper productMapper; + @Resource + private BizAuditLogMapper bizAuditLogMapper; + @Resource + private ObjectStatusTransitionMapper objectStatusTransitionMapper; + @Resource + private ObjectStatusModelMapper objectStatusModelMapper; + @Resource + private UserObjectRoleMapper userObjectRoleMapper; + @Resource + private ProjectStatusViewService projectStatusViewService; + @Resource + private ObjectPermissionApi objectPermissionApi; + @Resource + private AdminUserApi adminUserApi; + @Resource + private DictDataApi dictDataApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createProject(ProjectSaveReqVO createReqVO) { + validateCreateReqVO(createReqVO); + validateProjectType(createReqVO.getProjectType()); + String finalDirectionCode = resolveProjectDirectionCode(createReqVO.getProductId(), createReqVO.getDirectionCode()); + validateMainlineProjectUnique(null, createReqVO.getProductId(), createReqVO.getProjectType()); + validateManagerUser(createReqVO.getManagerUserId()); + + ProjectDO project = new ProjectDO(); + project.setProjectCode(generateProjectCode(createReqVO.getProjectCode())); + project.setProjectName(createReqVO.getProjectName().trim()); + project.setProjectType(createReqVO.getProjectType().trim()); + project.setDirectionCode(finalDirectionCode); + project.setProjectSetId(createReqVO.getProjectSetId()); + project.setProductId(createReqVO.getProductId()); + project.setProductVersionId(createReqVO.getProductVersionId()); + project.setManagerUserId(createReqVO.getManagerUserId()); + String initialStatus = getInitialStatusCode(); + project.setStatusCode(initialStatus); + project.setPlannedStartDate(createReqVO.getPlannedStartDate()); + project.setPlannedEndDate(createReqVO.getPlannedEndDate()); + project.setActualStartDate(createReqVO.getActualStartDate()); + project.setActualEndDate(createReqVO.getActualEndDate()); + project.setProgressRate(BigDecimal.ZERO); + project.setProjectDesc(normalizeNullableText(createReqVO.getProjectDesc())); + projectMapper.insert(project); + + initManagerMemberRelation(project); + writeBizAuditLog(project, ObjectActivityConstants.PROJECT_ACTION_CREATE, null, initialStatus, + buildProjectFieldChanges(null, project), null); + return project.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, + objectId = "#updateReqVO.id", permission = ProjectObjectConstants.PERMISSION_UPDATE) + public void updateProject(ProjectSaveReqVO updateReqVO) { + if (updateReqVO.getId() == null) { + throw invalidParamException("项目编号不能为空"); + } + ProjectDO project = validateProjectExists(updateReqVO.getId()); + validateProjectEditable(project); + validateProjectCodeUnchanged(project, updateReqVO.getProjectCode()); + validateProductUnchanged(project, updateReqVO.getProductId()); + validateProjectType(updateReqVO.getProjectType()); + validateManagerUser(updateReqVO.getManagerUserId()); + validateProjectNameUnique(project.getId(), project.getProductId(), updateReqVO.getProjectName()); + validateMainlineProjectUnique(project.getId(), project.getProductId(), updateReqVO.getProjectType()); + validateDateRange(updateReqVO.getPlannedStartDate(), updateReqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期"); + validateDateRange(updateReqVO.getActualStartDate(), updateReqVO.getActualEndDate(), "实际结束日期不能早于实际开始日期"); + String finalDirectionCode = resolveProjectDirectionCode(project.getProductId(), updateReqVO.getDirectionCode()); + + ProjectDO before = cloneProject(project); + project.setProjectName(updateReqVO.getProjectName().trim()); + project.setProjectType(updateReqVO.getProjectType().trim()); + project.setDirectionCode(finalDirectionCode); + project.setProjectSetId(updateReqVO.getProjectSetId()); + project.setProductVersionId(updateReqVO.getProductVersionId()); + project.setPlannedStartDate(updateReqVO.getPlannedStartDate()); + project.setPlannedEndDate(updateReqVO.getPlannedEndDate()); + project.setActualStartDate(updateReqVO.getActualStartDate()); + project.setActualEndDate(updateReqVO.getActualEndDate()); + project.setProjectDesc(normalizeNullableText(updateReqVO.getProjectDesc())); + if (!Objects.equals(project.getManagerUserId(), updateReqVO.getManagerUserId())) { + changeManager(project, updateReqVO.getManagerUserId(), null); + } + project.setManagerUserId(updateReqVO.getManagerUserId()); + projectMapper.updateById(project); + + writeBizAuditLog(project, ObjectActivityConstants.PROJECT_ACTION_UPDATE, before.getStatusCode(), project.getStatusCode(), + buildProjectFieldChanges(before, project), null); + } + + @Override + public ProjectDO getProject(Long id) { + return validateProjectExists(id); + } + + @Override + public ProjectRespVO getProjectDetail(Long id) { + ProjectDO project = validateProjectExists(id); + ProjectRespVO respVO = BeanUtils.toBean(project, ProjectRespVO.class); + respVO.setProductName(getProductName(project.getProductId())); + respVO.setManagerUserNickname(getManagerNickname(project.getManagerUserId())); + return respVO; + } + + @Override + public ProjectContextRespVO getProjectContext(Long id) { + ProjectDO project = validateProjectExists(id); + Long loginUserId = SecurityFrameworkUtils.getLoginUserId(); + UserObjectRoleDO currentMember = userObjectRoleMapper + .selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, id, loginUserId); + if (currentMember != null) { + return buildProjectContext(project, currentMember.getRoleId(), false, null); + } + + ObjectRoleRespDTO visitorRole = objectPermissionApi + .getObjectRoleByCode(ProjectObjectConstants.VISITOR_ROLE_CODE, ObjectRoleConstants.ROLE_SCOPE_OBJECT, + ProjectObjectConstants.OBJECT_TYPE) + .getCheckedData(); + if (visitorRole == null || visitorRole.getId() == null) { + return buildProjectContextWithoutMenus(project, true); + } + return buildProjectContext(project, visitorRole.getId(), true, visitorRole); + } + + private ProjectContextRespVO buildProjectContext(ProjectDO project, Long roleId, boolean guestFlag, + ObjectRoleRespDTO fallbackRole) { + ProjectContextRespVO respVO = new ProjectContextRespVO(); + respVO.setCurrentProject(buildCurrentProject(project)); + + ObjectRolePermissionRespDTO permissionDetail = objectPermissionApi + .getObjectRolePermissionDetail(roleId, ObjectRoleConstants.ROLE_SCOPE_OBJECT, + ProjectObjectConstants.OBJECT_TYPE) + .getCheckedData(); + ObjectRoleRespDTO currentRole = permissionDetail == null || permissionDetail.getCurrentRole() == null + ? fallbackRole + : permissionDetail.getCurrentRole(); + List menus = permissionDetail == null || permissionDetail.getMenus() == null + ? Collections.emptyList() + : permissionDetail.getMenus(); + respVO.setCurrentRole(buildCurrentRole(roleId, currentRole, guestFlag)); + respVO.setNavs(buildContextNavs(menus)); + respVO.setButtons(buildContextButtons(menus)); + return respVO; + } + + @Override + public PageResult getProjectPage(ProjectPageReqVO pageReqVO) { + return projectMapper.selectPage(pageReqVO); + } + + @Override + public ProjectOverviewSummaryRespVO getProjectOverviewSummary() { + ProjectOverviewSummaryRespVO respVO = new ProjectOverviewSummaryRespVO(); + respVO.setStatusCounts(buildProjectStatusCounts(objectStatusModelMapper + .selectListByObjectType(ProjectObjectConstants.OBJECT_TYPE), projectMapper.selectStatusCountList())); + return respVO; + } + + private String getProductName(Long productId) { + if (productId == null) { + return null; + } + ProductDO product = productMapper.selectById(productId); + return product == null ? null : product.getName(); + } + + private String getManagerNickname(Long managerUserId) { + if (managerUserId == null) { + return null; + } + CommonResult result = adminUserApi.getUser(managerUserId); + AdminUserRespDTO user = result == null ? null : result.getCheckedData(); + return user == null ? null : user.getNickname(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, + objectId = "#reqVO.id", permission = ProjectObjectConstants.PERMISSION_STATUS) + public void changeProjectStatus(ProjectStatusActionReqVO reqVO) { + ProjectDO project = validateProjectExists(reqVO.getId()); + String actionCode = reqVO.getActionCode().trim(); + if (ObjectActivityConstants.PROJECT_ACTION_AUTO_START.equals(actionCode)) { + throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_NOT_ALLOWED, actionCode); + } + changeStatus(project, actionCode, normalizeNullableText(reqVO.getReason())); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, + objectId = "#reqVO.id", permission = ProjectObjectConstants.PERMISSION_DELETE) + public void deleteProject(ProjectDeleteReqVO reqVO) { + ProjectDO project = validateProjectExists(reqVO.getId()); + validateDeleteConfirmText(reqVO.getConfirmText()); + if (!Objects.equals(project.getProjectName(), reqVO.getProjectName().trim())) { + throw exception(ErrorCodeConstants.PROJECT_DELETE_NAME_MISMATCH); + } + if (!isInitialStatus(project.getStatusCode())) { + throw exception(ErrorCodeConstants.PROJECT_NOT_ALLOW_DELETE); + } + + String reason = reqVO.getReason().trim(); + String fromStatus = project.getStatusCode(); + int deleteCount = projectMapper.deleteByIdAndStatus(reqVO.getId(), fromStatus); + if (deleteCount != 1) { + throw exception(ErrorCodeConstants.PROJECT_STATUS_CONCURRENT_MODIFIED); + } + writeProjectStatusLog(project, ObjectActivityConstants.PROJECT_ACTION_DELETE, fromStatus, null, reason); + writeBizAuditLog(project, ObjectActivityConstants.PROJECT_ACTION_DELETE, fromStatus, null, null, reason); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void autoStartProjectIfPending(Long projectId, String triggerAction) { + // auto_start 只允许由后端业务动作内部触发,前端不应直接透传该动作。 + if (!ProjectObjectConstants.AUTO_START_TRIGGERS.contains(triggerAction)) { + throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_NOT_ALLOWED, triggerAction); + } + ProjectDO project = validateProjectExists(projectId); + ObjectStatusTransitionDO transition = objectStatusTransitionMapper + .selectByObjectTypeAndFromStatusAndAction(ProjectObjectConstants.OBJECT_TYPE, project.getStatusCode(), + ObjectActivityConstants.PROJECT_ACTION_AUTO_START); + if (transition == null) { + // 未配置 auto_start 时,只要项目已离开初始态且仍可编辑,就允许真实业务动作继续推进,不强制补自动启动流转。 + ObjectStatusModelDO statusModel = validateEnabledStatusModel(project.getStatusCode()); + if (Boolean.TRUE.equals(statusModel.getInitialFlag())) { + throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_NOT_ALLOWED, + ObjectActivityConstants.PROJECT_ACTION_AUTO_START); + } + if (!Boolean.TRUE.equals(statusModel.getAllowEdit())) { + throw exception(ErrorCodeConstants.PROJECT_STATUS_NOT_ALLOW_EDIT); + } + return; + } + if (!isInitialStatus(project.getStatusCode())) { + throw exception(ErrorCodeConstants.PROJECT_STATUS_NOT_ALLOW_EDIT); + } + String reason = ObjectActivityConstants.resolveActionName(triggerAction); + changeStatus(project, transition, ObjectActivityConstants.PROJECT_ACTION_AUTO_START, reason); + } + + @VisibleForTesting + void validateCreateReqVO(ProjectSaveReqVO createReqVO) { + validateProjectCodeUnique(null, createReqVO.getProjectCode()); + validateProjectNameUnique(null, createReqVO.getProductId(), createReqVO.getProjectName()); + validateDateRange(createReqVO.getPlannedStartDate(), createReqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期"); + validateDateRange(createReqVO.getActualStartDate(), createReqVO.getActualEndDate(), "实际结束日期不能早于实际开始日期"); + } + + @VisibleForTesting + ProjectDO validateProjectExists(Long id) { + if (id == null) { + throw exception(ErrorCodeConstants.PROJECT_NOT_EXISTS); + } + ProjectDO project = projectMapper.selectById(id); + if (project == null) { + throw exception(ErrorCodeConstants.PROJECT_NOT_EXISTS); + } + return project; + } + + @VisibleForTesting + void validateProjectCodeUnique(Long id, String projectCode) { + if (!StringUtils.hasText(projectCode)) { + return; + } + String normalizedCode = projectCode.trim(); + ProjectDO project = projectMapper.selectByCode(normalizedCode); + if (project == null) { + return; + } + if (id == null || !project.getId().equals(id)) { + throw exception(ErrorCodeConstants.PROJECT_CODE_DUPLICATE, normalizedCode); + } + } + + @VisibleForTesting + void validateProjectNameUnique(Long id, Long productId, String projectName) { + String normalizedName = projectName.trim(); + ProjectDO project = projectMapper.selectActiveByProductIdAndName(productId, normalizedName, + ProjectObjectConstants.STATUS_CANCELLED); + if (project == null) { + return; + } + if (id == null || !project.getId().equals(id)) { + throw exception(ErrorCodeConstants.PROJECT_NAME_DUPLICATE, normalizedName); + } + } + + @VisibleForTesting + void validateMainlineProjectUnique(Long id, Long productId, String projectType) { + if (productId == null || !isMainlineProjectType(projectType)) { + return; + } + List projects = projectMapper.selectActiveMainlineListByProductId(productId, + ProjectObjectConstants.MAINLINE_PROJECT_TYPE_CODES, ProjectObjectConstants.STATUS_CANCELLED); + if (projects == null || projects.isEmpty() + || projects.stream().allMatch(project -> Objects.equals(project.getId(), id))) { + return; + } + throw exception(ErrorCodeConstants.PROJECT_MAINLINE_DUPLICATE); + } + + private boolean isMainlineProjectType(String projectType) { + return StringUtils.hasText(projectType) + && ProjectObjectConstants.MAINLINE_PROJECT_TYPE_CODES.contains(projectType.trim()); + } + + @VisibleForTesting + void validateProjectType(String projectType) { + try { + Boolean valid = dictDataApi.validateDictDataList(ProjectDictTypeConstants.PROJECT_TYPE, + List.of(projectType.trim())).getCheckedData(); + if (Boolean.TRUE.equals(valid)) { + return; + } + } catch (RuntimeException ex) { + throw exception(ErrorCodeConstants.PROJECT_TYPE_INVALID); + } + throw exception(ErrorCodeConstants.PROJECT_TYPE_INVALID); + } + + @VisibleForTesting + void validateProjectDirection(String directionCode) { + try { + Boolean valid = dictDataApi.validateDictDataList(DictTypeConstants.OBJECT_DIRECTION, + List.of(directionCode)).getCheckedData(); + if (Boolean.TRUE.equals(valid)) { + return; + } + } catch (RuntimeException ex) { + throw exception(ErrorCodeConstants.PROJECT_DIRECTION_INVALID); + } + throw exception(ErrorCodeConstants.PROJECT_DIRECTION_INVALID); + } + + @VisibleForTesting + ObjectStatusTransitionDO validateProjectTransition(String fromStatusCode, String actionCode, String reason) { + ObjectStatusTransitionDO transition = objectStatusTransitionMapper + .selectByObjectTypeAndFromStatusAndAction(ProjectObjectConstants.OBJECT_TYPE, fromStatusCode, actionCode); + if (transition == null) { + throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_NOT_ALLOWED, actionCode); + } + if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(reason)) { + throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_REASON_REQUIRED, transition.getActionCode()); + } + return transition; + } + + private ProductDO validateProductUsable(Long productId) { + if (productId == null) { + return null; + } + ProductDO product = productMapper.selectById(productId); + if (product == null) { + throw exception(ErrorCodeConstants.PROJECT_PRODUCT_NOT_EXISTS); + } + return product; + } + + private String resolveProjectDirectionCode(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); + } + return productDirectionCode; + } + + private void validateManagerUser(Long managerUserId) { + try { + Boolean valid = adminUserApi.validateUserList(List.of(managerUserId)).getCheckedData(); + if (Boolean.TRUE.equals(valid)) { + return; + } + } catch (RuntimeException ex) { + throw exception(ErrorCodeConstants.PROJECT_MANAGER_USER_INVALID); + } + throw exception(ErrorCodeConstants.PROJECT_MANAGER_USER_INVALID); + } + + private void validateProjectCodeUnchanged(ProjectDO project, String projectCode) { + if (!StringUtils.hasText(projectCode)) { + return; + } + if (!Objects.equals(project.getProjectCode(), projectCode.trim())) { + throw exception(ErrorCodeConstants.PROJECT_CODE_NOT_MODIFIABLE); + } + } + + private void validateProductUnchanged(ProjectDO project, Long productId) { + if (!Objects.equals(project.getProductId(), productId)) { + throw exception(ErrorCodeConstants.PROJECT_PRODUCT_NOT_MODIFIABLE); + } + } + + private void validateProjectEditable(ProjectDO project) { + ObjectStatusModelDO statusModel = validateEnabledStatusModel(project.getStatusCode()); + if (!Boolean.TRUE.equals(statusModel.getAllowEdit())) { + throw exception(ErrorCodeConstants.PROJECT_STATUS_NOT_ALLOW_EDIT); + } + } + + private ObjectStatusModelDO validateEnabledStatusModel(String statusCode) { + ObjectStatusModelDO statusModel = objectStatusModelMapper + .selectByObjectTypeAndStatusCodeEnabled(ProjectObjectConstants.OBJECT_TYPE, statusCode); + if (statusModel == null) { + throw exception(ErrorCodeConstants.PROJECT_STATUS_MODEL_NOT_EXISTS_OR_DISABLED); + } + return statusModel; + } + + private String getInitialStatusCode() { + ObjectStatusModelDO statusModel = objectStatusModelMapper + .selectInitialByObjectTypeEnabled(ProjectObjectConstants.OBJECT_TYPE); + if (statusModel == null || !StringUtils.hasText(statusModel.getStatusCode())) { + throw exception(ErrorCodeConstants.PROJECT_STATUS_MODEL_NOT_EXISTS_OR_DISABLED); + } + return statusModel.getStatusCode(); + } + + private boolean isInitialStatus(String statusCode) { + return Boolean.TRUE.equals(validateEnabledStatusModel(statusCode).getInitialFlag()); + } + + private void validateDateRange(LocalDate startDate, LocalDate endDate, String message) { + if (startDate != null && endDate != null && endDate.isBefore(startDate)) { + throw invalidParamException(message); + } + } + + private String generateProjectCode(String projectCode) { + String normalizedCode = normalizeNullableText(projectCode); + if (StringUtils.hasText(normalizedCode)) { + validateProjectCodeUnique(null, normalizedCode); + return normalizedCode; + } + + String year = String.valueOf(LocalDate.now().getYear()); + String codePrefix = ProjectObjectConstants.CODE_PREFIX + year; + int nextSequence = 1; + for (ProjectDO project : projectMapper.selectListByCodePrefix(codePrefix)) { + String existedCode = project.getProjectCode(); + if (!StringUtils.hasText(existedCode) || !existedCode.matches(codePrefix + "\\d{3}")) { + continue; + } + nextSequence = Integer.parseInt(existedCode.substring(codePrefix.length())) + 1; + break; + } + if (nextSequence > 999) { + throw invalidParamException("{} 年项目自动编码序号已用尽", year); + } + String generatedCode = codePrefix + String.format("%03d", nextSequence); + validateProjectCodeUnique(null, generatedCode); + return generatedCode; + } + + private void initManagerMemberRelation(ProjectDO project) { + // 创建项目后要同步补齐负责人成员关系和审计日志,保证负责人与成员数据链路一致。 + ObjectRoleRespDTO managerRole = getManagerRole(); + UserObjectRoleDO member = new UserObjectRoleDO(); + member.setUserId(project.getManagerUserId()); + member.setObjectType(ProjectObjectConstants.OBJECT_TYPE); + member.setObjectId(project.getId()); + member.setRoleId(managerRole.getId()); + member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE); + member.setJoinedTime(LocalDateTime.now()); + member.setLeftTime(null); + userObjectRoleMapper.insert(member); + + writeMemberAuditLog(member, ObjectActivityConstants.MEMBER_ACTION_ADD, null, member, null); + writeManagerChangeAuditLog(project.getId(), null, project.getManagerUserId(), null); + } + + private ObjectRoleRespDTO getManagerRole() { + ObjectRoleRespDTO managerRole = objectPermissionApi + .getObjectRoleByCode(ProjectObjectConstants.MANAGER_ROLE_CODE, ObjectRoleConstants.ROLE_SCOPE_OBJECT, + ProjectObjectConstants.OBJECT_TYPE) + .getCheckedData(); + if (managerRole == null) { + throw invalidParamException("未找到项目经理对象角色配置:{}", ProjectObjectConstants.MANAGER_ROLE_CODE); + } + return managerRole; + } + + private void changeManager(ProjectDO project, Long newManagerUserId, String reason) { + // 负责人切换不能只改项目主表,还要同步调整成员关系并记录负责人变更审计。 + Long oldManagerUserId = project.getManagerUserId(); + if (Objects.equals(oldManagerUserId, newManagerUserId)) { + return; + } + ObjectRoleRespDTO managerRole = getManagerRole(); + inactiveOldManagerRelation(project.getId(), oldManagerUserId, reason); + ensureManagerRelation(project.getId(), newManagerUserId, managerRole.getId(), reason); + writeManagerChangeAuditLog(project.getId(), oldManagerUserId, newManagerUserId, reason); + } + + private void inactiveOldManagerRelation(Long projectId, Long oldManagerUserId, String reason) { + if (oldManagerUserId == null) { + return; + } + UserObjectRoleDO oldMember = userObjectRoleMapper.selectByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, + projectId, oldManagerUserId); + if (oldMember == null || !Objects.equals(oldMember.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) { + return; + } + UserObjectRoleDO before = cloneMember(oldMember); + oldMember.setStatus(ObjectRoleConstants.MEMBER_STATUS_INACTIVE); + oldMember.setLeftTime(LocalDateTime.now()); + userObjectRoleMapper.updateById(oldMember); + writeMemberAuditLog(oldMember, ObjectActivityConstants.MEMBER_ACTION_REMOVE, before, oldMember, reason); + } + + private void ensureManagerRelation(Long projectId, Long managerUserId, Long managerRoleId, String reason) { + UserObjectRoleDO existingMember = userObjectRoleMapper.selectByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, + projectId, managerUserId); + LocalDateTime now = LocalDateTime.now(); + if (existingMember == null) { + UserObjectRoleDO member = new UserObjectRoleDO(); + member.setUserId(managerUserId); + member.setObjectType(ProjectObjectConstants.OBJECT_TYPE); + member.setObjectId(projectId); + member.setRoleId(managerRoleId); + member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE); + member.setJoinedTime(now); + member.setLeftTime(null); + userObjectRoleMapper.insert(member); + writeMemberAuditLog(member, ObjectActivityConstants.MEMBER_ACTION_ADD, null, member, reason); + return; + } + UserObjectRoleDO before = cloneMember(existingMember); + existingMember.setRoleId(managerRoleId); + existingMember.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE); + existingMember.setLeftTime(null); + if (!Objects.equals(before.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) { + existingMember.setJoinedTime(now); + } + userObjectRoleMapper.updateById(existingMember); + writeMemberAuditLog(existingMember, ObjectActivityConstants.MEMBER_ACTION_UPDATE, before, existingMember, reason); + } + + private void changeStatus(ProjectDO project, String actionCode, String reason) { + String fromStatus = project.getStatusCode(); + ObjectStatusTransitionDO transition = validateProjectTransition(fromStatus, actionCode, reason); + changeStatus(project, transition, actionCode, reason); + } + + private void changeStatus(ProjectDO project, ObjectStatusTransitionDO transition, String actionCode, String reason) { + String fromStatus = project.getStatusCode(); + String toStatus = transition.getToStatusCode(); + // 状态更新必须携带 fromStatus 条件,避免并发状态变更时发生后写覆盖先写。 + int updateCount = projectMapper.updateStatusByIdAndStatus(project.getId(), fromStatus, toStatus, reason); + if (updateCount != 1) { + throw exception(ErrorCodeConstants.PROJECT_STATUS_CONCURRENT_MODIFIED); + } + project.setStatusCode(toStatus); + project.setLastStatusReason(reason); + + writeProjectStatusLog(project, actionCode, fromStatus, toStatus, reason); + writeBizAuditLog(project, actionCode, fromStatus, toStatus, null, reason); + } + + private ProjectContextProjectRespVO buildCurrentProject(ProjectDO project) { + // 项目上下文返回的是主表快照叠加运行时生命周期读模型,状态名称和动作列表不在主表写死。 + ProjectContextProjectRespVO currentProject = BeanUtils.toBean(project, ProjectContextProjectRespVO.class); + ProjectStatusViewService.ProjectLifecycleView lifecycle = projectStatusViewService.getLifecycle(project.getStatusCode()); + currentProject.setStatusName(lifecycle.statusName()); + currentProject.setTerminal(lifecycle.terminal()); + currentProject.setAllowEdit(lifecycle.allowEdit()); + currentProject.setAvailableActions(lifecycle.availableActions()); + return currentProject; + } + + private ProjectContextRespVO buildProjectContextWithoutMenus(ProjectDO project, boolean guestFlag) { + ProjectContextRespVO respVO = new ProjectContextRespVO(); + respVO.setCurrentProject(buildCurrentProject(project)); + respVO.setCurrentRole(buildCurrentRole(null, null, guestFlag)); + respVO.setNavs(Collections.emptyList()); + respVO.setButtons(Collections.emptyList()); + return respVO; + } + + private ProjectContextRoleRespVO buildCurrentRole(Long roleId, ObjectRoleRespDTO currentRole, boolean guestFlag) { + ProjectContextRoleRespVO roleRespVO = new ProjectContextRoleRespVO(); + roleRespVO.setRoleId(roleId); + roleRespVO.setGuestFlag(guestFlag); + if (currentRole != null) { + roleRespVO.setRoleCode(currentRole.getCode()); + roleRespVO.setRoleName(currentRole.getName()); + } + return roleRespVO; + } + + private List buildContextNavs(List menus) { + if (menus.isEmpty()) { + return Collections.emptyList(); + } + List navs = menus.stream() + .filter(menu -> !MenuTypeEnum.BUTTON.getType().equals(menu.getType())) + .filter(menu -> !Boolean.FALSE.equals(menu.getVisible())) + .map(menu -> { + ProjectContextNavRespVO nav = new ProjectContextNavRespVO(); + nav.setId(menu.getId()); + nav.setName(menu.getName()); + nav.setPath(menu.getPath()); + nav.setIcon(menu.getIcon()); + nav.setSort(menu.getSort()); + return nav; + }) + .collect(Collectors.toCollection(ArrayList::new)); + navs.sort(Comparator.comparing(ProjectContextNavRespVO::getSort, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(ProjectContextNavRespVO::getId, Comparator.nullsLast(Long::compareTo))); + return navs; + } + + private List buildContextButtons(List menus) { + if (menus.isEmpty()) { + return Collections.emptyList(); + } + return menus.stream() + .filter(menu -> MenuTypeEnum.BUTTON.getType().equals(menu.getType())) + .map(ObjectMenuRespDTO::getPermission) + .filter(StringUtils::hasText) + .map(String::trim) + .distinct() + .sorted() + .collect(Collectors.toList()); + } + + private Map buildProjectStatusCounts(List statusModels, + List> rows) { + Map statusCounts = new LinkedHashMap<>(); + if (statusModels == null || statusModels.isEmpty()) { + return statusCounts; + } + statusModels.stream() + .filter(statusModel -> Objects.equals(statusModel.getStatus(), 0)) + .filter(statusModel -> StringUtils.hasText(statusModel.getStatusCode())) + .sorted(Comparator.comparing(ObjectStatusModelDO::getSort, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(ObjectStatusModelDO::getStatusCode)) + .forEach(statusModel -> statusCounts.put(statusModel.getStatusCode(), 0L)); + if (rows == null || rows.isEmpty()) { + return statusCounts; + } + for (Map row : rows) { + Object statusValue = row.getOrDefault("statusCode", row.get("status_code")); + String statusCode = Objects.toString(statusValue, null); + if (!statusCounts.containsKey(statusCode)) { + continue; + } + Object countValue = row.getOrDefault("countValue", row.get("count_value")); + if (countValue instanceof Number number) { + statusCounts.put(statusCode, number.longValue()); + } + } + return statusCounts; + } + + private void validateDeleteConfirmText(String confirmText) { + String normalizedConfirmText = normalizeNullableText(confirmText); + if (!Objects.equals(ProjectObjectConstants.DELETE_CONFIRM_TEXT, normalizedConfirmText)) { + throw exception(ErrorCodeConstants.PROJECT_DELETE_CONFIRM_TEXT_INVALID); + } + } + + private void writeProjectStatusLog(ProjectDO project, String actionType, String fromStatus, + String toStatus, String reason) { + ProjectStatusLogDO statusLog = new ProjectStatusLogDO(); + statusLog.setProjectId(project.getId()); + statusLog.setActionType(actionType); + statusLog.setFromStatus(fromStatus); + statusLog.setToStatus(toStatus); + statusLog.setReason(defaultText(reason)); + statusLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId()); + statusLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname())); + statusLog.setProjectCodeSnapshot(project.getProjectCode()); + statusLog.setProjectNameSnapshot(project.getProjectName()); + projectStatusLogMapper.insert(statusLog); + } + + private void writeBizAuditLog(ProjectDO project, String actionType, String fromStatus, String toStatus, + String fieldChanges, String reason) { + BizAuditLogDO auditLog = new BizAuditLogDO(); + auditLog.setBizType(ProjectObjectConstants.OBJECT_TYPE); + auditLog.setBizId(project.getId()); + auditLog.setActionType(actionType); + auditLog.setFromStatus(fromStatus); + auditLog.setToStatus(toStatus); + auditLog.setFieldChanges(fieldChanges); + auditLog.setReason(reason); + auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId()); + auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname())); + bizAuditLogMapper.insert(auditLog); + } + + private void writeMemberAuditLog(UserObjectRoleDO member, String actionType, + UserObjectRoleDO before, UserObjectRoleDO after, String reason) { + BizAuditLogDO auditLog = new BizAuditLogDO(); + auditLog.setBizType(ObjectActivityConstants.MEMBER_BIZ_TYPE); + auditLog.setBizId(member.getId()); + auditLog.setActionType(actionType); + auditLog.setFieldChanges(buildMemberFieldChanges(before, after)); + auditLog.setReason(reason); + auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId()); + auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname())); + bizAuditLogMapper.insert(auditLog); + } + + private void writeManagerChangeAuditLog(Long projectId, Long beforeManagerUserId, Long afterManagerUserId, String reason) { + if (Objects.equals(beforeManagerUserId, afterManagerUserId)) { + return; + } + BizAuditLogDO auditLog = new BizAuditLogDO(); + auditLog.setBizType(ProjectObjectConstants.OBJECT_TYPE); + auditLog.setBizId(projectId); + auditLog.setActionType(ObjectActivityConstants.PROJECT_ACTION_CHANGE_MANAGER); + auditLog.setFieldChanges(buildManagerFieldChanges(beforeManagerUserId, afterManagerUserId)); + auditLog.setReason(reason); + auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId()); + auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname())); + bizAuditLogMapper.insert(auditLog); + } + + private ProjectDO cloneProject(ProjectDO source) { + ProjectDO target = new ProjectDO(); + target.setId(source.getId()); + target.setProjectCode(source.getProjectCode()); + target.setProjectName(source.getProjectName()); + target.setProjectType(source.getProjectType()); + target.setDirectionCode(source.getDirectionCode()); + target.setProjectSetId(source.getProjectSetId()); + target.setProductId(source.getProductId()); + target.setProductVersionId(source.getProductVersionId()); + target.setManagerUserId(source.getManagerUserId()); + target.setStatusCode(source.getStatusCode()); + target.setPlannedStartDate(source.getPlannedStartDate()); + target.setPlannedEndDate(source.getPlannedEndDate()); + target.setActualStartDate(source.getActualStartDate()); + target.setActualEndDate(source.getActualEndDate()); + target.setProgressRate(source.getProgressRate()); + target.setProjectDesc(source.getProjectDesc()); + target.setLastStatusReason(source.getLastStatusReason()); + return target; + } + + private UserObjectRoleDO cloneMember(UserObjectRoleDO source) { + UserObjectRoleDO clone = new UserObjectRoleDO(); + clone.setId(source.getId()); + clone.setUserId(source.getUserId()); + clone.setObjectType(source.getObjectType()); + clone.setObjectId(source.getObjectId()); + clone.setRoleId(source.getRoleId()); + clone.setStatus(source.getStatus()); + clone.setJoinedTime(source.getJoinedTime()); + clone.setLeftTime(source.getLeftTime()); + clone.setRemark(source.getRemark()); + return clone; + } + + private String buildProjectFieldChanges(ProjectDO before, ProjectDO after) { + Map fieldChanges = new LinkedHashMap<>(); + appendFieldChange(fieldChanges, "projectCode", valueOf(before, ProjectDO::getProjectCode), + valueOf(after, ProjectDO::getProjectCode)); + appendFieldChange(fieldChanges, "projectName", valueOf(before, ProjectDO::getProjectName), + valueOf(after, ProjectDO::getProjectName)); + appendFieldChange(fieldChanges, "projectType", valueOf(before, ProjectDO::getProjectType), + valueOf(after, ProjectDO::getProjectType)); + appendFieldChange(fieldChanges, "directionCode", valueOf(before, ProjectDO::getDirectionCode), + valueOf(after, ProjectDO::getDirectionCode)); + appendFieldChange(fieldChanges, "productId", valueOf(before, ProjectDO::getProductId), + valueOf(after, ProjectDO::getProductId)); + appendFieldChange(fieldChanges, "managerUserId", valueOf(before, ProjectDO::getManagerUserId), + valueOf(after, ProjectDO::getManagerUserId)); + appendFieldChange(fieldChanges, "statusCode", valueOf(before, ProjectDO::getStatusCode), + valueOf(after, ProjectDO::getStatusCode)); + appendFieldChange(fieldChanges, "projectDesc", valueOf(before, ProjectDO::getProjectDesc), + valueOf(after, ProjectDO::getProjectDesc)); + appendFieldChange(fieldChanges, "lastStatusReason", valueOf(before, ProjectDO::getLastStatusReason), + valueOf(after, ProjectDO::getLastStatusReason)); + return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges); + } + + private String buildMemberFieldChanges(UserObjectRoleDO before, UserObjectRoleDO after) { + Map fieldChanges = new LinkedHashMap<>(); + appendFieldChange(fieldChanges, "userId", valueOf(before, UserObjectRoleDO::getUserId), + valueOf(after, UserObjectRoleDO::getUserId)); + appendFieldChange(fieldChanges, "roleId", valueOf(before, UserObjectRoleDO::getRoleId), + valueOf(after, UserObjectRoleDO::getRoleId)); + appendFieldChange(fieldChanges, "status", valueOf(before, UserObjectRoleDO::getStatus), + valueOf(after, UserObjectRoleDO::getStatus)); + appendFieldChange(fieldChanges, "joinedTime", valueOf(before, UserObjectRoleDO::getJoinedTime), + valueOf(after, UserObjectRoleDO::getJoinedTime)); + appendFieldChange(fieldChanges, "leftTime", valueOf(before, UserObjectRoleDO::getLeftTime), + valueOf(after, UserObjectRoleDO::getLeftTime)); + appendFieldChange(fieldChanges, "remark", valueOf(before, UserObjectRoleDO::getRemark), + valueOf(after, UserObjectRoleDO::getRemark)); + return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges); + } + + private String buildManagerFieldChanges(Long beforeManagerUserId, Long afterManagerUserId) { + Map fieldChanges = new LinkedHashMap<>(); + appendFieldChange(fieldChanges, "managerUserId", beforeManagerUserId, afterManagerUserId); + return JsonUtils.toJsonString(fieldChanges); + } + + private T valueOf(ProjectDO project, Function getter) { + return project == null ? null : getter.apply(project); + } + + private T valueOf(UserObjectRoleDO member, Function getter) { + return member == null ? null : getter.apply(member); + } + + private void appendFieldChange(Map fieldChanges, String fieldName, Object before, Object after) { + if (Objects.equals(before, after)) { + return; + } + Map value = new LinkedHashMap<>(); + value.put("before", before); + value.put("after", after); + fieldChanges.put(fieldName, value); + } + + private String normalizeNullableText(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + return value.trim(); + } + + private String defaultText(String value) { + return StringUtils.hasText(value) ? value : ""; + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectStatusBoardService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectStatusBoardService.java new file mode 100644 index 0000000..e126906 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectStatusBoardService.java @@ -0,0 +1,14 @@ +package com.njcn.rdms.module.project.service.project; + +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; + +public interface ProjectStatusBoardService { + + ProjectExecutionStatusBoardRespVO getExecutionStatusBoard(Long projectId, ProjectExecutionStatusBoardReqVO reqVO); + + ProjectTaskStatusBoardRespVO getTaskStatusBoard(Long projectId, Long executionId, ProjectTaskStatusBoardReqVO reqVO); + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectStatusBoardServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectStatusBoardServiceImpl.java new file mode 100644 index 0000000..4847567 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectStatusBoardServiceImpl.java @@ -0,0 +1,76 @@ +package com.njcn.rdms.module.project.service.project; + +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.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 jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService { + + @Resource + private ObjectStatusModelMapper objectStatusModelMapper; + @Resource + private ProjectExecutionMapper projectExecutionMapper; + @Resource + private ProjectTaskMapper projectTaskMapper; + + @Override + public ProjectExecutionStatusBoardRespVO getExecutionStatusBoard(Long projectId, ProjectExecutionStatusBoardReqVO reqVO) { + List statusModels = objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE); + return buildExecutionStatusBoard(projectId, reqVO, statusModels); + } + + @Override + public ProjectTaskStatusBoardRespVO getTaskStatusBoard(Long projectId, Long executionId, ProjectTaskStatusBoardReqVO reqVO) { + List statusModels = objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE); + return buildTaskStatusBoard(projectId, executionId, reqVO, statusModels); + } + + private ProjectExecutionStatusBoardRespVO buildExecutionStatusBoard(Long projectId, ProjectExecutionStatusBoardReqVO reqVO, + List statusModels) { + ProjectExecutionStatusBoardRespVO respVO = new ProjectExecutionStatusBoardRespVO(); + List items = statusModels.stream().map(statusModel -> { + ProjectExecutionStatusBoardRespVO.ProjectStatusBoardItemVO item = + new ProjectExecutionStatusBoardRespVO.ProjectStatusBoardItemVO(); + item.setStatusCode(statusModel.getStatusCode()); + item.setStatusName(statusModel.getStatusName()); + item.setCount(projectExecutionMapper.countByProjectIdAndStatusCode(projectId, reqVO, statusModel.getStatusCode()).longValue()); + item.setSort(statusModel.getSort()); + item.setTerminal(statusModel.getTerminalFlag()); + return item; + }).toList(); + respVO.setItems(items); + respVO.setTotal(items.stream().mapToLong(ProjectExecutionStatusBoardRespVO.ProjectStatusBoardItemVO::getCount).sum()); + return respVO; + } + + private ProjectTaskStatusBoardRespVO buildTaskStatusBoard(Long projectId, Long executionId, ProjectTaskStatusBoardReqVO reqVO, + List statusModels) { + ProjectTaskStatusBoardRespVO respVO = new ProjectTaskStatusBoardRespVO(); + List 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, + statusModel.getStatusCode()).longValue()); + item.setSort(statusModel.getSort()); + item.setTerminal(statusModel.getTerminalFlag()); + return item; + }).toList(); + respVO.setItems(items); + respVO.setTotal(items.stream().mapToLong(ProjectTaskStatusBoardRespVO.ProjectStatusBoardItemVO::getCount).sum()); + return respVO; + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectStatusViewService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectStatusViewService.java new file mode 100644 index 0000000..fadb839 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectStatusViewService.java @@ -0,0 +1,66 @@ +package com.njcn.rdms.module.project.service.project; + +import com.njcn.rdms.module.project.constant.ObjectActivityConstants; +import com.njcn.rdms.module.project.constant.ProjectObjectConstants; +import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectLifecycleActionRespVO; +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.status.ObjectStatusModelMapper; +import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper; +import com.njcn.rdms.module.project.enums.ErrorCodeConstants; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception; + +@Service +public class ProjectStatusViewService { + + @Resource + private ObjectStatusModelMapper objectStatusModelMapper; + @Resource + private ObjectStatusTransitionMapper objectStatusTransitionMapper; + + public ProjectLifecycleView getLifecycle(String statusCode) { + ObjectStatusModelDO statusModel = objectStatusModelMapper + .selectByObjectTypeAndStatusCodeEnabled(ProjectObjectConstants.OBJECT_TYPE, statusCode); + if (statusModel == null) { + throw exception(ErrorCodeConstants.PROJECT_STATUS_MODEL_NOT_EXISTS_OR_DISABLED); + } + return new ProjectLifecycleView( + statusModel.getStatusName(), + statusModel.getTerminalFlag(), + statusModel.getAllowEdit(), + buildAvailableActions(statusCode) + ); + } + + private List buildAvailableActions(String statusCode) { + List transitions = objectStatusTransitionMapper + .selectListByObjectTypeAndFromStatus(ProjectObjectConstants.OBJECT_TYPE, statusCode); + if (transitions == null || transitions.isEmpty()) { + return Collections.emptyList(); + } + return transitions.stream() + // auto_start 是后端内部推进动作,允许存在于流转配置中,但不应直接暴露给前端按钮。 + .filter(transition -> !Objects.equals(transition.getActionCode(), ObjectActivityConstants.PROJECT_ACTION_AUTO_START)) + .map(transition -> { + ProjectLifecycleActionRespVO action = new ProjectLifecycleActionRespVO(); + action.setActionCode(transition.getActionCode()); + action.setActionName(transition.getActionName()); + action.setNeedReason(transition.getNeedReason()); + return action; + }).toList(); + } + + public record ProjectLifecycleView(String statusName, + Boolean terminal, + Boolean allowEdit, + List availableActions) { + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionMemberService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionMemberService.java new file mode 100644 index 0000000..24671d2 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionMemberService.java @@ -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.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 模型 - 多行周期记录)。 + *

+ * 加入:每次 INSERT 一条新的活跃段(不复用旧段)+ 一条 join 日志。 + * 失效:当前活跃段 setRemovedAt + 永久保留 removedReason;写一条 inactive 日志。 + * 负责人转移:在 ProjectExecutionService.changeOwner 内同步写 owner_transfer_in/out 双向事件。 + */ +public interface ProjectExecutionMemberService { + + /** + * 获取当前活跃成员列表(仅 removed_at IS NULL 段)。 + */ + List 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 getExecutionMemberLogPage(Long projectId, Long executionId, + ExecutionMemberLogPageReqVO reqVO); + + /** + * 写一条执行成员变更日志(供 ProjectExecutionServiceImpl.changeOwner 调用)。 + * 只写用户编号与事件语义;昵称由查询阶段通过 AdminUserApi 按当前用户信息回填。 + */ + void writeMemberLog(Long executionId, Long userId, String actionType, String reason); + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionMemberServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionMemberServiceImpl.java new file mode 100644 index 0000000..e4752e3 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionMemberServiceImpl.java @@ -0,0 +1,379 @@ +package com.njcn.rdms.module.project.service.project.execution; + +import com.google.common.annotations.VisibleForTesting; +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.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.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.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.ProjectExecutionMapper; +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; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +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; + +import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception; + +/** + * 执行成员 Service 实现类(B 模型 - 多行周期记录)。 + *

+ * 同 userId 在同执行内任意时刻只允许一段未失效;重新加入新插一条独立段, + * 旧段的 removed_reason 永久保留不再覆盖。每次状态变更同步落 rdms_execution_member_log。 + */ +@Service +public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMemberService { + + @Resource + private ProjectMapper projectMapper; + @Resource + private ProjectExecutionMapper projectExecutionMapper; + @Resource + private ExecutionMemberMapper executionMemberMapper; + @Resource + private ExecutionMemberLogMapper executionMemberLogMapper; + @Resource + private UserObjectRoleMapper userObjectRoleMapper; + @Resource + private BizAuditLogMapper bizAuditLogMapper; + @Resource + private ObjectStatusModelMapper objectStatusModelMapper; + @Resource + private AdminUserApi adminUserApi; + + @Override + public List getExecutionMemberList(Long projectId, Long executionId) { + validateProjectExists(projectId); + validateExecutionExists(projectId, executionId); + // 仅返当前活跃段(removed_at IS NULL);B 模型下同 userId 至多一段 + List activeList = executionMemberMapper.selectActiveListByExecutionId(executionId); + if (activeList.isEmpty()) { + return Collections.emptyList(); + } + // 批量回查昵称,避免 N+1 + Map 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())); + return respVO; + }).collect(Collectors.toList()); + } + + @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) { + validateEditableExecution(projectId, executionId); + validateProjectMember(projectId, reqVO.getUserId()); + // B 模型:只看是否有当前活跃段;旧的失效段允许"重新加入"(新插一行) + ExecutionMemberDO active = executionMemberMapper + .selectActiveByExecutionIdAndUserId(executionId, reqVO.getUserId()); + if (active != null) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_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); + + // 双写:旧 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(); + } + + @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) { + validateEditableExecution(projectId, executionId); + ExecutionMemberDO member = validateExecutionMemberExists(executionId, memberId); + if (member.getRemovedAt() != null) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_NOT_ACTIVE); + } + ExecutionMemberDO before = cloneExecutionMember(member); + LocalDateTime now = LocalDateTime.now(); + String reason = normalizeNullableText(reqVO.getReason()); + member.setRemovedAt(now); + member.setRemovedReason(reason); + executionMemberMapper.updateById(member); + + writeExecutionMemberAuditLog(executionId, ObjectActivityConstants.EXECUTION_MEMBER_ACTION_REMOVE, + before, member, reason); + writeMemberLogInternal(executionId, member.getUserId(), + ObjectActivityConstants.EXECUTION_MEMBER_LOG_ACTION_INACTIVE, reason, now); + } + + @Override + public PageResult getExecutionMemberLogPage(Long projectId, Long executionId, + ExecutionMemberLogPageReqVO reqVO) { + validateProjectExists(projectId); + validateExecutionExists(projectId, executionId); + PageResult page = executionMemberLogMapper.selectPageByExecutionId(executionId, reqVO); + PageResult result = BeanUtils.toBean(page, ExecutionMemberLogRespVO.class); + fillMemberLogNicknames(result.getList()); + return result; + } + + @Override + public void writeMemberLog(Long executionId, Long userId, String actionType, String reason) { + writeMemberLogInternal(executionId, userId, actionType, reason, LocalDateTime.now()); + } + + @VisibleForTesting + ProjectExecutionDO validateEditableExecution(Long projectId, Long executionId) { + ProjectDO project = validateProjectExists(projectId); + ObjectStatusModelDO statusModel = objectStatusModelMapper + .selectByObjectTypeAndStatusCodeEnabled(ProjectObjectConstants.OBJECT_TYPE, project.getStatusCode()); + if (statusModel == null) { + throw exception(ErrorCodeConstants.PROJECT_STATUS_MODEL_NOT_EXISTS_OR_DISABLED); + } + if (!Boolean.TRUE.equals(statusModel.getAllowEdit())) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_NOT_ALLOW_EDIT); + } + ProjectExecutionDO execution = validateExecutionExists(projectId, executionId); + validateExecutionAllowEdit(execution); + return execution; + } + + @VisibleForTesting + ProjectDO validateProjectExists(Long projectId) { + if (projectId == null) { + throw exception(ErrorCodeConstants.PROJECT_NOT_EXISTS); + } + ProjectDO project = projectMapper.selectById(projectId); + if (project == null) { + throw exception(ErrorCodeConstants.PROJECT_NOT_EXISTS); + } + return project; + } + + @VisibleForTesting + ProjectExecutionDO validateExecutionExists(Long projectId, Long executionId) { + if (executionId == null) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_NOT_EXISTS); + } + ProjectExecutionDO execution = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId); + if (execution == null) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_NOT_EXISTS); + } + return execution; + } + + @VisibleForTesting + void validateProjectMember(Long projectId, Long userId) { + UserObjectRoleDO member = userObjectRoleMapper + .selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, userId); + if (member == null) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_INVALID); + } + } + + @VisibleForTesting + void validateExecutionAllowEdit(ProjectExecutionDO execution) { + ObjectStatusModelDO statusModel = objectStatusModelMapper + .selectByObjectTypeAndStatusCodeEnabled(ProjectExecutionConstants.OBJECT_TYPE, execution.getStatusCode()); + if (statusModel == null) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_MODEL_NOT_EXISTS_OR_DISABLED); + } + if (!Boolean.TRUE.equals(statusModel.getAllowEdit())) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_NOT_ALLOW_EDIT); + } + } + + @VisibleForTesting + ExecutionMemberDO validateExecutionMemberExists(Long executionId, Long memberId) { + ExecutionMemberDO member = executionMemberMapper.selectByIdAndExecutionId(memberId, executionId); + if (member == null) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_NOT_EXISTS); + } + return member; + } + + 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()); + return respVO; + } + + /** + * 返回历史日志时,按当前系统用户信息统一回填展示名。 + *

+ * 这里只用 userId / operatorUserId 查当前昵称,不依赖历史快照,避免用户改名后前后展示不一致。 + */ + private void fillMemberLogNicknames(List logs) { + if (logs == null || logs.isEmpty()) { + return; + } + Set userIds = new LinkedHashSet<>(); + for (ExecutionMemberLogRespVO log : logs) { + if (log == null) { + continue; + } + if (log.getUserId() != null) { + userIds.add(log.getUserId()); + } + if (log.getOperatorUserId() != null) { + userIds.add(log.getOperatorUserId()); + } + } + if (userIds.isEmpty()) { + return; + } + Map nicknameMap = loadUserNicknameMap(userIds); + for (ExecutionMemberLogRespVO log : logs) { + if (log == null) { + continue; + } + log.setUserNicknameSnapshot(nicknameMap.get(log.getUserId())); + log.setOperatorNicknameSnapshot(nicknameMap.get(log.getOperatorUserId())); + } + } + + private void writeExecutionMemberAuditLog(Long executionId, + String actionType, + ExecutionMemberDO before, + ExecutionMemberDO after, + String reason) { + BizAuditLogDO auditLog = new BizAuditLogDO(); + auditLog.setBizType(ProjectExecutionConstants.BIZ_TYPE); + auditLog.setBizId(executionId); + auditLog.setActionType(actionType); + auditLog.setFieldChanges(buildExecutionMemberFieldChanges(before, after)); + auditLog.setReason(reason); + auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId()); + auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname())); + bizAuditLogMapper.insert(auditLog); + } + + /** + * 写一条 rdms_execution_member_log 事件。 + * 只写 ID 和事件语义字段,昵称展示由查询阶段按当前系统用户信息回填。 + */ + private void writeMemberLogInternal(Long executionId, Long userId, + String actionType, String reason, LocalDateTime when) { + ExecutionMemberLogDO log = new ExecutionMemberLogDO(); + log.setExecutionId(executionId); + log.setUserId(userId); + log.setActionType(actionType); + log.setReason(reason); + log.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId()); + log.setActionTime(when); + executionMemberLogMapper.insert(log); + } + + private Map loadUserNicknameMap(Collection userIds) { + if (userIds == null || userIds.isEmpty()) { + return Collections.emptyMap(); + } + Map userMap = adminUserApi.getUserMap(userIds); + if (userMap == null || userMap.isEmpty()) { + return Collections.emptyMap(); + } + Map nicknameMap = new HashMap<>(userMap.size()); + userMap.forEach((id, user) -> nicknameMap.put(id, user == null ? null : user.getNickname())); + return nicknameMap; + } + + private String buildExecutionMemberFieldChanges(ExecutionMemberDO before, ExecutionMemberDO after) { + Map 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)); + return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges); + } + + private ExecutionMemberDO cloneExecutionMember(ExecutionMemberDO source) { + ExecutionMemberDO target = new ExecutionMemberDO(); + target.setId(source.getId()); + target.setExecutionId(source.getExecutionId()); + target.setUserId(source.getUserId()); + target.setJoinedAt(source.getJoinedAt()); + target.setRemovedAt(source.getRemovedAt()); + target.setRemovedReason(source.getRemovedReason()); + return target; + } + + private T valueOf(ExecutionMemberDO member, Function getter) { + return member == null ? null : getter.apply(member); + } + + private void appendFieldChange(Map fieldChanges, String fieldName, Object before, Object after) { + if (Objects.equals(before, after)) { + return; + } + Map value = new LinkedHashMap<>(); + value.put("before", before); + value.put("after", after); + fieldChanges.put(fieldName, value); + } + + private String normalizeNullableText(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + return value.trim(); + } + + private String defaultText(String value) { + return StringUtils.hasText(value) ? value : ""; + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionService.java new file mode 100644 index 0000000..097a3ac --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionService.java @@ -0,0 +1,38 @@ +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.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.ProjectExecutionStatusActionReqVO; +import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO; + +/** + * 执行主数据 Service。 + */ +public interface ProjectExecutionService { + + Long createExecution(Long projectId, ProjectExecutionSaveReqVO reqVO); + + void updateExecution(Long projectId, ProjectExecutionSaveReqVO reqVO); + + ProjectExecutionDO getExecution(Long projectId, Long executionId); + + PageResult getExecutionPage(Long projectId, ProjectExecutionPageReqVO reqVO); + + /** + * 获取执行详情 VO,附带状态生命周期与负责人昵称。 + */ + ProjectExecutionRespVO getExecutionRespVO(Long projectId, Long executionId); + + /** + * 获取执行分页 VO,附带状态生命周期与负责人昵称(昵称按 ownerId 去重批量查询,避免 N+1)。 + */ + PageResult getExecutionRespVOPage(Long projectId, ProjectExecutionPageReqVO reqVO); + + void changeOwner(Long projectId, Long executionId, ProjectExecutionOwnerChangeReqVO reqVO); + + void changeExecutionStatus(Long projectId, Long executionId, ProjectExecutionStatusActionReqVO reqVO); + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImpl.java new file mode 100644 index 0000000..0a5398d --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImpl.java @@ -0,0 +1,627 @@ +package com.njcn.rdms.module.project.service.project.execution; + +import com.google.common.annotations.VisibleForTesting; +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.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.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.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.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.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.ProjectExecutionMapper; +import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionStatusLogMapper; +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.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 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.time.LocalDateTime; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +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; + +import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception; +import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; + +/** + * 执行主数据 Service 实现类。 + */ +@Service +public class ProjectExecutionServiceImpl implements ProjectExecutionService { + + @Resource + private ProjectMapper projectMapper; + @Resource + private ProjectExecutionMapper projectExecutionMapper; + @Resource + private ExecutionMemberMapper executionMemberMapper; + @Resource + private UserObjectRoleMapper userObjectRoleMapper; + @Resource + private BizAuditLogMapper bizAuditLogMapper; + @Resource + private ObjectStatusModelMapper objectStatusModelMapper; + @Resource + private ObjectStatusTransitionMapper objectStatusTransitionMapper; + @Resource + private ProjectExecutionStatusLogMapper projectExecutionStatusLogMapper; + @Resource + private DictDataApi dictDataApi; + @Resource + private ProjectExecutionStatusViewService projectExecutionStatusViewService; + @Resource + private ProjectExecutionMemberService projectExecutionMemberService; + @Resource + private AdminUserApi adminUserApi; + + @Override + @Transactional(rollbackFor = Exception.class) + @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", + permission = ProjectExecutionConstants.PERMISSION_CREATE) + public Long createExecution(Long projectId, ProjectExecutionSaveReqVO reqVO) { + validateEditableProject(projectId); + validateRequirementIdPhaseOne(reqVO.getProjectRequirementId()); + String executionName = normalizeRequiredName(reqVO.getExecutionName()); + validateExecutionNameUnique(projectId, null, executionName); + validateOwnerIsActiveProjectMember(projectId, reqVO.getOwnerId()); + String executionType = normalizeRequiredExecutionType(reqVO.getExecutionType()); + validateExecutionType(executionType); + Set memberUserIds = normalizeRequiredMemberUserIds(reqVO.getMemberUserIds()); + validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期"); + + ProjectExecutionDO execution = new ProjectExecutionDO(); + execution.setProjectId(projectId); + execution.setProjectRequirementId(null); + execution.setExecutionName(executionName); + execution.setExecutionType(executionType); + execution.setOwnerId(reqVO.getOwnerId()); + String initialStatusCode = getInitialExecutionStatusCode(); + execution.setStatusCode(initialStatusCode); + execution.setPlannedStartDate(reqVO.getPlannedStartDate()); + execution.setPlannedEndDate(reqVO.getPlannedEndDate()); + execution.setActualStartDate(null); + execution.setActualEndDate(null); + execution.setProgressRate(BigDecimal.ZERO); + execution.setExecutionDesc(normalizeNullableText(reqVO.getExecutionDesc())); + projectExecutionMapper.insert(execution); + + writeExecutionAuditLog(execution, ObjectActivityConstants.EXECUTION_ACTION_CREATE, null, + initialStatusCode, buildExecutionFieldChanges(null, execution), null); + createExecutionMembers(execution.getId(), projectId, memberUserIds); + return execution.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", + permission = ProjectExecutionConstants.PERMISSION_UPDATE) + public void updateExecution(Long projectId, ProjectExecutionSaveReqVO reqVO) { + if (reqVO.getId() == null) { + throw invalidParamException("执行编号不能为空"); + } + validateEditableProject(projectId); + ProjectExecutionDO execution = validateExecutionExists(projectId, reqVO.getId()); + validateExecutionAllowEdit(execution); + 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(), "计划结束日期不能早于计划开始日期"); + + 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()); + execution.setExecutionDesc(normalizeNullableText(reqVO.getExecutionDesc())); + projectExecutionMapper.updateById(execution); + + writeExecutionAuditLog(execution, ObjectActivityConstants.EXECUTION_ACTION_UPDATE, before.getStatusCode(), + execution.getStatusCode(), buildExecutionFieldChanges(before, execution), null); + } + + @Override + public ProjectExecutionDO getExecution(Long projectId, Long executionId) { + validateProjectExists(projectId); + return validateExecutionExists(projectId, executionId); + } + + @Override + public PageResult getExecutionPage(Long projectId, ProjectExecutionPageReqVO reqVO) { + validateProjectExists(projectId); + return projectExecutionMapper.selectPageByProjectId(projectId, reqVO); + } + + @Override + public ProjectExecutionRespVO getExecutionRespVO(Long projectId, Long executionId) { + ProjectExecutionDO execution = getExecution(projectId, executionId); + ProjectExecutionRespVO respVO = BeanUtils.toBean(execution, ProjectExecutionRespVO.class); + applyLifecycle(respVO); + respVO.setOwnerNickname(loadOwnerNickname(execution.getOwnerId())); + return respVO; + } + + @Override + public PageResult getExecutionRespVOPage(Long projectId, ProjectExecutionPageReqVO reqVO) { + PageResult pageResult = getExecutionPage(projectId, reqVO); + PageResult voPageResult = BeanUtils.toBean(pageResult, ProjectExecutionRespVO.class); + List list = voPageResult.getList(); + if (list == null || list.isEmpty()) { + return voPageResult; + } + // 批量补负责人昵称,避免 N+1(lifecycle 字段保持原行为不在分页装配) + Set ownerIds = list.stream() + .map(ProjectExecutionRespVO::getOwnerId) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + Map nicknameMap = loadOwnerNicknameMap(ownerIds); + list.forEach(vo -> vo.setOwnerNickname(nicknameMap.get(vo.getOwnerId()))); + return voPageResult; + } + + @Override + @Transactional(rollbackFor = Exception.class) + @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", + permission = ProjectExecutionConstants.PERMISSION_OWNER) + public void changeOwner(Long projectId, Long executionId, ProjectExecutionOwnerChangeReqVO reqVO) { + validateEditableProject(projectId); + ProjectExecutionDO execution = validateExecutionExists(projectId, executionId); + validateExecutionAllowEdit(execution); + Long oldOwnerId = execution.getOwnerId(); + if (Objects.equals(oldOwnerId, reqVO.getNewOwnerId())) { + return; + } + validateOwnerIsActiveProjectMember(projectId, reqVO.getNewOwnerId()); + ProjectExecutionDO before = cloneExecution(execution); + execution.setOwnerId(reqVO.getNewOwnerId()); + projectExecutionMapper.updateById(execution); + String reason = normalizeNullableText(reqVO.getReason()); + writeExecutionAuditLog(execution, ObjectActivityConstants.EXECUTION_ACTION_CHANGE_OWNER, before.getStatusCode(), + 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); + } + projectExecutionMemberService.writeMemberLog(executionId, reqVO.getNewOwnerId(), + ObjectActivityConstants.EXECUTION_MEMBER_LOG_ACTION_OWNER_TRANSFER_IN, reason); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", + permission = ProjectExecutionConstants.PERMISSION_STATUS) + public void changeExecutionStatus(Long projectId, Long executionId, ProjectExecutionStatusActionReqVO reqVO) { + validateProjectExists(projectId); + ProjectExecutionDO execution = validateExecutionExists(projectId, executionId); + String actionCode = normalizeRequiredActionCode(reqVO.getActionCode()); + String reason = normalizeNullableText(reqVO.getReason()); + String fromStatus = execution.getStatusCode(); + ObjectStatusTransitionDO transition = validateExecutionTransition(fromStatus, actionCode, reason); + String toStatus = transition.getToStatusCode(); + + int updateCount = projectExecutionMapper.updateStatusByIdAndStatus(executionId, fromStatus, toStatus, reason); + if (updateCount != 1) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_CONCURRENT_MODIFIED); + } + execution.setStatusCode(toStatus); + execution.setLastStatusReason(reason); + applyExecutionActualDateByStatusAction(execution, actionCode, toStatus); + + writeExecutionStatusLog(execution, actionCode, fromStatus, toStatus, reason); + writeExecutionAuditLog(execution, actionCode, fromStatus, toStatus, null, reason); + } + + @VisibleForTesting + ProjectDO validateEditableProject(Long projectId) { + ProjectDO project = validateProjectExists(projectId); + ObjectStatusModelDO statusModel = objectStatusModelMapper + .selectByObjectTypeAndStatusCodeEnabled(ProjectObjectConstants.OBJECT_TYPE, project.getStatusCode()); + if (statusModel == null) { + throw exception(ErrorCodeConstants.PROJECT_STATUS_MODEL_NOT_EXISTS_OR_DISABLED); + } + if (!Boolean.TRUE.equals(statusModel.getAllowEdit())) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_NOT_ALLOW_EDIT); + } + return project; + } + + @VisibleForTesting + ProjectDO validateProjectExists(Long projectId) { + if (projectId == null) { + throw exception(ErrorCodeConstants.PROJECT_NOT_EXISTS); + } + ProjectDO project = projectMapper.selectById(projectId); + if (project == null) { + throw exception(ErrorCodeConstants.PROJECT_NOT_EXISTS); + } + return project; + } + + @VisibleForTesting + ProjectExecutionDO validateExecutionExists(Long projectId, Long executionId) { + if (executionId == null) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_NOT_EXISTS); + } + ProjectExecutionDO execution = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId); + if (execution == null) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_NOT_EXISTS); + } + return execution; + } + + @VisibleForTesting + void validateRequirementIdPhaseOne(Long projectRequirementId) { + if (projectRequirementId != null) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_REQUIREMENT_NOT_READY); + } + } + + @VisibleForTesting + void validateOwnerIsActiveProjectMember(Long projectId, Long ownerId) { + UserObjectRoleDO member = userObjectRoleMapper + .selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, ownerId); + if (member == null) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_OWNER_INVALID); + } + } + + @VisibleForTesting + void validateExecutionAllowEdit(ProjectExecutionDO execution) { + ObjectStatusModelDO statusModel = objectStatusModelMapper + .selectByObjectTypeAndStatusCodeEnabled(ProjectExecutionConstants.OBJECT_TYPE, execution.getStatusCode()); + if (statusModel == null) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_MODEL_NOT_EXISTS_OR_DISABLED); + } + if (!Boolean.TRUE.equals(statusModel.getAllowEdit())) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_NOT_ALLOW_EDIT); + } + } + + @VisibleForTesting + ObjectStatusTransitionDO validateExecutionTransition(String fromStatusCode, String actionCode, String reason) { + ObjectStatusTransitionDO transition = objectStatusTransitionMapper + .selectByObjectTypeAndFromStatusAndAction(ProjectExecutionConstants.OBJECT_TYPE, fromStatusCode, actionCode); + if (transition == null) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_NOT_ALLOWED, actionCode); + } + if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(reason)) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_REASON_REQUIRED, transition.getActionCode()); + } + return transition; + } + + @VisibleForTesting + String getInitialExecutionStatusCode() { + ObjectStatusModelDO statusModel = objectStatusModelMapper + .selectInitialByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE); + if (statusModel == null || !StringUtils.hasText(statusModel.getStatusCode())) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_MODEL_NOT_EXISTS_OR_DISABLED); + } + return statusModel.getStatusCode(); + } + + @VisibleForTesting + void validateExecutionNameUnique(Long projectId, Long executionId, String executionName) { + ProjectExecutionDO existingExecution = projectExecutionMapper.selectByProjectIdAndName(projectId, executionName); + if (existingExecution == null) { + return; + } + if (executionId == null || !Objects.equals(existingExecution.getId(), executionId)) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_NAME_DUPLICATE, executionName); + } + } + + @VisibleForTesting + void validateExecutionType(String executionType) { + try { + Boolean valid = dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, + List.of(executionType)).getCheckedData(); + if (Boolean.TRUE.equals(valid)) { + return; + } + } catch (RuntimeException ex) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_TYPE_INVALID); + } + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_TYPE_INVALID); + } + + @VisibleForTesting + void validateDateRange(LocalDate startDate, LocalDate endDate, String message) { + if (startDate == null || endDate == null) { + return; + } + if (endDate.isBefore(startDate)) { + throw invalidParamException(message); + } + } + + private void createExecutionMembers(Long executionId, Long projectId, Set memberUserIds) { + LocalDateTime now = LocalDateTime.now(); + for (Long memberUserId : memberUserIds) { + validateExecutionMemberProjectScope(projectId, memberUserId); + ExecutionMemberDO member = new ExecutionMemberDO(); + member.setExecutionId(executionId); + member.setUserId(memberUserId); + 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); + // B 模型:每次创建活跃段同步写一条 join 事件,确保活跃成员都能在变更历史中追溯加入时间 + projectExecutionMemberService.writeMemberLog(executionId, memberUserId, + ObjectActivityConstants.EXECUTION_MEMBER_LOG_ACTION_JOIN, null); + } + } + + private void validateExecutionMemberProjectScope(Long projectId, Long userId) { + UserObjectRoleDO member = userObjectRoleMapper + .selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, userId); + if (member == null) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_INVALID); + } + } + + private Set normalizeMemberUserIds(List memberUserIds) { + if (memberUserIds == null || memberUserIds.isEmpty()) { + return Collections.emptySet(); + } + Set normalizedUserIds = new LinkedHashSet<>(); + for (Long memberUserId : memberUserIds) { + if (memberUserId != null) { + normalizedUserIds.add(memberUserId); + } + } + return normalizedUserIds; + } + + private Set normalizeRequiredMemberUserIds(List memberUserIds) { + Set normalizedUserIds = normalizeMemberUserIds(memberUserIds); + if (normalizedUserIds.isEmpty()) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_REQUIRED); + } + return normalizedUserIds; + } + + private void writeExecutionAuditLog(ProjectExecutionDO execution, + String actionType, + String fromStatus, + String toStatus, + String fieldChanges, + String reason) { + writeExecutionAuditLogByBizId(execution.getId(), actionType, fromStatus, toStatus, fieldChanges, reason); + } + + private void writeExecutionAuditLogByBizId(Long executionId, + String actionType, + String fromStatus, + String toStatus, + String fieldChanges, + String reason) { + BizAuditLogDO auditLog = new BizAuditLogDO(); + auditLog.setBizType(ProjectExecutionConstants.BIZ_TYPE); + auditLog.setBizId(executionId); + auditLog.setActionType(actionType); + auditLog.setFromStatus(fromStatus); + auditLog.setToStatus(toStatus); + auditLog.setFieldChanges(fieldChanges); + auditLog.setReason(reason); + auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId()); + auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname())); + bizAuditLogMapper.insert(auditLog); + } + + private void writeExecutionStatusLog(ProjectExecutionDO execution, String actionType, String fromStatus, + String toStatus, String reason) { + ProjectExecutionStatusLogDO statusLog = new ProjectExecutionStatusLogDO(); + statusLog.setExecutionId(execution.getId()); + statusLog.setActionType(actionType); + statusLog.setFromStatus(fromStatus); + statusLog.setToStatus(toStatus); + statusLog.setReason(defaultText(reason)); + statusLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId()); + statusLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname())); + statusLog.setExecutionNameSnapshot(execution.getExecutionName()); + projectExecutionStatusLogMapper.insert(statusLog); + } + + private ProjectExecutionDO cloneExecution(ProjectExecutionDO source) { + ProjectExecutionDO target = new ProjectExecutionDO(); + target.setId(source.getId()); + target.setProjectId(source.getProjectId()); + target.setProjectRequirementId(source.getProjectRequirementId()); + target.setExecutionName(source.getExecutionName()); + target.setExecutionType(source.getExecutionType()); + target.setOwnerId(source.getOwnerId()); + target.setStatusCode(source.getStatusCode()); + target.setPlannedStartDate(source.getPlannedStartDate()); + target.setPlannedEndDate(source.getPlannedEndDate()); + target.setActualStartDate(source.getActualStartDate()); + target.setActualEndDate(source.getActualEndDate()); + target.setProgressRate(source.getProgressRate()); + target.setExecutionDesc(source.getExecutionDesc()); + target.setLastStatusReason(source.getLastStatusReason()); + return target; + } + + private String buildExecutionFieldChanges(ProjectExecutionDO before, ProjectExecutionDO after) { + Map fieldChanges = new LinkedHashMap<>(); + appendFieldChange(fieldChanges, "executionName", valueOf(before, ProjectExecutionDO::getExecutionName), + valueOf(after, ProjectExecutionDO::getExecutionName)); + appendFieldChange(fieldChanges, "executionType", valueOf(before, ProjectExecutionDO::getExecutionType), + valueOf(after, ProjectExecutionDO::getExecutionType)); + appendFieldChange(fieldChanges, "ownerId", valueOf(before, ProjectExecutionDO::getOwnerId), + valueOf(after, ProjectExecutionDO::getOwnerId)); + appendFieldChange(fieldChanges, "statusCode", valueOf(before, ProjectExecutionDO::getStatusCode), + valueOf(after, ProjectExecutionDO::getStatusCode)); + appendFieldChange(fieldChanges, "plannedStartDate", valueOf(before, ProjectExecutionDO::getPlannedStartDate), + valueOf(after, ProjectExecutionDO::getPlannedStartDate)); + appendFieldChange(fieldChanges, "plannedEndDate", valueOf(before, ProjectExecutionDO::getPlannedEndDate), + valueOf(after, ProjectExecutionDO::getPlannedEndDate)); + appendFieldChange(fieldChanges, "actualStartDate", valueOf(before, ProjectExecutionDO::getActualStartDate), + valueOf(after, ProjectExecutionDO::getActualStartDate)); + appendFieldChange(fieldChanges, "actualEndDate", valueOf(before, ProjectExecutionDO::getActualEndDate), + valueOf(after, ProjectExecutionDO::getActualEndDate)); + appendFieldChange(fieldChanges, "progressRate", valueOf(before, ProjectExecutionDO::getProgressRate), + valueOf(after, ProjectExecutionDO::getProgressRate)); + appendFieldChange(fieldChanges, "executionDesc", valueOf(before, ProjectExecutionDO::getExecutionDesc), + valueOf(after, ProjectExecutionDO::getExecutionDesc)); + appendFieldChange(fieldChanges, "lastStatusReason", valueOf(before, ProjectExecutionDO::getLastStatusReason), + valueOf(after, ProjectExecutionDO::getLastStatusReason)); + return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges); + } + + private String buildExecutionMemberFieldChanges(ExecutionMemberDO before, ExecutionMemberDO after) { + Map 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)); + return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges); + } + + private T valueOf(ProjectExecutionDO execution, Function getter) { + return execution == null ? null : getter.apply(execution); + } + + private T valueOf(ExecutionMemberDO member, Function getter) { + return member == null ? null : getter.apply(member); + } + + private void appendFieldChange(Map fieldChanges, String fieldName, Object before, Object after) { + if (Objects.equals(before, after)) { + return; + } + Map value = new LinkedHashMap<>(); + value.put("before", before); + value.put("after", after); + fieldChanges.put(fieldName, value); + } + + private String normalizeRequiredName(String value) { + if (!StringUtils.hasText(value)) { + throw invalidParamException("执行名称不能为空"); + } + return value.trim(); + } + + private String normalizeRequiredExecutionType(String value) { + if (!StringUtils.hasText(value)) { + throw invalidParamException("执行类型不能为空"); + } + return value.trim(); + } + + private String normalizeRequiredActionCode(String value) { + if (!StringUtils.hasText(value)) { + throw invalidParamException("动作编码不能为空"); + } + return value.trim(); + } + + private String normalizeNullableText(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + return value.trim(); + } + + private String defaultText(String value) { + return StringUtils.hasText(value) ? value : ""; + } + + private void applyLifecycle(ProjectExecutionRespVO respVO) { + ProjectExecutionStatusViewService.ProjectExecutionLifecycleView lifecycle = + projectExecutionStatusViewService.getLifecycle(respVO.getStatusCode()); + respVO.setStatusName(lifecycle.statusName()); + respVO.setTerminal(lifecycle.terminal()); + respVO.setAllowEdit(lifecycle.allowEdit()); + respVO.setAvailableActions(lifecycle.availableActions()); + } + + private String loadOwnerNickname(Long ownerId) { + if (ownerId == null) { + return null; + } + CommonResult result = adminUserApi.getUser(ownerId); + AdminUserRespDTO user = result == null ? null : result.getCheckedData(); + return user == null ? null : user.getNickname(); + } + + private Map loadOwnerNicknameMap(Collection ownerIds) { + if (ownerIds == null || ownerIds.isEmpty()) { + return Collections.emptyMap(); + } + Map userMap = adminUserApi.getUserMap(ownerIds); + if (userMap == null || userMap.isEmpty()) { + return Collections.emptyMap(); + } + Map nicknameMap = new HashMap<>(userMap.size()); + userMap.forEach((id, user) -> nicknameMap.put(id, user == null ? null : user.getNickname())); + return nicknameMap; + } + + private void applyExecutionActualDateByStatusAction(ProjectExecutionDO execution, String actionCode, String toStatus) { + LocalDate today = LocalDate.now(); + boolean changed = false; + if ("start".equals(actionCode) && execution.getActualStartDate() == null) { + execution.setActualStartDate(today); + changed = true; + } + if ("completed".equals(toStatus) && execution.getActualEndDate() == null) { + execution.setActualEndDate(today); + changed = true; + } + if (changed) { + projectExecutionMapper.updateById(execution); + } + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionStatusViewService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionStatusViewService.java new file mode 100644 index 0000000..528f84d --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionStatusViewService.java @@ -0,0 +1,61 @@ +package com.njcn.rdms.module.project.service.project.execution; + +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; +import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO; +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 jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; + +import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception; + +@Service +public class ProjectExecutionStatusViewService { + + @Resource + private ObjectStatusModelMapper objectStatusModelMapper; + @Resource + private ObjectStatusTransitionMapper objectStatusTransitionMapper; + + public ProjectExecutionLifecycleView getLifecycle(String statusCode) { + ObjectStatusModelDO statusModel = objectStatusModelMapper + .selectByObjectTypeAndStatusCodeEnabled(ProjectExecutionConstants.OBJECT_TYPE, statusCode); + if (statusModel == null) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_MODEL_NOT_EXISTS_OR_DISABLED); + } + return new ProjectExecutionLifecycleView( + statusModel.getStatusName(), + statusModel.getTerminalFlag(), + statusModel.getAllowEdit(), + buildAvailableActions(statusCode) + ); + } + + private List buildAvailableActions(String statusCode) { + List 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(); + } + + public record ProjectExecutionLifecycleView(String statusName, + Boolean terminal, + Boolean allowEdit, + List availableActions) { + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskService.java new file mode 100644 index 0000000..74b94c2 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskService.java @@ -0,0 +1,35 @@ +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.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; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusActionReqVO; +import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO; + +/** + * 项目任务 Service 接口。 + */ +public interface ProjectTaskService { + + Long createTask(Long projectId, Long executionId, ProjectTaskSaveReqVO reqVO); + + void updateTask(Long projectId, Long executionId, ProjectTaskSaveReqVO reqVO); + + ProjectTaskDO getTask(Long projectId, Long executionId, Long taskId); + + PageResult getTaskPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO); + + /** + * 获取任务详情 VO,附带状态生命周期与负责人昵称。 + */ + ProjectTaskRespVO getTaskRespVO(Long projectId, Long executionId, Long taskId); + + /** + * 获取任务分页 VO,附带状态生命周期与负责人昵称(昵称按 ownerId 去重批量查询,避免 N+1)。 + */ + PageResult getTaskRespVOPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO); + + void changeTaskStatus(Long projectId, Long executionId, Long taskId, ProjectTaskStatusActionReqVO reqVO); + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImpl.java new file mode 100644 index 0000000..8c97a27 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImpl.java @@ -0,0 +1,713 @@ +package com.njcn.rdms.module.project.service.project.task; + +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.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.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; +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.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.ProjectTaskStatusLogDO; +import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskAssigneeDO; +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.ProjectExecutionMapper; +import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper; +import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskStatusLogMapper; +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.security.annotation.CheckObjectPermission; +import com.njcn.rdms.module.project.service.project.ProjectService; +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 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.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +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; + +import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception; +import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; + +/** + * 项目任务 Service 实现类。 + */ +@Service +public class ProjectTaskServiceImpl implements ProjectTaskService { + + @Resource + private ProjectMapper projectMapper; + @Resource + private ProjectExecutionMapper projectExecutionMapper; + @Resource + private ExecutionMemberMapper executionMemberMapper; + @Resource + private ProjectTaskMapper projectTaskMapper; + @Resource + private TaskWorklogMapper taskWorklogMapper; + @Resource + private ProjectTaskStatusLogMapper projectTaskStatusLogMapper; + @Resource + private BizAuditLogMapper bizAuditLogMapper; + @Resource + private ObjectStatusModelMapper objectStatusModelMapper; + @Resource + private ObjectStatusTransitionMapper objectStatusTransitionMapper; + @Resource + private ProjectService projectService; + @Resource + private ProjectTaskStatusViewService projectTaskStatusViewService; + @Resource + private TaskAssigneeService taskAssigneeService; + @Resource + private TaskWorklogService taskWorklogService; + @Resource + private AdminUserApi adminUserApi; + + @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()); + Long ownerId = resolveTaskOwner(reqVO.getOwnerId(), parentTask); + validateOwnerIsActiveExecutionMember(executionId, ownerId); + validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期"); + // 叶子转父校验:当前 parentTask 还是叶子(无任何子任务)时,要求其进度=0 且无工时记录 + validateLeafToParentSplit(parentTask); + + ProjectTaskDO task = new ProjectTaskDO(); + task.setProjectId(projectId); + task.setExecutionId(executionId); + task.setParentTaskId(parentTask == null ? null : parentTask.getId()); + task.setTaskTitle(normalizeRequiredTitle(reqVO.getTaskTitle())); + task.setOwnerId(ownerId); + task.setStatusCode(getInitialTaskStatusCode()); + // 新建任务自身一定是叶子(尚无子任务),按 owner 手填值落库(默认 0) + task.setProgressRate(normalizeProgress(reqVO.getProgressRate())); + task.setPlannedStartDate(reqVO.getPlannedStartDate()); + task.setPlannedEndDate(reqVO.getPlannedEndDate()); + // 实际开始/结束日期不允许人工填写,由 changeTaskStatus 在状态流转时推导 + task.setTaskDesc(normalizeNullableText(reqVO.getTaskDesc())); + projectTaskMapper.insert(task); + + // 创建任务时初始化协办人列表(同事务,任一项失败整笔回滚;列表为空跳过) + taskAssigneeService.initializeAssignees(task.getId(), ownerId, executionId, reqVO.getAssigneeUserIds()); + + // 父任务(含祖先)进度按子任务平均自动汇总 + if (task.getParentTaskId() != null) { + recalcParentProgressFrom(task.getParentTaskId()); + } + + writeTaskAuditLog(task, ObjectActivityConstants.TASK_ACTION_CREATE, null, task.getStatusCode(), + buildTaskFieldChanges(null, task), null); + projectService.autoStartProjectIfPending(projectId, ObjectActivityConstants.PROJECT_TRIGGER_CREATE_TASK); + return task.getId(); + } + + @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("任务编号不能为空"); + } + validateEditableProject(projectId); + ProjectExecutionDO execution = validateExecutionExists(projectId, executionId); + validateExecutionAllowEdit(execution); + ProjectTaskDO task = validateTaskExists(projectId, executionId, reqVO.getId()); + validateTaskAllowEdit(task); + 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); + validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期"); + // 父任务变更校验:迁到新父时,新父若仍是叶子(即将变父),要求新父进度=0 且无工时 + Long oldParentId = task.getParentTaskId(); + Long newParentId = parentTask == null ? null : parentTask.getId(); + if (!Objects.equals(oldParentId, newParentId)) { + validateLeafToParentSplit(parentTask); + } + // 进度入参根据"当前任务是否已是父任务"决定:父任务拒绝手填变更,叶子任务沿用原规则 + BigDecimal resolvedProgress = resolveProgressRateOnUpdate(task, reqVO.getProgressRate()); + + 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())); + projectTaskMapper.updateById(task); + + // 进度联动:旧父链 + 新父链都需要重算(父任务不可手填进度,由本逻辑统一刷新) + Set parentsToRecalc = new HashSet<>(); + if (oldParentId != null) { + parentsToRecalc.add(oldParentId); + } + if (newParentId != null) { + parentsToRecalc.add(newParentId); + } + parentsToRecalc.forEach(this::recalcParentProgressFrom); + + writeTaskAuditLog(task, ObjectActivityConstants.TASK_ACTION_UPDATE, before.getStatusCode(), + task.getStatusCode(), buildTaskFieldChanges(before, task), null); + } + + @Override + public ProjectTaskDO getTask(Long projectId, Long executionId, Long taskId) { + validateExecutionExists(projectId, executionId); + return validateTaskExists(projectId, executionId, taskId); + } + + @Override + public PageResult getTaskPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) { + validateExecutionExists(projectId, executionId); + return projectTaskMapper.selectPageByExecutionId(projectId, executionId, reqVO); + } + + @Override + public ProjectTaskRespVO getTaskRespVO(Long projectId, Long executionId, Long taskId) { + ProjectTaskDO task = getTask(projectId, executionId, taskId); + 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())); + return respVO; + } + + @Override + public PageResult getTaskRespVOPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) { + PageResult pageResult = getTaskPage(projectId, executionId, reqVO); + PageResult voPageResult = BeanUtils.toBean(pageResult, ProjectTaskRespVO.class); + List list = voPageResult.getList(); + if (list == null || list.isEmpty()) { + return voPageResult; + } + // 批量装配 ownerNickname + assignees,统一收集所有需要的 userId 一次性查 nickname,避免 N+1 + Set taskIds = list.stream().map(ProjectTaskRespVO::getId) + .filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new)); + Map> assigneeMap = taskAssigneeService + .loadActiveAssigneesGroupedByTaskId(taskIds); + Map spentMinutesMap = taskWorklogService.sumDurationGroupedByTaskIds(taskIds); + Set 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 nicknameMap = loadOwnerNicknameMap(userIdsToResolve); + list.forEach(vo -> { + vo.setOwnerNickname(nicknameMap.get(vo.getOwnerId())); + List 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)); + }); + return voPageResult; + } + + private List buildAssigneeViews(List activeList) { + if (activeList == null || activeList.isEmpty()) { + return List.of(); + } + Set userIds = activeList.stream().map(TaskAssigneeDO::getUserId) + .filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new)); + Map nicknameMap = loadOwnerNicknameMap(userIds); + return activeList.stream() + .map(a -> toAssigneeView(a, nicknameMap.get(a.getUserId()))) + .collect(Collectors.toList()); + } + + private ProjectTaskRespVO.TaskAssigneeView toAssigneeView(TaskAssigneeDO assignee, String nickname) { + ProjectTaskRespVO.TaskAssigneeView view = new ProjectTaskRespVO.TaskAssigneeView(); + view.setId(assignee.getId()); + view.setUserId(assignee.getUserId()); + view.setNickname(nickname); + return view; + } + + @Override + @Transactional(rollbackFor = Exception.class) + @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", + permission = ProjectTaskConstants.PERMISSION_STATUS) + public void changeTaskStatus(Long projectId, Long executionId, Long taskId, ProjectTaskStatusActionReqVO reqVO) { + 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); + + int updateCount = projectTaskMapper.updateStatusByIdAndStatus(taskId, 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); + } + + /** + * 根据通用状态语义位推导实际开始/结束日期: + * - 首次离开初始态(fromStatus.initialFlag=true)且未填写时,写入 actualStartDate + * - 进入终态(toStatus.terminalFlag=true)且未填写时,写入 actualEndDate + */ + private void maybeFillActualDates(ProjectTaskDO task, String fromStatus, String toStatus) { + LocalDate today = LocalDate.now(); + LocalDate newActualStart = null; + LocalDate newActualEnd = null; + if (task.getActualStartDate() == null) { + ObjectStatusModelDO fromModel = objectStatusModelMapper + .selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, fromStatus); + if (fromModel != null && Boolean.TRUE.equals(fromModel.getInitialFlag())) { + newActualStart = today; + } + } + if (task.getActualEndDate() == null) { + ObjectStatusModelDO toModel = objectStatusModelMapper + .selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, toStatus); + if (toModel != null && Boolean.TRUE.equals(toModel.getTerminalFlag())) { + newActualEnd = today; + } + } + if (newActualStart == null && newActualEnd == null) { + return; + } + projectTaskMapper.updateActualDatesById(task.getId(), newActualStart, newActualEnd); + if (newActualStart != null) { + task.setActualStartDate(newActualStart); + } + if (newActualEnd != null) { + task.setActualEndDate(newActualEnd); + } + } + + private ProjectDO validateEditableProject(Long projectId) { + ProjectDO project = validateProjectExists(projectId); + ObjectStatusModelDO statusModel = objectStatusModelMapper + .selectByObjectTypeAndStatusCodeEnabled(ProjectObjectConstants.OBJECT_TYPE, project.getStatusCode()); + if (statusModel == null) { + throw exception(ErrorCodeConstants.PROJECT_STATUS_MODEL_NOT_EXISTS_OR_DISABLED); + } + if (!Boolean.TRUE.equals(statusModel.getAllowEdit())) { + throw exception(ErrorCodeConstants.PROJECT_TASK_NOT_ALLOW_EDIT); + } + return project; + } + + private ProjectDO validateProjectExists(Long projectId) { + if (projectId == null) { + throw exception(ErrorCodeConstants.PROJECT_NOT_EXISTS); + } + ProjectDO project = projectMapper.selectById(projectId); + if (project == null) { + throw exception(ErrorCodeConstants.PROJECT_NOT_EXISTS); + } + return project; + } + + private ProjectExecutionDO validateExecutionExists(Long projectId, Long executionId) { + if (executionId == null) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_NOT_EXISTS); + } + ProjectExecutionDO execution = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId); + if (execution == null) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_NOT_EXISTS); + } + return execution; + } + + private void validateExecutionAllowEdit(ProjectExecutionDO execution) { + ObjectStatusModelDO statusModel = objectStatusModelMapper + .selectByObjectTypeAndStatusCodeEnabled(ProjectExecutionConstants.OBJECT_TYPE, execution.getStatusCode()); + if (statusModel == null) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_MODEL_NOT_EXISTS_OR_DISABLED); + } + if (!Boolean.TRUE.equals(statusModel.getAllowEdit())) { + throw exception(ErrorCodeConstants.PROJECT_TASK_NOT_ALLOW_EDIT); + } + } + + private ProjectTaskDO validateTaskExists(Long projectId, Long executionId, Long taskId) { + if (taskId == null) { + throw exception(ErrorCodeConstants.PROJECT_TASK_NOT_EXISTS); + } + ProjectTaskDO task = projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId); + if (task == null) { + throw exception(ErrorCodeConstants.PROJECT_TASK_NOT_EXISTS); + } + return task; + } + + private ProjectTaskDO validateParentTask(Long projectId, Long executionId, Long parentTaskId) { + if (parentTaskId == null) { + return null; + } + ProjectTaskDO parentTask = projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId); + if (parentTask == null) { + throw exception(ErrorCodeConstants.PROJECT_TASK_PARENT_INVALID); + } + return parentTask; + } + + private void validateTaskAllowEdit(ProjectTaskDO task) { + ObjectStatusModelDO statusModel = objectStatusModelMapper + .selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, task.getStatusCode()); + if (statusModel == null) { + throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_MODEL_NOT_EXISTS_OR_DISABLED); + } + if (!Boolean.TRUE.equals(statusModel.getAllowEdit())) { + throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_NOT_ALLOW_EDIT); + } + } + + private void validateOwnerIsActiveExecutionMember(Long executionId, Long ownerId) { + if (ownerId == null || executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, ownerId) == null) { + throw exception(ErrorCodeConstants.PROJECT_TASK_OWNER_INVALID); + } + } + + private Long resolveTaskOwner(Long reqOwnerId, ProjectTaskDO parentTask) { + if (reqOwnerId != null) { + return reqOwnerId; + } + if (parentTask != null) { + return parentTask.getOwnerId(); + } + throw exception(ErrorCodeConstants.PROJECT_TASK_OWNER_INVALID); + } + + private ObjectStatusTransitionDO validateTaskTransition(String fromStatusCode, String actionCode, String reason) { + ObjectStatusTransitionDO transition = objectStatusTransitionMapper + .selectByObjectTypeAndFromStatusAndAction(ProjectTaskConstants.OBJECT_TYPE, fromStatusCode, actionCode); + if (transition == null) { + throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_ACTION_NOT_ALLOWED, actionCode); + } + if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(reason)) { + throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_ACTION_REASON_REQUIRED, transition.getActionCode()); + } + return transition; + } + + /** + * 进入终态前置校验:当目标状态属于终态(terminalFlag=true)时,要求所有子任务已进入终态, + * 避免父任务关闭后子任务仍处于未结束状态。判定基于通用语义位,不识别具体 statusCode。 + * 进度合理性(如完成时进度 100%)由前端在动作触发前的交互层把关。 + */ + private void validateBeforeStatusChange(ProjectTaskDO task, String toStatus) { + ObjectStatusModelDO toModel = objectStatusModelMapper + .selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, toStatus); + if (toModel == null || !Boolean.TRUE.equals(toModel.getTerminalFlag())) { + return; + } + List terminalStatusCodes = objectStatusModelMapper + .selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE); + Integer openChildrenCount = projectTaskMapper.countChildrenNotInStatus(task.getId(), terminalStatusCodes); + if (openChildrenCount != null && openChildrenCount > 0) { + throw exception(ErrorCodeConstants.PROJECT_TASK_COMPLETE_CHILDREN_REQUIRED); + } + } + + private String getInitialTaskStatusCode() { + ObjectStatusModelDO statusModel = objectStatusModelMapper + .selectInitialByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE); + if (statusModel == null || !StringUtils.hasText(statusModel.getStatusCode())) { + throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_MODEL_NOT_EXISTS_OR_DISABLED); + } + return statusModel.getStatusCode(); + } + + private void writeTaskAuditLog(ProjectTaskDO task, + String actionType, + String fromStatus, + String toStatus, + String fieldChanges, + String reason) { + BizAuditLogDO auditLog = new BizAuditLogDO(); + auditLog.setBizType(ProjectTaskConstants.BIZ_TYPE); + auditLog.setBizId(task.getId()); + auditLog.setActionType(actionType); + auditLog.setFromStatus(fromStatus); + auditLog.setToStatus(toStatus); + auditLog.setFieldChanges(fieldChanges); + auditLog.setReason(reason); + auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId()); + auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname())); + bizAuditLogMapper.insert(auditLog); + } + + private void writeTaskStatusLog(ProjectTaskDO task, String actionType, String fromStatus, + String toStatus, String reason) { + ProjectTaskStatusLogDO statusLog = new ProjectTaskStatusLogDO(); + statusLog.setTaskId(task.getId()); + statusLog.setActionType(actionType); + statusLog.setFromStatus(fromStatus); + statusLog.setToStatus(toStatus); + statusLog.setReason(defaultText(reason)); + statusLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId()); + statusLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname())); + statusLog.setTaskTitleSnapshot(task.getTaskTitle()); + projectTaskStatusLogMapper.insert(statusLog); + } + + private ProjectTaskDO cloneTask(ProjectTaskDO source) { + ProjectTaskDO target = new ProjectTaskDO(); + target.setId(source.getId()); + target.setProjectId(source.getProjectId()); + target.setExecutionId(source.getExecutionId()); + target.setParentTaskId(source.getParentTaskId()); + target.setTaskTitle(source.getTaskTitle()); + target.setOwnerId(source.getOwnerId()); + target.setStatusCode(source.getStatusCode()); + target.setProgressRate(source.getProgressRate()); + target.setPlannedStartDate(source.getPlannedStartDate()); + target.setPlannedEndDate(source.getPlannedEndDate()); + target.setActualStartDate(source.getActualStartDate()); + target.setActualEndDate(source.getActualEndDate()); + target.setTaskDesc(source.getTaskDesc()); + target.setLastStatusReason(source.getLastStatusReason()); + return target; + } + + private String buildTaskFieldChanges(ProjectTaskDO before, ProjectTaskDO after) { + Map fieldChanges = new LinkedHashMap<>(); + appendFieldChange(fieldChanges, "projectId", valueOf(before, ProjectTaskDO::getProjectId), + valueOf(after, ProjectTaskDO::getProjectId)); + appendFieldChange(fieldChanges, "executionId", valueOf(before, ProjectTaskDO::getExecutionId), + valueOf(after, ProjectTaskDO::getExecutionId)); + appendFieldChange(fieldChanges, "parentTaskId", valueOf(before, ProjectTaskDO::getParentTaskId), + valueOf(after, ProjectTaskDO::getParentTaskId)); + appendFieldChange(fieldChanges, "taskTitle", valueOf(before, ProjectTaskDO::getTaskTitle), + valueOf(after, ProjectTaskDO::getTaskTitle)); + appendFieldChange(fieldChanges, "ownerId", valueOf(before, ProjectTaskDO::getOwnerId), + valueOf(after, ProjectTaskDO::getOwnerId)); + appendFieldChange(fieldChanges, "statusCode", valueOf(before, ProjectTaskDO::getStatusCode), + valueOf(after, ProjectTaskDO::getStatusCode)); + appendFieldChange(fieldChanges, "progressRate", valueOf(before, ProjectTaskDO::getProgressRate), + valueOf(after, ProjectTaskDO::getProgressRate)); + appendFieldChange(fieldChanges, "plannedStartDate", valueOf(before, ProjectTaskDO::getPlannedStartDate), + valueOf(after, ProjectTaskDO::getPlannedStartDate)); + appendFieldChange(fieldChanges, "plannedEndDate", valueOf(before, ProjectTaskDO::getPlannedEndDate), + valueOf(after, ProjectTaskDO::getPlannedEndDate)); + appendFieldChange(fieldChanges, "actualStartDate", valueOf(before, ProjectTaskDO::getActualStartDate), + valueOf(after, ProjectTaskDO::getActualStartDate)); + appendFieldChange(fieldChanges, "actualEndDate", valueOf(before, ProjectTaskDO::getActualEndDate), + valueOf(after, ProjectTaskDO::getActualEndDate)); + appendFieldChange(fieldChanges, "taskDesc", valueOf(before, ProjectTaskDO::getTaskDesc), + valueOf(after, ProjectTaskDO::getTaskDesc)); + appendFieldChange(fieldChanges, "lastStatusReason", valueOf(before, ProjectTaskDO::getLastStatusReason), + valueOf(after, ProjectTaskDO::getLastStatusReason)); + return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges); + } + + private T valueOf(ProjectTaskDO task, Function getter) { + return task == null ? null : getter.apply(task); + } + + private void appendFieldChange(Map fieldChanges, String fieldName, Object before, Object after) { + if (Objects.equals(before, after)) { + return; + } + Map value = new LinkedHashMap<>(); + value.put("before", before); + value.put("after", after); + fieldChanges.put(fieldName, value); + } + + private void validateDateRange(LocalDate startDate, LocalDate endDate, String message) { + if (startDate == null || endDate == null) { + return; + } + if (endDate.isBefore(startDate)) { + throw invalidParamException(message); + } + } + + private BigDecimal normalizeProgress(BigDecimal value) { + return value == null ? BigDecimal.ZERO : value; + } + + /** + * 父任务进度由子任务自动汇总,不接受手工修改。 + *

    + *
  • 当前任务是叶子(无子):返回 normalizeProgress 后的入参,沿用 owner 手填模式。
  • + *
  • 当前任务是父(≥1 个子):忽略入参变更,保留数据库原值;若入参与原值数值不一致则抛错。 + * 这样前端把读取到的 progressRate 原样回传不会触发拒错。
  • + *
+ */ + 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 且没有任何工时记录。已是父任务则直接放行。 + */ + private void validateLeafToParentSplit(ProjectTaskDO parentTask) { + if (parentTask == null) { + return; + } + int childCount = projectTaskMapper.countChildrenByParentTaskId(parentTask.getId()); + if (childCount > 0) { + return; + } + BigDecimal progress = parentTask.getProgressRate(); + if (progress != null && progress.compareTo(BigDecimal.ZERO) > 0) { + throw exception(ErrorCodeConstants.PROJECT_TASK_LEAF_TO_PARENT_FORBIDDEN_PROGRESS); + } + if (taskWorklogMapper.existsByTaskId(parentTask.getId())) { + throw exception(ErrorCodeConstants.PROJECT_TASK_LEAF_TO_PARENT_FORBIDDEN_WORKLOG); + } + } + + /** + * 从给定父任务向上递归刷新进度:父进度 = AVG(直接子.progressRate),scale=2 HALF_UP。 + * 终止条件:parentTaskId 为 null(已到根);或当前层进度未发生变化(截断进一步刷新)。 + * 若父任务的子集合在并发场景下变为空(极少见),保留原值不动。 + */ + private void recalcParentProgressFrom(Long parentTaskId) { + Long current = parentTaskId; + while (current != null) { + ProjectTaskDO parent = projectTaskMapper.selectById(current); + if (parent == null) { + return; + } + List children = projectTaskMapper.selectChildrenProgressByParentTaskId(current); + if (children.isEmpty()) { + return; + } + BigDecimal sum = BigDecimal.ZERO; + for (ProjectTaskDO child : children) { + BigDecimal cp = child.getProgressRate() == null ? BigDecimal.ZERO : child.getProgressRate(); + sum = sum.add(cp); + } + BigDecimal avg = sum.divide(BigDecimal.valueOf(children.size()), 2, RoundingMode.HALF_UP); + if (progressNumericallyEquals(avg, parent.getProgressRate())) { + return; + } + projectTaskMapper.updateProgressRateById(current, avg); + current = parent.getParentTaskId(); + } + } + + private boolean progressNumericallyEquals(BigDecimal a, BigDecimal b) { + if (a == null && b == null) { + return true; + } + if (a == null || b == null) { + return false; + } + return a.compareTo(b) == 0; + } + + private String normalizeRequiredTitle(String value) { + if (!StringUtils.hasText(value)) { + throw invalidParamException("任务标题不能为空"); + } + return value.trim(); + } + + private String normalizeRequiredActionCode(String value) { + if (!StringUtils.hasText(value)) { + throw invalidParamException("动作编码不能为空"); + } + return value.trim(); + } + + private String normalizeNullableText(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + return value.trim(); + } + + private String defaultText(String value) { + return StringUtils.hasText(value) ? value : ""; + } + + private void applyLifecycle(ProjectTaskRespVO respVO) { + ProjectTaskStatusViewService.ProjectTaskLifecycleView lifecycle = + projectTaskStatusViewService.getLifecycle(respVO.getStatusCode()); + respVO.setStatusName(lifecycle.statusName()); + respVO.setTerminal(lifecycle.terminal()); + respVO.setAllowEdit(lifecycle.allowEdit()); + respVO.setAvailableActions(lifecycle.availableActions()); + } + + private String loadOwnerNickname(Long ownerId) { + if (ownerId == null) { + return null; + } + CommonResult result = adminUserApi.getUser(ownerId); + AdminUserRespDTO user = result == null ? null : result.getCheckedData(); + return user == null ? null : user.getNickname(); + } + + private Map loadOwnerNicknameMap(Collection ownerIds) { + if (ownerIds == null || ownerIds.isEmpty()) { + return Collections.emptyMap(); + } + Map userMap = adminUserApi.getUserMap(ownerIds); + if (userMap == null || userMap.isEmpty()) { + return Collections.emptyMap(); + } + Map nicknameMap = new HashMap<>(userMap.size()); + userMap.forEach((id, user) -> nicknameMap.put(id, user == null ? null : user.getNickname())); + return nicknameMap; + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskStatusViewService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskStatusViewService.java new file mode 100644 index 0000000..be0fb05 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskStatusViewService.java @@ -0,0 +1,61 @@ +package com.njcn.rdms.module.project.service.project.task; + +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; +import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO; +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 jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; + +import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception; + +@Service +public class ProjectTaskStatusViewService { + + @Resource + private ObjectStatusModelMapper objectStatusModelMapper; + @Resource + private ObjectStatusTransitionMapper objectStatusTransitionMapper; + + public ProjectTaskLifecycleView getLifecycle(String statusCode) { + ObjectStatusModelDO statusModel = objectStatusModelMapper + .selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, statusCode); + if (statusModel == null) { + throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_MODEL_NOT_EXISTS_OR_DISABLED); + } + return new ProjectTaskLifecycleView( + statusModel.getStatusName(), + statusModel.getTerminalFlag(), + statusModel.getAllowEdit(), + buildAvailableActions(statusCode) + ); + } + + private List buildAvailableActions(String statusCode) { + List 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(); + } + + public record ProjectTaskLifecycleView(String statusName, + Boolean terminal, + Boolean allowEdit, + List availableActions) { + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/assignee/TaskAssigneeService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/assignee/TaskAssigneeService.java new file mode 100644 index 0000000..612d9f8 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/assignee/TaskAssigneeService.java @@ -0,0 +1,57 @@ +package com.njcn.rdms.module.project.service.project.task.assignee; + +import com.njcn.rdms.framework.common.pojo.PageResult; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.assignee.TaskAssigneeInactiveReqVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.assignee.TaskAssigneeLogPageReqVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.assignee.TaskAssigneeLogRespVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.assignee.TaskAssigneeRespVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.assignee.TaskAssigneeSaveReqVO; +import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskAssigneeDO; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * 任务协办人 Service。 + * 协办人采用 B 模型 - 多行周期记录:每次加入新增一条 active 行 + 一条 join 日志; + * 失效时 active 行 setRemovedAt + 写一条 inactive 日志。日志中昵称快照写入时落库不再回查。 + */ +public interface TaskAssigneeService { + + /** + * 获取指定任务的当前活跃协办人列表(含昵称装配)。 + */ + List getAssigneeList(Long projectId, Long executionId, Long taskId); + + /** + * 加入协办人(独立接口入口)。校验:项目/执行/任务可编辑、user 是有效执行成员、 + * 不能与 owner 重复、不能已有活跃段。返回新建的协办关系编号。 + */ + Long createAssignee(Long projectId, Long executionId, Long taskId, TaskAssigneeSaveReqVO reqVO); + + /** + * 失效协办人(独立接口入口)。校验:记录存在、当前活跃、reason 非空。 + */ + void inactiveAssignee(Long projectId, Long executionId, Long taskId, Long assigneeId, + TaskAssigneeInactiveReqVO reqVO); + + /** + * 分页查询协办人变更历史。日志中昵称快照已写入,无需回查。 + */ + PageResult getAssigneeLogPage(Long projectId, Long executionId, Long taskId, + TaskAssigneeLogPageReqVO reqVO); + + /** + * 创建任务时一次性初始化协办人列表(仅供 ProjectTaskServiceImpl.createTask 调用)。 + * 不重复校验项目/执行/任务编辑权限(外层已保证);同一事务内全部插入或整笔回滚。 + * 列表会自动去重;任一项校验失败抛出异常。assigneeUserIds 为 null 或空时直接返回。 + */ + void initializeAssignees(Long taskId, Long ownerId, Long executionId, List assigneeUserIds); + + /** + * 批量加载多个任务的活跃协办人,按任务 ID 分组返回。供分页装配使用,避免 N+1。 + */ + Map> loadActiveAssigneesGroupedByTaskId(Collection taskIds); + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/assignee/TaskAssigneeServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/assignee/TaskAssigneeServiceImpl.java new file mode 100644 index 0000000..772f8d7 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/assignee/TaskAssigneeServiceImpl.java @@ -0,0 +1,285 @@ +package com.njcn.rdms.module.project.service.project.task.assignee; + +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.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.assignee.TaskAssigneeInactiveReqVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.assignee.TaskAssigneeLogPageReqVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.assignee.TaskAssigneeLogRespVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.assignee.TaskAssigneeRespVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.assignee.TaskAssigneeSaveReqVO; +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.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.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.system.api.user.AdminUserApi; +import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception; + +@Service +public class TaskAssigneeServiceImpl implements TaskAssigneeService { + + @Resource + private ProjectMapper projectMapper; + @Resource + private ProjectExecutionMapper projectExecutionMapper; + @Resource + private ProjectTaskMapper projectTaskMapper; + @Resource + private ExecutionMemberMapper executionMemberMapper; + @Resource + private TaskAssigneeMapper taskAssigneeMapper; + @Resource + private TaskAssigneeLogMapper taskAssigneeLogMapper; + @Resource + private ObjectStatusModelMapper objectStatusModelMapper; + @Resource + private AdminUserApi adminUserApi; + + @Override + public List getAssigneeList(Long projectId, Long executionId, Long taskId) { + validateExecutionAndTaskExists(projectId, executionId, taskId); + List activeList = taskAssigneeMapper.selectActiveListByTaskId(taskId); + if (activeList.isEmpty()) { + return Collections.emptyList(); + } + Map nicknameMap = loadUserNicknameMap(activeList.stream() + .map(TaskAssigneeDO::getUserId).collect(Collectors.toCollection(LinkedHashSet::new))); + return activeList.stream().map(active -> { + TaskAssigneeRespVO respVO = BeanUtils.toBean(active, TaskAssigneeRespVO.class); + respVO.setUserNickname(nicknameMap.get(active.getUserId())); + return respVO; + }).collect(Collectors.toList()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createAssignee(Long projectId, Long executionId, Long taskId, TaskAssigneeSaveReqVO reqVO) { + ProjectTaskDO task = validateEditableContext(projectId, executionId, taskId); + Long userId = reqVO.getUserId(); + validateAssigneeCandidate(executionId, taskId, task.getOwnerId(), userId); + return doInsertAssignee(taskId, userId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void inactiveAssignee(Long projectId, Long executionId, Long taskId, Long assigneeId, + TaskAssigneeInactiveReqVO reqVO) { + validateEditableContext(projectId, executionId, taskId); + TaskAssigneeDO assignee = taskAssigneeMapper.selectByIdAndTaskId(assigneeId, taskId); + if (assignee == null) { + throw exception(ErrorCodeConstants.PROJECT_TASK_ASSIGNEE_NOT_EXISTS); + } + if (assignee.getRemovedAt() != null) { + throw exception(ErrorCodeConstants.PROJECT_TASK_ASSIGNEE_NOT_ACTIVE); + } + String reason = normalizeNullableText(reqVO.getReason()); + if (!StringUtils.hasText(reason)) { + throw exception(ErrorCodeConstants.PROJECT_TASK_ASSIGNEE_REASON_REQUIRED); + } + LocalDateTime now = LocalDateTime.now(); + assignee.setRemovedAt(now); + taskAssigneeMapper.updateById(assignee); + writeAssigneeLog(assignee.getTaskId(), assignee.getUserId(), + ObjectActivityConstants.TASK_ASSIGNEE_ACTION_INACTIVE, reason, now); + } + + @Override + public PageResult getAssigneeLogPage(Long projectId, Long executionId, Long taskId, + TaskAssigneeLogPageReqVO reqVO) { + validateExecutionAndTaskExists(projectId, executionId, taskId); + PageResult page = taskAssigneeLogMapper.selectPageByTaskId(taskId, reqVO); + return BeanUtils.toBean(page, TaskAssigneeLogRespVO.class); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void initializeAssignees(Long taskId, Long ownerId, Long executionId, List assigneeUserIds) { + if (assigneeUserIds == null || assigneeUserIds.isEmpty()) { + return; + } + // 保留首次出现顺序的去重 + Set uniqueUserIds = new LinkedHashSet<>(assigneeUserIds); + for (Long userId : uniqueUserIds) { + validateAssigneeCandidate(executionId, taskId, ownerId, userId); + doInsertAssignee(taskId, userId); + } + } + + @Override + public Map> loadActiveAssigneesGroupedByTaskId(Collection taskIds) { + if (taskIds == null || taskIds.isEmpty()) { + return Collections.emptyMap(); + } + List all = taskAssigneeMapper.selectActiveListByTaskIds(taskIds); + return all.stream().collect(Collectors.groupingBy(TaskAssigneeDO::getTaskId)); + } + + // -------------------- 内部辅助 -------------------- + + /** + * 校验项目、执行、任务都允许编辑,并返回任务实体。 + * 复用 ProjectTaskServiceImpl 的同口径校验,避免跨 Service 互相依赖。 + */ + private ProjectTaskDO validateEditableContext(Long projectId, Long executionId, Long taskId) { + ProjectDO project = validateProjectExists(projectId); + validateAllowEdit(ProjectObjectConstants.OBJECT_TYPE, project.getStatusCode()); + ProjectExecutionDO execution = validateExecutionExists(projectId, executionId); + validateAllowEdit(ProjectExecutionConstants.OBJECT_TYPE, execution.getStatusCode()); + ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId); + validateAllowEdit(ProjectTaskConstants.OBJECT_TYPE, task.getStatusCode()); + return task; + } + + private ProjectTaskDO validateExecutionAndTaskExists(Long projectId, Long executionId, Long taskId) { + validateExecutionExists(projectId, executionId); + return validateTaskExists(projectId, executionId, taskId); + } + + private ProjectDO validateProjectExists(Long projectId) { + if (projectId == null) { + throw exception(ErrorCodeConstants.PROJECT_NOT_EXISTS); + } + ProjectDO project = projectMapper.selectById(projectId); + if (project == null) { + throw exception(ErrorCodeConstants.PROJECT_NOT_EXISTS); + } + return project; + } + + private ProjectExecutionDO validateExecutionExists(Long projectId, Long executionId) { + if (executionId == null) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_NOT_EXISTS); + } + ProjectExecutionDO execution = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId); + if (execution == null) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_NOT_EXISTS); + } + return execution; + } + + private ProjectTaskDO validateTaskExists(Long projectId, Long executionId, Long taskId) { + if (taskId == null) { + throw exception(ErrorCodeConstants.PROJECT_TASK_NOT_EXISTS); + } + ProjectTaskDO task = projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId); + if (task == null) { + throw exception(ErrorCodeConstants.PROJECT_TASK_NOT_EXISTS); + } + return task; + } + + private void validateAllowEdit(String objectType, String statusCode) { + ObjectStatusModelDO status = objectStatusModelMapper + .selectByObjectTypeAndStatusCodeEnabled(objectType, statusCode); + if (status == null || !Boolean.TRUE.equals(status.getAllowEdit())) { + throw exception(ErrorCodeConstants.PROJECT_TASK_NOT_ALLOW_EDIT); + } + } + + /** + * 校验单个候选协办人:与 owner 不能重复、必须是当前有效执行成员、不能已有活跃段。 + */ + private void validateAssigneeCandidate(Long executionId, Long taskId, Long ownerId, Long userId) { + if (userId == null) { + throw exception(ErrorCodeConstants.PROJECT_TASK_ASSIGNEE_INVALID_MEMBER); + } + if (Objects.equals(userId, ownerId)) { + throw exception(ErrorCodeConstants.PROJECT_TASK_ASSIGNEE_OWNER_CONFLICT); + } + if (executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, userId) == null) { + throw exception(ErrorCodeConstants.PROJECT_TASK_ASSIGNEE_INVALID_MEMBER); + } + if (taskAssigneeMapper.selectActiveByTaskIdAndUserId(taskId, userId) != null) { + throw exception(ErrorCodeConstants.PROJECT_TASK_ASSIGNEE_ALREADY_ACTIVE); + } + } + + /** + * 执行 INSERT 活跃 + INSERT 日志(join),返回新建协办关系编号。 + */ + private Long doInsertAssignee(Long taskId, Long userId) { + LocalDateTime now = LocalDateTime.now(); + TaskAssigneeDO assignee = new TaskAssigneeDO(); + assignee.setTaskId(taskId); + assignee.setUserId(userId); + assignee.setJoinedAt(now); + assignee.setRemovedAt(null); + taskAssigneeMapper.insert(assignee); + writeAssigneeLog(taskId, userId, ObjectActivityConstants.TASK_ASSIGNEE_ACTION_JOIN, null, now); + return assignee.getId(); + } + + private void writeAssigneeLog(Long taskId, Long userId, String actionType, String reason, LocalDateTime when) { + TaskAssigneeLogDO log = new TaskAssigneeLogDO(); + log.setTaskId(taskId); + log.setUserId(userId); + log.setUserNicknameSnapshot(loadUserNickname(userId)); + log.setActionType(actionType); + log.setReason(reason); + log.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId()); + log.setOperatorNicknameSnapshot(SecurityFrameworkUtils.getLoginUserNickname()); + log.setActionTime(when); + taskAssigneeLogMapper.insert(log); + } + + private String loadUserNickname(Long userId) { + if (userId == null) { + return null; + } + CommonResult result = adminUserApi.getUser(userId); + AdminUserRespDTO user = result == null ? null : result.getCheckedData(); + return user == null ? null : user.getNickname(); + } + + private Map loadUserNicknameMap(Collection userIds) { + if (userIds == null || userIds.isEmpty()) { + return Collections.emptyMap(); + } + Map userMap = adminUserApi.getUserMap(userIds); + if (userMap == null || userMap.isEmpty()) { + return Collections.emptyMap(); + } + Map nicknameMap = new HashMap<>(userMap.size()); + userMap.forEach((id, user) -> nicknameMap.put(id, user == null ? null : user.getNickname())); + return nicknameMap; + } + + private String normalizeNullableText(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + return value.trim(); + } +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/worklog/TaskWorklogService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/worklog/TaskWorklogService.java new file mode 100644 index 0000000..14fa6d3 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/worklog/TaskWorklogService.java @@ -0,0 +1,62 @@ +package com.njcn.rdms.module.project.service.project.task.worklog; + +import com.njcn.rdms.framework.common.pojo.PageResult; +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 java.util.Collection; +import java.util.Collections; +import java.util.Map; + +/** + * 任务工时 Service。 + *

+ * 决策来源:docs/任务工时与进度模型_业内标杆调研.md。 + * 核心约束:仅叶子任务可挂工时;填报人限制为 owner + 在岗协办人;时长按分钟存(30 分钟整数倍); + * 同一 user × task × work_date 允许多条;改/删自己的,owner 可删别人的。 + */ +public interface TaskWorklogService { + + /** + * 任务工时分页(按 workDate DESC, id DESC)。 + * 支持按填报人 / 日期区间筛选;上下文校验仅要求项目/执行/任务存在,不要求允许编辑。 + */ + PageResult getWorklogPage(Long projectId, Long executionId, Long taskId, + TaskWorklogPageReqVO reqVO); + + /** + * 新增工时记录。校验:上下文可编辑、任务为叶子、登录人是 owner 或在岗协办人、时长合法。 + * 填报人 userId 取登录用户,前端不传。返回新建记录编号。 + */ + Long createWorklog(Long projectId, Long executionId, Long taskId, TaskWorklogSaveReqVO reqVO); + + /** + * 修改工时记录(含工作日期/时长/工作内容)。校验:记录归属任务、登录人是该记录原填报人、时长合法。 + * 不允许修改 taskId / userId(前端不可传)。 + */ + void updateWorklog(Long projectId, Long executionId, Long taskId, Long worklogId, + TaskWorklogSaveReqVO reqVO); + + /** + * 删除工时记录。校验:记录归属任务、登录人是原填报人或当前任务负责人。逻辑删除。 + */ + void deleteWorklog(Long projectId, Long executionId, Long taskId, Long worklogId); + + /** + * 批量任务工时汇总(分钟),用于详情/分页装配 totalSpentMinutes,避免 N+1。 + * 任务无任何工时时不会出现在结果中(调用方需用默认 0 兜底)。 + */ + Map sumDurationGroupedByTaskIds(Collection taskIds); + + /** + * 单任务工时汇总(分钟)。无记录返回 0。 + */ + default long sumDurationByTaskId(Long taskId) { + if (taskId == null) { + return 0L; + } + return sumDurationGroupedByTaskIds(Collections.singletonList(taskId)).getOrDefault(taskId, 0L); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/worklog/TaskWorklogServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/worklog/TaskWorklogServiceImpl.java new file mode 100644 index 0000000..c7eeca8 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/worklog/TaskWorklogServiceImpl.java @@ -0,0 +1,286 @@ +package com.njcn.rdms.module.project.service.project.task.worklog; + +import com.njcn.rdms.framework.common.pojo.PageResult; +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.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.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.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 jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception; + +@Service +public class TaskWorklogServiceImpl implements TaskWorklogService { + + /** + * 工时颗粒:30 分钟(0.5 小时)。duration_minutes 必须 > 0 且为本值的整数倍。 + */ + private static final int DURATION_GRANULARITY_MINUTES = 30; + + @Resource + private ProjectMapper projectMapper; + @Resource + private ProjectExecutionMapper projectExecutionMapper; + @Resource + private ProjectTaskMapper projectTaskMapper; + @Resource + private TaskAssigneeMapper taskAssigneeMapper; + @Resource + private TaskWorklogMapper taskWorklogMapper; + @Resource + private ObjectStatusModelMapper objectStatusModelMapper; + @Resource + private AdminUserApi adminUserApi; + + @Override + public PageResult getWorklogPage(Long projectId, Long executionId, Long taskId, + TaskWorklogPageReqVO reqVO) { + validateExecutionAndTaskExists(projectId, executionId, taskId); + PageResult page = taskWorklogMapper.selectPageByTaskId(taskId, reqVO); + if (page.getList().isEmpty()) { + return new PageResult<>(Collections.emptyList(), page.getTotal()); + } + Map nicknameMap = loadUserNicknameMap(page.getList().stream() + .map(TaskWorklogDO::getUserId) + .collect(Collectors.toCollection(LinkedHashSet::new))); + List list = page.getList().stream().map(worklog -> { + TaskWorklogRespVO vo = BeanUtils.toBean(worklog, TaskWorklogRespVO.class); + vo.setUserNickname(nicknameMap.get(worklog.getUserId())); + return vo; + }).collect(Collectors.toList()); + return new PageResult<>(list, page.getTotal()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + 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()); + + TaskWorklogDO worklog = new TaskWorklogDO(); + worklog.setTaskId(taskId); + worklog.setUserId(loginUserId); + worklog.setWorkDate(reqVO.getWorkDate()); + worklog.setDurationMinutes(reqVO.getDurationMinutes()); + worklog.setWorkContent(normalizeNullableText(reqVO.getWorkContent())); + taskWorklogMapper.insert(worklog); + return worklog.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateWorklog(Long projectId, Long executionId, Long taskId, Long worklogId, + TaskWorklogSaveReqVO reqVO) { + 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()); + + TaskWorklogDO update = new TaskWorklogDO(); + update.setId(worklog.getId()); + update.setWorkDate(reqVO.getWorkDate()); + update.setDurationMinutes(reqVO.getDurationMinutes()); + update.setWorkContent(normalizeNullableText(reqVO.getWorkContent())); + taskWorklogMapper.updateById(update); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteWorklog(Long projectId, Long executionId, Long taskId, Long worklogId) { + ProjectTaskDO task = validateEditableContext(projectId, executionId, taskId); + TaskWorklogDO worklog = loadWorklog(worklogId, taskId); + Long loginUserId = SecurityFrameworkUtils.getLoginUserId(); + boolean isFiler = Objects.equals(worklog.getUserId(), loginUserId); + boolean isOwner = Objects.equals(task.getOwnerId(), loginUserId); + if (!isFiler && !isOwner) { + throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DELETE_FORBIDDEN); + } + taskWorklogMapper.deleteById(worklog.getId()); + } + + @Override + public Map sumDurationGroupedByTaskIds(Collection taskIds) { + if (taskIds == null || taskIds.isEmpty()) { + return Collections.emptyMap(); + } + List> rows = taskWorklogMapper.sumDurationGroupByTaskIds(taskIds); + if (rows == null || rows.isEmpty()) { + return Collections.emptyMap(); + } + Map result = new HashMap<>(rows.size()); + for (Map 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()); + } + } + return result; + } + + // -------------------- 内部辅助 -------------------- + + /** + * 校验项目/执行/任务都允许编辑,并返回任务实体。与 TaskAssigneeServiceImpl 同口径。 + */ + private ProjectTaskDO validateEditableContext(Long projectId, Long executionId, Long taskId) { + ProjectDO project = validateProjectExists(projectId); + validateAllowEdit(ProjectObjectConstants.OBJECT_TYPE, project.getStatusCode()); + ProjectExecutionDO execution = validateExecutionExists(projectId, executionId); + validateAllowEdit(ProjectExecutionConstants.OBJECT_TYPE, execution.getStatusCode()); + ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId); + validateAllowEdit(ProjectTaskConstants.OBJECT_TYPE, task.getStatusCode()); + return task; + } + + private void validateExecutionAndTaskExists(Long projectId, Long executionId, Long taskId) { + validateExecutionExists(projectId, executionId); + validateTaskExists(projectId, executionId, taskId); + } + + private ProjectDO validateProjectExists(Long projectId) { + if (projectId == null) { + throw exception(ErrorCodeConstants.PROJECT_NOT_EXISTS); + } + ProjectDO project = projectMapper.selectById(projectId); + if (project == null) { + throw exception(ErrorCodeConstants.PROJECT_NOT_EXISTS); + } + return project; + } + + private ProjectExecutionDO validateExecutionExists(Long projectId, Long executionId) { + if (executionId == null) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_NOT_EXISTS); + } + ProjectExecutionDO execution = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId); + if (execution == null) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_NOT_EXISTS); + } + return execution; + } + + private ProjectTaskDO validateTaskExists(Long projectId, Long executionId, Long taskId) { + if (taskId == null) { + throw exception(ErrorCodeConstants.PROJECT_TASK_NOT_EXISTS); + } + ProjectTaskDO task = projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId); + if (task == null) { + throw exception(ErrorCodeConstants.PROJECT_TASK_NOT_EXISTS); + } + return task; + } + + private void validateAllowEdit(String objectType, String statusCode) { + ObjectStatusModelDO status = objectStatusModelMapper + .selectByObjectTypeAndStatusCodeEnabled(objectType, statusCode); + if (status == null || !Boolean.TRUE.equals(status.getAllowEdit())) { + throw exception(ErrorCodeConstants.PROJECT_TASK_NOT_ALLOW_EDIT); + } + } + + /** + * 校验任务为叶子(无任何子任务)。父任务不允许挂工时。 + */ + private void validateLeafTask(Long taskId) { + long childCount = projectTaskMapper.selectCount(new LambdaQueryWrapperX() + .eq(ProjectTaskDO::getParentTaskId, taskId)); + if (childCount > 0) { + throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_NOT_LEAF_TASK); + } + } + + /** + * 校验登录人具备工时填报资格:是 owner,或在该任务上有当前活跃的协办段。 + */ + private void validateFileWorklogPermission(Long taskId, Long ownerId, Long loginUserId) { + if (loginUserId == null) { + throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_NOT_OWNER_OR_ASSIGNEE); + } + if (Objects.equals(loginUserId, ownerId)) { + return; + } + if (taskAssigneeMapper.selectActiveByTaskIdAndUserId(taskId, loginUserId) != null) { + return; + } + 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) { + throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DURATION_INVALID); + } + } + + private TaskWorklogDO loadWorklog(Long worklogId, Long taskId) { + if (worklogId == null) { + throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_NOT_EXISTS); + } + TaskWorklogDO worklog = taskWorklogMapper.selectByIdAndTaskId(worklogId, taskId); + if (worklog == null) { + throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_NOT_EXISTS); + } + return worklog; + } + + private Map loadUserNicknameMap(Collection userIds) { + if (userIds == null || userIds.isEmpty()) { + return Collections.emptyMap(); + } + Map userMap = adminUserApi.getUserMap(userIds); + if (userMap == null || userMap.isEmpty()) { + return Collections.emptyMap(); + } + Map nicknameMap = new HashMap<>(userMap.size()); + userMap.forEach((id, user) -> nicknameMap.put(id, user == null ? null : user.getNickname())); + return nicknameMap; + } + + private String normalizeNullableText(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + return value.trim(); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/resources/application-dev.yaml b/rdms-project/rdms-project-boot/src/main/resources/application-dev.yaml index 0f82dcd..831ac25 100644 --- a/rdms-project/rdms-project-boot/src/main/resources/application-dev.yaml +++ b/rdms-project/rdms-project-boot/src/main/resources/application-dev.yaml @@ -7,12 +7,12 @@ spring: username: # Nacos 账号 password: # Nacos 密码 discovery: # 【配置中心】配置项 - namespace: dev # 命名空间。这里使用 dev 开发环境 + namespace: 1924bcfb-4eab-4c58-9003-4a37d5fc2949 # 命名空间。这里使用 dev 开发环境 group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP metadata: version: 1.0.0 # 服务实例的版本号,可用于灰度发布 config: # 【注册中心】配置项 - namespace: dev # 命名空间。这里使用 dev 开发环境 + namespace: 1924bcfb-4eab-4c58-9003-4a37d5fc2949 # 命名空间。这里使用 dev 开发环境 group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP #################### 数据库相关配置 #################### diff --git a/rdms-project/rdms-project-boot/src/main/resources/application-local.yaml b/rdms-project/rdms-project-boot/src/main/resources/application-local.yaml index 2fc5cb2..dff2ac4 100644 --- a/rdms-project/rdms-project-boot/src/main/resources/application-local.yaml +++ b/rdms-project/rdms-project-boot/src/main/resources/application-local.yaml @@ -6,12 +6,12 @@ spring: username: # Nacos 账号 password: # Nacos 密码 discovery: # 【配置中心】配置项 - namespace: dev # 命名空间。这里使用 dev 开发环境 + namespace: 1924bcfb-4eab-4c58-9003-4a37d5fc2949 # 命名空间。这里使用 dev 开发环境 group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP metadata: version: 1.0.0 # 服务实例的版本号,可用于灰度发布 config: # 【注册中心】配置项 - namespace: dev # 命名空间。这里使用 dev 开发环境 + namespace: 1924bcfb-4eab-4c58-9003-4a37d5fc2949 # 命名空间。这里使用 dev 开发环境 group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP #################### 数据库相关配置 #################### diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/controller/admin/project/execution/ProjectExecutionStatusBoardControllerTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/controller/admin/project/execution/ProjectExecutionStatusBoardControllerTest.java new file mode 100644 index 0000000..3f2ead1 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/controller/admin/project/execution/ProjectExecutionStatusBoardControllerTest.java @@ -0,0 +1,36 @@ +package com.njcn.rdms.module.project.controller.admin.project.execution; + +import com.njcn.rdms.framework.common.pojo.CommonResult; +import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest; +import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardRespVO; +import com.njcn.rdms.module.project.service.project.ProjectStatusBoardService; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +class ProjectExecutionStatusBoardControllerTest extends BaseMockitoUnitTest { + + @InjectMocks + private ProjectExecutionController projectExecutionController; + @Mock + private ProjectStatusBoardService projectStatusBoardService; + + @Test + void getExecutionStatusBoard_shouldReturnServiceResult() { + ProjectExecutionStatusBoardRespVO respVO = new ProjectExecutionStatusBoardRespVO(); + respVO.setTotal(1L); + respVO.setItems(List.of()); + when(projectStatusBoardService.getExecutionStatusBoard(2001L, null)).thenReturn(respVO); + + CommonResult result = projectExecutionController.getExecutionStatusBoard(2001L, null); + + assertEquals(0, result.getCode()); + assertEquals(1L, result.getData().getTotal()); + } + +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/controller/admin/project/task/ProjectTaskStatusBoardControllerTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/controller/admin/project/task/ProjectTaskStatusBoardControllerTest.java new file mode 100644 index 0000000..996add6 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/controller/admin/project/task/ProjectTaskStatusBoardControllerTest.java @@ -0,0 +1,36 @@ +package com.njcn.rdms.module.project.controller.admin.project.task; + +import com.njcn.rdms.framework.common.pojo.CommonResult; +import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardRespVO; +import com.njcn.rdms.module.project.service.project.ProjectStatusBoardService; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +class ProjectTaskStatusBoardControllerTest extends BaseMockitoUnitTest { + + @InjectMocks + private ProjectTaskController projectTaskController; + @Mock + private ProjectStatusBoardService projectStatusBoardService; + + @Test + void getTaskStatusBoard_shouldReturnServiceResult() { + ProjectTaskStatusBoardRespVO respVO = new ProjectTaskStatusBoardRespVO(); + respVO.setTotal(1L); + respVO.setItems(List.of()); + when(projectStatusBoardService.getTaskStatusBoard(2001L, 5001L, null)).thenReturn(respVO); + + CommonResult result = projectTaskController.getTaskStatusBoard(2001L, 5001L, null); + + assertEquals(0, result.getCode()); + assertEquals(1L, result.getData().getTotal()); + } + +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectPermissionServiceTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectPermissionServiceTest.java new file mode 100644 index 0000000..6c5e4eb --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectPermissionServiceTest.java @@ -0,0 +1,112 @@ +package com.njcn.rdms.module.project.framework.security.service; + +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.member.UserObjectRoleDO; +import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper; +import com.njcn.rdms.module.project.enums.ErrorCodeConstants; +import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi; +import com.njcn.rdms.module.system.enums.permission.PermissionScopeTypeEnum; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; + +import java.util.Set; + +import static com.njcn.rdms.framework.common.pojo.CommonResult.success; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class ProjectObjectPermissionServiceTest extends BaseMockitoUnitTest { + + @InjectMocks + private ProjectObjectPermissionService permissionService; + @Mock + private UserObjectRoleMapper userObjectRoleMapper; + @Mock + private ObjectPermissionApi objectPermissionApi; + + @Test + void checkPermission_whenMemberOnlyAndActiveMember_shouldPass() { + Long projectId = 1001L; + Long loginUserId = 2001L; + when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, loginUserId)) + .thenReturn(createMember(projectId, loginUserId, 3001L)); + + try (MockedStatic mockedStatic = mockLoginUser(loginUserId)) { + assertDoesNotThrow(() -> permissionService.checkPermission(projectId, null, true)); + } + + verifyNoInteractions(objectPermissionApi); + } + + @Test + void checkPermission_whenNoActiveMember_shouldThrowProjectPermissionDenied() { + Long projectId = 1002L; + Long loginUserId = 2002L; + when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, loginUserId)) + .thenReturn(null); + + try (MockedStatic mockedStatic = mockLoginUser(loginUserId)) { + ServiceException ex = assertThrows(ServiceException.class, + () -> permissionService.checkPermission(projectId, "project:project:update", false)); + assertEquals(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode()); + } + } + + @Test + void checkPermission_whenPermissionPresent_shouldPass() { + Long projectId = 1003L; + Long loginUserId = 2003L; + when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, loginUserId)) + .thenReturn(createMember(projectId, loginUserId, 3003L)); + when(objectPermissionApi.getObjectRolePermissions(3003L, + PermissionScopeTypeEnum.OBJECT.getScopeType(), "project")) + .thenReturn(success(Set.of("project:project:update"))); + + try (MockedStatic mockedStatic = mockLoginUser(loginUserId)) { + assertDoesNotThrow(() -> permissionService.checkPermission(projectId, "project:project:update", false)); + } + } + + @Test + void checkPermission_whenPermissionMissing_shouldThrowProjectPermissionDenied() { + Long projectId = 1004L; + Long loginUserId = 2004L; + when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, loginUserId)) + .thenReturn(createMember(projectId, loginUserId, 3004L)); + when(objectPermissionApi.getObjectRolePermissions(3004L, + PermissionScopeTypeEnum.OBJECT.getScopeType(), "project")) + .thenReturn(success(Set.of("project:project:query"))); + + try (MockedStatic mockedStatic = mockLoginUser(loginUserId)) { + ServiceException ex = assertThrows(ServiceException.class, + () -> permissionService.checkPermission(projectId, "project:project:delete", false)); + assertEquals(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode()); + } + } + + private UserObjectRoleDO createMember(Long projectId, Long loginUserId, Long roleId) { + UserObjectRoleDO member = new UserObjectRoleDO(); + member.setId(9001L); + member.setUserId(loginUserId); + member.setObjectType("project"); + member.setObjectId(projectId); + member.setRoleId(roleId); + member.setStatus(0); + return member; + } + + private MockedStatic mockLoginUser(Long loginUserId) { + MockedStatic mockedStatic = mockStatic(SecurityFrameworkUtils.class); + mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId); + return mockedStatic; + } + +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductActivityTimelineQueryServiceTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductActivityTimelineQueryServiceTest.java index acac506..d20d279 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductActivityTimelineQueryServiceTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductActivityTimelineQueryServiceTest.java @@ -186,6 +186,27 @@ class ProductActivityTimelineQueryServiceTest extends BaseMockitoUnitTest { eq(List.of("remove_member")), any(LocalDateTime.class), any(LocalDateTime.class)); } + @Test + void getProductActivityTimelinePage_whenStatusActionIsNewCode_shouldStillQueryStatusLogs() { + Long productId = 10041L; + ProductActivityTimelinePageReqVO reqVO = new ProductActivityTimelinePageReqVO(); + reqVO.setPageNo(1); + reqVO.setPageSize(10); + reqVO.setActivityType("status"); + reqVO.setActionTypes(List.of("freeze")); + + when(productStatusLogMapper.selectListByProductIdAndActions(eq(productId), eq(List.of("freeze")), + any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(List.of()); + + PageResult result = + productActivityTimelineQueryService.getProductActivityTimelinePage(productId, reqVO); + + assertEquals(0L, result.getTotal()); + verify(productStatusLogMapper).selectListByProductIdAndActions(eq(productId), eq(List.of("freeze")), + any(LocalDateTime.class), any(LocalDateTime.class)); + } + @Test void getProductActivityTimelinePage_shouldExcludeUpdateMemberAction() { Long productId = 1005L; diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductMemberServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductMemberServiceImplTest.java index 7bdf617..22745b3 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductMemberServiceImplTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductMemberServiceImplTest.java @@ -8,9 +8,11 @@ import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMe 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.product.ProductDO; +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.product.ProductMapper; +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.permission.ObjectPermissionApi; import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO; @@ -47,6 +49,8 @@ class ProductMemberServiceImplTest extends BaseMockitoUnitTest { @Mock private BizAuditLogMapper bizAuditLogMapper; @Mock + private ObjectStatusModelMapper objectStatusModelMapper; + @Mock private AdminUserApi adminUserApi; @Test @@ -85,6 +89,8 @@ class ProductMemberServiceImplTest extends BaseMockitoUnitTest { reqVO.setUserId(2003L); reqVO.setRoleId(3999L); when(productMapper.selectById(productId)).thenReturn(createProduct(productId, 2001L)); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("product", "active")) + .thenReturn(createStatus("active", true)); when(objectPermissionApi.getObjectRoleById(3999L, "object", "product")).thenReturn(success(null)); ServiceException ex = assertThrows(ServiceException.class, @@ -110,6 +116,8 @@ class ProductMemberServiceImplTest extends BaseMockitoUnitTest { ProductDO product = createProduct(productId, currentManagerUserId); UserObjectRoleDO member = createMember(memberId, productId, 2002L, 3203L, 0); when(productMapper.selectById(productId)).thenReturn(product); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("product", "active")) + .thenReturn(createStatus("active", true)); when(userObjectRoleMapper.selectByIdAndObject(memberId, "product", productId)).thenReturn(member); when(objectPermissionApi.getObjectRoleById(targetManagerRoleId, "object", "product")) .thenReturn(success(createRole(targetManagerRoleId, "product_manager", "产品经理"))); @@ -125,6 +133,26 @@ class ProductMemberServiceImplTest extends BaseMockitoUnitTest { verify(bizAuditLogMapper, never()).insert(any(BizAuditLogDO.class)); } + @Test + void createProductMember_whenStatusNotAllowEdit_shouldThrowException() { + Long productId = 1004L; + ProductMemberSaveReqVO reqVO = new ProductMemberSaveReqVO(); + reqVO.setUserId(2004L); + reqVO.setRoleId(3204L); + ProductDO product = createProduct(productId, 2001L); + product.setStatusCode("archived"); + when(productMapper.selectById(productId)).thenReturn(product); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("product", "archived")) + .thenReturn(createStatus("archived", false)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> productMemberService.createProductMember(productId, reqVO)); + + assertEquals(ErrorCodeConstants.PRODUCT_STATUS_NOT_ALLOW_EDIT.getCode(), ex.getCode()); + verify(userObjectRoleMapper, never()).insert(any(UserObjectRoleDO.class)); + verify(bizAuditLogMapper, never()).insert(any(BizAuditLogDO.class)); + } + private ProductDO createProduct(Long productId, Long managerUserId) { ProductDO product = new ProductDO(); product.setId(productId); @@ -163,4 +191,12 @@ class ProductMemberServiceImplTest extends BaseMockitoUnitTest { return user; } + private ObjectStatusModelDO createStatus(String statusCode, boolean allowEdit) { + ObjectStatusModelDO statusModel = new ObjectStatusModelDO(); + statusModel.setObjectType("product"); + statusModel.setStatusCode(statusCode); + statusModel.setAllowEdit(allowEdit); + return statusModel; + } + } diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductServiceImplTest.java index bbb8252..1599928 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductServiceImplTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductServiceImplTest.java @@ -7,6 +7,7 @@ import com.njcn.rdms.framework.common.util.json.JsonUtils; import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest; import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextRespVO; 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.ProductSaveReqVO; import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductStatusActionReqVO; import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingBaseInfoUpdateReqVO; @@ -14,11 +15,13 @@ 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.product.ProductDO; import com.njcn.rdms.module.project.dal.dataobject.product.ProductStatusLogDO; +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.dal.mysql.product.ProductMapper; import com.njcn.rdms.module.project.dal.mysql.product.ProductStatusLogMapper; +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.system.api.permission.ObjectPermissionApi; @@ -33,6 +36,7 @@ import org.mockito.MockedStatic; import org.mockito.Mock; import java.util.List; +import java.util.Map; import java.util.Set; import static com.njcn.rdms.framework.common.pojo.CommonResult.success; @@ -61,6 +65,8 @@ class ProductServiceImplTest extends BaseMockitoUnitTest { @Mock private ObjectStatusTransitionMapper objectStatusTransitionMapper; @Mock + private ObjectStatusModelMapper objectStatusModelMapper; + @Mock private UserObjectRoleMapper userObjectRoleMapper; @Mock private ObjectPermissionApi objectPermissionApi; @@ -104,6 +110,37 @@ class ProductServiceImplTest extends BaseMockitoUnitTest { assertEquals(0, defaultModule.getSort()); } + @Test + void createProduct_shouldUseConfiguredInitialStatusAndWriteCreateAudit() { + ProductSaveReqVO reqVO = new ProductSaveReqVO(); + reqVO.setCode("CNPD2026999"); + reqVO.setDirectionCode("direction_value"); + reqVO.setName("新产品"); + reqVO.setManagerUserId(2001L); + reqVO.setDescription(" 产品描述 "); + + when(productMapper.selectByCode("CNPD2026999")).thenReturn(null); + when(productMapper.selectByName("新产品")).thenReturn(null); + when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("product")) + .thenReturn(createStatus("active", true, true)); + when(objectPermissionApi.getObjectRoleByCode("product_manager", "object", "product")) + .thenReturn(success(createRole(3101L, "product_manager", "产品经理"))); + + try (MockedStatic mockedStatic = mockLoginUser(3001L, "创建人")) { + productService.createProduct(reqVO); + } + + ArgumentCaptor productCaptor = ArgumentCaptor.forClass(ProductDO.class); + verify(productMapper).insert(productCaptor.capture()); + assertEquals("active", productCaptor.getValue().getStatusCode()); + assertEquals("产品描述", productCaptor.getValue().getDescription()); + + BizAuditLogDO auditLog = captureCreateAuditLog(); + assertEquals("product", auditLog.getBizType()); + assertEquals("create", auditLog.getActionType()); + assertEquals("active", auditLog.getToStatus()); + } + @Test void updateProductBaseInfo_shouldOnlyUpdateBaseInfoAndRecordFieldChanges() { Long productId = 1002L; @@ -115,6 +152,8 @@ class ProductServiceImplTest extends BaseMockitoUnitTest { when(productMapper.selectById(productId)).thenReturn(product); when(productMapper.selectByName("新产品")).thenReturn(null); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("product", "active")) + .thenReturn(createStatus("active", false, true)); productService.updateProductBaseInfo(productId, reqVO); @@ -141,6 +180,8 @@ class ProductServiceImplTest extends BaseMockitoUnitTest { when(productMapper.selectById(productId)).thenReturn(product); when(productMapper.selectByName("新产品")).thenReturn(null); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("product", "active")) + .thenReturn(createStatus("active", false, true)); productService.updateProduct(reqVO); @@ -174,6 +215,28 @@ class ProductServiceImplTest extends BaseMockitoUnitTest { verify(bizAuditLogMapper, never()).insert(any(BizAuditLogDO.class)); } + @Test + void getProductOverviewSummary_shouldReturnFixedStatusCountsAndIgnoreUnknownStatus() { + when(objectStatusModelMapper.selectListByObjectType("product")).thenReturn(List.of( + createStatusModel("active", 10, 0), + createStatusModel("paused", 20, 0), + createStatusModel("abandoned", 30, 0), + createStatusModel("disabled", 40, 1) + )); + when(productMapper.selectStatusCountList()).thenReturn(List.of( + Map.of("statusCode", "active", "countValue", 5L), + Map.of("statusCode", "abandoned", "countValue", 1L), + Map.of("statusCode", "unknown", "countValue", 99L) + )); + + ProductOverviewSummaryRespVO respVO = productService.getProductOverviewSummary(); + + assertEquals(3, respVO.getStatusCounts().size()); + assertEquals(5L, respVO.getStatusCounts().get("active")); + assertEquals(0L, respVO.getStatusCounts().get("paused")); + assertEquals(1L, respVO.getStatusCounts().get("abandoned")); + } + @Test void updateProductBaseInfo_whenPausedAndDirectionOrNameChanged_shouldThrowException() { Long productId = 1004L; @@ -184,6 +247,8 @@ class ProductServiceImplTest extends BaseMockitoUnitTest { reqVO.setDescription("新描述"); when(productMapper.selectById(productId)).thenReturn(product); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("product", "paused")) + .thenReturn(createStatus("paused", false, true)); ServiceException ex = assertThrows(ServiceException.class, () -> productService.updateProductBaseInfo(productId, reqVO)); @@ -370,6 +435,8 @@ class ProductServiceImplTest extends BaseMockitoUnitTest { reqVO.setDescription("新描述"); when(productMapper.selectById(productId)).thenReturn(product); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("product", "archived")) + .thenReturn(createStatus("archived", false, false)); ServiceException ex = assertThrows(ServiceException.class, () -> productService.updateProductBaseInfo(productId, reqVO)); @@ -468,6 +535,25 @@ class ProductServiceImplTest extends BaseMockitoUnitTest { return transition; } + private ObjectStatusModelDO createStatus(String statusCode, boolean initialFlag, boolean allowEdit) { + ObjectStatusModelDO statusModel = new ObjectStatusModelDO(); + statusModel.setObjectType("product"); + statusModel.setStatusCode(statusCode); + statusModel.setStatusName(statusCode); + statusModel.setInitialFlag(initialFlag); + statusModel.setAllowEdit(allowEdit); + return statusModel; + } + + private ObjectStatusModelDO createStatusModel(String statusCode, Integer sort, Integer status) { + ObjectStatusModelDO statusModel = new ObjectStatusModelDO(); + statusModel.setObjectType("product"); + statusModel.setStatusCode(statusCode); + statusModel.setSort(sort); + statusModel.setStatus(status); + return statusModel; + } + private MockedStatic mockLoginUser(Long loginUserId, String nickname) { MockedStatic mockedStatic = mockStatic(SecurityFrameworkUtils.class); mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId); @@ -487,6 +573,15 @@ class ProductServiceImplTest extends BaseMockitoUnitTest { return auditCaptor.getValue(); } + private BizAuditLogDO captureCreateAuditLog() { + ArgumentCaptor auditCaptor = ArgumentCaptor.forClass(BizAuditLogDO.class); + verify(bizAuditLogMapper, times(3)).insert(auditCaptor.capture()); + return auditCaptor.getAllValues().stream() + .filter(log -> "product".equals(log.getBizType()) && "create".equals(log.getActionType())) + .findFirst() + .orElseThrow(); + } + private void assertProductBaseInfoUpdated(ProductDO updated, String code, Long managerUserId, String statusCode, String directionCode, String name, String description) { diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectMemberServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectMemberServiceImplTest.java new file mode 100644 index 0000000..5428395 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectMemberServiceImplTest.java @@ -0,0 +1,283 @@ +package com.njcn.rdms.module.project.service.project; + +import com.njcn.rdms.framework.common.exception.ServiceException; +import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberInactiveReqVO; +import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest; +import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberRespVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberSaveReqVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberUpdateReqVO; +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.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.ProjectExecutionMapper; +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.permission.ObjectPermissionApi; +import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO; +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 java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static com.njcn.rdms.framework.common.pojo.CommonResult.success; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ProjectMemberServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ProjectMemberServiceImpl projectMemberService; + @Mock + private ProjectMapper projectMapper; + @Mock + private UserObjectRoleMapper userObjectRoleMapper; + @Mock + private ObjectPermissionApi objectPermissionApi; + @Mock + private BizAuditLogMapper bizAuditLogMapper; + @Mock + private ObjectStatusModelMapper objectStatusModelMapper; + @Mock + private AdminUserApi adminUserApi; + @Mock + private ProjectExecutionMapper projectExecutionMapper; + + @Test + void getProjectMemberList_shouldFillRoleAndUserInfo() { + Long projectId = 1001L; + ProjectDO project = createProject(projectId, 2001L); + UserObjectRoleDO manager = createMember(9001L, projectId, 2001L, 3101L, 0); + UserObjectRoleDO member = createMember(9002L, projectId, 2002L, 3102L, 0); + when(projectMapper.selectById(projectId)).thenReturn(project); + when(userObjectRoleMapper.selectListByObject("project", projectId)).thenReturn(List.of(manager, member)); + when(objectPermissionApi.getObjectRoleList(Set.of(3101L, 3102L), "object", "project")) + .thenReturn(success(List.of( + createRole(3101L, "project_manager", "项目经理"), + createRole(3102L, "project_member_configured", "项目成员") + ))); + when(adminUserApi.getUserMap(Set.of(2001L, 2002L))).thenReturn(Map.of( + 2001L, createUser("经理甲"), + 2002L, createUser("成员乙") + )); + + List respVOList = projectMemberService.getProjectMemberList(projectId); + + assertEquals(2, respVOList.size()); + assertEquals("项目经理", respVOList.get(0).getRoleName()); + assertEquals("project_manager", respVOList.get(0).getRoleCode()); + assertEquals(Boolean.TRUE, respVOList.get(0).getManagerFlag()); + assertEquals("项目成员", respVOList.get(1).getRoleName()); + assertFalse(respVOList.get(1).getManagerFlag()); + } + + @Test + void createProjectMember_whenUserAlreadyActive_shouldThrowAlreadyExists() { + Long projectId = 1002L; + ProjectMemberSaveReqVO reqVO = new ProjectMemberSaveReqVO(); + reqVO.setUserId(2002L); + reqVO.setRoleId(3102L); + when(projectMapper.selectById(projectId)).thenReturn(createProject(projectId, 2001L)); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "active")) + .thenReturn(createStatus("active", true)); + when(adminUserApi.validateUserList(any())).thenReturn(success(true)); + when(objectPermissionApi.getObjectRoleById(3102L, "object", "project")) + .thenReturn(success(createRole(3102L, "project_member_configured", "项目成员"))); + when(userObjectRoleMapper.selectByObjectAndUserId("project", projectId, 2002L)) + .thenReturn(createMember(9002L, projectId, 2002L, 3102L, 0)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectMemberService.createProjectMember(projectId, reqVO)); + + assertEquals(ErrorCodeConstants.PROJECT_MEMBER_ALREADY_EXISTS.getCode(), ex.getCode()); + verify(userObjectRoleMapper, never()).insert(any(UserObjectRoleDO.class)); + verify(bizAuditLogMapper, never()).insert(any(BizAuditLogDO.class)); + } + + @Test + void createProjectMember_whenRoleInvalid_shouldThrowRoleInvalid() { + Long projectId = 1003L; + ProjectMemberSaveReqVO reqVO = new ProjectMemberSaveReqVO(); + reqVO.setUserId(2003L); + reqVO.setRoleId(3999L); + when(projectMapper.selectById(projectId)).thenReturn(createProject(projectId, 2001L)); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "active")) + .thenReturn(createStatus("active", true)); + when(adminUserApi.validateUserList(any())).thenReturn(success(true)); + when(objectPermissionApi.getObjectRoleById(3999L, "object", "project")).thenReturn(success(null)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectMemberService.createProjectMember(projectId, reqVO)); + + assertEquals(ErrorCodeConstants.PROJECT_MEMBER_ROLE_INVALID.getCode(), ex.getCode()); + } + + @Test + void createProjectMember_whenManagerTransfer_shouldKeepPreviousManagerActiveWithNewRole() { + Long projectId = 1004L; + ProjectMemberSaveReqVO reqVO = new ProjectMemberSaveReqVO(); + reqVO.setUserId(2002L); + reqVO.setRoleId(3101L); + reqVO.setPreviousManagerUserId(2001L); + reqVO.setPreviousManagerRoleId(3102L); + ProjectDO project = createProject(projectId, 2001L); + UserObjectRoleDO previousManager = createMember(9001L, projectId, 2001L, 3101L, 0); + previousManager.setLeftTime(LocalDateTime.now()); + when(projectMapper.selectById(projectId)).thenReturn(project); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "active")) + .thenReturn(createStatus("active", true)); + when(adminUserApi.validateUserList(any())).thenReturn(success(true)); + when(objectPermissionApi.getObjectRoleById(3101L, "object", "project")) + .thenReturn(success(createRole(3101L, "project_manager", "项目经理"))); + when(objectPermissionApi.getObjectRoleById(3102L, "object", "project")) + .thenReturn(success(createRole(3102L, "project_member_configured", "项目成员"))); + when(userObjectRoleMapper.selectByObjectAndUserId("project", projectId, 2002L)).thenReturn(null); + when(userObjectRoleMapper.selectByObjectAndUserId("project", projectId, 2001L)).thenReturn(previousManager); + + projectMemberService.createProjectMember(projectId, reqVO); + + ArgumentCaptor previousManagerCaptor = ArgumentCaptor.forClass(UserObjectRoleDO.class); + verify(userObjectRoleMapper).updateById(previousManagerCaptor.capture()); + assertEquals(2001L, previousManagerCaptor.getValue().getUserId()); + assertEquals(3102L, previousManagerCaptor.getValue().getRoleId()); + assertEquals(0, previousManagerCaptor.getValue().getStatus()); + assertNull(previousManagerCaptor.getValue().getLeftTime()); + assertEquals(2002L, project.getManagerUserId()); + verify(projectMapper).updateById(project); + } + + @Test + void updateProjectMember_whenManagerTransferInfoMissing_shouldThrowInfoRequired() { + Long projectId = 1005L; + Long memberId = 9002L; + ProjectMemberUpdateReqVO reqVO = new ProjectMemberUpdateReqVO(); + reqVO.setRoleId(3101L); + UserObjectRoleDO member = createMember(memberId, projectId, 2002L, 3102L, 0); + when(projectMapper.selectById(projectId)).thenReturn(createProject(projectId, 2001L)); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "active")) + .thenReturn(createStatus("active", true)); + when(userObjectRoleMapper.selectByIdAndObject(memberId, "project", projectId)).thenReturn(member); + when(objectPermissionApi.getObjectRoleById(3101L, "object", "project")) + .thenReturn(success(createRole(3101L, "project_manager", "项目经理"))); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectMemberService.updateProjectMember(projectId, memberId, reqVO)); + + assertEquals(ErrorCodeConstants.PROJECT_MANAGER_TRANSFER_INFO_REQUIRED.getCode(), ex.getCode()); + } + + @Test + void updateProjectMember_whenCurrentManagerDowngrade_shouldThrowNotAllowDowngrade() { + Long projectId = 1006L; + Long memberId = 9001L; + ProjectMemberUpdateReqVO reqVO = new ProjectMemberUpdateReqVO(); + reqVO.setRoleId(3102L); + UserObjectRoleDO member = createMember(memberId, projectId, 2001L, 3101L, 0); + when(projectMapper.selectById(projectId)).thenReturn(createProject(projectId, 2001L)); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "active")) + .thenReturn(createStatus("active", true)); + when(userObjectRoleMapper.selectByIdAndObject(memberId, "project", projectId)).thenReturn(member); + when(objectPermissionApi.getObjectRoleById(3102L, "object", "project")) + .thenReturn(success(createRole(3102L, "project_member_configured", "项目成员"))); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectMemberService.updateProjectMember(projectId, memberId, reqVO)); + + assertEquals(ErrorCodeConstants.PROJECT_MANAGER_MEMBER_NOT_ALLOW_DOWNGRADE.getCode(), ex.getCode()); + verify(userObjectRoleMapper, never()).updateById(any(UserObjectRoleDO.class)); + } + + @Test + void inactiveProjectMember_whenStillOwnsOpenExecution_shouldThrowHandoffRequired() { + Long projectId = 1001L; + Long memberId = 9002L; + UserObjectRoleDO member = createMember(memberId, projectId, 2002L, 3102L, 0); + when(projectMapper.selectById(projectId)).thenReturn(createProject(projectId, 2001L)); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "active")) + .thenReturn(createStatus("active", true)); + when(userObjectRoleMapper.selectByIdAndObject(memberId, "project", projectId)).thenReturn(member); + when(objectStatusModelMapper.selectListByObjectType("execution")) + .thenReturn(List.of(createExecutionStatus("completed", true), createExecutionStatus("cancelled", true))); + when(projectExecutionMapper.countNonTerminalByProjectIdAndOwnerId(projectId, 2002L, + List.of("completed", "cancelled"))).thenReturn(1); + + ProjectMemberInactiveReqVO reqVO = new ProjectMemberInactiveReqVO(); + reqVO.setReason("退出项目"); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectMemberService.inactiveProjectMember(projectId, memberId, reqVO)); + + assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_OWNER_HANDOFF_REQUIRED.getCode(), ex.getCode()); + } + + private ProjectDO createProject(Long projectId, Long managerUserId) { + ProjectDO project = new ProjectDO(); + project.setId(projectId); + project.setManagerUserId(managerUserId); + project.setStatusCode("active"); + project.setProjectName("测试项目"); + project.setProjectCode("CNPJ2026001"); + return project; + } + + private UserObjectRoleDO createMember(Long memberId, Long projectId, Long userId, Long roleId, Integer status) { + UserObjectRoleDO member = new UserObjectRoleDO(); + member.setId(memberId); + member.setObjectType("project"); + member.setObjectId(projectId); + member.setUserId(userId); + member.setRoleId(roleId); + member.setStatus(status); + member.setJoinedTime(LocalDateTime.now()); + return member; + } + + private ObjectRoleRespDTO createRole(Long roleId, String roleCode, String roleName) { + ObjectRoleRespDTO role = new ObjectRoleRespDTO(); + role.setId(roleId); + role.setCode(roleCode); + role.setName(roleName); + role.setScopeType("object"); + role.setObjectType("project"); + return role; + } + + private AdminUserRespDTO createUser(String nickname) { + AdminUserRespDTO user = new AdminUserRespDTO(); + user.setNickname(nickname); + return user; + } + + private ObjectStatusModelDO createStatus(String statusCode, boolean allowEdit) { + ObjectStatusModelDO statusModel = new ObjectStatusModelDO(); + statusModel.setObjectType("project"); + statusModel.setStatusCode(statusCode); + statusModel.setAllowEdit(allowEdit); + return statusModel; + } + + private ObjectStatusModelDO createExecutionStatus(String statusCode, boolean terminalFlag) { + ObjectStatusModelDO statusModel = new ObjectStatusModelDO(); + statusModel.setObjectType("execution"); + statusModel.setStatusCode(statusCode); + statusModel.setTerminalFlag(terminalFlag); + return statusModel; + } + +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectServiceImplTest.java new file mode 100644 index 0000000..0649f34 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectServiceImplTest.java @@ -0,0 +1,449 @@ +package com.njcn.rdms.module.project.service.project; + +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.controller.admin.project.vo.project.ProjectContextRespVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectLifecycleActionRespVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectOverviewSummaryRespVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectRespVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectSaveReqVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectStatusActionReqVO; +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.product.ProductDO; +import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO; +import com.njcn.rdms.module.project.dal.dataobject.project.ProjectStatusLogDO; +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.dal.mysql.product.ProductMapper; +import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper; +import com.njcn.rdms.module.project.dal.mysql.project.ProjectStatusLogMapper; +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.permission.ObjectPermissionApi; +import com.njcn.rdms.module.system.api.permission.dto.ObjectMenuRespDTO; +import com.njcn.rdms.module.system.api.permission.dto.ObjectRolePermissionRespDTO; +import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO; +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.List; +import java.util.Map; +import java.util.Set; + +import static com.njcn.rdms.framework.common.pojo.CommonResult.success; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +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 ProjectServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ProjectServiceImpl projectService; + @Mock + private ProjectMapper projectMapper; + @Mock + private ProjectStatusLogMapper projectStatusLogMapper; + @Mock + private ProductMapper productMapper; + @Mock + private BizAuditLogMapper bizAuditLogMapper; + @Mock + private ObjectStatusTransitionMapper objectStatusTransitionMapper; + @Mock + private ObjectStatusModelMapper objectStatusModelMapper; + @Mock + private UserObjectRoleMapper userObjectRoleMapper; + @Mock + private ProjectStatusViewService projectStatusViewService; + @Mock + private ObjectPermissionApi objectPermissionApi; + @Mock + private AdminUserApi adminUserApi; + @Mock + private DictDataApi dictDataApi; + + @Test + void getProjectDetail_shouldFillProductNameAndManagerNickname() { + Long projectId = 3001L; + Long productId = 1001L; + Long managerUserId = 2001L; + + ProjectDO project = new ProjectDO(); + project.setId(projectId); + project.setProjectCode("CNPJ2026001"); + project.setProjectName("研发管理系统"); + project.setProductId(productId); + project.setManagerUserId(managerUserId); + + ProductDO product = new ProductDO(); + product.setId(productId); + product.setName("统一交付平台"); + + AdminUserRespDTO manager = new AdminUserRespDTO(); + manager.setId(managerUserId); + manager.setNickname("张三"); + + when(projectMapper.selectById(projectId)).thenReturn(project); + when(productMapper.selectById(productId)).thenReturn(product); + when(adminUserApi.getUser(managerUserId)).thenReturn(success(manager)); + + ProjectRespVO result = projectService.getProjectDetail(projectId); + + assertEquals(projectId, result.getId()); + assertEquals("统一交付平台", result.getProductName()); + assertEquals("张三", result.getManagerUserNickname()); + } + + @Test + void createProject_withManualCode_shouldInsertPendingProjectInitManagerAndWriteAudit() { + ProjectSaveReqVO reqVO = createReqVO("CNPJ-MANUAL", 1001L, "合同交付项目", 2001L); + ProductDO product = new ProductDO(); + product.setId(1001L); + product.setDirectionCode("product_direction"); + when(projectMapper.selectByCode("CNPJ-MANUAL")).thenReturn(null); + when(projectMapper.selectActiveByProductIdAndName(1001L, "合同交付项目", "cancelled")).thenReturn(null); + when(productMapper.selectById(1001L)).thenReturn(product); + when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.PROJECT_TYPE, List.of("delivery"))).thenReturn(success(true)); + when(adminUserApi.validateUserList(any())).thenReturn(success(true)); + when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("project")).thenReturn(createStatus("pending", true, true)); + when(objectPermissionApi.getObjectRoleByCode("project_manager", "object", "project")) + .thenReturn(success(createRole(3101L, "project_manager", "项目经理"))); + + try (MockedStatic mockedStatic = mockLoginUser(3001L, "创建人")) { + projectService.createProject(reqVO); + } + + ArgumentCaptor projectCaptor = ArgumentCaptor.forClass(ProjectDO.class); + verify(projectMapper).insert(projectCaptor.capture()); + assertEquals("pending", projectCaptor.getValue().getStatusCode()); + assertEquals("CNPJ-MANUAL", projectCaptor.getValue().getProjectCode()); + assertEquals("product_direction", projectCaptor.getValue().getDirectionCode()); + + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(UserObjectRoleDO.class); + verify(userObjectRoleMapper).insert(memberCaptor.capture()); + assertEquals("project", memberCaptor.getValue().getObjectType()); + assertEquals(3101L, memberCaptor.getValue().getRoleId()); + assertEquals(2001L, memberCaptor.getValue().getUserId()); + + ArgumentCaptor auditCaptor = ArgumentCaptor.forClass(BizAuditLogDO.class); + verify(bizAuditLogMapper, times(3)).insert(auditCaptor.capture()); + assertTrue(auditCaptor.getAllValues().stream().anyMatch(log -> + "project".equals(log.getBizType()) && "create".equals(log.getActionType()) + && "pending".equals(log.getToStatus()))); + } + + @Test + void getProjectOverviewSummary_shouldReturnFixedStatusCountsAndIgnoreUnknownStatus() { + when(objectStatusModelMapper.selectListByObjectType("project")).thenReturn(List.of( + createStatusModel("pending", 10, 0), + createStatusModel("active", 20, 0), + createStatusModel("paused", 30, 0), + createStatusModel("disabled", 40, 1) + )); + when(projectMapper.selectStatusCountList()).thenReturn(List.of( + Map.of("statusCode", "pending", "countValue", 2L), + Map.of("statusCode", "active", "countValue", 3L), + Map.of("statusCode", "unknown", "countValue", 99L) + )); + + ProjectOverviewSummaryRespVO respVO = projectService.getProjectOverviewSummary(); + + assertEquals(3, respVO.getStatusCounts().size()); + assertEquals(2L, respVO.getStatusCounts().get("pending")); + assertEquals(3L, respVO.getStatusCounts().get("active")); + assertEquals(0L, respVO.getStatusCounts().get("paused")); + } + + @Test + void createProject_whenNullProductNameExists_shouldThrowDuplicate() { + ProjectSaveReqVO reqVO = createReqVO(null, null, "内部项目", 2001L); + when(projectMapper.selectActiveByProductIdAndName(null, "内部项目", "cancelled")) + .thenReturn(createProject(1001L, null, "内部项目", 2001L, "pending")); + + ServiceException ex = assertThrows(ServiceException.class, () -> projectService.createProject(reqVO)); + + assertEquals(ErrorCodeConstants.PROJECT_NAME_DUPLICATE.getCode(), ex.getCode()); + verify(projectMapper, never()).insert(any(ProjectDO.class)); + } + + @Test + void createProject_whenOnlyCancelledProjectNameExists_shouldAllowReuseName() { + ProjectSaveReqVO reqVO = createReqVO("CNPJ-REUSE", 1001L, "研发管理系统", 2001L); + ProductDO product = new ProductDO(); + product.setId(1001L); + product.setDirectionCode("product_direction"); + when(projectMapper.selectByCode("CNPJ-REUSE")).thenReturn(null); + when(projectMapper.selectActiveByProductIdAndName(1001L, "研发管理系统", "cancelled")).thenReturn(null); + when(productMapper.selectById(1001L)).thenReturn(product); + when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.PROJECT_TYPE, List.of("delivery"))).thenReturn(success(true)); + when(adminUserApi.validateUserList(any())).thenReturn(success(true)); + when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("project")).thenReturn(createStatus("pending", true, true)); + when(objectPermissionApi.getObjectRoleByCode("project_manager", "object", "project")) + .thenReturn(success(createRole(3101L, "project_manager", "项目经理"))); + + try (MockedStatic mockedStatic = mockLoginUser(3001L, "创建人")) { + projectService.createProject(reqVO); + } + + verify(projectMapper).insert(any(ProjectDO.class)); + } + + @Test + void createProject_withoutProductAndDirection_shouldThrowInvalidParam() { + ProjectSaveReqVO reqVO = createReqVO("CNPJ-DIRECTION", null, "方向校验项目", 2001L); + reqVO.setDirectionCode(null); + when(projectMapper.selectByCode("CNPJ-DIRECTION")).thenReturn(null); + when(projectMapper.selectActiveByProductIdAndName(null, "方向校验项目", "cancelled")).thenReturn(null); + when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.PROJECT_TYPE, List.of("delivery"))).thenReturn(success(true)); + + ServiceException ex = assertThrows(ServiceException.class, () -> projectService.createProject(reqVO)); + + assertEquals("项目方向不能为空", ex.getMessage()); + verify(projectMapper, never()).insert(any(ProjectDO.class)); + } + + @Test + void createProject_whenActiveMainlineExists_shouldThrowDuplicate() { + ProjectSaveReqVO reqVO = createReqVO("CNPJ-MAINLINE", 1001L, "产品主线项目", 2001L); + reqVO.setProjectType("mainline"); + ProductDO product = new ProductDO(); + product.setId(1001L); + product.setDirectionCode("product_direction"); + when(projectMapper.selectByCode("CNPJ-MAINLINE")).thenReturn(null); + when(projectMapper.selectActiveByProductIdAndName(1001L, "产品主线项目", "cancelled")).thenReturn(null); + when(productMapper.selectById(1001L)).thenReturn(product); + when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.PROJECT_TYPE, List.of("mainline"))).thenReturn(success(true)); + when(projectMapper.selectActiveMainlineListByProductId(1001L, + Set.of("mainline", "main", "product_mainline", "主线"), "cancelled")) + .thenReturn(List.of(createProject(1001L, 1001L, "已有主线项目", 2001L, "active"))); + + ServiceException ex = assertThrows(ServiceException.class, () -> projectService.createProject(reqVO)); + + assertEquals(ErrorCodeConstants.PROJECT_MAINLINE_DUPLICATE.getCode(), ex.getCode()); + verify(projectMapper, never()).insert(any(ProjectDO.class)); + } + + @Test + void updateProject_withoutProduct_shouldUpdateDirectionCode() { + Long projectId = 1004L; + ProjectDO project = createProject(projectId, null, "内部项目", 2001L, "pending"); + project.setDirectionCode("old_direction"); + ProjectSaveReqVO reqVO = createReqVO("CNPJ2026001", null, "内部项目", 2001L); + reqVO.setId(projectId); + reqVO.setDirectionCode("new_direction"); + when(projectMapper.selectById(projectId)).thenReturn(project); + when(projectMapper.selectActiveByProductIdAndName(null, "内部项目", "cancelled")).thenReturn(project); + when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.PROJECT_TYPE, List.of("delivery"))).thenReturn(success(true)); + when(dictDataApi.validateDictDataList("rdms_object_direction", List.of("new_direction"))).thenReturn(success(true)); + when(adminUserApi.validateUserList(any())).thenReturn(success(true)); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) + .thenReturn(createStatus("pending", true, true)); + + projectService.updateProject(reqVO); + + assertEquals("new_direction", project.getDirectionCode()); + } + + @Test + void updateProject_withProduct_shouldUseProductDirectionCode() { + Long projectId = 1005L; + ProjectDO project = createProject(projectId, 1001L, "产品关联项目", 2001L, "pending"); + project.setDirectionCode("old_direction"); + ProjectSaveReqVO reqVO = createReqVO("CNPJ2026001", 1001L, "产品关联项目", 2001L); + reqVO.setId(projectId); + reqVO.setDirectionCode("manual_direction"); + ProductDO product = new ProductDO(); + product.setId(1001L); + product.setDirectionCode("product_direction"); + when(projectMapper.selectById(projectId)).thenReturn(project); + when(projectMapper.selectActiveByProductIdAndName(1001L, "产品关联项目", "cancelled")).thenReturn(project); + when(productMapper.selectById(1001L)).thenReturn(product); + when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.PROJECT_TYPE, List.of("delivery"))).thenReturn(success(true)); + when(adminUserApi.validateUserList(any())).thenReturn(success(true)); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) + .thenReturn(createStatus("pending", true, true)); + + projectService.updateProject(reqVO); + + assertEquals("product_direction", project.getDirectionCode()); + } + + @Test + void getProjectContext_whenCreatorHasNoObjectRole_shouldReturnVisitorResources() { + Long projectId = 1002L; + Long loginUserId = 3002L; + Long visitorRoleId = 9201L; + ProjectDO project = createProject(projectId, null, "创建人项目", 2001L, "pending"); + project.setCreator(String.valueOf(loginUserId)); + ObjectRolePermissionRespDTO detail = new ObjectRolePermissionRespDTO(); + detail.setCurrentRole(createRole(visitorRoleId, "visitor", "游客")); + detail.setMenus(List.of( + createMenu(9301L, "概览", null, 2, 10, "/project/project/overview", "mdi:home-outline", true) + )); + detail.setPermissions(Set.of()); + when(projectMapper.selectById(projectId)).thenReturn(project); + when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, loginUserId)).thenReturn(null); + when(objectPermissionApi.getObjectRoleByCode("visitor", "object", "project")) + .thenReturn(success(createRole(visitorRoleId, "visitor", "游客"))); + when(objectPermissionApi.getObjectRolePermissionDetail(visitorRoleId, "object", "project")) + .thenReturn(success(detail)); + when(projectStatusViewService.getLifecycle("pending")) + .thenReturn(new ProjectStatusViewService.ProjectLifecycleView( + "待开始", false, true, List.of( + createLifecycleAction("cancel", "取消", true) + ))); + + ProjectContextRespVO respVO; + try (MockedStatic mockedStatic = mockLoginUser(loginUserId, "创建人")) { + respVO = projectService.getProjectContext(projectId); + } + + assertNotNull(respVO.getCurrentProject()); + assertEquals("待开始", respVO.getCurrentProject().getStatusName()); + assertEquals(Boolean.FALSE, respVO.getCurrentProject().getTerminal()); + assertEquals(Boolean.TRUE, respVO.getCurrentProject().getAllowEdit()); + assertEquals(1, respVO.getCurrentProject().getAvailableActions().size()); + assertEquals("cancel", respVO.getCurrentProject().getAvailableActions().get(0).getActionCode()); + assertEquals(Boolean.TRUE, respVO.getCurrentRole().getGuestFlag()); + assertEquals(visitorRoleId, respVO.getCurrentRole().getRoleId()); + assertEquals("visitor", respVO.getCurrentRole().getRoleCode()); + assertEquals("游客", respVO.getCurrentRole().getRoleName()); + assertEquals(1, respVO.getNavs().size()); + assertEquals(9301L, respVO.getNavs().get(0).getId()); + assertEquals(List.of(), respVO.getButtons()); + } + + @Test + void changeProjectStatus_shouldUpdateStatusAndWriteStatusAndAuditLogs() { + Long projectId = 1003L; + ProjectDO project = createProject(projectId, null, "状态项目", 2001L, "active"); + ProjectStatusActionReqVO reqVO = new ProjectStatusActionReqVO(); + reqVO.setId(projectId); + reqVO.setActionCode("pause"); + reqVO.setReason("环境受限"); + when(projectMapper.selectById(projectId)).thenReturn(project); + when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("project", "active", "pause")) + .thenReturn(createTransition("pause", "paused", true)); + when(projectMapper.updateStatusByIdAndStatus(projectId, "active", "paused", "环境受限")).thenReturn(1); + + try (MockedStatic mockedStatic = mockLoginUser(3003L, "操作人")) { + projectService.changeProjectStatus(reqVO); + } + + verify(projectMapper).updateStatusByIdAndStatus(projectId, "active", "paused", "环境受限"); + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(ProjectStatusLogDO.class); + verify(projectStatusLogMapper).insert(statusCaptor.capture()); + assertEquals("pause", statusCaptor.getValue().getActionType()); + assertEquals("active", statusCaptor.getValue().getFromStatus()); + assertEquals("paused", statusCaptor.getValue().getToStatus()); + } + + private ProjectSaveReqVO createReqVO(String code, Long productId, String projectName, Long managerUserId) { + ProjectSaveReqVO reqVO = new ProjectSaveReqVO(); + reqVO.setProjectCode(code); + reqVO.setProductId(productId); + reqVO.setProjectName(projectName); + reqVO.setProjectType("delivery"); + reqVO.setManagerUserId(managerUserId); + reqVO.setDirectionCode("manual_direction"); + return reqVO; + } + + private ProjectDO createProject(Long id, Long productId, String projectName, Long managerUserId, String statusCode) { + ProjectDO project = new ProjectDO(); + project.setId(id); + project.setProjectCode("CNPJ" + LocalDate.now().getYear() + "001"); + project.setProductId(productId); + project.setProjectName(projectName); + project.setProjectType("delivery"); + project.setManagerUserId(managerUserId); + project.setStatusCode(statusCode); + return project; + } + + private ObjectRoleRespDTO createRole(Long roleId, String roleCode, String roleName) { + ObjectRoleRespDTO role = new ObjectRoleRespDTO(); + role.setId(roleId); + role.setCode(roleCode); + role.setName(roleName); + role.setScopeType("object"); + role.setObjectType("project"); + return role; + } + + private ObjectMenuRespDTO createMenu(Long id, String name, String permission, Integer type, Integer sort, + String path, String icon, Boolean visible) { + ObjectMenuRespDTO menu = new ObjectMenuRespDTO(); + menu.setId(id); + menu.setName(name); + menu.setPermission(permission); + menu.setType(type); + menu.setSort(sort); + menu.setPath(path); + menu.setIcon(icon); + menu.setVisible(visible); + return menu; + } + + private ObjectStatusTransitionDO createTransition(String actionCode, String toStatus, boolean needReason) { + ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO(); + transition.setActionCode(actionCode); + transition.setToStatusCode(toStatus); + transition.setNeedReason(needReason); + return transition; + } + + private ProjectLifecycleActionRespVO createLifecycleAction(String actionCode, String actionName, boolean needReason) { + ProjectLifecycleActionRespVO action = new ProjectLifecycleActionRespVO(); + action.setActionCode(actionCode); + action.setActionName(actionName); + action.setNeedReason(needReason); + return action; + } + + private ObjectStatusModelDO createStatus(String statusCode, boolean initialFlag, boolean allowEdit) { + ObjectStatusModelDO statusModel = new ObjectStatusModelDO(); + statusModel.setObjectType("project"); + statusModel.setStatusCode(statusCode); + statusModel.setInitialFlag(initialFlag); + statusModel.setAllowEdit(allowEdit); + return statusModel; + } + + private ObjectStatusModelDO createStatusModel(String statusCode, Integer sort, Integer status) { + ObjectStatusModelDO statusModel = new ObjectStatusModelDO(); + statusModel.setObjectType("project"); + statusModel.setStatusCode(statusCode); + statusModel.setSort(sort); + statusModel.setStatus(status); + return statusModel; + } + + private MockedStatic mockLoginUser(Long loginUserId, String nickname) { + MockedStatic mockedStatic = mockStatic(SecurityFrameworkUtils.class); + mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId); + mockedStatic.when(SecurityFrameworkUtils::getLoginUserNickname).thenReturn(nickname); + return mockedStatic; + } + +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectStatusBoardServiceTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectStatusBoardServiceTest.java new file mode 100644 index 0000000..aec6b3e --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectStatusBoardServiceTest.java @@ -0,0 +1,130 @@ +package com.njcn.rdms.module.project.service.project; + +import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest; +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.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 org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.time.LocalDateTime; +import java.util.List; + +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.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +class ProjectStatusBoardServiceTest extends BaseMockitoUnitTest { + + @InjectMocks + private ProjectStatusBoardServiceImpl projectStatusBoardService; + @Mock + private ObjectStatusModelMapper objectStatusModelMapper; + @Mock + private ProjectExecutionMapper projectExecutionMapper; + @Mock + private ProjectTaskMapper projectTaskMapper; + + @Test + void getExecutionStatusBoard_shouldReturnEnabledStatusesInSortOrderAndSumCounts() { + when(objectStatusModelMapper.selectListByObjectTypeEnabled("execution")).thenReturn(List.of( + createStatus("pending", "待开始", 10, false), + createStatus("active", "进行中", 20, false), + createStatus("paused", "已暂停", 30, false), + createStatus("completed", "已完成", 40, true), + 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); + + ProjectExecutionStatusBoardReqVO reqVO = new ProjectExecutionStatusBoardReqVO(); + reqVO.setKeyword("接口"); + reqVO.setExecutionType("feature"); + reqVO.setOwnerId(3001L); + reqVO.setUpdateTime(new LocalDateTime[]{LocalDateTime.of(2026, 5, 1, 0, 0), LocalDateTime.of(2026, 5, 31, 23, 59, 59)}); + + ProjectExecutionStatusBoardRespVO result = projectStatusBoardService.getExecutionStatusBoard(2001L, reqVO); + + assertEquals(18L, result.getTotal()); + assertEquals(5, result.getItems().size()); + assertEquals("pending", result.getItems().get(0).getStatusCode()); + assertEquals("active", result.getItems().get(1).getStatusCode()); + assertEquals("paused", result.getItems().get(2).getStatusCode()); + assertEquals("completed", result.getItems().get(3).getStatusCode()); + assertEquals("cancelled", result.getItems().get(4).getStatusCode()); + assertTrue(result.getItems().get(3).getTerminal()); + assertFalse(result.getItems().get(0).getTerminal()); + assertEquals(10, result.getItems().get(0).getSort()); + } + + @Test + void getTaskStatusBoard_shouldReturnEnabledStatusesInSortOrderAndSumCounts() { + when(objectStatusModelMapper.selectListByObjectTypeEnabled("task")).thenReturn(List.of( + createStatus("pending", "待开始", 10, false), + createStatus("active", "进行中", 20, false), + createStatus("blocked", "已阻塞", 30, false), + createStatus("completed", "已完成", 40, true), + createStatus("cancelled", "已取消", 50, true) + )); + when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L), + any(ProjectTaskStatusBoardReqVO.class), eq("pending"))).thenReturn(5); + when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L), + any(ProjectTaskStatusBoardReqVO.class), eq("active"))).thenReturn(12); + when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L), + any(ProjectTaskStatusBoardReqVO.class), eq("blocked"))).thenReturn(2); + when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L), + any(ProjectTaskStatusBoardReqVO.class), eq("completed"))).thenReturn(4); + when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L), + any(ProjectTaskStatusBoardReqVO.class), eq("cancelled"))).thenReturn(1); + + ProjectTaskStatusBoardReqVO reqVO = new ProjectTaskStatusBoardReqVO(); + reqVO.setKeyword("任务"); + reqVO.setParentTaskId(9001L); + reqVO.setOwnerId(3002L); + reqVO.setUpdateTime(new LocalDateTime[]{LocalDateTime.of(2026, 5, 1, 0, 0), LocalDateTime.of(2026, 5, 31, 23, 59, 59)}); + + ProjectTaskStatusBoardRespVO result = projectStatusBoardService.getTaskStatusBoard(2001L, 5001L, reqVO); + + assertEquals(24L, result.getTotal()); + 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("completed", result.getItems().get(3).getStatusCode()); + assertEquals("cancelled", result.getItems().get(4).getStatusCode()); + } + + private ObjectStatusModelDO createStatus(String statusCode, String statusName, int sort, boolean terminalFlag) { + return createStatus(statusCode, statusName, sort, terminalFlag, 0); + } + + private ObjectStatusModelDO createStatus(String statusCode, String statusName, int sort, + boolean terminalFlag, int status) { + ObjectStatusModelDO statusModel = new ObjectStatusModelDO(); + statusModel.setStatusCode(statusCode); + statusModel.setStatusName(statusName); + statusModel.setSort(sort); + statusModel.setTerminalFlag(terminalFlag); + statusModel.setStatus(status); + return statusModel; + } + +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectStatusViewServiceTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectStatusViewServiceTest.java new file mode 100644 index 0000000..0b57149 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectStatusViewServiceTest.java @@ -0,0 +1,74 @@ +package com.njcn.rdms.module.project.service.project; + +import com.njcn.rdms.framework.common.exception.ServiceException; +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; +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 org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.List; + +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.when; + +class ProjectStatusViewServiceTest extends BaseMockitoUnitTest { + + @InjectMocks + private ProjectStatusViewService projectStatusViewService; + @Mock + private ObjectStatusModelMapper objectStatusModelMapper; + @Mock + private ObjectStatusTransitionMapper objectStatusTransitionMapper; + + @Test + void getLifecycle_shouldReturnStatusMetadataAndActions() { + ObjectStatusModelDO statusModel = new ObjectStatusModelDO(); + statusModel.setObjectType("project"); + statusModel.setStatusCode("pending"); + statusModel.setStatusName("待开始"); + statusModel.setTerminalFlag(false); + statusModel.setAllowEdit(true); + statusModel.setStatus(0); + + ObjectStatusTransitionDO autoStartTransition = new ObjectStatusTransitionDO(); + autoStartTransition.setActionCode("auto_start"); + autoStartTransition.setActionName("自动进入进行中"); + autoStartTransition.setNeedReason(false); + ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO(); + transition.setActionCode("cancel"); + transition.setActionName("取消"); + transition.setNeedReason(true); + + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) + .thenReturn(statusModel); + when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("project", "pending")) + .thenReturn(List.of(autoStartTransition, transition)); + + ProjectStatusViewService.ProjectLifecycleView result = projectStatusViewService.getLifecycle("pending"); + + assertEquals("待开始", result.statusName()); + assertFalse(result.terminal()); + assertTrue(result.allowEdit()); + assertEquals(1, result.availableActions().size()); + assertEquals("cancel", result.availableActions().get(0).getActionCode()); + } + + @Test + void getLifecycle_whenStatusModelMissing_shouldThrowException() { + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) + .thenReturn(null); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectStatusViewService.getLifecycle("pending")); + assertEquals(ErrorCodeConstants.PROJECT_STATUS_MODEL_NOT_EXISTS_OR_DISABLED.getCode(), ex.getCode()); + } + +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionMemberServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionMemberServiceImplTest.java new file mode 100644 index 0000000..4181bde --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionMemberServiceImplTest.java @@ -0,0 +1,386 @@ +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.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.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.ProjectExecutionMapper; +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 java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ProjectExecutionMemberServiceImpl projectExecutionMemberService; + @Mock + private ProjectMapper projectMapper; + @Mock + private ProjectExecutionMapper projectExecutionMapper; + @Mock + private ExecutionMemberMapper executionMemberMapper; + @Mock + private ExecutionMemberLogMapper executionMemberLogMapper; + @Mock + private UserObjectRoleMapper userObjectRoleMapper; + @Mock + private BizAuditLogMapper bizAuditLogMapper; + @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 OWNER_ID = 3001L; + private static final Long USER_ID = 3002L; + + // -------------------- getExecutionMemberList -------------------- + + @Test + void getExecutionMemberList_shouldReturnOnlyActiveSegments() { + when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject()); + when(projectExecutionMapper.selectByProjectIdAndId(PROJECT_ID, EXECUTION_ID)) + .thenReturn(createExecution()); + when(executionMemberMapper.selectActiveListByExecutionId(EXECUTION_ID)) + .thenReturn(List.of(createMember(7001L, USER_ID, null))); + + List respVOList = projectExecutionMemberService + .getExecutionMemberList(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()); + } + + // -------------------- createExecutionMember -------------------- + + @Test + void createExecutionMember_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); + m.setId(7001L); + return 1; + }); + + ExecutionMemberSaveReqVO reqVO = new ExecutionMemberSaveReqVO(); + reqVO.setUserId(USER_ID); + + Long memberId = projectExecutionMemberService.createExecutionMember(PROJECT_ID, EXECUTION_ID, reqVO); + + assertEquals(7001L, memberId); + ArgumentCaptor activeCaptor = ArgumentCaptor.forClass(ExecutionMemberDO.class); + verify(executionMemberMapper).insert(activeCaptor.capture()); + assertNull(activeCaptor.getValue().getRemovedAt()); + assertNull(activeCaptor.getValue().getRemovedReason()); + ArgumentCaptor logCaptor = ArgumentCaptor.forClass(ExecutionMemberLogDO.class); + verify(executionMemberLogMapper).insert(logCaptor.capture()); + assertEquals("join", logCaptor.getValue().getActionType()); + assertEquals(USER_ID, logCaptor.getValue().getUserId()); + assertNull(logCaptor.getValue().getReason()); + // 旧 BizAuditLog 仍写一条(兼容现有审计链路) + verify(bizAuditLogMapper).insert(any(BizAuditLogDO.class)); + } + + @Test + void createExecutionMember_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); + m.setId(7002L); + return 1; + }); + + ExecutionMemberSaveReqVO reqVO = new ExecutionMemberSaveReqVO(); + reqVO.setUserId(USER_ID); + + Long memberId = projectExecutionMemberService.createExecutionMember(PROJECT_ID, EXECUTION_ID, reqVO); + + assertEquals(7002L, memberId); + // 不应触碰旧段(updateById 不应被调用) + verify(executionMemberMapper, never()).updateById(any(ExecutionMemberDO.class)); + verify(executionMemberMapper).insert(any(ExecutionMemberDO.class)); + } + + @Test + void createExecutionMember_whenAlreadyActive_shouldThrowAlreadyExists() { + stubEditableContext(); + when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", PROJECT_ID, USER_ID)) + .thenReturn(createProjectMember()); + when(executionMemberMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID)) + .thenReturn(createMember(7001L, USER_ID, null)); + + ExecutionMemberSaveReqVO reqVO = new ExecutionMemberSaveReqVO(); + 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)); + } + + @Test + void createExecutionMember_whenExecutionPaused_shouldThrowNotAllowEdit() { + ProjectExecutionDO execution = createExecution(); + execution.setStatusCode("paused"); + when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject()); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) + .thenReturn(createStatus("project", "pending", true)); + when(projectExecutionMapper.selectByProjectIdAndId(PROJECT_ID, EXECUTION_ID)).thenReturn(execution); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "paused")) + .thenReturn(createStatus("execution", "paused", false)); + + ExecutionMemberSaveReqVO reqVO = new ExecutionMemberSaveReqVO(); + reqVO.setUserId(USER_ID); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectExecutionMemberService.createExecutionMember(PROJECT_ID, EXECUTION_ID, reqVO)); + assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_NOT_ALLOW_EDIT.getCode(), ex.getCode()); + } + + // -------------------- inactiveExecutionMember -------------------- + + @Test + void inactiveExecutionMember_shouldSetRemovedAtAndWriteInactiveLog() { + ExecutionMemberDO member = createMember(7001L, USER_ID, null); + stubEditableContext(); + when(executionMemberMapper.selectByIdAndExecutionId(7001L, EXECUTION_ID)).thenReturn(member); + + ExecutionMemberInactiveReqVO reqVO = new ExecutionMemberInactiveReqVO(); + reqVO.setReason("阶段性退出"); + + projectExecutionMemberService.inactiveExecutionMember(PROJECT_ID, EXECUTION_ID, 7001L, reqVO); + + assertNotNull(member.getRemovedAt()); + assertEquals("阶段性退出", member.getRemovedReason()); + verify(executionMemberMapper).updateById(member); + ArgumentCaptor logCaptor = ArgumentCaptor.forClass(ExecutionMemberLogDO.class); + verify(executionMemberLogMapper).insert(logCaptor.capture()); + assertEquals("inactive", logCaptor.getValue().getActionType()); + assertEquals(USER_ID, logCaptor.getValue().getUserId()); + assertEquals("阶段性退出", logCaptor.getValue().getReason()); + assertNotNull(logCaptor.getValue().getActionTime()); + } + + @Test + void inactiveExecutionMember_whenAlreadyInactive_shouldThrowNotActive() { + ExecutionMemberDO inactiveMember = createMember(7001L, USER_ID, LocalDateTime.now().minusDays(1)); + stubEditableContext(); + when(executionMemberMapper.selectByIdAndExecutionId(7001L, EXECUTION_ID)).thenReturn(inactiveMember); + + ExecutionMemberInactiveReqVO reqVO = new ExecutionMemberInactiveReqVO(); + 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)); + } + + @Test + void inactiveExecutionMember_whenExecutionPaused_shouldThrowNotAllowEdit() { + ProjectExecutionDO execution = createExecution(); + execution.setStatusCode("paused"); + when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject()); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) + .thenReturn(createStatus("project", "pending", true)); + when(projectExecutionMapper.selectByProjectIdAndId(PROJECT_ID, EXECUTION_ID)).thenReturn(execution); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "paused")) + .thenReturn(createStatus("execution", "paused", false)); + + ExecutionMemberInactiveReqVO reqVO = new ExecutionMemberInactiveReqVO(); + reqVO.setReason("暂停态退出"); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectExecutionMemberService.inactiveExecutionMember(PROJECT_ID, EXECUTION_ID, 7001L, reqVO)); + assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_NOT_ALLOW_EDIT.getCode(), ex.getCode()); + } + + // -------------------- getExecutionMemberLogPage -------------------- + + @Test + void getExecutionMemberLogPage_shouldDelegateToMapperAndConvertVO() { + when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject()); + when(projectExecutionMapper.selectByProjectIdAndId(PROJECT_ID, EXECUTION_ID)) + .thenReturn(createExecution()); + ExecutionMemberLogDO logRow = new ExecutionMemberLogDO(); + 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))) + .thenReturn(new PageResult<>(List.of(logRow), 1L)); + + ExecutionMemberLogPageReqVO reqVO = new ExecutionMemberLogPageReqVO(); + PageResult page = projectExecutionMemberService + .getExecutionMemberLogPage(PROJECT_ID, EXECUTION_ID, reqVO); + + assertEquals(1L, page.getTotal()); + assertEquals("join", page.getList().get(0).getActionType()); + assertEquals(USER_ID, page.getList().get(0).getUserId()); + } + + @Test + void getExecutionMemberLogPage_whenSnapshotExists_shouldUseCurrentNicknamesByAdminUserApi() { + when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject()); + when(projectExecutionMapper.selectByProjectIdAndId(PROJECT_ID, EXECUTION_ID)) + .thenReturn(createExecution()); + ExecutionMemberLogDO logRow = new ExecutionMemberLogDO(); + logRow.setId(12002L); + logRow.setExecutionId(EXECUTION_ID); + logRow.setUserId(USER_ID); + logRow.setUserNicknameSnapshot("旧成员名"); + logRow.setActionType("join"); + logRow.setOperatorUserId(OWNER_ID); + logRow.setOperatorNicknameSnapshot("旧操作人名"); + logRow.setActionTime(LocalDateTime.now()); + when(executionMemberLogMapper.selectPageByExecutionId(eq(EXECUTION_ID), any(ExecutionMemberLogPageReqVO.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 page = projectExecutionMemberService + .getExecutionMemberLogPage(PROJECT_ID, EXECUTION_ID, reqVO); + + assertEquals("陈道飞", page.getList().get(0).getUserNicknameSnapshot()); + assertEquals("灿能源码", page.getList().get(0).getOperatorNicknameSnapshot()); + verify(adminUserApi).getUserMap(anyCollection()); + } + + // -------------------- writeMemberLog 直接调用 -------------------- + + @Test + void writeMemberLog_shouldInsertWithProvidedFields() { + projectExecutionMemberService.writeMemberLog(EXECUTION_ID, USER_ID, "owner_transfer_in", "test reason"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ExecutionMemberLogDO.class); + verify(executionMemberLogMapper).insert(captor.capture()); + assertEquals(EXECUTION_ID, captor.getValue().getExecutionId()); + assertEquals(USER_ID, captor.getValue().getUserId()); + assertEquals("owner_transfer_in", captor.getValue().getActionType()); + assertEquals("test reason", captor.getValue().getReason()); + assertNotNull(captor.getValue().getActionTime()); + } + + // -------------------- helpers -------------------- + + private void stubEditableContext() { + when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject()); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) + .thenReturn(createStatus("project", "pending", true)); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending")) + .thenReturn(createStatus("execution", "pending", true)); + when(projectExecutionMapper.selectByProjectIdAndId(PROJECT_ID, EXECUTION_ID)) + .thenReturn(createExecution()); + } + + private ProjectDO createEditableProject() { + ProjectDO project = new ProjectDO(); + project.setId(PROJECT_ID); + project.setProjectCode("CNPJ2026001"); + project.setProjectName("测试项目"); + project.setStatusCode("pending"); + project.setManagerUserId(OWNER_ID); + return project; + } + + private ProjectExecutionDO createExecution() { + ProjectExecutionDO execution = new ProjectExecutionDO(); + execution.setId(EXECUTION_ID); + execution.setProjectId(PROJECT_ID); + execution.setExecutionName("接口联调"); + execution.setOwnerId(OWNER_ID); + execution.setStatusCode("pending"); + execution.setProgressRate(BigDecimal.ZERO); + execution.setDeleted(Boolean.FALSE); + return execution; + } + + private ExecutionMemberDO createMember(Long id, Long userId, LocalDateTime removedAt) { + ExecutionMemberDO member = new ExecutionMemberDO(); + member.setId(id); + member.setExecutionId(EXECUTION_ID); + member.setUserId(userId); + member.setJoinedAt(LocalDateTime.now().minusDays(2)); + member.setRemovedAt(removedAt); + member.setRemovedReason(removedAt == null ? null : "历史退出"); + return member; + } + + private UserObjectRoleDO createProjectMember() { + UserObjectRoleDO role = new UserObjectRoleDO(); + role.setId(99L); + role.setObjectType("project"); + role.setObjectId(PROJECT_ID); + role.setUserId(USER_ID); + role.setRoleId(3102L); + role.setStatus(0); + role.setJoinedTime(LocalDateTime.now()); + return role; + } + + private ObjectStatusModelDO createStatus(String objectType, String statusCode, boolean allowEdit) { + ObjectStatusModelDO status = new ObjectStatusModelDO(); + status.setObjectType(objectType); + status.setStatusCode(statusCode); + status.setAllowEdit(allowEdit); + return status; + } + + private AdminUserRespDTO createUser(String nickname) { + AdminUserRespDTO user = new AdminUserRespDTO(); + user.setNickname(nickname); + return user; + } + +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImplTest.java new file mode 100644 index 0000000..2fd7d9e --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImplTest.java @@ -0,0 +1,454 @@ +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.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.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.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.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.ProjectExecutionMapper; +import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionStatusLogMapper; +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 org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +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.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.Mockito.atLeastOnce; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ProjectExecutionServiceImpl projectExecutionService; + @Mock + private ProjectMapper projectMapper; + @Mock + private ProjectExecutionMapper projectExecutionMapper; + @Mock + private ExecutionMemberMapper executionMemberMapper; + @Mock + private UserObjectRoleMapper userObjectRoleMapper; + @Mock + private BizAuditLogMapper bizAuditLogMapper; + @Mock + private ObjectStatusModelMapper objectStatusModelMapper; + @Mock + private ObjectStatusTransitionMapper objectStatusTransitionMapper; + @Mock + private ProjectExecutionStatusLogMapper projectExecutionStatusLogMapper; + @Mock + private DictDataApi dictDataApi; + @Mock + private ProjectExecutionMemberService projectExecutionMemberService; + + @Test + void createExecution_shouldInsertPendingExecutionAndMembers() { + Long projectId = 2001L; + ProjectExecutionSaveReqVO reqVO = new ProjectExecutionSaveReqVO(); + reqVO.setExecutionName("后端接口联调"); + reqVO.setExecutionType("feature"); + reqVO.setOwnerId(3001L); + reqVO.setProjectRequirementId(null); + reqVO.setMemberUserIds(List.of(3002L, 3003L)); + + when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) + .thenReturn(createProjectStatus("pending", true)); + when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("execution")) + .thenReturn(createExecutionStatus("pending", true)); + when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3001L)) + .thenReturn(createProjectMember(projectId, 3001L, 3101L)); + when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3002L)) + .thenReturn(createProjectMember(projectId, 3002L, 3102L)); + when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3003L)) + .thenReturn(createProjectMember(projectId, 3003L, 3102L)); + when(projectExecutionMapper.selectByProjectIdAndName(projectId, "后端接口联调")).thenReturn(null); + when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature"))) + .thenReturn(success(true)); + when(projectExecutionMapper.insert(any(ProjectExecutionDO.class))).thenAnswer(invocation -> { + ProjectExecutionDO execution = invocation.getArgument(0); + execution.setId(5001L); + return 1; + }); + + Long executionId = projectExecutionService.createExecution(projectId, reqVO); + + assertNotNull(executionId); + ArgumentCaptor executionCaptor = ArgumentCaptor.forClass(ProjectExecutionDO.class); + verify(projectExecutionMapper).insert(executionCaptor.capture()); + assertEquals("pending", executionCaptor.getValue().getStatusCode()); + assertEquals(BigDecimal.ZERO, executionCaptor.getValue().getProgressRate()); + assertEquals(projectId, executionCaptor.getValue().getProjectId()); + assertNull(executionCaptor.getValue().getProjectRequirementId()); + assertNull(executionCaptor.getValue().getActualStartDate()); + assertNull(executionCaptor.getValue().getActualEndDate()); + verify(executionMemberMapper, times(2)).insert(any(ExecutionMemberDO.class)); + verify(bizAuditLogMapper, atLeastOnce()).insert(any(BizAuditLogDO.class)); + } + + @Test + void createExecution_whenRequirementIdNotNull_shouldThrowNotReady() { + Long projectId = 2001L; + ProjectExecutionSaveReqVO reqVO = new ProjectExecutionSaveReqVO(); + reqVO.setExecutionName("后端接口联调"); + reqVO.setOwnerId(3001L); + reqVO.setProjectRequirementId(9001L); + + when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) + .thenReturn(createProjectStatus("pending", true)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectExecutionService.createExecution(projectId, reqVO)); + + assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_REQUIREMENT_NOT_READY.getCode(), ex.getCode()); + } + + @Test + void createExecution_whenExecutionTypeInvalid_shouldThrow() { + Long projectId = 2001L; + ProjectExecutionSaveReqVO reqVO = new ProjectExecutionSaveReqVO(); + reqVO.setExecutionName("后端接口联调"); + reqVO.setExecutionType("unknown"); + reqVO.setOwnerId(3001L); + reqVO.setMemberUserIds(List.of(3002L)); + + when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) + .thenReturn(createProjectStatus("pending", true)); + when(projectExecutionMapper.selectByProjectIdAndName(projectId, "后端接口联调")).thenReturn(null); + when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3001L)) + .thenReturn(createProjectMember(projectId, 3001L, 3101L)); + when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("unknown"))) + .thenThrow(new RuntimeException("invalid dict")); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectExecutionService.createExecution(projectId, reqVO)); + + assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_TYPE_INVALID.getCode(), ex.getCode()); + } + + @Test + void createExecution_whenMemberListEmpty_shouldThrowRequired() { + Long projectId = 2001L; + ProjectExecutionSaveReqVO reqVO = new ProjectExecutionSaveReqVO(); + reqVO.setExecutionName("后端接口联调"); + reqVO.setExecutionType("feature"); + reqVO.setOwnerId(3001L); + reqVO.setMemberUserIds(List.of()); + + when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) + .thenReturn(createProjectStatus("pending", true)); + when(projectExecutionMapper.selectByProjectIdAndName(projectId, "后端接口联调")).thenReturn(null); + when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3001L)) + .thenReturn(createProjectMember(projectId, 3001L, 3101L)); + when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature"))) + .thenReturn(success(true)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectExecutionService.createExecution(projectId, reqVO)); + + assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_REQUIRED.getCode(), ex.getCode()); + } + + @Test + void updateExecution_shouldKeepPendingStatusAndRejectManualStatusInput() { + Long projectId = 2001L; + Long executionId = 5001L; + ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L); + ProjectExecutionSaveReqVO reqVO = new ProjectExecutionSaveReqVO(); + reqVO.setId(executionId); + reqVO.setExecutionName("接口联调-修订"); + reqVO.setExecutionType("feature"); + reqVO.setOwnerId(3001L); + reqVO.setProjectRequirementId(null); + + when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) + .thenReturn(createProjectStatus("pending", true)); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending")) + .thenReturn(createExecutionStatus("pending", true)); + when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution); + when(projectExecutionMapper.selectByProjectIdAndName(projectId, "接口联调-修订")).thenReturn(null); + when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3001L)) + .thenReturn(createProjectMember(projectId, 3001L, 3101L)); + when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature"))) + .thenReturn(success(true)); + + projectExecutionService.updateExecution(projectId, reqVO); + + assertEquals("pending", execution.getStatusCode()); + verify(projectExecutionMapper).updateById(execution); + } + + @Test + void changeOwner_shouldUpdateOwnerAndWriteOwnerTransferLogs() { + Long projectId = 2001L; + Long executionId = 5001L; + ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L); + when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) + .thenReturn(createProjectStatus("pending", true)); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending")) + .thenReturn(createExecutionStatus("pending", true)); + when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution); + when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3002L)) + .thenReturn(createProjectMember(projectId, 3002L, 3102L)); + + ProjectExecutionOwnerChangeReqVO reqVO = new ProjectExecutionOwnerChangeReqVO(); + reqVO.setNewOwnerId(3002L); + reqVO.setReason("负责人调整"); + + projectExecutionService.changeOwner(projectId, executionId, reqVO); + + assertEquals(3002L, execution.getOwnerId()); + 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, "负责人调整"); + } + + @Test + void changeExecutionStatus_shouldUpdateByTransitionAndWriteStatusAndAuditLogs() { + Long projectId = 2001L; + Long executionId = 5001L; + ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L); + ProjectExecutionStatusActionReqVO reqVO = new ProjectExecutionStatusActionReqVO(); + reqVO.setActionCode("start"); + + when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); + when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution); + when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("execution", "pending", "start")) + .thenReturn(createTransition("start", "active", false)); + when(projectExecutionMapper.updateStatusByIdAndStatus(executionId, "pending", "active", null)).thenReturn(1); + + projectExecutionService.changeExecutionStatus(projectId, executionId, reqVO); + + verify(projectExecutionMapper).updateStatusByIdAndStatus(executionId, "pending", "active", null); + ArgumentCaptor executionCaptor = ArgumentCaptor.forClass(ProjectExecutionDO.class); + verify(projectExecutionMapper).updateById(executionCaptor.capture()); + assertNotNull(executionCaptor.getValue().getActualStartDate()); + ArgumentCaptor statusCaptor = + ArgumentCaptor.forClass(ProjectExecutionStatusLogDO.class); + verify(projectExecutionStatusLogMapper).insert(statusCaptor.capture()); + assertEquals("start", statusCaptor.getValue().getActionType()); + assertEquals("pending", statusCaptor.getValue().getFromStatus()); + assertEquals("active", statusCaptor.getValue().getToStatus()); + verify(bizAuditLogMapper).insert(any(BizAuditLogDO.class)); + } + + @Test + void changeExecutionStatus_whenReasonRequiredButBlank_shouldThrow() { + Long projectId = 2001L; + Long executionId = 5001L; + ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L); + ProjectExecutionStatusActionReqVO reqVO = new ProjectExecutionStatusActionReqVO(); + reqVO.setActionCode("cancel"); + reqVO.setReason(" "); + + when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); + when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution); + when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("execution", "pending", "cancel")) + .thenReturn(createTransition("cancel", "cancelled", true)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectExecutionService.changeExecutionStatus(projectId, executionId, reqVO)); + + assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_REASON_REQUIRED.getCode(), ex.getCode()); + } + + @Test + void changeExecutionStatus_whenConcurrentModified_shouldThrow() { + Long projectId = 2001L; + Long executionId = 5001L; + ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L); + ProjectExecutionStatusActionReqVO reqVO = new ProjectExecutionStatusActionReqVO(); + reqVO.setActionCode("start"); + + when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); + when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution); + when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("execution", "pending", "start")) + .thenReturn(createTransition("start", "active", false)); + when(projectExecutionMapper.updateStatusByIdAndStatus(executionId, "pending", "active", null)).thenReturn(0); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectExecutionService.changeExecutionStatus(projectId, executionId, reqVO)); + + assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_CONCURRENT_MODIFIED.getCode(), ex.getCode()); + } + + @Test + void changeExecutionStatus_whenCompleteTransitionMissing_shouldThrow() { + Long projectId = 2001L; + Long executionId = 5001L; + ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L); + execution.setStatusCode("active"); + ProjectExecutionStatusActionReqVO reqVO = new ProjectExecutionStatusActionReqVO(); + reqVO.setActionCode("complete"); + + when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); + when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution); + when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("execution", "active", "complete")) + .thenReturn(null); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectExecutionService.changeExecutionStatus(projectId, executionId, reqVO)); + + assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_NOT_ALLOWED.getCode(), ex.getCode()); + } + + @Test + void updateExecution_whenExecutionPaused_shouldThrowNotAllowEdit() { + Long projectId = 2001L; + Long executionId = 5001L; + ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L); + execution.setStatusCode("paused"); + ProjectExecutionSaveReqVO reqVO = new ProjectExecutionSaveReqVO(); + reqVO.setId(executionId); + reqVO.setExecutionName("接口联调-修订"); + reqVO.setOwnerId(3001L); + + when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) + .thenReturn(createProjectStatus("pending", true)); + when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "paused")) + .thenReturn(createExecutionStatus("paused", false)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectExecutionService.updateExecution(projectId, reqVO)); + + assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_NOT_ALLOW_EDIT.getCode(), ex.getCode()); + } + + @Test + void changeOwner_whenExecutionPaused_shouldThrowNotAllowEdit() { + Long projectId = 2001L; + Long executionId = 5001L; + ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L); + execution.setStatusCode("paused"); + ProjectExecutionOwnerChangeReqVO reqVO = new ProjectExecutionOwnerChangeReqVO(); + reqVO.setNewOwnerId(3002L); + + when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) + .thenReturn(createProjectStatus("pending", true)); + when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "paused")) + .thenReturn(createExecutionStatus("paused", false)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectExecutionService.changeOwner(projectId, executionId, reqVO)); + + assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_NOT_ALLOW_EDIT.getCode(), ex.getCode()); + } + + @Test + void getExecutionPage_shouldDelegateMapper() { + Long projectId = 2001L; + ProjectExecutionPageReqVO reqVO = new ProjectExecutionPageReqVO(); + reqVO.setPageNo(1); + reqVO.setPageSize(20); + when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); + when(projectExecutionMapper.selectPageByProjectId(projectId, reqVO)) + .thenReturn(new PageResult<>(List.of(), 0L)); + + PageResult result = projectExecutionService.getExecutionPage(projectId, reqVO); + + assertEquals(0L, result.getTotal()); + } + + private ProjectDO createEditableProject(Long projectId) { + ProjectDO project = new ProjectDO(); + project.setId(projectId); + project.setProjectCode("CNPJ2026001"); + project.setProjectName("测试项目"); + project.setStatusCode("pending"); + project.setManagerUserId(3001L); + return project; + } + + private ObjectStatusModelDO createProjectStatus(String statusCode, boolean allowEdit) { + ObjectStatusModelDO statusModel = new ObjectStatusModelDO(); + statusModel.setObjectType("project"); + statusModel.setStatusCode(statusCode); + statusModel.setAllowEdit(allowEdit); + return statusModel; + } + + private ObjectStatusModelDO createExecutionStatus(String statusCode, boolean allowEdit) { + ObjectStatusModelDO statusModel = new ObjectStatusModelDO(); + statusModel.setObjectType("execution"); + statusModel.setStatusCode(statusCode); + statusModel.setAllowEdit(allowEdit); + return statusModel; + } + + private ObjectStatusTransitionDO createTransition(String actionCode, String toStatus, boolean needReason) { + ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO(); + transition.setActionCode(actionCode); + transition.setActionName(actionCode); + transition.setToStatusCode(toStatus); + transition.setNeedReason(needReason); + return transition; + } + + private UserObjectRoleDO createProjectMember(Long projectId, Long userId, Long roleId) { + UserObjectRoleDO member = new UserObjectRoleDO(); + member.setId(projectId + userId); + member.setObjectType("project"); + member.setObjectId(projectId); + member.setUserId(userId); + member.setRoleId(roleId); + member.setStatus(0); + member.setJoinedTime(LocalDateTime.now()); + return member; + } + + private ProjectExecutionDO createExecution(Long projectId, Long executionId, Long ownerId) { + ProjectExecutionDO execution = new ProjectExecutionDO(); + execution.setId(executionId); + execution.setProjectId(projectId); + execution.setExecutionName("接口联调"); + execution.setOwnerId(ownerId); + execution.setStatusCode("pending"); + execution.setProgressRate(BigDecimal.ZERO); + execution.setDeleted(Boolean.FALSE); + return execution; + } +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionStatusViewServiceTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionStatusViewServiceTest.java new file mode 100644 index 0000000..77e8627 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionStatusViewServiceTest.java @@ -0,0 +1,72 @@ +package com.njcn.rdms.module.project.service.project.execution; + +import com.njcn.rdms.framework.common.exception.ServiceException; +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; +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 org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.List; + +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.when; + +class ProjectExecutionStatusViewServiceTest extends BaseMockitoUnitTest { + + @InjectMocks + private ProjectExecutionStatusViewService projectExecutionStatusViewService; + @Mock + private ObjectStatusModelMapper objectStatusModelMapper; + @Mock + 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); + + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "active")) + .thenReturn(statusModel); + when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("execution", "active")) + .thenReturn(List.of(transition)); + + ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result = + projectExecutionStatusViewService.getLifecycle("active"); + + 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_whenStatusModelMissing_shouldThrowException() { + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "active")) + .thenReturn(null); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectExecutionStatusViewService.getLifecycle("active")); + assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_MODEL_NOT_EXISTS_OR_DISABLED.getCode(), ex.getCode()); + } + +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImplTest.java new file mode 100644 index 0000000..e181427 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImplTest.java @@ -0,0 +1,654 @@ +package com.njcn.rdms.module.project.service.project.task; + +import com.njcn.rdms.framework.common.exception.ServiceException; +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.project.ProjectDO; +import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionMemberDO; +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; +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.ProjectExecutionMapper; +import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper; +import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskStatusLogMapper; +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.service.project.ProjectService; +import com.njcn.rdms.module.project.service.project.task.assignee.TaskAssigneeService; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.math.BigDecimal; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ProjectTaskServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ProjectTaskServiceImpl projectTaskService; + @Mock + private ProjectMapper projectMapper; + @Mock + private ProjectExecutionMapper projectExecutionMapper; + @Mock + private ExecutionMemberMapper executionMemberMapper; + @Mock + private ProjectTaskMapper projectTaskMapper; + @Mock + private TaskWorklogMapper taskWorklogMapper; + @Mock + private ProjectTaskStatusLogMapper projectTaskStatusLogMapper; + @Mock + private BizAuditLogMapper bizAuditLogMapper; + @Mock + private ObjectStatusModelMapper objectStatusModelMapper; + @Mock + private ObjectStatusTransitionMapper objectStatusTransitionMapper; + @Mock + private ProjectService projectService; + @Mock + private TaskAssigneeService taskAssigneeService; + + @Test + void createTask_shouldInsertPendingTaskAndAutoStartProject() { + Long projectId = 2001L; + Long executionId = 5001L; + ProjectTaskSaveReqVO reqVO = createTaskReqVO(); + reqVO.setOwnerId(3002L); + + 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(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L)) + .thenReturn(createExecutionMember(executionId, 3002L)); + when(projectTaskMapper.insert(any(ProjectTaskDO.class))).thenAnswer(invocation -> { + ProjectTaskDO task = invocation.getArgument(0); + task.setId(9001L); + return 1; + }); + + Long taskId = projectTaskService.createTask(projectId, executionId, reqVO); + + assertNotNull(taskId); + ArgumentCaptor taskCaptor = ArgumentCaptor.forClass(ProjectTaskDO.class); + verify(projectTaskMapper).insert(taskCaptor.capture()); + assertEquals(projectId, taskCaptor.getValue().getProjectId()); + assertEquals(executionId, taskCaptor.getValue().getExecutionId()); + assertEquals("接口联调任务", taskCaptor.getValue().getTaskTitle()); + assertEquals(3002L, taskCaptor.getValue().getOwnerId()); + assertEquals("pending", taskCaptor.getValue().getStatusCode()); + assertEquals(BigDecimal.ZERO, taskCaptor.getValue().getProgressRate()); + verify(projectService).autoStartProjectIfPending(projectId, "create_task"); + verify(bizAuditLogMapper).insert(any(BizAuditLogDO.class)); + } + + @Test + void createSubTask_whenOwnerBlank_shouldInheritParentOwner() { + Long projectId = 2001L; + Long executionId = 5001L; + ProjectTaskDO parentTask = createTask(projectId, executionId, 8001L, 3002L); + ProjectTaskSaveReqVO reqVO = createTaskReqVO(); + reqVO.setParentTaskId(parentTask.getId()); + reqVO.setOwnerId(null); + + when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); + when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)) + .thenReturn(createEditableExecution(projectId, executionId)); + when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTask.getId())) + .thenReturn(parentTask); + 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(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L)) + .thenReturn(createExecutionMember(executionId, 3002L)); + when(projectTaskMapper.insert(any(ProjectTaskDO.class))).thenAnswer(invocation -> { + ProjectTaskDO task = invocation.getArgument(0); + task.setId(9002L); + return 1; + }); + + projectTaskService.createTask(projectId, executionId, reqVO); + + ArgumentCaptor taskCaptor = ArgumentCaptor.forClass(ProjectTaskDO.class); + verify(projectTaskMapper).insert(taskCaptor.capture()); + assertEquals(3002L, taskCaptor.getValue().getOwnerId()); + assertEquals(parentTask.getId(), taskCaptor.getValue().getParentTaskId()); + } + + @Test + void createTask_whenOwnerNotActiveExecutionMember_shouldThrow() { + Long projectId = 2001L; + Long executionId = 5001L; + ProjectTaskSaveReqVO reqVO = createTaskReqVO(); + reqVO.setOwnerId(3999L); + + 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(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3999L)).thenReturn(null); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectTaskService.createTask(projectId, executionId, reqVO)); + + assertEquals(ErrorCodeConstants.PROJECT_TASK_OWNER_INVALID.getCode(), ex.getCode()); + } + + @Test + void createTask_whenParentNotInSameExecution_shouldThrow() { + Long projectId = 2001L; + Long executionId = 5001L; + ProjectTaskSaveReqVO reqVO = createTaskReqVO(); + reqVO.setParentTaskId(8001L); + reqVO.setOwnerId(3002L); + + 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(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, 8001L)).thenReturn(null); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectTaskService.createTask(projectId, executionId, reqVO)); + + assertEquals(ErrorCodeConstants.PROJECT_TASK_PARENT_INVALID.getCode(), ex.getCode()); + } + + @Test + void changeTaskStatus_shouldUseTransitionAndWriteLogs() { + Long projectId = 2001L; + Long executionId = 5001L; + Long taskId = 9001L; + ProjectTaskDO task = createTask(projectId, executionId, taskId, 3002L); + ProjectTaskStatusActionReqVO reqVO = new ProjectTaskStatusActionReqVO(); + reqVO.setActionCode("start"); + + when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task); + when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("task", "pending", "start")) + .thenReturn(createTransition("start", "active", false)); + when(projectTaskMapper.updateStatusByIdAndStatus(taskId, "pending", "active", null)).thenReturn(1); + + projectTaskService.changeTaskStatus(projectId, executionId, taskId, reqVO); + + verify(projectTaskMapper).updateStatusByIdAndStatus(taskId, "pending", "active", null); + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(ProjectTaskStatusLogDO.class); + verify(projectTaskStatusLogMapper).insert(statusCaptor.capture()); + assertEquals("start", statusCaptor.getValue().getActionType()); + assertEquals("pending", statusCaptor.getValue().getFromStatus()); + assertEquals("active", statusCaptor.getValue().getToStatus()); + verify(bizAuditLogMapper).insert(any(BizAuditLogDO.class)); + } + + @Test + void changeTaskStatus_whenReasonRequiredButBlank_shouldThrow() { + Long projectId = 2001L; + Long executionId = 5001L; + Long taskId = 9001L; + ProjectTaskDO task = createTask(projectId, executionId, taskId, 3002L); + ProjectTaskStatusActionReqVO reqVO = new ProjectTaskStatusActionReqVO(); + reqVO.setActionCode("cancel"); + reqVO.setReason(" "); + + when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task); + when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("task", "pending", "cancel")) + .thenReturn(createTransition("cancel", "cancelled", true)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectTaskService.changeTaskStatus(projectId, executionId, taskId, reqVO)); + + assertEquals(ErrorCodeConstants.PROJECT_TASK_STATUS_ACTION_REASON_REQUIRED.getCode(), ex.getCode()); + } + + @Test + void changeTaskStatus_whenConcurrentModified_shouldThrow() { + Long projectId = 2001L; + Long executionId = 5001L; + Long taskId = 9001L; + ProjectTaskDO task = createTask(projectId, executionId, taskId, 3002L); + ProjectTaskStatusActionReqVO reqVO = new ProjectTaskStatusActionReqVO(); + reqVO.setActionCode("start"); + + when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task); + when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("task", "pending", "start")) + .thenReturn(createTransition("start", "active", false)); + when(projectTaskMapper.updateStatusByIdAndStatus(taskId, "pending", "active", null)).thenReturn(0); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectTaskService.changeTaskStatus(projectId, executionId, taskId, reqVO)); + + assertEquals(ErrorCodeConstants.PROJECT_TASK_STATUS_CONCURRENT_MODIFIED.getCode(), ex.getCode()); + } + + // -------------------- Phase 3 进度自动汇总 + 叶子转父限制 -------------------- + + @Test + void createTask_whenParentIsLeafWithProgress_shouldThrowLeafToParentForbiddenProgress() { + Long projectId = 2001L; + Long executionId = 5001L; + Long parentTaskId = 8001L; + ProjectTaskDO parentTask = createTask(projectId, executionId, parentTaskId, 3002L); + parentTask.setProgressRate(new BigDecimal("30.00")); + ProjectTaskSaveReqVO reqVO = createTaskReqVO(); + reqVO.setParentTaskId(parentTaskId); + reqVO.setOwnerId(3002L); + + 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(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId)) + .thenReturn(parentTask); + when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L)) + .thenReturn(createExecutionMember(executionId, 3002L)); + when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(0); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectTaskService.createTask(projectId, executionId, reqVO)); + assertEquals(ErrorCodeConstants.PROJECT_TASK_LEAF_TO_PARENT_FORBIDDEN_PROGRESS.getCode(), ex.getCode()); + verify(projectTaskMapper, never()).insert(any(ProjectTaskDO.class)); + } + + @Test + void createTask_whenParentIsLeafWithWorklog_shouldThrowLeafToParentForbiddenWorklog() { + Long projectId = 2001L; + Long executionId = 5001L; + Long parentTaskId = 8001L; + ProjectTaskDO parentTask = createTask(projectId, executionId, parentTaskId, 3002L); + parentTask.setProgressRate(BigDecimal.ZERO); + ProjectTaskSaveReqVO reqVO = createTaskReqVO(); + reqVO.setParentTaskId(parentTaskId); + reqVO.setOwnerId(3002L); + + 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(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId)) + .thenReturn(parentTask); + when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L)) + .thenReturn(createExecutionMember(executionId, 3002L)); + when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(0); + when(taskWorklogMapper.existsByTaskId(parentTaskId)).thenReturn(true); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectTaskService.createTask(projectId, executionId, reqVO)); + assertEquals(ErrorCodeConstants.PROJECT_TASK_LEAF_TO_PARENT_FORBIDDEN_WORKLOG.getCode(), ex.getCode()); + verify(projectTaskMapper, never()).insert(any(ProjectTaskDO.class)); + } + + @Test + void createTask_whenParentAlreadyHasChildren_shouldSkipLeafCheckAndRecalc() { + Long projectId = 2001L; + Long executionId = 5001L; + Long parentTaskId = 8001L; + // 父任务进度 60%,但已有 1 个子任务,已是父任务,不再触发"叶子转父"校验 + ProjectTaskDO parentTask = createTask(projectId, executionId, parentTaskId, 3002L); + parentTask.setProgressRate(new BigDecimal("60.00")); + ProjectTaskSaveReqVO reqVO = createTaskReqVO(); + reqVO.setParentTaskId(parentTaskId); + reqVO.setOwnerId(3002L); + + 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(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId)) + .thenReturn(parentTask); + when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L)) + .thenReturn(createExecutionMember(executionId, 3002L)); + // 已有 1 个子任务 → 跳过叶子转父校验 + when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(1); + // 插入新任务(id=9002)后,父任务下有 2 个子,进度分别 60.00 与 0.00 → AVG 30.00 + when(projectTaskMapper.insert(any(ProjectTaskDO.class))).thenAnswer(inv -> { + ProjectTaskDO task = inv.getArgument(0); + task.setId(9002L); + return 1; + }); + when(projectTaskMapper.selectById(parentTaskId)).thenReturn(parentTask); + ProjectTaskDO existingChild = createTask(projectId, executionId, 9001L, 3002L); + existingChild.setProgressRate(new BigDecimal("60.00")); + ProjectTaskDO newChild = createTask(projectId, executionId, 9002L, 3002L); + newChild.setProgressRate(BigDecimal.ZERO); + when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId)) + .thenReturn(List.of(existingChild, newChild)); + + projectTaskService.createTask(projectId, executionId, reqVO); + + ArgumentCaptor progressCaptor = ArgumentCaptor.forClass(BigDecimal.class); + verify(projectTaskMapper).updateProgressRateById(eq(parentTaskId), progressCaptor.capture()); + assertEquals(0, new BigDecimal("30.00").compareTo(progressCaptor.getValue())); + verify(taskWorklogMapper, never()).existsByTaskId(parentTaskId); + } + + @Test + void createTask_withLeafParentZeroProgress_shouldRecalcGrandparentChain() { + Long projectId = 2001L; + Long executionId = 5001L; + Long grandparentId = 7000L; + Long parentTaskId = 8001L; + ProjectTaskDO parentTask = createTask(projectId, executionId, parentTaskId, 3002L); + parentTask.setProgressRate(BigDecimal.ZERO); + parentTask.setParentTaskId(grandparentId); + // 爷爷的初值刻意设为 50.00,便于稳定验证"递归爬到爷爷且发生写入" + 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); + + 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(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId)) + .thenReturn(parentTask); + when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L)) + .thenReturn(createExecutionMember(executionId, 3002L)); + when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(0); + when(taskWorklogMapper.existsByTaskId(parentTaskId)).thenReturn(false); + when(projectTaskMapper.insert(any(ProjectTaskDO.class))).thenAnswer(inv -> { + ProjectTaskDO t = inv.getArgument(0); + t.setId(9002L); + return 1; + }); + // 父链:parent(8001) -> grandparent(7000) -> null + when(projectTaskMapper.selectById(parentTaskId)).thenReturn(parentTask); + when(projectTaskMapper.selectById(grandparentId)).thenReturn(grandparent); + ProjectTaskDO newChild = createTask(projectId, executionId, 9002L, 3002L); + newChild.setProgressRate(new BigDecimal("80.00")); + when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId)) + .thenReturn(List.of(newChild)); + when(projectTaskMapper.selectChildrenProgressByParentTaskId(grandparentId)) + .thenReturn(List.of(parentTask)); + + projectTaskService.createTask(projectId, executionId, reqVO); + + // parent: AVG([80.00]) = 80.00 + verify(projectTaskMapper).updateProgressRateById(eq(parentTaskId), + argThat(v -> new BigDecimal("80.00").compareTo(v) == 0)); + // 爷爷:mock 返回的 parentTask 仍是初始引用(progress=0,未被自动改写) + // → 爷爷应被设为 0;而其原值 50.00 不等,会触发更新 + 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 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; + Long executionId = 5001L; + Long taskId = 9001L; + Long oldParentId = 8001L; + Long newParentId = 8002L; + ProjectTaskDO task = createTask(projectId, executionId, taskId, 3002L); + task.setParentTaskId(oldParentId); + task.setProgressRate(new BigDecimal("70.00")); + ProjectTaskDO oldParent = createTask(projectId, executionId, oldParentId, 3002L); + ProjectTaskDO newParent = createTask(projectId, executionId, newParentId, 3002L); + newParent.setProgressRate(BigDecimal.ZERO); + + ProjectTaskSaveReqVO reqVO = createTaskReqVO(); + 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)) + .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, newParentId)) + .thenReturn(newParent); + when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L)) + .thenReturn(createExecutionMember(executionId, 3002L)); + // 校验"叶子转父":新父 8002 当前是叶子,进度=0,无工时 → 通过 + when(projectTaskMapper.countChildrenByParentTaskId(newParentId)).thenReturn(0); + when(taskWorklogMapper.existsByTaskId(newParentId)).thenReturn(false); + // 当前任务自身:是叶子 + when(projectTaskMapper.countChildrenByParentTaskId(taskId)).thenReturn(0); + // 递归刷新两条链:旧父 8001 与 新父 8002 + when(projectTaskMapper.selectById(oldParentId)).thenReturn(oldParent); + when(projectTaskMapper.selectById(newParentId)).thenReturn(newParent); + when(projectTaskMapper.selectChildrenProgressByParentTaskId(oldParentId)) + .thenReturn(List.of()); // 旧父在迁移后无子,保留原值 + when(projectTaskMapper.selectChildrenProgressByParentTaskId(newParentId)) + .thenReturn(List.of(task)); + + projectTaskService.updateTask(projectId, executionId, reqVO); + + verify(projectTaskMapper).updateProgressRateById(eq(newParentId), + argThat(v -> new BigDecimal("70.00").compareTo(v) == 0)); + // 旧父无子,recalcParentProgressFrom 早退,不调 updateProgressRateById + verify(projectTaskMapper, never()).updateProgressRateById(eq(oldParentId), any(BigDecimal.class)); + } + + private ProjectTaskSaveReqVO createTaskReqVO() { + ProjectTaskSaveReqVO reqVO = new ProjectTaskSaveReqVO(); + reqVO.setTaskTitle("接口联调任务"); + reqVO.setProgressRate(BigDecimal.ZERO); + reqVO.setTaskDesc("完成接口联调"); + return reqVO; + } + + private ProjectDO createEditableProject(Long projectId) { + ProjectDO project = new ProjectDO(); + project.setId(projectId); + project.setProjectName("测试项目"); + project.setStatusCode("pending"); + return project; + } + + private ProjectExecutionDO createEditableExecution(Long projectId, Long executionId) { + ProjectExecutionDO execution = new ProjectExecutionDO(); + execution.setId(executionId); + execution.setProjectId(projectId); + execution.setExecutionName("接口联调"); + execution.setStatusCode("pending"); + return execution; + } + + private ProjectTaskDO createTask(Long projectId, Long executionId, Long taskId, Long ownerId) { + ProjectTaskDO task = new ProjectTaskDO(); + task.setId(taskId); + task.setProjectId(projectId); + task.setExecutionId(executionId); + task.setTaskTitle("接口联调任务"); + task.setOwnerId(ownerId); + task.setStatusCode("pending"); + task.setProgressRate(BigDecimal.ZERO); + return task; + } + + private ExecutionMemberDO createExecutionMember(Long executionId, Long userId) { + ExecutionMemberDO member = new ExecutionMemberDO(); + member.setExecutionId(executionId); + member.setUserId(userId); + return member; + } + + private ObjectStatusModelDO createStatus(String objectType, String statusCode, boolean allowEdit) { + ObjectStatusModelDO status = new ObjectStatusModelDO(); + status.setObjectType(objectType); + status.setStatusCode(statusCode); + status.setStatusName(statusCode); + status.setAllowEdit(allowEdit); + return status; + } + + private ObjectStatusTransitionDO createTransition(String actionCode, String toStatus, boolean needReason) { + ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO(); + transition.setActionCode(actionCode); + transition.setActionName(actionCode); + transition.setToStatusCode(toStatus); + transition.setNeedReason(needReason); + return transition; + } + +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskStatusViewServiceTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskStatusViewServiceTest.java new file mode 100644 index 0000000..e01c9ab --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskStatusViewServiceTest.java @@ -0,0 +1,74 @@ +package com.njcn.rdms.module.project.service.project.task; + +import com.njcn.rdms.framework.common.exception.ServiceException; +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; +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 org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.List; + +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.when; + +class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest { + + @InjectMocks + private ProjectTaskStatusViewService projectTaskStatusViewService; + @Mock + private ObjectStatusModelMapper objectStatusModelMapper; + @Mock + 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); + + ObjectStatusTransitionDO complete = createTransition("complete", "完成"); + ObjectStatusTransitionDO block = createTransition("block", "阻塞"); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "active")).thenReturn(statusModel); + when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("task", "active")) + .thenReturn(List.of(complete, block)); + + ProjectTaskStatusViewService.ProjectTaskLifecycleView view = + projectTaskStatusViewService.getLifecycle("active"); + + 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_whenStatusModelMissing_shouldThrow() { + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "missing")).thenReturn(null); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectTaskStatusViewService.getLifecycle("missing")); + + assertEquals(ErrorCodeConstants.PROJECT_TASK_STATUS_MODEL_NOT_EXISTS_OR_DISABLED.getCode(), ex.getCode()); + } + + private ObjectStatusTransitionDO createTransition(String actionCode, String actionName) { + ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO(); + transition.setActionCode(actionCode); + transition.setActionName(actionName); + return transition; + } + +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/assignee/TaskAssigneeServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/assignee/TaskAssigneeServiceImplTest.java new file mode 100644 index 0000000..414a41e --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/assignee/TaskAssigneeServiceImplTest.java @@ -0,0 +1,397 @@ +package com.njcn.rdms.module.project.service.project.task.assignee; + +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.task.vo.assignee.TaskAssigneeInactiveReqVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.assignee.TaskAssigneeLogPageReqVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.assignee.TaskAssigneeLogRespVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.assignee.TaskAssigneeRespVO; +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.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.TaskAssigneeLogDO; +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.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.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 java.time.LocalDateTime; +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class TaskAssigneeServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private TaskAssigneeServiceImpl taskAssigneeService; + + @Mock + private ProjectMapper projectMapper; + @Mock + private ProjectExecutionMapper projectExecutionMapper; + @Mock + private ProjectTaskMapper projectTaskMapper; + @Mock + private ExecutionMemberMapper executionMemberMapper; + @Mock + private TaskAssigneeMapper taskAssigneeMapper; + @Mock + private TaskAssigneeLogMapper taskAssigneeLogMapper; + @Mock + private ObjectStatusModelMapper objectStatusModelMapper; + @Mock + private BizAuditLogMapper bizAuditLogMapper; + @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 USER_ID = 3002L; + + // -------------------- createAssignee -------------------- + + @Test + void createAssignee_shouldInsertActiveRowAndJoinLog() { + 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)) + .thenReturn(createExecutionMember(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); + assignee.setId(7001L); + return 1; + }); + + Long assigneeId = taskAssigneeService.createAssignee(PROJECT_ID, EXECUTION_ID, TASK_ID, reqVO); + + assertEquals(7001L, assigneeId); + ArgumentCaptor activeCaptor = ArgumentCaptor.forClass(TaskAssigneeDO.class); + verify(taskAssigneeMapper).insert(activeCaptor.capture()); + assertEquals(TASK_ID, activeCaptor.getValue().getTaskId()); + assertEquals(USER_ID, activeCaptor.getValue().getUserId()); + assertNotNull(activeCaptor.getValue().getJoinedAt()); + + ArgumentCaptor logCaptor = ArgumentCaptor.forClass(TaskAssigneeLogDO.class); + verify(taskAssigneeLogMapper).insert(logCaptor.capture()); + assertEquals(TASK_ID, logCaptor.getValue().getTaskId()); + assertEquals(USER_ID, logCaptor.getValue().getUserId()); + assertEquals("join", logCaptor.getValue().getActionType()); + assertNull(logCaptor.getValue().getReason()); + assertNotNull(logCaptor.getValue().getActionTime()); + } + + @Test + void createAssignee_whenUserIsOwner_shouldThrow() { + TaskAssigneeSaveReqVO reqVO = new TaskAssigneeSaveReqVO(); + reqVO.setUserId(OWNER_ID); + + stubEditableContext(); + when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID)) + .thenReturn(createTask(OWNER_ID)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> taskAssigneeService.createAssignee(PROJECT_ID, EXECUTION_ID, TASK_ID, reqVO)); + assertEquals(ErrorCodeConstants.PROJECT_TASK_ASSIGNEE_OWNER_CONFLICT.getCode(), ex.getCode()); + verify(taskAssigneeMapper, never()).insert(any(TaskAssigneeDO.class)); + } + + @Test + void createAssignee_whenUserNotActiveExecutionMember_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)) + .thenReturn(null); + + ServiceException ex = assertThrows(ServiceException.class, + () -> taskAssigneeService.createAssignee(PROJECT_ID, EXECUTION_ID, TASK_ID, reqVO)); + assertEquals(ErrorCodeConstants.PROJECT_TASK_ASSIGNEE_INVALID_MEMBER.getCode(), ex.getCode()); + verify(taskAssigneeMapper, never()).insert(any(TaskAssigneeDO.class)); + } + + @Test + void createAssignee_whenAlreadyActive_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)) + .thenReturn(createExecutionMember(EXECUTION_ID, USER_ID)); + when(taskAssigneeMapper.selectActiveByTaskIdAndUserId(TASK_ID, USER_ID)) + .thenReturn(createActiveAssignee(7001L, TASK_ID, USER_ID)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> taskAssigneeService.createAssignee(PROJECT_ID, EXECUTION_ID, TASK_ID, reqVO)); + assertEquals(ErrorCodeConstants.PROJECT_TASK_ASSIGNEE_ALREADY_ACTIVE.getCode(), ex.getCode()); + verify(taskAssigneeMapper, never()).insert(any(TaskAssigneeDO.class)); + } + + // -------------------- inactiveAssignee -------------------- + + @Test + void inactiveAssignee_shouldMarkDeletedAndWriteInactiveLog() { + Long assigneeId = 7001L; + TaskAssigneeInactiveReqVO reqVO = new TaskAssigneeInactiveReqVO(); + reqVO.setReason("项目阶段调整"); + + stubEditableContext(); + when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID)) + .thenReturn(createTask(OWNER_ID)); + when(taskAssigneeMapper.selectByIdAndTaskId(assigneeId, TASK_ID)) + .thenReturn(createActiveAssignee(assigneeId, TASK_ID, USER_ID)); + + taskAssigneeService.inactiveAssignee(PROJECT_ID, EXECUTION_ID, TASK_ID, assigneeId, reqVO); + + ArgumentCaptor activeCaptor = ArgumentCaptor.forClass(TaskAssigneeDO.class); + verify(taskAssigneeMapper).updateById(activeCaptor.capture()); + assertNotNull(activeCaptor.getValue().getRemovedAt()); + + ArgumentCaptor logCaptor = ArgumentCaptor.forClass(TaskAssigneeLogDO.class); + verify(taskAssigneeLogMapper).insert(logCaptor.capture()); + assertEquals("inactive", logCaptor.getValue().getActionType()); + assertEquals("项目阶段调整", logCaptor.getValue().getReason()); + assertEquals(USER_ID, logCaptor.getValue().getUserId()); + } + + @Test + void inactiveAssignee_whenAssigneeNotExists_shouldThrow() { + TaskAssigneeInactiveReqVO reqVO = new TaskAssigneeInactiveReqVO(); + reqVO.setReason("调整"); + + stubEditableContext(); + when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID)) + .thenReturn(createTask(OWNER_ID)); + when(taskAssigneeMapper.selectByIdAndTaskId(7001L, TASK_ID)).thenReturn(null); + + ServiceException ex = assertThrows(ServiceException.class, + () -> taskAssigneeService.inactiveAssignee(PROJECT_ID, EXECUTION_ID, TASK_ID, 7001L, reqVO)); + assertEquals(ErrorCodeConstants.PROJECT_TASK_ASSIGNEE_NOT_EXISTS.getCode(), ex.getCode()); + } + + @Test + void inactiveAssignee_whenAlreadyInactive_shouldThrow() { + TaskAssigneeInactiveReqVO reqVO = new TaskAssigneeInactiveReqVO(); + reqVO.setReason("调整"); + + stubEditableContext(); + when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID)) + .thenReturn(createTask(OWNER_ID)); + TaskAssigneeDO inactive = createActiveAssignee(7001L, TASK_ID, USER_ID); + inactive.setRemovedAt(LocalDateTime.now()); + when(taskAssigneeMapper.selectByIdAndTaskId(7001L, TASK_ID)).thenReturn(inactive); + + ServiceException ex = assertThrows(ServiceException.class, + () -> taskAssigneeService.inactiveAssignee(PROJECT_ID, EXECUTION_ID, TASK_ID, 7001L, reqVO)); + assertEquals(ErrorCodeConstants.PROJECT_TASK_ASSIGNEE_NOT_ACTIVE.getCode(), ex.getCode()); + } + + @Test + void inactiveAssignee_whenReasonBlank_shouldThrow() { + TaskAssigneeInactiveReqVO reqVO = new TaskAssigneeInactiveReqVO(); + reqVO.setReason(" "); + + stubEditableContext(); + when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID)) + .thenReturn(createTask(OWNER_ID)); + when(taskAssigneeMapper.selectByIdAndTaskId(7001L, TASK_ID)) + .thenReturn(createActiveAssignee(7001L, TASK_ID, USER_ID)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> taskAssigneeService.inactiveAssignee(PROJECT_ID, EXECUTION_ID, TASK_ID, 7001L, reqVO)); + assertEquals(ErrorCodeConstants.PROJECT_TASK_ASSIGNEE_REASON_REQUIRED.getCode(), ex.getCode()); + } + + // -------------------- getAssigneeList -------------------- + + @Test + void getAssigneeList_shouldReturnActiveAssigneesWithNickname() { + when(projectExecutionMapper.selectByProjectIdAndId(PROJECT_ID, EXECUTION_ID)) + .thenReturn(createExecution()); + when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID)) + .thenReturn(createTask(OWNER_ID)); + when(taskAssigneeMapper.selectActiveListByTaskId(TASK_ID)) + .thenReturn(List.of(createActiveAssignee(7001L, TASK_ID, USER_ID))); + AdminUserRespDTO user = new AdminUserRespDTO(); + user.setId(USER_ID); + user.setNickname("张三"); + when(adminUserApi.getUserMap(anyCollection())).thenReturn(Map.of(USER_ID, user)); + + List list = taskAssigneeService.getAssigneeList(PROJECT_ID, EXECUTION_ID, TASK_ID); + + assertEquals(1, list.size()); + assertEquals(7001L, list.get(0).getId()); + assertEquals(USER_ID, list.get(0).getUserId()); + assertEquals("张三", list.get(0).getUserNickname()); + } + + // -------------------- getAssigneeLogPage -------------------- + + @Test + void getAssigneeLogPage_shouldDelegateToMapper() { + TaskAssigneeLogPageReqVO reqVO = new TaskAssigneeLogPageReqVO(); + TaskAssigneeLogDO logDO = new TaskAssigneeLogDO(); + logDO.setId(8001L); + logDO.setTaskId(TASK_ID); + logDO.setUserId(USER_ID); + logDO.setUserNicknameSnapshot("张三"); + logDO.setActionType("join"); + logDO.setOperatorUserId(OWNER_ID); + logDO.setOperatorNicknameSnapshot("项目经理"); + logDO.setActionTime(LocalDateTime.now()); + PageResult page = new PageResult<>(List.of(logDO), 1L); + + when(projectExecutionMapper.selectByProjectIdAndId(PROJECT_ID, EXECUTION_ID)) + .thenReturn(createExecution()); + when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID)) + .thenReturn(createTask(OWNER_ID)); + when(taskAssigneeLogMapper.selectPageByTaskId(TASK_ID, reqVO)).thenReturn(page); + + PageResult result = + taskAssigneeService.getAssigneeLogPage(PROJECT_ID, EXECUTION_ID, TASK_ID, reqVO); + + assertEquals(1L, result.getTotal()); + assertEquals(1, result.getList().size()); + assertEquals("join", result.getList().get(0).getActionType()); + assertEquals(USER_ID, result.getList().get(0).getUserId()); + } + + // -------------------- initializeAssignees -------------------- + + @Test + void initializeAssignees_whenListEmpty_shouldNoop() { + taskAssigneeService.initializeAssignees(TASK_ID, OWNER_ID, EXECUTION_ID, Collections.emptyList()); + verify(taskAssigneeMapper, never()).insert(any(TaskAssigneeDO.class)); + verify(taskAssigneeLogMapper, never()).insert(any(TaskAssigneeLogDO.class)); + } + + @Test + 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(taskAssigneeMapper.selectActiveByTaskIdAndUserId(TASK_ID, userA)).thenReturn(null); + when(taskAssigneeMapper.selectActiveByTaskIdAndUserId(TASK_ID, userB)).thenReturn(null); + when(taskAssigneeMapper.insert(any(TaskAssigneeDO.class))).thenReturn(1); + + // 列表里 userA 重复出现两次,期望去重为单次写入 + taskAssigneeService.initializeAssignees(TASK_ID, OWNER_ID, EXECUTION_ID, List.of(userA, userB, userA)); + + verify(taskAssigneeMapper, org.mockito.Mockito.times(2)).insert(any(TaskAssigneeDO.class)); + verify(taskAssigneeLogMapper, org.mockito.Mockito.times(2)).insert(any(TaskAssigneeLogDO.class)); + } + + @Test + void initializeAssignees_whenContainsOwnerId_shouldThrow() { + ServiceException ex = assertThrows(ServiceException.class, + () -> taskAssigneeService.initializeAssignees(TASK_ID, OWNER_ID, EXECUTION_ID, List.of(OWNER_ID))); + assertEquals(ErrorCodeConstants.PROJECT_TASK_ASSIGNEE_OWNER_CONFLICT.getCode(), ex.getCode()); + verify(taskAssigneeMapper, never()).insert(any(TaskAssigneeDO.class)); + } + + // -------------------- 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 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 ExecutionMemberDO createExecutionMember(Long executionId, Long userId) { + ExecutionMemberDO member = new ExecutionMemberDO(); + member.setExecutionId(executionId); + member.setUserId(userId); + return member; + } + + private TaskAssigneeDO createActiveAssignee(Long id, Long taskId, Long userId) { + TaskAssigneeDO assignee = new TaskAssigneeDO(); + assignee.setId(id); + assignee.setTaskId(taskId); + assignee.setUserId(userId); + assignee.setJoinedAt(LocalDateTime.now()); + assignee.setRemovedAt(null); + 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; + } +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/worklog/TaskWorklogServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/worklog/TaskWorklogServiceImplTest.java new file mode 100644 index 0000000..328f26b --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/worklog/TaskWorklogServiceImplTest.java @@ -0,0 +1,412 @@ +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 mocked = mockLoginUser(OWNER_ID)) { + Long worklogId = taskWorklogService.createWorklog(PROJECT_ID, EXECUTION_ID, TASK_ID, reqVO); + assertEquals(11001L, worklogId); + } + + ArgumentCaptor 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 mocked = mockLoginUser(ASSIGNEE_USER_ID)) { + Long id = taskWorklogService.createWorklog(PROJECT_ID, EXECUTION_ID, TASK_ID, reqVO); + assertEquals(11002L, id); + } + + ArgumentCaptor 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 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 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 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 mocked = mockLoginUser(ASSIGNEE_USER_ID)) { + taskWorklogService.updateWorklog(PROJECT_ID, EXECUTION_ID, TASK_ID, 11001L, reqVO); + } + + ArgumentCaptor 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 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 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 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 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 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 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 result = taskWorklogService.sumDurationGroupedByTaskIds(List.of(9001L, 9002L)); + assertEquals(150L, result.get(9001L)); + assertEquals(30L, result.get(9002L)); + } + + @Test + void sumDurationGroupedByTaskIds_whenEmpty_shouldReturnEmptyMap() { + Map 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 mockLoginUser(Long loginUserId) { + MockedStatic 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; + } + +} diff --git a/rdms-system/rdms-system-boot/src/main/resources/application-dev.yaml b/rdms-system/rdms-system-boot/src/main/resources/application-dev.yaml index 3237cc0..79c21f7 100644 --- a/rdms-system/rdms-system-boot/src/main/resources/application-dev.yaml +++ b/rdms-system/rdms-system-boot/src/main/resources/application-dev.yaml @@ -7,12 +7,12 @@ spring: username: # Nacos 账号 password: # Nacos 密码 discovery: # 【配置中心】配置项 - namespace: dev # 命名空间。这里使用 dev 开发环境 + namespace: 1924bcfb-4eab-4c58-9003-4a37d5fc2949 # 命名空间。这里使用 dev 开发环境 group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP metadata: version: 1.0.0 # 服务实例的版本号,可用于灰度发布 config: # 【注册中心】配置项 - namespace: dev # 命名空间。这里使用 dev 开发环境 + namespace: 1924bcfb-4eab-4c58-9003-4a37d5fc2949 # 命名空间。这里使用 dev 开发环境 group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP #################### 数据库相关配置 #################### diff --git a/rdms-system/rdms-system-boot/src/main/resources/application-local.yaml b/rdms-system/rdms-system-boot/src/main/resources/application-local.yaml index 69b9c07..42119fb 100644 --- a/rdms-system/rdms-system-boot/src/main/resources/application-local.yaml +++ b/rdms-system/rdms-system-boot/src/main/resources/application-local.yaml @@ -6,12 +6,12 @@ spring: username: # Nacos 账号 password: # Nacos 密码 discovery: # 【配置中心】配置项 - namespace: dev # 命名空间。这里使用 dev 开发环境 + namespace: 1924bcfb-4eab-4c58-9003-4a37d5fc2949 # 命名空间。这里使用 dev 开发环境 group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP metadata: version: 1.0.0 # 服务实例的版本号,可用于灰度发布 config: # 【注册中心】配置项 - namespace: dev # 命名空间。这里使用 dev 开发环境 + namespace: 1924bcfb-4eab-4c58-9003-4a37d5fc2949 # 命名空间。这里使用 dev 开发环境 group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP #################### 数据库相关配置 ####################