Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 57061d4f03 | |||
| 5a2ca23217 | |||
| 572fc391ee | |||
| 2830b55ffb | |||
| f77007c4b7 | |||
| 5b2c6dd94c | |||
| b6c2311749 | |||
| 0a2d5ba86c | |||
| 2a5e90a4d4 | |||
| 796c79d58f | |||
| d65a7456db | |||
| 66c187f3de | |||
| 9e502e6a80 |
@@ -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 没注解 = 无鉴权"。
|
||||
|
||||
|
||||
79
docs/superpowers/specs/2026-07-07-执行编辑删除权限口径-design.md
Normal file
79
docs/superpowers/specs/2026-07-07-执行编辑删除权限口径-design.md
Normal 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 补丁。
|
||||
@@ -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, "未找到对应月报审批记录,无法同步绩效结果");
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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 + "')")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 + "')")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -19,7 +19,6 @@ public class MonthlyReportApproveReqVO extends WorkReportStatusActionReqVO {
|
||||
|
||||
@Schema(description = "面谈时间")
|
||||
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY)
|
||||
@NotNull
|
||||
private LocalDate meetingDate;
|
||||
|
||||
@Schema(description = "优势描述")
|
||||
|
||||
@@ -14,4 +14,7 @@ public class MonthlyReportPageReqVO extends WorkReportBasePageReqVO {
|
||||
|
||||
@Schema(description = "团队视角下的填报人用户编号列表")
|
||||
private List<Long> reporterIds;
|
||||
|
||||
@Schema(description = "下签状态编码", example = "pending_employee_confirm")
|
||||
private String signOffStatus;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -46,6 +46,8 @@ public class PerformanceSheetDO extends BaseDO {
|
||||
|
||||
private String statusCode;
|
||||
|
||||
private String signOffStatus;
|
||||
|
||||
private BigDecimal actualScoreTotal;
|
||||
|
||||
private BigDecimal baseScoreTotal;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -47,6 +47,8 @@ public class MonthlyReportDO extends BaseDO {
|
||||
|
||||
private String statusCode;
|
||||
|
||||
private String signOffStatus;
|
||||
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private BigDecimal totalWorkHours;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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 为空时不排除任何状态(空集守卫,与既有口径一致)。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 时排除自身(更新场景)。
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
@@ -177,6 +185,7 @@ public interface MonthlyReportMapper extends BaseMapperX<MonthlyReportDO> {
|
||||
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())) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -255,21 +255,9 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
|
||||
@Override
|
||||
@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
|
||||
@@ -325,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",
|
||||
@@ -749,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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性加载当前对象类型下的状态模型,供列表和树查询复用。
|
||||
*/
|
||||
|
||||
@@ -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 排除集合。
|
||||
* 由任务侧在"任务进度变化 / 状态变更 / 创建删除 / 父子结构变化"等入口触发。
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
@@ -445,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);
|
||||
@@ -479,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);
|
||||
@@ -492,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
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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
|
||||
@@ -728,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
|
||||
@@ -916,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)) {
|
||||
|
||||
@@ -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 工时变更后同步任务自身进度并触发父任务汇总。
|
||||
@@ -118,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);
|
||||
@@ -165,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);
|
||||
@@ -186,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())) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.framework.common.util.object.BeanUtils;
|
||||
import com.njcn.rdms.framework.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;
|
||||
@@ -24,6 +25,8 @@ import com.njcn.rdms.module.project.dal.dataobject.workreport.common.WorkReportM
|
||||
import com.njcn.rdms.module.project.dal.dataobject.workreport.common.WorkReportStatusLogDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.workreport.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;
|
||||
@@ -41,6 +44,8 @@ import com.njcn.rdms.module.project.dal.mysql.workreport.common.PersonalReportRe
|
||||
import com.njcn.rdms.module.project.dal.mysql.workreport.common.WorkReportStatusLogMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.workreport.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;
|
||||
@@ -49,10 +54,12 @@ import com.njcn.rdms.module.project.dal.mysql.workreport.weekly.WeeklyReportAppr
|
||||
import com.njcn.rdms.module.project.dal.mysql.workreport.weekly.WeeklyReportMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.workreport.weekly.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;
|
||||
@@ -60,6 +67,7 @@ import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO;
|
||||
import com.njcn.rdms.module.system.api.user.AdminUserApi;
|
||||
import com.njcn.rdms.module.system.api.user.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;
|
||||
@@ -109,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;
|
||||
@@ -121,6 +133,8 @@ public class WorkReportCommonService {
|
||||
@Resource
|
||||
private StatusActionTextResolver statusActionTextResolver;
|
||||
@Resource
|
||||
private EmployeeSignOffStatusService employeeSignOffStatusService;
|
||||
@Resource
|
||||
private AdminUserApi adminUserApi;
|
||||
@Resource
|
||||
private DeptApi deptApi;
|
||||
@@ -129,6 +143,8 @@ public class WorkReportCommonService {
|
||||
@Resource
|
||||
private UserManagementRelationApi userManagementRelationApi;
|
||||
@Resource
|
||||
private UserSignatureApi userSignatureApi;
|
||||
@Resource
|
||||
private ObjectPermissionApi objectPermissionApi;
|
||||
@Resource
|
||||
private ProjectMapper projectMapper;
|
||||
@@ -368,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());
|
||||
@@ -439,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);
|
||||
@@ -478,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);
|
||||
@@ -513,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);
|
||||
@@ -810,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());
|
||||
@@ -1438,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()));
|
||||
@@ -1446,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());
|
||||
@@ -1513,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()),
|
||||
@@ -1704,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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -71,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);
|
||||
@@ -86,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);
|
||||
|
||||
@@ -9,8 +9,13 @@ import com.njcn.rdms.module.project.dal.mysql.workreport.project.ProjectReportMa
|
||||
import com.njcn.rdms.module.project.dal.mysql.workreport.weekly.WeeklyReportMapper;
|
||||
import com.njcn.rdms.module.project.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;
|
||||
@@ -19,11 +24,16 @@ 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(
|
||||
@@ -38,6 +48,8 @@ public class WorkReportPendingSubmitRemindService {
|
||||
private ProjectReportMapper projectReportMapper;
|
||||
@Resource
|
||||
private ApplicationEventPublisher applicationEventPublisher;
|
||||
@Resource
|
||||
private NotifyMessageSendApi notifyMessageSendApi;
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void remindPendingSubmitReports() {
|
||||
@@ -49,14 +61,21 @@ public class WorkReportPendingSubmitRemindService {
|
||||
|
||||
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,
|
||||
@@ -67,14 +86,21 @@ public class WorkReportPendingSubmitRemindService {
|
||||
|
||||
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,
|
||||
@@ -85,14 +111,21 @@ public class WorkReportPendingSubmitRemindService {
|
||||
|
||||
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,
|
||||
@@ -119,6 +152,58 @@ public class WorkReportPendingSubmitRemindService {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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)); // 协办人
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 客户端不存在");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.njcn.rdms.module.system.api.user;
|
||||
|
||||
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||
import com.njcn.rdms.framework.common.util.object.BeanUtils;
|
||||
import com.njcn.rdms.module.system.api.user.dto.UserSignatureRespDTO;
|
||||
import com.njcn.rdms.module.system.dal.dataobject.user.UserSignatureDO;
|
||||
import com.njcn.rdms.module.system.service.user.UserSignatureService;
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
||||
|
||||
@RestController
|
||||
@Validated
|
||||
@Hidden
|
||||
public class UserSignatureApiImpl implements UserSignatureApi {
|
||||
|
||||
@Resource
|
||||
private UserSignatureService userSignatureService;
|
||||
|
||||
@Override
|
||||
public CommonResult<UserSignatureRespDTO> getByUserId(Long userId) {
|
||||
UserSignatureDO signature = userSignatureService.getByUserId(userId);
|
||||
return success(BeanUtils.toBean(signature, UserSignatureRespDTO.class));
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,8 @@ public class FileController {
|
||||
@DeleteMapping("/delete")
|
||||
@Operation(summary = "删除文件")
|
||||
@Parameter(name = "id", description = "编号", required = true)
|
||||
@PreAuthorize("@ss.hasPermission('system:file:delete')")
|
||||
// 权限下沉到 FileService:上传者本人可删自己上传的文件,其余需 system:file:delete 权限。
|
||||
// 故意不挂 @PreAuthorize,否则无该权限码的上传者会在进方法体前被 AOP 挡死(口径同项目域"属主放行+权限码兜底")。
|
||||
public CommonResult<Boolean> deleteFile(@RequestParam("id") Long id) throws Exception {
|
||||
fileService.deleteFile(id);
|
||||
return success(true);
|
||||
@@ -93,7 +94,7 @@ public class FileController {
|
||||
@DeleteMapping("/delete-list")
|
||||
@Operation(summary = "批量删除文件")
|
||||
@Parameter(name = "ids", description = "编号列表", required = true)
|
||||
@PreAuthorize("@ss.hasPermission('system:file:delete')")
|
||||
// 权限下沉到 FileService:逐个按上传者归属校验,其余需 system:file:delete 权限(见 deleteFile 注释)。
|
||||
public CommonResult<Boolean> deleteFileList(@RequestParam("ids") List<Long> ids) throws Exception {
|
||||
fileService.deleteFileList(ids);
|
||||
return success(true);
|
||||
@@ -131,6 +132,13 @@ public class FileController {
|
||||
// https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1432/
|
||||
path = URLUtil.decode(path, StandardCharsets.UTF_8, false);
|
||||
|
||||
FileDO file = fileService.getFileByConfigIdAndPath(configId, path);
|
||||
if (file == null) {
|
||||
log.warn("[getFileContent][configId({}) path({}) 文件不存在]", configId, path);
|
||||
response.setStatus(HttpStatus.NOT_FOUND.value());
|
||||
return;
|
||||
}
|
||||
|
||||
// 读取内容
|
||||
byte[] content = fileService.getFileContent(configId, path);
|
||||
if (content == null) {
|
||||
@@ -138,7 +146,7 @@ public class FileController {
|
||||
response.setStatus(HttpStatus.NOT_FOUND.value());
|
||||
return;
|
||||
}
|
||||
writeAttachment(response, path, content);
|
||||
writeAttachment(response, StrUtil.blankToDefault(file.getName(), file.getPath()), content);
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
|
||||
@@ -136,7 +136,7 @@ public class UserManagementRelationController {
|
||||
* 获得用户管理链路信息
|
||||
*
|
||||
* 根据主键ID查询单条用户管理链路记录
|
||||
* 权限要求:system:user-management-relation:query
|
||||
* 权限要求:仅需登录,不做权限码校验
|
||||
*
|
||||
* @param id 关系记录主键ID
|
||||
* @return 用户管理链路详情
|
||||
@@ -144,7 +144,6 @@ public class UserManagementRelationController {
|
||||
@GetMapping(value = "/get")
|
||||
@Operation(summary = "获得用户管理链路信息")
|
||||
@Parameter(name = "id", description = "关系编号", required = true, example = "1024")
|
||||
@PreAuthorize("@ss.hasPermission('system:user-management-relation:query')")
|
||||
public CommonResult<UserManagementRelationRespVO> getUserManagementRelation(@RequestParam("id") Long id) {
|
||||
UserManagementRelationDO relation = userManagementRelationService.getRelation(id);
|
||||
return success(BeanUtils.toBean(relation, UserManagementRelationRespVO.class));
|
||||
@@ -154,14 +153,13 @@ public class UserManagementRelationController {
|
||||
* 获取用户管理链路列表
|
||||
*
|
||||
* 根据查询条件查询用户管理链路记录列表
|
||||
* 权限要求:system:user-management-relation:query
|
||||
* 权限要求:仅需登录,不做权限码校验
|
||||
*
|
||||
* @param reqVO 查询条件VO
|
||||
* @return 用户管理链路列表
|
||||
*/
|
||||
@GetMapping("/query")
|
||||
@Operation(summary = "获取用户管理链路列表")
|
||||
@PreAuthorize("@ss.hasPermission('system:user-management-relation:query')")
|
||||
public CommonResult<List<UserManagementRelationTreeRespVO>> getUserManagementRelationQuery(@Validated UserManagementRelationQueryReqVO reqVO) {
|
||||
List<UserManagementRelationTreeRespVO> list = userManagementRelationService.getRelationQuery(reqVO);
|
||||
return success(list);
|
||||
@@ -176,7 +174,6 @@ public class UserManagementRelationController {
|
||||
@GetMapping("/direct-manager")
|
||||
@Operation(summary = "获得某用户当前生效的直属上级")
|
||||
@Parameter(name = "userId", description = "用户ID", required = true, example = "1024")
|
||||
@PreAuthorize("@ss.hasPermission('system:user-management-relation:query')")
|
||||
public CommonResult<UserSimpleRespVO> getDirectManager(@RequestParam("userId") Long userId) {
|
||||
AdminUserDO manager = userManagementRelationService.getDirectManager(userId);
|
||||
if (manager == null) {
|
||||
@@ -193,7 +190,6 @@ public class UserManagementRelationController {
|
||||
*/
|
||||
@GetMapping("/candidate-users")
|
||||
@Operation(summary = "获取未绑定直属上级的候选下级用户列表")
|
||||
@PreAuthorize("@ss.hasPermission('system:user-management-relation:query')")
|
||||
public CommonResult<List<UserSimpleRespVO>> getCandidateSubordinateUsers() {
|
||||
List<AdminUserDO> users = userManagementRelationService.getCandidateSubordinateUsers();
|
||||
Map<Long, DeptDO> deptMap = deptService.getDeptMap(convertList(users, AdminUserDO::getDeptId));
|
||||
@@ -208,7 +204,6 @@ public class UserManagementRelationController {
|
||||
@GetMapping("/direct-subordinates")
|
||||
@Operation(summary = "获取某用户当前生效的直属下级列表")
|
||||
@Parameter(name = "userId", description = "用户ID", required = true, example = "1024")
|
||||
//@PreAuthorize("@ss.hasPermission('system:user-management-relation:query')")
|
||||
public CommonResult<List<UserSimpleRespVO>> getDirectSubordinates(@RequestParam("userId") Long userId) {
|
||||
List<UserManagementRelationDO> relations = userManagementRelationService.getRelationListByManagerUserId(userId);
|
||||
if (relations.isEmpty()) {
|
||||
@@ -250,13 +245,12 @@ public class UserManagementRelationController {
|
||||
* - 中间节点:有上级也有下级
|
||||
* - 叶子节点:基层员工,没有下级
|
||||
*
|
||||
* 权限要求:system:user-management-relation:query
|
||||
* 权限要求:仅需登录,不做权限码校验
|
||||
*
|
||||
* @return 用户管理链路树形列表
|
||||
*/
|
||||
@GetMapping("/tree")
|
||||
@Operation(summary = "获取用户管理链路树形结构", description = "用于前端树形控件展示,包含用户的上下级层级关系")
|
||||
@PreAuthorize("@ss.hasPermission('system:user-management-relation:query')")
|
||||
public CommonResult<List<UserManagementRelationTreeRespVO>> getUserManagementRelationTree(@Validated UserManagementRelationQueryReqVO reqVO) {
|
||||
return success(userManagementRelationService.getRelationTree(reqVO));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.njcn.rdms.module.system.controller.admin.user;
|
||||
|
||||
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||
import com.njcn.rdms.framework.common.util.object.BeanUtils;
|
||||
import com.njcn.rdms.module.system.controller.admin.user.vo.signature.UserSignatureRespVO;
|
||||
import com.njcn.rdms.module.system.controller.admin.user.vo.signature.UserSignatureSaveReqVO;
|
||||
import com.njcn.rdms.module.system.dal.dataobject.user.UserSignatureDO;
|
||||
import com.njcn.rdms.module.system.service.user.UserSignatureService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
||||
import static com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
|
||||
|
||||
@Tag(name = "管理后台 - 用户电子签名")
|
||||
@RestController
|
||||
@RequestMapping("/system/user-signatures")
|
||||
@Validated
|
||||
public class UserSignatureController {
|
||||
|
||||
@Resource
|
||||
private UserSignatureService userSignatureService;
|
||||
|
||||
@GetMapping("/get")
|
||||
@Operation(summary = "获取用户电子签名")
|
||||
@Parameter(name = "userId", description = "用户 ID", required = true)
|
||||
@PreAuthorize("@ss.hasPermission('system:user-signature:query')")
|
||||
public CommonResult<UserSignatureRespVO> get(@RequestParam("userId") Long userId) {
|
||||
return success(toRespVO(userSignatureService.getByUserId(userId)));
|
||||
}
|
||||
|
||||
@PostMapping("/save")
|
||||
@Operation(summary = "保存用户电子签名")
|
||||
@PreAuthorize("@ss.hasPermission('system:user-signature:update')")
|
||||
public CommonResult<Boolean> save(@Valid @RequestBody UserSignatureSaveReqVO reqVO) {
|
||||
userSignatureService.save(reqVO.getUserId(), reqVO.getFileId(), reqVO.getFileName(), reqVO.getStatus(), reqVO.getRemark());
|
||||
return success(true);
|
||||
}
|
||||
|
||||
@DeleteMapping("/delete")
|
||||
@Operation(summary = "删除用户电子签名")
|
||||
@Parameter(name = "userId", description = "用户 ID", required = true)
|
||||
@PreAuthorize("@ss.hasPermission('system:user-signature:update')")
|
||||
public CommonResult<Boolean> delete(@RequestParam("userId") Long userId) {
|
||||
userSignatureService.deleteByUserId(userId);
|
||||
return success(true);
|
||||
}
|
||||
|
||||
@GetMapping("/my")
|
||||
@Operation(summary = "获取当前登录用户电子签名")
|
||||
public CommonResult<UserSignatureRespVO> my() {
|
||||
return success(toRespVO(userSignatureService.getByUserId(getLoginUserId())));
|
||||
}
|
||||
|
||||
private UserSignatureRespVO toRespVO(UserSignatureDO signature) {
|
||||
return BeanUtils.toBean(signature, UserSignatureRespVO.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.njcn.rdms.module.system.controller.admin.user.vo.signature;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Schema(description = "管理后台 - 用户电子签名 Response VO")
|
||||
@Data
|
||||
public class UserSignatureRespVO {
|
||||
|
||||
private Long id;
|
||||
|
||||
private Long userId;
|
||||
|
||||
private Long fileId;
|
||||
|
||||
private String fileName;
|
||||
|
||||
private Integer status;
|
||||
|
||||
private String remark;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.njcn.rdms.module.system.controller.admin.user.vo.signature;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
@Schema(description = "管理后台 - 用户电子签名保存 Request VO")
|
||||
@Data
|
||||
public class UserSignatureSaveReqVO {
|
||||
|
||||
@Schema(description = "用户 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
|
||||
@NotNull(message = "用户 ID 不能为空")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "电子签文件 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048")
|
||||
@NotNull(message = "电子签文件 ID 不能为空")
|
||||
private Long fileId;
|
||||
|
||||
@Schema(description = "文件名", example = "zhangsan-sign.png")
|
||||
@Size(max = 255, message = "文件名长度不能超过 255 个字符")
|
||||
private String fileName;
|
||||
|
||||
@Schema(description = "状态", example = "0")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "备注")
|
||||
@Size(max = 255, message = "备注长度不能超过 255 个字符")
|
||||
private String remark;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.njcn.rdms.module.system.dal.dataobject.user;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 用户电子签名。
|
||||
*/
|
||||
@TableName("system_user_signature")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class UserSignatureDO extends BaseDO {
|
||||
|
||||
@TableId
|
||||
private Long id;
|
||||
|
||||
private Long userId;
|
||||
|
||||
private Long fileId;
|
||||
|
||||
private String fileName;
|
||||
|
||||
private Integer status;
|
||||
|
||||
private String remark;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.njcn.rdms.module.system.dal.mysql.user;
|
||||
|
||||
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import com.njcn.rdms.module.system.dal.dataobject.user.UserSignatureDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface UserSignatureMapper extends BaseMapperX<UserSignatureDO> {
|
||||
|
||||
default UserSignatureDO selectByUserId(Long userId) {
|
||||
return selectOne(new LambdaQueryWrapperX<UserSignatureDO>()
|
||||
.eq(UserSignatureDO::getUserId, userId));
|
||||
}
|
||||
|
||||
default int deleteByUserId(Long userId) {
|
||||
return delete(new LambdaQueryWrapperX<UserSignatureDO>()
|
||||
.eq(UserSignatureDO::getUserId, userId));
|
||||
}
|
||||
}
|
||||
@@ -114,9 +114,10 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
|
||||
|
||||
@Override
|
||||
public String presignGetUrl(String url, Integer expirationSeconds) {
|
||||
// 1. 将 url 转换为 path
|
||||
String path = StrUtil.removePrefix(url, config.getDomain() + "/");
|
||||
path = HttpUtils.decodeUtf8(HttpUtils.removeUrlQuery(path));
|
||||
// 1. 将入参转换为对象 key
|
||||
// - upload(...) 这里传进来的是原始 path(未编码),绝不能先 decode
|
||||
// - 历史记录 / 对外接口传进来的可能是完整 URL(可能已编码),这时才需要 decode
|
||||
String path = resolveObjectKey(url);
|
||||
|
||||
// 2.1 情况一:公开访问:无需签名
|
||||
// 考虑到老版本的兼容,所以必须是 config.getEnablePublicAccess() 为 false 时,才进行签名
|
||||
@@ -134,6 +135,23 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
|
||||
return signedUrl.toString();
|
||||
}
|
||||
|
||||
private String resolveObjectKey(String urlOrPath) {
|
||||
if (!HttpUtil.isHttp(urlOrPath) && !HttpUtil.isHttps(urlOrPath)) {
|
||||
return urlOrPath;
|
||||
}
|
||||
|
||||
String cleaned = HttpUtils.removeUrlQuery(urlOrPath);
|
||||
String path = StrUtil.removePrefix(cleaned, config.getDomain() + "/");
|
||||
if (StrUtil.equals(path, cleaned)) {
|
||||
URI uri = URI.create(cleaned);
|
||||
path = StrUtil.removePrefix(StrUtil.nullToEmpty(uri.getPath()), "/");
|
||||
if (Boolean.TRUE.equals(config.getEnablePathStyleAccess())) {
|
||||
path = StrUtil.removePrefix(path, config.getBucket() + "/");
|
||||
}
|
||||
}
|
||||
return HttpUtils.decodeUtf8(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于 bucket + endpoint 构建访问的 Domain 地址
|
||||
*
|
||||
|
||||
@@ -63,6 +63,15 @@ public interface FileService {
|
||||
Long createFile(FileCreateReqVO createReqVO);
|
||||
FileDO getFile(Long id);
|
||||
|
||||
/**
|
||||
* 按 (configId, path) 获取文件记录。
|
||||
*
|
||||
* @param configId 文件配置编号
|
||||
* @param path 文件存储路径
|
||||
* @return 文件记录,不存在返回 null
|
||||
*/
|
||||
FileDO getFileByConfigIdAndPath(Long configId, String path);
|
||||
|
||||
/**
|
||||
* 通过文件 URL 获得文件。
|
||||
*
|
||||
|
||||
@@ -3,12 +3,15 @@ package com.njcn.rdms.module.system.service.file;
|
||||
import cn.hutool.core.date.LocalDateTimeUtil;
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.framework.common.util.http.HttpUtils;
|
||||
import com.njcn.rdms.framework.common.util.object.BeanUtils;
|
||||
import com.njcn.rdms.framework.security.core.service.SecurityFrameworkService;
|
||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import com.njcn.rdms.module.system.controller.admin.file.vo.file.FileCreateReqVO;
|
||||
import com.njcn.rdms.module.system.controller.admin.file.vo.file.FilePageReqVO;
|
||||
import com.njcn.rdms.module.system.controller.admin.file.vo.file.FilePresignedUrlRespVO;
|
||||
@@ -26,6 +29,7 @@ import java.util.List;
|
||||
|
||||
import static cn.hutool.core.date.DatePattern.PURE_DATE_PATTERN;
|
||||
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static com.njcn.rdms.module.system.enums.ErrorCodeConstants.FILE_DELETE_NO_PERMISSION;
|
||||
import static com.njcn.rdms.module.system.enums.ErrorCodeConstants.FILE_NOT_EXISTS;
|
||||
|
||||
/**
|
||||
@@ -42,14 +46,6 @@ public class FileServiceImpl implements FileService {
|
||||
* 目的:按照日期,进行分目录
|
||||
*/
|
||||
static boolean PATH_PREFIX_DATE_ENABLE = true;
|
||||
/**
|
||||
* 上传文件的后缀,是否包含时间戳
|
||||
* <p>
|
||||
* 目的:保证文件的唯一性,避免覆盖
|
||||
* 定制:可按需调整成 UUID、或者其他方式
|
||||
*/
|
||||
static boolean PATH_SUFFIX_TIMESTAMP_ENABLE = true;
|
||||
|
||||
@Resource
|
||||
private FileConfigService fileConfigService;
|
||||
|
||||
@@ -59,6 +55,9 @@ public class FileServiceImpl implements FileService {
|
||||
@Resource
|
||||
private FileContentMapper fileContentMapper;
|
||||
|
||||
@Resource
|
||||
private SecurityFrameworkService securityFrameworkService;
|
||||
|
||||
@Override
|
||||
public PageResult<FileDO> getFilePage(FilePageReqVO pageReqVO) {
|
||||
return fileMapper.selectPage(pageReqVO);
|
||||
@@ -103,34 +102,27 @@ public class FileServiceImpl implements FileService {
|
||||
|
||||
@VisibleForTesting
|
||||
String generateUploadPath(String name, String directory) {
|
||||
// 1. 生成前缀、后缀
|
||||
// 1. 生成前缀,并将存储文件名切换为安全 key:
|
||||
// path 仅承担对象存储 key / 路径职责,不再复用原文件名,避免 %、&、中文等字符进入 URI 路径。
|
||||
String prefix = null;
|
||||
if (PATH_PREFIX_DATE_ENABLE) {
|
||||
prefix = LocalDateTimeUtil.format(LocalDateTimeUtil.now(), PURE_DATE_PATTERN);
|
||||
}
|
||||
String suffix = null;
|
||||
if (PATH_SUFFIX_TIMESTAMP_ENABLE) {
|
||||
suffix = String.valueOf(System.currentTimeMillis());
|
||||
String ext = FileUtil.extName(name);
|
||||
String storageName = IdUtil.fastSimpleUUID();
|
||||
if (StrUtil.isNotEmpty(ext)) {
|
||||
storageName = storageName + StrUtil.DOT + ext;
|
||||
}
|
||||
|
||||
// 2.1 先拼接 suffix 后缀
|
||||
if (StrUtil.isNotEmpty(suffix)) {
|
||||
String ext = FileUtil.extName(name);
|
||||
if (StrUtil.isNotEmpty(ext)) {
|
||||
name = FileUtil.mainName(name) + StrUtil.C_UNDERLINE + suffix + StrUtil.DOT + ext;
|
||||
} else {
|
||||
name = name + StrUtil.C_UNDERLINE + suffix;
|
||||
}
|
||||
}
|
||||
// 2.2 再拼接 prefix 前缀
|
||||
// 2. 先拼接 prefix 前缀
|
||||
if (StrUtil.isNotEmpty(prefix)) {
|
||||
name = prefix + StrUtil.SLASH + name;
|
||||
storageName = prefix + StrUtil.SLASH + storageName;
|
||||
}
|
||||
// 2.3 最后拼接 directory 目录
|
||||
// 3. 最后拼接 directory 目录
|
||||
if (StrUtil.isNotEmpty(directory)) {
|
||||
name = directory + StrUtil.SLASH + name;
|
||||
storageName = directory + StrUtil.SLASH + storageName;
|
||||
}
|
||||
return name;
|
||||
return storageName;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -166,6 +158,14 @@ public class FileServiceImpl implements FileService {
|
||||
return validateFileExists(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileDO getFileByConfigIdAndPath(Long configId, String path) {
|
||||
if (configId == null || StrUtil.isBlank(path)) {
|
||||
return null;
|
||||
}
|
||||
return fileMapper.selectByConfigIdAndPath(configId, path);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileDO getFileByUrl(String url) {
|
||||
if (StrUtil.isBlank(url)) {
|
||||
@@ -178,6 +178,8 @@ public class FileServiceImpl implements FileService {
|
||||
public void deleteFile(Long id) throws Exception {
|
||||
// 校验存在
|
||||
FileDO file = validateFileExists(id);
|
||||
// 校验删除权限:上传者本人,或具备文件删除权限者
|
||||
validateFileDeletePermission(file);
|
||||
|
||||
// 从文件存储器中删除
|
||||
FileClient client = fileConfigService.getFileClient(file.getConfigId());
|
||||
@@ -193,6 +195,8 @@ public class FileServiceImpl implements FileService {
|
||||
public void deleteFileList(List<Long> ids) {
|
||||
// 删除文件
|
||||
List<FileDO> files = fileMapper.selectByIds(ids);
|
||||
// 逐个校验删除权限,任一越权即整体拒绝
|
||||
files.forEach(this::validateFileDeletePermission);
|
||||
for (FileDO file : files) {
|
||||
// 获取客户端
|
||||
FileClient client = fileConfigService.getFileClient(file.getConfigId());
|
||||
@@ -213,6 +217,25 @@ public class FileServiceImpl implements FileService {
|
||||
return fileDO;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验文件删除权限。
|
||||
* <p>
|
||||
* 口径:上传者本人可删除自己上传的文件;其余情况需具备文件管理删除权限 {@code system:file:delete}(运维 / 管理员)。
|
||||
* 该接口按 id 删除、不校验业务归属,故不能对所有登录用户放开,只对"本人上传"放行。
|
||||
*/
|
||||
private void validateFileDeletePermission(FileDO file) {
|
||||
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
// 上传者本人放行(creator 存的是上传时的登录用户 id)
|
||||
if (loginUserId != null && String.valueOf(loginUserId).equals(file.getCreator())) {
|
||||
return;
|
||||
}
|
||||
// 兜底:具备文件删除权限者(运维 / 管理员)可删除任意文件
|
||||
if (securityFrameworkService.hasPermission("system:file:delete")) {
|
||||
return;
|
||||
}
|
||||
throw exception(FILE_DELETE_NO_PERMISSION);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] getFileContent(Long configId, String path) throws Exception {
|
||||
// 软删 / 不存在的记录直接拒绝
|
||||
|
||||
@@ -563,8 +563,8 @@ public class AdminUserServiceImpl implements AdminUserService {
|
||||
|
||||
url = client.upload(content, path, type);
|
||||
|
||||
//删除旧的
|
||||
client.delete(user.getAvatar());
|
||||
// 旧头像删除失败不应影响新头像上传;而且 avatar 字段存的是 URL,不能直接当 path 删。
|
||||
deleteOldAvatarQuietly(client, user);
|
||||
|
||||
// 3. 保存到数据库
|
||||
fileDO = new FileDO().setConfigId(client.getId())
|
||||
@@ -574,11 +574,31 @@ public class AdminUserServiceImpl implements AdminUserService {
|
||||
user.setAvatar(fileDO.getUrl());
|
||||
this.userMapper.updateById(user);
|
||||
} catch (Exception e) {
|
||||
log.warn("头像上传失败,userId={}, avatar={}, fileName={}, contentType={}",
|
||||
user == null ? null : user.getId(),
|
||||
user == null ? null : user.getAvatar(),
|
||||
name, type, e);
|
||||
throw exception(USER_AVATAR_UPLOAD_FAILED);
|
||||
}
|
||||
return fileDO;
|
||||
}
|
||||
|
||||
private void deleteOldAvatarQuietly(FileClient client, AdminUserDO user) {
|
||||
if (client == null || user == null || StrUtil.isBlank(user.getAvatar())) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
FileDO oldAvatar = fileMapper.selectByUrl(user.getAvatar());
|
||||
if (oldAvatar == null || StrUtil.isBlank(oldAvatar.getPath())) {
|
||||
log.warn("删除旧头像时未找到文件记录,userId={}, avatar={}", user.getId(), user.getAvatar());
|
||||
return;
|
||||
}
|
||||
client.delete(oldAvatar.getPath());
|
||||
} catch (Exception ex) {
|
||||
log.warn("删除旧头像失败,userId={}, avatar={}", user.getId(), user.getAvatar(), ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateImageFile(MultipartFile file) {
|
||||
// 检查文件类型
|
||||
String contentType = file.getContentType();
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.njcn.rdms.module.system.service.user;
|
||||
|
||||
import com.njcn.rdms.module.system.dal.dataobject.user.UserSignatureDO;
|
||||
|
||||
public interface UserSignatureService {
|
||||
|
||||
UserSignatureDO getByUserId(Long userId);
|
||||
|
||||
void save(Long userId, Long fileId, String fileName, Integer status, String remark);
|
||||
|
||||
void deleteByUserId(Long userId);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.njcn.rdms.module.system.service.user;
|
||||
|
||||
import com.njcn.rdms.framework.common.enums.CommonStatusEnum;
|
||||
import com.njcn.rdms.module.system.dal.dataobject.file.FileDO;
|
||||
import com.njcn.rdms.module.system.dal.dataobject.user.UserSignatureDO;
|
||||
import com.njcn.rdms.module.system.dal.mysql.user.UserSignatureMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import static com.njcn.rdms.framework.common.util.collection.CollectionUtils.singleton;
|
||||
|
||||
@Service
|
||||
public class UserSignatureServiceImpl implements UserSignatureService {
|
||||
|
||||
@Resource
|
||||
private UserSignatureMapper userSignatureMapper;
|
||||
@Resource
|
||||
private AdminUserService adminUserService;
|
||||
@Resource
|
||||
private com.njcn.rdms.module.system.service.file.FileService fileService;
|
||||
|
||||
@Override
|
||||
public UserSignatureDO getByUserId(Long userId) {
|
||||
if (userId == null) {
|
||||
return null;
|
||||
}
|
||||
return userSignatureMapper.selectByUserId(userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void save(Long userId, Long fileId, String fileName, Integer status, String remark) {
|
||||
adminUserService.validateUserList(singleton(userId));
|
||||
FileDO file = fileService.getFile(fileId);
|
||||
|
||||
UserSignatureDO existing = userSignatureMapper.selectByUserId(userId);
|
||||
UserSignatureDO target = new UserSignatureDO();
|
||||
if (existing != null) {
|
||||
target.setId(existing.getId());
|
||||
}
|
||||
target.setUserId(userId);
|
||||
target.setFileId(fileId);
|
||||
target.setFileName(StringUtils.hasText(fileName) ? fileName.trim() : file.getName());
|
||||
target.setStatus(status == null ? CommonStatusEnum.ENABLE.getStatus() : status);
|
||||
target.setRemark(StringUtils.hasText(remark) ? remark.trim() : null);
|
||||
if (existing == null) {
|
||||
userSignatureMapper.insert(target);
|
||||
} else {
|
||||
userSignatureMapper.updateById(target);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void deleteByUserId(Long userId) {
|
||||
if (userId == null) {
|
||||
return;
|
||||
}
|
||||
userSignatureMapper.deleteByUserId(userId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user