13 Commits

Author SHA1 Message Date
dk
57061d4f03 fix(工作台-工时、周报、日志管理):
- 修改工时周聚合逻辑,从仅统计周一到周五改为周一到周日
- 修复周末工时分配逻辑,周末工时不参与工作日均摊而是保留在周末
- 修复头像上传时旧头像删除逻辑,通过URL查找文件路径进行删除
- 添加加班工时统计功能,在团队工时周报表中显示批准的加班时间
- 优化未提交报告提醒功能,避免重复发送未读消息的提醒
- 修改周报告提醒的cron表达式,从每周五改为每周日执行
2026-07-14 11:39:45 +08:00
5a2ca23217 refactor(user): 移除用户管理链路接口权限校验
- 移除 getUserManagementRelation 接口的权限码校验注解
- 移除 getUserManagementRelationQuery 接口的权限码校验注解
- 移除 getDirectManager 接口的权限码校验注解
- 移除 getCandidateSubordinateUsers 接口的权限码校验注解
- 移除 getDirectSubordinates 接口的权限码校验注解
- 更新接口文档说明为仅需登录访问
2026-07-14 11:25:27 +08:00
572fc391ee fix(user): 移除用户管理链路查询权限校验
- 将权限要求从 system:user-management-relation:query 改为仅需登录
- 移除 @PreAuthorize 注解对查询接口的权限控制
- 保留树形结构返回功能用于前端展示用户上下级关系
2026-07-14 10:50:26 +08:00
dk
2830b55ffb feat(绩效第二阶段、签名管理): 开发绩效第二阶段、下签功能、用户签名管理。 2026-07-13 22:09:39 +08:00
f77007c4b7 feat(file): 实现文件删除权限控制功能
- 新增 FILE_DELETE_NO_PERMISSION 错误码用于无权删除文件的提示
- 移除控制器层的 @PreAuthorize 注解,将权限校验逻辑下沉到服务层
- 在 FileServiceImpl 中注入 SecurityFrameworkService 进行权限验证
- 实现 validateFileDeletePermission 方法校验删除权限
- 支持上传者本人可删除自己上传的文件,管理员仍保留全局删除权限
- 批量删除时逐个校验权限,任一越权即整体拒绝操作
2026-07-13 16:23:24 +08:00
dk
5b2c6dd94c feat(performance): 优化绩效表合并导出功能
- 移除ZIP压缩功能,改为直接返回合并的Excel文件
- 新增MERGED_EXPORT_CELL_ADDRESSES常量定义合并导出单元格地址
- 添加正则表达式用于匹配和替换被考核人姓名
- 实现基于模板的合并导出功能,支持模板缓存机制
- 添加导出失败时的降级处理和空白页生成
- 实现被考核人姓名的自动填充和单元格值的安全读取
- 优化合并工作簿构建逻辑,移除重复的文件内容加载
- 改进下载文件命名规则,去除编号前缀并调整格式
2026-07-09 16:23:56 +08:00
dk
b6c2311749 feat(performance): 实现绩效表格预填充功能并优化配置管理 2026-07-08 19:59:42 +08:00
0a2d5ba86c docs(guidelines): 更新鉴权规范文档并移除需求服务单元测试
- 在 CLAUDE.md 中补充对象域权限校验的第二种正规写法:属主放行 + 权限码兜底
- 移除 ProductRequirementServiceImplTest.java 文件中的所有单元测试代码
- 更新 ProjectExecutionServiceImpl 添加属主放行的权限校验实现
- 在执行服务中新增 requireExecutionEditDeletePermission 方法处理负责人权限
- 修正执行更新、删除及删除预检接口的权限验证逻辑
2026-07-08 10:57:26 +08:00
2a5e90a4d4 Merge remote-tracking branch 'origin/main' 2026-07-08 10:56:39 +08:00
dk
796c79d58f feat(notify、workReport、Project):
- 移除月报审批请求VO中面谈时间字段的非空验证
- 新增站内信跳转参数常量类NotifyJumpConstants
- 在执行任务分配通知中添加跳转类型和项目ID参数
- 在项目创建通知中添加跳转类型和项目ID参数
- 在任务分配通知中添加跳转类型和项目ID参数
- 添加工作日报内容分隔符常量用于区分不同日期的工作内容
- 修改工作内容拼接方式使用新的分隔符替代分号
2026-07-07 16:48:41 +08:00
d65a7456db docs(project): 设计执行编辑删除权限口径 2026-07-07 15:23:49 +08:00
dk
66c187f3de feat(file、requirement): 优化文件服务和需求管理功能
- 新增按配置ID和路径获取文件的方法
- 实现文件不存在时的404响应处理
- 优化文件上传路径生成逻辑,使用UUID确保文件名安全性
- 添加需求执行选项查询接口和实现
- 支持排除特定状态码的需求分页查询
- 重构需求模块过滤逻辑并提取公共方法
- 修复S3客户端对象键解析问题
2026-07-06 16:50:52 +08:00
dk
9e502e6a80 fix(project): 实现项目和任务实际开始结束日期的自动同步功能。
fix(file): 优化 S3 文件客户端的 URL 处理逻辑。
2026-07-03 17:33:27 +08:00
100 changed files with 3082 additions and 1096 deletions

View File

@@ -78,7 +78,8 @@
红线: 红线:
- **对象内接口绝不能挂 `@PreAuthorize("@ss.hasPermission(...)")`**。该注解走的链路在 `PermissionServiceImpl` 里强制按 GLOBAL 取角色line 343-347+ 强制按 GLOBAL 查菜单line 92-94对象域角色与对象域菜单都进不来即使授权配置完全正确也必然 403。 - **对象内接口绝不能挂 `@PreAuthorize("@ss.hasPermission(...)")`**。该注解走的链路在 `PermissionServiceImpl` 里强制按 GLOBAL 取角色line 343-347+ 强制按 GLOBAL 查菜单line 92-94对象域角色与对象域菜单都进不来即使授权配置完全正确也必然 403。
- **对象域权限校验必须落在 Service 层 `@CheckObjectPermission`**,原因:路径里 `objectId` 通常以 `#projectId`/`#productId` 等 SpEL 解析Controller 的参数校验前置阶段不便复用;与同模块(`ProjectMemberServiceImpl` / `ProjectExecutionServiceImpl` / `ProjectExecutionAssigneeServiceImpl` / `ProjectTaskServiceImpl`)保持一致。 - **对象域权限校验必须落在 Service 层**,默认写法是方法上挂 `@CheckObjectPermission`,原因:路径里 `objectId` 通常以 `#projectId`/`#productId` 等 SpEL 解析Controller 的参数校验前置阶段不便复用;与同模块(`ProjectMemberServiceImpl` / `ProjectExecutionAssigneeServiceImpl`)保持一致。
- **第二种正规写法:属主放行 + 权限码兜底(方法体内裁决)**。任务、执行的创建 / 编辑 / 删除 / 删除预检采用"项目/执行/任务负责人身份命中直接放行,未命中回落对象域权限码"的口径,权限裁决在方法体内调 `ProjectObjectAuthorizationService`(入口见 `ProjectTaskServiceImpl.requireTaskEditDeletePermission` / `ProjectExecutionServiceImpl.requireExecutionEditDeletePermission`)。这些方法**故意不挂** `@CheckObjectPermission`——注解是前置拦截,会把"无权限码的负责人"挡死。**不要误判为漏鉴权把注解补回去**;盘点权限面时除了搜注解,还要搜 `ProjectObjectAuthorizationService` 的调用点。口径来源:`docs/superpowers/specs/2026-07-07-执行编辑删除权限口径-design.md`
- **同一接口不要两条通道叠加**。要么全域,要么对象域;叠加只会让对象域用户被全域那条卡死。 - **同一接口不要两条通道叠加**。要么全域,要么对象域;叠加只会让对象域用户被全域那条卡死。
- 对象内**读路径**(列表 / 详情 / 状态看板 / 聚合)同样要挂 `@CheckObjectPermission(objectType=PROJECT, permission=...PERMISSION_QUERY)`——查询同样扫库耗资源,必须按对象域鉴权。**Controller 方法层一律不挂权限注解**,新增读接口照此在 Service 层挂对象域权限;不要只在 Controller 留空,更不要误判"Controller 没注解 = 无鉴权"。 - 对象内**读路径**(列表 / 详情 / 状态看板 / 聚合)同样要挂 `@CheckObjectPermission(objectType=PROJECT, permission=...PERMISSION_QUERY)`——查询同样扫库耗资源,必须按对象域鉴权。**Controller 方法层一律不挂权限注解**,新增读接口照此在 Service 层挂对象域权限;不要只在 Controller 留空,更不要误判"Controller 没注解 = 无鉴权"。

View File

@@ -0,0 +1,79 @@
# 执行编辑删除权限口径 - 设计 spec
- 日期2026-07-07
- 范围:`rdms-project` 项目对象域下执行编辑、执行删除、执行删除预检
- 状态:已与用户确认设计,待 review 后写实现 plan
## 1. 背景
任务侧已经采用“业务身份命中直接放行,未命中再回落对象域权限码”的兼容口径。执行侧当前仍主要依赖 `@CheckObjectPermission(permission = project:execution:update/delete)`,导致执行负责人如果没有对象域权限码,可能无法编辑或删除自己负责的执行。
用户确认执行侧应采用类似任务侧的裁决方式:项目负责人、执行负责人可直接操作;管理员仍走对象域权限码兜底;项目创建者不作为天然放行身份。
## 2. 权限口径
执行编辑、执行删除、执行删除预检的后端最终放行条件:
| 操作 | 业务身份直接放行 | 权限码兜底 |
|---|---|---|
| 编辑执行 | 项目负责人、执行负责人 | `project:execution:update` |
| 删除执行 | 项目负责人、执行负责人 | `project:execution:delete` |
| 删除预检 | 项目负责人、执行负责人 | `project:execution:delete` |
不放行的身份:
- 项目创建者:只作为审计来源,不代表当前管理责任。
- 执行协办人:参与执行,不代表可管理执行主数据。
- 普通项目成员:除非其对象域角色拥有对应权限码。
这里的“管理员”不是全局超级管理员短路,而是项目对象域内拥有对应权限码的管理员型角色。当前对象域写权限不引入超管无条件绕过。
## 3. 后端设计
执行侧新增一个私有授权方法,语义与任务侧 `requireTaskEditDeletePermission` 对齐,但参与身份更少:
1. 加载项目与执行。
2. 组装候选负责人:`project.managerUserId``execution.ownerId`
3. 调用 `ProjectObjectAuthorizationService.checkAnyOwnerOrProjectPermission(projectId, ownerUserIds, permission)`
4. 命中任一负责人直接返回;否则由 `ProjectObjectPermissionService.checkPermission` 校验对象域权限码。
为了让“执行负责人但无权限码”的用户能进入方法体,执行编辑、执行删除、执行删除预检方法上的 `@CheckObjectPermission(permission = ...)` 不能继续作为前置拦截;这些入口改由方法内部统一裁决。
保持不变:
- 创建执行仍走 `project:execution:create`
- 换执行负责人仍走项目层管理权限,不给执行负责人天然放权。
- 执行状态动作 `complete/cancel/pause/resume` 仍保持执行负责人独占,不纳入本次编辑/删除口径。
- 删除的业务规则不变:已完成执行禁删,非 completed 状态允许删除;删除预检仍不能被无权限用户用于探测下挂任务数量。
## 4. 前端联调口径
前端按钮展示建议:
- 编辑执行:`currentProject.managerUserId === currentUserId || execution.ownerId === currentUserId || hasPermission('project:execution:update')`
- 删除执行 / 删除预检:`currentProject.managerUserId === currentUserId || execution.ownerId === currentUserId || hasPermission('project:execution:delete')`
后端仍是最终裁决。前端不需要识别项目创建者,也不需要把执行协办人作为执行编辑/删除按钮依据。
## 5. 测试与验证
优先补单测覆盖:
- 项目负责人可编辑执行,无需 `project:execution:update`
- 执行负责人可编辑执行,无需 `project:execution:update`
- 普通成员无 `project:execution:update` 时编辑被拒。
- 拥有 `project:execution:update` 的对象域角色可编辑执行。
- 执行负责人可删除预检和删除执行,无需 `project:execution:delete`
- 普通成员无 `project:execution:delete` 时删除预检和删除均被拒。
静态检查:
- 执行编辑、删除、删除预检不再由方法注解提前拦截权限码。
- 权限裁决落在 service 层,仍属于项目对象域通道。
- 不新增菜单、角色、权限码或数据库结构。
运行验证需用户同意后执行 Maven 测试命令。
## 6. 数据与 SQL
本次是纯代码权限裁决调整,不涉及表结构、字典、菜单、角色或权限码种子变更。无需演示库 SQL 补丁。

View File

@@ -261,6 +261,15 @@ public interface ErrorCodeConstants {
ErrorCode WORK_REPORT_PERIOD_DUPLICATE = new ErrorCode(1_008_010_013, "当前周期的工作报告已存在,请勿重复创建"); ErrorCode WORK_REPORT_PERIOD_DUPLICATE = new ErrorCode(1_008_010_013, "当前周期的工作报告已存在,请勿重复创建");
ErrorCode WORK_REPORT_APPROVAL_RECORD_NOT_EXISTS = new ErrorCode(1_008_010_014, "工作报告审核记录不存在"); ErrorCode WORK_REPORT_APPROVAL_RECORD_NOT_EXISTS = new ErrorCode(1_008_010_014, "工作报告审核记录不存在");
ErrorCode WORK_REPORT_PROJECT_OWNER_ONLY = new ErrorCode(1_008_010_015, "仅项目负责人可创建或维护该项目的项目半月报"); ErrorCode WORK_REPORT_PROJECT_OWNER_ONLY = new ErrorCode(1_008_010_015, "仅项目负责人可创建或维护该项目的项目半月报");
ErrorCode WORK_REPORT_SIGN_OFF_NOT_ALLOWED = new ErrorCode(1_008_010_016, "当前月报未处于待员工确认状态");
ErrorCode WORK_REPORT_SIGN_OFF_BATCH_LOCKED = new ErrorCode(1_008_010_017, "该月份已下签,禁止重复操作");
ErrorCode WORK_REPORT_MONTHLY_SIGN_OFF_NOT_EXISTS = new ErrorCode(1_008_010_018, "月报下签实例不存在");
ErrorCode WORK_REPORT_MONTHLY_SIGN_OFF_CONFIRM_ONLY_REPORTER = new ErrorCode(1_008_010_019, "仅月报填报人可确认下签");
ErrorCode WORK_REPORT_MONTHLY_SIGN_OFF_APPROVAL_RECORD_NOT_EXISTS = new ErrorCode(1_008_010_020, "月报缺少审批通过记录,无法完成下签确认");
ErrorCode WORK_REPORT_MONTHLY_SIGN_OFF_CANDIDATE_EMPTY = new ErrorCode(1_008_010_021, "没有可下签的月报数据");
ErrorCode SIGN_OFF_STATUS_ACTION_NOT_ALLOWED = new ErrorCode(1_008_010_022, "当前下签状态为「{}」,不支持「{}」操作");
ErrorCode SIGN_OFF_STATUS_ACTION_REASON_REQUIRED = new ErrorCode(1_008_010_023, "「{}」操作必须填写原因");
ErrorCode SIGN_OFF_STATUS_MODEL_NOT_EXISTS_OR_DISABLED = new ErrorCode(1_008_010_024, "下签状态定义不存在或已停用");
// ========== 绩效管理 1_008_011_xxx ========== // ========== 绩效管理 1_008_011_xxx ==========
ErrorCode PERFORMANCE_TEMPLATE_NOT_EXISTS = new ErrorCode(1_008_011_001, "绩效模板不存在"); ErrorCode PERFORMANCE_TEMPLATE_NOT_EXISTS = new ErrorCode(1_008_011_001, "绩效模板不存在");
@@ -282,4 +291,12 @@ public interface ErrorCodeConstants {
ErrorCode PERFORMANCE_SHEET_STATUS_MODEL_NOT_EXISTS_OR_DISABLED = new ErrorCode(1_008_011_017, "绩效表状态定义不存在或已停用"); ErrorCode PERFORMANCE_SHEET_STATUS_MODEL_NOT_EXISTS_OR_DISABLED = new ErrorCode(1_008_011_017, "绩效表状态定义不存在或已停用");
ErrorCode PERFORMANCE_SHEET_STATUS_ACTION_REASON_REQUIRED = new ErrorCode(1_008_011_018, "「{}」操作必须填写原因"); ErrorCode PERFORMANCE_SHEET_STATUS_ACTION_REASON_REQUIRED = new ErrorCode(1_008_011_018, "「{}」操作必须填写原因");
ErrorCode PERFORMANCE_EMPLOYEE_DEPT_INVALID = new ErrorCode(1_008_011_019, "被考核员工部门信息不完整"); ErrorCode PERFORMANCE_EMPLOYEE_DEPT_INVALID = new ErrorCode(1_008_011_019, "被考核员工部门信息不完整");
ErrorCode PERFORMANCE_SHEET_SIGN_OFF_NOT_ALLOWED = new ErrorCode(1_008_011_020, "当前绩效表未处于待员工确认状态");
ErrorCode PERFORMANCE_SHEET_SIGN_OFF_BATCH_LOCKED = new ErrorCode(1_008_011_021, "该月份已下签,禁止重复操作");
ErrorCode PERFORMANCE_SHEET_SIGN_OFF_NOT_EXISTS = new ErrorCode(1_008_011_022, "绩效下签实例不存在");
ErrorCode PERFORMANCE_SHEET_IMPORT_ZIP_EMPTY = new ErrorCode(1_008_011_023, "导入压缩包不能为空");
ErrorCode PERFORMANCE_SHEET_IMPORT_TARGET_EMPTY = new ErrorCode(1_008_011_024, "没有匹配到可导入的绩效表");
ErrorCode PERFORMANCE_SHEET_SIGN_OFF_CANDIDATE_EMPTY = new ErrorCode(1_008_011_025, "没有可下签的绩效表数据");
ErrorCode PERFORMANCE_SHEET_MONTHLY_REPORT_NOT_EXISTS = new ErrorCode(1_008_011_026, "未找到对应月报,无法同步绩效结果");
ErrorCode PERFORMANCE_SHEET_MONTHLY_APPROVAL_RECORD_NOT_EXISTS = new ErrorCode(1_008_011_027, "未找到对应月报审批记录,无法同步绩效结果");
} }

View File

@@ -36,4 +36,6 @@ public final class PerformanceConstants {
public static final String PERMISSION_SHEET_REJECT = "project:performance-sheet:reject"; public static final String PERMISSION_SHEET_REJECT = "project:performance-sheet:reject";
public static final String PERMISSION_SHEET_EXPORT = "project:performance-sheet:export"; public static final String PERMISSION_SHEET_EXPORT = "project:performance-sheet:export";
public static final String PERMISSION_TEAM_DASHBOARD = "project:performance-sheet:team-dashboard"; public static final String PERMISSION_TEAM_DASHBOARD = "project:performance-sheet:team-dashboard";
public static final String PERMISSION_SHEET_IMPORT = "project:performance-sheet:import";
public static final String PERMISSION_SHEET_SIGN_OFF = "project:performance-sheet:sign-off";
} }

View File

@@ -0,0 +1,35 @@
package com.njcn.rdms.module.project.constant;
import java.util.List;
/**
* 绩效 Excel 单元格常量。
*/
public final class PerformanceExcelCellConstants {
private PerformanceExcelCellConstants() {
}
public static final String ACTUAL_SCORE_TOTAL_CELL = "J10";
public static final String BASE_SCORE_TOTAL_CELL = "K10";
public static final String EXTRA_SCORE_TOTAL_CELL = "L10";
public static final List<String> RESULT_DESCRIPTION_CELLS = List.of("I6", "I7", "I8", "I9");
public static final List<String> ACTUAL_SCORE_CELLS = List.of("J6", "J7", "J8", "J9", "J10");
public static final List<String> BASE_SCORE_CELLS = List.of("K6", "K7", "K8", "K9", "K10");
public static final List<String> EXTRA_SCORE_CELLS = List.of("L6", "L7", "L8", "L9", "L10");
public static final List<String> PREFILL_CELL_ADDRESSES = List.of(
"I6", "I7", "I8", "I9",
"J6", "J7", "J8", "J9", "J10",
"K6", "K7", "K8", "K9", "K10",
"L6", "L7", "L8", "L9", "L10"
);
public static final List<String> MERGED_EXPORT_CELL_ADDRESSES = List.of(
"I6", "I7", "I8", "I9",
"J6", "J7", "J8", "J9", "J10",
"K6", "K7", "K8", "K9", "K10",
"L6", "L7", "L8", "L9", "L10"
);
}

View File

@@ -61,8 +61,8 @@ public final class ProjectTaskConstants {
/** /**
* 删除任务权限码。 * 删除任务权限码。
* 推荐挂"项目负责人"角色(参见 docs/项目/2026-05-11-执行按钮可见度对齐设计.md §6.4)。 * 推荐挂"项目负责人"角色(参见 docs/项目/2026-05-11-执行按钮可见度对齐设计.md §6.4)。
* 实际拦截:service 内按 task.parentTaskId 分流走 checkOwnerOrProjectPermission * 实际拦截:项目负责人 / 执行负责人 / 任务负责人 / 父任务负责人直接放行;
* (一级任务 → execution.ownerId 字段身份;子任务 → parentTask.ownerId 字段身份;不命中字段时回落本权限码) * 不命中业务身份时回落本权限码,作为管理员兜底
*/ */
public static final String PERMISSION_DELETE = "project:task:delete"; public static final String PERMISSION_DELETE = "project:task:delete";

View File

@@ -0,0 +1,21 @@
package com.njcn.rdms.module.project.constant;
/**
* 员工下签常量。
*/
public final class SignOffConstants {
private SignOffConstants() {
}
public static final String STATUS_OBJECT_TYPE = "employee_sign_off";
public static final String STATUS_NOT_STARTED = "not_started";
public static final String STATUS_PENDING_EMPLOYEE_CONFIRM = "pending_employee_confirm";
public static final String STATUS_EMPLOYEE_CONFIRMED = "employee_confirmed";
public static final String STATUS_EMPLOYEE_REJECTED = "employee_rejected";
public static final String ACTION_ISSUE = "issue";
public static final String ACTION_CONFIRM = "confirm";
public static final String ACTION_REJECT = "reject";
}

View File

@@ -31,6 +31,18 @@ public final class WorkReportConstants {
public static final String ACTION_APPROVE = "approve"; public static final String ACTION_APPROVE = "approve";
public static final String ACTION_REJECT = "reject"; public static final String ACTION_REJECT = "reject";
/**
* 未提交提醒附加进消息模板参数的报告类型键。
* 仅供“未读不重发”判定使用,不参与模板正文渲染。
*/
public static final String PARAM_REMIND_REPORT_TYPE = "reportType";
/**
* 未提交提醒附加进消息模板参数的报告标识键。
* 统一按字符串存,避免 JSON 反序列化后的数字类型差异。
*/
public static final String PARAM_REMIND_REPORT_ID = "reportId";
public static final String PERMISSION_QUERY = "project:work-report:query"; public static final String PERMISSION_QUERY = "project:work-report:query";
public static final String PERMISSION_CREATE = "project:work-report:create"; public static final String PERMISSION_CREATE = "project:work-report:create";
public static final String PERMISSION_UPDATE = "project:work-report:update"; public static final String PERMISSION_UPDATE = "project:work-report:update";
@@ -39,4 +51,6 @@ public final class WorkReportConstants {
public static final String PERMISSION_EXPORT = "project:work-report:export"; public static final String PERMISSION_EXPORT = "project:work-report:export";
public static final String PERMISSION_PROJECT_OWNER = "project:work-report:project-owner"; public static final String PERMISSION_PROJECT_OWNER = "project:work-report:project-owner";
public static final String PERMISSION_TEAM_DASHBOARD = "project:work-report:team-dashboard"; public static final String PERMISSION_TEAM_DASHBOARD = "project:work-report:team-dashboard";
public static final String PERMISSION_SIGN_OFF_QUERY = "project:work-report:sign-off-query";
public static final String PERMISSION_SIGN_OFF_CONFIRM = "project:work-report:sign-off-confirm";
} }

View File

@@ -8,9 +8,13 @@ import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceM
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetBatchDownloadReqVO; import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetBatchDownloadReqVO;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetCreateReqVO; import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetCreateReqVO;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetExcelUpdateReqVO; import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetExcelUpdateReqVO;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetImportZipReqVO;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetImportZipRespVO;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetPageReqVO; import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetPageReqVO;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetPrefillRespVO;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetRespVO; import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetRespVO;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetResponseRecordRespVO; import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetResponseRecordRespVO;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetSignOffReqVO;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetStatusActionReqVO; import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetStatusActionReqVO;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetStatusDictRespVO; import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetStatusDictRespVO;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetStatusLogRespVO; import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetStatusLogRespVO;
@@ -123,6 +127,21 @@ public class PerformanceSheetController {
return success(true); return success(true);
} }
@PostMapping("/import-zip")
@Operation(summary = "导入绩效压缩包")
@PreAuthorize("@ss.hasPermission('" + PerformanceConstants.PERMISSION_SHEET_IMPORT + "')")
public CommonResult<PerformanceSheetImportZipRespVO> importZip(@Valid PerformanceSheetImportZipReqVO reqVO)
throws Exception {
return success(performanceSheetService.importZip(reqVO));
}
@PostMapping("/sign-off")
@Operation(summary = "一键下签")
@PreAuthorize("@ss.hasPermission('" + PerformanceConstants.PERMISSION_SHEET_SIGN_OFF + "')")
public CommonResult<Long> signOff(@Valid @RequestBody PerformanceSheetSignOffReqVO reqVO) {
return success(performanceSheetService.signOff(reqVO));
}
@GetMapping("/{id}/download") @GetMapping("/{id}/download")
@Operation(summary = "下载单条绩效表") @Operation(summary = "下载单条绩效表")
@PreAuthorize("@ss.hasPermission('" + PerformanceConstants.PERMISSION_SHEET_EXPORT + "')") @PreAuthorize("@ss.hasPermission('" + PerformanceConstants.PERMISSION_SHEET_EXPORT + "')")
@@ -162,6 +181,14 @@ public class PerformanceSheetController {
return success(performanceSheetService.getResponseRecords(id)); return success(performanceSheetService.getResponseRecords(id));
} }
@GetMapping("/prefill")
@Operation(summary = "获取绩效表上一份预填充数据")
@PreAuthorize("@ss.hasPermission('" + PerformanceConstants.PERMISSION_SHEET_CREATE + "')")
public CommonResult<PerformanceSheetPrefillRespVO> prefill(@RequestParam("employeeId") Long employeeId,
@RequestParam("periodMonth") String periodMonth) {
return success(performanceSheetService.getPrefillData(employeeId, periodMonth));
}
@GetMapping("/monthly-result") @GetMapping("/monthly-result")
@Operation(summary = "获取月报审批绩效结果") @Operation(summary = "获取月报审批绩效结果")
@PreAuthorize("@ss.hasPermission('" + PerformanceConstants.PERMISSION_SHEET_QUERY + "')") @PreAuthorize("@ss.hasPermission('" + PerformanceConstants.PERMISSION_SHEET_QUERY + "')")

View File

@@ -3,6 +3,8 @@ package com.njcn.rdms.module.project.controller.admin.performance.vo;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import java.util.List;
@Schema(description = "管理后台 - 绩效分数单元格映射 Response VO") @Schema(description = "管理后台 - 绩效分数单元格映射 Response VO")
@Data @Data
public class PerformanceScoreCellMappingRespVO { public class PerformanceScoreCellMappingRespVO {
@@ -15,4 +17,16 @@ public class PerformanceScoreCellMappingRespVO {
@Schema(description = "附加得分总计单元格", example = "L10") @Schema(description = "附加得分总计单元格", example = "L10")
private String extraScoreTotalCell; private String extraScoreTotalCell;
@Schema(description = "结果描述预填充单元格", example = "[\"I6\",\"I7\",\"I8\",\"I9\"]")
private List<String> resultDescriptionCells;
@Schema(description = "实际得分预填充单元格", example = "[\"J6\",\"J7\",\"J8\",\"J9\",\"J10\"]")
private List<String> actualScoreCells;
@Schema(description = "基础得分预填充单元格", example = "[\"K6\",\"K7\",\"K8\",\"K9\",\"K10\"]")
private List<String> baseScoreCells;
@Schema(description = "附加得分预填充单元格", example = "[\"L6\",\"L7\",\"L8\",\"L9\",\"L10\"]")
private List<String> extraScoreCells;
} }

View File

@@ -0,0 +1,25 @@
package com.njcn.rdms.module.project.controller.admin.performance.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
@Schema(description = "管理后台 - 绩效压缩包导入 Request VO")
@Data
public class PerformanceSheetImportZipReqVO {
@Schema(description = "zip 文件", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "导入文件不能为空")
private MultipartFile file;
@Schema(description = "绩效月份,格式 yyyy-MM", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-06")
@NotBlank(message = "绩效月份不能为空")
private String periodMonth;
@Schema(description = "限定员工 ID 列表")
private List<Long> employeeIds;
}

View File

@@ -0,0 +1,23 @@
package com.njcn.rdms.module.project.controller.admin.performance.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
@Schema(description = "管理后台 - 绩效压缩包导入结果 Response VO")
@Data
public class PerformanceSheetImportZipRespVO {
private Integer workbookCount;
private Integer sheetCount;
private Integer successCount;
private Integer skippedCount;
private Integer failedCount;
private List<String> messages;
}

View File

@@ -38,4 +38,7 @@ public class PerformanceSheetPageReqVO extends PageParam {
@Schema(description = "状态编码", example = "sent") @Schema(description = "状态编码", example = "sent")
private String statusCode; private String statusCode;
@Schema(description = "下签状态编码", example = "pending_employee_confirm")
private String signOffStatus;
} }

View File

@@ -0,0 +1,21 @@
package com.njcn.rdms.module.project.controller.admin.performance.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.LinkedHashMap;
import java.util.Map;
@Schema(description = "管理后台 - 绩效表上一份预填充 Response VO")
@Data
public class PerformanceSheetPrefillRespVO {
@Schema(description = "来源绩效表 ID", example = "2042074259501088770")
private Long sourceSheetId;
@Schema(description = "来源绩效月份", example = "2026-06")
private String sourcePeriodMonth;
@Schema(description = "按单元格地址返回的预填充值,例如 {\"I6\":\"结果描述\",\"J10\":\"13.00\"}")
private Map<String, String> cellValues = new LinkedHashMap<>();
}

View File

@@ -25,6 +25,8 @@ public class PerformanceSheetRespVO {
private Integer fileVersion; private Integer fileVersion;
private String statusCode; private String statusCode;
private String statusName; private String statusName;
private String signOffStatus;
private String signOffStatusName;
private BigDecimal actualScoreTotal; private BigDecimal actualScoreTotal;
private BigDecimal baseScoreTotal; private BigDecimal baseScoreTotal;
private BigDecimal extraScoreTotal; private BigDecimal extraScoreTotal;

View File

@@ -0,0 +1,24 @@
package com.njcn.rdms.module.project.controller.admin.performance.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
@Schema(description = "管理后台 - 绩效一键下签 Request VO")
@Data
public class PerformanceSheetSignOffReqVO {
@Schema(description = "绩效月份,格式 yyyy-MM", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-06")
@NotBlank(message = "绩效月份不能为空")
private String periodMonth;
@Schema(description = "员工 ID 列表;为空则默认全部下属")
private List<Long> employeeIds;
@Schema(description = "备注")
@Size(max = 500, message = "备注长度不能超过 500 个字符")
private String remark;
}

View File

@@ -76,6 +76,13 @@ public class ProjectRequirementController {
return success(requirementService.getRequirementTree(pageReqVO)); return success(requirementService.getRequirementTree(pageReqVO));
} }
@GetMapping("/execution-options")
@Operation(summary = "获取执行可关联的项目需求下拉选项")
public CommonResult<PageResult<ProjectRequirementRespVO>> getExecutionRequirementOptions(
@Valid ProjectRequirementPageReqVO pageReqVO) {
return success(requirementService.getExecutionRequirementOptions(pageReqVO));
}
@PostMapping("/change-status") @PostMapping("/change-status")
@Operation(summary = "变更需求状态") @Operation(summary = "变更需求状态")
public CommonResult<Boolean> changeRequirementStatus(@Valid @RequestBody ProjectRequirementStatusActionReqVO reqVO) { public CommonResult<Boolean> changeRequirementStatus(@Valid @RequestBody ProjectRequirementStatusActionReqVO reqVO) {

View File

@@ -39,7 +39,7 @@ public class MyWorkbenchController {
} }
@GetMapping("/worklog-week") @GetMapping("/worklog-week")
@Operation(summary = "我的工时周聚合(逐日为均摊推算值;weekStart 任意日期自动归一到周一)") @Operation(summary = "我的工时周聚合(返回周一~周日;按周填报只均摊到工作日,周末单天工时保留在周末)")
public CommonResult<MyWorklogWeekRespVO> getMyWorklogWeek( public CommonResult<MyWorklogWeekRespVO> getMyWorklogWeek(
@RequestParam("weekStart") @RequestParam("weekStart")
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) @NotNull(message = "weekStart 不能为空") LocalDate weekStart) { @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) @NotNull(message = "weekStart 不能为空") LocalDate weekStart) {

View File

@@ -15,7 +15,7 @@ public class MyWorklogWeekRespVO {
@Schema(description = "所选周周一", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-06-08") @Schema(description = "所选周周一", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-06-08")
private LocalDate weekStart; private LocalDate weekStart;
@Schema(description = "周一~周逐日工时(固定 5 元素,均摊推算,保留 2 位小数)", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "周一~周逐日工时(固定 7 元素,按填报日期段推算,保留 2 位小数)", requiredMode = Schema.RequiredMode.REQUIRED)
private List<BigDecimal> dailyHours; private List<BigDecimal> dailyHours;
@Schema(description = "本周工时按归属分布(hours 降序,personal/other 排在项目后)", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "本周工时按归属分布(hours 降序,personal/other 排在项目后)", requiredMode = Schema.RequiredMode.REQUIRED)
private List<DistributionItemVO> distribution; private List<DistributionItemVO> distribution;

View File

@@ -5,28 +5,37 @@ import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List; import java.util.List;
@Schema(description = "管理后台 - 工作台「团队工时周聚合」Response VO") @Schema(description = "Admin - workbench team worklog week response")
@Data @Data
public class TeamWorklogWeekRespVO { public class TeamWorklogWeekRespVO {
@Schema(description = "所选周周一", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-06-08") @Schema(description = "Week monday", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-06-08")
private LocalDate weekStart; private LocalDate weekStart;
@Schema(description = "团队成员工时列表;members[0] 恒为当前用户;该周无填报的成员 items 为空数组", requiredMode = Schema.RequiredMode.REQUIRED)
@Schema(description = "Team members", requiredMode = Schema.RequiredMode.REQUIRED)
private List<MemberVO> members; private List<MemberVO> members;
@Schema(description = "单个成员的周工时") @Schema(description = "Single member week worklog")
@Data @Data
public static class MemberVO { public static class MemberVO {
@Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001")
@Schema(description = "User id", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001")
@JsonSerialize(using = ToStringSerializer.class) @JsonSerialize(using = ToStringSerializer.class)
private Long userId; private Long userId;
@Schema(description = "用户昵称", example = "张三")
@Schema(description = "User nickname", example = "zhangsan")
private String userNickname; private String userNickname;
@Schema(description = "本周工时按归属分布", requiredMode = Schema.RequiredMode.REQUIRED)
@Schema(description = "Weekly worklog distribution", requiredMode = Schema.RequiredMode.REQUIRED)
private List<MyWorklogWeekRespVO.DistributionItemVO> items; private List<MyWorklogWeekRespVO.DistributionItemVO> items;
@Schema(description = "Approved overtime hours in selected week",
requiredMode = Schema.RequiredMode.REQUIRED, example = "9.00")
private BigDecimal overtimeHours;
} }
} }

View File

@@ -105,7 +105,7 @@ public class ProjectTaskController {
} }
@DeleteMapping("/{taskId}") @DeleteMapping("/{taskId}")
@Operation(summary = "删除任务(已完成态禁止,三重确认 + 上级 owner 硬卡") @Operation(summary = "删除任务(已完成态禁止,三重确认 + 多身份授权")
public CommonResult<Boolean> deleteTask(@PathVariable("projectId") Long projectId, public CommonResult<Boolean> deleteTask(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId, @PathVariable("executionId") Long executionId,
@PathVariable("taskId") Long taskId, @PathVariable("taskId") Long taskId,

View File

@@ -17,6 +17,8 @@ import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.Month
import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.MonthlyReportRefreshDraftReqVO; import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.MonthlyReportRefreshDraftReqVO;
import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.MonthlyReportRespVO; import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.MonthlyReportRespVO;
import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.MonthlyReportSaveReqVO; import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.MonthlyReportSaveReqVO;
import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.MonthlyReportSignOffConfirmReqVO;
import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.MonthlyReportSignOffDetailRespVO;
import com.njcn.rdms.module.project.service.workreport.export.WorkReportContentExportService; import com.njcn.rdms.module.project.service.workreport.export.WorkReportContentExportService;
import com.njcn.rdms.module.project.service.workreport.monthly.MonthlyReportService; import com.njcn.rdms.module.project.service.workreport.monthly.MonthlyReportService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -111,6 +113,20 @@ public class MonthlyReportController {
return success(monthlyReportService.getMonthlyApprovalPage(reqVO)); return success(monthlyReportService.getMonthlyApprovalPage(reqVO));
} }
@GetMapping("/sign-off-page")
@Operation(summary = "获取待我确认的月报分页")
@PreAuthorize("@ss.hasPermission('" + WorkReportConstants.PERMISSION_SIGN_OFF_QUERY + "')")
public CommonResult<PageResult<MonthlyReportRespVO>> getMonthlySignOffPage(@Valid MonthlyReportPageReqVO reqVO) {
return success(monthlyReportService.getMonthlySignOffPage(reqVO));
}
@GetMapping("/{id}/sign-off-detail")
@Operation(summary = "获取月报下签详情")
@PreAuthorize("@ss.hasPermission('" + WorkReportConstants.PERMISSION_SIGN_OFF_QUERY + "')")
public CommonResult<MonthlyReportSignOffDetailRespVO> getMonthlySignOffDetail(@PathVariable("id") Long id) {
return success(monthlyReportService.getMonthlySignOffDetail(id));
}
@PostMapping("/{id}/submit") @PostMapping("/{id}/submit")
@Operation(summary = "提交月报") @Operation(summary = "提交月报")
@PreAuthorize("@ss.hasPermission('" + WorkReportConstants.PERMISSION_UPDATE + "')") @PreAuthorize("@ss.hasPermission('" + WorkReportConstants.PERMISSION_UPDATE + "')")
@@ -137,6 +153,15 @@ public class MonthlyReportController {
return success(true); return success(true);
} }
@PostMapping("/{id}/confirm-sign-off")
@Operation(summary = "确认月报下签")
@PreAuthorize("@ss.hasPermission('" + WorkReportConstants.PERMISSION_SIGN_OFF_CONFIRM + "')")
public CommonResult<Boolean> confirmMonthlySignOff(@PathVariable("id") Long id,
@Valid @RequestBody MonthlyReportSignOffConfirmReqVO reqVO) {
monthlyReportService.confirmMonthlySignOff(id, reqVO);
return success(true);
}
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@Operation(summary = "删除月报") @Operation(summary = "删除月报")
@PreAuthorize("@ss.hasPermission('" + WorkReportConstants.PERMISSION_DELETE + "')") @PreAuthorize("@ss.hasPermission('" + WorkReportConstants.PERMISSION_DELETE + "')")

View File

@@ -36,10 +36,14 @@ public class MonthlyReportApprovalRecordRespVO {
private String employeeSignName; private String employeeSignName;
private Long employeeSignatureFileId;
private LocalDate employeeSignedDate; private LocalDate employeeSignedDate;
private String supervisorSignName; private String supervisorSignName;
private Long supervisorSignatureFileId;
private LocalDate supervisorSignedDate; private LocalDate supervisorSignedDate;
private Long auditorUserId; private Long auditorUserId;

View File

@@ -19,7 +19,6 @@ public class MonthlyReportApproveReqVO extends WorkReportStatusActionReqVO {
@Schema(description = "面谈时间") @Schema(description = "面谈时间")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY) @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY)
@NotNull
private LocalDate meetingDate; private LocalDate meetingDate;
@Schema(description = "优势描述") @Schema(description = "优势描述")

View File

@@ -14,4 +14,7 @@ public class MonthlyReportPageReqVO extends WorkReportBasePageReqVO {
@Schema(description = "团队视角下的填报人用户编号列表") @Schema(description = "团队视角下的填报人用户编号列表")
private List<Long> reporterIds; private List<Long> reporterIds;
@Schema(description = "下签状态编码", example = "pending_employee_confirm")
private String signOffStatus;
} }

View File

@@ -27,6 +27,8 @@ public class MonthlyReportRespVO {
private LocalDate periodEndDate; private LocalDate periodEndDate;
private String statusCode; private String statusCode;
private String statusName; private String statusName;
private String signOffStatus;
private String signOffStatusName;
private Boolean allowEdit; private Boolean allowEdit;
private Boolean terminal; private Boolean terminal;
private BigDecimal totalWorkHours; private BigDecimal totalWorkHours;

View File

@@ -0,0 +1,24 @@
package com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDate;
import static com.njcn.rdms.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY;
@Schema(description = "管理后台 - 月报下签确认 Request VO")
@Data
public class MonthlyReportSignOffConfirmReqVO {
@Schema(description = "面谈日期", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "面谈日期不能为空")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY)
private LocalDate meetingDate;
@Schema(description = "员工签字日期")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY)
private LocalDate employeeSignedDate;
}

View File

@@ -0,0 +1,13 @@
package com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - 月报下签详情 Response VO")
@Data
public class MonthlyReportSignOffDetailRespVO {
private MonthlyReportRespVO report;
private MonthlyReportApprovalRecordRespVO latestApprovalRecord;
}

View File

@@ -46,6 +46,8 @@ public class PerformanceSheetDO extends BaseDO {
private String statusCode; private String statusCode;
private String signOffStatus;
private BigDecimal actualScoreTotal; private BigDecimal actualScoreTotal;
private BigDecimal baseScoreTotal; private BigDecimal baseScoreTotal;

View File

@@ -0,0 +1,47 @@
package com.njcn.rdms.module.project.dal.dataobject.performance;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 绩效下签当前实例。
*/
@TableName("rdms_performance_sheet_sign_off")
@Data
@EqualsAndHashCode(callSuper = true)
public class PerformanceSheetSignOffDO extends BaseDO {
@TableId
private Long id;
private Long performanceSheetId;
private Long batchNo;
private String statusCode;
private Long issueUserId;
private String issueUserName;
private LocalDateTime issueTime;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long confirmUserId;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String confirmUserName;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private LocalDateTime confirmTime;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String lastRejectReason;
}

View File

@@ -0,0 +1,37 @@
package com.njcn.rdms.module.project.dal.dataobject.performance;
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_performance_sheet_sign_off_record")
@Data
@EqualsAndHashCode(callSuper = true)
public class PerformanceSheetSignOffRecordDO extends BaseDO {
@TableId
private Long id;
private Long signOffId;
private Long performanceSheetId;
private Long batchNo;
private String actionType;
private String fromSignOffStatus;
private String toSignOffStatus;
private String reason;
private Long operatorUserId;
private String operatorName;
}

View File

@@ -0,0 +1,33 @@
package com.njcn.rdms.module.project.dal.dataobject.signoff;
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_employee_sign_off_batch")
@Data
@EqualsAndHashCode(callSuper = true)
public class EmployeeSignOffBatchDO extends BaseDO {
@TableId
private Long id;
private String periodMonth;
private Long deptId;
private String deptName;
private Long operatorUserId;
private String operatorName;
private Integer lockedFlag;
private String remark;
}

View File

@@ -56,12 +56,18 @@ public class MonthlyReportApprovalRecordDO extends BaseDO {
@TableField(updateStrategy = FieldStrategy.ALWAYS) @TableField(updateStrategy = FieldStrategy.ALWAYS)
private String employeeSignName; private String employeeSignName;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long employeeSignatureFileId;
@TableField(updateStrategy = FieldStrategy.ALWAYS) @TableField(updateStrategy = FieldStrategy.ALWAYS)
private LocalDate employeeSignedDate; private LocalDate employeeSignedDate;
@TableField(updateStrategy = FieldStrategy.ALWAYS) @TableField(updateStrategy = FieldStrategy.ALWAYS)
private String supervisorSignName; private String supervisorSignName;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long supervisorSignatureFileId;
@TableField(updateStrategy = FieldStrategy.ALWAYS) @TableField(updateStrategy = FieldStrategy.ALWAYS)
private LocalDate supervisorSignedDate; private LocalDate supervisorSignedDate;

View File

@@ -47,6 +47,8 @@ public class MonthlyReportDO extends BaseDO {
private String statusCode; private String statusCode;
private String signOffStatus;
@TableField(updateStrategy = FieldStrategy.ALWAYS) @TableField(updateStrategy = FieldStrategy.ALWAYS)
private BigDecimal totalWorkHours; private BigDecimal totalWorkHours;

View File

@@ -0,0 +1,44 @@
package com.njcn.rdms.module.project.dal.dataobject.workreport.monthly;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 月报下签当前实例。
*/
@TableName("rdms_work_report_monthly_sign_off")
@Data
@EqualsAndHashCode(callSuper = true)
public class MonthlyReportSignOffDO extends BaseDO {
@TableId
private Long id;
private Long monthlyReportId;
private Long batchNo;
private String statusCode;
private Long issueUserId;
private String issueUserName;
private LocalDateTime issueTime;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long confirmUserId;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String confirmUserName;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private LocalDateTime confirmTime;
}

View File

@@ -0,0 +1,37 @@
package com.njcn.rdms.module.project.dal.dataobject.workreport.monthly;
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_work_report_monthly_sign_off_record")
@Data
@EqualsAndHashCode(callSuper = true)
public class MonthlyReportSignOffRecordDO extends BaseDO {
@TableId
private Long id;
private Long signOffId;
private Long monthlyReportId;
private Long approvalRecordId;
private Long batchNo;
private String actionType;
private String fromSignOffStatus;
private String toSignOffStatus;
private Long operatorUserId;
private String operatorName;
}

View File

@@ -11,6 +11,7 @@ import org.springframework.util.StringUtils;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.time.LocalDate;
@Mapper @Mapper
public interface OvertimeApplicationMapper extends BaseMapperX<OvertimeApplicationDO> { public interface OvertimeApplicationMapper extends BaseMapperX<OvertimeApplicationDO> {
@@ -53,6 +54,20 @@ public interface OvertimeApplicationMapper extends BaseMapperX<OvertimeApplicati
.eq(OvertimeApplicationDO::getApplicantId, applicantId)); .eq(OvertimeApplicationDO::getApplicantId, applicantId));
} }
default List<OvertimeApplicationDO> selectApprovedListByApplicantIdsAndDateRange(Collection<Long> applicantIds,
LocalDate startDate,
LocalDate endDate,
String approvedStatusCode) {
if (applicantIds == null || applicantIds.isEmpty() || startDate == null || endDate == null
|| !StringUtils.hasText(approvedStatusCode)) {
return List.of();
}
return selectList(new LambdaQueryWrapperX<OvertimeApplicationDO>()
.in(OvertimeApplicationDO::getApplicantId, applicantIds)
.eq(OvertimeApplicationDO::getStatusCode, approvedStatusCode)
.between(OvertimeApplicationDO::getOvertimeDate, startDate, endDate));
}
default int updateByIdAndStatus(OvertimeApplicationDO update, Long id, String fromStatus) { default int updateByIdAndStatus(OvertimeApplicationDO update, Long id, String fromStatus) {
return update(update, new LambdaQueryWrapperX<OvertimeApplicationDO>() return update(update, new LambdaQueryWrapperX<OvertimeApplicationDO>()
.eq(OvertimeApplicationDO::getId, id) .eq(OvertimeApplicationDO::getId, id)

View File

@@ -1,8 +1,8 @@
package com.njcn.rdms.module.project.dal.mysql.performance; package com.njcn.rdms.module.project.dal.mysql.performance;
import com.njcn.rdms.framework.common.pojo.PageResult; 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.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
import com.njcn.rdms.module.project.constant.PerformanceConstants; import com.njcn.rdms.module.project.constant.PerformanceConstants;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetPageReqVO; import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetPageReqVO;
import com.njcn.rdms.module.project.dal.dataobject.performance.PerformanceSheetDO; import com.njcn.rdms.module.project.dal.dataobject.performance.PerformanceSheetDO;
@@ -20,6 +20,16 @@ public interface PerformanceSheetMapper extends BaseMapperX<PerformanceSheetDO>
.eq(PerformanceSheetDO::getPeriodMonth, periodMonth)); .eq(PerformanceSheetDO::getPeriodMonth, periodMonth));
} }
default PerformanceSheetDO selectLatestHistoryWithFile(Long employeeId, String beforePeriodMonth) {
return selectOne(new LambdaQueryWrapperX<PerformanceSheetDO>()
.eq(PerformanceSheetDO::getEmployeeId, employeeId)
.lt(PerformanceSheetDO::getPeriodMonth, beforePeriodMonth)
.isNotNull(PerformanceSheetDO::getFileId)
.orderByDesc(PerformanceSheetDO::getPeriodMonth)
.orderByDesc(PerformanceSheetDO::getId)
.last("LIMIT 1"));
}
default PageResult<PerformanceSheetDO> selectEmployeePage(Long employeeId, PerformanceSheetPageReqVO reqVO) { default PageResult<PerformanceSheetDO> selectEmployeePage(Long employeeId, PerformanceSheetPageReqVO reqVO) {
LambdaQueryWrapperX<PerformanceSheetDO> wrapper = buildPageQuery(reqVO) LambdaQueryWrapperX<PerformanceSheetDO> wrapper = buildPageQuery(reqVO)
.eq(PerformanceSheetDO::getEmployeeId, employeeId) .eq(PerformanceSheetDO::getEmployeeId, employeeId)
@@ -92,6 +102,14 @@ public interface PerformanceSheetMapper extends BaseMapperX<PerformanceSheetDO>
.eq(PerformanceSheetDO::getStatusCode, fromStatus)); .eq(PerformanceSheetDO::getStatusCode, fromStatus));
} }
default int updateByIdAndStatusAndSignOffStatus(PerformanceSheetDO update, Long id, String fromStatus,
String fromSignOffStatus) {
return update(update, new LambdaQueryWrapperX<PerformanceSheetDO>()
.eq(PerformanceSheetDO::getId, id)
.eq(PerformanceSheetDO::getStatusCode, fromStatus)
.eq(PerformanceSheetDO::getSignOffStatus, fromSignOffStatus));
}
private LambdaQueryWrapperX<PerformanceSheetDO> buildPageQuery(PerformanceSheetPageReqVO reqVO) { private LambdaQueryWrapperX<PerformanceSheetDO> buildPageQuery(PerformanceSheetPageReqVO reqVO) {
return new LambdaQueryWrapperX<PerformanceSheetDO>() return new LambdaQueryWrapperX<PerformanceSheetDO>()
.geIfPresent(PerformanceSheetDO::getPeriodMonth, reqVO.getPeriodMonthStart()) .geIfPresent(PerformanceSheetDO::getPeriodMonth, reqVO.getPeriodMonthStart())
@@ -101,6 +119,7 @@ public interface PerformanceSheetMapper extends BaseMapperX<PerformanceSheetDO>
.likeIfPresent(PerformanceSheetDO::getEmployeeDeptName, reqVO.getEmployeeDeptName()) .likeIfPresent(PerformanceSheetDO::getEmployeeDeptName, reqVO.getEmployeeDeptName())
.eqIfPresent(PerformanceSheetDO::getManagerId, reqVO.getManagerId()) .eqIfPresent(PerformanceSheetDO::getManagerId, reqVO.getManagerId())
.likeIfPresent(PerformanceSheetDO::getManagerName, reqVO.getManagerName()) .likeIfPresent(PerformanceSheetDO::getManagerName, reqVO.getManagerName())
.eqIfPresent(PerformanceSheetDO::getStatusCode, reqVO.getStatusCode()); .eqIfPresent(PerformanceSheetDO::getStatusCode, reqVO.getStatusCode())
.eqIfPresent(PerformanceSheetDO::getSignOffStatus, reqVO.getSignOffStatus());
} }
} }

View File

@@ -0,0 +1,22 @@
package com.njcn.rdms.module.project.dal.mysql.performance;
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.performance.PerformanceSheetSignOffDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PerformanceSheetSignOffMapper extends BaseMapperX<PerformanceSheetSignOffDO> {
default PerformanceSheetSignOffDO selectByPerformanceSheetId(Long performanceSheetId) {
return selectOne(new LambdaQueryWrapperX<PerformanceSheetSignOffDO>()
.eq(PerformanceSheetSignOffDO::getPerformanceSheetId, performanceSheetId));
}
default int updateByPerformanceSheetIdAndStatus(PerformanceSheetSignOffDO update, Long performanceSheetId,
String fromStatus) {
return update(update, new LambdaQueryWrapperX<PerformanceSheetSignOffDO>()
.eq(PerformanceSheetSignOffDO::getPerformanceSheetId, performanceSheetId)
.eq(PerformanceSheetSignOffDO::getStatusCode, fromStatus));
}
}

View File

@@ -0,0 +1,9 @@
package com.njcn.rdms.module.project.dal.mysql.performance;
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
import com.njcn.rdms.module.project.dal.dataobject.performance.PerformanceSheetSignOffRecordDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PerformanceSheetSignOffRecordMapper extends BaseMapperX<PerformanceSheetSignOffRecordDO> {
}

View File

@@ -114,6 +114,20 @@ public interface ProjectMapper extends BaseMapperX<ProjectDO> {
.eq(ProjectDO::getId, id)); .eq(ProjectDO::getId, id));
} }
/**
* 仅更新实际开始/结束日期。null 字段依据全局 FieldStrategy 不会被覆盖。
*/
default int updateActualDatesById(Long id, LocalDate actualStartDate, LocalDate actualEndDate) {
if (actualStartDate == null && actualEndDate == null) {
return 0;
}
ProjectDO update = new ProjectDO();
update.setActualStartDate(actualStartDate);
update.setActualEndDate(actualEndDate);
return update(update, new LambdaQueryWrapperX<ProjectDO>()
.eq(ProjectDO::getId, id));
}
/** /**
* 临期/逾期告警候选:计划完成日非空且 <= maxPlannedEndDate状态不在排除集终态+paused * 临期/逾期告警候选:计划完成日非空且 <= maxPlannedEndDate状态不在排除集终态+paused
* excludeStatusCodes 为空时不排除任何状态(空集守卫,与既有口径一致)。 * excludeStatusCodes 为空时不排除任何状态(空集守卫,与既有口径一致)。

View File

@@ -22,20 +22,28 @@ public interface ProjectRequirementMapper extends BaseMapperX<ProjectRequirement
* 分页查询需求列表 * 分页查询需求列表
*/ */
default PageResult<ProjectRequirementDO> selectPage(ProjectRequirementPageReqVO reqVO) { default PageResult<ProjectRequirementDO> selectPage(ProjectRequirementPageReqVO reqVO) {
return selectPage(reqVO, buildQueryWrapper(reqVO)); return selectPage(reqVO, buildQueryWrapper(reqVO, null));
}
/**
* 分页查询需求列表,并排除指定状态集合。
*/
default PageResult<ProjectRequirementDO> selectPage(ProjectRequirementPageReqVO reqVO, Collection<String> excludeStatusCodes) {
return selectPage(reqVO, buildQueryWrapper(reqVO, excludeStatusCodes));
} }
/** /**
* 查询所有符合条件的需求列表,用于树查询 * 查询所有符合条件的需求列表,用于树查询
*/ */
default List<ProjectRequirementDO> selectList(ProjectRequirementPageReqVO reqVO) { default List<ProjectRequirementDO> selectList(ProjectRequirementPageReqVO reqVO) {
return selectList(buildQueryWrapper(reqVO)); return selectList(buildQueryWrapper(reqVO, null));
} }
/** /**
* 构建查询条件 * 构建查询条件
*/ */
private LambdaQueryWrapperX<ProjectRequirementDO> buildQueryWrapper(ProjectRequirementPageReqVO reqVO) { private LambdaQueryWrapperX<ProjectRequirementDO> buildQueryWrapper(ProjectRequirementPageReqVO reqVO,
Collection<String> excludeStatusCodes) {
LambdaQueryWrapperX<ProjectRequirementDO> queryWrapper = new LambdaQueryWrapperX<>(); LambdaQueryWrapperX<ProjectRequirementDO> queryWrapper = new LambdaQueryWrapperX<>();
if (StringUtils.hasText(reqVO.getTitle())) { if (StringUtils.hasText(reqVO.getTitle())) {
queryWrapper.like(ProjectRequirementDO::getTitle, reqVO.getTitle()); queryWrapper.like(ProjectRequirementDO::getTitle, reqVO.getTitle());
@@ -49,8 +57,11 @@ public interface ProjectRequirementMapper extends BaseMapperX<ProjectRequirement
.inIfPresent(ProjectRequirementDO::getModuleId, reqVO.getModuleIds()) .inIfPresent(ProjectRequirementDO::getModuleId, reqVO.getModuleIds())
.eqIfPresent(ProjectRequirementDO::getModuleId, reqVO.getModuleId()) .eqIfPresent(ProjectRequirementDO::getModuleId, reqVO.getModuleId())
.eqIfPresent(ProjectRequirementDO::getParentId, reqVO.getParentId()) .eqIfPresent(ProjectRequirementDO::getParentId, reqVO.getParentId())
.eq(ProjectRequirementDO::getProjectId, reqVO.getProjectId()) .eq(ProjectRequirementDO::getProjectId, reqVO.getProjectId());
.orderByAsc(ProjectRequirementDO::getSort) if (excludeStatusCodes != null && !excludeStatusCodes.isEmpty()) {
queryWrapper.notIn(ProjectRequirementDO::getStatusCode, excludeStatusCodes);
}
queryWrapper.orderByAsc(ProjectRequirementDO::getSort)
.orderByDesc(ProjectRequirementDO::getCreateTime); .orderByDesc(ProjectRequirementDO::getCreateTime);
return queryWrapper; return queryWrapper;
} }

View File

@@ -12,6 +12,19 @@ import java.util.List;
@Mapper @Mapper
public interface ProjectStatusLogMapper extends BaseMapperX<ProjectStatusLogDO> { public interface ProjectStatusLogMapper extends BaseMapperX<ProjectStatusLogDO> {
default ProjectStatusLogDO selectEarliestByProjectIdAndActionType(Long projectId, String actionType) {
if (projectId == null || actionType == null || actionType.isBlank()) {
return null;
}
List<ProjectStatusLogDO> list = selectList(new LambdaQueryWrapperX<ProjectStatusLogDO>()
.eq(ProjectStatusLogDO::getProjectId, projectId)
.eq(ProjectStatusLogDO::getActionType, actionType.trim())
.orderByAsc(BaseDO::getCreateTime)
.orderByAsc(ProjectStatusLogDO::getId)
.last("LIMIT 1"));
return list.isEmpty() ? null : list.get(0);
}
default List<ProjectStatusLogDO> selectListByProjectId(Long projectId, String actionType, LocalDateTime[] operateTime) { default List<ProjectStatusLogDO> selectListByProjectId(Long projectId, String actionType, LocalDateTime[] operateTime) {
return selectList(new LambdaQueryWrapperX<ProjectStatusLogDO>() return selectList(new LambdaQueryWrapperX<ProjectStatusLogDO>()
.eq(ProjectStatusLogDO::getProjectId, projectId) .eq(ProjectStatusLogDO::getProjectId, projectId)

View File

@@ -1,5 +1,6 @@
package com.njcn.rdms.module.project.dal.mysql.project.task; package com.njcn.rdms.module.project.dal.mysql.project.task;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.njcn.rdms.framework.common.pojo.PageResult; import com.njcn.rdms.framework.common.pojo.PageResult;
@@ -115,16 +116,15 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
} }
/** /**
* 仅更新实际开始/结束日期。null 字段依据全局 FieldStrategy 不会被覆盖 * 仅更新实际开始/结束日期。显式 set允许把字段清空为 null
*/ */
default int updateActualDatesById(Long id, LocalDate actualStartDate, LocalDate actualEndDate) { default int updateActualDatesById(Long id, LocalDate actualStartDate, LocalDate actualEndDate) {
if (actualStartDate == null && actualEndDate == null) { if (id == null) {
return 0; return 0;
} }
ProjectTaskDO update = new ProjectTaskDO(); return update(null, new LambdaUpdateWrapper<ProjectTaskDO>()
update.setActualStartDate(actualStartDate); .set(ProjectTaskDO::getActualStartDate, actualStartDate)
update.setActualEndDate(actualEndDate); .set(ProjectTaskDO::getActualEndDate, actualEndDate)
return update(update, new LambdaQueryWrapperX<ProjectTaskDO>()
.eq(ProjectTaskDO::getId, id)); .eq(ProjectTaskDO::getId, id));
} }

View File

@@ -1,6 +1,7 @@
package com.njcn.rdms.module.project.dal.mysql.project.task; package com.njcn.rdms.module.project.dal.mysql.project.task;
import com.njcn.rdms.framework.common.pojo.PageResult; 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.mapper.BaseMapperX;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX; 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.controller.admin.project.task.vo.worklog.TaskWorklogPageReqVO;
@@ -58,6 +59,19 @@ public interface TaskWorklogMapper extends BaseMapperX<TaskWorklogDO> {
""") """)
BigDecimal sumDurationByTaskId(@Param("taskId") Long taskId); BigDecimal sumDurationByTaskId(@Param("taskId") Long taskId);
/**
* 项目下所有任务工作日志的最早开始日期。逻辑删除的任务 / 工作日志不参与统计。
*/
@Select("""
SELECT MIN(w.start_date)
FROM rdms_task_worklog w
INNER JOIN rdms_task t ON t.id = w.task_id
WHERE w.deleted = b'0'
AND t.deleted = b'0'
AND t.project_id = #{projectId}
""")
LocalDate selectEarliestStartDateByProjectId(@Param("projectId") Long projectId);
/** /**
* 批量任务工时小时数汇总,返回 [{taskId, total}]。用于详情/分页装配避免 N+1。 * 批量任务工时小时数汇总,返回 [{taskId, total}]。用于详情/分页装配避免 N+1。
*/ */
@@ -83,6 +97,22 @@ public interface TaskWorklogMapper extends BaseMapperX<TaskWorklogDO> {
.eq(TaskWorklogDO::getTaskId, taskId)) > 0; .eq(TaskWorklogDO::getTaskId, taskId)) > 0;
} }
/**
* 任务下最早一条工作日志(按 start_date asc, create_time asc, id asc
*/
default TaskWorklogDO selectEarliestByTaskId(Long taskId) {
if (taskId == null) {
return null;
}
List<TaskWorklogDO> list = selectList(new LambdaQueryWrapperX<TaskWorklogDO>()
.eq(TaskWorklogDO::getTaskId, taskId)
.orderByAsc(TaskWorklogDO::getStartDate)
.orderByAsc(BaseDO::getCreateTime)
.orderByAsc(TaskWorklogDO::getId)
.last("LIMIT 1"));
return list.isEmpty() ? null : list.get(0);
}
/** /**
* 取指定用户在该任务下最新的一条工时(按 end_date desc, create_time desc, id desc * 取指定用户在该任务下最新的一条工时(按 end_date desc, create_time desc, id desc
* 用于 owner 填报后回查"本人最新一条"以同步任务进度。 * 用于 owner 填报后回查"本人最新一条"以同步任务进度。
@@ -102,6 +132,24 @@ public interface TaskWorklogMapper extends BaseMapperX<TaskWorklogDO> {
return list.isEmpty() ? null : list.get(0); return list.isEmpty() ? null : list.get(0);
} }
/**
* 取该任务下指定用户 progressRate=100 的最晚一条工作日志(按 end_date desc, create_time desc, id desc
*/
default TaskWorklogDO selectLatestCompletedByTaskIdAndUserId(Long taskId, Long userId) {
if (taskId == null || userId == null) {
return null;
}
List<TaskWorklogDO> list = selectList(new LambdaQueryWrapperX<TaskWorklogDO>()
.eq(TaskWorklogDO::getTaskId, taskId)
.eq(TaskWorklogDO::getUserId, userId)
.eq(TaskWorklogDO::getProgressRate, new BigDecimal("100"))
.orderByDesc(TaskWorklogDO::getEndDate)
.orderByDesc(BaseDO::getCreateTime)
.orderByDesc(TaskWorklogDO::getId)
.last("LIMIT 1"));
return list.isEmpty() ? null : list.get(0);
}
/** /**
* 取该 (taskId, userId) 下 endDate 严格早于给定值的"最近一条",按 end_date desc, id desc 取首行。 * 取该 (taskId, userId) 下 endDate 严格早于给定值的"最近一条",按 end_date desc, id desc 取首行。
* 用于进度单调性校验(与"前一段"比较。excludeId 不为 null 时排除自身(更新场景)。 * 用于进度单调性校验(与"前一段"比较。excludeId 不为 null 时排除自身(更新场景)。

View File

@@ -0,0 +1,16 @@
package com.njcn.rdms.module.project.dal.mysql.signoff;
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.signoff.EmployeeSignOffBatchDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface EmployeeSignOffBatchMapper extends BaseMapperX<EmployeeSignOffBatchDO> {
default EmployeeSignOffBatchDO selectByPeriodMonthAndDeptId(String periodMonth, Long deptId) {
return selectOne(new LambdaQueryWrapperX<EmployeeSignOffBatchDO>()
.eq(EmployeeSignOffBatchDO::getPeriodMonth, periodMonth)
.eq(EmployeeSignOffBatchDO::getDeptId, deptId));
}
}

View File

@@ -26,4 +26,13 @@ public interface MonthlyReportApprovalRecordMapper extends BaseMapperX<MonthlyRe
return delete(new LambdaQueryWrapperX<MonthlyReportApprovalRecordDO>() return delete(new LambdaQueryWrapperX<MonthlyReportApprovalRecordDO>()
.eq(MonthlyReportApprovalRecordDO::getMonthlyReportId, monthlyReportId)); .eq(MonthlyReportApprovalRecordDO::getMonthlyReportId, monthlyReportId));
} }
default MonthlyReportApprovalRecordDO selectLatestApprovedByMonthlyReportId(Long monthlyReportId) {
return selectOne(new LambdaQueryWrapperX<MonthlyReportApprovalRecordDO>()
.eq(MonthlyReportApprovalRecordDO::getMonthlyReportId, monthlyReportId)
.eq(MonthlyReportApprovalRecordDO::getConclusion, "approved")
.orderByDesc(MonthlyReportApprovalRecordDO::getApprovalRound)
.orderByDesc(MonthlyReportApprovalRecordDO::getId)
.last("LIMIT 1"));
}
} }

View File

@@ -147,6 +147,14 @@ public interface MonthlyReportMapper extends BaseMapperX<MonthlyReportDO> {
.eq(MonthlyReportDO::getStatusCode, fromStatus)); .eq(MonthlyReportDO::getStatusCode, fromStatus));
} }
default int updateByIdAndStatusAndSignOffStatus(MonthlyReportDO update, Long id, String fromStatus,
String fromSignOffStatus) {
return update(update, new LambdaQueryWrapperX<MonthlyReportDO>()
.eq(MonthlyReportDO::getId, id)
.eq(MonthlyReportDO::getStatusCode, fromStatus)
.eq(MonthlyReportDO::getSignOffStatus, fromSignOffStatus));
}
default int updateByIdAndStatusesAndReporterId(MonthlyReportDO update, Long id, Collection<String> statuses, default int updateByIdAndStatusesAndReporterId(MonthlyReportDO update, Long id, Collection<String> statuses,
Long reporterId) { Long reporterId) {
return update(update, new LambdaQueryWrapperX<MonthlyReportDO>() return update(update, new LambdaQueryWrapperX<MonthlyReportDO>()
@@ -177,6 +185,7 @@ public interface MonthlyReportMapper extends BaseMapperX<MonthlyReportDO> {
private LambdaQueryWrapperX<MonthlyReportDO> buildPageQuery(MonthlyReportPageReqVO reqVO) { private LambdaQueryWrapperX<MonthlyReportDO> buildPageQuery(MonthlyReportPageReqVO reqVO) {
LambdaQueryWrapperX<MonthlyReportDO> wrapper = new LambdaQueryWrapperX<MonthlyReportDO>() LambdaQueryWrapperX<MonthlyReportDO> wrapper = new LambdaQueryWrapperX<MonthlyReportDO>()
.eqIfPresent(MonthlyReportDO::getStatusCode, reqVO.getStatusCode()) .eqIfPresent(MonthlyReportDO::getStatusCode, reqVO.getStatusCode())
.eqIfPresent(MonthlyReportDO::getSignOffStatus, reqVO.getSignOffStatus())
.betweenIfPresent(MonthlyReportDO::getPeriodStartDate, reqVO.getPeriodStartDate()) .betweenIfPresent(MonthlyReportDO::getPeriodStartDate, reqVO.getPeriodStartDate())
.betweenIfPresent(MonthlyReportDO::getSubmitTime, reqVO.getSubmitTime()); .betweenIfPresent(MonthlyReportDO::getSubmitTime, reqVO.getSubmitTime());
if (StringUtils.hasText(reqVO.getKeyword())) { if (StringUtils.hasText(reqVO.getKeyword())) {

View File

@@ -0,0 +1,22 @@
package com.njcn.rdms.module.project.dal.mysql.workreport.monthly;
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.workreport.monthly.MonthlyReportSignOffDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface MonthlyReportSignOffMapper extends BaseMapperX<MonthlyReportSignOffDO> {
default MonthlyReportSignOffDO selectByMonthlyReportId(Long monthlyReportId) {
return selectOne(new LambdaQueryWrapperX<MonthlyReportSignOffDO>()
.eq(MonthlyReportSignOffDO::getMonthlyReportId, monthlyReportId));
}
default int updateByMonthlyReportIdAndStatus(MonthlyReportSignOffDO update, Long monthlyReportId,
String fromStatus) {
return update(update, new LambdaQueryWrapperX<MonthlyReportSignOffDO>()
.eq(MonthlyReportSignOffDO::getMonthlyReportId, monthlyReportId)
.eq(MonthlyReportSignOffDO::getStatusCode, fromStatus));
}
}

View File

@@ -0,0 +1,9 @@
package com.njcn.rdms.module.project.dal.mysql.workreport.monthly;
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
import com.njcn.rdms.module.project.dal.dataobject.workreport.monthly.MonthlyReportSignOffRecordDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface MonthlyReportSignOffRecordMapper extends BaseMapperX<MonthlyReportSignOffRecordDO> {
}

View File

@@ -0,0 +1,18 @@
package com.njcn.rdms.module.project.framework.notify;
/**
* 站内信跳转参数常量。
*/
public final class NotifyJumpConstants {
private NotifyJumpConstants() {
}
public static final String PARAM_JUMP_TYPE = "jumpType";
public static final String PARAM_PROJECT_ID = "projectId";
public static final String JUMP_TYPE_PROJECT = "project";
public static final String JUMP_TYPE_EXECUTION = "execution";
public static final String JUMP_TYPE_TASK = "task";
}

View File

@@ -11,6 +11,7 @@ import com.njcn.rdms.module.system.api.permission.PermissionApi;
import com.njcn.rdms.module.system.api.permission.UserVisibilityConfigApi; import com.njcn.rdms.module.system.api.permission.UserVisibilityConfigApi;
import com.njcn.rdms.module.system.api.user.AdminUserApi; import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.UserManagementRelationApi; import com.njcn.rdms.module.system.api.user.UserManagementRelationApi;
import com.njcn.rdms.module.system.api.user.UserSignatureApi;
import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -22,6 +23,6 @@ import org.springframework.context.annotation.Configuration;
{AdminUserApi.class, ObjectPermissionApi.class, DictDataApi.class, FileApi.class, {AdminUserApi.class, ObjectPermissionApi.class, DictDataApi.class, FileApi.class,
PermissionApi.class, OrgLeaderApi.class, UserVisibilityConfigApi.class, PermissionApi.class, OrgLeaderApi.class, UserVisibilityConfigApi.class,
DeptApi.class, PostApi.class, UserManagementRelationApi.class, DeptApi.class, PostApi.class, UserManagementRelationApi.class,
NotifyMessageSendApi.class}) NotifyMessageSendApi.class, UserSignatureApi.class})
public class RpcConfiguration { public class RpcConfiguration {
} }

View File

@@ -4,10 +4,17 @@ import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.Collection;
import java.util.Objects; import java.util.Objects;
/** /**
* 项目对象业务授权服务。 * 项目对象业务授权服务:属主(业务负责人)身份命中直接放行,未命中回落对象域权限码兜底
*
* <p>供任务 / 执行的创建、编辑、删除、删除预检等入口在<b>方法体内</b>调用,替代方法上的
* {@code @CheckObjectPermission} 前置拦截——注解拦截发生在进入方法体之前,无法先认负责人身份。
*
* <p>红线:调用本服务做裁决的 service 方法<b>不要再挂 {@code @CheckObjectPermission}</b>
* 叠加会把"无权限码的负责人"在注解层直接挡死。口径登记见 CLAUDE.md §鉴权。
*/ */
@Service @Service
public class ProjectObjectAuthorizationService { public class ProjectObjectAuthorizationService {
@@ -26,4 +33,15 @@ public class ProjectObjectAuthorizationService {
projectObjectPermissionService.checkPermission(projectId, permission, false); projectObjectPermissionService.checkPermission(projectId, permission, false);
} }
/**
* 当前登录人命中任一业务负责人时直接放行,否则回落到项目对象角色权限校验。
*/
public void checkAnyOwnerOrProjectPermission(Long projectId, Collection<Long> ownerUserIds, String permission) {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
if (ownerUserIds != null && ownerUserIds.stream().anyMatch(ownerUserId -> Objects.equals(loginUserId, ownerUserId))) {
return;
}
projectObjectPermissionService.checkPermission(projectId, permission, false);
}
} }

View File

@@ -1,26 +0,0 @@
package com.njcn.rdms.module.project.service.performance;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 绩效配置。
*/
@Component
@ConfigurationProperties(prefix = "rdms.performance")
@Data
public class PerformanceProperties {
private ScoreCellMapping scoreCellMapping = new ScoreCellMapping();
@Data
public static class ScoreCellMapping {
private String actualScoreTotal = "J10";
private String baseScoreTotal = "K10";
private String extraScoreTotal = "L10";
}
}

View File

@@ -4,9 +4,13 @@ import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceMonthlyResultRespVO; import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceMonthlyResultRespVO;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetCreateReqVO; import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetCreateReqVO;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetExcelUpdateReqVO; import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetExcelUpdateReqVO;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetImportZipReqVO;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetImportZipRespVO;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetPageReqVO; import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetPageReqVO;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetPrefillRespVO;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetRespVO; import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetRespVO;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetResponseRecordRespVO; import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetResponseRecordRespVO;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetSignOffReqVO;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetStatusActionReqVO; import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetStatusActionReqVO;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetStatusDictRespVO; import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetStatusDictRespVO;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetStatusLogRespVO; import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceSheetStatusLogRespVO;
@@ -35,6 +39,10 @@ public interface PerformanceSheetService {
void reject(Long id, PerformanceSheetStatusActionReqVO reqVO); void reject(Long id, PerformanceSheetStatusActionReqVO reqVO);
PerformanceSheetImportZipRespVO importZip(PerformanceSheetImportZipReqVO reqVO) throws Exception;
Long signOff(PerformanceSheetSignOffReqVO reqVO);
PerformanceDownloadFile download(Long id); PerformanceDownloadFile download(Long id);
PerformanceDownloadFile batchDownload(Collection<Long> ids); PerformanceDownloadFile batchDownload(Collection<Long> ids);
@@ -45,6 +53,8 @@ public interface PerformanceSheetService {
List<PerformanceSheetResponseRecordRespVO> getResponseRecords(Long id); List<PerformanceSheetResponseRecordRespVO> getResponseRecords(Long id);
PerformanceSheetPrefillRespVO getPrefillData(Long employeeId, String periodMonth);
PerformanceMonthlyResultRespVO getMonthlyResult(Long employeeId, String periodMonth); PerformanceMonthlyResultRespVO getMonthlyResult(Long employeeId, String periodMonth);
List<PerformanceSheetStatusDictRespVO> getStatusDict(); List<PerformanceSheetStatusDictRespVO> getStatusDict();

View File

@@ -2,6 +2,7 @@ package com.njcn.rdms.module.project.service.performance;
import com.njcn.rdms.framework.common.pojo.PageResult; import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.common.util.object.BeanUtils; import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.module.project.constant.PerformanceExcelCellConstants;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX; import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceScoreCellMappingRespVO; import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceScoreCellMappingRespVO;
@@ -25,8 +26,6 @@ public class PerformanceTemplateServiceImpl implements PerformanceTemplateServic
@Resource @Resource
private PerformanceTemplateMapper performanceTemplateMapper; private PerformanceTemplateMapper performanceTemplateMapper;
@Resource
private PerformanceProperties performanceProperties;
@Override @Override
public PerformanceTemplateRespVO getCurrentTemplate() { public PerformanceTemplateRespVO getCurrentTemplate() {
@@ -98,11 +97,14 @@ public class PerformanceTemplateServiceImpl implements PerformanceTemplateServic
} }
private PerformanceScoreCellMappingRespVO scoreCellMapping() { private PerformanceScoreCellMappingRespVO scoreCellMapping() {
PerformanceProperties.ScoreCellMapping mapping = performanceProperties.getScoreCellMapping();
PerformanceScoreCellMappingRespVO respVO = new PerformanceScoreCellMappingRespVO(); PerformanceScoreCellMappingRespVO respVO = new PerformanceScoreCellMappingRespVO();
respVO.setActualScoreTotalCell(mapping.getActualScoreTotal()); respVO.setActualScoreTotalCell(PerformanceExcelCellConstants.ACTUAL_SCORE_TOTAL_CELL);
respVO.setBaseScoreTotalCell(mapping.getBaseScoreTotal()); respVO.setBaseScoreTotalCell(PerformanceExcelCellConstants.BASE_SCORE_TOTAL_CELL);
respVO.setExtraScoreTotalCell(mapping.getExtraScoreTotal()); respVO.setExtraScoreTotalCell(PerformanceExcelCellConstants.EXTRA_SCORE_TOTAL_CELL);
respVO.setResultDescriptionCells(PerformanceExcelCellConstants.RESULT_DESCRIPTION_CELLS);
respVO.setActualScoreCells(PerformanceExcelCellConstants.ACTUAL_SCORE_CELLS);
respVO.setBaseScoreCells(PerformanceExcelCellConstants.BASE_SCORE_CELLS);
respVO.setExtraScoreCells(PerformanceExcelCellConstants.EXTRA_SCORE_CELLS);
return respVO; return respVO;
} }

View File

@@ -1,18 +1,22 @@
package com.njcn.rdms.module.project.service.project; package com.njcn.rdms.module.project.service.project;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.OvertimeApplicationConstants;
import com.njcn.rdms.module.project.controller.admin.project.project.vo.workbench.MyWorklogWeekRespVO; import com.njcn.rdms.module.project.controller.admin.project.project.vo.workbench.MyWorklogWeekRespVO;
import com.njcn.rdms.module.project.controller.admin.project.project.vo.workbench.TeamWorklogWeekRespVO; import com.njcn.rdms.module.project.controller.admin.project.project.vo.workbench.TeamWorklogWeekRespVO;
import com.njcn.rdms.module.project.dal.dataobject.overtime.OvertimeApplicationDO;
import com.njcn.rdms.module.project.dal.dataobject.personal.PersonalItemDO; import com.njcn.rdms.module.project.dal.dataobject.personal.PersonalItemDO;
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO; import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO; 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.project.task.TaskWorklogDO;
import com.njcn.rdms.module.project.dal.mysql.overtime.OvertimeApplicationMapper;
import com.njcn.rdms.module.project.dal.mysql.personal.PersonalItemMapper; import com.njcn.rdms.module.project.dal.mysql.personal.PersonalItemMapper;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper; import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper; import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskWorklogMapper; import com.njcn.rdms.module.project.dal.mysql.project.task.TaskWorklogMapper;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
@@ -31,6 +35,8 @@ import java.util.stream.Collectors;
@Service @Service
public class MyWorklogServiceImpl implements MyWorklogService { public class MyWorklogServiceImpl implements MyWorklogService {
private static final BigDecimal OVERTIME_DAY_HOURS = new BigDecimal("6");
@Resource @Resource
private TaskWorklogMapper taskWorklogMapper; private TaskWorklogMapper taskWorklogMapper;
@Resource @Resource
@@ -41,29 +47,29 @@ public class MyWorklogServiceImpl implements MyWorklogService {
private ProjectMapper projectMapper; private ProjectMapper projectMapper;
@Resource @Resource
private MyTeamService myTeamService; private MyTeamService myTeamService;
@Resource
private OvertimeApplicationMapper overtimeApplicationMapper;
@Override @Override
public MyWorklogWeekRespVO getMyWorklogWeek(LocalDate weekStart) { public MyWorklogWeekRespVO getMyWorklogWeek(LocalDate weekStart) {
Long me = SecurityFrameworkUtils.getLoginUserId(); Long me = SecurityFrameworkUtils.getLoginUserId();
LocalDate monday = MyWorklogWeekSupport.normalizeToMonday(weekStart); LocalDate monday = MyWorklogWeekSupport.normalizeToMonday(weekStart);
// 查询区间含周末:周六/周日的段要兜底归周五,必须查回来
List<TaskWorklogDO> worklogs = taskWorklogMapper List<TaskWorklogDO> worklogs = taskWorklogMapper
.selectListByUserIdAndPeriod(me, monday, monday.plusDays(6)); .selectListByUserIdAndPeriod(me, monday, monday.plusDays(6));
// 逐日累计(scale=4)与每任务份额累计
Map<LocalDate, BigDecimal> daily = new LinkedHashMap<>(); Map<LocalDate, BigDecimal> daily = new LinkedHashMap<>();
Map<Long, BigDecimal> hoursByTask = new HashMap<>(); Map<Long, BigDecimal> hoursByTask = new HashMap<>();
for (TaskWorklogDO w : worklogs) { for (TaskWorklogDO worklog : worklogs) {
Map<LocalDate, BigDecimal> shares = MyWorklogWeekSupport Map<LocalDate, BigDecimal> shares = MyWorklogWeekSupport
.apportionToWeek(w.getStartDate(), w.getEndDate(), w.getDurationHours(), monday); .apportionToWeek(worklog.getStartDate(), worklog.getEndDate(), worklog.getDurationHours(), monday);
for (Map.Entry<LocalDate, BigDecimal> e : shares.entrySet()) { for (Map.Entry<LocalDate, BigDecimal> entry : shares.entrySet()) {
daily.merge(e.getKey(), e.getValue(), BigDecimal::add); daily.merge(entry.getKey(), entry.getValue(), BigDecimal::add);
hoursByTask.merge(w.getTaskId(), e.getValue(), BigDecimal::add); hoursByTask.merge(worklog.getTaskId(), entry.getValue(), BigDecimal::add);
} }
} }
MyWorklogWeekRespVO resp = new MyWorklogWeekRespVO(); MyWorklogWeekRespVO resp = new MyWorklogWeekRespVO();
resp.setWeekStart(monday); resp.setWeekStart(monday);
List<BigDecimal> dailyHours = new ArrayList<>(5); List<BigDecimal> dailyHours = new ArrayList<>(7);
for (int i = 0; i < 5; i++) { for (int i = 0; i < 7; i++) {
dailyHours.add(daily.getOrDefault(monday.plusDays(i), BigDecimal.ZERO) dailyHours.add(daily.getOrDefault(monday.plusDays(i), BigDecimal.ZERO)
.setScale(2, RoundingMode.HALF_UP)); .setScale(2, RoundingMode.HALF_UP));
} }
@@ -75,40 +81,82 @@ public class MyWorklogServiceImpl implements MyWorklogService {
@Override @Override
public TeamWorklogWeekRespVO getTeamWorklogWeek(LocalDate weekStart) { public TeamWorklogWeekRespVO getTeamWorklogWeek(LocalDate weekStart) {
LocalDate monday = MyWorklogWeekSupport.normalizeToMonday(weekStart); LocalDate monday = MyWorklogWeekSupport.normalizeToMonday(weekStart);
LocalDate sunday = monday.plusDays(6);
List<MyTeamService.TeamMember> members = myTeamService.resolveTeamMembers(); List<MyTeamService.TeamMember> members = myTeamService.resolveTeamMembers();
List<Long> userIds = members.stream().map(MyTeamService.TeamMember::userId).collect(Collectors.toList()); List<Long> userIds = members.stream().map(MyTeamService.TeamMember::userId).collect(Collectors.toList());
List<TaskWorklogDO> worklogs = taskWorklogMapper List<TaskWorklogDO> worklogs = taskWorklogMapper.selectListByUserIdsAndPeriod(userIds, monday, sunday);
.selectListByUserIdsAndPeriod(userIds, monday, monday.plusDays(6));
// 每成员每任务的本周份额合计
Map<Long, Map<Long, BigDecimal>> hoursByUserAndTask = new LinkedHashMap<>(); Map<Long, Map<Long, BigDecimal>> hoursByUserAndTask = new LinkedHashMap<>();
for (TaskWorklogDO w : worklogs) { for (TaskWorklogDO worklog : worklogs) {
BigDecimal weekTotal = MyWorklogWeekSupport BigDecimal weekTotal = MyWorklogWeekSupport
.apportionToWeek(w.getStartDate(), w.getEndDate(), w.getDurationHours(), monday) .apportionToWeek(worklog.getStartDate(), worklog.getEndDate(), worklog.getDurationHours(), monday)
.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add); .values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
if (weekTotal.signum() > 0) { if (weekTotal.signum() > 0) {
hoursByUserAndTask.computeIfAbsent(w.getUserId(), k -> new HashMap<>()) hoursByUserAndTask.computeIfAbsent(worklog.getUserId(), key -> new HashMap<>())
.merge(w.getTaskId(), weekTotal, BigDecimal::add); .merge(worklog.getTaskId(), weekTotal, BigDecimal::add);
} }
} }
Map<Long, BigDecimal> overtimeHoursByUser = buildApprovedOvertimeHoursByUser(userIds, monday, sunday);
TeamWorklogWeekRespVO resp = new TeamWorklogWeekRespVO(); TeamWorklogWeekRespVO resp = new TeamWorklogWeekRespVO();
resp.setWeekStart(monday); resp.setWeekStart(monday);
resp.setMembers(members.stream().map(m -> { resp.setMembers(members.stream().map(member -> {
TeamWorklogWeekRespVO.MemberVO vo = new TeamWorklogWeekRespVO.MemberVO(); TeamWorklogWeekRespVO.MemberVO vo = new TeamWorklogWeekRespVO.MemberVO();
vo.setUserId(m.userId()); vo.setUserId(member.userId());
vo.setUserNickname(m.nickname()); vo.setUserNickname(member.nickname());
vo.setItems(buildDistribution( vo.setItems(buildDistribution(
hoursByUserAndTask.getOrDefault(m.userId(), Collections.emptyMap()))); hoursByUserAndTask.getOrDefault(member.userId(), Collections.emptyMap())));
vo.setOvertimeHours(overtimeHoursByUser.getOrDefault(member.userId(), BigDecimal.ZERO)
.setScale(2, RoundingMode.HALF_UP));
return vo; return vo;
}).collect(Collectors.toList())); }).collect(Collectors.toList()));
return resp; return resp;
} }
/** private Map<Long, BigDecimal> buildApprovedOvertimeHoursByUser(List<Long> userIds,
* 把 taskId→本周工时 聚成归属分布:工时表的 task_id 既可能指向任务也可能指向个人事项 LocalDate startDate,
* (个人事项工时复用 rdms_task_worklog),先按任务表归 project,再按个人事项表归 personal, LocalDate endDate) {
* 都对不上归 other。排序:project 行按 hours 降序,personal/other 殿后。 if (userIds == null || userIds.isEmpty()) {
* 被软删的任务/事项的残留工时归 other(两表主键同为雪花 id 全局不撞,不存在误归类)。 return Collections.emptyMap();
*/ }
List<OvertimeApplicationDO> applications = overtimeApplicationMapper.selectApprovedListByApplicantIdsAndDateRange(
userIds, startDate, endDate, OvertimeApplicationConstants.STATUS_APPROVED);
if (applications.isEmpty()) {
return Collections.emptyMap();
}
Map<Long, BigDecimal> overtimeHoursByUser = new HashMap<>();
for (OvertimeApplicationDO application : applications) {
if (application == null || application.getApplicantId() == null) {
continue;
}
BigDecimal overtimeHours = parseOvertimeHours(application.getOvertimeDuration());
if (overtimeHours.signum() <= 0) {
continue;
}
overtimeHoursByUser.merge(application.getApplicantId(), overtimeHours, BigDecimal::add);
}
return overtimeHoursByUser;
}
private BigDecimal parseOvertimeHours(String overtimeDuration) {
if (!StringUtils.hasText(overtimeDuration)) {
return BigDecimal.ZERO;
}
String normalized = overtimeDuration.trim()
.replace("\u5929", "")
.replace("\u5c0f\u65f6", "")
.replace("h", "")
.trim();
if (!StringUtils.hasText(normalized)) {
return BigDecimal.ZERO;
}
try {
return new BigDecimal(normalized).multiply(OVERTIME_DAY_HOURS);
} catch (NumberFormatException ex) {
return BigDecimal.ZERO;
}
}
private List<MyWorklogWeekRespVO.DistributionItemVO> buildDistribution(Map<Long, BigDecimal> hoursByTask) { private List<MyWorklogWeekRespVO.DistributionItemVO> buildDistribution(Map<Long, BigDecimal> hoursByTask) {
if (hoursByTask.isEmpty()) { if (hoursByTask.isEmpty()) {
return Collections.emptyList(); return Collections.emptyList();
@@ -117,37 +165,41 @@ public class MyWorklogServiceImpl implements MyWorklogService {
Map<Long, ProjectTaskDO> taskMap = projectTaskMapper.selectBatchIds(taskIds).stream() Map<Long, ProjectTaskDO> taskMap = projectTaskMapper.selectBatchIds(taskIds).stream()
.collect(Collectors.toMap(ProjectTaskDO::getId, Function.identity(), (a, b) -> a)); .collect(Collectors.toMap(ProjectTaskDO::getId, Function.identity(), (a, b) -> a));
Set<Long> personalIds = taskIds.stream() Set<Long> personalIds = taskIds.stream()
.filter(id -> !taskMap.containsKey(id)).collect(Collectors.toSet()); .filter(id -> !taskMap.containsKey(id))
.collect(Collectors.toSet());
Set<Long> personalHit = personalIds.isEmpty() ? Collections.emptySet() Set<Long> personalHit = personalIds.isEmpty() ? Collections.emptySet()
: personalItemMapper.selectBatchIds(personalIds).stream() : personalItemMapper.selectBatchIds(personalIds).stream()
.map(PersonalItemDO::getId).collect(Collectors.toSet()); .map(PersonalItemDO::getId)
// 聚合:projectId→hours / personal / other .collect(Collectors.toSet());
Map<Long, BigDecimal> hoursByProject = new LinkedHashMap<>(); Map<Long, BigDecimal> hoursByProject = new LinkedHashMap<>();
BigDecimal personalHours = BigDecimal.ZERO; BigDecimal personalHours = BigDecimal.ZERO;
BigDecimal otherHours = BigDecimal.ZERO; BigDecimal otherHours = BigDecimal.ZERO;
for (Map.Entry<Long, BigDecimal> e : hoursByTask.entrySet()) { for (Map.Entry<Long, BigDecimal> entry : hoursByTask.entrySet()) {
ProjectTaskDO task = taskMap.get(e.getKey()); ProjectTaskDO task = taskMap.get(entry.getKey());
if (task != null) { if (task != null) {
hoursByProject.merge(task.getProjectId(), e.getValue(), BigDecimal::add); hoursByProject.merge(task.getProjectId(), entry.getValue(), BigDecimal::add);
} else if (personalHit.contains(e.getKey())) { } else if (personalHit.contains(entry.getKey())) {
personalHours = personalHours.add(e.getValue()); personalHours = personalHours.add(entry.getValue());
} else { } else {
otherHours = otherHours.add(e.getValue()); otherHours = otherHours.add(entry.getValue());
} }
} }
Map<Long, ProjectDO> projectMap = hoursByProject.isEmpty() ? Collections.emptyMap() Map<Long, ProjectDO> projectMap = hoursByProject.isEmpty() ? Collections.emptyMap()
: projectMapper.selectBatchIds(hoursByProject.keySet()).stream() : projectMapper.selectBatchIds(hoursByProject.keySet()).stream()
.collect(Collectors.toMap(ProjectDO::getId, Function.identity(), (a, b) -> a)); .collect(Collectors.toMap(ProjectDO::getId, Function.identity(), (a, b) -> a));
List<MyWorklogWeekRespVO.DistributionItemVO> items = new ArrayList<>(); List<MyWorklogWeekRespVO.DistributionItemVO> items = new ArrayList<>();
hoursByProject.entrySet().stream() hoursByProject.entrySet().stream()
.sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())) .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder()))
.forEach(e -> { .forEach(entry -> {
MyWorklogWeekRespVO.DistributionItemVO item = new MyWorklogWeekRespVO.DistributionItemVO(); MyWorklogWeekRespVO.DistributionItemVO item = new MyWorklogWeekRespVO.DistributionItemVO();
item.setProjectId(e.getKey()); item.setProjectId(entry.getKey());
ProjectDO project = projectMap.get(e.getKey()); ProjectDO project = projectMap.get(entry.getKey());
item.setProjectName(project == null ? null : project.getProjectName()); item.setProjectName(project == null ? null : project.getProjectName());
item.setKind("project"); item.setKind("project");
item.setHours(e.getValue().setScale(2, RoundingMode.HALF_UP)); item.setHours(entry.getValue().setScale(2, RoundingMode.HALF_UP));
items.add(item); items.add(item);
}); });
if (personalHours.signum() > 0) { if (personalHours.signum() > 0) {

View File

@@ -10,8 +10,8 @@ import java.util.Map;
/** /**
* 工时周聚合的纯函数支撑:周一归一 + 按段均摊。 * 工时周聚合的纯函数支撑:周一归一 + 按段均摊。
* 口径(2026-06-12 与用户确认):工时按段填报无逐日明细,均摊分母 = 段内工作日(周一~周五)数; * 口径(2026-07-14 更新):工时按段填报无逐日明细,均摊分母 = 段内工作日(周一~周五)数;
* 纯周末段全额兜底归段结束日前最近的工作日;份额 scale=4,最终展示由调用方聚合后 setScale(2)。 * 周六/周日不参与工作日段均摊;周末单天工时保留在当天展示;份额 scale=4,最终展示由调用方聚合后 setScale(2)。
*/ */
final class MyWorklogWeekSupport { final class MyWorklogWeekSupport {
@@ -25,7 +25,7 @@ final class MyWorklogWeekSupport {
/** /**
* 把一笔按段填报的工时摊到所选周的工作日。 * 把一笔按段填报的工时摊到所选周的工作日。
* 返回 [weekMonday, weekMonday+4] 内各工作日的份额(scale=4);无份额的日不出现在 map。 * 返回 [weekMonday, weekMonday+6] 内各自然日的份额(scale=4);无份额的日不出现在 map。
* *
* <p>前置条件:weekMonday 必须已归一到 ISO 周的周一(调用方先调 {@link #normalizeToMonday})。 * <p>前置条件:weekMonday 必须已归一到 ISO 周的周一(调用方先调 {@link #normalizeToMonday})。
* 方法内不做防御归一,以便在调用方传入非周一时尽早暴露 bug。 * 方法内不做防御归一,以便在调用方传入非周一时尽早暴露 bug。
@@ -36,28 +36,32 @@ final class MyWorklogWeekSupport {
if (segStart == null || segEnd == null || hours == null || segStart.isAfter(segEnd)) { if (segStart == null || segEnd == null || hours == null || segStart.isAfter(segEnd)) {
return result; return result;
} }
LocalDate weekFriday = weekMonday.plusDays(4); LocalDate weekSunday = weekMonday.plusDays(6);
List<LocalDate> workdays = segStart.datesUntil(segEnd.plusDays(1)) List<LocalDate> workdays = segStart.datesUntil(segEnd.plusDays(1))
.filter(d -> d.getDayOfWeek().getValue() <= 5) .filter(d -> d.getDayOfWeek().getValue() <= 5)
.toList(); .toList();
if (workdays.isEmpty()) { if (!workdays.isEmpty()) {
// 纯周末段:全额归段结束日前最近的工作日(周六/周日 → 周五)
LocalDate fallback = segEnd;
while (fallback.getDayOfWeek().getValue() > 5) {
fallback = fallback.minusDays(1);
}
if (!fallback.isBefore(weekMonday) && !fallback.isAfter(weekFriday)) {
result.put(fallback, hours.setScale(4, RoundingMode.HALF_UP));
}
return result;
}
BigDecimal share = hours.divide(BigDecimal.valueOf(workdays.size()), 4, RoundingMode.HALF_UP); BigDecimal share = hours.divide(BigDecimal.valueOf(workdays.size()), 4, RoundingMode.HALF_UP);
for (LocalDate d : workdays) { for (LocalDate d : workdays) {
if (!d.isBefore(weekMonday) && !d.isAfter(weekFriday)) { if (!d.isBefore(weekMonday) && !d.isAfter(weekSunday)) {
result.put(d, share); result.put(d, share);
} }
} }
return result; return result;
} }
List<LocalDate> weekendDays = segStart.datesUntil(segEnd.plusDays(1))
.filter(d -> d.getDayOfWeek().getValue() > 5)
.filter(d -> !d.isBefore(weekMonday) && !d.isAfter(weekSunday))
.toList();
if (weekendDays.isEmpty()) {
return result;
}
BigDecimal share = hours.divide(BigDecimal.valueOf(weekendDays.size()), 4, RoundingMode.HALF_UP);
for (LocalDate d : weekendDays) {
result.put(d, share);
}
return result;
}
} }

View File

@@ -32,6 +32,8 @@ public interface ProjectRequirementService {
PageResult<ProjectRequirementRespVO> getRequirementTree(ProjectRequirementPageReqVO pageReqVO); PageResult<ProjectRequirementRespVO> getRequirementTree(ProjectRequirementPageReqVO pageReqVO);
PageResult<ProjectRequirementRespVO> getExecutionRequirementOptions(ProjectRequirementPageReqVO pageReqVO);
void changeRequirementStatus(ProjectRequirementStatusActionReqVO reqVO); void changeRequirementStatus(ProjectRequirementStatusActionReqVO reqVO);
void changeRequirementStatusForReview(ProjectRequirementStatusActionReqVO reqVO); void changeRequirementStatusForReview(ProjectRequirementStatusActionReqVO reqVO);

View File

@@ -255,21 +255,9 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
@Override @Override
@CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#pageReqVO.projectId", accessible = true) @CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#pageReqVO.projectId", accessible = true)
public PageResult<ProjectRequirementRespVO> getRequirementPage(ProjectRequirementPageReqVO pageReqVO) { public PageResult<ProjectRequirementRespVO> getRequirementPage(ProjectRequirementPageReqVO pageReqVO) {
if (pageReqVO.getModuleId() != null) { normalizePageModuleFilter(pageReqVO);
if (isAllRequirementsModule(pageReqVO.getModuleId())) {
pageReqVO.setModuleId(null);
} else {
List<Long> moduleIds = getAllModuleIdsWithChildren(pageReqVO.getModuleId(), pageReqVO.getProjectId());
pageReqVO.setModuleIds(moduleIds);
pageReqVO.setModuleId(null);
}
}
PageResult<ProjectRequirementDO> pageResult = requirementMapper.selectPage(pageReqVO); PageResult<ProjectRequirementDO> pageResult = requirementMapper.selectPage(pageReqVO);
Map<String, ObjectStatusModelDO> statusModelMap = getStatusModelMap(); return toRequirementRespPage(pageResult);
List<ProjectRequirementRespVO> list = pageResult.getList().stream()
.map(requirement -> buildRequirementRespVO(requirement, statusModelMap))
.collect(Collectors.toList());
return new PageResult<>(list, pageResult.getTotal());
} }
@Override @Override
@@ -325,6 +313,16 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
return new PageResult<>(list, (long) total); return new PageResult<>(list, (long) total);
} }
@Override
@CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#pageReqVO.projectId", accessible = true)
public PageResult<ProjectRequirementRespVO> getExecutionRequirementOptions(ProjectRequirementPageReqVO pageReqVO) {
normalizePageModuleFilter(pageReqVO);
Set<String> excludedStatusCodes = new HashSet<>(getTerminalStatusCodes());
excludedStatusCodes.add(STATUS_REVIEW_REJECTED);
PageResult<ProjectRequirementDO> pageResult = requirementMapper.selectPage(pageReqVO, excludedStatusCodes);
return toRequirementRespPage(pageResult);
}
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#reqVO.projectId", @CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#reqVO.projectId",
@@ -749,6 +747,27 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
return childrenMap; return childrenMap;
} }
private void normalizePageModuleFilter(ProjectRequirementPageReqVO pageReqVO) {
if (pageReqVO.getModuleId() == null) {
return;
}
if (isAllRequirementsModule(pageReqVO.getModuleId())) {
pageReqVO.setModuleId(null);
return;
}
List<Long> moduleIds = getAllModuleIdsWithChildren(pageReqVO.getModuleId(), pageReqVO.getProjectId());
pageReqVO.setModuleIds(moduleIds);
pageReqVO.setModuleId(null);
}
private PageResult<ProjectRequirementRespVO> toRequirementRespPage(PageResult<ProjectRequirementDO> pageResult) {
Map<String, ObjectStatusModelDO> statusModelMap = getStatusModelMap();
List<ProjectRequirementRespVO> list = pageResult.getList().stream()
.map(requirement -> buildRequirementRespVO(requirement, statusModelMap))
.collect(Collectors.toList());
return new PageResult<>(list, pageResult.getTotal());
}
/** /**
* 一次性加载当前对象类型下的状态模型,供列表和树查询复用。 * 一次性加载当前对象类型下的状态模型,供列表和树查询复用。
*/ */

View File

@@ -68,6 +68,12 @@ public interface ProjectService {
void autoStartProjectIfPending(Long projectId, String triggerAction); void autoStartProjectIfPending(Long projectId, String triggerAction);
/**
* 以项目下最早一条任务工作日志的 startDate 为优先口径,同步项目实际开始日期。
* 若项目下尚无任务工作日志,则回退到首次 auto_start 状态日志日期;两者都没有时不更新。
*/
void syncActualStartDateFromWorklogs(Long projectId);
/** /**
* 重算项目进度并落库AVG(项目下所有根任务 progressRate),沿用 task 维度的 progress_excluded 排除集合。 * 重算项目进度并落库AVG(项目下所有根任务 progressRate),沿用 task 维度的 progress_excluded 排除集合。
* 由任务侧在"任务进度变化 / 状态变更 / 创建删除 / 父子结构变化"等入口触发。 * 由任务侧在"任务进度变化 / 状态变更 / 创建删除 / 父子结构变化"等入口触发。

View File

@@ -44,10 +44,12 @@ import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementModuleMapper; import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementModuleMapper;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectStatusLogMapper; import com.njcn.rdms.module.project.dal.mysql.project.ProjectStatusLogMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper; import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
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.ObjectStatusModelMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper; 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.ErrorCodeConstants;
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants; import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
import com.njcn.rdms.module.project.framework.notify.NotifyJumpConstants;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO; import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX; import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScope; import com.njcn.rdms.module.project.service.datascope.ObjectDataScope;
@@ -122,6 +124,8 @@ class ProjectServiceImpl implements ProjectService {
@Resource @Resource
private ProjectTaskMapper projectTaskMapper; private ProjectTaskMapper projectTaskMapper;
@Resource @Resource
private TaskWorklogMapper taskWorklogMapper;
@Resource
private com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper projectExecutionMapper; private com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper projectExecutionMapper;
@Resource @Resource
private com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementMapper projectRequirementMapper; private com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementMapper projectRequirementMapper;
@@ -276,6 +280,8 @@ class ProjectServiceImpl implements ProjectService {
Map<String, Object> params = new HashMap<>(); Map<String, Object> params = new HashMap<>();
params.put("projectName", project.getProjectName()); params.put("projectName", project.getProjectName());
params.put("roleName", r.roleName()); params.put("roleName", r.roleName());
params.put(NotifyJumpConstants.PARAM_JUMP_TYPE, NotifyJumpConstants.JUMP_TYPE_PROJECT);
params.put(NotifyJumpConstants.PARAM_PROJECT_ID, String.valueOf(project.getId()));
applicationEventPublisher.publishEvent( applicationEventPublisher.publishEvent(
com.njcn.rdms.module.project.framework.notify.NotifySendEvent.of( com.njcn.rdms.module.project.framework.notify.NotifySendEvent.of(
java.util.List.of(r.userId()), java.util.List.of(r.userId()),
@@ -1065,6 +1071,19 @@ class ProjectServiceImpl implements ProjectService {
changeStatus(project, transition, ObjectActivityConstants.PROJECT_ACTION_AUTO_START, reason); changeStatus(project, transition, ObjectActivityConstants.PROJECT_ACTION_AUTO_START, reason);
} }
@Override
@Transactional(rollbackFor = Exception.class)
public void syncActualStartDateFromWorklogs(Long projectId) {
if (projectId == null) {
return;
}
ProjectDO project = projectMapper.selectById(projectId);
if (project == null) {
return;
}
syncActualStartDate(project);
}
@Override @Override
public void recalcProgress(Long projectId) { public void recalcProgress(Long projectId) {
if (projectId == null) { if (projectId == null) {
@@ -1494,6 +1513,40 @@ class ProjectServiceImpl implements ProjectService {
writeProjectStatusLog(project, actionCode, fromStatus, toStatus, reason); writeProjectStatusLog(project, actionCode, fromStatus, toStatus, reason);
writeBizAuditLog(project, actionCode, fromStatus, toStatus, null, reason); writeBizAuditLog(project, actionCode, fromStatus, toStatus, null, reason);
if (ObjectActivityConstants.PROJECT_ACTION_AUTO_START.equals(actionCode)) {
syncActualStartDate(project);
}
}
/**
* 项目实际开始日期口径:
* 1) 优先取项目下最早一条任务工作日志的 startDate
* 2) 若无工作日志,则回退到首次 auto_start 状态日志的 createTime 所在日期;
* 3) 两者都没有时不更新。
*/
private void syncActualStartDate(ProjectDO project) {
if (project == null || project.getId() == null) {
return;
}
LocalDate targetDate = resolveActualStartDate(project.getId());
if (targetDate == null || Objects.equals(targetDate, project.getActualStartDate())) {
return;
}
projectMapper.updateActualDatesById(project.getId(), targetDate, null);
project.setActualStartDate(targetDate);
}
private LocalDate resolveActualStartDate(Long projectId) {
LocalDate earliestWorklogDate = taskWorklogMapper.selectEarliestStartDateByProjectId(projectId);
if (earliestWorklogDate != null) {
return earliestWorklogDate;
}
ProjectStatusLogDO autoStartLog = projectStatusLogMapper
.selectEarliestByProjectIdAndActionType(projectId, ObjectActivityConstants.PROJECT_ACTION_AUTO_START);
if (autoStartLog == null || autoStartLog.getCreateTime() == null) {
return null;
}
return autoStartLog.getCreateTime().toLocalDate();
} }
private ProjectContextProjectRespVO buildCurrentProject(ProjectDO project) { private ProjectContextProjectRespVO buildCurrentProject(ProjectDO project) {

View File

@@ -43,10 +43,12 @@ 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.dal.mysql.status.ObjectStatusTransitionMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants; import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants; import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
import com.njcn.rdms.module.project.framework.notify.NotifyJumpConstants;
import com.njcn.rdms.module.project.framework.notify.NotifySendEvent; import com.njcn.rdms.module.project.framework.notify.NotifySendEvent;
import com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants; import com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants;
import com.njcn.rdms.module.project.util.DueRangeSupport; import com.njcn.rdms.module.project.util.DueRangeSupport;
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission; import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
import com.njcn.rdms.module.project.framework.security.service.ProjectObjectAuthorizationService;
import com.njcn.rdms.module.project.service.project.ProjectRequirementService; import com.njcn.rdms.module.project.service.project.ProjectRequirementService;
import com.njcn.rdms.module.project.service.project.ProjectService; import com.njcn.rdms.module.project.service.project.ProjectService;
import com.njcn.rdms.module.project.service.project.task.ProjectTaskService; import com.njcn.rdms.module.project.service.project.task.ProjectTaskService;
@@ -127,6 +129,8 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
@Resource @Resource
private ProjectRequirementMapper projectRequirementMapper; private ProjectRequirementMapper projectRequirementMapper;
@Resource @Resource
private ProjectObjectAuthorizationService projectObjectAuthorizationService;
@Resource
private StatusActionTextResolver statusActionTextResolver; private StatusActionTextResolver statusActionTextResolver;
/** 站内信事件发布:创建执行后发「执行指派」通知,由 NotifySendEventListener 事务提交后统一发送。 */ /** 站内信事件发布:创建执行后发「执行指派」通知,由 NotifySendEventListener 事务提交后统一发送。 */
@Resource @Resource
@@ -193,21 +197,23 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
ProjectDO project = projectMapper.selectById(execution.getProjectId()); ProjectDO project = projectMapper.selectById(execution.getProjectId());
params.put("projectName", project == null ? "" : project.getProjectName()); params.put("projectName", project == null ? "" : project.getProjectName());
params.put("executionName", execution.getExecutionName()); params.put("executionName", execution.getExecutionName());
params.put(NotifyJumpConstants.PARAM_JUMP_TYPE, NotifyJumpConstants.JUMP_TYPE_EXECUTION);
params.put(NotifyJumpConstants.PARAM_PROJECT_ID, String.valueOf(execution.getProjectId()));
applicationEventPublisher.publishEvent(NotifySendEvent.of(recipients, applicationEventPublisher.publishEvent(NotifySendEvent.of(recipients,
NotifyTemplateCodeConstants.EXECUTION_ASSIGNED, params, NotifyTemplateCodeConstants.EXECUTION_ASSIGNED, params,
NotifyMessageLevelConstants.NORMAL, SecurityFrameworkUtils.getLoginUserId())); NotifyMessageLevelConstants.NORMAL, SecurityFrameworkUtils.getLoginUserId()));
} }
// 权限:方法体内属主放行 + 权限码兜底requireExecutionEditDeletePermission勿挂 @CheckObjectPermission
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_UPDATE)
public void updateExecution(Long projectId, ProjectExecutionUpdateReqVO reqVO) { public void updateExecution(Long projectId, ProjectExecutionUpdateReqVO reqVO) {
if (reqVO.getId() == null) { if (reqVO.getId() == null) {
throw invalidParamException("执行编号不能为空"); throw invalidParamException("执行编号不能为空");
} }
validateEditableProject(projectId); ProjectDO project = validateEditableProject(projectId);
ProjectExecutionDO execution = validateExecutionExists(projectId, reqVO.getId()); ProjectExecutionDO execution = validateExecutionExists(projectId, reqVO.getId());
requireExecutionEditDeletePermission(project, execution, ProjectExecutionConstants.PERMISSION_UPDATE);
validateExecutionAllowEdit(execution); validateExecutionAllowEdit(execution);
projectRequirementService.validateUsableForExecution(projectId, reqVO.getProjectRequirementId()); projectRequirementService.validateUsableForExecution(projectId, reqVO.getProjectRequirementId());
String executionName = normalizeRequiredName(reqVO.getExecutionName()); String executionName = normalizeRequiredName(reqVO.getExecutionName());
@@ -445,13 +451,13 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_IN, reason); ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_IN, reason);
} }
// 权限:方法体内属主放行 + 权限码兜底requireExecutionEditDeletePermission勿挂 @CheckObjectPermission
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_DELETE)
public void deleteExecution(Long projectId, Long executionId, ProjectExecutionDeleteReqVO reqVO) { public void deleteExecution(Long projectId, Long executionId, ProjectExecutionDeleteReqVO reqVO) {
validateProjectExists(projectId); ProjectDO project = validateProjectExists(projectId);
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId); ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
requireExecutionEditDeletePermission(project, execution, ProjectExecutionConstants.PERMISSION_DELETE);
validateDeleteConfirmText(reqVO.getConfirmText()); validateDeleteConfirmText(reqVO.getConfirmText());
if (!Objects.equals(execution.getExecutionName(), reqVO.getExecutionName().trim())) { if (!Objects.equals(execution.getExecutionName(), reqVO.getExecutionName().trim())) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_DELETE_NAME_MISMATCH); throw exception(ErrorCodeConstants.PROJECT_EXECUTION_DELETE_NAME_MISMATCH);
@@ -479,12 +485,12 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
writeExecutionAuditLog(execution, ObjectActivityConstants.EXECUTION_ACTION_DELETE, fromStatus, null, null, reason); writeExecutionAuditLog(execution, ObjectActivityConstants.EXECUTION_ACTION_DELETE, fromStatus, null, null, reason);
} }
// 权限:方法体内属主放行 + 权限码兜底requireExecutionEditDeletePermission勿挂 @CheckObjectPermission
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_DELETE)
public ProjectExecutionDeletePrecheckRespVO precheckDeleteExecution(Long projectId, Long executionId) { public ProjectExecutionDeletePrecheckRespVO precheckDeleteExecution(Long projectId, Long executionId) {
validateProjectExists(projectId); ProjectDO project = validateProjectExists(projectId);
validateExecutionExists(projectId, executionId); ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
requireExecutionEditDeletePermission(project, execution, ProjectExecutionConstants.PERMISSION_DELETE);
int taskCount = projectTaskMapper.countByExecutionId(executionId); int taskCount = projectTaskMapper.countByExecutionId(executionId);
ProjectExecutionDeletePrecheckRespVO respVO = new ProjectExecutionDeletePrecheckRespVO(); ProjectExecutionDeletePrecheckRespVO respVO = new ProjectExecutionDeletePrecheckRespVO();
respVO.setTaskCount(taskCount); respVO.setTaskCount(taskCount);
@@ -492,6 +498,27 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
return respVO; return respVO;
} }
/**
* 执行编辑/删除授权:项目负责人、执行负责人直接放行;其它用户回落对象域权限码。
*
* <p>红线调用本方法的入口updateExecution / deleteExecution / precheckDeleteExecution<b>故意不挂
* {@code @CheckObjectPermission}</b>——注解是前置拦截,会把"无权限码的负责人"挡死。
* 不要误判为漏鉴权把注解补回去;口径见 CLAUDE.md §鉴权。
*/
private void requireExecutionEditDeletePermission(ProjectDO project, ProjectExecutionDO execution,
String permission) {
List<Long> ownerUserIds = new ArrayList<>();
addIfNotNull(ownerUserIds, project.getManagerUserId());
addIfNotNull(ownerUserIds, execution.getOwnerId());
projectObjectAuthorizationService.checkAnyOwnerOrProjectPermission(project.getId(), ownerUserIds, permission);
}
private void addIfNotNull(Collection<Long> values, Long value) {
if (value != null) {
values.add(value);
}
}
private void validateDeleteConfirmText(String confirmText) { private void validateDeleteConfirmText(String confirmText) {
String normalizedConfirmText = normalizeNullableText(confirmText); String normalizedConfirmText = normalizeNullableText(confirmText);
if (normalizedConfirmText == null if (normalizedConfirmText == null

View File

@@ -9,8 +9,6 @@ import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTask
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusActionReqVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusActionReqVO;
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO; import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO;
import java.time.LocalDate;
/** /**
* 项目任务 Service 接口。 * 项目任务 Service 接口。
*/ */
@@ -57,11 +55,11 @@ public interface ProjectTaskService {
void changeTaskStatus(Long projectId, Long executionId, Long taskId, ProjectTaskStatusActionReqVO reqVO); void changeTaskStatus(Long projectId, Long executionId, Long taskId, ProjectTaskStatusActionReqVO reqVO);
/** /**
* 删除任务(软删 + 三重确认 + 上级硬卡 + 级联软删 + 父进度回算 + 审计)。 * 删除任务(软删 + 三重确认 + 多身份授权 + 级联软删 + 父进度回算 + 审计)。
* <p>删除规则:非 completed 即可删pending / active / paused / cancelled 全允许); * <p>删除规则:非 completed 即可删pending / active / paused / cancelled 全允许);
* 已 completed 的任务用专门错误码 PROJECT_TASK_NOT_ALLOW_DELETE 拦截。 * 已 completed 的任务用专门错误码 PROJECT_TASK_NOT_ALLOW_DELETE 拦截。
* <p>上级硬卡:一级任务由"执行负责人 OR 项目负责人(权限码 {@code project:task:delete}"删; * <p>授权:项目负责人 / 执行负责人 / 任务负责人 / 父任务负责人均可删;
* 子任务由"父任务负责人 OR 项目负责人(权限码)"删 * 其它用户持有 {@code project:task:delete} 时作为管理员兜底放行
* <p>三重确认:{@code taskName} / {@code confirmText}DELETE 或 删除)/ {@code reason} 全部必填, * <p>三重确认:{@code taskName} / {@code confirmText}DELETE 或 删除)/ {@code reason} 全部必填,
* 任意一项缺失或不匹配即拒。 * 任意一项缺失或不匹配即拒。
* <p>级联范围:软删该任务及其所有子孙任务、所有相关工作日志、所有相关任务协办; * <p>级联范围:软删该任务及其所有子孙任务、所有相关工作日志、所有相关任务协办;
@@ -89,7 +87,7 @@ public interface ProjectTaskService {
/** /**
* 由工时填报5.11触发的任务自动开始当任务当前处于初始态initialFlag=true * 由工时填报5.11触发的任务自动开始当任务当前处于初始态initialFlag=true
* 走一次 {@link com.njcn.rdms.module.project.constant.ObjectActivityConstants#TASK_ACTION_AUTO_START} * 走一次 {@link com.njcn.rdms.module.project.constant.ObjectActivityConstants#TASK_ACTION_AUTO_START}
* 动作把任务推进到下一状态,并复用 5.5 的钩子写入 actualStartDate / 状态日志 / 审计日志。 * 动作把任务推进到下一状态,并状态日志 / 审计日志。
* <p> * <p>
* 调用方必须已自行确认登录人是 task.ownerId仅 owner 工时驱动状态推进,与 progressRate 同步口径一致)。 * 调用方必须已自行确认登录人是 task.ownerId仅 owner 工时驱动状态推进,与 progressRate 同步口径一致)。
* 本方法**不走** @CheckObjectPermission因为 5.11 持有的是 PERMISSION_WORKLOG * 本方法**不走** @CheckObjectPermission因为 5.11 持有的是 PERMISSION_WORKLOG
@@ -101,16 +99,15 @@ public interface ProjectTaskService {
void internalAutoStartByWorklog(ProjectTaskDO task); void internalAutoStartByWorklog(ProjectTaskDO task);
/** /**
* 工作日志填报触发:当任务"实际开始时间"为空时,用给定的开始日期回填,使"任务何时开始" * 工作日志事实回算任务实际开始/结束日期:
* 以最早一条工作日志的开始日期为准。owner / 协办人共用,不限任务当前状态。 * <ul>
* <li>actualStartDate = 该任务最早一条工作日志的 startDate不区分填报人</li>
* <li>actualEndDate = 该任务负责人本人所填、progressRate=100 的最晚一条工作日志 endDate</li>
* </ul>
* <p> * <p>
* 与 {@link #internalAutoStartByWorklog} 解耦:本方法只回填 actualStartDate不动实际结束日期、 * 当日志被删除或改动后,本方法也负责把对应字段清空或改写为新的回算结果。
* 不推进任务状态)。调用顺序应在 internalAutoStartByWorklog 之前,确保后者的回填钩子见到非空值即跳过,
* 不会再用"提交当天"覆盖工作日志的开始日期。
* <p>
* 任务实际开始时间已有值、或入参为空时静默 return。
*/ */
void fillActualStartDateIfAbsent(Long taskId, LocalDate startDate); void syncActualDatesFromWorklogs(Long taskId);
/** /**
* 由执行 cancel 触发的级联取消指定执行下所有未终态的顶层任务parentTaskId IS NULL * 由执行 cancel 触发的级联取消指定执行下所有未终态的顶层任务parentTaskId IS NULL

View File

@@ -41,6 +41,7 @@ import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMappe
import com.njcn.rdms.module.project.enums.ErrorCodeConstants; import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.project.framework.attachment.AttachmentFileIdResolver; import com.njcn.rdms.module.project.framework.attachment.AttachmentFileIdResolver;
import com.njcn.rdms.module.project.framework.attachment.AttachmentValidator; import com.njcn.rdms.module.project.framework.attachment.AttachmentValidator;
import com.njcn.rdms.module.project.framework.notify.NotifyJumpConstants;
import com.njcn.rdms.module.project.framework.notify.NotifySendEvent; import com.njcn.rdms.module.project.framework.notify.NotifySendEvent;
import com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants; import com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants;
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission; import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
@@ -65,7 +66,6 @@ import org.springframework.util.StringUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
@@ -90,8 +90,6 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
@Slf4j @Slf4j
public class ProjectTaskServiceImpl implements ProjectTaskService { public class ProjectTaskServiceImpl implements ProjectTaskService {
private static final ZoneId SERVER_ZONE = ZoneId.of("Asia/Shanghai");
@Resource @Resource
private ProjectMapper projectMapper; private ProjectMapper projectMapper;
@Resource @Resource
@@ -137,6 +135,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
@Resource @Resource
private org.springframework.context.ApplicationEventPublisher applicationEventPublisher; private org.springframework.context.ApplicationEventPublisher applicationEventPublisher;
// 权限:方法体内属主放行 + 权限码兜底checkOwnerOrProjectPermission勿挂 @CheckObjectPermission
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public Long createTask(Long projectId, Long executionId, ProjectTaskSaveReqVO reqVO) { public Long createTask(Long projectId, Long executionId, ProjectTaskSaveReqVO reqVO) {
@@ -210,29 +209,26 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
ProjectDO project = projectMapper.selectById(projectId); ProjectDO project = projectMapper.selectById(projectId);
params.put("projectName", project == null ? "" : project.getProjectName()); params.put("projectName", project == null ? "" : project.getProjectName());
params.put("taskName", task.getTaskTitle()); params.put("taskName", task.getTaskTitle());
params.put(NotifyJumpConstants.PARAM_JUMP_TYPE, NotifyJumpConstants.JUMP_TYPE_TASK);
params.put(NotifyJumpConstants.PARAM_PROJECT_ID, String.valueOf(projectId));
applicationEventPublisher.publishEvent(NotifySendEvent.of(recipients, applicationEventPublisher.publishEvent(NotifySendEvent.of(recipients,
NotifyTemplateCodeConstants.TASK_ASSIGNED, params, NotifyTemplateCodeConstants.TASK_ASSIGNED, params,
NotifyMessageLevelConstants.NORMAL, SecurityFrameworkUtils.getLoginUserId())); NotifyMessageLevelConstants.NORMAL, SecurityFrameworkUtils.getLoginUserId()));
} }
// 权限:方法体内属主放行 + 权限码兜底requireTaskEditDeletePermission勿挂 @CheckObjectPermission
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void updateTask(Long projectId, Long executionId, ProjectTaskSaveReqVO reqVO) { public void updateTask(Long projectId, Long executionId, ProjectTaskSaveReqVO reqVO) {
if (reqVO.getId() == null) { if (reqVO.getId() == null) {
throw invalidParamException("任务编号不能为空"); throw invalidParamException("任务编号不能为空");
} }
validateEditableProject(projectId); ProjectDO project = validateEditableProject(projectId);
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId); ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
validateExecutionAllowEdit(execution); validateExecutionAllowEdit(execution);
ProjectTaskDO task = validateTaskExists(projectId, executionId, reqVO.getId()); ProjectTaskDO task = validateTaskExists(projectId, executionId, reqVO.getId());
validateTaskAllowEdit(task); validateTaskAllowEdit(task);
// 上级硬卡:一级任务由"执行负责人 OR 项目负责人(权限码)"改;子任务由"父任务负责人 OR 项目负责人(权限码)"改 requireTaskEditDeletePermission(project, execution, task, ProjectTaskConstants.PERMISSION_UPDATE);
// 按当前 task 的 parentTaskId修改前判定改父亲属于改动结果授权按改动前形态裁决
Long upperOwnerId = task.getParentTaskId() == null
? execution.getOwnerId()
: projectTaskMapper.selectById(task.getParentTaskId()).getOwnerId();
projectObjectAuthorizationService.checkOwnerOrProjectPermission(
projectId, upperOwnerId, ProjectTaskConstants.PERMISSION_UPDATE);
ProjectTaskDO parentTask = validateParentTask(projectId, executionId, reqVO.getParentTaskId()); ProjectTaskDO parentTask = validateParentTask(projectId, executionId, reqVO.getParentTaskId());
if (parentTask != null && Objects.equals(parentTask.getId(), task.getId())) { if (parentTask != null && Objects.equals(parentTask.getId(), task.getId())) {
throw exception(ErrorCodeConstants.PROJECT_TASK_PARENT_INVALID); throw exception(ErrorCodeConstants.PROJECT_TASK_PARENT_INVALID);
@@ -281,14 +277,14 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
task.getStatusCode(), buildTaskFieldChanges(before, task), null); task.getStatusCode(), buildTaskFieldChanges(before, task), null);
} }
// 权限:方法体内属主放行 + 权限码兜底requireTaskEditDeletePermission勿挂 @CheckObjectPermission
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void deleteTask(Long projectId, Long executionId, Long taskId, ProjectTaskDeleteReqVO reqVO) { public void deleteTask(Long projectId, Long executionId, Long taskId, ProjectTaskDeleteReqVO reqVO) {
validateProjectExists(projectId); ProjectDO project = validateProjectExists(projectId);
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId); ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId); ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId);
// 上级硬卡:一级任务由"执行负责人 OR 项目负责人(权限码)"删;子任务由"父任务负责人 OR 项目负责人(权限码)"删 requireTaskEditDeletePermission(project, execution, task, ProjectTaskConstants.PERMISSION_DELETE);
requireDeletePermissionOnTask(projectId, task, execution);
validateDeleteConfirmText(reqVO.getConfirmText()); validateDeleteConfirmText(reqVO.getConfirmText());
if (!Objects.equals(task.getTaskTitle(), reqVO.getTaskName().trim())) { if (!Objects.equals(task.getTaskTitle(), reqVO.getTaskName().trim())) {
throw exception(ErrorCodeConstants.PROJECT_TASK_DELETE_NAME_MISMATCH); throw exception(ErrorCodeConstants.PROJECT_TASK_DELETE_NAME_MISMATCH);
@@ -326,13 +322,14 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
writeTaskAuditLog(task, ObjectActivityConstants.TASK_ACTION_DELETE, fromStatus, null, null, reason); writeTaskAuditLog(task, ObjectActivityConstants.TASK_ACTION_DELETE, fromStatus, null, null, reason);
} }
// 权限:方法体内属主放行 + 权限码兜底requireTaskEditDeletePermission勿挂 @CheckObjectPermission
@Override @Override
public ProjectTaskDeletePrecheckRespVO precheckDeleteTask(Long projectId, Long executionId, Long taskId) { public ProjectTaskDeletePrecheckRespVO precheckDeleteTask(Long projectId, Long executionId, Long taskId) {
validateProjectExists(projectId); ProjectDO project = validateProjectExists(projectId);
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId); ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId); ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId);
// 预检也走"上级硬卡",防止"无权限的人调 precheck 试探下挂数量" // 预检也走同款编辑/删除授权,防止无权限用户调 precheck 试探下挂数量
requireDeletePermissionOnTask(projectId, task, execution); requireTaskEditDeletePermission(project, execution, task, ProjectTaskConstants.PERMISSION_DELETE);
int childTaskCount = projectTaskMapper.countChildrenByParentTaskId(taskId); int childTaskCount = projectTaskMapper.countChildrenByParentTaskId(taskId);
int worklogCount = taskWorklogMapper.countByTaskId(taskId); int worklogCount = taskWorklogMapper.countByTaskId(taskId);
@@ -345,26 +342,32 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
} }
/** /**
* 任务删除"上级硬卡":一级任务由执行负责人 OR 项目负责人(权限码)操作; * 任务编辑/删除授权:项目负责人、执行负责人、任务负责人、父任务负责人均可操作;
* 子任务由父任务负责人 OR 项目负责人(权限码)操作 * 其它用户回落到项目对象角色权限码,作为管理员兜底
* <p>同时被 deleteTask 与 precheckDeleteTask 使用,保证删除路径与预检入口走同款权限通道。 *
* <p>红线调用本方法的入口updateTask / deleteTask / precheckDeleteTask<b>故意不挂
* {@code @CheckObjectPermission}</b>——注解是前置拦截,会把"无权限码的负责人"挡死。
* 不要误判为漏鉴权把注解补回去;口径见 CLAUDE.md §鉴权。
*/ */
private void requireDeletePermissionOnTask(Long projectId, ProjectTaskDO task, ProjectExecutionDO execution) { private void requireTaskEditDeletePermission(ProjectDO project, ProjectExecutionDO execution,
Long upperOwnerId; ProjectTaskDO task, String permission) {
if (task.getParentTaskId() == null) { List<Long> ownerUserIds = new ArrayList<>();
upperOwnerId = execution.getOwnerId(); addIfNotNull(ownerUserIds, project.getManagerUserId());
} else { addIfNotNull(ownerUserIds, execution.getOwnerId());
addIfNotNull(ownerUserIds, task.getOwnerId());
if (task.getParentTaskId() != null) {
ProjectTaskDO parent = projectTaskMapper.selectById(task.getParentTaskId()); ProjectTaskDO parent = projectTaskMapper.selectById(task.getParentTaskId());
if (parent == null) { if (parent != null) {
// 父任务被并发硬删的极端场景:当前任务已是孤儿,按"无上级负责人"处理 addIfNotNull(ownerUserIds, parent.getOwnerId());
// 仍要求调用方持有项目级权限码,由 checkOwnerOrProjectPermission 兜底拦截
upperOwnerId = null;
} else {
upperOwnerId = parent.getOwnerId();
} }
} }
projectObjectAuthorizationService.checkOwnerOrProjectPermission( projectObjectAuthorizationService.checkAnyOwnerOrProjectPermission(project.getId(), ownerUserIds, permission);
projectId, upperOwnerId, ProjectTaskConstants.PERMISSION_DELETE); }
private void addIfNotNull(Collection<Long> values, Long value) {
if (value != null) {
values.add(value);
}
} }
private void validateDeleteConfirmText(String confirmText) { private void validateDeleteConfirmText(String confirmText) {
@@ -447,7 +450,6 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
writeTaskStatusLog(current, actionCode, fromStatus, toStatus, reason); writeTaskStatusLog(current, actionCode, fromStatus, toStatus, reason);
writeTaskAuditLog(current, actionCode, fromStatus, toStatus, null, reason); writeTaskAuditLog(current, actionCode, fromStatus, toStatus, null, reason);
maybeFillActualDates(current, fromStatus, toStatus);
// 通知执行:任务自动开始也属于"任务状态变更"事件,按 §6.4 触发表上报 // 通知执行:任务自动开始也属于"任务状态变更"事件,按 §6.4 触发表上报
projectExecutionService.onTaskStatusChanged( projectExecutionService.onTaskStatusChanged(
current.getExecutionId(), current.getId(), fromStatus, toStatus, actionCode, reason); current.getExecutionId(), current.getId(), fromStatus, toStatus, actionCode, reason);
@@ -455,17 +457,25 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void fillActualStartDateIfAbsent(Long taskId, LocalDate startDate) { public void syncActualDatesFromWorklogs(Long taskId) {
if (taskId == null || startDate == null) { if (taskId == null) {
return; return;
} }
ProjectTaskDO current = projectTaskMapper.selectById(taskId); ProjectTaskDO task = projectTaskMapper.selectById(taskId);
// 仅在实际开始时间为空时回填;已有值(含 internalAutoStartByWorklog 已写过)则保持不变 if (task == null) {
if (current == null || current.getActualStartDate() != null) {
return; return;
} }
// 只动开始日期:实际结束日期传 null依据全局 FieldStrategy 不会被覆盖 TaskWorklogDO earliestWorklog = taskWorklogMapper.selectEarliestByTaskId(taskId);
projectTaskMapper.updateActualDatesById(taskId, startDate, null); TaskWorklogDO latestOwnerCompletedWorklog = task.getOwnerId() == null
? null
: taskWorklogMapper.selectLatestCompletedByTaskIdAndUserId(taskId, task.getOwnerId());
LocalDate actualStartDate = earliestWorklog == null ? null : earliestWorklog.getStartDate();
LocalDate actualEndDate = latestOwnerCompletedWorklog == null ? null : latestOwnerCompletedWorklog.getEndDate();
if (Objects.equals(task.getActualStartDate(), actualStartDate)
&& Objects.equals(task.getActualEndDate(), actualEndDate)) {
return;
}
projectTaskMapper.updateActualDatesById(taskId, actualStartDate, actualEndDate);
} }
@Override @Override
@@ -728,41 +738,6 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|| "resume".equals(actionCode); || "resume".equals(actionCode);
} }
/**
* 根据通用状态语义位推导实际开始/结束日期:
* - 首次离开初始态fromStatus.initialFlag=true且未填写时写入 actualStartDate
* - 进入终态toStatus.terminalFlag=true且未填写时写入 actualEndDate
*/
private void maybeFillActualDates(ProjectTaskDO task, String fromStatus, String toStatus) {
LocalDate today = LocalDate.now(SERVER_ZONE);
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) { private ProjectDO validateEditableProject(Long projectId) {
ProjectDO project = validateProjectExists(projectId); ProjectDO project = validateProjectExists(projectId);
ObjectStatusModelDO statusModel = objectStatusModelMapper ObjectStatusModelDO statusModel = objectStatusModelMapper
@@ -916,7 +891,6 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
writeTaskStatusLog(task, actionCode, fromStatus, toStatus, reason); writeTaskStatusLog(task, actionCode, fromStatus, toStatus, reason);
writeTaskAuditLog(task, actionCode, fromStatus, toStatus, null, reason); writeTaskAuditLog(task, actionCode, fromStatus, toStatus, null, reason);
maybeFillActualDates(task, fromStatus, toStatus);
// 完成动作:兜底把任务进度刷到 100%,并触发父任务 AVG 重算forceCompleteProgress 内部触发项目刷新) // 完成动作:兜底把任务进度刷到 100%,并触发父任务 AVG 重算forceCompleteProgress 内部触发项目刷新)
if ("complete".equals(actionCode)) { if ("complete".equals(actionCode)) {

View File

@@ -27,6 +27,7 @@ import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
import com.njcn.rdms.module.project.framework.attachment.AttachmentFileIdResolver; import com.njcn.rdms.module.project.framework.attachment.AttachmentFileIdResolver;
import com.njcn.rdms.module.project.framework.attachment.AttachmentValidator; import com.njcn.rdms.module.project.framework.attachment.AttachmentValidator;
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission; 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.ProjectTaskService; import com.njcn.rdms.module.project.service.project.task.ProjectTaskService;
import com.njcn.rdms.module.system.api.dict.DictDataApi; import com.njcn.rdms.module.system.api.dict.DictDataApi;
import com.njcn.rdms.module.system.api.user.AdminUserApi; import com.njcn.rdms.module.system.api.user.AdminUserApi;
@@ -70,6 +71,8 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
private DictDataApi dictDataApi; private DictDataApi dictDataApi;
@Resource @Resource
private AttachmentFileIdResolver attachmentFileIdResolver; private AttachmentFileIdResolver attachmentFileIdResolver;
@Resource
private ProjectService projectService;
/** /**
* 与 ProjectTaskService 互相依赖ProjectTaskService 也注入本类),用 @Lazy 打破循环。 * 与 ProjectTaskService 互相依赖ProjectTaskService 也注入本类),用 @Lazy 打破循环。
* 仅用于 owner 工时变更后同步任务自身进度并触发父任务汇总。 * 仅用于 owner 工时变更后同步任务自身进度并触发父任务汇总。
@@ -118,10 +121,8 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
TaskWorklogDO worklog = buildWorklog(taskId, loginUserId, reqVO); TaskWorklogDO worklog = buildWorklog(taskId, loginUserId, reqVO);
taskWorklogMapper.insert(worklog); taskWorklogMapper.insert(worklog);
// 实际开始时间为空时用本条工作日志的开始日期回填owner / 协办人一致,不限任务当前状态)。 projectTaskService.syncActualDatesFromWorklogs(taskId);
// 必须在 internalAutoStartByWorklog 之前:先把开始日期落库,自动开始里的回填钩子见到非空值即跳过, projectService.syncActualStartDateFromWorklogs(projectId);
// 不会再用"提交当天"覆盖工作日志填写的开始日期。
projectTaskService.fillActualStartDateIfAbsent(taskId, reqVO.getStartDate());
// 任意填报人owner / 协办人)都触发任务自动开始(仅当任务仍处初始态时生效),推进任务状态。 // 任意填报人owner / 协办人)都触发任务自动开始(仅当任务仍处初始态时生效),推进任务状态。
// 任务"是否开始"是客观事实,协办人开工同样代表任务已开始,不应等 owner 补工时才反映。 // 任务"是否开始"是客观事实,协办人开工同样代表任务已开始,不应等 owner 补工时才反映。
projectTaskService.internalAutoStartByWorklog(task); projectTaskService.internalAutoStartByWorklog(task);
@@ -165,6 +166,8 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
update.setWorkContent(normalizeNullableText(reqVO.getWorkContent())); update.setWorkContent(normalizeNullableText(reqVO.getWorkContent()));
update.setAttachments(reqVO.getAttachments()); update.setAttachments(reqVO.getAttachments());
taskWorklogMapper.updateById(update); taskWorklogMapper.updateById(update);
projectTaskService.syncActualDatesFromWorklogs(taskId);
projectService.syncActualStartDateFromWorklogs(projectId);
// owner 改自己工时:按"owner 最新一条"重算任务进度 // owner 改自己工时:按"owner 最新一条"重算任务进度
if (Objects.equals(worklog.getUserId(), task.getOwnerId())) { if (Objects.equals(worklog.getUserId(), task.getOwnerId())) {
projectTaskService.syncOwnerProgressFromLatestWorklog(taskId); projectTaskService.syncOwnerProgressFromLatestWorklog(taskId);
@@ -186,6 +189,8 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DELETE_FORBIDDEN); throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DELETE_FORBIDDEN);
} }
taskWorklogMapper.deleteById(worklog.getId()); taskWorklogMapper.deleteById(worklog.getId());
projectTaskService.syncActualDatesFromWorklogs(taskId);
projectService.syncActualStartDateFromWorklogs(projectId);
// 删的是 owner 自己的工时:按"owner 剩余最新一条"重算任务进度; // 删的是 owner 自己的工时:按"owner 剩余最新一条"重算任务进度;
// 若 owner 已无任何工时syncOwnerProgressFromLatestWorklog 内部会保留原值不动(不归零) // 若 owner 已无任何工时syncOwnerProgressFromLatestWorklog 内部会保留原值不动(不归零)
if (Objects.equals(worklog.getUserId(), task.getOwnerId())) { if (Objects.equals(worklog.getUserId(), task.getOwnerId())) {

View File

@@ -0,0 +1,15 @@
package com.njcn.rdms.module.project.service.signoff;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
public interface EmployeeSignOffStatusService {
String getInitialStatusCode();
String statusName(String statusCode);
ObjectStatusTransitionDO validateTransition(String fromStatus, String actionCode, String reason);
ObjectStatusModelDO validateStatusModel(String statusCode);
}

View File

@@ -0,0 +1,66 @@
package com.njcn.rdms.module.project.service.signoff;
import com.njcn.rdms.module.project.constant.SignOffConstants;
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 com.njcn.rdms.module.project.service.status.StatusActionTextResolver;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
@Service
public class EmployeeSignOffStatusServiceImpl implements EmployeeSignOffStatusService {
@Resource
private ObjectStatusModelMapper objectStatusModelMapper;
@Resource
private ObjectStatusTransitionMapper objectStatusTransitionMapper;
@Resource
private StatusActionTextResolver statusActionTextResolver;
@Override
public String getInitialStatusCode() {
ObjectStatusModelDO model = objectStatusModelMapper.selectInitialByObjectTypeEnabled(SignOffConstants.STATUS_OBJECT_TYPE);
if (model == null || !StringUtils.hasText(model.getStatusCode())) {
throw exception(ErrorCodeConstants.SIGN_OFF_STATUS_MODEL_NOT_EXISTS_OR_DISABLED);
}
return model.getStatusCode();
}
@Override
public String statusName(String statusCode) {
return statusActionTextResolver.statusName(SignOffConstants.STATUS_OBJECT_TYPE, statusCode);
}
@Override
public ObjectStatusTransitionDO validateTransition(String fromStatus, String actionCode, String reason) {
ObjectStatusTransitionDO transition = objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction(
SignOffConstants.STATUS_OBJECT_TYPE, fromStatus, actionCode);
if (transition == null) {
throw exception(ErrorCodeConstants.SIGN_OFF_STATUS_ACTION_NOT_ALLOWED,
statusActionTextResolver.statusName(SignOffConstants.STATUS_OBJECT_TYPE, fromStatus),
statusActionTextResolver.actionName(SignOffConstants.STATUS_OBJECT_TYPE, actionCode));
}
if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(reason)) {
throw exception(ErrorCodeConstants.SIGN_OFF_STATUS_ACTION_REASON_REQUIRED,
statusActionTextResolver.actionName(SignOffConstants.STATUS_OBJECT_TYPE, actionCode));
}
validateStatusModel(transition.getToStatusCode());
return transition;
}
@Override
public ObjectStatusModelDO validateStatusModel(String statusCode) {
ObjectStatusModelDO model = objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled(
SignOffConstants.STATUS_OBJECT_TYPE, statusCode);
if (model == null) {
throw exception(ErrorCodeConstants.SIGN_OFF_STATUS_MODEL_NOT_EXISTS_OR_DISABLED);
}
return model;
}
}

View File

@@ -6,6 +6,7 @@ import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.common.util.object.BeanUtils; import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ProjectObjectConstants; import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
import com.njcn.rdms.module.project.constant.SignOffConstants;
import com.njcn.rdms.module.project.constant.WorkReportConstants; import com.njcn.rdms.module.project.constant.WorkReportConstants;
import com.njcn.rdms.module.project.framework.notify.NotifySendEvent; import com.njcn.rdms.module.project.framework.notify.NotifySendEvent;
import com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants; import com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants;
@@ -24,6 +25,8 @@ import com.njcn.rdms.module.project.dal.dataobject.workreport.common.WorkReportM
import com.njcn.rdms.module.project.dal.dataobject.workreport.common.WorkReportStatusLogDO; import com.njcn.rdms.module.project.dal.dataobject.workreport.common.WorkReportStatusLogDO;
import com.njcn.rdms.module.project.dal.dataobject.workreport.monthly.MonthlyReportApprovalRecordDO; import com.njcn.rdms.module.project.dal.dataobject.workreport.monthly.MonthlyReportApprovalRecordDO;
import com.njcn.rdms.module.project.dal.dataobject.workreport.monthly.MonthlyReportDO; import com.njcn.rdms.module.project.dal.dataobject.workreport.monthly.MonthlyReportDO;
import com.njcn.rdms.module.project.dal.dataobject.workreport.monthly.MonthlyReportSignOffDO;
import com.njcn.rdms.module.project.dal.dataobject.workreport.monthly.MonthlyReportSignOffRecordDO;
import com.njcn.rdms.module.project.dal.dataobject.workreport.project.ProjectReportApprovalRecordDO; import com.njcn.rdms.module.project.dal.dataobject.workreport.project.ProjectReportApprovalRecordDO;
import com.njcn.rdms.module.project.dal.dataobject.workreport.project.ProjectReportCurrentItemDO; import com.njcn.rdms.module.project.dal.dataobject.workreport.project.ProjectReportCurrentItemDO;
import com.njcn.rdms.module.project.dal.dataobject.workreport.project.ProjectReportDO; import com.njcn.rdms.module.project.dal.dataobject.workreport.project.ProjectReportDO;
@@ -41,6 +44,8 @@ import com.njcn.rdms.module.project.dal.mysql.workreport.common.PersonalReportRe
import com.njcn.rdms.module.project.dal.mysql.workreport.common.WorkReportStatusLogMapper; import com.njcn.rdms.module.project.dal.mysql.workreport.common.WorkReportStatusLogMapper;
import com.njcn.rdms.module.project.dal.mysql.workreport.monthly.MonthlyReportApprovalRecordMapper; import com.njcn.rdms.module.project.dal.mysql.workreport.monthly.MonthlyReportApprovalRecordMapper;
import com.njcn.rdms.module.project.dal.mysql.workreport.monthly.MonthlyReportMapper; import com.njcn.rdms.module.project.dal.mysql.workreport.monthly.MonthlyReportMapper;
import com.njcn.rdms.module.project.dal.mysql.workreport.monthly.MonthlyReportSignOffMapper;
import com.njcn.rdms.module.project.dal.mysql.workreport.monthly.MonthlyReportSignOffRecordMapper;
import com.njcn.rdms.module.project.dal.mysql.workreport.project.ProjectReportApprovalRecordMapper; import com.njcn.rdms.module.project.dal.mysql.workreport.project.ProjectReportApprovalRecordMapper;
import com.njcn.rdms.module.project.dal.mysql.workreport.project.ProjectReportCurrentItemMapper; import com.njcn.rdms.module.project.dal.mysql.workreport.project.ProjectReportCurrentItemMapper;
import com.njcn.rdms.module.project.dal.mysql.workreport.project.ProjectReportMapper; import com.njcn.rdms.module.project.dal.mysql.workreport.project.ProjectReportMapper;
@@ -49,10 +54,12 @@ import com.njcn.rdms.module.project.dal.mysql.workreport.weekly.WeeklyReportAppr
import com.njcn.rdms.module.project.dal.mysql.workreport.weekly.WeeklyReportMapper; import com.njcn.rdms.module.project.dal.mysql.workreport.weekly.WeeklyReportMapper;
import com.njcn.rdms.module.project.dal.mysql.workreport.weekly.WeeklyReportTravelSegmentMapper; import com.njcn.rdms.module.project.dal.mysql.workreport.weekly.WeeklyReportTravelSegmentMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants; import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.project.service.signoff.EmployeeSignOffStatusService;
import com.njcn.rdms.module.project.service.status.StatusActionTextResolver; import com.njcn.rdms.module.project.service.status.StatusActionTextResolver;
import com.njcn.rdms.module.project.service.team.TeamDashboardAccessService; import com.njcn.rdms.module.project.service.team.TeamDashboardAccessService;
import com.njcn.rdms.module.system.api.dept.DeptApi; import com.njcn.rdms.module.system.api.dept.DeptApi;
import com.njcn.rdms.module.system.api.dept.PostApi; import com.njcn.rdms.module.system.api.dept.PostApi;
import com.njcn.rdms.module.system.api.user.UserSignatureApi;
import com.njcn.rdms.module.system.api.dept.dto.DeptRespDTO; import com.njcn.rdms.module.system.api.dept.dto.DeptRespDTO;
import com.njcn.rdms.module.system.api.dept.dto.PostRespDTO; import com.njcn.rdms.module.system.api.dept.dto.PostRespDTO;
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi; import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
@@ -60,6 +67,7 @@ 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.AdminUserApi;
import com.njcn.rdms.module.system.api.user.UserManagementRelationApi; import com.njcn.rdms.module.system.api.user.UserManagementRelationApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO; import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import com.njcn.rdms.module.system.api.user.dto.UserSignatureRespDTO;
import com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants; import com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisher;
@@ -109,6 +117,10 @@ public class WorkReportCommonService {
@Resource @Resource
private MonthlyReportApprovalRecordMapper monthlyReportApprovalRecordMapper; private MonthlyReportApprovalRecordMapper monthlyReportApprovalRecordMapper;
@Resource @Resource
private MonthlyReportSignOffMapper monthlyReportSignOffMapper;
@Resource
private MonthlyReportSignOffRecordMapper monthlyReportSignOffRecordMapper;
@Resource
private ProjectReportApprovalRecordMapper projectReportApprovalRecordMapper; private ProjectReportApprovalRecordMapper projectReportApprovalRecordMapper;
@Resource @Resource
private WorkReportStatusLogMapper workReportStatusLogMapper; private WorkReportStatusLogMapper workReportStatusLogMapper;
@@ -121,6 +133,8 @@ public class WorkReportCommonService {
@Resource @Resource
private StatusActionTextResolver statusActionTextResolver; private StatusActionTextResolver statusActionTextResolver;
@Resource @Resource
private EmployeeSignOffStatusService employeeSignOffStatusService;
@Resource
private AdminUserApi adminUserApi; private AdminUserApi adminUserApi;
@Resource @Resource
private DeptApi deptApi; private DeptApi deptApi;
@@ -129,6 +143,8 @@ public class WorkReportCommonService {
@Resource @Resource
private UserManagementRelationApi userManagementRelationApi; private UserManagementRelationApi userManagementRelationApi;
@Resource @Resource
private UserSignatureApi userSignatureApi;
@Resource
private ObjectPermissionApi objectPermissionApi; private ObjectPermissionApi objectPermissionApi;
@Resource @Resource
private ProjectMapper projectMapper; private ProjectMapper projectMapper;
@@ -368,6 +384,7 @@ public class WorkReportCommonService {
MonthlyReportDO report = new MonthlyReportDO(); MonthlyReportDO report = new MonthlyReportDO();
applyMonthlySaveFields(report, reqVO, profile); applyMonthlySaveFields(report, reqVO, profile);
report.setStatusCode(getInitialStatusCode()); report.setStatusCode(getInitialStatusCode());
report.setSignOffStatus(SignOffConstants.STATUS_NOT_STARTED);
monthlyReportMapper.insert(report); monthlyReportMapper.insert(report);
replaceMonthlyChildren(report.getId(), reqVO); replaceMonthlyChildren(report.getId(), reqVO);
MonthlyReportDO latest = monthlyReportMapper.selectById(report.getId()); MonthlyReportDO latest = monthlyReportMapper.selectById(report.getId());
@@ -439,6 +456,29 @@ public class WorkReportCommonService {
.collect(Collectors.toList()), pageResult.getTotal()); .collect(Collectors.toList()), pageResult.getTotal());
} }
public PageResult<MonthlyReportRespVO> getMonthlySignOffPage(MonthlyReportPageReqVO reqVO) {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
reqVO.setStatusCode(WorkReportConstants.STATUS_APPROVED);
reqVO.setSignOffStatus(SignOffConstants.STATUS_PENDING_EMPLOYEE_CONFIRM);
PageResult<MonthlyReportDO> pageResult = monthlyReportMapper.selectReporterPage(loginUserId, reqVO,
List.of(WorkReportConstants.STATUS_APPROVED));
return new PageResult<>(pageResult.getList().stream()
.map(report -> toMonthlyRespVO(report, false))
.collect(Collectors.toList()), pageResult.getTotal());
}
public MonthlyReportSignOffDetailRespVO getMonthlySignOffDetail(Long id) {
MonthlyReportDO report = validateMonthlyReportExists(id);
validateReporter(SecurityFrameworkUtils.getLoginUserId(), report.getReporterId());
MonthlyReportSignOffDetailRespVO respVO = new MonthlyReportSignOffDetailRespVO();
respVO.setReport(toMonthlyRespVO(report, true));
MonthlyReportApprovalRecordDO latestApprovalRecord = monthlyReportApprovalRecordMapper
.selectLatestApprovedByMonthlyReportId(id);
respVO.setLatestApprovalRecord(latestApprovalRecord == null ? null
: BeanUtils.toBean(latestApprovalRecord, MonthlyReportApprovalRecordRespVO.class));
return respVO;
}
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void submitMonthlyReport(Long id) { public void submitMonthlyReport(Long id) {
MonthlyReportDO current = validateMonthlyReportExists(id); MonthlyReportDO current = validateMonthlyReportExists(id);
@@ -478,6 +518,55 @@ public class WorkReportCommonService {
processMonthlyApproval(id, WorkReportConstants.ACTION_REJECT, rejectReqVO); processMonthlyApproval(id, WorkReportConstants.ACTION_REJECT, rejectReqVO);
} }
@Transactional(rollbackFor = Exception.class)
public void confirmMonthlySignOff(Long id, MonthlyReportSignOffConfirmReqVO reqVO) {
MonthlyReportDO current = validateMonthlyReportExists(id);
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
validateReporter(loginUserId, current.getReporterId());
if (!WorkReportConstants.STATUS_APPROVED.equals(current.getStatusCode())
|| !SignOffConstants.STATUS_PENDING_EMPLOYEE_CONFIRM.equals(current.getSignOffStatus())) {
throw exception(ErrorCodeConstants.WORK_REPORT_SIGN_OFF_NOT_ALLOWED);
}
MonthlyReportSignOffDO signOff = validateMonthlySignOffExists(current.getId());
employeeSignOffStatusService.validateTransition(signOff.getStatusCode(), SignOffConstants.ACTION_CONFIRM, null);
MonthlyReportApprovalRecordDO approvalRecord = monthlyReportApprovalRecordMapper
.selectLatestApprovedByMonthlyReportId(current.getId());
if (approvalRecord == null) {
throw exception(ErrorCodeConstants.WORK_REPORT_MONTHLY_SIGN_OFF_APPROVAL_RECORD_NOT_EXISTS);
}
UserSignatureRespDTO userSignature = getUserSignature(loginUserId);
LocalDate signedDate = reqVO.getEmployeeSignedDate() != null ? reqVO.getEmployeeSignedDate() : LocalDate.now();
MonthlyReportApprovalRecordDO approvalUpdate = new MonthlyReportApprovalRecordDO();
approvalUpdate.setId(approvalRecord.getId());
approvalUpdate.setMeetingDate(reqVO.getMeetingDate());
approvalUpdate.setEmployeeSignName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
approvalUpdate.setEmployeeSignatureFileId(userSignature == null ? null : userSignature.getFileId());
approvalUpdate.setEmployeeSignedDate(signedDate);
monthlyReportApprovalRecordMapper.updateById(approvalUpdate);
MonthlyReportDO reportUpdate = new MonthlyReportDO();
reportUpdate.setSignOffStatus(SignOffConstants.STATUS_EMPLOYEE_CONFIRMED);
int reportUpdateCount = monthlyReportMapper.updateByIdAndStatusAndSignOffStatus(reportUpdate, current.getId(),
current.getStatusCode(), current.getSignOffStatus());
if (reportUpdateCount != 1) {
throw exception(ErrorCodeConstants.WORK_REPORT_STATUS_CONCURRENT_MODIFIED);
}
MonthlyReportSignOffDO signOffUpdate = new MonthlyReportSignOffDO();
signOffUpdate.setStatusCode(SignOffConstants.STATUS_EMPLOYEE_CONFIRMED);
signOffUpdate.setConfirmUserId(loginUserId);
signOffUpdate.setConfirmUserName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
signOffUpdate.setConfirmTime(LocalDateTime.now());
int signOffUpdateCount = monthlyReportSignOffMapper.updateByMonthlyReportIdAndStatus(signOffUpdate,
current.getId(), signOff.getStatusCode());
if (signOffUpdateCount != 1) {
throw exception(ErrorCodeConstants.WORK_REPORT_STATUS_CONCURRENT_MODIFIED);
}
writeMonthlySignOffRecord(signOff.getId(), current.getId(), approvalRecord.getId(), signOff.getBatchNo(),
SignOffConstants.ACTION_CONFIRM, signOff.getStatusCode(), SignOffConstants.STATUS_EMPLOYEE_CONFIRMED);
}
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void deleteMonthlyReport(Long id) { public void deleteMonthlyReport(Long id) {
MonthlyReportDO current = validateMonthlyReportExists(id); MonthlyReportDO current = validateMonthlyReportExists(id);
@@ -513,6 +602,89 @@ public class WorkReportCommonService {
return BeanUtils.toBean(getMonthlyReportPage(reqVO).getList(), MonthlyReportExportVO.class); return BeanUtils.toBean(getMonthlyReportPage(reqVO).getList(), MonthlyReportExportVO.class);
} }
public List<MonthlyReportDO> listMonthlyReportsForSignOff(Collection<Long> reporterIds, String periodMonth) {
return monthlyReportMapper.selectListByReporterIdsAndPeriodKey(reporterIds, periodMonth,
List.of(WorkReportConstants.STATUS_APPROVED));
}
public void issueMonthlySignOff(List<MonthlyReportDO> reports, Long batchNo) {
if (reports == null || reports.isEmpty()) {
throw exception(ErrorCodeConstants.WORK_REPORT_MONTHLY_SIGN_OFF_CANDIDATE_EMPTY);
}
Long operatorUserId = SecurityFrameworkUtils.getLoginUserId();
String operatorName = defaultText(SecurityFrameworkUtils.getLoginUserNickname());
LocalDateTime issueTime = LocalDateTime.now();
for (MonthlyReportDO report : reports) {
if (!WorkReportConstants.STATUS_APPROVED.equals(report.getStatusCode())
|| !isIssueAllowed(report.getSignOffStatus())) {
throw exception(ErrorCodeConstants.WORK_REPORT_SIGN_OFF_NOT_ALLOWED);
}
MonthlyReportApprovalRecordDO approvalRecord = monthlyReportApprovalRecordMapper
.selectLatestApprovedByMonthlyReportId(report.getId());
if (approvalRecord == null) {
throw exception(ErrorCodeConstants.WORK_REPORT_MONTHLY_SIGN_OFF_APPROVAL_RECORD_NOT_EXISTS);
}
MonthlyReportDO reportUpdate = new MonthlyReportDO();
reportUpdate.setSignOffStatus(SignOffConstants.STATUS_PENDING_EMPLOYEE_CONFIRM);
int reportUpdateCount = monthlyReportMapper.updateByIdAndStatusAndSignOffStatus(reportUpdate, report.getId(),
report.getStatusCode(), report.getSignOffStatus());
if (reportUpdateCount != 1) {
throw exception(ErrorCodeConstants.WORK_REPORT_STATUS_CONCURRENT_MODIFIED);
}
MonthlyReportSignOffDO currentSignOff = monthlyReportSignOffMapper.selectByMonthlyReportId(report.getId());
if (currentSignOff == null) {
MonthlyReportSignOffDO signOff = new MonthlyReportSignOffDO();
signOff.setMonthlyReportId(report.getId());
signOff.setBatchNo(batchNo);
signOff.setStatusCode(SignOffConstants.STATUS_PENDING_EMPLOYEE_CONFIRM);
signOff.setIssueUserId(operatorUserId);
signOff.setIssueUserName(operatorName);
signOff.setIssueTime(issueTime);
monthlyReportSignOffMapper.insert(signOff);
writeMonthlySignOffRecord(signOff.getId(), report.getId(), approvalRecord.getId(), batchNo,
SignOffConstants.ACTION_ISSUE, SignOffConstants.STATUS_NOT_STARTED,
SignOffConstants.STATUS_PENDING_EMPLOYEE_CONFIRM);
} else {
employeeSignOffStatusService.validateTransition(currentSignOff.getStatusCode(), SignOffConstants.ACTION_ISSUE, null);
MonthlyReportSignOffDO signOffUpdate = new MonthlyReportSignOffDO();
signOffUpdate.setStatusCode(SignOffConstants.STATUS_PENDING_EMPLOYEE_CONFIRM);
signOffUpdate.setBatchNo(batchNo);
signOffUpdate.setIssueUserId(operatorUserId);
signOffUpdate.setIssueUserName(operatorName);
signOffUpdate.setIssueTime(issueTime);
signOffUpdate.setConfirmUserId(null);
signOffUpdate.setConfirmUserName(null);
signOffUpdate.setConfirmTime(null);
int signOffUpdateCount = monthlyReportSignOffMapper.updateByMonthlyReportIdAndStatus(signOffUpdate,
report.getId(), currentSignOff.getStatusCode());
if (signOffUpdateCount != 1) {
throw exception(ErrorCodeConstants.WORK_REPORT_STATUS_CONCURRENT_MODIFIED);
}
writeMonthlySignOffRecord(currentSignOff.getId(), report.getId(), approvalRecord.getId(), batchNo,
SignOffConstants.ACTION_ISSUE, currentSignOff.getStatusCode(),
SignOffConstants.STATUS_PENDING_EMPLOYEE_CONFIRM);
}
}
}
public void syncPerformanceResult(Long reporterId, String periodMonth, String performanceResult) {
MonthlyReportDO report = monthlyReportMapper.selectByReporterIdAndPeriodKey(reporterId, periodMonth);
if (report == null) {
throw exception(ErrorCodeConstants.PERFORMANCE_SHEET_MONTHLY_REPORT_NOT_EXISTS);
}
MonthlyReportApprovalRecordDO approvalRecord = monthlyReportApprovalRecordMapper
.selectLatestApprovedByMonthlyReportId(report.getId());
if (approvalRecord == null) {
throw exception(ErrorCodeConstants.PERFORMANCE_SHEET_MONTHLY_APPROVAL_RECORD_NOT_EXISTS);
}
MonthlyReportApprovalRecordDO update = new MonthlyReportApprovalRecordDO();
update.setId(approvalRecord.getId());
update.setPerformanceResult(performanceResult);
monthlyReportApprovalRecordMapper.updateById(update);
}
public ProjectReportRespVO initProjectReport(Long projectId) { public ProjectReportRespVO initProjectReport(Long projectId) {
CurrentUserProfile profile = loadCurrentUserProfile(true); CurrentUserProfile profile = loadCurrentUserProfile(true);
ProjectDO project = validateProjectExists(projectId); ProjectDO project = validateProjectExists(projectId);
@@ -810,6 +982,14 @@ public class WorkReportCommonService {
return report; return report;
} }
private MonthlyReportSignOffDO validateMonthlySignOffExists(Long monthlyReportId) {
MonthlyReportSignOffDO signOff = monthlyReportSignOffMapper.selectByMonthlyReportId(monthlyReportId);
if (signOff == null) {
throw exception(ErrorCodeConstants.WORK_REPORT_MONTHLY_SIGN_OFF_NOT_EXISTS);
}
return signOff;
}
private ProjectReportDO validateReadableProjectReport(Long id) { private ProjectReportDO validateReadableProjectReport(Long id) {
ProjectReportDO report = validateProjectReportExists(id); ProjectReportDO report = validateProjectReportExists(id);
validateReadable(report.getProjectOwnerId(), report.getSupervisorUserId()); validateReadable(report.getProjectOwnerId(), report.getSupervisorUserId());
@@ -1438,6 +1618,7 @@ public class WorkReportCommonService {
record.setConclusion(statusLog.getToStatus()); record.setConclusion(statusLog.getToStatus());
record.setOpinion(normalizeReason(reqVO)); record.setOpinion(normalizeReason(reqVO));
if (WorkReportConstants.ACTION_APPROVE.equals(actionCode)) { if (WorkReportConstants.ACTION_APPROVE.equals(actionCode)) {
UserSignatureRespDTO userSignature = getUserSignature(SecurityFrameworkUtils.getLoginUserId());
record.setMeetingDate(reqVO.getMeetingDate()); record.setMeetingDate(reqVO.getMeetingDate());
record.setStrengthDesc(normalizeNullableText(reqVO.getStrengthDesc())); record.setStrengthDesc(normalizeNullableText(reqVO.getStrengthDesc()));
record.setStrengthExample(normalizeNullableText(reqVO.getStrengthExample())); record.setStrengthExample(normalizeNullableText(reqVO.getStrengthExample()));
@@ -1446,15 +1627,32 @@ public class WorkReportCommonService {
record.setImprovementSuggestion(normalizeNullableText(reqVO.getImprovementSuggestion())); record.setImprovementSuggestion(normalizeNullableText(reqVO.getImprovementSuggestion()));
record.setPerformanceResult(normalizeNullableText(reqVO.getPerformanceResult())); record.setPerformanceResult(normalizeNullableText(reqVO.getPerformanceResult()));
record.setEmployeeSignName(normalizeNullableText(reqVO.getEmployeeSignName())); record.setEmployeeSignName(normalizeNullableText(reqVO.getEmployeeSignName()));
record.setEmployeeSignatureFileId(null);
record.setEmployeeSignedDate(reqVO.getEmployeeSignedDate()); record.setEmployeeSignedDate(reqVO.getEmployeeSignedDate());
record.setSupervisorSignName(normalizeNullableText(reqVO.getSupervisorSignName())); record.setSupervisorSignName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
record.setSupervisorSignedDate(reqVO.getSupervisorSignedDate()); record.setSupervisorSignatureFileId(userSignature == null ? null : userSignature.getFileId());
record.setSupervisorSignedDate(LocalDate.now());
} }
record.setAuditorUserId(SecurityFrameworkUtils.getLoginUserId()); record.setAuditorUserId(SecurityFrameworkUtils.getLoginUserId());
record.setAuditorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname())); record.setAuditorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
monthlyReportApprovalRecordMapper.insert(record); monthlyReportApprovalRecordMapper.insert(record);
} }
private void writeMonthlySignOffRecord(Long signOffId, Long monthlyReportId, Long approvalRecordId, Long batchNo,
String actionType, String fromStatus, String toStatus) {
MonthlyReportSignOffRecordDO record = new MonthlyReportSignOffRecordDO();
record.setSignOffId(signOffId);
record.setMonthlyReportId(monthlyReportId);
record.setApprovalRecordId(approvalRecordId);
record.setBatchNo(batchNo);
record.setActionType(actionType);
record.setFromSignOffStatus(fromStatus);
record.setToSignOffStatus(toStatus);
record.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId());
record.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
monthlyReportSignOffRecordMapper.insert(record);
}
private void writeProjectApprovalRecord(ProjectReportDO report, WorkReportStatusLogDO statusLog, String reason) { private void writeProjectApprovalRecord(ProjectReportDO report, WorkReportStatusLogDO statusLog, String reason) {
ProjectReportApprovalRecordDO record = new ProjectReportApprovalRecordDO(); ProjectReportApprovalRecordDO record = new ProjectReportApprovalRecordDO();
record.setProjectReportId(report.getId()); record.setProjectReportId(report.getId());
@@ -1513,6 +1711,11 @@ public class WorkReportCommonService {
private MonthlyReportRespVO toMonthlyRespVO(MonthlyReportDO report, boolean withChildren) { private MonthlyReportRespVO toMonthlyRespVO(MonthlyReportDO report, boolean withChildren) {
MonthlyReportRespVO respVO = BeanUtils.toBean(report, MonthlyReportRespVO.class); MonthlyReportRespVO respVO = BeanUtils.toBean(report, MonthlyReportRespVO.class);
applyStatusView(respVO, getStatusModel(report.getStatusCode())); applyStatusView(respVO, getStatusModel(report.getStatusCode()));
String signOffStatus = StringUtils.hasText(report.getSignOffStatus())
? report.getSignOffStatus()
: SignOffConstants.STATUS_NOT_STARTED;
respVO.setSignOffStatus(signOffStatus);
respVO.setSignOffStatusName(employeeSignOffStatusService.statusName(signOffStatus));
if (withChildren) { if (withChildren) {
respVO.setReviewItems(BeanUtils.toBean( respVO.setReviewItems(BeanUtils.toBean(
personalReportReviewItemMapper.selectListByReport(WorkReportConstants.REPORT_TYPE_MONTHLY, report.getId()), personalReportReviewItemMapper.selectListByReport(WorkReportConstants.REPORT_TYPE_MONTHLY, report.getId()),
@@ -1704,6 +1907,19 @@ public class WorkReportCommonService {
return StringUtils.hasText(value) ? value.trim() : ""; return StringUtils.hasText(value) ? value.trim() : "";
} }
private boolean isIssueAllowed(String signOffStatus) {
return !StringUtils.hasText(signOffStatus)
|| SignOffConstants.STATUS_NOT_STARTED.equals(signOffStatus);
}
private UserSignatureRespDTO getUserSignature(Long userId) {
if (userId == null) {
return null;
}
CommonResult<UserSignatureRespDTO> result = userSignatureApi.getByUserId(userId);
return result == null ? null : result.getCheckedData();
}
private <T> List<T> defaultList(List<T> list) { private <T> List<T> defaultList(List<T> list) {
return list == null ? Collections.emptyList() : list; return list == null ? Collections.emptyList() : list;
} }

View File

@@ -67,6 +67,7 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
public class WorkReportDefaultDraftService { public class WorkReportDefaultDraftService {
private static final String WORK_ITEM_MY_ITEMS = "我的事项"; private static final String WORK_ITEM_MY_ITEMS = "我的事项";
private static final String WORKLOG_DAY_SEPARATOR = "[[WR_DAY_SPLIT]]";
@Resource @Resource
private ObjectStatusModelMapper objectStatusModelMapper; private ObjectStatusModelMapper objectStatusModelMapper;
@@ -759,7 +760,7 @@ public class WorkReportDefaultDraftService {
builder.append(lineTitle); builder.append(lineTitle);
appendBracket(builder, priority, latestProgressRate, totalWorkHours); appendBracket(builder, priority, latestProgressRate, totalWorkHours);
if (!workContents.isEmpty()) { if (!workContents.isEmpty()) {
builder.append("").append(String.join("", workContents)); builder.append("").append(String.join(WORKLOG_DAY_SEPARATOR, workContents));
} }
return builder.toString(); return builder.toString();
} }
@@ -860,7 +861,7 @@ public class WorkReportDefaultDraftService {
builder.append("").append(String.join(" / ", parts)).append(""); builder.append("").append(String.join(" / ", parts)).append("");
} }
if (weeklyMode && worklogAggregate.hasWorkContent()) { if (weeklyMode && worklogAggregate.hasWorkContent()) {
builder.append("").append(String.join("", worklogAggregate.workContents())); builder.append("").append(String.join(WORKLOG_DAY_SEPARATOR, worklogAggregate.workContents()));
} }
return builder.toString(); return builder.toString();
} }

View File

@@ -9,6 +9,8 @@ import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.Month
import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.MonthlyReportRefreshDraftReqVO; import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.MonthlyReportRefreshDraftReqVO;
import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.MonthlyReportRespVO; import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.MonthlyReportRespVO;
import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.MonthlyReportSaveReqVO; import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.MonthlyReportSaveReqVO;
import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.MonthlyReportSignOffConfirmReqVO;
import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.MonthlyReportSignOffDetailRespVO;
import com.njcn.rdms.module.project.controller.admin.workreport.common.vo.WorkReportStatusActionReqVO; import com.njcn.rdms.module.project.controller.admin.workreport.common.vo.WorkReportStatusActionReqVO;
import com.njcn.rdms.module.project.controller.admin.workreport.common.vo.WorkReportStatusLogRespVO; import com.njcn.rdms.module.project.controller.admin.workreport.common.vo.WorkReportStatusLogRespVO;
@@ -32,12 +34,18 @@ public interface MonthlyReportService {
PageResult<MonthlyReportRespVO> getMonthlyApprovalPage(MonthlyReportPageReqVO reqVO); PageResult<MonthlyReportRespVO> getMonthlyApprovalPage(MonthlyReportPageReqVO reqVO);
PageResult<MonthlyReportRespVO> getMonthlySignOffPage(MonthlyReportPageReqVO reqVO);
MonthlyReportSignOffDetailRespVO getMonthlySignOffDetail(Long id);
void submitMonthlyReport(Long id); void submitMonthlyReport(Long id);
void approveMonthlyReport(Long id, MonthlyReportApproveReqVO reqVO); void approveMonthlyReport(Long id, MonthlyReportApproveReqVO reqVO);
void rejectMonthlyReport(Long id, WorkReportStatusActionReqVO reqVO); void rejectMonthlyReport(Long id, WorkReportStatusActionReqVO reqVO);
void confirmMonthlySignOff(Long id, MonthlyReportSignOffConfirmReqVO reqVO);
void deleteMonthlyReport(Long id); void deleteMonthlyReport(Long id);
List<WorkReportStatusLogRespVO> getMonthlyStatusLogs(Long id); List<WorkReportStatusLogRespVO> getMonthlyStatusLogs(Long id);

View File

@@ -9,6 +9,8 @@ import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.Month
import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.MonthlyReportRefreshDraftReqVO; import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.MonthlyReportRefreshDraftReqVO;
import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.MonthlyReportRespVO; import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.MonthlyReportRespVO;
import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.MonthlyReportSaveReqVO; import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.MonthlyReportSaveReqVO;
import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.MonthlyReportSignOffConfirmReqVO;
import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.MonthlyReportSignOffDetailRespVO;
import com.njcn.rdms.module.project.controller.admin.workreport.common.vo.WorkReportStatusActionReqVO; import com.njcn.rdms.module.project.controller.admin.workreport.common.vo.WorkReportStatusActionReqVO;
import com.njcn.rdms.module.project.controller.admin.workreport.common.vo.WorkReportStatusLogRespVO; import com.njcn.rdms.module.project.controller.admin.workreport.common.vo.WorkReportStatusLogRespVO;
import com.njcn.rdms.module.project.service.workreport.common.WorkReportCommonService; import com.njcn.rdms.module.project.service.workreport.common.WorkReportCommonService;
@@ -71,6 +73,16 @@ public class MonthlyReportServiceImpl implements MonthlyReportService {
return workReportCommonService.getMonthlyApprovalPage(reqVO); return workReportCommonService.getMonthlyApprovalPage(reqVO);
} }
@Override
public PageResult<MonthlyReportRespVO> getMonthlySignOffPage(MonthlyReportPageReqVO reqVO) {
return workReportCommonService.getMonthlySignOffPage(reqVO);
}
@Override
public MonthlyReportSignOffDetailRespVO getMonthlySignOffDetail(Long id) {
return workReportCommonService.getMonthlySignOffDetail(id);
}
@Override @Override
public void submitMonthlyReport(Long id) { public void submitMonthlyReport(Long id) {
workReportCommonService.submitMonthlyReport(id); workReportCommonService.submitMonthlyReport(id);
@@ -86,6 +98,11 @@ public class MonthlyReportServiceImpl implements MonthlyReportService {
workReportCommonService.rejectMonthlyReport(id, reqVO); workReportCommonService.rejectMonthlyReport(id, reqVO);
} }
@Override
public void confirmMonthlySignOff(Long id, MonthlyReportSignOffConfirmReqVO reqVO) {
workReportCommonService.confirmMonthlySignOff(id, reqVO);
}
@Override @Override
public void deleteMonthlyReport(Long id) { public void deleteMonthlyReport(Long id) {
workReportCommonService.deleteMonthlyReport(id); workReportCommonService.deleteMonthlyReport(id);

View File

@@ -9,8 +9,13 @@ import com.njcn.rdms.module.project.dal.mysql.workreport.project.ProjectReportMa
import com.njcn.rdms.module.project.dal.mysql.workreport.weekly.WeeklyReportMapper; import com.njcn.rdms.module.project.dal.mysql.workreport.weekly.WeeklyReportMapper;
import com.njcn.rdms.module.project.framework.notify.NotifySendEvent; import com.njcn.rdms.module.project.framework.notify.NotifySendEvent;
import com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants; import com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants;
import com.njcn.rdms.module.system.api.notify.NotifyMessageSendApi;
import com.njcn.rdms.module.system.api.notify.dto.NotifyUnreadMessageListReqDTO;
import com.njcn.rdms.module.system.api.notify.dto.NotifyUnreadMessageRespDTO;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants; import com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -19,11 +24,16 @@ import org.springframework.util.StringUtils;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.temporal.IsoFields; import java.time.temporal.IsoFields;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
@Service @Service
@Slf4j
public class WorkReportPendingSubmitRemindService { public class WorkReportPendingSubmitRemindService {
private static final List<String> REMIND_STATUS_CODES = List.of( private static final List<String> REMIND_STATUS_CODES = List.of(
@@ -38,6 +48,8 @@ public class WorkReportPendingSubmitRemindService {
private ProjectReportMapper projectReportMapper; private ProjectReportMapper projectReportMapper;
@Resource @Resource
private ApplicationEventPublisher applicationEventPublisher; private ApplicationEventPublisher applicationEventPublisher;
@Resource
private NotifyMessageSendApi notifyMessageSendApi;
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void remindPendingSubmitReports() { public void remindPendingSubmitReports() {
@@ -49,14 +61,21 @@ public class WorkReportPendingSubmitRemindService {
private void remindWeekly(LocalDateTime cutoffTime) { private void remindWeekly(LocalDateTime cutoffTime) {
List<WeeklyReportDO> reports = weeklyReportMapper.selectListByPendingSubmitReminder(cutoffTime, REMIND_STATUS_CODES); List<WeeklyReportDO> reports = weeklyReportMapper.selectListByPendingSubmitReminder(cutoffTime, REMIND_STATUS_CODES);
Set<String> unreadKeys = loadUnreadReminderKeys(
reports.stream().map(WeeklyReportDO::getReporterId).toList(),
List.of(NotifyTemplateCodeConstants.WORK_REPORT_TEAM_REMIND));
for (WeeklyReportDO report : reports) { for (WeeklyReportDO report : reports) {
if (report == null || report.getReporterId() == null) { if (report == null || report.getReporterId() == null) {
continue; continue;
} }
if (hasUnreadReminder(report.getReporterId(), WorkReportConstants.REPORT_TYPE_WEEKLY, report.getId(), unreadKeys)) {
continue;
}
Map<String, Object> params = new HashMap<>(); Map<String, Object> params = new HashMap<>();
params.put("reportTypeName", "周报"); params.put("reportTypeName", "周报");
params.put("periodKey", formatWeeklyPeriod(report.getPeriodKey(), report.getPeriodStartDate(), report.getPeriodEndDate())); params.put("periodKey", formatWeeklyPeriod(report.getPeriodKey(), report.getPeriodStartDate(), report.getPeriodEndDate()));
params.put("managerName", defaultText(report.getSupervisorName(), "系统通知")); params.put("managerName", defaultText(report.getSupervisorName(), "系统通知"));
appendRemindIdentity(params, WorkReportConstants.REPORT_TYPE_WEEKLY, report.getId());
applicationEventPublisher.publishEvent(NotifySendEvent.of( applicationEventPublisher.publishEvent(NotifySendEvent.of(
List.of(report.getReporterId()), List.of(report.getReporterId()),
NotifyTemplateCodeConstants.WORK_REPORT_TEAM_REMIND, NotifyTemplateCodeConstants.WORK_REPORT_TEAM_REMIND,
@@ -67,14 +86,21 @@ public class WorkReportPendingSubmitRemindService {
private void remindMonthly(LocalDateTime cutoffTime) { private void remindMonthly(LocalDateTime cutoffTime) {
List<MonthlyReportDO> reports = monthlyReportMapper.selectListByPendingSubmitReminder(cutoffTime, REMIND_STATUS_CODES); List<MonthlyReportDO> reports = monthlyReportMapper.selectListByPendingSubmitReminder(cutoffTime, REMIND_STATUS_CODES);
Set<String> unreadKeys = loadUnreadReminderKeys(
reports.stream().map(MonthlyReportDO::getReporterId).toList(),
List.of(NotifyTemplateCodeConstants.WORK_REPORT_TEAM_REMIND));
for (MonthlyReportDO report : reports) { for (MonthlyReportDO report : reports) {
if (report == null || report.getReporterId() == null) { if (report == null || report.getReporterId() == null) {
continue; continue;
} }
if (hasUnreadReminder(report.getReporterId(), WorkReportConstants.REPORT_TYPE_MONTHLY, report.getId(), unreadKeys)) {
continue;
}
Map<String, Object> params = new HashMap<>(); Map<String, Object> params = new HashMap<>();
params.put("reportTypeName", "月报"); params.put("reportTypeName", "月报");
params.put("periodKey", formatMonthlyPeriod(report.getPeriodKey(), report.getPeriodStartDate())); params.put("periodKey", formatMonthlyPeriod(report.getPeriodKey(), report.getPeriodStartDate()));
params.put("managerName", defaultText(report.getSupervisorName(), "系统通知")); params.put("managerName", defaultText(report.getSupervisorName(), "系统通知"));
appendRemindIdentity(params, WorkReportConstants.REPORT_TYPE_MONTHLY, report.getId());
applicationEventPublisher.publishEvent(NotifySendEvent.of( applicationEventPublisher.publishEvent(NotifySendEvent.of(
List.of(report.getReporterId()), List.of(report.getReporterId()),
NotifyTemplateCodeConstants.WORK_REPORT_TEAM_REMIND, NotifyTemplateCodeConstants.WORK_REPORT_TEAM_REMIND,
@@ -85,14 +111,21 @@ public class WorkReportPendingSubmitRemindService {
private void remindProject(LocalDateTime cutoffTime) { private void remindProject(LocalDateTime cutoffTime) {
List<ProjectReportDO> reports = projectReportMapper.selectListByPendingSubmitReminder(cutoffTime, REMIND_STATUS_CODES); List<ProjectReportDO> reports = projectReportMapper.selectListByPendingSubmitReminder(cutoffTime, REMIND_STATUS_CODES);
Set<String> unreadKeys = loadUnreadReminderKeys(
reports.stream().map(ProjectReportDO::getProjectOwnerId).toList(),
List.of(NotifyTemplateCodeConstants.WORK_REPORT_PROJECT_HALF_MONTH_TEAM_REMIND));
for (ProjectReportDO report : reports) { for (ProjectReportDO report : reports) {
if (report == null || report.getProjectOwnerId() == null) { if (report == null || report.getProjectOwnerId() == null) {
continue; continue;
} }
if (hasUnreadReminder(report.getProjectOwnerId(), WorkReportConstants.REPORT_TYPE_PROJECT, report.getId(), unreadKeys)) {
continue;
}
Map<String, Object> params = new HashMap<>(); Map<String, Object> params = new HashMap<>();
params.put("projectName", defaultText(report.getProjectName(), "")); params.put("projectName", defaultText(report.getProjectName(), ""));
params.put("periodLabel", defaultText(report.getPeriodLabel(), report.getPeriodKey())); params.put("periodLabel", defaultText(report.getPeriodLabel(), report.getPeriodKey()));
params.put("managerName", defaultText(report.getSupervisorName(), "系统通知")); params.put("managerName", defaultText(report.getSupervisorName(), "系统通知"));
appendRemindIdentity(params, WorkReportConstants.REPORT_TYPE_PROJECT, report.getId());
applicationEventPublisher.publishEvent(NotifySendEvent.of( applicationEventPublisher.publishEvent(NotifySendEvent.of(
List.of(report.getProjectOwnerId()), List.of(report.getProjectOwnerId()),
NotifyTemplateCodeConstants.WORK_REPORT_PROJECT_HALF_MONTH_TEAM_REMIND, NotifyTemplateCodeConstants.WORK_REPORT_PROJECT_HALF_MONTH_TEAM_REMIND,
@@ -119,6 +152,58 @@ public class WorkReportPendingSubmitRemindService {
return startDate.getYear() + "" + startDate.getMonthValue() + ""; return startDate.getYear() + "" + startDate.getMonthValue() + "";
} }
private Set<String> loadUnreadReminderKeys(Collection<Long> userIds, List<String> templateCodes) {
if (userIds == null || userIds.isEmpty() || templateCodes == null || templateCodes.isEmpty()) {
return Set.of();
}
List<Long> filteredUserIds = userIds.stream().filter(java.util.Objects::nonNull).distinct().toList();
if (filteredUserIds.isEmpty()) {
return Set.of();
}
try {
CommonResult<List<NotifyUnreadMessageRespDTO>> result = notifyMessageSendApi.getUnreadNotifyMessageListByTemplateCodes(
new NotifyUnreadMessageListReqDTO()
.setUserIds(new ArrayList<>(filteredUserIds))
.setTemplateCodes(templateCodes));
List<NotifyUnreadMessageRespDTO> messages = result == null ? null : result.getCheckedData();
if (messages == null || messages.isEmpty()) {
return Set.of();
}
Set<String> keys = new HashSet<>();
for (NotifyUnreadMessageRespDTO message : messages) {
if (message == null || message.getUserId() == null || message.getTemplateParams() == null) {
continue;
}
Object reportType = message.getTemplateParams().get(WorkReportConstants.PARAM_REMIND_REPORT_TYPE);
Object reportId = message.getTemplateParams().get(WorkReportConstants.PARAM_REMIND_REPORT_ID);
if (reportType == null || reportId == null) {
continue;
}
keys.add(buildUnreadReminderKey(message.getUserId(), String.valueOf(reportType), String.valueOf(reportId)));
}
return keys;
} catch (Exception ex) {
log.warn("[loadUnreadReminderKeys][未读消息查询失败,本轮按无未读处理]", ex);
return Set.of();
}
}
private boolean hasUnreadReminder(Long userId, String reportType, Long reportId, Set<String> unreadKeys) {
if (userId == null || reportId == null || unreadKeys == null || unreadKeys.isEmpty()) {
return false;
}
return unreadKeys.contains(buildUnreadReminderKey(userId, reportType, String.valueOf(reportId)));
}
private String buildUnreadReminderKey(Long userId, String reportType, String reportId) {
return userId + "#" + reportType + "#" + reportId;
}
private void appendRemindIdentity(Map<String, Object> params, String reportType, Long reportId) {
params.put(WorkReportConstants.PARAM_REMIND_REPORT_TYPE, reportType);
params.put(WorkReportConstants.PARAM_REMIND_REPORT_ID, reportId == null ? null : String.valueOf(reportId));
}
private String defaultText(String text, String fallback) { private String defaultText(String text, String fallback) {
return StringUtils.hasText(text) ? text.trim() : fallback; return StringUtils.hasText(text) ? text.trim() : fallback;
} }

View File

@@ -123,7 +123,7 @@ rdms:
enabled: true enabled: true
weekly: weekly:
enabled: true enabled: true
cron: "0 0 12 ? * FRI" cron: "0 0 12 ? * SUN"
monthly: monthly:
enabled: true enabled: true
cron: "0 0 12 1-31 * ?" cron: "0 0 12 1-31 * ?"
@@ -132,10 +132,5 @@ rdms:
cron: "0 0 12 1-31 * ?" cron: "0 0 12 1-31 * ?"
scope: scope:
dept-ids: [100] dept-ids: [100]
performance:
score-cell-mapping:
actual-score-total: J10
base-score-total: K10
extra-score-total: L10
debug: false debug: false

View File

@@ -7,6 +7,8 @@ import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.MockedStatic; import org.mockito.MockedStatic;
import java.util.List;
import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -42,6 +44,30 @@ class ProjectObjectAuthorizationServiceTest extends BaseMockitoUnitTest {
verify(projectObjectPermissionService).checkPermission(projectId, "project:task:status", false); verify(projectObjectPermissionService).checkPermission(projectId, "project:task:status", false);
} }
@Test
void checkAnyOwnerOrProjectPermission_whenCurrentUserMatchesAnyOwner_shouldSkipProjectPermissionCheck() {
Long projectId = 1003L;
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(3002L)) {
authorizationService.checkAnyOwnerOrProjectPermission(projectId,
List.of(3001L, 3002L, 3003L), "project:task:update");
}
verify(projectObjectPermissionService, never()).checkPermission(projectId, "project:task:update", false);
}
@Test
void checkAnyOwnerOrProjectPermission_whenCurrentUserMatchesNoOwner_shouldCheckProjectPermission() {
Long projectId = 1004L;
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(3999L)) {
authorizationService.checkAnyOwnerOrProjectPermission(projectId,
List.of(3001L, 3002L, 3003L), "project:task:delete");
}
verify(projectObjectPermissionService).checkPermission(projectId, "project:task:delete", false);
}
private MockedStatic<SecurityFrameworkUtils> mockLoginUser(Long loginUserId) { private MockedStatic<SecurityFrameworkUtils> mockLoginUser(Long loginUserId) {
MockedStatic<SecurityFrameworkUtils> mockedStatic = mockStatic(SecurityFrameworkUtils.class); MockedStatic<SecurityFrameworkUtils> mockedStatic = mockStatic(SecurityFrameworkUtils.class);
mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId); mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);

View File

@@ -1,726 +0,0 @@
package com.njcn.rdms.module.project.service.product;
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.product.vo.requirement.*;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementDO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementModuleDO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementStatusLogDO;
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.product.ProductRequirementMapper;
import com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementModuleMapper;
import com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementStatusLogMapper;
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.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import java.util.Collections;
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.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* 产品需求 Service 单元测试
*/
class ProductRequirementServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private ProductRequirementServiceImpl requirementService;
@Mock
private ProductRequirementMapper requirementMapper;
@Mock
private ProductRequirementModuleMapper moduleMapper;
@Mock
private ProductRequirementStatusLogMapper statusLogMapper;
@Mock
private BizAuditLogMapper bizAuditLogMapper;
@Mock
private ObjectStatusTransitionMapper statusTransitionMapper;
@Mock
private ObjectStatusModelMapper statusModelMapper;
@Mock
private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver;
@Mock
private com.njcn.rdms.module.project.dal.mysql.product.ProductMapper productMapper;
@Mock
private org.springframework.context.ApplicationEventPublisher applicationEventPublisher;
// ========== 创建需求通知处理人测试Task 9 ==========
/**
* Task 9创建产品需求后通知处理人currentHandlerUserId操作人由监听器统一排除。
*/
@Test
void createRequirement_notifiesHandler() {
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(9L);
ProductRequirementDO req = new ProductRequirementDO();
req.setId(1L);
req.setProductId(20L);
req.setTitle("电池告警");
req.setCurrentHandlerUserId(5L);
com.njcn.rdms.module.project.dal.dataobject.product.ProductDO product =
new com.njcn.rdms.module.project.dal.dataobject.product.ProductDO();
product.setName("智能终端");
when(productMapper.selectById(20L)).thenReturn(product);
ArgumentCaptor<com.njcn.rdms.module.project.framework.notify.NotifySendEvent> captor =
ArgumentCaptor.forClass(com.njcn.rdms.module.project.framework.notify.NotifySendEvent.class);
requirementService.publishRequirementAssignedNotify(req);
verify(applicationEventPublisher).publishEvent(captor.capture());
com.njcn.rdms.module.project.framework.notify.NotifySendEvent ev = captor.getValue();
assertEquals("product_requirement_assigned", ev.getTemplateCode());
assertEquals(9L, ev.getOperatorUserId());
assertTrue(ev.getUserIds().contains(5L));
assertEquals("电池告警", ev.getParams().get("requirementTitle"));
assertEquals("智能终端", ev.getParams().get("productName"));
}
}
/**
* Task 9处理人为空时不发通知。
*/
@Test
void createRequirement_whenHandlerNull_shouldNotPublish() {
ProductRequirementDO req = new ProductRequirementDO();
req.setId(1L);
req.setProductId(20L);
req.setTitle("电池告警");
req.setCurrentHandlerUserId(null);
requirementService.publishRequirementAssignedNotify(req);
verify(applicationEventPublisher, never()).publishEvent(any());
}
// ========== 创建需求测试 ==========
@Test
void createRequirement_withoutReview_shouldCreateWithPendingDispatchStatus() {
Long loginUserId = 1001L;
Long defaultModuleId = 50L;
ProductRequirementSaveReqVO reqVO = new ProductRequirementSaveReqVO();
reqVO.setProductId(100L);
reqVO.setTitle("测试需求");
reqVO.setDescription("测试描述");
reqVO.setCategory("function");
reqVO.setReviewRequired(0); // 不需要评审
reqVO.setPriority(1);
reqVO.setProposerId(2001L);
reqVO.setProposerNickname("proposer-a");
reqVO.setCurrentHandlerUserId(2002L);
reqVO.setCurrentHandlerUserNickname("handler-a");
// 模拟"全部需求"模块存在parentId = 0L 的根模块)
ProductRequirementModuleDO defaultModule = createDefaultModule(defaultModuleId, 100L);
when(moduleMapper.selectByProductIdAndParentId(100L, 0L)).thenReturn(defaultModule);
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
requirementService.createRequirement(reqVO);
}
ArgumentCaptor<ProductRequirementDO> captor = ArgumentCaptor.forClass(ProductRequirementDO.class);
verify(requirementMapper, times(1)).insert(captor.capture());
ProductRequirementDO created = captor.getValue();
assertEquals("pending_dispatch", created.getStatusCode()); // 不需要评审时初始状态为待指派
assertEquals("manual", created.getSourceType());
assertEquals(0L, created.getParentId());
assertEquals("测试需求", created.getTitle());
assertEquals(defaultModuleId, created.getModuleId()); // 未选择模块时自动归属到"全部需求"模块
assertEquals(100L, created.getProductId());
assertEquals("handler-a", created.getCurrentHandlerUserNickname());
ArgumentCaptor<BizAuditLogDO> auditCaptor = ArgumentCaptor.forClass(BizAuditLogDO.class);
verify(bizAuditLogMapper, times(1)).insert(auditCaptor.capture());
assertNotNull(auditCaptor.getValue().getFieldChanges());
assertTrue(auditCaptor.getValue().getFieldChanges().contains("currentHandlerUserNickname"));
}
@Test
void createRequirement_withReview_shouldCreateWithPendingReviewStatus() {
Long loginUserId = 1001L;
Long defaultModuleId = 50L;
ProductRequirementSaveReqVO reqVO = new ProductRequirementSaveReqVO();
reqVO.setProductId(100L);
reqVO.setTitle("需评审的需求");
reqVO.setCategory("function");
reqVO.setReviewRequired(1); // 需要评审
reqVO.setPriority(2);
reqVO.setProposerId(2001L);
// 模拟"全部需求"模块存在parentId = 0L 的根模块)
ProductRequirementModuleDO defaultModule = createDefaultModule(defaultModuleId, 100L);
when(moduleMapper.selectByProductIdAndParentId(100L, 0L)).thenReturn(defaultModule);
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
requirementService.createRequirement(reqVO);
}
ArgumentCaptor<ProductRequirementDO> captor = ArgumentCaptor.forClass(ProductRequirementDO.class);
verify(requirementMapper, times(1)).insert(captor.capture());
ProductRequirementDO created = captor.getValue();
assertEquals("pending_review", created.getStatusCode()); // 需要评审时初始状态为待评审
assertEquals(defaultModuleId, created.getModuleId()); // 未选择模块时自动归属到"全部需求"模块
}
// ========== 更新需求测试 ==========
@Test
void updateRequirement_whenTerminalStatus_shouldThrowException() {
Long requirementId = 1001L;
ProductRequirementDO requirement = createRequirement(requirementId, 100L, "已关闭需求",
"closed", 0L, 0);
ProductRequirementUpdateReqVO reqVO = new ProductRequirementUpdateReqVO();
reqVO.setId(requirementId);
reqVO.setProductId(100L);
reqVO.setTitle("修改标题");
reqVO.setCategory("function");
reqVO.setReviewRequired(0);
reqVO.setPriority(1);
reqVO.setProposerId(2001L);
when(requirementMapper.selectById(requirementId)).thenReturn(requirement);
ServiceException ex = assertThrows(ServiceException.class,
() -> requirementService.updateRequirement(reqVO));
assertEquals(ErrorCodeConstants.REQUIREMENT_STATUS_NOT_ALLOW_EDIT.getCode(), ex.getCode());
verify(requirementMapper, never()).updateById(any(ProductRequirementDO.class));
}
@Test
void updateRequirement_whenNormalStatus_shouldUpdateSuccessfully() {
Long requirementId = 1002L;
Long loginUserId = 1001L;
Long defaultModuleId = 50L;
ProductRequirementDO requirement = createRequirement(requirementId, 100L, "待指派需求",
"pending_dispatch", 0L, 0);
ProductRequirementUpdateReqVO reqVO = new ProductRequirementUpdateReqVO();
reqVO.setId(requirementId);
reqVO.setProductId(100L);
reqVO.setTitle("修改后的标题");
reqVO.setCategory("security");
reqVO.setReviewRequired(0);
reqVO.setPriority(2);
reqVO.setProposerId(2001L);
// 模拟"全部需求"模块存在未选择模块时自动归属parentId = 0L 的根模块)
ProductRequirementModuleDO defaultModule = createDefaultModule(defaultModuleId, 100L);
when(moduleMapper.selectByProductIdAndParentId(100L, 0L)).thenReturn(defaultModule);
when(requirementMapper.selectById(requirementId)).thenReturn(requirement);
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
requirementService.updateRequirement(reqVO);
}
ArgumentCaptor<ProductRequirementDO> captor = ArgumentCaptor.forClass(ProductRequirementDO.class);
verify(requirementMapper, times(1)).updateById(captor.capture());
assertEquals(defaultModuleId, captor.getValue().getModuleId()); // 未选择模块时自动归属到"全部需求"模块
verify(bizAuditLogMapper, times(1)).insert(any(BizAuditLogDO.class));
}
// ========== 状态变更测试 ==========
@Test
void changeRequirementStatus_whenActionAllowed_shouldUpdateStatusAndWriteLogs() {
Long requirementId = 1003L;
Long loginUserId = 1001L;
ProductRequirementDO requirement = createRequirement(requirementId, 100L, "待指派需求",
"pending_dispatch", 0L, 0);
ProductRequirementStatusActionReqVO reqVO = new ProductRequirementStatusActionReqVO();
reqVO.setId(requirementId);
reqVO.setActionCode("dispatch");
reqVO.setReason(null);
when(requirementMapper.selectById(requirementId)).thenReturn(requirement);
when(statusTransitionMapper.selectByObjectTypeAndFromStatusAndAction(
"product_requirement", "pending_dispatch", "dispatch"))
.thenReturn(createTransition("dispatch", "implementing", false));
when(requirementMapper.updateStatusByIdAndStatus(requirementId, "pending_dispatch", "implementing", null))
.thenReturn(1);
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
requirementService.changeRequirementStatus(reqVO);
}
verify(requirementMapper, times(1))
.updateStatusByIdAndStatus(requirementId, "pending_dispatch", "implementing", null);
verify(statusLogMapper, times(1)).insert(any(ProductRequirementStatusLogDO.class));
verify(bizAuditLogMapper, times(1)).insert(any(BizAuditLogDO.class));
}
@Test
void changeRequirementStatus_whenActionNotAllowed_shouldThrowException() {
Long requirementId = 1004L;
ProductRequirementDO requirement = createRequirement(requirementId, 100L, "已关闭需求",
"closed", 0L, 0);
ProductRequirementStatusActionReqVO reqVO = new ProductRequirementStatusActionReqVO();
reqVO.setId(requirementId);
reqVO.setActionCode("dispatch");
when(requirementMapper.selectById(requirementId)).thenReturn(requirement);
when(statusTransitionMapper.selectByObjectTypeAndFromStatusAndAction(
"product_requirement", "closed", "dispatch"))
.thenReturn(null);
ServiceException ex = assertThrows(ServiceException.class,
() -> requirementService.changeRequirementStatus(reqVO));
assertEquals(ErrorCodeConstants.REQUIREMENT_STATUS_ACTION_NOT_ALLOWED.getCode(), ex.getCode());
}
@Test
void changeRequirementStatus_whenReasonRequiredButMissing_shouldThrowException() {
Long requirementId = 1005L;
ProductRequirementDO requirement = createRequirement(requirementId, 100L, "待指派需求",
"pending_dispatch", 0L, 0);
ProductRequirementStatusActionReqVO reqVO = new ProductRequirementStatusActionReqVO();
reqVO.setId(requirementId);
reqVO.setActionCode("cancel");
reqVO.setReason(" "); // 空原因
when(requirementMapper.selectById(requirementId)).thenReturn(requirement);
when(statusTransitionMapper.selectByObjectTypeAndFromStatusAndAction(
"product_requirement", "pending_dispatch", "cancel"))
.thenReturn(createTransition("cancel", "cancelled", true));
ServiceException ex = assertThrows(ServiceException.class,
() -> requirementService.changeRequirementStatus(reqVO));
assertEquals(ErrorCodeConstants.REQUIREMENT_STATUS_ACTION_REASON_REQUIRED.getCode(), ex.getCode());
}
@Test
void changeRequirementStatus_whenConcurrentModified_shouldThrowException() {
Long requirementId = 1006L;
Long loginUserId = 1001L;
ProductRequirementDO requirement = createRequirement(requirementId, 100L, "待指派需求",
"pending_dispatch", 0L, 0);
ProductRequirementStatusActionReqVO reqVO = new ProductRequirementStatusActionReqVO();
reqVO.setId(requirementId);
reqVO.setActionCode("dispatch");
when(requirementMapper.selectById(requirementId)).thenReturn(requirement);
when(statusTransitionMapper.selectByObjectTypeAndFromStatusAndAction(
"product_requirement", "pending_dispatch", "dispatch"))
.thenReturn(createTransition("dispatch", "implementing", false));
when(requirementMapper.updateStatusByIdAndStatus(requirementId, "pending_dispatch", "implementing", null))
.thenReturn(0); // 并发修改,更新失败
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
ServiceException ex = assertThrows(ServiceException.class,
() -> requirementService.changeRequirementStatus(reqVO));
assertEquals(ErrorCodeConstants.REQUIREMENT_STATUS_CONCURRENT_MODIFIED.getCode(), ex.getCode());
}
verify(statusLogMapper, never()).insert(any(ProductRequirementStatusLogDO.class));
verify(bizAuditLogMapper, never()).insert(any(BizAuditLogDO.class));
}
// ========== 删除需求测试 ==========
@Test
void deleteRequirement_shouldDeleteByConditionAndWriteLogs() {
Long requirementId = 1007L;
Long loginUserId = 1001L;
ProductRequirementDO requirement = createRequirement(requirementId, 100L, "待指派需求",
"pending_dispatch", 0L, 0);
when(requirementMapper.selectById(requirementId)).thenReturn(requirement);
when(requirementMapper.deleteByIdAndStatus(requirementId, "pending_dispatch")).thenReturn(1);
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
requirementService.deleteRequirement(requirementId, 100L);
}
verify(requirementMapper, times(1)).deleteByIdAndStatus(requirementId, "pending_dispatch");
verify(bizAuditLogMapper, times(1)).insert(any(BizAuditLogDO.class));
}
// ========== 拆分需求测试 ==========
@Test
void splitRequirement_whenParentPendingDispatch_shouldChangeParentToImplementing() {
Long parentId = 1008L;
Long loginUserId = 1001L;
ProductRequirementDO parent = createRequirement(parentId, 100L, "大需求",
"pending_dispatch", 0L, 0);
parent.setModuleId(50L);
parent.setProposerId(2001L);
ProductRequirementSplitReqVO reqVO = new ProductRequirementSplitReqVO();
reqVO.setParentId(parentId);
reqVO.setProductId(100L);
reqVO.setTitle("子需求");
reqVO.setCategory("function");
reqVO.setPriority(1);
when(requirementMapper.selectById(parentId)).thenReturn(parent);
when(requirementMapper.updateStatusByIdAndStatus(parentId, "pending_dispatch", "implementing", null))
.thenReturn(1);
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
requirementService.splitRequirement(reqVO);
}
// 验证子需求被创建
ArgumentCaptor<ProductRequirementDO> childCaptor = ArgumentCaptor.forClass(ProductRequirementDO.class);
verify(requirementMapper, times(1)).insert(childCaptor.capture());
ProductRequirementDO child = childCaptor.getValue();
assertEquals(parentId, child.getParentId());
assertEquals("pending_dispatch", child.getStatusCode());
assertEquals(parent.getModuleId(), child.getModuleId());
assertEquals(reqVO.getProposerId(), child.getProposerId());
// 验证父需求状态变为实施中
verify(requirementMapper, times(1))
.updateStatusByIdAndStatus(parentId, "pending_dispatch", "implementing", null);
verify(statusLogMapper, times(1)).insert(any(ProductRequirementStatusLogDO.class));
}
@Test
void splitRequirement_whenParentImplementing_shouldNotChangeParentStatus() {
Long parentId = 1009L;
Long loginUserId = 1001L;
ProductRequirementDO parent = createRequirement(parentId, 100L, "实施中的大需求",
"implementing", 0L, 0);
parent.setModuleId(50L);
parent.setProposerId(2001L);
ProductRequirementSplitReqVO reqVO = new ProductRequirementSplitReqVO();
reqVO.setParentId(parentId);
reqVO.setProductId(100L);
reqVO.setTitle("子需求2");
reqVO.setCategory("function");
reqVO.setPriority(1);
when(requirementMapper.selectById(parentId)).thenReturn(parent);
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
requirementService.splitRequirement(reqVO);
}
// 父需求已经是实施中,不需要再更新状态
verify(requirementMapper, never())
.updateStatusByIdAndStatus(parentId, "implementing", "implementing", null);
}
@Test
void splitRequirement_whenParentNotAllowSplit_shouldThrowException() {
Long parentId = 1010L;
ProductRequirementDO parent = createRequirement(parentId, 100L, "已验收的大需求",
"accepted", 0L, 0);
ProductRequirementSplitReqVO reqVO = new ProductRequirementSplitReqVO();
reqVO.setParentId(parentId);
reqVO.setProductId(100L);
reqVO.setTitle("子需求");
reqVO.setCategory("function");
reqVO.setPriority(1);
when(requirementMapper.selectById(parentId)).thenReturn(parent);
ServiceException ex = assertThrows(ServiceException.class,
() -> requirementService.splitRequirement(reqVO));
assertEquals(ErrorCodeConstants.REQUIREMENT_PARENT_NOT_ALLOW_SPLIT.getCode(), ex.getCode());
verify(requirementMapper, never()).insert(any(ProductRequirementDO.class));
}
// ========== 关闭需求测试 ==========
@Test
void closeRequirement_whenAccepted_shouldCloseSuccessfully() {
Long requirementId = 1011L;
Long loginUserId = 1001L;
ProductRequirementDO requirement = createRequirement(requirementId, 100L, "已验收需求",
"accepted", 0L, 0);
ProductRequirementCloseReqVO reqVO = new ProductRequirementCloseReqVO();
reqVO.setId(requirementId);
reqVO.setProductId(100L);
reqVO.setReason("需求已完成");
when(requirementMapper.selectById(requirementId)).thenReturn(requirement);
when(requirementMapper.selectListByParentId(requirementId)).thenReturn(Collections.emptyList());
when(requirementMapper.updateStatusByIdAndStatus(requirementId, "accepted", "closed", "需求已完成"))
.thenReturn(1);
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
requirementService.closeRequirement(reqVO);
}
verify(requirementMapper, times(1))
.updateStatusByIdAndStatus(requirementId, "accepted", "closed", "需求已完成");
verify(statusLogMapper, times(1)).insert(any(ProductRequirementStatusLogDO.class));
verify(bizAuditLogMapper, times(1)).insert(any(BizAuditLogDO.class));
}
@Test
void closeRequirement_whenNotAccepted_shouldThrowException() {
Long requirementId = 1012L;
ProductRequirementDO requirement = createRequirement(requirementId, 100L, "实施中需求",
"implementing", 0L, 0);
ProductRequirementCloseReqVO reqVO = new ProductRequirementCloseReqVO();
reqVO.setId(requirementId);
reqVO.setProductId(100L);
reqVO.setReason("需求已完成");
when(requirementMapper.selectById(requirementId)).thenReturn(requirement);
ServiceException ex = assertThrows(ServiceException.class,
() -> requirementService.closeRequirement(reqVO));
assertEquals(ErrorCodeConstants.REQUIREMENT_STATUS_NOT_ALLOW_CLOSE.getCode(), ex.getCode());
}
@Test
void closeRequirement_withChildren_shouldCascadeCloseAcceptedChildren() {
Long parentId = 1013L;
Long childId = 1014L;
Long loginUserId = 1001L;
ProductRequirementDO parent = createRequirement(parentId, 100L, "父需求",
"accepted", 0L, 0);
ProductRequirementDO child = createRequirement(childId, 100L, "子需求",
"accepted", parentId, 0);
ProductRequirementCloseReqVO reqVO = new ProductRequirementCloseReqVO();
reqVO.setId(parentId);
reqVO.setProductId(100L);
reqVO.setReason("全部验收完成");
when(requirementMapper.selectById(parentId)).thenReturn(parent);
when(requirementMapper.selectListByParentId(parentId)).thenReturn(List.of(child));
when(requirementMapper.updateStatusByIdAndStatus(childId, "accepted", "closed", "全部验收完成"))
.thenReturn(1);
when(requirementMapper.updateStatusByIdAndStatus(parentId, "accepted", "closed", "全部验收完成"))
.thenReturn(1);
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
requirementService.closeRequirement(reqVO);
}
// 子需求被关闭
verify(requirementMapper, times(1))
.updateStatusByIdAndStatus(childId, "accepted", "closed", "全部验收完成");
// 父需求被关闭
verify(requirementMapper, times(1))
.updateStatusByIdAndStatus(parentId, "accepted", "closed", "全部验收完成");
}
@Test
void closeRequirement_withChildNotAllowClose_shouldThrowException() {
Long parentId = 1015L;
ProductRequirementDO parent = createRequirement(parentId, 100L, "父需求",
"accepted", 0L, 0);
ProductRequirementDO child = createRequirement(1016L, 100L, "实施中子需求",
"implementing", parentId, 0);
ProductRequirementCloseReqVO reqVO = new ProductRequirementCloseReqVO();
reqVO.setId(parentId);
reqVO.setProductId(100L);
reqVO.setReason("全部验收完成");
when(requirementMapper.selectById(parentId)).thenReturn(parent);
when(requirementMapper.selectListByParentId(parentId)).thenReturn(List.of(child));
ServiceException ex = assertThrows(ServiceException.class,
() -> requirementService.closeRequirement(reqVO));
assertEquals(ErrorCodeConstants.REQUIREMENT_CHILD_NOT_ALLOW_CLOSE.getCode(), ex.getCode());
}
// ========== 模块管理测试 ==========
@Test
void createRequirementModule_whenNameDuplicate_shouldThrowException() {
Long productId = 100L;
ProductRequirementModuleDO existModule = new ProductRequirementModuleDO();
existModule.setId(50L);
existModule.setProductId(productId);
existModule.setModuleName("核心功能");
ProductRequirementModuleReqVO reqVO = new ProductRequirementModuleReqVO();
reqVO.setProductId(productId);
reqVO.setModuleName("核心功能");
when(moduleMapper.selectByProductIdAndModuleName(productId, "核心功能")).thenReturn(existModule);
ServiceException ex = assertThrows(ServiceException.class,
() -> requirementService.createRequirementModule(reqVO));
assertEquals(ErrorCodeConstants.REQUIREMENT_MODULE_NAME_DUPLICATE.getCode(), ex.getCode());
}
@Test
void deleteRequirementModule_whenHasNonTerminalRequirements_shouldThrowException() {
Long moduleId = 50L;
Long productId = 100L;
ProductRequirementModuleDO module = new ProductRequirementModuleDO();
module.setId(moduleId);
module.setProductId(productId);
module.setModuleName("核心功能");
ProductRequirementDO nonTerminalReq = createRequirement(2001L, productId, "实施中需求",
"implementing", 0L, 0);
nonTerminalReq.setModuleId(moduleId);
when(moduleMapper.selectById(moduleId)).thenReturn(module);
when(requirementMapper.selectListByModuleId(moduleId)).thenReturn(List.of(nonTerminalReq));
ServiceException ex = assertThrows(ServiceException.class,
() -> requirementService.deleteRequirementModule(moduleId, productId));
assertEquals(ErrorCodeConstants.REQUIREMENT_MODULE_HAS_NON_TERMINAL_REQUIREMENTS.getCode(), ex.getCode());
verify(moduleMapper, never()).deleteById(moduleId);
}
@Test
void deleteRequirementModule_whenAllTerminal_shouldDeleteSuccessfully() {
Long moduleId = 50L;
Long productId = 100L;
Long loginUserId = 1001L;
ProductRequirementModuleDO module = new ProductRequirementModuleDO();
module.setId(moduleId);
module.setProductId(productId);
module.setModuleName("核心功能");
ProductRequirementDO closedReq = createRequirement(2001L, productId, "已关闭需求",
"closed", 0L, 0);
closedReq.setModuleId(moduleId);
when(moduleMapper.selectById(moduleId)).thenReturn(module);
when(requirementMapper.selectListByModuleId(moduleId)).thenReturn(List.of(closedReq));
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
requirementService.deleteRequirementModule(moduleId, productId);
}
verify(requirementMapper, times(1)).deleteById(2001L);
verify(moduleMapper, times(1)).deleteById(moduleId);
}
// ========== resolveModuleId 测试 ==========
@Test
void resolveModuleId_whenModuleIdIsNull_shouldReturnDefaultModuleId() {
Long defaultModuleId = 50L;
Long productId = 100L;
ProductRequirementModuleDO defaultModule = createDefaultModule(defaultModuleId, productId);
when(moduleMapper.selectByProductIdAndParentId(productId, 0L)).thenReturn(defaultModule);
Long result = requirementService.resolveModuleId(null, productId);
assertEquals(defaultModuleId, result);
}
@Test
void resolveModuleId_whenModuleIdProvided_shouldReturnSameId() {
Long moduleId = 60L;
Long productId = 100L;
// 传入有效moduleId时直接返回不查询数据库
Long result = requirementService.resolveModuleId(moduleId, productId);
assertEquals(moduleId, result);
verify(moduleMapper, never()).selectByProductIdAndParentId(any(), any());
}
// ========== isAllRequirementsModule 测试 ==========
@Test
void isAllRequirementsModule_whenRootModule_shouldReturnTrue() {
Long moduleId = 50L;
ProductRequirementModuleDO rootModule = createDefaultModule(moduleId, 100L);
when(moduleMapper.selectById(moduleId)).thenReturn(rootModule);
boolean result = requirementService.isAllRequirementsModule(moduleId);
assertEquals(true, result);
}
@Test
void isAllRequirementsModule_whenChildModule_shouldReturnFalse() {
Long moduleId = 60L;
ProductRequirementModuleDO childModule = new ProductRequirementModuleDO();
childModule.setId(moduleId);
childModule.setProductId(100L);
childModule.setParentId(50L); // parentId != 0
childModule.setModuleName("子模块");
when(moduleMapper.selectById(moduleId)).thenReturn(childModule);
boolean result = requirementService.isAllRequirementsModule(moduleId);
assertEquals(false, result);
}
@Test
void isAllRequirementsModule_whenModuleNotExists_shouldReturnFalse() {
Long moduleId = 70L;
when(moduleMapper.selectById(moduleId)).thenReturn(null);
boolean result = requirementService.isAllRequirementsModule(moduleId);
assertEquals(false, result);
}
@Test
void resolveModuleId_whenDefaultModuleNotExists_shouldThrowException() {
Long productId = 100L;
when(moduleMapper.selectByProductIdAndParentId(productId, 0L)).thenReturn(null);
ServiceException ex = assertThrows(ServiceException.class,
() -> requirementService.resolveModuleId(null, productId));
assertEquals(ErrorCodeConstants.REQUIREMENT_MODULE_NOT_EXISTS.getCode(), ex.getCode());
}
// ========== 辅助方法 ==========
private ProductRequirementDO createRequirement(Long id, Long productId, String title,
String statusCode, Long parentId, Integer reviewRequired) {
ProductRequirementDO requirement = new ProductRequirementDO();
requirement.setId(id);
requirement.setProductId(productId);
requirement.setTitle(title);
requirement.setStatusCode(statusCode);
requirement.setParentId(parentId);
requirement.setReviewRequired(reviewRequired);
requirement.setCategory("function");
requirement.setSourceType("manual");
requirement.setPriority(1);
requirement.setProposerId(2001L);
requirement.setSort(0);
return requirement;
}
private ProductRequirementModuleDO createDefaultModule(Long id, Long productId) {
ProductRequirementModuleDO module = new ProductRequirementModuleDO();
module.setId(id);
module.setProductId(productId);
module.setParentId(0L);
module.setModuleName("全部需求");
module.setRemark("自动创建的模块");
module.setSort(0);
return module;
}
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 MockedStatic<SecurityFrameworkUtils> mockLoginUser(Long loginUserId, String nickname) {
MockedStatic<SecurityFrameworkUtils> mockedStatic = mockStatic(SecurityFrameworkUtils.class);
mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);
mockedStatic.when(SecurityFrameworkUtils::getLoginUserNickname).thenReturn(nickname);
return mockedStatic;
}
}

View File

@@ -811,6 +811,10 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
com.njcn.rdms.module.project.framework.notify.NotifySendEvent first = captor.getAllValues().get(0); com.njcn.rdms.module.project.framework.notify.NotifySendEvent first = captor.getAllValues().get(0);
assertEquals("project_created", first.getTemplateCode()); assertEquals("project_created", first.getTemplateCode());
assertEquals(9L, first.getOperatorUserId()); assertEquals(9L, first.getOperatorUserId());
assertEquals(com.njcn.rdms.module.project.framework.notify.NotifyJumpConstants.JUMP_TYPE_PROJECT,
first.getParams().get(com.njcn.rdms.module.project.framework.notify.NotifyJumpConstants.PARAM_JUMP_TYPE));
assertEquals("10",
first.getParams().get(com.njcn.rdms.module.project.framework.notify.NotifyJumpConstants.PARAM_PROJECT_ID));
assertEquals("项目经理", first.getParams().get("roleName")); assertEquals("项目经理", first.getParams().get("roleName"));
assertEquals("灿能项目", first.getParams().get("projectName")); assertEquals("灿能项目", first.getParams().get("projectName"));
assertTrue(first.getUserIds().contains(5L)); assertTrue(first.getUserIds().contains(5L));

View File

@@ -10,10 +10,12 @@ import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execut
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.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.ProjectExecutionRespVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionCreateReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionCreateReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionUpdateReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionUpdateReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusActionReqVO; 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.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.constant.ObjectActivityConstants; import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
import com.njcn.rdms.module.project.constant.ProjectExecutionConstants;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO; 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.ProjectDO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO; import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO;
@@ -33,7 +35,9 @@ 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.dal.mysql.status.ObjectStatusTransitionMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants; import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants; import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
import com.njcn.rdms.module.project.framework.notify.NotifyJumpConstants;
import com.njcn.rdms.module.project.framework.notify.NotifySendEvent; import com.njcn.rdms.module.project.framework.notify.NotifySendEvent;
import com.njcn.rdms.module.project.framework.security.service.ProjectObjectAuthorizationService;
import com.njcn.rdms.module.project.service.project.ProjectRequirementService; import com.njcn.rdms.module.project.service.project.ProjectRequirementService;
import com.njcn.rdms.module.system.api.dict.DictDataApi; 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.AdminUserApi;
@@ -106,6 +110,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
@Mock @Mock
private ProjectRequirementMapper projectRequirementMapper; private ProjectRequirementMapper projectRequirementMapper;
@Mock @Mock
private ProjectObjectAuthorizationService projectObjectAuthorizationService;
@Mock
private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver; private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver;
@Mock @Mock
private org.springframework.context.ApplicationEventPublisher applicationEventPublisher; private org.springframework.context.ApplicationEventPublisher applicationEventPublisher;
@@ -118,6 +124,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
void setupDefaultPriorityValidation() { void setupDefaultPriorityValidation() {
lenient().when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.REQ_PRIORITY), any())) lenient().when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.REQ_PRIORITY), any()))
.thenReturn(success(true)); .thenReturn(success(true));
lenient().when(userObjectRoleMapper.selectActiveListByObjectAndUserId(eq("project"), any(), eq(3001L)))
.thenAnswer(invocation -> List.of(createProjectMember(invocation.getArgument(1), 3001L, 3101L)));
} }
@Test @Test
@@ -129,6 +137,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
reqVO.setOwnerId(3001L); reqVO.setOwnerId(3001L);
reqVO.setProjectRequirementId(null); reqVO.setProjectRequirementId(null);
reqVO.setAssigneeUserIds(List.of(3002L, 3003L)); reqVO.setAssigneeUserIds(List.of(3002L, 3003L));
reqVO.setPriority("1");
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
@@ -202,6 +211,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
reqVO.setOwnerId(3001L); reqVO.setOwnerId(3001L);
reqVO.setProjectRequirementId(9001L); reqVO.setProjectRequirementId(9001L);
reqVO.setAssigneeUserIds(List.of(3002L)); reqVO.setAssigneeUserIds(List.of(3002L));
reqVO.setPriority("1");
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
@@ -268,8 +278,6 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
.thenReturn(createProjectStatus("pending", true)); .thenReturn(createProjectStatus("pending", true));
when(projectExecutionMapper.selectByProjectIdAndName(projectId, "后端接口联调")).thenReturn(null); when(projectExecutionMapper.selectByProjectIdAndName(projectId, "后端接口联调")).thenReturn(null);
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L))
.thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L)));
when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature"))) when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature")))
.thenReturn(success(true)); .thenReturn(success(true));
@@ -298,8 +306,6 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(createExecutionStatus("pending", true)); .thenReturn(createExecutionStatus("pending", true));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution); when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
when(projectExecutionMapper.selectByProjectIdAndName(projectId, "接口联调-修订")).thenReturn(null); when(projectExecutionMapper.selectByProjectIdAndName(projectId, "接口联调-修订")).thenReturn(null);
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L))
.thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L)));
when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature"))) when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature")))
.thenReturn(success(true)); .thenReturn(success(true));
@@ -309,6 +315,36 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
verify(projectExecutionMapper).updateById(execution); verify(projectExecutionMapper).updateById(execution);
} }
@Test
void updateExecution_shouldAuthorizeProjectManagerOrExecutionOwnerBeforeUpdating() {
Long projectId = 2001L;
Long executionId = 5001L;
ProjectDO project = createEditableProject(projectId);
project.setManagerUserId(3001L);
ProjectExecutionDO execution = createExecution(projectId, executionId, 3002L);
ProjectExecutionUpdateReqVO reqVO = new ProjectExecutionUpdateReqVO();
reqVO.setId(executionId);
reqVO.setExecutionName("执行权限联调");
reqVO.setExecutionType("feature");
reqVO.setPriority("1");
reqVO.setProjectRequirementId(null);
when(projectMapper.selectById(projectId)).thenReturn(project);
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(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature")))
.thenReturn(success(true));
projectExecutionService.updateExecution(projectId, reqVO);
verify(projectObjectAuthorizationService).checkAnyOwnerOrProjectPermission(projectId,
List.of(3001L, 3002L), ProjectExecutionConstants.PERMISSION_UPDATE);
}
@Test @Test
void changeOwner_shouldUpdateOwnerAndWriteOwnerTransferLogs() { void changeOwner_shouldUpdateOwnerAndWriteOwnerTransferLogs() {
Long projectId = 2001L; Long projectId = 2001L;
@@ -339,6 +375,51 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_IN, "负责人调整"); ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_IN, "负责人调整");
} }
@Test
void deleteExecution_shouldAuthorizeProjectManagerOrExecutionOwnerBeforeDeleting() {
Long projectId = 2001L;
Long executionId = 5001L;
ProjectDO project = createEditableProject(projectId);
project.setManagerUserId(3001L);
ProjectExecutionDO execution = createExecution(projectId, executionId, 3002L);
execution.setExecutionName("Execution auth");
ProjectExecutionDeleteReqVO reqVO = new ProjectExecutionDeleteReqVO();
reqVO.setExecutionName("Execution auth");
reqVO.setConfirmText("DELETE");
reqVO.setReason("录入错误");
when(projectMapper.selectById(projectId)).thenReturn(project);
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
when(projectTaskMapper.selectIdsByExecutionId(executionId)).thenReturn(Collections.emptyList());
when(projectExecutionMapper.deleteById(executionId)).thenReturn(1);
projectExecutionService.deleteExecution(projectId, executionId, reqVO);
verify(projectObjectAuthorizationService).checkAnyOwnerOrProjectPermission(projectId,
List.of(3001L, 3002L), ProjectExecutionConstants.PERMISSION_DELETE);
verify(projectExecutionMapper).deleteById(executionId);
}
@Test
void precheckDeleteExecution_shouldAuthorizeProjectManagerOrExecutionOwnerBeforeCountingTasks() {
Long projectId = 2001L;
Long executionId = 5001L;
ProjectDO project = createEditableProject(projectId);
project.setManagerUserId(3001L);
ProjectExecutionDO execution = createExecution(projectId, executionId, 3002L);
when(projectMapper.selectById(projectId)).thenReturn(project);
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
when(projectTaskMapper.countByExecutionId(executionId)).thenReturn(3);
var result = projectExecutionService.precheckDeleteExecution(projectId, executionId);
verify(projectObjectAuthorizationService).checkAnyOwnerOrProjectPermission(projectId,
List.of(3001L, 3002L), ProjectExecutionConstants.PERMISSION_DELETE);
assertEquals(3, result.getTaskCount());
assertEquals(true, result.getHasDependentData());
}
@Test @Test
void changeExecutionStatus_shouldUpdateByTransitionAndWriteStatusAndAuditLogs() { void changeExecutionStatus_shouldUpdateByTransitionAndWriteStatusAndAuditLogs() {
Long projectId = 2001L; Long projectId = 2001L;
@@ -770,8 +851,6 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
.thenReturn(createProjectStatus("pending", true)); .thenReturn(createProjectStatus("pending", true));
when(projectExecutionMapper.selectByProjectIdAndName(projectId, "接口联调")).thenReturn(null); when(projectExecutionMapper.selectByProjectIdAndName(projectId, "接口联调")).thenReturn(null);
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L))
.thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L)));
when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.EXECUTION_TYPE), any())) when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.EXECUTION_TYPE), any()))
.thenReturn(success(true)); .thenReturn(success(true));
@@ -876,8 +955,6 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(createExecutionStatus("pending", true)); .thenReturn(createExecutionStatus("pending", true));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution); when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
when(projectExecutionMapper.selectByProjectIdAndName(projectId, "接口联调")).thenReturn(null); when(projectExecutionMapper.selectByProjectIdAndName(projectId, "接口联调")).thenReturn(null);
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L))
.thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L)));
when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.EXECUTION_TYPE), any())) when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.EXECUTION_TYPE), any()))
.thenReturn(success(true)); .thenReturn(success(true));
when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.REQ_PRIORITY), any())) when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.REQ_PRIORITY), any()))
@@ -919,6 +996,9 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
assertEquals("execution_assigned", ev.getTemplateCode()); assertEquals("execution_assigned", ev.getTemplateCode());
assertEquals(7L, ev.getOperatorUserId()); assertEquals(7L, ev.getOperatorUserId());
assertTrue(ev.getUserIds().containsAll(List.of(5L, 6L))); assertTrue(ev.getUserIds().containsAll(List.of(5L, 6L)));
assertEquals(NotifyJumpConstants.JUMP_TYPE_EXECUTION,
ev.getParams().get(NotifyJumpConstants.PARAM_JUMP_TYPE));
assertEquals("10", ev.getParams().get(NotifyJumpConstants.PARAM_PROJECT_ID));
assertEquals("灿能项目", ev.getParams().get("projectName")); assertEquals("灿能项目", ev.getParams().get("projectName"));
assertEquals("一阶段", ev.getParams().get("executionName")); assertEquals("一阶段", ev.getParams().get("executionName"));
} }

View File

@@ -7,6 +7,7 @@ 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.TaskAssigneeDO;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper; import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeMapper; import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeMapper;
import com.njcn.rdms.module.project.framework.notify.NotifyJumpConstants;
import com.njcn.rdms.module.project.framework.notify.NotifySendEvent; import com.njcn.rdms.module.project.framework.notify.NotifySendEvent;
import com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants; import com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -63,6 +64,9 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
ArgumentCaptor<NotifySendEvent> captor = ArgumentCaptor.forClass(NotifySendEvent.class); ArgumentCaptor<NotifySendEvent> captor = ArgumentCaptor.forClass(NotifySendEvent.class);
verify(applicationEventPublisher).publishEvent(captor.capture()); verify(applicationEventPublisher).publishEvent(captor.capture());
NotifySendEvent event = captor.getValue(); NotifySendEvent event = captor.getValue();
assertEquals(NotifyJumpConstants.JUMP_TYPE_TASK,
event.getParams().get(NotifyJumpConstants.PARAM_JUMP_TYPE));
assertEquals("50", event.getParams().get(NotifyJumpConstants.PARAM_PROJECT_ID));
assertEquals(NotifyTemplateCodeConstants.TASK_ASSIGNED, event.getTemplateCode()); assertEquals(NotifyTemplateCodeConstants.TASK_ASSIGNED, event.getTemplateCode());
assertTrue(event.getUserIds().contains(1L)); // 负责人 assertTrue(event.getUserIds().contains(1L)); // 负责人
assertTrue(event.getUserIds().contains(2L)); // 协办人 assertTrue(event.getUserIds().contains(2L)); // 协办人

View File

@@ -1,6 +1,7 @@
package com.njcn.rdms.module.system.api.file; package com.njcn.rdms.module.system.api.file;
import com.njcn.rdms.framework.common.pojo.CommonResult; import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.module.system.api.file.dto.FileContentCreateReqDTO;
import com.njcn.rdms.module.system.api.file.dto.FileRespDTO; import com.njcn.rdms.module.system.api.file.dto.FileRespDTO;
import com.njcn.rdms.module.system.enums.ApiConstants; import com.njcn.rdms.module.system.enums.ApiConstants;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -8,6 +9,8 @@ import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.cloud.openfeign.FeignClient; import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = ApiConstants.NAME) @FeignClient(name = ApiConstants.NAME)
@@ -31,4 +34,8 @@ public interface FileApi {
@Parameter(name = "id", description = "文件 ID", required = true) @Parameter(name = "id", description = "文件 ID", required = true)
CommonResult<byte[]> getFileContent(@RequestParam("id") Long id); CommonResult<byte[]> getFileContent(@RequestParam("id") Long id);
@PostMapping(PREFIX + "/create-by-content")
@Operation(summary = "通过文件字节创建文件")
CommonResult<Long> createFileByContent(@RequestBody FileContentCreateReqDTO reqDTO);
} }

View File

@@ -0,0 +1,21 @@
package com.njcn.rdms.module.system.api.file.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "RPC 服务 - 文件字节创建 Request DTO")
@Data
public class FileContentCreateReqDTO {
@Schema(description = "文件内容", requiredMode = Schema.RequiredMode.REQUIRED)
private byte[] content;
@Schema(description = "文件名", example = "2026-06_张三.xlsx")
private String name;
@Schema(description = "目录", example = "rdms/performance")
private String directory;
@Schema(description = "MIME 类型", example = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
private String type;
}

View File

@@ -0,0 +1,23 @@
package com.njcn.rdms.module.system.api.user;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.module.system.api.user.dto.UserSignatureRespDTO;
import com.njcn.rdms.module.system.enums.ApiConstants;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = ApiConstants.NAME)
@Tag(name = "RPC 服务 - 用户电子签名")
public interface UserSignatureApi {
String PREFIX = ApiConstants.PREFIX + "/user-signature";
@GetMapping(PREFIX + "/get")
@Operation(summary = "根据用户 ID 查询电子签名")
@Parameter(name = "userId", description = "用户 ID", required = true)
CommonResult<UserSignatureRespDTO> getByUserId(@RequestParam("userId") Long userId);
}

View File

@@ -0,0 +1,27 @@
package com.njcn.rdms.module.system.api.user.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "RPC 服务 - 用户电子签名 Response DTO")
@Data
public class UserSignatureRespDTO {
@Schema(description = "主键", example = "1")
private Long id;
@Schema(description = "用户 ID", example = "1024")
private Long userId;
@Schema(description = "文件 ID", example = "2048")
private Long fileId;
@Schema(description = "文件名", example = "zhangsan-sign.png")
private String fileName;
@Schema(description = "状态", example = "0")
private Integer status;
@Schema(description = "备注")
private String remark;
}

View File

@@ -106,6 +106,7 @@ public interface ErrorCodeConstants {
ErrorCode FILE_PATH_EXISTS = new ErrorCode(1_001_003_000, "文件路径已存在"); ErrorCode FILE_PATH_EXISTS = new ErrorCode(1_001_003_000, "文件路径已存在");
ErrorCode FILE_NOT_EXISTS = new ErrorCode(1_001_003_001, "文件不存在"); ErrorCode FILE_NOT_EXISTS = new ErrorCode(1_001_003_001, "文件不存在");
ErrorCode FILE_IS_EMPTY = new ErrorCode(1_001_003_002, "文件为空"); ErrorCode FILE_IS_EMPTY = new ErrorCode(1_001_003_002, "文件为空");
ErrorCode FILE_DELETE_NO_PERMISSION = new ErrorCode(1_001_003_003, "无权删除该文件,仅可删除本人上传的文件");
// ========== OAuth2 客户端 1-002-020-000 ========= // ========== OAuth2 客户端 1-002-020-000 =========
ErrorCode OAUTH2_CLIENT_NOT_EXISTS = new ErrorCode(1_002_020_000, "OAuth2 客户端不存在"); ErrorCode OAUTH2_CLIENT_NOT_EXISTS = new ErrorCode(1_002_020_000, "OAuth2 客户端不存在");

View File

@@ -2,6 +2,7 @@ package com.njcn.rdms.module.system.api.file;
import com.njcn.rdms.framework.common.pojo.CommonResult; import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.util.object.BeanUtils; import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.module.system.api.file.dto.FileContentCreateReqDTO;
import com.njcn.rdms.module.system.api.file.dto.FileRespDTO; import com.njcn.rdms.module.system.api.file.dto.FileRespDTO;
import com.njcn.rdms.module.system.dal.dataobject.file.FileDO; import com.njcn.rdms.module.system.dal.dataobject.file.FileDO;
import com.njcn.rdms.module.system.service.file.FileService; import com.njcn.rdms.module.system.service.file.FileService;
@@ -45,4 +46,10 @@ public class FileApiImpl implements FileApi {
} }
} }
@Override
public CommonResult<Long> createFileByContent(FileContentCreateReqDTO reqDTO) {
FileDO file = fileService.createFile(reqDTO.getContent(), reqDTO.getName(), reqDTO.getDirectory(), reqDTO.getType());
return success(file.getId());
}
} }

View File

@@ -0,0 +1,28 @@
package com.njcn.rdms.module.system.api.user;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.module.system.api.user.dto.UserSignatureRespDTO;
import com.njcn.rdms.module.system.dal.dataobject.user.UserSignatureDO;
import com.njcn.rdms.module.system.service.user.UserSignatureService;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.annotation.Resource;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RestController;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
@RestController
@Validated
@Hidden
public class UserSignatureApiImpl implements UserSignatureApi {
@Resource
private UserSignatureService userSignatureService;
@Override
public CommonResult<UserSignatureRespDTO> getByUserId(Long userId) {
UserSignatureDO signature = userSignatureService.getByUserId(userId);
return success(BeanUtils.toBean(signature, UserSignatureRespDTO.class));
}
}

View File

@@ -84,7 +84,8 @@ public class FileController {
@DeleteMapping("/delete") @DeleteMapping("/delete")
@Operation(summary = "删除文件") @Operation(summary = "删除文件")
@Parameter(name = "id", description = "编号", required = true) @Parameter(name = "id", description = "编号", required = true)
@PreAuthorize("@ss.hasPermission('system:file:delete')") // 权限下沉到 FileService上传者本人可删自己上传的文件其余需 system:file:delete 权限。
// 故意不挂 @PreAuthorize否则无该权限码的上传者会在进方法体前被 AOP 挡死(口径同项目域"属主放行+权限码兜底")。
public CommonResult<Boolean> deleteFile(@RequestParam("id") Long id) throws Exception { public CommonResult<Boolean> deleteFile(@RequestParam("id") Long id) throws Exception {
fileService.deleteFile(id); fileService.deleteFile(id);
return success(true); return success(true);
@@ -93,7 +94,7 @@ public class FileController {
@DeleteMapping("/delete-list") @DeleteMapping("/delete-list")
@Operation(summary = "批量删除文件") @Operation(summary = "批量删除文件")
@Parameter(name = "ids", description = "编号列表", required = true) @Parameter(name = "ids", description = "编号列表", required = true)
@PreAuthorize("@ss.hasPermission('system:file:delete')") // 权限下沉到 FileService逐个按上传者归属校验其余需 system:file:delete 权限(见 deleteFile 注释)。
public CommonResult<Boolean> deleteFileList(@RequestParam("ids") List<Long> ids) throws Exception { public CommonResult<Boolean> deleteFileList(@RequestParam("ids") List<Long> ids) throws Exception {
fileService.deleteFileList(ids); fileService.deleteFileList(ids);
return success(true); return success(true);
@@ -131,6 +132,13 @@ public class FileController {
// https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1432/ // https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1432/
path = URLUtil.decode(path, StandardCharsets.UTF_8, false); path = URLUtil.decode(path, StandardCharsets.UTF_8, false);
FileDO file = fileService.getFileByConfigIdAndPath(configId, path);
if (file == null) {
log.warn("[getFileContent][configId({}) path({}) 文件不存在]", configId, path);
response.setStatus(HttpStatus.NOT_FOUND.value());
return;
}
// 读取内容 // 读取内容
byte[] content = fileService.getFileContent(configId, path); byte[] content = fileService.getFileContent(configId, path);
if (content == null) { if (content == null) {
@@ -138,7 +146,7 @@ public class FileController {
response.setStatus(HttpStatus.NOT_FOUND.value()); response.setStatus(HttpStatus.NOT_FOUND.value());
return; return;
} }
writeAttachment(response, path, content); writeAttachment(response, StrUtil.blankToDefault(file.getName(), file.getPath()), content);
} }
@GetMapping("/page") @GetMapping("/page")

View File

@@ -136,7 +136,7 @@ public class UserManagementRelationController {
* 获得用户管理链路信息 * 获得用户管理链路信息
* *
* 根据主键ID查询单条用户管理链路记录 * 根据主键ID查询单条用户管理链路记录
* 权限要求:system:user-management-relation:query * 权限要求:仅需登录,不做权限码校验
* *
* @param id 关系记录主键ID * @param id 关系记录主键ID
* @return 用户管理链路详情 * @return 用户管理链路详情
@@ -144,7 +144,6 @@ public class UserManagementRelationController {
@GetMapping(value = "/get") @GetMapping(value = "/get")
@Operation(summary = "获得用户管理链路信息") @Operation(summary = "获得用户管理链路信息")
@Parameter(name = "id", description = "关系编号", required = true, example = "1024") @Parameter(name = "id", description = "关系编号", required = true, example = "1024")
@PreAuthorize("@ss.hasPermission('system:user-management-relation:query')")
public CommonResult<UserManagementRelationRespVO> getUserManagementRelation(@RequestParam("id") Long id) { public CommonResult<UserManagementRelationRespVO> getUserManagementRelation(@RequestParam("id") Long id) {
UserManagementRelationDO relation = userManagementRelationService.getRelation(id); UserManagementRelationDO relation = userManagementRelationService.getRelation(id);
return success(BeanUtils.toBean(relation, UserManagementRelationRespVO.class)); return success(BeanUtils.toBean(relation, UserManagementRelationRespVO.class));
@@ -154,14 +153,13 @@ public class UserManagementRelationController {
* 获取用户管理链路列表 * 获取用户管理链路列表
* *
* 根据查询条件查询用户管理链路记录列表 * 根据查询条件查询用户管理链路记录列表
* 权限要求:system:user-management-relation:query * 权限要求:仅需登录,不做权限码校验
* *
* @param reqVO 查询条件VO * @param reqVO 查询条件VO
* @return 用户管理链路列表 * @return 用户管理链路列表
*/ */
@GetMapping("/query") @GetMapping("/query")
@Operation(summary = "获取用户管理链路列表") @Operation(summary = "获取用户管理链路列表")
@PreAuthorize("@ss.hasPermission('system:user-management-relation:query')")
public CommonResult<List<UserManagementRelationTreeRespVO>> getUserManagementRelationQuery(@Validated UserManagementRelationQueryReqVO reqVO) { public CommonResult<List<UserManagementRelationTreeRespVO>> getUserManagementRelationQuery(@Validated UserManagementRelationQueryReqVO reqVO) {
List<UserManagementRelationTreeRespVO> list = userManagementRelationService.getRelationQuery(reqVO); List<UserManagementRelationTreeRespVO> list = userManagementRelationService.getRelationQuery(reqVO);
return success(list); return success(list);
@@ -176,7 +174,6 @@ public class UserManagementRelationController {
@GetMapping("/direct-manager") @GetMapping("/direct-manager")
@Operation(summary = "获得某用户当前生效的直属上级") @Operation(summary = "获得某用户当前生效的直属上级")
@Parameter(name = "userId", description = "用户ID", required = true, example = "1024") @Parameter(name = "userId", description = "用户ID", required = true, example = "1024")
@PreAuthorize("@ss.hasPermission('system:user-management-relation:query')")
public CommonResult<UserSimpleRespVO> getDirectManager(@RequestParam("userId") Long userId) { public CommonResult<UserSimpleRespVO> getDirectManager(@RequestParam("userId") Long userId) {
AdminUserDO manager = userManagementRelationService.getDirectManager(userId); AdminUserDO manager = userManagementRelationService.getDirectManager(userId);
if (manager == null) { if (manager == null) {
@@ -193,7 +190,6 @@ public class UserManagementRelationController {
*/ */
@GetMapping("/candidate-users") @GetMapping("/candidate-users")
@Operation(summary = "获取未绑定直属上级的候选下级用户列表") @Operation(summary = "获取未绑定直属上级的候选下级用户列表")
@PreAuthorize("@ss.hasPermission('system:user-management-relation:query')")
public CommonResult<List<UserSimpleRespVO>> getCandidateSubordinateUsers() { public CommonResult<List<UserSimpleRespVO>> getCandidateSubordinateUsers() {
List<AdminUserDO> users = userManagementRelationService.getCandidateSubordinateUsers(); List<AdminUserDO> users = userManagementRelationService.getCandidateSubordinateUsers();
Map<Long, DeptDO> deptMap = deptService.getDeptMap(convertList(users, AdminUserDO::getDeptId)); Map<Long, DeptDO> deptMap = deptService.getDeptMap(convertList(users, AdminUserDO::getDeptId));
@@ -208,7 +204,6 @@ public class UserManagementRelationController {
@GetMapping("/direct-subordinates") @GetMapping("/direct-subordinates")
@Operation(summary = "获取某用户当前生效的直属下级列表") @Operation(summary = "获取某用户当前生效的直属下级列表")
@Parameter(name = "userId", description = "用户ID", required = true, example = "1024") @Parameter(name = "userId", description = "用户ID", required = true, example = "1024")
//@PreAuthorize("@ss.hasPermission('system:user-management-relation:query')")
public CommonResult<List<UserSimpleRespVO>> getDirectSubordinates(@RequestParam("userId") Long userId) { public CommonResult<List<UserSimpleRespVO>> getDirectSubordinates(@RequestParam("userId") Long userId) {
List<UserManagementRelationDO> relations = userManagementRelationService.getRelationListByManagerUserId(userId); List<UserManagementRelationDO> relations = userManagementRelationService.getRelationListByManagerUserId(userId);
if (relations.isEmpty()) { if (relations.isEmpty()) {
@@ -250,13 +245,12 @@ public class UserManagementRelationController {
* - 中间节点:有上级也有下级 * - 中间节点:有上级也有下级
* - 叶子节点:基层员工,没有下级 * - 叶子节点:基层员工,没有下级
* *
* 权限要求:system:user-management-relation:query * 权限要求:仅需登录,不做权限码校验
* *
* @return 用户管理链路树形列表 * @return 用户管理链路树形列表
*/ */
@GetMapping("/tree") @GetMapping("/tree")
@Operation(summary = "获取用户管理链路树形结构", description = "用于前端树形控件展示,包含用户的上下级层级关系") @Operation(summary = "获取用户管理链路树形结构", description = "用于前端树形控件展示,包含用户的上下级层级关系")
@PreAuthorize("@ss.hasPermission('system:user-management-relation:query')")
public CommonResult<List<UserManagementRelationTreeRespVO>> getUserManagementRelationTree(@Validated UserManagementRelationQueryReqVO reqVO) { public CommonResult<List<UserManagementRelationTreeRespVO>> getUserManagementRelationTree(@Validated UserManagementRelationQueryReqVO reqVO) {
return success(userManagementRelationService.getRelationTree(reqVO)); return success(userManagementRelationService.getRelationTree(reqVO));
} }

View File

@@ -0,0 +1,70 @@
package com.njcn.rdms.module.system.controller.admin.user;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.module.system.controller.admin.user.vo.signature.UserSignatureRespVO;
import com.njcn.rdms.module.system.controller.admin.user.vo.signature.UserSignatureSaveReqVO;
import com.njcn.rdms.module.system.dal.dataobject.user.UserSignatureDO;
import com.njcn.rdms.module.system.service.user.UserSignatureService;
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.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
import static com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
@Tag(name = "管理后台 - 用户电子签名")
@RestController
@RequestMapping("/system/user-signatures")
@Validated
public class UserSignatureController {
@Resource
private UserSignatureService userSignatureService;
@GetMapping("/get")
@Operation(summary = "获取用户电子签名")
@Parameter(name = "userId", description = "用户 ID", required = true)
@PreAuthorize("@ss.hasPermission('system:user-signature:query')")
public CommonResult<UserSignatureRespVO> get(@RequestParam("userId") Long userId) {
return success(toRespVO(userSignatureService.getByUserId(userId)));
}
@PostMapping("/save")
@Operation(summary = "保存用户电子签名")
@PreAuthorize("@ss.hasPermission('system:user-signature:update')")
public CommonResult<Boolean> save(@Valid @RequestBody UserSignatureSaveReqVO reqVO) {
userSignatureService.save(reqVO.getUserId(), reqVO.getFileId(), reqVO.getFileName(), reqVO.getStatus(), reqVO.getRemark());
return success(true);
}
@DeleteMapping("/delete")
@Operation(summary = "删除用户电子签名")
@Parameter(name = "userId", description = "用户 ID", required = true)
@PreAuthorize("@ss.hasPermission('system:user-signature:update')")
public CommonResult<Boolean> delete(@RequestParam("userId") Long userId) {
userSignatureService.deleteByUserId(userId);
return success(true);
}
@GetMapping("/my")
@Operation(summary = "获取当前登录用户电子签名")
public CommonResult<UserSignatureRespVO> my() {
return success(toRespVO(userSignatureService.getByUserId(getLoginUserId())));
}
private UserSignatureRespVO toRespVO(UserSignatureDO signature) {
return BeanUtils.toBean(signature, UserSignatureRespVO.class);
}
}

View File

@@ -0,0 +1,27 @@
package com.njcn.rdms.module.system.controller.admin.user.vo.signature;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - 用户电子签名 Response VO")
@Data
public class UserSignatureRespVO {
private Long id;
private Long userId;
private Long fileId;
private String fileName;
private Integer status;
private String remark;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,30 @@
package com.njcn.rdms.module.system.controller.admin.user.vo.signature;
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 UserSignatureSaveReqVO {
@Schema(description = "用户 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "用户 ID 不能为空")
private Long userId;
@Schema(description = "电子签文件 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048")
@NotNull(message = "电子签文件 ID 不能为空")
private Long fileId;
@Schema(description = "文件名", example = "zhangsan-sign.png")
@Size(max = 255, message = "文件名长度不能超过 255 个字符")
private String fileName;
@Schema(description = "状态", example = "0")
private Integer status;
@Schema(description = "备注")
@Size(max = 255, message = "备注长度不能超过 255 个字符")
private String remark;
}

View File

@@ -0,0 +1,29 @@
package com.njcn.rdms.module.system.dal.dataobject.user;
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("system_user_signature")
@Data
@EqualsAndHashCode(callSuper = true)
public class UserSignatureDO extends BaseDO {
@TableId
private Long id;
private Long userId;
private Long fileId;
private String fileName;
private Integer status;
private String remark;
}

View File

@@ -0,0 +1,20 @@
package com.njcn.rdms.module.system.dal.mysql.user;
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.system.dal.dataobject.user.UserSignatureDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface UserSignatureMapper extends BaseMapperX<UserSignatureDO> {
default UserSignatureDO selectByUserId(Long userId) {
return selectOne(new LambdaQueryWrapperX<UserSignatureDO>()
.eq(UserSignatureDO::getUserId, userId));
}
default int deleteByUserId(Long userId) {
return delete(new LambdaQueryWrapperX<UserSignatureDO>()
.eq(UserSignatureDO::getUserId, userId));
}
}

View File

@@ -114,9 +114,10 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
@Override @Override
public String presignGetUrl(String url, Integer expirationSeconds) { public String presignGetUrl(String url, Integer expirationSeconds) {
// 1. 将 url 转换为 path // 1. 将入参转换为对象 key
String path = StrUtil.removePrefix(url, config.getDomain() + "/"); // - upload(...) 这里传进来的是原始 path未编码绝不能先 decode
path = HttpUtils.decodeUtf8(HttpUtils.removeUrlQuery(path)); // - 历史记录 / 对外接口传进来的可能是完整 URL可能已编码这时才需要 decode
String path = resolveObjectKey(url);
// 2.1 情况一:公开访问:无需签名 // 2.1 情况一:公开访问:无需签名
// 考虑到老版本的兼容,所以必须是 config.getEnablePublicAccess() 为 false 时,才进行签名 // 考虑到老版本的兼容,所以必须是 config.getEnablePublicAccess() 为 false 时,才进行签名
@@ -134,6 +135,23 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
return signedUrl.toString(); return signedUrl.toString();
} }
private String resolveObjectKey(String urlOrPath) {
if (!HttpUtil.isHttp(urlOrPath) && !HttpUtil.isHttps(urlOrPath)) {
return urlOrPath;
}
String cleaned = HttpUtils.removeUrlQuery(urlOrPath);
String path = StrUtil.removePrefix(cleaned, config.getDomain() + "/");
if (StrUtil.equals(path, cleaned)) {
URI uri = URI.create(cleaned);
path = StrUtil.removePrefix(StrUtil.nullToEmpty(uri.getPath()), "/");
if (Boolean.TRUE.equals(config.getEnablePathStyleAccess())) {
path = StrUtil.removePrefix(path, config.getBucket() + "/");
}
}
return HttpUtils.decodeUtf8(path);
}
/** /**
* 基于 bucket + endpoint 构建访问的 Domain 地址 * 基于 bucket + endpoint 构建访问的 Domain 地址
* *

View File

@@ -63,6 +63,15 @@ public interface FileService {
Long createFile(FileCreateReqVO createReqVO); Long createFile(FileCreateReqVO createReqVO);
FileDO getFile(Long id); FileDO getFile(Long id);
/**
* 按 (configId, path) 获取文件记录。
*
* @param configId 文件配置编号
* @param path 文件存储路径
* @return 文件记录,不存在返回 null
*/
FileDO getFileByConfigIdAndPath(Long configId, String path);
/** /**
* 通过文件 URL 获得文件。 * 通过文件 URL 获得文件。
* *

View File

@@ -3,12 +3,15 @@ package com.njcn.rdms.module.system.service.file;
import cn.hutool.core.date.LocalDateTimeUtil; import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.io.FileUtil; import cn.hutool.core.io.FileUtil;
import cn.hutool.core.lang.Assert; import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil; import cn.hutool.crypto.digest.DigestUtil;
import com.google.common.annotations.VisibleForTesting; import com.google.common.annotations.VisibleForTesting;
import com.njcn.rdms.framework.common.pojo.PageResult; import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.common.util.http.HttpUtils; import com.njcn.rdms.framework.common.util.http.HttpUtils;
import com.njcn.rdms.framework.common.util.object.BeanUtils; import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.framework.security.core.service.SecurityFrameworkService;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.system.controller.admin.file.vo.file.FileCreateReqVO; import com.njcn.rdms.module.system.controller.admin.file.vo.file.FileCreateReqVO;
import com.njcn.rdms.module.system.controller.admin.file.vo.file.FilePageReqVO; import com.njcn.rdms.module.system.controller.admin.file.vo.file.FilePageReqVO;
import com.njcn.rdms.module.system.controller.admin.file.vo.file.FilePresignedUrlRespVO; import com.njcn.rdms.module.system.controller.admin.file.vo.file.FilePresignedUrlRespVO;
@@ -26,6 +29,7 @@ import java.util.List;
import static cn.hutool.core.date.DatePattern.PURE_DATE_PATTERN; import static cn.hutool.core.date.DatePattern.PURE_DATE_PATTERN;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception; import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
import static com.njcn.rdms.module.system.enums.ErrorCodeConstants.FILE_DELETE_NO_PERMISSION;
import static com.njcn.rdms.module.system.enums.ErrorCodeConstants.FILE_NOT_EXISTS; import static com.njcn.rdms.module.system.enums.ErrorCodeConstants.FILE_NOT_EXISTS;
/** /**
@@ -42,14 +46,6 @@ public class FileServiceImpl implements FileService {
* 目的:按照日期,进行分目录 * 目的:按照日期,进行分目录
*/ */
static boolean PATH_PREFIX_DATE_ENABLE = true; static boolean PATH_PREFIX_DATE_ENABLE = true;
/**
* 上传文件的后缀,是否包含时间戳
* <p>
* 目的:保证文件的唯一性,避免覆盖
* 定制:可按需调整成 UUID、或者其他方式
*/
static boolean PATH_SUFFIX_TIMESTAMP_ENABLE = true;
@Resource @Resource
private FileConfigService fileConfigService; private FileConfigService fileConfigService;
@@ -59,6 +55,9 @@ public class FileServiceImpl implements FileService {
@Resource @Resource
private FileContentMapper fileContentMapper; private FileContentMapper fileContentMapper;
@Resource
private SecurityFrameworkService securityFrameworkService;
@Override @Override
public PageResult<FileDO> getFilePage(FilePageReqVO pageReqVO) { public PageResult<FileDO> getFilePage(FilePageReqVO pageReqVO) {
return fileMapper.selectPage(pageReqVO); return fileMapper.selectPage(pageReqVO);
@@ -103,34 +102,27 @@ public class FileServiceImpl implements FileService {
@VisibleForTesting @VisibleForTesting
String generateUploadPath(String name, String directory) { String generateUploadPath(String name, String directory) {
// 1. 生成前缀、后缀 // 1. 生成前缀,并将存储文件名切换为安全 key
// path 仅承担对象存储 key / 路径职责,不再复用原文件名,避免 %、&、中文等字符进入 URI 路径。
String prefix = null; String prefix = null;
if (PATH_PREFIX_DATE_ENABLE) { if (PATH_PREFIX_DATE_ENABLE) {
prefix = LocalDateTimeUtil.format(LocalDateTimeUtil.now(), PURE_DATE_PATTERN); prefix = LocalDateTimeUtil.format(LocalDateTimeUtil.now(), PURE_DATE_PATTERN);
} }
String suffix = null; String ext = FileUtil.extName(name);
if (PATH_SUFFIX_TIMESTAMP_ENABLE) { String storageName = IdUtil.fastSimpleUUID();
suffix = String.valueOf(System.currentTimeMillis()); if (StrUtil.isNotEmpty(ext)) {
storageName = storageName + StrUtil.DOT + ext;
} }
// 2.1 先拼接 suffix // 2. 先拼接 prefix
if (StrUtil.isNotEmpty(suffix)) {
String ext = FileUtil.extName(name);
if (StrUtil.isNotEmpty(ext)) {
name = FileUtil.mainName(name) + StrUtil.C_UNDERLINE + suffix + StrUtil.DOT + ext;
} else {
name = name + StrUtil.C_UNDERLINE + suffix;
}
}
// 2.2 再拼接 prefix 前缀
if (StrUtil.isNotEmpty(prefix)) { if (StrUtil.isNotEmpty(prefix)) {
name = prefix + StrUtil.SLASH + name; storageName = prefix + StrUtil.SLASH + storageName;
} }
// 2.3 最后拼接 directory 目录 // 3. 最后拼接 directory 目录
if (StrUtil.isNotEmpty(directory)) { if (StrUtil.isNotEmpty(directory)) {
name = directory + StrUtil.SLASH + name; storageName = directory + StrUtil.SLASH + storageName;
} }
return name; return storageName;
} }
@Override @Override
@@ -166,6 +158,14 @@ public class FileServiceImpl implements FileService {
return validateFileExists(id); return validateFileExists(id);
} }
@Override
public FileDO getFileByConfigIdAndPath(Long configId, String path) {
if (configId == null || StrUtil.isBlank(path)) {
return null;
}
return fileMapper.selectByConfigIdAndPath(configId, path);
}
@Override @Override
public FileDO getFileByUrl(String url) { public FileDO getFileByUrl(String url) {
if (StrUtil.isBlank(url)) { if (StrUtil.isBlank(url)) {
@@ -178,6 +178,8 @@ public class FileServiceImpl implements FileService {
public void deleteFile(Long id) throws Exception { public void deleteFile(Long id) throws Exception {
// 校验存在 // 校验存在
FileDO file = validateFileExists(id); FileDO file = validateFileExists(id);
// 校验删除权限:上传者本人,或具备文件删除权限者
validateFileDeletePermission(file);
// 从文件存储器中删除 // 从文件存储器中删除
FileClient client = fileConfigService.getFileClient(file.getConfigId()); FileClient client = fileConfigService.getFileClient(file.getConfigId());
@@ -193,6 +195,8 @@ public class FileServiceImpl implements FileService {
public void deleteFileList(List<Long> ids) { public void deleteFileList(List<Long> ids) {
// 删除文件 // 删除文件
List<FileDO> files = fileMapper.selectByIds(ids); List<FileDO> files = fileMapper.selectByIds(ids);
// 逐个校验删除权限,任一越权即整体拒绝
files.forEach(this::validateFileDeletePermission);
for (FileDO file : files) { for (FileDO file : files) {
// 获取客户端 // 获取客户端
FileClient client = fileConfigService.getFileClient(file.getConfigId()); FileClient client = fileConfigService.getFileClient(file.getConfigId());
@@ -213,6 +217,25 @@ public class FileServiceImpl implements FileService {
return fileDO; return fileDO;
} }
/**
* 校验文件删除权限。
* <p>
* 口径:上传者本人可删除自己上传的文件;其余情况需具备文件管理删除权限 {@code system:file:delete}(运维 / 管理员)。
* 该接口按 id 删除、不校验业务归属,故不能对所有登录用户放开,只对"本人上传"放行。
*/
private void validateFileDeletePermission(FileDO file) {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
// 上传者本人放行creator 存的是上传时的登录用户 id
if (loginUserId != null && String.valueOf(loginUserId).equals(file.getCreator())) {
return;
}
// 兜底:具备文件删除权限者(运维 / 管理员)可删除任意文件
if (securityFrameworkService.hasPermission("system:file:delete")) {
return;
}
throw exception(FILE_DELETE_NO_PERMISSION);
}
@Override @Override
public byte[] getFileContent(Long configId, String path) throws Exception { public byte[] getFileContent(Long configId, String path) throws Exception {
// 软删 / 不存在的记录直接拒绝 // 软删 / 不存在的记录直接拒绝

View File

@@ -563,8 +563,8 @@ public class AdminUserServiceImpl implements AdminUserService {
url = client.upload(content, path, type); url = client.upload(content, path, type);
//删除旧的 // 旧头像删除失败不应影响新头像上传;而且 avatar 字段存的是 URL不能直接当 path 删。
client.delete(user.getAvatar()); deleteOldAvatarQuietly(client, user);
// 3. 保存到数据库 // 3. 保存到数据库
fileDO = new FileDO().setConfigId(client.getId()) fileDO = new FileDO().setConfigId(client.getId())
@@ -574,11 +574,31 @@ public class AdminUserServiceImpl implements AdminUserService {
user.setAvatar(fileDO.getUrl()); user.setAvatar(fileDO.getUrl());
this.userMapper.updateById(user); this.userMapper.updateById(user);
} catch (Exception e) { } catch (Exception e) {
log.warn("头像上传失败userId={}, avatar={}, fileName={}, contentType={}",
user == null ? null : user.getId(),
user == null ? null : user.getAvatar(),
name, type, e);
throw exception(USER_AVATAR_UPLOAD_FAILED); throw exception(USER_AVATAR_UPLOAD_FAILED);
} }
return fileDO; return fileDO;
} }
private void deleteOldAvatarQuietly(FileClient client, AdminUserDO user) {
if (client == null || user == null || StrUtil.isBlank(user.getAvatar())) {
return;
}
try {
FileDO oldAvatar = fileMapper.selectByUrl(user.getAvatar());
if (oldAvatar == null || StrUtil.isBlank(oldAvatar.getPath())) {
log.warn("删除旧头像时未找到文件记录userId={}, avatar={}", user.getId(), user.getAvatar());
return;
}
client.delete(oldAvatar.getPath());
} catch (Exception ex) {
log.warn("删除旧头像失败userId={}, avatar={}", user.getId(), user.getAvatar(), ex);
}
}
private void validateImageFile(MultipartFile file) { private void validateImageFile(MultipartFile file) {
// 检查文件类型 // 检查文件类型
String contentType = file.getContentType(); String contentType = file.getContentType();

View File

@@ -0,0 +1,12 @@
package com.njcn.rdms.module.system.service.user;
import com.njcn.rdms.module.system.dal.dataobject.user.UserSignatureDO;
public interface UserSignatureService {
UserSignatureDO getByUserId(Long userId);
void save(Long userId, Long fileId, String fileName, Integer status, String remark);
void deleteByUserId(Long userId);
}

View File

@@ -0,0 +1,63 @@
package com.njcn.rdms.module.system.service.user;
import com.njcn.rdms.framework.common.enums.CommonStatusEnum;
import com.njcn.rdms.module.system.dal.dataobject.file.FileDO;
import com.njcn.rdms.module.system.dal.dataobject.user.UserSignatureDO;
import com.njcn.rdms.module.system.dal.mysql.user.UserSignatureMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import static com.njcn.rdms.framework.common.util.collection.CollectionUtils.singleton;
@Service
public class UserSignatureServiceImpl implements UserSignatureService {
@Resource
private UserSignatureMapper userSignatureMapper;
@Resource
private AdminUserService adminUserService;
@Resource
private com.njcn.rdms.module.system.service.file.FileService fileService;
@Override
public UserSignatureDO getByUserId(Long userId) {
if (userId == null) {
return null;
}
return userSignatureMapper.selectByUserId(userId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void save(Long userId, Long fileId, String fileName, Integer status, String remark) {
adminUserService.validateUserList(singleton(userId));
FileDO file = fileService.getFile(fileId);
UserSignatureDO existing = userSignatureMapper.selectByUserId(userId);
UserSignatureDO target = new UserSignatureDO();
if (existing != null) {
target.setId(existing.getId());
}
target.setUserId(userId);
target.setFileId(fileId);
target.setFileName(StringUtils.hasText(fileName) ? fileName.trim() : file.getName());
target.setStatus(status == null ? CommonStatusEnum.ENABLE.getStatus() : status);
target.setRemark(StringUtils.hasText(remark) ? remark.trim() : null);
if (existing == null) {
userSignatureMapper.insert(target);
} else {
userSignatureMapper.updateById(target);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteByUserId(Long userId) {
if (userId == null) {
return;
}
userSignatureMapper.deleteByUserId(userId);
}
}