17 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
dk
7f832d819e feat(workreport): 添加工作报告待提交提醒功能
- 新增 selectListByPendingSubmitReminder 方法用于查询待提交的工作报告
- 添加通知模板常量用于待审批通知功能
- 替换 ChronoField 为 IsoFields 以正确处理基于周的年份计算
- 实现个人工作报告待直属上级审批的通知发布逻辑
- 实现项目报告待审批通知的发布功能
- 创建 WorkReportPendingSubmitRemindScheduler 定时任务类
- 实现 WorkReportPendingSubmitRemindService 服务类处理提醒逻辑
- 添加定时任务每晚 8 点检查并提醒逾期未提交的报告
- 支持周报、月报和项目报告三种类型的待提交提醒
- 实现基于截止时间过滤并按创建时间排序的查询逻辑
2026-07-02 16:33:33 +08:00
dk
0bd04867b3 Merge remote-tracking branch 'origin/main' 2026-07-01 17:07:09 +08:00
dk
77b660cc34 fix(工作报告、我的绩效、加班申请、日志管理): 优化报表导出逻辑并补充日志用户昵称,团队模式查询现正确包含当前用户数据。 2026-07-01 17:06:44 +08:00
671d43a3f8 refactor(permission): 简化对象权限检查配置
- 移除产品需求查询权限常量定义
- 移除项目需求查询权限常量定义
- 将权限检查注解从具体权限改为通用可访问性检查
- 统一所有查询方法的权限验证方式
- 简化权限检查逻辑,提高代码可维护性
- 减少权限常量定义,降低配置复杂度
2026-06-30 15:57:54 +08:00
120 changed files with 3515 additions and 1218 deletions

View File

@@ -78,7 +78,8 @@
红线:
- **对象内接口绝不能挂 `@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 没注解 = 无鉴权"。

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_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_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 ==========
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_ACTION_REASON_REQUIRED = new ErrorCode(1_008_011_018, "「{}」操作必须填写原因");
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_EXPORT = "project:performance-sheet:export";
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)。
* 实际拦截:service 内按 task.parentTaskId 分流走 checkOwnerOrProjectPermission
* (一级任务 → execution.ownerId 字段身份;子任务 → parentTask.ownerId 字段身份;不命中字段时回落本权限码)
* 实际拦截:项目负责人 / 执行负责人 / 任务负责人 / 父任务负责人直接放行;
* 不命中业务身份时回落本权限码,作为管理员兜底
*/
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_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_CREATE = "project:work-report:create";
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_PROJECT_OWNER = "project:work-report:project-owner";
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.PerformanceSheetCreateReqVO;
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.PerformanceSheetPrefillRespVO;
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.PerformanceSheetSignOffReqVO;
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.PerformanceSheetStatusLogRespVO;
@@ -123,6 +127,21 @@ public class PerformanceSheetController {
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")
@Operation(summary = "下载单条绩效表")
@PreAuthorize("@ss.hasPermission('" + PerformanceConstants.PERMISSION_SHEET_EXPORT + "')")
@@ -162,6 +181,14 @@ public class PerformanceSheetController {
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")
@Operation(summary = "获取月报审批绩效结果")
@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 lombok.Data;
import java.util.List;
@Schema(description = "管理后台 - 绩效分数单元格映射 Response VO")
@Data
public class PerformanceScoreCellMappingRespVO {
@@ -15,4 +17,16 @@ public class PerformanceScoreCellMappingRespVO {
@Schema(description = "附加得分总计单元格", example = "L10")
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")
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 String statusCode;
private String statusName;
private String signOffStatus;
private String signOffStatusName;
private BigDecimal actualScoreTotal;
private BigDecimal baseScoreTotal;
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));
}
@GetMapping("/execution-options")
@Operation(summary = "获取执行可关联的项目需求下拉选项")
public CommonResult<PageResult<ProjectRequirementRespVO>> getExecutionRequirementOptions(
@Valid ProjectRequirementPageReqVO pageReqVO) {
return success(requirementService.getExecutionRequirementOptions(pageReqVO));
}
@PostMapping("/change-status")
@Operation(summary = "变更需求状态")
public CommonResult<Boolean> changeRequirementStatus(@Valid @RequestBody ProjectRequirementStatusActionReqVO reqVO) {

View File

@@ -39,7 +39,7 @@ public class MyWorkbenchController {
}
@GetMapping("/worklog-week")
@Operation(summary = "我的工时周聚合(逐日为均摊推算值;weekStart 任意日期自动归一到周一)")
@Operation(summary = "我的工时周聚合(返回周一~周日;按周填报只均摊到工作日,周末单天工时保留在周末)")
public CommonResult<MyWorklogWeekRespVO> getMyWorklogWeek(
@RequestParam("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")
private LocalDate weekStart;
@Schema(description = "周一~周逐日工时(固定 5 元素,均摊推算,保留 2 位小数)", requiredMode = Schema.RequiredMode.REQUIRED)
@Schema(description = "周一~周逐日工时(固定 7 元素,按填报日期段推算,保留 2 位小数)", requiredMode = Schema.RequiredMode.REQUIRED)
private List<BigDecimal> dailyHours;
@Schema(description = "本周工时按归属分布(hours 降序,personal/other 排在项目后)", requiredMode = Schema.RequiredMode.REQUIRED)
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 lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
@Schema(description = "管理后台 - 工作台「团队工时周聚合」Response VO")
@Schema(description = "Admin - workbench team worklog week response")
@Data
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;
@Schema(description = "团队成员工时列表;members[0] 恒为当前用户;该周无填报的成员 items 为空数组", requiredMode = Schema.RequiredMode.REQUIRED)
@Schema(description = "Team members", requiredMode = Schema.RequiredMode.REQUIRED)
private List<MemberVO> members;
@Schema(description = "单个成员的周工时")
@Schema(description = "Single member week worklog")
@Data
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)
private Long userId;
@Schema(description = "用户昵称", example = "张三")
@Schema(description = "User nickname", example = "zhangsan")
private String userNickname;
@Schema(description = "本周工时按归属分布", requiredMode = Schema.RequiredMode.REQUIRED)
@Schema(description = "Weekly worklog distribution", requiredMode = Schema.RequiredMode.REQUIRED)
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}")
@Operation(summary = "删除任务(已完成态禁止,三重确认 + 上级 owner 硬卡")
@Operation(summary = "删除任务(已完成态禁止,三重确认 + 多身份授权")
public CommonResult<Boolean> deleteTask(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId,
@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.MonthlyReportRespVO;
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.monthly.MonthlyReportService;
import io.swagger.v3.oas.annotations.Operation;
@@ -111,6 +113,20 @@ public class MonthlyReportController {
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")
@Operation(summary = "提交月报")
@PreAuthorize("@ss.hasPermission('" + WorkReportConstants.PERMISSION_UPDATE + "')")
@@ -137,6 +153,15 @@ public class MonthlyReportController {
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}")
@Operation(summary = "删除月报")
@PreAuthorize("@ss.hasPermission('" + WorkReportConstants.PERMISSION_DELETE + "')")

View File

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

View File

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

View File

@@ -14,4 +14,7 @@ public class MonthlyReportPageReqVO extends WorkReportBasePageReqVO {
@Schema(description = "团队视角下的填报人用户编号列表")
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 String statusCode;
private String statusName;
private String signOffStatus;
private String signOffStatusName;
private Boolean allowEdit;
private Boolean terminal;
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 signOffStatus;
private BigDecimal actualScoreTotal;
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)
private String employeeSignName;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long employeeSignatureFileId;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private LocalDate employeeSignedDate;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String supervisorSignName;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long supervisorSignatureFileId;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private LocalDate supervisorSignedDate;

View File

@@ -47,6 +47,8 @@ public class MonthlyReportDO extends BaseDO {
private String statusCode;
private String signOffStatus;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
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.List;
import java.time.LocalDate;
@Mapper
public interface OvertimeApplicationMapper extends BaseMapperX<OvertimeApplicationDO> {
@@ -53,6 +54,20 @@ public interface OvertimeApplicationMapper extends BaseMapperX<OvertimeApplicati
.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) {
return update(update, new LambdaQueryWrapperX<OvertimeApplicationDO>()
.eq(OvertimeApplicationDO::getId, id)

View File

@@ -1,8 +1,8 @@
package com.njcn.rdms.module.project.dal.mysql.performance;
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.mapper.BaseMapperX;
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.dal.dataobject.performance.PerformanceSheetDO;
@@ -20,6 +20,16 @@ public interface PerformanceSheetMapper extends BaseMapperX<PerformanceSheetDO>
.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) {
LambdaQueryWrapperX<PerformanceSheetDO> wrapper = buildPageQuery(reqVO)
.eq(PerformanceSheetDO::getEmployeeId, employeeId)
@@ -92,6 +102,14 @@ public interface PerformanceSheetMapper extends BaseMapperX<PerformanceSheetDO>
.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) {
return new LambdaQueryWrapperX<PerformanceSheetDO>()
.geIfPresent(PerformanceSheetDO::getPeriodMonth, reqVO.getPeriodMonthStart())
@@ -101,6 +119,7 @@ public interface PerformanceSheetMapper extends BaseMapperX<PerformanceSheetDO>
.likeIfPresent(PerformanceSheetDO::getEmployeeDeptName, reqVO.getEmployeeDeptName())
.eqIfPresent(PerformanceSheetDO::getManagerId, reqVO.getManagerId())
.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));
}
/**
* 仅更新实际开始/结束日期。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
* excludeStatusCodes 为空时不排除任何状态(空集守卫,与既有口径一致)。

View File

@@ -22,20 +22,28 @@ public interface ProjectRequirementMapper extends BaseMapperX<ProjectRequirement
* 分页查询需求列表
*/
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) {
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<>();
if (StringUtils.hasText(reqVO.getTitle())) {
queryWrapper.like(ProjectRequirementDO::getTitle, reqVO.getTitle());
@@ -49,8 +57,11 @@ public interface ProjectRequirementMapper extends BaseMapperX<ProjectRequirement
.inIfPresent(ProjectRequirementDO::getModuleId, reqVO.getModuleIds())
.eqIfPresent(ProjectRequirementDO::getModuleId, reqVO.getModuleId())
.eqIfPresent(ProjectRequirementDO::getParentId, reqVO.getParentId())
.eq(ProjectRequirementDO::getProjectId, reqVO.getProjectId())
.orderByAsc(ProjectRequirementDO::getSort)
.eq(ProjectRequirementDO::getProjectId, reqVO.getProjectId());
if (excludeStatusCodes != null && !excludeStatusCodes.isEmpty()) {
queryWrapper.notIn(ProjectRequirementDO::getStatusCode, excludeStatusCodes);
}
queryWrapper.orderByAsc(ProjectRequirementDO::getSort)
.orderByDesc(ProjectRequirementDO::getCreateTime);
return queryWrapper;
}

View File

@@ -12,6 +12,19 @@ import java.util.List;
@Mapper
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) {
return selectList(new LambdaQueryWrapperX<ProjectStatusLogDO>()
.eq(ProjectStatusLogDO::getProjectId, projectId)

View File

@@ -1,5 +1,6 @@
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.extension.plugins.pagination.Page;
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) {
if (actualStartDate == null && actualEndDate == null) {
if (id == null) {
return 0;
}
ProjectTaskDO update = new ProjectTaskDO();
update.setActualStartDate(actualStartDate);
update.setActualEndDate(actualEndDate);
return update(update, new LambdaQueryWrapperX<ProjectTaskDO>()
return update(null, new LambdaUpdateWrapper<ProjectTaskDO>()
.set(ProjectTaskDO::getActualStartDate, actualStartDate)
.set(ProjectTaskDO::getActualEndDate, actualEndDate)
.eq(ProjectTaskDO::getId, id));
}

View File

@@ -1,6 +1,7 @@
package com.njcn.rdms.module.project.dal.mysql.project.task;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog.TaskWorklogPageReqVO;
@@ -58,6 +59,19 @@ public interface TaskWorklogMapper extends BaseMapperX<TaskWorklogDO> {
""")
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。
*/
@@ -83,6 +97,22 @@ public interface TaskWorklogMapper extends BaseMapperX<TaskWorklogDO> {
.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
* 用于 owner 填报后回查"本人最新一条"以同步任务进度。
@@ -102,6 +132,24 @@ public interface TaskWorklogMapper extends BaseMapperX<TaskWorklogDO> {
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 取首行。
* 用于进度单调性校验(与"前一段"比较。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>()
.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));
}
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,
Long reporterId) {
return update(update, new LambdaQueryWrapperX<MonthlyReportDO>()
@@ -162,9 +170,22 @@ public interface MonthlyReportMapper extends BaseMapperX<MonthlyReportDO> {
.in(MonthlyReportDO::getStatusCode, statuses));
}
default List<MonthlyReportDO> selectListByPendingSubmitReminder(LocalDateTime cutoffTime,
Collection<String> statusCodes) {
if (cutoffTime == null || statusCodes == null || statusCodes.isEmpty()) {
return List.of();
}
return selectList(new LambdaQueryWrapperX<MonthlyReportDO>()
.in(MonthlyReportDO::getStatusCode, statusCodes)
.le(MonthlyReportDO::getCreateTime, cutoffTime)
.orderByAsc(MonthlyReportDO::getCreateTime)
.orderByAsc(MonthlyReportDO::getId));
}
private LambdaQueryWrapperX<MonthlyReportDO> buildPageQuery(MonthlyReportPageReqVO reqVO) {
LambdaQueryWrapperX<MonthlyReportDO> wrapper = new LambdaQueryWrapperX<MonthlyReportDO>()
.eqIfPresent(MonthlyReportDO::getStatusCode, reqVO.getStatusCode())
.eqIfPresent(MonthlyReportDO::getSignOffStatus, reqVO.getSignOffStatus())
.betweenIfPresent(MonthlyReportDO::getPeriodStartDate, reqVO.getPeriodStartDate())
.betweenIfPresent(MonthlyReportDO::getSubmitTime, reqVO.getSubmitTime());
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

@@ -172,6 +172,18 @@ public interface ProjectReportMapper extends BaseMapperX<ProjectReportDO> {
.in(ProjectReportDO::getStatusCode, statuses));
}
default List<ProjectReportDO> selectListByPendingSubmitReminder(LocalDateTime cutoffTime,
Collection<String> statusCodes) {
if (cutoffTime == null || statusCodes == null || statusCodes.isEmpty()) {
return List.of();
}
return selectList(new LambdaQueryWrapperX<ProjectReportDO>()
.in(ProjectReportDO::getStatusCode, statusCodes)
.le(ProjectReportDO::getCreateTime, cutoffTime)
.orderByAsc(ProjectReportDO::getCreateTime)
.orderByAsc(ProjectReportDO::getId));
}
private LambdaQueryWrapperX<ProjectReportDO> buildPageQuery(ProjectReportPageReqVO reqVO) {
LambdaQueryWrapperX<ProjectReportDO> wrapper = new LambdaQueryWrapperX<ProjectReportDO>()
.eqIfPresent(ProjectReportDO::getProjectId, reqVO.getProjectId())

View File

@@ -162,6 +162,18 @@ public interface WeeklyReportMapper extends BaseMapperX<WeeklyReportDO> {
.in(WeeklyReportDO::getStatusCode, statuses));
}
default List<WeeklyReportDO> selectListByPendingSubmitReminder(LocalDateTime cutoffTime,
Collection<String> statusCodes) {
if (cutoffTime == null || statusCodes == null || statusCodes.isEmpty()) {
return List.of();
}
return selectList(new LambdaQueryWrapperX<WeeklyReportDO>()
.in(WeeklyReportDO::getStatusCode, statusCodes)
.le(WeeklyReportDO::getCreateTime, cutoffTime)
.orderByAsc(WeeklyReportDO::getCreateTime)
.orderByAsc(WeeklyReportDO::getId));
}
private LambdaQueryWrapperX<WeeklyReportDO> buildPageQuery(WeeklyReportPageReqVO reqVO) {
LambdaQueryWrapperX<WeeklyReportDO> wrapper = new LambdaQueryWrapperX<WeeklyReportDO>()
.eqIfPresent(WeeklyReportDO::getStatusCode, reqVO.getStatusCode())

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

@@ -32,6 +32,14 @@ public class NotifyTemplateCodeConstants {
public static final String WORK_REPORT_PROJECT_HALF_MONTH_TEAM_REMIND =
"work_report_project_half_month_team_remind";
/** 工作报告待直属上级审批通知:周报/月报通用 */
public static final String WORK_REPORT_PENDING_APPROVAL_NOTIFY =
"work_report_pending_approval_notify";
/** 项目半月报待直属上级审批通知 */
public static final String WORK_REPORT_PROJECT_HALF_MONTH_PENDING_APPROVAL_NOTIFY =
"work_report_project_half_month_pending_approval_notify";
/** 执行指派:创建执行后通知负责人 + 协办人 */
public static final String EXECUTION_ASSIGNED = "execution_assigned";

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.user.AdminUserApi;
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.context.annotation.Configuration;
@@ -22,6 +23,6 @@ import org.springframework.context.annotation.Configuration;
{AdminUserApi.class, ObjectPermissionApi.class, DictDataApi.class, FileApi.class,
PermissionApi.class, OrgLeaderApi.class, UserVisibilityConfigApi.class,
DeptApi.class, PostApi.class, UserManagementRelationApi.class,
NotifyMessageSendApi.class})
NotifyMessageSendApi.class, UserSignatureApi.class})
public class RpcConfiguration {
}

View File

@@ -4,10 +4,17 @@ import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.Collection;
import java.util.Objects;
/**
* 项目对象业务授权服务。
* 项目对象业务授权服务:属主(业务负责人)身份命中直接放行,未命中回落对象域权限码兜底
*
* <p>供任务 / 执行的创建、编辑、删除、删除预检等入口在<b>方法体内</b>调用,替代方法上的
* {@code @CheckObjectPermission} 前置拦截——注解拦截发生在进入方法体之前,无法先认负责人身份。
*
* <p>红线:调用本服务做裁决的 service 方法<b>不要再挂 {@code @CheckObjectPermission}</b>
* 叠加会把"无权限码的负责人"在注解层直接挡死。口径登记见 CLAUDE.md §鉴权。
*/
@Service
public class ProjectObjectAuthorizationService {
@@ -26,4 +33,15 @@ public class ProjectObjectAuthorizationService {
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

@@ -42,12 +42,13 @@ import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Collection;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -229,8 +230,22 @@ public class OvertimeApplicationServiceImpl implements OvertimeApplicationServic
@Override
public List<OvertimeApplicationExportVO> getExportList(OvertimeApplicationPageReqVO reqVO) {
reqVO.setPageSize(PageParam.PAGE_SIZE_NONE);
PageResult<OvertimeApplicationRespVO> page = getMyPage(reqVO);
return BeanUtils.toBean(page.getList(), OvertimeApplicationExportVO.class);
PageResult<OvertimeApplicationDO> page;
if (reqVO.getApplicantIds() != null) {
List<Long> applicantIds = teamDashboardAccessService.resolveRequestedSubordinateUserIds(
reqVO.getApplicantIds(), OvertimeApplicationConstants.PERMISSION_TEAM_DASHBOARD);
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
if (loginUserId != null) {
LinkedHashSet<Long> mergedIds = new LinkedHashSet<>(applicantIds);
mergedIds.add(loginUserId);
applicantIds = new ArrayList<>(mergedIds);
}
page = overtimeApplicationMapper.selectMyPage(applicantIds, reqVO, TEAM_VISIBLE_STATUS_CODES);
} else {
page = overtimeApplicationMapper.selectMyPage(SecurityFrameworkUtils.getLoginUserId(), reqVO);
}
return BeanUtils.toBean(BeanUtils.toBean(page, OvertimeApplicationRespVO.class, this::applyStatusView).getList(),
OvertimeApplicationExportVO.class);
}
private void processApprovalAction(Long id, String actionCode, OvertimeApplicationStatusActionReqVO reqVO) {

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.PerformanceSheetCreateReqVO;
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.PerformanceSheetPrefillRespVO;
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.PerformanceSheetSignOffReqVO;
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.PerformanceSheetStatusLogRespVO;
@@ -35,6 +39,10 @@ public interface PerformanceSheetService {
void reject(Long id, PerformanceSheetStatusActionReqVO reqVO);
PerformanceSheetImportZipRespVO importZip(PerformanceSheetImportZipReqVO reqVO) throws Exception;
Long signOff(PerformanceSheetSignOffReqVO reqVO);
PerformanceDownloadFile download(Long id);
PerformanceDownloadFile batchDownload(Collection<Long> ids);
@@ -45,6 +53,8 @@ public interface PerformanceSheetService {
List<PerformanceSheetResponseRecordRespVO> getResponseRecords(Long id);
PerformanceSheetPrefillRespVO getPrefillData(Long employeeId, String periodMonth);
PerformanceMonthlyResultRespVO getMonthlyResult(Long employeeId, String periodMonth);
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.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.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.controller.admin.performance.vo.PerformanceScoreCellMappingRespVO;
@@ -25,8 +26,6 @@ public class PerformanceTemplateServiceImpl implements PerformanceTemplateServic
@Resource
private PerformanceTemplateMapper performanceTemplateMapper;
@Resource
private PerformanceProperties performanceProperties;
@Override
public PerformanceTemplateRespVO getCurrentTemplate() {
@@ -98,11 +97,14 @@ public class PerformanceTemplateServiceImpl implements PerformanceTemplateServic
}
private PerformanceScoreCellMappingRespVO scoreCellMapping() {
PerformanceProperties.ScoreCellMapping mapping = performanceProperties.getScoreCellMapping();
PerformanceScoreCellMappingRespVO respVO = new PerformanceScoreCellMappingRespVO();
respVO.setActualScoreTotalCell(mapping.getActualScoreTotal());
respVO.setBaseScoreTotalCell(mapping.getBaseScoreTotal());
respVO.setExtraScoreTotalCell(mapping.getExtraScoreTotal());
respVO.setActualScoreTotalCell(PerformanceExcelCellConstants.ACTUAL_SCORE_TOTAL_CELL);
respVO.setBaseScoreTotalCell(PerformanceExcelCellConstants.BASE_SCORE_TOTAL_CELL);
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;
}

View File

@@ -82,7 +82,6 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
// 权限常量
private static final String PRODUCT_CREATE_PERMISSION = "project:product:requirement:create";
private static final String PRODUCT_QUERY_PERMISSION = "project:product:requirement:query";
private static final String PRODUCT_UPDATE_PERMISSION = "project:product:requirement:update";
private static final String PRODUCT_STATUS_PERMISSION = "project:product:requirement:status";
private static final String PRODUCT_REVIEW_PERMISSION = "project:product:requirement:review";
@@ -243,16 +242,14 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
}
@Override
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId",
permission = PRODUCT_QUERY_PERMISSION)
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId", accessible = true)
public ProductRequirementRespVO getRequirement(Long id, Long productId) {
ProductRequirementDO requirement = validateRequirementExists(id);
return buildRequirementRespVO(requirement);
}
@Override
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#pageReqVO.productId",
permission = PRODUCT_QUERY_PERMISSION)
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#pageReqVO.productId", accessible = true)
public PageResult<ProductRequirementRespVO> getRequirementPage(ProductRequirementPageReqVO pageReqVO) {
// 处理模块ID条件支持递归查询子模块
if (pageReqVO.getModuleId() != null) {
@@ -275,8 +272,7 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
}
@Override
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#pageReqVO.productId",
permission = PRODUCT_QUERY_PERMISSION)
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#pageReqVO.productId", accessible = true)
public PageResult<ProductRequirementRespVO> getRequirementTree(ProductRequirementPageReqVO pageReqVO) {
Long moduleId = pageReqVO.getModuleId();
Long productId = pageReqVO.getProductId();
@@ -340,8 +336,7 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
}
@Override
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId",
permission = PRODUCT_QUERY_PERMISSION)
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId", accessible = true)
public ProductRequirementDashboardRespVO getRequirementDashboard(Long productId) {
List<ProductRequirementDO> requirements = requirementMapper.selectListByProductId(productId);
Map<String, ObjectStatusModelDO> statusModelMap = getStatusModelMap();
@@ -1038,8 +1033,7 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
}
@Override
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId",
permission = PRODUCT_QUERY_PERMISSION)
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId", accessible = true)
public List<ProductRequirementStatusTransitionRespVO> getAllowedTransitions(Long requirementId, Long productId) {
ProductRequirementDO requirement = validateRequirementExists(requirementId);
// 当产品需求已指派并生成项目需求后,产品需求端不再返回动作按钮
@@ -1071,8 +1065,7 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
}
@Override
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#reqVO.productId",
permission = PRODUCT_QUERY_PERMISSION)
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#reqVO.productId", accessible = true)
public List<ProductRequirementAllowedTransitionBatchRespVO> getAllowedTransitionsBatch(ProductRequirementBatchReqVO reqVO) {
List<ProductRequirementDO> requirements = getBatchRequirements(reqVO.getRequirementIds(), reqVO.getProductId());
Set<Long> dispatchedRequirementIds = getDispatchedProductRequirementIds(requirements);
@@ -1093,16 +1086,14 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
}
@Override
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId",
permission = PRODUCT_QUERY_PERMISSION)
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId", accessible = true)
public boolean hasDispatchedProjectRequirement(Long requirementId, Long productId) {
ProductRequirementDO requirement = validateRequirementExists(requirementId);
return hasDispatchedProjectRequirement(requirement);
}
@Override
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#reqVO.productId",
permission = PRODUCT_QUERY_PERMISSION)
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#reqVO.productId", accessible = true)
public List<ProductRequirementHasDispatchedBatchRespVO> hasDispatchedProjectRequirementBatch(ProductRequirementBatchReqVO reqVO) {
List<ProductRequirementDO> requirements = getBatchRequirements(reqVO.getRequirementIds(), reqVO.getProductId());
Set<Long> dispatchedRequirementIds = getDispatchedProductRequirementIds(requirements);
@@ -1443,8 +1434,7 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
}
@Override
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId",
permission = PRODUCT_QUERY_PERMISSION)
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId", accessible = true)
public List<ProductRequirementModuleRespVO> getRequirementModuleTree(Long productId) {
List<ProductRequirementModuleDO> modules = moduleMapper.selectListByProductId(productId);
return buildModuleTree(modules, 0L);

View File

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

View File

@@ -10,8 +10,8 @@ import java.util.Map;
/**
* 工时周聚合的纯函数支撑:周一归一 + 按段均摊。
* 口径(2026-06-12 与用户确认):工时按段填报无逐日明细,均摊分母 = 段内工作日(周一~周五)数;
* 纯周末段全额兜底归段结束日前最近的工作日;份额 scale=4,最终展示由调用方聚合后 setScale(2)。
* 口径(2026-07-14 更新):工时按段填报无逐日明细,均摊分母 = 段内工作日(周一~周五)数;
* 周六/周日不参与工作日段均摊;周末单天工时保留在当天展示;份额 scale=4,最终展示由调用方聚合后 setScale(2)。
*/
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})。
* 方法内不做防御归一,以便在调用方传入非周一时尽早暴露 bug。
@@ -36,26 +36,30 @@ final class MyWorklogWeekSupport {
if (segStart == null || segEnd == null || hours == null || segStart.isAfter(segEnd)) {
return result;
}
LocalDate weekFriday = weekMonday.plusDays(4);
LocalDate weekSunday = weekMonday.plusDays(6);
List<LocalDate> workdays = segStart.datesUntil(segEnd.plusDays(1))
.filter(d -> d.getDayOfWeek().getValue() <= 5)
.toList();
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));
if (!workdays.isEmpty()) {
BigDecimal share = hours.divide(BigDecimal.valueOf(workdays.size()), 4, RoundingMode.HALF_UP);
for (LocalDate d : workdays) {
if (!d.isBefore(weekMonday) && !d.isAfter(weekSunday)) {
result.put(d, share);
}
}
return result;
}
BigDecimal share = hours.divide(BigDecimal.valueOf(workdays.size()), 4, RoundingMode.HALF_UP);
for (LocalDate d : workdays) {
if (!d.isBefore(weekMonday) && !d.isAfter(weekFriday)) {
result.put(d, share);
}
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> getExecutionRequirementOptions(ProjectRequirementPageReqVO pageReqVO);
void changeRequirementStatus(ProjectRequirementStatusActionReqVO reqVO);
void changeRequirementStatusForReview(ProjectRequirementStatusActionReqVO reqVO);

View File

@@ -94,7 +94,6 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
STATUS_REJECTED, STATUS_CANCELLED);
private static final String PROJECT_CREATE_PERMISSION = "project:project:requirement:create";
private static final String PROJECT_QUERY_PERMISSION = "project:project:requirement:query";
private static final String PROJECT_UPDATE_PERMISSION = "project:project:requirement:update";
private static final String PROJECT_STATUS_PERMISSION = "project:project:requirement:status";
private static final String PROJECT_REVIEW_PERMISSION = "project:project:requirement:review";
@@ -245,8 +244,7 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
}
@Override
@CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#projectId",
permission = PROJECT_QUERY_PERMISSION)
@CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#projectId", accessible = true)
public ProjectRequirementRespVO getRequirement(Long id, Long projectId) {
ProjectRequirementDO requirement = validateRequirementExists(id);
validateRequirementBelongsToProject(requirement, projectId);
@@ -255,29 +253,15 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
}
@Override
@CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#pageReqVO.projectId",
permission = PROJECT_QUERY_PERMISSION)
@CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#pageReqVO.projectId", accessible = true)
public PageResult<ProjectRequirementRespVO> getRequirementPage(ProjectRequirementPageReqVO pageReqVO) {
if (pageReqVO.getModuleId() != null) {
if (isAllRequirementsModule(pageReqVO.getModuleId())) {
pageReqVO.setModuleId(null);
} else {
List<Long> moduleIds = getAllModuleIdsWithChildren(pageReqVO.getModuleId(), pageReqVO.getProjectId());
pageReqVO.setModuleIds(moduleIds);
pageReqVO.setModuleId(null);
}
}
normalizePageModuleFilter(pageReqVO);
PageResult<ProjectRequirementDO> pageResult = requirementMapper.selectPage(pageReqVO);
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());
return toRequirementRespPage(pageResult);
}
@Override
@CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#pageReqVO.projectId",
permission = PROJECT_QUERY_PERMISSION)
@CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#pageReqVO.projectId", accessible = true)
public PageResult<ProjectRequirementRespVO> getRequirementTree(ProjectRequirementPageReqVO pageReqVO) {
Long moduleId = pageReqVO.getModuleId();
Long projectId = pageReqVO.getProjectId();
@@ -329,6 +313,16 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
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
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#reqVO.projectId",
@@ -542,8 +536,7 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
}
@Override
@CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#projectId",
permission = PROJECT_QUERY_PERMISSION)
@CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#projectId", accessible = true)
public List<ProjectRequirementStatusTransitionRespVO> getAllowedTransitions(Long requirementId, Long projectId) {
ProjectRequirementDO requirement = validateRequirementExists(requirementId);
validateRequirementBelongsToProject(requirement, projectId);
@@ -569,8 +562,7 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
}
@Override
@CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#reqVO.projectId",
permission = PROJECT_QUERY_PERMISSION)
@CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#reqVO.projectId", accessible = true)
public List<ProjectRequirementAllowedTransitionBatchRespVO> getAllowedTransitionsBatch(ProjectRequirementBatchReqVO reqVO) {
List<ProjectRequirementDO> requirements = getBatchRequirements(reqVO.getRequirementIds(), reqVO.getProjectId());
Map<String, List<ObjectStatusTransitionDO>> transitionsByStatus = getTransitionsByStatus(requirements);
@@ -644,8 +636,7 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
}
@Override
@CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#projectId",
permission = PROJECT_QUERY_PERMISSION)
@CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#projectId", accessible = true)
public List<ProjectRequirementModuleRespVO> getRequirementModuleTree(Long projectId) {
List<ProjectRequirementModuleDO> modules = moduleMapper.selectListByProjectId(projectId);
return buildModuleTree(modules, 0L);
@@ -756,6 +747,27 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
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);
/**
* 以项目下最早一条任务工作日志的 startDate 为优先口径,同步项目实际开始日期。
* 若项目下尚无任务工作日志,则回退到首次 auto_start 状态日志日期;两者都没有时不更新。
*/
void syncActualStartDateFromWorklogs(Long projectId);
/**
* 重算项目进度并落库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.ProjectStatusLogMapper;
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.ObjectStatusTransitionMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
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.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScope;
@@ -122,6 +124,8 @@ class ProjectServiceImpl implements ProjectService {
@Resource
private ProjectTaskMapper projectTaskMapper;
@Resource
private TaskWorklogMapper taskWorklogMapper;
@Resource
private com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper projectExecutionMapper;
@Resource
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<>();
params.put("projectName", project.getProjectName());
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(
com.njcn.rdms.module.project.framework.notify.NotifySendEvent.of(
java.util.List.of(r.userId()),
@@ -1065,6 +1071,19 @@ class ProjectServiceImpl implements ProjectService {
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
public void recalcProgress(Long projectId) {
if (projectId == null) {
@@ -1494,6 +1513,40 @@ class ProjectServiceImpl implements ProjectService {
writeProjectStatusLog(project, actionCode, fromStatus, toStatus, 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) {

View File

@@ -45,24 +45,21 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
private ProjectTaskService projectTaskService;
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_QUERY, visibilityFallback = true)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", accessible = true)
public ProjectExecutionStatusBoardRespVO getExecutionStatusBoard(Long projectId, ProjectExecutionStatusBoardReqVO reqVO) {
List<ObjectStatusModelDO> statusModels = objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE);
return buildExecutionStatusBoard(projectId, reqVO, statusModels);
}
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY, visibilityFallback = true)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", accessible = true)
public ProjectTaskStatusBoardRespVO getTaskStatusBoard(Long projectId, Long executionId, ProjectTaskStatusBoardReqVO reqVO) {
List<ObjectStatusModelDO> statusModels = objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
return buildTaskStatusBoard(projectId, executionId, reqVO, statusModels);
}
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY, visibilityFallback = true)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", accessible = true)
public ProjectTaskBoardPageRespVO getTaskBoardPage(Long projectId, Long executionId, ProjectTaskBoardPageReqVO reqVO) {
List<ObjectStatusModelDO> statusModels = objectStatusModelMapper
.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);

View File

@@ -78,8 +78,7 @@ public class ProjectExecutionAssigneeServiceImpl implements ProjectExecutionAssi
private AdminUserApi adminUserApi;
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_QUERY, visibilityFallback = true)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", accessible = true)
public List<ExecutionAssigneeRespVO> getExecutionAssigneeList(Long projectId, Long executionId) {
validateProjectExists(projectId);
validateExecutionExists(projectId, executionId);
@@ -152,8 +151,7 @@ public class ProjectExecutionAssigneeServiceImpl implements ProjectExecutionAssi
}
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_QUERY, visibilityFallback = true)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", accessible = true)
public PageResult<ExecutionAssigneeLogRespVO> getExecutionAssigneeLogPage(Long projectId, Long executionId,
ExecutionAssigneeLogPageReqVO reqVO) {
validateProjectExists(projectId);

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.enums.ErrorCodeConstants;
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.NotifyTemplateCodeConstants;
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.service.ProjectObjectAuthorizationService;
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.task.ProjectTaskService;
@@ -127,6 +129,8 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
@Resource
private ProjectRequirementMapper projectRequirementMapper;
@Resource
private ProjectObjectAuthorizationService projectObjectAuthorizationService;
@Resource
private StatusActionTextResolver statusActionTextResolver;
/** 站内信事件发布:创建执行后发「执行指派」通知,由 NotifySendEventListener 事务提交后统一发送。 */
@Resource
@@ -193,21 +197,23 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
ProjectDO project = projectMapper.selectById(execution.getProjectId());
params.put("projectName", project == null ? "" : project.getProjectName());
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,
NotifyTemplateCodeConstants.EXECUTION_ASSIGNED, params,
NotifyMessageLevelConstants.NORMAL, SecurityFrameworkUtils.getLoginUserId()));
}
// 权限:方法体内属主放行 + 权限码兜底requireExecutionEditDeletePermission勿挂 @CheckObjectPermission
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_UPDATE)
public void updateExecution(Long projectId, ProjectExecutionUpdateReqVO reqVO) {
if (reqVO.getId() == null) {
throw invalidParamException("执行编号不能为空");
}
validateEditableProject(projectId);
ProjectDO project = validateEditableProject(projectId);
ProjectExecutionDO execution = validateExecutionExists(projectId, reqVO.getId());
requireExecutionEditDeletePermission(project, execution, ProjectExecutionConstants.PERMISSION_UPDATE);
validateExecutionAllowEdit(execution);
projectRequirementService.validateUsableForExecution(projectId, reqVO.getProjectRequirementId());
String executionName = normalizeRequiredName(reqVO.getExecutionName());
@@ -239,8 +245,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
}
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_QUERY, visibilityFallback = true)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", accessible = true)
public PageResult<ProjectExecutionDO> getExecutionPage(Long projectId, ProjectExecutionPageReqVO reqVO) {
validateProjectExists(projectId);
// "我参与 / 所有"视角完全由前端发不发 reqVO.involveUserId 决定。
@@ -254,8 +259,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
}
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_QUERY, visibilityFallback = true)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", accessible = true)
public ProjectExecutionRespVO getExecutionRespVO(Long projectId, Long executionId) {
ProjectExecutionDO execution = getExecution(projectId, executionId);
ProjectExecutionRespVO respVO = BeanUtils.toBean(execution, ProjectExecutionRespVO.class);
@@ -268,8 +272,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
}
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_QUERY, visibilityFallback = true)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", accessible = true)
public PageResult<ProjectExecutionRespVO> getExecutionRespVOPage(Long projectId, ProjectExecutionPageReqVO reqVO) {
PageResult<ProjectExecutionDO> pageResult = getExecutionPage(projectId, reqVO);
PageResult<ProjectExecutionRespVO> voPageResult = BeanUtils.toBean(pageResult, ProjectExecutionRespVO.class);
@@ -448,13 +451,13 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_IN, reason);
}
// 权限:方法体内属主放行 + 权限码兜底requireExecutionEditDeletePermission勿挂 @CheckObjectPermission
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_DELETE)
public void deleteExecution(Long projectId, Long executionId, ProjectExecutionDeleteReqVO reqVO) {
validateProjectExists(projectId);
ProjectDO project = validateProjectExists(projectId);
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
requireExecutionEditDeletePermission(project, execution, ProjectExecutionConstants.PERMISSION_DELETE);
validateDeleteConfirmText(reqVO.getConfirmText());
if (!Objects.equals(execution.getExecutionName(), reqVO.getExecutionName().trim())) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_DELETE_NAME_MISMATCH);
@@ -482,12 +485,12 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
writeExecutionAuditLog(execution, ObjectActivityConstants.EXECUTION_ACTION_DELETE, fromStatus, null, null, reason);
}
// 权限:方法体内属主放行 + 权限码兜底requireExecutionEditDeletePermission勿挂 @CheckObjectPermission
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_DELETE)
public ProjectExecutionDeletePrecheckRespVO precheckDeleteExecution(Long projectId, Long executionId) {
validateProjectExists(projectId);
validateExecutionExists(projectId, executionId);
ProjectDO project = validateProjectExists(projectId);
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
requireExecutionEditDeletePermission(project, execution, ProjectExecutionConstants.PERMISSION_DELETE);
int taskCount = projectTaskMapper.countByExecutionId(executionId);
ProjectExecutionDeletePrecheckRespVO respVO = new ProjectExecutionDeletePrecheckRespVO();
respVO.setTaskCount(taskCount);
@@ -495,6 +498,27 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
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) {
String normalizedConfirmText = normalizeNullableText(confirmText);
if (normalizedConfirmText == null

View File

@@ -67,8 +67,7 @@ public class ProjectTaskAggregateServiceImpl implements ProjectTaskAggregateServ
// ========= page =========
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY, visibilityFallback = true)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", accessible = true)
public PageResult<ProjectTaskRespVO> getAggregateTaskPage(Long projectId, ProjectTaskAggregatePageReqVO reqVO) {
// 空数组语义短路:前端明确"按 0 个执行状态/执行 id 过滤" → 返空集合,不让 MyBatis 退化成"不过滤"
if ((reqVO.getExecutionStatusCodes() != null && reqVO.getExecutionStatusCodes().isEmpty())
@@ -90,8 +89,7 @@ public class ProjectTaskAggregateServiceImpl implements ProjectTaskAggregateServ
// ========= status-board =========
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY, visibilityFallback = true)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", accessible = true)
public ProjectTaskStatusBoardRespVO getAggregateTaskStatusBoard(Long projectId, ProjectTaskAggregateStatusBoardReqVO reqVO) {
// 空数组语义短路:跳过 SQL,但保留状态列骨架(前端看板依赖全列表渲染),count 全部 0
if ((reqVO.getExecutionStatusCodes() != null && reqVO.getExecutionStatusCodes().isEmpty())
@@ -147,8 +145,7 @@ public class ProjectTaskAggregateServiceImpl implements ProjectTaskAggregateServ
// ========= board-page =========
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY, visibilityFallback = true)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", accessible = true)
public ProjectTaskBoardPageRespVO getAggregateTaskBoardPage(Long projectId, ProjectTaskAggregateBoardPageReqVO reqVO) {
// 空数组语义短路:executionStatusCodes 或 executionIds 为空数组 → 该范围明确为空,跳过 SQL,保留列骨架(每列 list=[] / total=0)
boolean emptyExecScope = (reqVO.getExecutionStatusCodes() != null && reqVO.getExecutionStatusCodes().isEmpty())
@@ -237,8 +234,7 @@ public class ProjectTaskAggregateServiceImpl implements ProjectTaskAggregateServ
// ========= summary =========
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY, visibilityFallback = true)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", accessible = true)
public ProjectTaskSummaryRespVO getAggregateTaskSummary(Long projectId, ProjectTaskSummaryReqVO reqVO) {
LocalDate today = today();
LocalDate weekStart = weekStart(today);

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.dal.dataobject.project.task.ProjectTaskDO;
import java.time.LocalDate;
/**
* 项目任务 Service 接口。
*/
@@ -57,11 +55,11 @@ public interface ProjectTaskService {
void changeTaskStatus(Long projectId, Long executionId, Long taskId, ProjectTaskStatusActionReqVO reqVO);
/**
* 删除任务(软删 + 三重确认 + 上级硬卡 + 级联软删 + 父进度回算 + 审计)。
* 删除任务(软删 + 三重确认 + 多身份授权 + 级联软删 + 父进度回算 + 审计)。
* <p>删除规则:非 completed 即可删pending / active / paused / cancelled 全允许);
* 已 completed 的任务用专门错误码 PROJECT_TASK_NOT_ALLOW_DELETE 拦截。
* <p>上级硬卡:一级任务由"执行负责人 OR 项目负责人(权限码 {@code project:task:delete}"删;
* 子任务由"父任务负责人 OR 项目负责人(权限码)"删
* <p>授权:项目负责人 / 执行负责人 / 任务负责人 / 父任务负责人均可删;
* 其它用户持有 {@code project:task:delete} 时作为管理员兜底放行
* <p>三重确认:{@code taskName} / {@code confirmText}DELETE 或 删除)/ {@code reason} 全部必填,
* 任意一项缺失或不匹配即拒。
* <p>级联范围:软删该任务及其所有子孙任务、所有相关工作日志、所有相关任务协办;
@@ -89,7 +87,7 @@ public interface ProjectTaskService {
/**
* 由工时填报5.11触发的任务自动开始当任务当前处于初始态initialFlag=true
* 走一次 {@link com.njcn.rdms.module.project.constant.ObjectActivityConstants#TASK_ACTION_AUTO_START}
* 动作把任务推进到下一状态,并复用 5.5 的钩子写入 actualStartDate / 状态日志 / 审计日志。
* 动作把任务推进到下一状态,并状态日志 / 审计日志。
* <p>
* 调用方必须已自行确认登录人是 task.ownerId仅 owner 工时驱动状态推进,与 progressRate 同步口径一致)。
* 本方法**不走** @CheckObjectPermission因为 5.11 持有的是 PERMISSION_WORKLOG
@@ -101,16 +99,15 @@ public interface ProjectTaskService {
void internalAutoStartByWorklog(ProjectTaskDO task);
/**
* 工作日志填报触发:当任务"实际开始时间"为空时,用给定的开始日期回填,使"任务何时开始"
* 以最早一条工作日志的开始日期为准。owner / 协办人共用,不限任务当前状态。
* 工作日志事实回算任务实际开始/结束日期:
* <ul>
* <li>actualStartDate = 该任务最早一条工作日志的 startDate不区分填报人</li>
* <li>actualEndDate = 该任务负责人本人所填、progressRate=100 的最晚一条工作日志 endDate</li>
* </ul>
* <p>
* 与 {@link #internalAutoStartByWorklog} 解耦:本方法只回填 actualStartDate不动实际结束日期、
* 不推进任务状态)。调用顺序应在 internalAutoStartByWorklog 之前,确保后者的回填钩子见到非空值即跳过,
* 不会再用"提交当天"覆盖工作日志的开始日期。
* <p>
* 任务实际开始时间已有值、或入参为空时静默 return。
* 当日志被删除或改动后,本方法也负责把对应字段清空或改写为新的回算结果。
*/
void fillActualStartDateIfAbsent(Long taskId, LocalDate startDate);
void syncActualDatesFromWorklogs(Long taskId);
/**
* 由执行 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.framework.attachment.AttachmentFileIdResolver;
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.NotifyTemplateCodeConstants;
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.RoundingMode;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
@@ -90,8 +90,6 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
@Slf4j
public class ProjectTaskServiceImpl implements ProjectTaskService {
private static final ZoneId SERVER_ZONE = ZoneId.of("Asia/Shanghai");
@Resource
private ProjectMapper projectMapper;
@Resource
@@ -137,6 +135,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
@Resource
private org.springframework.context.ApplicationEventPublisher applicationEventPublisher;
// 权限:方法体内属主放行 + 权限码兜底checkOwnerOrProjectPermission勿挂 @CheckObjectPermission
@Override
@Transactional(rollbackFor = Exception.class)
public Long createTask(Long projectId, Long executionId, ProjectTaskSaveReqVO reqVO) {
@@ -210,29 +209,26 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
ProjectDO project = projectMapper.selectById(projectId);
params.put("projectName", project == null ? "" : project.getProjectName());
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,
NotifyTemplateCodeConstants.TASK_ASSIGNED, params,
NotifyMessageLevelConstants.NORMAL, SecurityFrameworkUtils.getLoginUserId()));
}
// 权限:方法体内属主放行 + 权限码兜底requireTaskEditDeletePermission勿挂 @CheckObjectPermission
@Override
@Transactional(rollbackFor = Exception.class)
public void updateTask(Long projectId, Long executionId, ProjectTaskSaveReqVO reqVO) {
if (reqVO.getId() == null) {
throw invalidParamException("任务编号不能为空");
}
validateEditableProject(projectId);
ProjectDO project = validateEditableProject(projectId);
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
validateExecutionAllowEdit(execution);
ProjectTaskDO task = validateTaskExists(projectId, executionId, reqVO.getId());
validateTaskAllowEdit(task);
// 上级硬卡:一级任务由"执行负责人 OR 项目负责人(权限码)"改;子任务由"父任务负责人 OR 项目负责人(权限码)"改
// 按当前 task 的 parentTaskId修改前判定改父亲属于改动结果授权按改动前形态裁决
Long upperOwnerId = task.getParentTaskId() == null
? execution.getOwnerId()
: projectTaskMapper.selectById(task.getParentTaskId()).getOwnerId();
projectObjectAuthorizationService.checkOwnerOrProjectPermission(
projectId, upperOwnerId, ProjectTaskConstants.PERMISSION_UPDATE);
requireTaskEditDeletePermission(project, execution, task, ProjectTaskConstants.PERMISSION_UPDATE);
ProjectTaskDO parentTask = validateParentTask(projectId, executionId, reqVO.getParentTaskId());
if (parentTask != null && Objects.equals(parentTask.getId(), task.getId())) {
throw exception(ErrorCodeConstants.PROJECT_TASK_PARENT_INVALID);
@@ -281,14 +277,14 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
task.getStatusCode(), buildTaskFieldChanges(before, task), null);
}
// 权限:方法体内属主放行 + 权限码兜底requireTaskEditDeletePermission勿挂 @CheckObjectPermission
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteTask(Long projectId, Long executionId, Long taskId, ProjectTaskDeleteReqVO reqVO) {
validateProjectExists(projectId);
ProjectDO project = validateProjectExists(projectId);
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId);
// 上级硬卡:一级任务由"执行负责人 OR 项目负责人(权限码)"删;子任务由"父任务负责人 OR 项目负责人(权限码)"删
requireDeletePermissionOnTask(projectId, task, execution);
requireTaskEditDeletePermission(project, execution, task, ProjectTaskConstants.PERMISSION_DELETE);
validateDeleteConfirmText(reqVO.getConfirmText());
if (!Objects.equals(task.getTaskTitle(), reqVO.getTaskName().trim())) {
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);
}
// 权限:方法体内属主放行 + 权限码兜底requireTaskEditDeletePermission勿挂 @CheckObjectPermission
@Override
public ProjectTaskDeletePrecheckRespVO precheckDeleteTask(Long projectId, Long executionId, Long taskId) {
validateProjectExists(projectId);
ProjectDO project = validateProjectExists(projectId);
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId);
// 预检也走"上级硬卡",防止"无权限的人调 precheck 试探下挂数量"
requireDeletePermissionOnTask(projectId, task, execution);
// 预检也走同款编辑/删除授权,防止无权限用户调 precheck 试探下挂数量
requireTaskEditDeletePermission(project, execution, task, ProjectTaskConstants.PERMISSION_DELETE);
int childTaskCount = projectTaskMapper.countChildrenByParentTaskId(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) {
Long upperOwnerId;
if (task.getParentTaskId() == null) {
upperOwnerId = execution.getOwnerId();
} else {
private void requireTaskEditDeletePermission(ProjectDO project, ProjectExecutionDO execution,
ProjectTaskDO task, String permission) {
List<Long> ownerUserIds = new ArrayList<>();
addIfNotNull(ownerUserIds, project.getManagerUserId());
addIfNotNull(ownerUserIds, execution.getOwnerId());
addIfNotNull(ownerUserIds, task.getOwnerId());
if (task.getParentTaskId() != null) {
ProjectTaskDO parent = projectTaskMapper.selectById(task.getParentTaskId());
if (parent == null) {
// 父任务被并发硬删的极端场景:当前任务已是孤儿,按"无上级负责人"处理
// 仍要求调用方持有项目级权限码,由 checkOwnerOrProjectPermission 兜底拦截
upperOwnerId = null;
} else {
upperOwnerId = parent.getOwnerId();
if (parent != null) {
addIfNotNull(ownerUserIds, parent.getOwnerId());
}
}
projectObjectAuthorizationService.checkOwnerOrProjectPermission(
projectId, upperOwnerId, ProjectTaskConstants.PERMISSION_DELETE);
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) {
@@ -447,7 +450,6 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
writeTaskStatusLog(current, actionCode, fromStatus, toStatus, reason);
writeTaskAuditLog(current, actionCode, fromStatus, toStatus, null, reason);
maybeFillActualDates(current, fromStatus, toStatus);
// 通知执行:任务自动开始也属于"任务状态变更"事件,按 §6.4 触发表上报
projectExecutionService.onTaskStatusChanged(
current.getExecutionId(), current.getId(), fromStatus, toStatus, actionCode, reason);
@@ -455,17 +457,25 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
@Override
@Transactional(rollbackFor = Exception.class)
public void fillActualStartDateIfAbsent(Long taskId, LocalDate startDate) {
if (taskId == null || startDate == null) {
public void syncActualDatesFromWorklogs(Long taskId) {
if (taskId == null) {
return;
}
ProjectTaskDO current = projectTaskMapper.selectById(taskId);
// 仅在实际开始时间为空时回填;已有值(含 internalAutoStartByWorklog 已写过)则保持不变
if (current == null || current.getActualStartDate() != null) {
ProjectTaskDO task = projectTaskMapper.selectById(taskId);
if (task == null) {
return;
}
// 只动开始日期:实际结束日期传 null依据全局 FieldStrategy 不会被覆盖
projectTaskMapper.updateActualDatesById(taskId, startDate, null);
TaskWorklogDO earliestWorklog = taskWorklogMapper.selectEarliestByTaskId(taskId);
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
@@ -475,8 +485,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
}
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY, visibilityFallback = true)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", accessible = true)
public PageResult<ProjectTaskDO> getTaskPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) {
validateExecutionExists(projectId, executionId);
// 进得来 = 看执行下全部任务,"我参与 / 所有"由前端发不发 reqVO.ownerId / involveUserId 决定。
@@ -486,8 +495,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
}
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY, visibilityFallback = true)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", accessible = true)
public ProjectTaskRespVO getTaskRespVO(Long projectId, Long executionId, Long taskId) {
// 内联 validate便于接住 execution 供前端 executionOwnerId 字段使用
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
@@ -510,8 +518,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
}
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY, visibilityFallback = true)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", accessible = true)
public PageResult<ProjectTaskRespVO> getTaskRespVOPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) {
PageResult<ProjectTaskDO> pageResult = getTaskPage(projectId, executionId, reqVO);
return assembleTaskRespVOPage(projectId, executionId, pageResult);
@@ -731,41 +738,6 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|| "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) {
ProjectDO project = validateProjectExists(projectId);
ObjectStatusModelDO statusModel = objectStatusModelMapper
@@ -919,7 +891,6 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
writeTaskStatusLog(task, actionCode, fromStatus, toStatus, reason);
writeTaskAuditLog(task, actionCode, fromStatus, toStatus, null, reason);
maybeFillActualDates(task, fromStatus, toStatus);
// 完成动作:兜底把任务进度刷到 100%,并触发父任务 AVG 重算forceCompleteProgress 内部触发项目刷新)
if ("complete".equals(actionCode)) {

View File

@@ -69,8 +69,7 @@ public class TaskAssigneeServiceImpl implements TaskAssigneeService {
private AdminUserApi adminUserApi;
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY, visibilityFallback = true)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", accessible = true)
public List<TaskAssigneeRespVO> getAssigneeList(Long projectId, Long executionId, Long taskId) {
validateExecutionAndTaskExists(projectId, executionId, taskId);
List<TaskAssigneeDO> activeList = taskAssigneeMapper.selectActiveListByTaskId(taskId);
@@ -123,8 +122,7 @@ public class TaskAssigneeServiceImpl implements TaskAssigneeService {
}
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY, visibilityFallback = true)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", accessible = true)
public PageResult<TaskAssigneeLogRespVO> getAssigneeLogPage(Long projectId, Long executionId, Long taskId,
TaskAssigneeLogPageReqVO reqVO) {
validateExecutionAndTaskExists(projectId, executionId, taskId);

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

View File

@@ -78,8 +78,7 @@ public class RequirementReviewServiceImpl implements RequirementReviewService {
}
@Override
@CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#productId",
permission = ProductObjectConstants.PERMISSION_QUERY)
@CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#productId", accessible = true)
public RequirementReviewRespVO getProductRequirementReview(Long productId, Long requirementId) {
ProductRequirementDO requirement = productRequirementMapper.selectById(requirementId);
if (requirement == null || !Objects.equals(requirement.getProductId(), productId)) {
@@ -116,8 +115,7 @@ public class RequirementReviewServiceImpl implements RequirementReviewService {
}
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectObjectConstants.PERMISSION_QUERY)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", accessible = true)
public RequirementReviewRespVO getProjectRequirementReview(Long projectId, Long requirementId) {
ProjectRequirementDO requirement = projectRequirementMapper.selectById(requirementId);
if (requirement == null || !Objects.equals(requirement.getProjectId(), projectId)) {

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,7 +6,10 @@ import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
import com.njcn.rdms.module.project.constant.SignOffConstants;
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.NotifyTemplateCodeConstants;
import com.njcn.rdms.module.project.controller.admin.workreport.common.vo.*;
import com.njcn.rdms.module.project.controller.admin.workreport.monthly.vo.*;
import com.njcn.rdms.module.project.controller.admin.workreport.project.vo.*;
@@ -22,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.monthly.MonthlyReportApprovalRecordDO;
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.ProjectReportCurrentItemDO;
import com.njcn.rdms.module.project.dal.dataobject.workreport.project.ProjectReportDO;
@@ -39,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.monthly.MonthlyReportApprovalRecordMapper;
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.ProjectReportCurrentItemMapper;
import com.njcn.rdms.module.project.dal.mysql.workreport.project.ProjectReportMapper;
@@ -47,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.WeeklyReportTravelSegmentMapper;
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.team.TeamDashboardAccessService;
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.user.UserSignatureApi;
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.permission.ObjectPermissionApi;
@@ -58,7 +67,10 @@ 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.UserManagementRelationApi;
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 jakarta.annotation.Resource;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -66,6 +78,7 @@ import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.IsoFields;
import java.util.*;
import java.util.stream.Collectors;
@@ -104,6 +117,10 @@ public class WorkReportCommonService {
@Resource
private MonthlyReportApprovalRecordMapper monthlyReportApprovalRecordMapper;
@Resource
private MonthlyReportSignOffMapper monthlyReportSignOffMapper;
@Resource
private MonthlyReportSignOffRecordMapper monthlyReportSignOffRecordMapper;
@Resource
private ProjectReportApprovalRecordMapper projectReportApprovalRecordMapper;
@Resource
private WorkReportStatusLogMapper workReportStatusLogMapper;
@@ -116,6 +133,8 @@ public class WorkReportCommonService {
@Resource
private StatusActionTextResolver statusActionTextResolver;
@Resource
private EmployeeSignOffStatusService employeeSignOffStatusService;
@Resource
private AdminUserApi adminUserApi;
@Resource
private DeptApi deptApi;
@@ -124,6 +143,8 @@ public class WorkReportCommonService {
@Resource
private UserManagementRelationApi userManagementRelationApi;
@Resource
private UserSignatureApi userSignatureApi;
@Resource
private ObjectPermissionApi objectPermissionApi;
@Resource
private ProjectMapper projectMapper;
@@ -131,6 +152,8 @@ public class WorkReportCommonService {
private UserObjectRoleMapper userObjectRoleMapper;
@Resource
private TeamDashboardAccessService teamDashboardAccessService;
@Resource
private ApplicationEventPublisher applicationEventPublisher;
public List<WorkReportStatusDictRespVO> getStatusDict() {
return objectStatusModelMapper.selectListByObjectTypeEnabled(WorkReportConstants.STATUS_OBJECT_TYPE).stream()
@@ -181,19 +204,6 @@ public class WorkReportCommonService {
return createWeeklyReportInternal(reqVO, overrideReporterId);
}
public WeeklyReportRespVO mergeWeeklyDraft(WeeklyReportRespVO latestDraft, WeeklyReportRefreshDraftReqVO reqVO) {
WeeklyReportRespVO respVO = latestDraft;
respVO.setIsBusinessTrip(Boolean.TRUE.equals(reqVO.getIsBusinessTrip()));
respVO.setTravelSegments(BeanUtils.toBean(defaultList(reqVO.getTravelSegments()), WeeklyReportTravelSegmentRespVO.class));
respVO.setReviewItems(mergeWeeklyReviewItems(reqVO.getReviewItems(), latestDraft.getReviewItems()));
respVO.setPlanItems(mergePlanItems(reqVO.getPlanItems(), latestDraft.getPlanItems()));
respVO.setTotalTravelDays(Boolean.TRUE.equals(reqVO.getIsBusinessTrip())
? defaultIfNull(sumTravelDays(reqVO.getTravelSegments()))
: BigDecimal.ZERO);
respVO.setTotalWorkHours(sumReviewWorkHoursResp(respVO.getReviewItems()));
return respVO;
}
private Long createWeeklyReportInternal(WeeklyReportSaveReqVO reqVO, Long overrideReporterId) {
validatePeriod(reqVO.getPeriodStartDate(), reqVO.getPeriodEndDate());
CurrentUserProfile profile = resolveProfile(overrideReporterId, true);
@@ -239,11 +249,23 @@ public class WorkReportCommonService {
}
public PageResult<WeeklyReportRespVO> getWeeklyReportPage(WeeklyReportPageReqVO reqVO) {
return getWeeklyReportPage(reqVO, false);
}
public PageResult<WeeklyReportRespVO> getWeeklyReportExportPage(WeeklyReportPageReqVO reqVO) {
return getWeeklyReportPage(reqVO, true);
}
private PageResult<WeeklyReportRespVO> getWeeklyReportPage(WeeklyReportPageReqVO reqVO,
boolean includeSelfInTeamMode) {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
PageResult<WeeklyReportDO> pageResult;
if (reqVO.getReporterIds() != null) {
List<Long> reporterIds = teamDashboardAccessService.resolveRequestedSubordinateUserIds(
reqVO.getReporterIds(), WorkReportConstants.PERMISSION_TEAM_DASHBOARD);
if (includeSelfInTeamMode) {
reporterIds = appendLoginUser(reporterIds);
}
pageResult = weeklyReportMapper.selectReporterPage(reporterIds, reqVO, TEAM_VISIBLE_STATUS_CODES);
} else {
pageResult = weeklyReportMapper.selectReporterPage(loginUserId, reqVO, getEnabledStatusCodes());
@@ -283,6 +305,9 @@ public class WorkReportCommonService {
transition.getToStatusCode(), null, buildWeeklyRemark(latest));
writeAuditLog(WorkReportConstants.BIZ_TYPE_WEEKLY, id, actionCode, current.getStatusCode(),
transition.getToStatusCode(), null, buildWeeklyRemark(latest));
publishPersonalReportPendingApprovalNotice(latest.getSupervisorUserId(),
NotifyTemplateCodeConstants.WORK_REPORT_PENDING_APPROVAL_NOTIFY,
"周报", latest.getReporterName(), formatWeeklyPeriodLabelForNotify(latest));
}
@Transactional(rollbackFor = Exception.class)
@@ -351,14 +376,6 @@ public class WorkReportCommonService {
return createMonthlyReportInternal(reqVO, overrideReporterId);
}
public MonthlyReportRespVO mergeMonthlyDraft(MonthlyReportRespVO latestDraft, MonthlyReportRefreshDraftReqVO reqVO) {
MonthlyReportRespVO respVO = latestDraft;
respVO.setReviewItems(mergeReviewItems(reqVO.getReviewItems(), latestDraft.getReviewItems()));
respVO.setPlanItems(mergePlanItems(reqVO.getPlanItems(), latestDraft.getPlanItems()));
respVO.setTotalWorkHours(sumReviewWorkHoursResp(respVO.getReviewItems()));
return respVO;
}
private Long createMonthlyReportInternal(MonthlyReportSaveReqVO reqVO, Long overrideReporterId) {
validatePeriod(reqVO.getPeriodStartDate(), reqVO.getPeriodEndDate());
CurrentUserProfile profile = resolveProfile(overrideReporterId, true);
@@ -367,6 +384,7 @@ public class WorkReportCommonService {
MonthlyReportDO report = new MonthlyReportDO();
applyMonthlySaveFields(report, reqVO, profile);
report.setStatusCode(getInitialStatusCode());
report.setSignOffStatus(SignOffConstants.STATUS_NOT_STARTED);
monthlyReportMapper.insert(report);
replaceMonthlyChildren(report.getId(), reqVO);
MonthlyReportDO latest = monthlyReportMapper.selectById(report.getId());
@@ -404,11 +422,23 @@ public class WorkReportCommonService {
}
public PageResult<MonthlyReportRespVO> getMonthlyReportPage(MonthlyReportPageReqVO reqVO) {
return getMonthlyReportPage(reqVO, false);
}
public PageResult<MonthlyReportRespVO> getMonthlyReportExportPage(MonthlyReportPageReqVO reqVO) {
return getMonthlyReportPage(reqVO, true);
}
private PageResult<MonthlyReportRespVO> getMonthlyReportPage(MonthlyReportPageReqVO reqVO,
boolean includeSelfInTeamMode) {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
PageResult<MonthlyReportDO> pageResult;
if (reqVO.getReporterIds() != null) {
List<Long> reporterIds = teamDashboardAccessService.resolveRequestedSubordinateUserIds(
reqVO.getReporterIds(), WorkReportConstants.PERMISSION_TEAM_DASHBOARD);
if (includeSelfInTeamMode) {
reporterIds = appendLoginUser(reporterIds);
}
pageResult = monthlyReportMapper.selectReporterPage(reporterIds, reqVO, TEAM_VISIBLE_STATUS_CODES);
} else {
pageResult = monthlyReportMapper.selectReporterPage(loginUserId, reqVO, getEnabledStatusCodes());
@@ -426,6 +456,29 @@ public class WorkReportCommonService {
.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)
public void submitMonthlyReport(Long id) {
MonthlyReportDO current = validateMonthlyReportExists(id);
@@ -448,6 +501,9 @@ public class WorkReportCommonService {
transition.getToStatusCode(), null, buildMonthlyRemark(latest));
writeAuditLog(WorkReportConstants.BIZ_TYPE_MONTHLY, id, actionCode, current.getStatusCode(),
transition.getToStatusCode(), null, buildMonthlyRemark(latest));
publishPersonalReportPendingApprovalNotice(latest.getSupervisorUserId(),
NotifyTemplateCodeConstants.WORK_REPORT_PENDING_APPROVAL_NOTIFY,
"月报", latest.getReporterName(), latest.getPeriodLabel());
}
@Transactional(rollbackFor = Exception.class)
@@ -462,6 +518,55 @@ public class WorkReportCommonService {
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)
public void deleteMonthlyReport(Long id) {
MonthlyReportDO current = validateMonthlyReportExists(id);
@@ -497,6 +602,89 @@ public class WorkReportCommonService {
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) {
CurrentUserProfile profile = loadCurrentUserProfile(true);
ProjectDO project = validateProjectExists(projectId);
@@ -588,11 +776,23 @@ public class WorkReportCommonService {
}
public PageResult<ProjectReportRespVO> getProjectReportPage(ProjectReportPageReqVO reqVO) {
return getProjectReportPage(reqVO, false);
}
public PageResult<ProjectReportRespVO> getProjectReportExportPage(ProjectReportPageReqVO reqVO) {
return getProjectReportPage(reqVO, true);
}
private PageResult<ProjectReportRespVO> getProjectReportPage(ProjectReportPageReqVO reqVO,
boolean includeSelfInTeamMode) {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
PageResult<ProjectReportDO> pageResult;
if (reqVO.getProjectOwnerIds() != null) {
List<Long> projectOwnerIds = teamDashboardAccessService.resolveRequestedSubordinateUserIds(
reqVO.getProjectOwnerIds(), WorkReportConstants.PERMISSION_TEAM_DASHBOARD);
if (includeSelfInTeamMode) {
projectOwnerIds = appendLoginUser(projectOwnerIds);
}
pageResult = projectReportMapper.selectReporterPage(projectOwnerIds, reqVO, TEAM_VISIBLE_STATUS_CODES);
} else {
pageResult = projectReportMapper.selectReporterPage(loginUserId, reqVO, getEnabledStatusCodes());
@@ -634,6 +834,7 @@ public class WorkReportCommonService {
transition.getToStatusCode(), null, buildProjectRemark(latest));
writeAuditLog(WorkReportConstants.BIZ_TYPE_PROJECT, id, actionCode, current.getStatusCode(),
transition.getToStatusCode(), null, buildProjectRemark(latest));
publishProjectReportPendingApprovalNotice(latest);
}
@Transactional(rollbackFor = Exception.class)
@@ -781,6 +982,14 @@ public class WorkReportCommonService {
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) {
ProjectReportDO report = validateProjectReportExists(id);
validateReadable(report.getProjectOwnerId(), report.getSupervisorUserId());
@@ -854,6 +1063,59 @@ public class WorkReportCommonService {
.collect(Collectors.toList());
}
private List<Long> appendLoginUser(List<Long> userIds) {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
if (loginUserId == null) {
return userIds;
}
LinkedHashSet<Long> merged = new LinkedHashSet<>(userIds);
merged.add(loginUserId);
return new ArrayList<>(merged);
}
private void publishPersonalReportPendingApprovalNotice(Long supervisorUserId, String templateCode,
String reportTypeName, String reporterName, String periodLabel) {
if (supervisorUserId == null) {
return;
}
Map<String, Object> params = new LinkedHashMap<>();
params.put("reportTypeName", reportTypeName);
params.put("reporterName", defaultText(reporterName));
params.put("periodLabel", defaultText(periodLabel));
applicationEventPublisher.publishEvent(NotifySendEvent.of(
List.of(supervisorUserId), templateCode, params, NotifyMessageLevelConstants.REMIND));
}
private void publishProjectReportPendingApprovalNotice(ProjectReportDO report) {
if (report == null || report.getSupervisorUserId() == null) {
return;
}
Map<String, Object> params = new LinkedHashMap<>();
params.put("reporterName", defaultText(report.getProjectOwnerName()));
params.put("projectName", defaultText(report.getProjectName()));
params.put("periodLabel", defaultText(report.getPeriodLabel()));
applicationEventPublisher.publishEvent(NotifySendEvent.of(
List.of(report.getSupervisorUserId()),
NotifyTemplateCodeConstants.WORK_REPORT_PROJECT_HALF_MONTH_PENDING_APPROVAL_NOTIFY,
params, NotifyMessageLevelConstants.REMIND));
}
private String formatWeeklyPeriodLabelForNotify(WeeklyReportDO report) {
if (report == null || report.getPeriodStartDate() == null || report.getPeriodEndDate() == null) {
return defaultText(report == null ? null : report.getPeriodLabel());
}
LocalDate startDate = report.getPeriodStartDate();
LocalDate endDate = report.getPeriodEndDate();
int weekBasedYear = startDate.get(IsoFields.WEEK_BASED_YEAR);
int weekOfYear = startDate.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR);
return weekBasedYear + "" + weekOfYear + "周(" + formatMonthDayRange(startDate, endDate) + "";
}
private String formatMonthDayRange(LocalDate startDate, LocalDate endDate) {
return startDate.getMonthValue() + "" + startDate.getDayOfMonth()
+ "日-" + endDate.getMonthValue() + "" + endDate.getDayOfMonth() + "";
}
private ObjectStatusModelDO getInitialStatusModel() {
ObjectStatusModelDO statusModel = objectStatusModelMapper
.selectInitialByObjectTypeEnabled(WorkReportConstants.STATUS_OBJECT_TYPE);
@@ -1356,6 +1618,7 @@ public class WorkReportCommonService {
record.setConclusion(statusLog.getToStatus());
record.setOpinion(normalizeReason(reqVO));
if (WorkReportConstants.ACTION_APPROVE.equals(actionCode)) {
UserSignatureRespDTO userSignature = getUserSignature(SecurityFrameworkUtils.getLoginUserId());
record.setMeetingDate(reqVO.getMeetingDate());
record.setStrengthDesc(normalizeNullableText(reqVO.getStrengthDesc()));
record.setStrengthExample(normalizeNullableText(reqVO.getStrengthExample()));
@@ -1364,15 +1627,32 @@ public class WorkReportCommonService {
record.setImprovementSuggestion(normalizeNullableText(reqVO.getImprovementSuggestion()));
record.setPerformanceResult(normalizeNullableText(reqVO.getPerformanceResult()));
record.setEmployeeSignName(normalizeNullableText(reqVO.getEmployeeSignName()));
record.setEmployeeSignatureFileId(null);
record.setEmployeeSignedDate(reqVO.getEmployeeSignedDate());
record.setSupervisorSignName(normalizeNullableText(reqVO.getSupervisorSignName()));
record.setSupervisorSignedDate(reqVO.getSupervisorSignedDate());
record.setSupervisorSignName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
record.setSupervisorSignatureFileId(userSignature == null ? null : userSignature.getFileId());
record.setSupervisorSignedDate(LocalDate.now());
}
record.setAuditorUserId(SecurityFrameworkUtils.getLoginUserId());
record.setAuditorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
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) {
ProjectReportApprovalRecordDO record = new ProjectReportApprovalRecordDO();
record.setProjectReportId(report.getId());
@@ -1431,6 +1711,11 @@ public class WorkReportCommonService {
private MonthlyReportRespVO toMonthlyRespVO(MonthlyReportDO report, boolean withChildren) {
MonthlyReportRespVO respVO = BeanUtils.toBean(report, MonthlyReportRespVO.class);
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) {
respVO.setReviewItems(BeanUtils.toBean(
personalReportReviewItemMapper.selectListByReport(WorkReportConstants.REPORT_TYPE_MONTHLY, report.getId()),
@@ -1622,6 +1907,19 @@ public class WorkReportCommonService {
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) {
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 {
private static final String WORK_ITEM_MY_ITEMS = "我的事项";
private static final String WORKLOG_DAY_SEPARATOR = "[[WR_DAY_SPLIT]]";
@Resource
private ObjectStatusModelMapper objectStatusModelMapper;
@@ -759,7 +760,7 @@ public class WorkReportDefaultDraftService {
builder.append(lineTitle);
appendBracket(builder, priority, latestProgressRate, totalWorkHours);
if (!workContents.isEmpty()) {
builder.append("").append(String.join("", workContents));
builder.append("").append(String.join(WORKLOG_DAY_SEPARATOR, workContents));
}
return builder.toString();
}
@@ -860,7 +861,7 @@ public class WorkReportDefaultDraftService {
builder.append("").append(String.join(" / ", parts)).append("");
}
if (weeklyMode && worklogAggregate.hasWorkContent()) {
builder.append("").append(String.join("", worklogAggregate.workContents()));
builder.append("").append(String.join(WORKLOG_DAY_SEPARATOR, worklogAggregate.workContents()));
}
return builder.toString();
}

View File

@@ -91,7 +91,7 @@ public class WorkReportContentExportService {
if (Boolean.TRUE.equals(reqVO.getExportAll())) {
reqVO.setPageNo(1);
reqVO.setPageSize(PageParam.PAGE_SIZE_NONE);
return workReportCommonService.getWeeklyReportPage(reqVO).getList().stream()
return workReportCommonService.getWeeklyReportExportPage(reqVO).getList().stream()
.map(item -> workReportCommonService.getWeeklyReport(item.getId()))
.toList();
}
@@ -104,7 +104,7 @@ public class WorkReportContentExportService {
if (Boolean.TRUE.equals(reqVO.getExportAll())) {
reqVO.setPageNo(1);
reqVO.setPageSize(PageParam.PAGE_SIZE_NONE);
return workReportCommonService.getMonthlyReportPage(reqVO).getList().stream()
return workReportCommonService.getMonthlyReportExportPage(reqVO).getList().stream()
.map(item -> workReportCommonService.getMonthlyReport(item.getId()))
.toList();
}
@@ -117,7 +117,7 @@ public class WorkReportContentExportService {
if (Boolean.TRUE.equals(reqVO.getExportAll())) {
reqVO.setPageNo(1);
reqVO.setPageSize(PageParam.PAGE_SIZE_NONE);
return workReportCommonService.getProjectReportPage(reqVO).getList().stream()
return workReportCommonService.getProjectReportExportPage(reqVO).getList().stream()
.map(item -> workReportCommonService.getProjectReport(item.getId()))
.toList();
}

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.MonthlyReportRespVO;
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.WorkReportStatusLogRespVO;
@@ -32,12 +34,18 @@ public interface MonthlyReportService {
PageResult<MonthlyReportRespVO> getMonthlyApprovalPage(MonthlyReportPageReqVO reqVO);
PageResult<MonthlyReportRespVO> getMonthlySignOffPage(MonthlyReportPageReqVO reqVO);
MonthlyReportSignOffDetailRespVO getMonthlySignOffDetail(Long id);
void submitMonthlyReport(Long id);
void approveMonthlyReport(Long id, MonthlyReportApproveReqVO reqVO);
void rejectMonthlyReport(Long id, WorkReportStatusActionReqVO reqVO);
void confirmMonthlySignOff(Long id, MonthlyReportSignOffConfirmReqVO reqVO);
void deleteMonthlyReport(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.MonthlyReportRespVO;
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.WorkReportStatusLogRespVO;
import com.njcn.rdms.module.project.service.workreport.common.WorkReportCommonService;
@@ -43,8 +45,7 @@ public class MonthlyReportServiceImpl implements MonthlyReportService {
draftReqVO.setPeriodLabel(reqVO.getPeriodLabel());
draftReqVO.setPeriodStartDate(reqVO.getPeriodStartDate());
draftReqVO.setPeriodEndDate(reqVO.getPeriodEndDate());
MonthlyReportRespVO latestDraft = workReportDefaultDraftService.previewMonthlyDefaultDraft(draftReqVO);
return workReportCommonService.mergeMonthlyDraft(latestDraft, reqVO);
return workReportDefaultDraftService.previewMonthlyDefaultDraft(draftReqVO);
}
@Override
@@ -72,6 +73,16 @@ public class MonthlyReportServiceImpl implements MonthlyReportService {
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
public void submitMonthlyReport(Long id) {
workReportCommonService.submitMonthlyReport(id);
@@ -87,6 +98,11 @@ public class MonthlyReportServiceImpl implements MonthlyReportService {
workReportCommonService.rejectMonthlyReport(id, reqVO);
}
@Override
public void confirmMonthlySignOff(Long id, MonthlyReportSignOffConfirmReqVO reqVO) {
workReportCommonService.confirmMonthlySignOff(id, reqVO);
}
@Override
public void deleteMonthlyReport(Long id) {
workReportCommonService.deleteMonthlyReport(id);

View File

@@ -0,0 +1,17 @@
package com.njcn.rdms.module.project.service.workreport.remind;
import jakarta.annotation.Resource;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class WorkReportPendingSubmitRemindScheduler {
@Resource
private WorkReportPendingSubmitRemindService workReportPendingSubmitRemindService;
@Scheduled(cron = "0 0 20 * * ?", zone = "Asia/Shanghai")
public void remindPendingSubmitReports() {
workReportPendingSubmitRemindService.remindPendingSubmitReports();
}
}

View File

@@ -0,0 +1,210 @@
package com.njcn.rdms.module.project.service.workreport.remind;
import com.njcn.rdms.module.project.constant.WorkReportConstants;
import com.njcn.rdms.module.project.dal.dataobject.workreport.monthly.MonthlyReportDO;
import com.njcn.rdms.module.project.dal.dataobject.workreport.project.ProjectReportDO;
import com.njcn.rdms.module.project.dal.dataobject.workreport.weekly.WeeklyReportDO;
import com.njcn.rdms.module.project.dal.mysql.workreport.monthly.MonthlyReportMapper;
import com.njcn.rdms.module.project.dal.mysql.workreport.project.ProjectReportMapper;
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.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 jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.IsoFields;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
@Slf4j
public class WorkReportPendingSubmitRemindService {
private static final List<String> REMIND_STATUS_CODES = List.of(
WorkReportConstants.STATUS_DRAFT,
WorkReportConstants.STATUS_REJECTED);
@Resource
private WeeklyReportMapper weeklyReportMapper;
@Resource
private MonthlyReportMapper monthlyReportMapper;
@Resource
private ProjectReportMapper projectReportMapper;
@Resource
private ApplicationEventPublisher applicationEventPublisher;
@Resource
private NotifyMessageSendApi notifyMessageSendApi;
@Transactional(rollbackFor = Exception.class)
public void remindPendingSubmitReports() {
LocalDateTime cutoffTime = LocalDateTime.now().minusDays(2);
remindWeekly(cutoffTime);
remindMonthly(cutoffTime);
remindProject(cutoffTime);
}
private void remindWeekly(LocalDateTime cutoffTime) {
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) {
if (report == null || report.getReporterId() == null) {
continue;
}
if (hasUnreadReminder(report.getReporterId(), WorkReportConstants.REPORT_TYPE_WEEKLY, report.getId(), unreadKeys)) {
continue;
}
Map<String, Object> params = new HashMap<>();
params.put("reportTypeName", "周报");
params.put("periodKey", formatWeeklyPeriod(report.getPeriodKey(), report.getPeriodStartDate(), report.getPeriodEndDate()));
params.put("managerName", defaultText(report.getSupervisorName(), "系统通知"));
appendRemindIdentity(params, WorkReportConstants.REPORT_TYPE_WEEKLY, report.getId());
applicationEventPublisher.publishEvent(NotifySendEvent.of(
List.of(report.getReporterId()),
NotifyTemplateCodeConstants.WORK_REPORT_TEAM_REMIND,
params,
NotifyMessageLevelConstants.REMIND));
}
}
private void remindMonthly(LocalDateTime cutoffTime) {
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) {
if (report == null || report.getReporterId() == null) {
continue;
}
if (hasUnreadReminder(report.getReporterId(), WorkReportConstants.REPORT_TYPE_MONTHLY, report.getId(), unreadKeys)) {
continue;
}
Map<String, Object> params = new HashMap<>();
params.put("reportTypeName", "月报");
params.put("periodKey", formatMonthlyPeriod(report.getPeriodKey(), report.getPeriodStartDate()));
params.put("managerName", defaultText(report.getSupervisorName(), "系统通知"));
appendRemindIdentity(params, WorkReportConstants.REPORT_TYPE_MONTHLY, report.getId());
applicationEventPublisher.publishEvent(NotifySendEvent.of(
List.of(report.getReporterId()),
NotifyTemplateCodeConstants.WORK_REPORT_TEAM_REMIND,
params,
NotifyMessageLevelConstants.REMIND));
}
}
private void remindProject(LocalDateTime cutoffTime) {
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) {
if (report == null || report.getProjectOwnerId() == null) {
continue;
}
if (hasUnreadReminder(report.getProjectOwnerId(), WorkReportConstants.REPORT_TYPE_PROJECT, report.getId(), unreadKeys)) {
continue;
}
Map<String, Object> params = new HashMap<>();
params.put("projectName", defaultText(report.getProjectName(), ""));
params.put("periodLabel", defaultText(report.getPeriodLabel(), report.getPeriodKey()));
params.put("managerName", defaultText(report.getSupervisorName(), "系统通知"));
appendRemindIdentity(params, WorkReportConstants.REPORT_TYPE_PROJECT, report.getId());
applicationEventPublisher.publishEvent(NotifySendEvent.of(
List.of(report.getProjectOwnerId()),
NotifyTemplateCodeConstants.WORK_REPORT_PROJECT_HALF_MONTH_TEAM_REMIND,
params,
NotifyMessageLevelConstants.REMIND));
}
}
private String formatWeeklyPeriod(String periodKey, LocalDate startDate, LocalDate endDate) {
if (startDate == null || endDate == null) {
return defaultText(periodKey, "");
}
int weekBasedYear = startDate.get(IsoFields.WEEK_BASED_YEAR);
int weekOfYear = startDate.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR);
return weekBasedYear + "" + weekOfYear + "周("
+ startDate.getMonthValue() + "" + startDate.getDayOfMonth() + "日-"
+ endDate.getMonthValue() + "" + endDate.getDayOfMonth() + "日)";
}
private String formatMonthlyPeriod(String periodKey, LocalDate startDate) {
if (startDate == null) {
return defaultText(periodKey, "");
}
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) {
return StringUtils.hasText(text) ? text.trim() : fallback;
}
}

View File

@@ -31,7 +31,7 @@ import org.springframework.util.StringUtils;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoField;
import java.time.temporal.IsoFields;
import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.Collection;
@@ -576,8 +576,9 @@ public class TeamWorkReportServiceImpl implements TeamWorkReportService {
}
LocalDate startDate = parsePeriodDate(parts, 1);
LocalDate endDate = parsePeriodDate(parts, 4);
int weekOfYear = startDate.get(ChronoField.ALIGNED_WEEK_OF_YEAR);
return startDate.getYear() + "" + weekOfYear + "周(" + formatMonthDayRange(startDate, endDate) + "";
int weekBasedYear = startDate.get(IsoFields.WEEK_BASED_YEAR);
int weekOfYear = startDate.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR);
return weekBasedYear + "" + weekOfYear + "周(" + formatMonthDayRange(startDate, endDate) + "";
}
private String formatMonthlyPeriodKey(String periodKey) {

View File

@@ -42,8 +42,7 @@ public class WeeklyReportServiceImpl implements WeeklyReportService {
draftReqVO.setPeriodLabel(reqVO.getPeriodLabel());
draftReqVO.setPeriodStartDate(reqVO.getPeriodStartDate());
draftReqVO.setPeriodEndDate(reqVO.getPeriodEndDate());
WeeklyReportRespVO latestDraft = workReportDefaultDraftService.previewWeeklyDefaultDraft(draftReqVO);
return workReportCommonService.mergeWeeklyDraft(latestDraft, reqVO);
return workReportDefaultDraftService.previewWeeklyDefaultDraft(draftReqVO);
}
@Override

View File

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

View File

@@ -7,6 +7,8 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import java.util.List;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -42,6 +44,30 @@ class ProjectObjectAuthorizationServiceTest extends BaseMockitoUnitTest {
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) {
MockedStatic<SecurityFrameworkUtils> mockedStatic = mockStatic(SecurityFrameworkUtils.class);
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);
assertEquals("project_created", first.getTemplateCode());
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("projectName"));
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.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.ProjectExecutionDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionUpdateReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusActionReqVO;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.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.project.ProjectDO;
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.enums.ErrorCodeConstants;
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.security.service.ProjectObjectAuthorizationService;
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.user.AdminUserApi;
@@ -106,6 +110,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
@Mock
private ProjectRequirementMapper projectRequirementMapper;
@Mock
private ProjectObjectAuthorizationService projectObjectAuthorizationService;
@Mock
private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver;
@Mock
private org.springframework.context.ApplicationEventPublisher applicationEventPublisher;
@@ -118,6 +124,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
void setupDefaultPriorityValidation() {
lenient().when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.REQ_PRIORITY), any()))
.thenReturn(success(true));
lenient().when(userObjectRoleMapper.selectActiveListByObjectAndUserId(eq("project"), any(), eq(3001L)))
.thenAnswer(invocation -> List.of(createProjectMember(invocation.getArgument(1), 3001L, 3101L)));
}
@Test
@@ -129,6 +137,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
reqVO.setOwnerId(3001L);
reqVO.setProjectRequirementId(null);
reqVO.setAssigneeUserIds(List.of(3002L, 3003L));
reqVO.setPriority("1");
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
@@ -202,6 +211,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
reqVO.setOwnerId(3001L);
reqVO.setProjectRequirementId(9001L);
reqVO.setAssigneeUserIds(List.of(3002L));
reqVO.setPriority("1");
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
@@ -268,8 +278,6 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
.thenReturn(createProjectStatus("pending", true));
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")))
.thenReturn(success(true));
@@ -298,8 +306,6 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(createExecutionStatus("pending", true));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
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")))
.thenReturn(success(true));
@@ -309,6 +315,36 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
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
void changeOwner_shouldUpdateOwnerAndWriteOwnerTransferLogs() {
Long projectId = 2001L;
@@ -339,6 +375,51 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
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
void changeExecutionStatus_shouldUpdateByTransitionAndWriteStatusAndAuditLogs() {
Long projectId = 2001L;
@@ -770,8 +851,6 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
.thenReturn(createProjectStatus("pending", true));
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()))
.thenReturn(success(true));
@@ -876,8 +955,6 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(createExecutionStatus("pending", true));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
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()))
.thenReturn(success(true));
when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.REQ_PRIORITY), any()))
@@ -919,6 +996,9 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
assertEquals("execution_assigned", ev.getTemplateCode());
assertEquals(7L, ev.getOperatorUserId());
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("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.mysql.project.ProjectMapper;
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.NotifyTemplateCodeConstants;
import org.junit.jupiter.api.Test;
@@ -63,6 +64,9 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
ArgumentCaptor<NotifySendEvent> captor = ArgumentCaptor.forClass(NotifySendEvent.class);
verify(applicationEventPublisher).publishEvent(captor.capture());
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());
assertTrue(event.getUserIds().contains(1L)); // 负责人
assertTrue(event.getUserIds().contains(2L)); // 协办人

View File

@@ -1,6 +1,7 @@
package com.njcn.rdms.module.system.api.file;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.module.system.api.file.dto.FileContentCreateReqDTO;
import com.njcn.rdms.module.system.api.file.dto.FileRespDTO;
import com.njcn.rdms.module.system.enums.ApiConstants;
import io.swagger.v3.oas.annotations.Operation;
@@ -8,6 +9,8 @@ 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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = ApiConstants.NAME)
@@ -31,4 +34,8 @@ public interface FileApi {
@Parameter(name = "id", description = "文件 ID", required = true)
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_NOT_EXISTS = new ErrorCode(1_001_003_001, "文件不存在");
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 =========
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.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.dal.dataobject.file.FileDO;
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());
}
}

Some files were not shown because too many files have changed in this diff Show More