From d069948d2af1e95a6b7288bdd877ae95a1634a13 Mon Sep 17 00:00:00 2001 From: caozehui <2427765068@qq.com> Date: Thu, 21 May 2026 10:31:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(personal-item):=20=E4=B8=AA=E4=BA=BA?= =?UTF-8?q?=E4=BA=8B=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project/enums/ErrorCodeConstants.java | 11 + .../constant/ObjectActivityConstants.java | 6 + .../constant/PersonalItemConstants.java | 28 + .../personal/PersonalItemController.java | 160 ++++ .../PersonalItemLifecycleActionRespVO.java | 20 + .../vo/item/PersonalItemPageReqVO.java | 32 + .../personal/vo/item/PersonalItemRespVO.java | 76 ++ .../vo/item/PersonalItemSaveReqVO.java | 45 ++ .../item/PersonalItemStatusActionReqVO.java | 21 + .../execution/ProjectExecutionController.java | 3 + .../task/vo/worklog/TaskWorklogSaveReqVO.java | 2 + .../dataobject/personal/PersonalItemDO.java | 56 ++ .../project/task/TaskWorklogDO.java | 1 + .../mysql/personal/PersonalItemMapper.java | 48 ++ .../execution/ProjectExecutionMapper.java | 7 + .../personal/PersonalItemAccessService.java | 10 + .../PersonalItemAccessServiceImpl.java | 35 + .../service/personal/PersonalItemService.java | 41 + .../personal/PersonalItemServiceImpl.java | 745 ++++++++++++++++++ .../PersonalItemStatusViewService.java | 74 ++ .../execution/ProjectExecutionService.java | 4 + .../ProjectExecutionServiceImpl.java | 34 + .../task/worklog/TaskWorklogServiceImpl.java | 9 +- .../ProjectExecutionServiceImplTest.java | 48 ++ 24 files changed, 1509 insertions(+), 7 deletions(-) create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/PersonalItemConstants.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/personal/PersonalItemController.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/personal/vo/item/PersonalItemLifecycleActionRespVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/personal/vo/item/PersonalItemPageReqVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/personal/vo/item/PersonalItemRespVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/personal/vo/item/PersonalItemSaveReqVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/personal/vo/item/PersonalItemStatusActionReqVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/personal/PersonalItemDO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/personal/PersonalItemMapper.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/personal/PersonalItemAccessService.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/personal/PersonalItemAccessServiceImpl.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/personal/PersonalItemService.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/personal/PersonalItemServiceImpl.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/personal/PersonalItemStatusViewService.java diff --git a/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ErrorCodeConstants.java b/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ErrorCodeConstants.java index a8cae0b..c6e183d 100644 --- a/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ErrorCodeConstants.java +++ b/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ErrorCodeConstants.java @@ -202,4 +202,15 @@ public interface ErrorCodeConstants { ErrorCode PROJECT_REQUIREMENT_MODULE_HAS_REQUIREMENTS = new ErrorCode(1_008_007_016, "模块下存在项目需求,请先删除需求"); ErrorCode PROJECT_REQUIREMENT_CHILD_NOT_ALLOW_CANCEL = new ErrorCode(1_008_007_017, "只有不存在子需求,或子需求都处于已取消和已拒绝状态时,父需求才允许取消"); ErrorCode PROJECT_REQUIREMENT_SYNCED_FROM_PRODUCT_NOT_ALLOW_CANCEL = new ErrorCode(1_008_007_019, "\u7531\u4ea7\u54c1\u9700\u6c42\u6d41\u8f6c\u751f\u6210\u7684\u9879\u76ee\u9700\u6c42\u4e0d\u5141\u8bb8\u53d6\u6d88"); + + // ========== 个人事项 1_008_008_xxx ========== + ErrorCode PERSONAL_ITEM_NOT_EXISTS = new ErrorCode(1_008_008_001, "个人事项不存在"); + ErrorCode PERSONAL_ITEM_OWNER_NOT_IN_EXECUTION = new ErrorCode(1_008_008_002, "个人事项负责人必须属于当前有效执行团队成员"); + ErrorCode PERSONAL_ITEM_STATUS_MODEL_NOT_EXISTS_OR_DISABLED = new ErrorCode(1_008_008_003, "个人事项状态定义不存在或已停用"); + ErrorCode PERSONAL_ITEM_STATUS_ACTION_NOT_ALLOWED = new ErrorCode(1_008_008_004, "当前个人事项状态不支持动作【{}】"); + ErrorCode PERSONAL_ITEM_STATUS_ACTION_REASON_REQUIRED = new ErrorCode(1_008_008_005, "动作【{}】必须填写原因"); + ErrorCode PERSONAL_ITEM_STATUS_CONCURRENT_MODIFIED = new ErrorCode(1_008_008_006, "个人事项状态已发生变化,请刷新后重试"); + ErrorCode PERSONAL_ITEM_STATUS_NOT_ALLOW_EDIT = new ErrorCode(1_008_008_007, "当前个人事项状态不允许编辑"); + ErrorCode PERSONAL_ITEM_NOT_ALLOW_DELETE = new ErrorCode(1_008_008_008, "仅初始态(待开始)的个人事项允许删除"); + ErrorCode PERSONAL_ITEM_WRITE_FORBIDDEN = new ErrorCode(1_008_008_009, "无权修改个人事项"); } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ObjectActivityConstants.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ObjectActivityConstants.java index ebc773a..1f4ebe6 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ObjectActivityConstants.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ObjectActivityConstants.java @@ -85,6 +85,9 @@ public final class ObjectActivityConstants { public static final String TASK_ACTION_CREATE = "create_task_entity"; public static final String TASK_ACTION_UPDATE = "update_task_entity"; public static final String TASK_ACTION_DELETE = "delete_task_entity"; + public static final String PERSONAL_ITEM_ACTION_CREATE = "create_personal_item"; + public static final String PERSONAL_ITEM_ACTION_UPDATE = "update_personal_item"; + public static final String PERSONAL_ITEM_ACTION_DELETE = "delete_personal_item"; // ========== 任务协办人事件类型(B 模型 - 多行周期记录) ========== public static final String TASK_ASSIGNEE_ACTION_JOIN = "join"; @@ -134,6 +137,9 @@ public final class ObjectActivityConstants { case TASK_ACTION_CREATE -> "创建任务"; case TASK_ACTION_UPDATE -> "更新任务"; case TASK_ACTION_DELETE -> "删除任务"; + case PERSONAL_ITEM_ACTION_CREATE -> "创建个人事项"; + case PERSONAL_ITEM_ACTION_UPDATE -> "更新个人事项"; + case PERSONAL_ITEM_ACTION_DELETE -> "删除个人事项"; case TASK_ASSIGNEE_ACTION_JOIN -> "加入"; case TASK_ASSIGNEE_ACTION_INACTIVE -> "退出"; case EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_IN -> "转入负责人"; diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/PersonalItemConstants.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/PersonalItemConstants.java new file mode 100644 index 0000000..82993f8 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/PersonalItemConstants.java @@ -0,0 +1,28 @@ +package com.njcn.rdms.module.project.constant; + +/** + * 个人事项常量。 + */ +public final class PersonalItemConstants { + + private PersonalItemConstants() { + } + + /** + * 个人事项状态模型复用任务对象类型。 + */ + public static final String STATUS_OBJECT_TYPE = ProjectTaskConstants.OBJECT_TYPE; + + /** + * 个人事项业务类型。 + */ + public static final String BIZ_TYPE = "personal_item"; + + public static final String PERMISSION_QUERY = "project:personal-item:query"; + public static final String PERMISSION_CREATE = "project:personal-item:create"; + public static final String PERMISSION_UPDATE = "project:personal-item:update"; + public static final String PERMISSION_DELETE = "project:personal-item:delete"; + public static final String PERMISSION_STATUS = "project:personal-item:status"; + + public static final String STATUS_COMPLETED = "completed"; +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/personal/PersonalItemController.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/personal/PersonalItemController.java new file mode 100644 index 0000000..e9cf189 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/personal/PersonalItemController.java @@ -0,0 +1,160 @@ +package com.njcn.rdms.module.project.controller.admin.personal; + +import com.njcn.rdms.framework.common.pojo.CommonResult; +import com.njcn.rdms.framework.common.pojo.PageResult; +import com.njcn.rdms.module.project.constant.PersonalItemConstants; +import com.njcn.rdms.module.project.controller.admin.personal.vo.item.PersonalItemPageReqVO; +import com.njcn.rdms.module.project.controller.admin.personal.vo.item.PersonalItemRespVO; +import com.njcn.rdms.module.project.controller.admin.personal.vo.item.PersonalItemSaveReqVO; +import com.njcn.rdms.module.project.controller.admin.personal.vo.item.PersonalItemStatusActionReqVO; +import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionRespVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog.TaskWorklogPageReqVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog.TaskWorklogRespVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog.TaskWorklogSaveReqVO; +import com.njcn.rdms.module.project.service.personal.PersonalItemService; +import com.njcn.rdms.module.project.service.project.execution.ProjectExecutionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +import static com.njcn.rdms.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - 个人事项") +@RestController +@RequestMapping("/project/personal-items") +@Validated +public class PersonalItemController { + + @Resource + private PersonalItemService personalItemService; + @Resource + private ProjectExecutionService projectExecutionService; + + @PostMapping + @Operation(summary = "创建个人事项") + @PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_CREATE + "')") + public CommonResult create(@Valid @RequestBody PersonalItemSaveReqVO reqVO) { + return success(personalItemService.createItem(reqVO)); + } + + @PutMapping("/{id}") + @Operation(summary = "更新个人事项") + @PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_UPDATE + "')") + public CommonResult update(@PathVariable("id") Long id, + @Valid @RequestBody PersonalItemSaveReqVO reqVO) { + personalItemService.updateItem(id, reqVO); + return success(true); + } + + @GetMapping("/{id}") + @Operation(summary = "获取个人事项详情") + @PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_QUERY + "')") + public CommonResult get(@PathVariable("id") Long id) { + return success(personalItemService.getItemRespVO(id)); + } + + @GetMapping("/page") + @Operation(summary = "获取个人事项分页") + @PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_QUERY + "')") + public CommonResult> page(@Valid PersonalItemPageReqVO reqVO) { + return success(personalItemService.getItemRespVOPage(reqVO)); + } + + @PostMapping("/{id}/change-status") + @Operation(summary = "变更个人事项状态") + @PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_STATUS + "')") + public CommonResult changeStatus(@PathVariable("id") Long id, + @Valid @RequestBody PersonalItemStatusActionReqVO reqVO) { + personalItemService.changeStatus(id, reqVO); + return success(true); + } + + @PostMapping("/{id}/worklogs") + @Operation(summary = "新增个人事项工作日志") + @PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_UPDATE + "')") + public CommonResult createWorklog(@PathVariable("id") Long id, + @Valid @RequestBody TaskWorklogSaveReqVO reqVO) { + return success(personalItemService.createWorklog(id, reqVO)); + } + + @GetMapping("/{id}/worklogs") + @Operation(summary = "获取个人事项工作日志分页") + @PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_QUERY + "')") + public CommonResult> getWorklogPage(@PathVariable("id") Long id, + @Valid TaskWorklogPageReqVO reqVO) { + return success(personalItemService.getWorklogPage(id, reqVO)); + } + + @PutMapping("/{id}/worklogs/{worklogId}") + @Operation(summary = "修改个人事项工作日志") + @PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_UPDATE + "')") + public CommonResult updateWorklog(@PathVariable("id") Long id, + @PathVariable("worklogId") Long worklogId, + @Valid @RequestBody TaskWorklogSaveReqVO reqVO) { + personalItemService.updateWorklog(id, worklogId, reqVO); + return success(true); + } + + @DeleteMapping("/{id}/worklogs/{worklogId}") + @Operation(summary = "删除个人事项工作日志") + @PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_UPDATE + "')") + public CommonResult deleteWorklog(@PathVariable("id") Long id, + @PathVariable("worklogId") Long worklogId) { + personalItemService.deleteWorklog(id, worklogId); + return success(true); + } + + @DeleteMapping("/{id}/worklogs/delete-list") + @Operation(summary = "批量删除个人事项工作日志") + @PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_UPDATE + "')") + public CommonResult deleteWorklogs(@PathVariable("id") Long id, + @RequestParam("ids") List ids) { + personalItemService.deleteWorklogs(id, ids); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除个人事项") + @PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_DELETE + "')") + public CommonResult delete(@RequestParam("id") Long id) { + personalItemService.deleteItem(id); + return success(true); + } + + @DeleteMapping("/delete-list") + @Operation(summary = "批量删除个人事项") + @PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_DELETE + "')") + public CommonResult deleteList(@RequestParam("ids") List ids) { + personalItemService.deleteItems(ids); + return success(true); + } + + @PostMapping("/relate-execution") + @Operation(summary = "批量个人事项关联执行") + @PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_UPDATE + "')") + public CommonResult relateExecution(@RequestParam("itemIds") List itemIds, + @RequestParam("executionId") Long executionId) { + personalItemService.relateExecution(itemIds, executionId); + return success(true); + } + + @GetMapping("/owner/all-execution") + @Operation(summary = "获取当前登录用户负责的所有执行") + public CommonResult> getCurrentUserExecutionList() { + return success(projectExecutionService.getCurrentUserExecutionList()); + } +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/personal/vo/item/PersonalItemLifecycleActionRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/personal/vo/item/PersonalItemLifecycleActionRespVO.java new file mode 100644 index 0000000..2840c65 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/personal/vo/item/PersonalItemLifecycleActionRespVO.java @@ -0,0 +1,20 @@ +package com.njcn.rdms.module.project.controller.admin.personal.vo.item; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Schema(description = "管理后台 - 个人事项生命周期动作 Response VO") +@Data +@NoArgsConstructor +public class PersonalItemLifecycleActionRespVO { + + @Schema(description = "动作编码", example = "complete") + private String actionCode; + + @Schema(description = "动作名称", example = "完成") + private String actionName; + + @Schema(description = "是否必须填写原因", example = "true") + private Boolean needReason; +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/personal/vo/item/PersonalItemPageReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/personal/vo/item/PersonalItemPageReqVO.java new file mode 100644 index 0000000..718e129 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/personal/vo/item/PersonalItemPageReqVO.java @@ -0,0 +1,32 @@ +package com.njcn.rdms.module.project.controller.admin.personal.vo.item; + +import com.njcn.rdms.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static com.njcn.rdms.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - 个人事项分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class PersonalItemPageReqVO extends PageParam { + + @Schema(description = "关键词,匹配个人事项标题", example = "沟通纪要") + private String keyword; + + @Schema(description = "负责人用户编号;当前阶段仅支持查询本人事项", example = "3001") + private Long ownerId; + + @Schema(description = "个人事项状态编码", example = "pending") + @Size(max = 32, message = "个人事项状态编码长度不能超过32个字符") + private String statusCode; + + @Schema(description = "更新时间", example = "[2026-05-01 00:00:00, 2026-05-31 23:59:59]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] updateTime; +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/personal/vo/item/PersonalItemRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/personal/vo/item/PersonalItemRespVO.java new file mode 100644 index 0000000..aeea773 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/personal/vo/item/PersonalItemRespVO.java @@ -0,0 +1,76 @@ +package com.njcn.rdms.module.project.controller.admin.personal.vo.item; + +import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - 个人事项 Response VO") +@Data +public class PersonalItemRespVO { + + @Schema(description = "个人事项编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "9001") + private Long id; + + @Schema(description = "个人事项标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "整理供应商沟通纪要") + private String taskTitle; + + @Schema(description = "负责人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3001") + private Long ownerId; + + @Schema(description = "负责人用户昵称", example = "小王") + private String ownerNickname; + + @Schema(description = "个人事项状态编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "pending") + private String statusCode; + + @Schema(description = "个人事项状态名称", example = "待开始") + private String statusName; + + @Schema(description = "是否终态", example = "false") + private Boolean terminal; + + @Schema(description = "当前状态是否允许编辑", example = "true") + private Boolean allowEdit; + + @Schema(description = "当前状态可执行动作") + private List availableActions; + + @Schema(description = "个人事项进度", example = "60.00") + private BigDecimal progressRate; + + @Schema(description = "已填报工时合计(小时,0.5 颗粒);逻辑删除的工时记录不计入。无记录默认为 0", + example = "8.0") + private BigDecimal totalSpentHours; + + @Schema(description = "计划开始日期") + private LocalDate plannedStartDate; + + @Schema(description = "计划结束日期") + private LocalDate plannedEndDate; + + @Schema(description = "实际开始日期") + private LocalDate actualStartDate; + + @Schema(description = "实际结束日期") + private LocalDate actualEndDate; + + @Schema(description = "个人事项说明") + private String taskDesc; + + @Schema(description = "最近一次状态动作原因") + private String lastStatusReason; + + @Schema(description = "附件列表") + private List attachments; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/personal/vo/item/PersonalItemSaveReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/personal/vo/item/PersonalItemSaveReqVO.java new file mode 100644 index 0000000..5313b63 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/personal/vo/item/PersonalItemSaveReqVO.java @@ -0,0 +1,45 @@ +package com.njcn.rdms.module.project.controller.admin.personal.vo.item; + +import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +@Schema(description = "管理后台 - 个人事项保存 Request VO") +@Data +public class PersonalItemSaveReqVO { + + @Schema(description = "执行编号,仅用于创建/编辑时补充执行成员合法性校验", example = "5001") + private Long executionId; + + @Schema(description = "个人事项标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "整理供应商沟通纪要") + @NotBlank(message = "个人事项标题不能为空") + @Size(max = 300, message = "个人事项标题长度不能超过300个字符") + private String taskTitle; + + @Schema(description = "个人事项进度(0~100)", example = "60.00") + @DecimalMin(value = "0.00", message = "个人事项进度不能小于 0") + @DecimalMax(value = "100.00", message = "个人事项进度不能大于 100") + private BigDecimal progressRate; + + @Schema(description = "计划开始日期", example = "2026-05-20") + private LocalDate plannedStartDate; + + @Schema(description = "计划结束日期", example = "2026-05-25") + private LocalDate plannedEndDate; + + @Schema(description = "个人事项说明(富文本 HTML)") + private String taskDesc; + + @Schema(description = "附件列表") + @Valid + private List attachments; +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/personal/vo/item/PersonalItemStatusActionReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/personal/vo/item/PersonalItemStatusActionReqVO.java new file mode 100644 index 0000000..816d75b --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/personal/vo/item/PersonalItemStatusActionReqVO.java @@ -0,0 +1,21 @@ +package com.njcn.rdms.module.project.controller.admin.personal.vo.item; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - 个人事项状态动作 Request VO") +@Data +public class PersonalItemStatusActionReqVO { + + @Schema(description = "动作编码,如 start、complete、reopen", requiredMode = Schema.RequiredMode.REQUIRED, + example = "complete") + @NotBlank(message = "动作编码不能为空") + @Size(max = 32, message = "动作编码长度不能超过32个字符") + private String actionCode; + + @Schema(description = "动作原因;是否必填由状态流转配置决定", example = "事项完成") + @Size(max = 500, message = "动作原因长度不能超过500个字符") + private String reason; +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/ProjectExecutionController.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/ProjectExecutionController.java index 0e80c45..7c1bdc4 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/ProjectExecutionController.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/ProjectExecutionController.java @@ -20,6 +20,8 @@ import jakarta.validation.Valid; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import java.util.List; + import static com.njcn.rdms.framework.common.pojo.CommonResult.success; @Tag(name = "管理后台 - 项目执行") @@ -33,6 +35,7 @@ public class ProjectExecutionController { @Resource private ProjectStatusBoardService projectStatusBoardService; + @PostMapping @Operation(summary = "创建执行") public CommonResult createExecution(@PathVariable("projectId") Long projectId, diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/worklog/TaskWorklogSaveReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/worklog/TaskWorklogSaveReqVO.java index 6a851c8..9cf2fbe 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/worklog/TaskWorklogSaveReqVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/worklog/TaskWorklogSaveReqVO.java @@ -54,4 +54,6 @@ public class TaskWorklogSaveReqVO { @Valid private List attachments; + @Schema(description = "任务难度", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private String difficulty; } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/personal/PersonalItemDO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/personal/PersonalItemDO.java new file mode 100644 index 0000000..8c43d46 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/personal/PersonalItemDO.java @@ -0,0 +1,56 @@ +package com.njcn.rdms.module.project.dal.dataobject.personal; + +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO; +import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +/** + * 个人事项主表。 + */ +@TableName(value = "rdms_personal_item", autoResultMap = true) +@Data +@EqualsAndHashCode(callSuper = true) +public class PersonalItemDO extends BaseDO { + + @TableId + private Long id; + + private String taskTitle; + + private Long ownerId; + + private String statusCode; + + private BigDecimal progressRate; + + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private LocalDate plannedStartDate; + + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private LocalDate plannedEndDate; + + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private LocalDate actualStartDate; + + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private LocalDate actualEndDate; + + @TableField(value = "task_desc", updateStrategy = FieldStrategy.ALWAYS) + private String taskDesc; + + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String lastStatusReason; + + @TableField(typeHandler = JacksonTypeHandler.class, updateStrategy = FieldStrategy.ALWAYS) + private List attachments; +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/task/TaskWorklogDO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/task/TaskWorklogDO.java index 7dfd4cf..40c6998 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/task/TaskWorklogDO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/task/TaskWorklogDO.java @@ -71,4 +71,5 @@ public class TaskWorklogDO extends BaseDO { @TableField(typeHandler = JacksonTypeHandler.class) private List attachments; + private String difficulty; } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/personal/PersonalItemMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/personal/PersonalItemMapper.java new file mode 100644 index 0000000..321c6e7 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/personal/PersonalItemMapper.java @@ -0,0 +1,48 @@ +package com.njcn.rdms.module.project.dal.mysql.personal; + +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.personal.vo.item.PersonalItemPageReqVO; +import com.njcn.rdms.module.project.dal.dataobject.personal.PersonalItemDO; +import org.apache.ibatis.annotations.Mapper; +import org.springframework.util.StringUtils; + +@Mapper +public interface PersonalItemMapper extends BaseMapperX { + + default PersonalItemDO selectByIdAndOwnerId(Long id, Long ownerId) { + return selectOne(new LambdaQueryWrapperX() + .eq(PersonalItemDO::getId, id) + .eq(PersonalItemDO::getOwnerId, ownerId)); + } + + default PageResult selectPageByOwnerId(Long ownerId, PersonalItemPageReqVO reqVO) { + LambdaQueryWrapperX queryWrapper = new LambdaQueryWrapperX<>(); + queryWrapper.eq(PersonalItemDO::getOwnerId, ownerId); + queryWrapper.eqIfPresent(PersonalItemDO::getStatusCode, reqVO.getStatusCode()); + queryWrapper.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime()); + queryWrapper.orderByDesc(BaseDO::getUpdateTime); + queryWrapper.orderByDesc(PersonalItemDO::getId); + if (StringUtils.hasText(reqVO.getKeyword())) { + queryWrapper.and(wrapper -> wrapper.like(PersonalItemDO::getTaskTitle, reqVO.getKeyword())); + } + return selectPage(reqVO, queryWrapper); + } + + default int updateStatusByIdAndStatus(Long id, String fromStatus, String toStatus, String lastStatusReason) { + PersonalItemDO update = new PersonalItemDO(); + update.setStatusCode(toStatus); + update.setLastStatusReason(lastStatusReason); + return update(update, new LambdaQueryWrapperX() + .eq(PersonalItemDO::getId, id) + .eq(PersonalItemDO::getStatusCode, fromStatus)); + } + + default int deleteByIdAndStatus(Long id, String fromStatus) { + return delete(new LambdaQueryWrapperX() + .eq(PersonalItemDO::getId, id) + .eq(PersonalItemDO::getStatusCode, fromStatus)); + } +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ProjectExecutionMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ProjectExecutionMapper.java index 1acd11e..d5afcb9 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ProjectExecutionMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ProjectExecutionMapper.java @@ -66,6 +66,13 @@ public interface ProjectExecutionMapper extends BaseMapperX .toList(); } + default List selectListByOwnerId(Long ownerId) { + return selectList(new LambdaQueryWrapperX() + .eq(ProjectExecutionDO::getOwnerId, ownerId) + .orderByDesc(BaseDO::getCreateTime) + .orderByDesc(ProjectExecutionDO::getId)); + } + default Integer countNonTerminalByProjectIdAndOwnerId(Long projectId, Long ownerId, List terminalStatusCodes) { LambdaQueryWrapperX queryWrapper = new LambdaQueryWrapperX() .eq(ProjectExecutionDO::getProjectId, projectId) diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/personal/PersonalItemAccessService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/personal/PersonalItemAccessService.java new file mode 100644 index 0000000..dcaad08 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/personal/PersonalItemAccessService.java @@ -0,0 +1,10 @@ +package com.njcn.rdms.module.project.service.personal; + +import com.njcn.rdms.module.project.dal.dataobject.personal.PersonalItemDO; + +public interface PersonalItemAccessService { + + void checkCanWrite(PersonalItemDO item); + + void validateOwnerInExecution(Long executionId, Long ownerId); +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/personal/PersonalItemAccessServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/personal/PersonalItemAccessServiceImpl.java new file mode 100644 index 0000000..5f08191 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/personal/PersonalItemAccessServiceImpl.java @@ -0,0 +1,35 @@ +package com.njcn.rdms.module.project.service.personal; + +import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; +import com.njcn.rdms.module.project.dal.dataobject.personal.PersonalItemDO; +import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeMapper; +import com.njcn.rdms.module.project.enums.ErrorCodeConstants; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +import java.util.Objects; + +import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception; + +@Service +public class PersonalItemAccessServiceImpl implements PersonalItemAccessService { + + @Resource + private ExecutionAssigneeMapper executionAssigneeMapper; + + @Override + public void checkCanWrite(PersonalItemDO item) { + Long loginUserId = SecurityFrameworkUtils.getLoginUserId(); + if (loginUserId == null || item == null || !Objects.equals(loginUserId, item.getOwnerId())) { + throw exception(ErrorCodeConstants.PERSONAL_ITEM_WRITE_FORBIDDEN); + } + } + + @Override + public void validateOwnerInExecution(Long executionId, Long ownerId) { + if (executionId == null || ownerId == null + || executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, ownerId) == null) { + throw exception(ErrorCodeConstants.PERSONAL_ITEM_OWNER_NOT_IN_EXECUTION); + } + } +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/personal/PersonalItemService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/personal/PersonalItemService.java new file mode 100644 index 0000000..3bd5c8d --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/personal/PersonalItemService.java @@ -0,0 +1,41 @@ +package com.njcn.rdms.module.project.service.personal; + +import com.njcn.rdms.framework.common.pojo.PageResult; +import com.njcn.rdms.module.project.controller.admin.personal.vo.item.PersonalItemPageReqVO; +import com.njcn.rdms.module.project.controller.admin.personal.vo.item.PersonalItemRespVO; +import com.njcn.rdms.module.project.controller.admin.personal.vo.item.PersonalItemSaveReqVO; +import com.njcn.rdms.module.project.controller.admin.personal.vo.item.PersonalItemStatusActionReqVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog.TaskWorklogPageReqVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog.TaskWorklogRespVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog.TaskWorklogSaveReqVO; + +import java.util.List; + +public interface PersonalItemService { + + Long createItem(PersonalItemSaveReqVO reqVO); + + void updateItem(Long id, PersonalItemSaveReqVO reqVO); + + PersonalItemRespVO getItemRespVO(Long id); + + PageResult getItemRespVOPage(PersonalItemPageReqVO reqVO); + + void changeStatus(Long id, PersonalItemStatusActionReqVO reqVO); + + Long createWorklog(Long id, TaskWorklogSaveReqVO reqVO); + + PageResult getWorklogPage(Long id, TaskWorklogPageReqVO reqVO); + + void updateWorklog(Long id, Long worklogId, TaskWorklogSaveReqVO reqVO); + + void deleteWorklog(Long id, Long worklogId); + + void deleteWorklogs(Long id, List ids); + + void deleteItem(Long id); + + void deleteItems(List ids); + + void relateExecution(List itemIds, Long executionId); +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/personal/PersonalItemServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/personal/PersonalItemServiceImpl.java new file mode 100644 index 0000000..11d3342 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/personal/PersonalItemServiceImpl.java @@ -0,0 +1,745 @@ +package com.njcn.rdms.module.project.service.personal; + +import com.njcn.rdms.framework.common.pojo.PageResult; +import com.njcn.rdms.framework.common.util.json.JsonUtils; +import com.njcn.rdms.framework.common.util.object.BeanUtils; +import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; +import com.njcn.rdms.module.project.constant.ObjectActivityConstants; +import com.njcn.rdms.module.project.constant.PersonalItemConstants; +import com.njcn.rdms.module.project.controller.admin.personal.vo.item.PersonalItemPageReqVO; +import com.njcn.rdms.module.project.controller.admin.personal.vo.item.PersonalItemRespVO; +import com.njcn.rdms.module.project.controller.admin.personal.vo.item.PersonalItemSaveReqVO; +import com.njcn.rdms.module.project.controller.admin.personal.vo.item.PersonalItemStatusActionReqVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog.TaskWorklogPageReqVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog.TaskWorklogRespVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog.TaskWorklogSaveReqVO; +import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO; +import com.njcn.rdms.module.project.dal.dataobject.personal.PersonalItemDO; +import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO; +import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO; +import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskWorklogDO; +import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO; +import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO; +import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper; +import com.njcn.rdms.module.project.dal.mysql.personal.PersonalItemMapper; +import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper; +import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper; +import com.njcn.rdms.module.project.dal.mysql.project.task.TaskWorklogMapper; +import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper; +import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper; +import com.njcn.rdms.module.project.enums.ErrorCodeConstants; +import com.njcn.rdms.module.project.framework.attachment.AttachmentFileIdResolver; +import com.njcn.rdms.module.project.framework.attachment.AttachmentValidator; +import com.njcn.rdms.module.system.api.user.AdminUserApi; +import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; + +import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception; +import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; + +@Service +public class PersonalItemServiceImpl implements PersonalItemService { + + private static final BigDecimal DURATION_GRANULARITY_HOURS = new BigDecimal("0.5"); + private static final String WORKLOG_AUTO_START_ACTION = ObjectActivityConstants.TASK_ACTION_AUTO_START; + private static final String WORKLOG_AUTO_START_REASON = "由工作日志自动触发"; + + @Resource + private PersonalItemMapper personalItemMapper; + @Resource + private ProjectExecutionMapper projectExecutionMapper; + @Resource + private ProjectTaskMapper projectTaskMapper; + @Resource + private BizAuditLogMapper bizAuditLogMapper; + @Resource + private ObjectStatusModelMapper objectStatusModelMapper; + @Resource + private ObjectStatusTransitionMapper objectStatusTransitionMapper; + @Resource + private TaskWorklogMapper taskWorklogMapper; + @Resource + private PersonalItemAccessService personalItemAccessService; + @Resource + private PersonalItemStatusViewService personalItemStatusViewService; + @Resource + private AttachmentFileIdResolver attachmentFileIdResolver; + @Resource + private AdminUserApi adminUserApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createItem(PersonalItemSaveReqVO reqVO) { + Long loginUserId = SecurityFrameworkUtils.getLoginUserId(); + validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期"); + AttachmentValidator.validate(reqVO.getAttachments()); + attachmentFileIdResolver.resolve(reqVO.getAttachments()); + + PersonalItemDO item = new PersonalItemDO(); + item.setTaskTitle(normalizeRequiredTitle(reqVO.getTaskTitle())); + item.setOwnerId(loginUserId); + item.setStatusCode(getInitialStatusCode()); + item.setProgressRate(normalizeProgress(reqVO.getProgressRate())); + item.setPlannedStartDate(reqVO.getPlannedStartDate()); + item.setPlannedEndDate(reqVO.getPlannedEndDate()); + item.setTaskDesc(normalizeNullableText(reqVO.getTaskDesc())); + item.setAttachments(reqVO.getAttachments()); + personalItemMapper.insert(item); + + writeAuditLog(item, ObjectActivityConstants.PERSONAL_ITEM_ACTION_CREATE, null, + item.getStatusCode(), buildFieldChanges(null, item), null); + return item.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateItem(Long id, PersonalItemSaveReqVO reqVO) { + PersonalItemDO item = validateItemExists(id); + personalItemAccessService.checkCanWrite(item); + validateItemAllowEdit(item); +// if (reqVO.getExecutionId() != null) { +// personalItemAccessService.validateOwnerInExecution(reqVO.getExecutionId(), item.getOwnerId()); +// } + validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期"); + AttachmentValidator.validate(reqVO.getAttachments()); + attachmentFileIdResolver.resolve(reqVO.getAttachments()); + + PersonalItemDO before = cloneItem(item); + item.setTaskTitle(normalizeRequiredTitle(reqVO.getTaskTitle())); + item.setProgressRate(normalizeProgress(reqVO.getProgressRate())); + item.setPlannedStartDate(reqVO.getPlannedStartDate()); + item.setPlannedEndDate(reqVO.getPlannedEndDate()); + item.setTaskDesc(normalizeNullableText(reqVO.getTaskDesc())); + item.setAttachments(reqVO.getAttachments()); + personalItemMapper.updateById(item); + + writeAuditLog(item, ObjectActivityConstants.PERSONAL_ITEM_ACTION_UPDATE, before.getStatusCode(), + item.getStatusCode(), buildFieldChanges(before, item), null); + } + + @Override + public PersonalItemRespVO getItemRespVO(Long id) { + PersonalItemDO item = validateOwnedItemExists(id); + PersonalItemRespVO respVO = toRespVO(item); + respVO.setOwnerNickname(loadUserNickname(item.getOwnerId())); + respVO.setTotalSpentHours(defaultDuration(taskWorklogMapper.sumDurationByTaskId(item.getId()))); + return respVO; + } + + @Override + public PageResult getItemRespVOPage(PersonalItemPageReqVO reqVO) { + Long loginUserId = SecurityFrameworkUtils.getLoginUserId(); + if (reqVO.getOwnerId() != null && !Objects.equals(reqVO.getOwnerId(), loginUserId)) { + return PageResult.empty(); + } + reqVO.setOwnerId(loginUserId); + PageResult doPage = personalItemMapper.selectPageByOwnerId(loginUserId, reqVO); + List list = doPage.getList().stream().map(this::toRespVO).toList(); + fillOwnerNicknames(list); + fillTotalSpentHours(list); + return new PageResult<>(list, doPage.getTotal()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void changeStatus(Long id, PersonalItemStatusActionReqVO reqVO) { + PersonalItemDO item = validateItemExists(id); + personalItemAccessService.checkCanWrite(item); + + String actionCode = normalizeRequiredActionCode(reqVO.getActionCode()); + String fromStatus = item.getStatusCode(); + ObjectStatusTransitionDO transition = objectStatusTransitionMapper + .selectByObjectTypeAndFromStatusAndAction(PersonalItemConstants.STATUS_OBJECT_TYPE, fromStatus, actionCode); + if (transition == null) { + throw exception(ErrorCodeConstants.PERSONAL_ITEM_STATUS_ACTION_NOT_ALLOWED, actionCode); + } + String reason = normalizeNullableText(reqVO.getReason()); + if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(reason)) { + throw exception(ErrorCodeConstants.PERSONAL_ITEM_STATUS_ACTION_REASON_REQUIRED, actionCode); + } + String toStatus = transition.getToStatusCode(); + int updateCount = personalItemMapper.updateStatusByIdAndStatus(item.getId(), fromStatus, toStatus, reason); + if (updateCount != 1) { + throw exception(ErrorCodeConstants.PERSONAL_ITEM_STATUS_CONCURRENT_MODIFIED); + } + + PersonalItemDO before = cloneItem(item); + item.setStatusCode(toStatus); + item.setLastStatusReason(reason); + applyLifecycleFields(item, fromStatus, toStatus); + personalItemMapper.updateById(item); + + writeAuditLog(item, actionCode, fromStatus, toStatus, buildFieldChanges(before, item), reason); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createWorklog(Long id, TaskWorklogSaveReqVO reqVO) { + PersonalItemDO item = validateItemExists(id); + personalItemAccessService.checkCanWrite(item); + validateItemAllowEdit(item); + + Long userId = item.getOwnerId(); + validateDateRange(reqVO.getStartDate(), reqVO.getEndDate(), "段起始日期不能晚于段结束日期"); + validateDurationGranularity(reqVO.getDurationHours()); + validateNoOverlap(item.getId(), userId, reqVO.getStartDate(), reqVO.getEndDate(), null); + validateProgressMonotonicity(item.getId(), userId, reqVO.getEndDate(), reqVO.getProgressRate(), null); + AttachmentValidator.validate(reqVO.getAttachments()); + attachmentFileIdResolver.resolve(reqVO.getAttachments()); + + TaskWorklogDO worklog = buildWorklog(item.getId(), userId, reqVO); + taskWorklogMapper.insert(worklog); + + PersonalItemDO current = cloneItem(item); + maybeAutoStartByWorklog(current); + syncOwnerProgressFromLatestWorklog(current); + personalItemMapper.updateById(current); + return worklog.getId(); + } + + @Override + public PageResult getWorklogPage(Long id, TaskWorklogPageReqVO reqVO) { + PersonalItemDO item = validateOwnedItemExists(id); + if (reqVO.getUserId() != null && !Objects.equals(reqVO.getUserId(), item.getOwnerId())) { + return PageResult.empty(); + } + reqVO.setUserId(item.getOwnerId()); + PageResult page = taskWorklogMapper.selectPageByTaskId(item.getId(), reqVO); + if (page.getList().isEmpty()) { + return new PageResult<>(Collections.emptyList(), page.getTotal()); + } + Map nicknameMap = loadUserNicknameMap(page.getList().stream() + .map(TaskWorklogDO::getUserId) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new))); + List list = page.getList().stream().map(worklog -> { + TaskWorklogRespVO vo = BeanUtils.toBean(worklog, TaskWorklogRespVO.class); + vo.setUserNickname(nicknameMap.get(worklog.getUserId())); + return vo; + }).toList(); + return new PageResult<>(list, page.getTotal()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateWorklog(Long id, Long worklogId, TaskWorklogSaveReqVO reqVO) { + PersonalItemDO item = validateItemExists(id); + personalItemAccessService.checkCanWrite(item); + validateItemAllowEdit(item); + + TaskWorklogDO worklog = loadWorklog(worklogId, item.getId()); + Long loginUserId = SecurityFrameworkUtils.getLoginUserId(); + if (!Objects.equals(worklog.getUserId(), loginUserId)) { + throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_EDIT_NOT_OWN); + } + validateDateRange(reqVO.getStartDate(), reqVO.getEndDate(), "娈佃捣濮嬫棩鏈熶笉鑳芥櫄浜庢缁撴潫鏃ユ湡"); + validateDurationGranularity(reqVO.getDurationHours()); + validateNoOverlap(item.getId(), worklog.getUserId(), reqVO.getStartDate(), reqVO.getEndDate(), worklog.getId()); + validateProgressMonotonicity(item.getId(), worklog.getUserId(), reqVO.getEndDate(), reqVO.getProgressRate(), + worklog.getId()); + AttachmentValidator.validate(reqVO.getAttachments()); + attachmentFileIdResolver.resolve(reqVO.getAttachments()); + + TaskWorklogDO update = new TaskWorklogDO(); + update.setId(worklog.getId()); + update.setStartDate(reqVO.getStartDate()); + update.setEndDate(reqVO.getEndDate()); + update.setDurationHours(reqVO.getDurationHours()); + update.setProgressRate(reqVO.getProgressRate()); + update.setWorkContent(normalizeNullableText(reqVO.getWorkContent())); + update.setAttachments(reqVO.getAttachments()); + update.setDifficulty(reqVO.getDifficulty()); + taskWorklogMapper.updateById(update); + + PersonalItemDO current = cloneItem(item); + syncOwnerProgressFromLatestWorklog(current); + personalItemMapper.updateById(current); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteWorklog(Long id, Long worklogId) { + PersonalItemDO item = validateItemExists(id); + personalItemAccessService.checkCanWrite(item); + validateItemAllowEdit(item); + + TaskWorklogDO worklog = loadWorklog(worklogId, item.getId()); + Long loginUserId = SecurityFrameworkUtils.getLoginUserId(); + boolean isFiler = Objects.equals(worklog.getUserId(), loginUserId); + boolean isOwner = Objects.equals(item.getOwnerId(), loginUserId); + if (!isFiler && !isOwner) { + throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DELETE_FORBIDDEN); + } + taskWorklogMapper.deleteById(worklog.getId()); + + PersonalItemDO current = cloneItem(item); + syncOwnerProgressFromLatestWorklog(current); + personalItemMapper.updateById(current); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteWorklogs(Long id, List ids) { + if (CollectionUtils.isEmpty(ids)) { + throw invalidParamException("涓汉浜嬮」宸ヤ綔鏃ュ織缂栧彿鍒楄〃涓嶈兘涓虹┖"); + } + for (Long worklogId : ids) { + deleteWorklog(id, worklogId); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteItem(Long id) { + PersonalItemDO item = validateItemExists(id); + personalItemAccessService.checkCanWrite(item); + String fromStatus = item.getStatusCode(); + ObjectStatusModelDO statusModel = objectStatusModelMapper + .selectByObjectTypeAndStatusCodeEnabled(PersonalItemConstants.STATUS_OBJECT_TYPE, fromStatus); + if (statusModel == null || !Boolean.TRUE.equals(statusModel.getInitialFlag())) { + throw exception(ErrorCodeConstants.PERSONAL_ITEM_NOT_ALLOW_DELETE); + } + int deleteCount = personalItemMapper.deleteByIdAndStatus(id, fromStatus); + if (deleteCount != 1) { + throw exception(ErrorCodeConstants.PERSONAL_ITEM_STATUS_CONCURRENT_MODIFIED); + } + writeAuditLog(item, ObjectActivityConstants.PERSONAL_ITEM_ACTION_DELETE, fromStatus, null, null, null); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteItems(List ids) { + if (CollectionUtils.isEmpty(ids)) { + throw invalidParamException("个人事项编号列表不能为空"); + } + for (Long id : ids) { + deleteItem(id); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void relateExecution(List itemIds, Long executionId) { + ProjectExecutionDO execution = projectExecutionMapper.selectById(executionId); + if (execution == null) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_NOT_EXISTS); + } + Long projectId = execution.getProjectId(); + for (Long itemId : itemIds) { + PersonalItemDO item = validateItemExists(itemId); + personalItemAccessService.checkCanWrite(item); + personalItemAccessService.validateOwnerInExecution(executionId, item.getOwnerId()); + + ProjectTaskDO task = buildProjectTaskFromItem(item, projectId, executionId); + projectTaskMapper.insert(task); + personalItemMapper.deleteById(item.getId()); + writeAuditLog(item, ObjectActivityConstants.PERSONAL_ITEM_ACTION_DELETE, + item.getStatusCode(), null, null, "关联执行后转为项目任务"); + } + } + + private ProjectTaskDO buildProjectTaskFromItem(PersonalItemDO item, Long projectId, Long executionId) { + ProjectTaskDO task = new ProjectTaskDO(); + task.setId(item.getId()); + task.setProjectId(projectId); + task.setExecutionId(executionId); + task.setParentTaskId(null); + task.setTaskTitle(item.getTaskTitle()); + task.setOwnerId(item.getOwnerId()); + task.setStatusCode(item.getStatusCode()); + task.setProgressRate(item.getProgressRate()); + task.setPlannedStartDate(item.getPlannedStartDate()); + task.setPlannedEndDate(item.getPlannedEndDate()); + task.setActualStartDate(item.getActualStartDate()); + task.setActualEndDate(item.getActualEndDate()); + task.setTaskDesc(item.getTaskDesc()); + task.setLastStatusReason(item.getLastStatusReason()); + task.setAttachments(item.getAttachments()); + return task; + } + + private PersonalItemDO validateItemExists(Long id) { + PersonalItemDO item = personalItemMapper.selectById(id); + if (item == null) { + throw exception(ErrorCodeConstants.PERSONAL_ITEM_NOT_EXISTS); + } + return item; + } + + private PersonalItemDO validateOwnedItemExists(Long id) { + Long loginUserId = SecurityFrameworkUtils.getLoginUserId(); + PersonalItemDO item = personalItemMapper.selectByIdAndOwnerId(id, loginUserId); + if (item == null) { + throw exception(ErrorCodeConstants.PERSONAL_ITEM_NOT_EXISTS); + } + return item; + } + + private void validateItemAllowEdit(PersonalItemDO item) { + ObjectStatusModelDO statusModel = objectStatusModelMapper + .selectByObjectTypeAndStatusCodeEnabled(PersonalItemConstants.STATUS_OBJECT_TYPE, item.getStatusCode()); + if (statusModel == null) { + throw exception(ErrorCodeConstants.PERSONAL_ITEM_STATUS_MODEL_NOT_EXISTS_OR_DISABLED); + } + if (!Boolean.TRUE.equals(statusModel.getAllowEdit())) { + throw exception(ErrorCodeConstants.PERSONAL_ITEM_STATUS_NOT_ALLOW_EDIT); + } + } + + private String getInitialStatusCode() { + ObjectStatusModelDO statusModel = objectStatusModelMapper + .selectInitialByObjectTypeEnabled(PersonalItemConstants.STATUS_OBJECT_TYPE); + if (statusModel == null || !StringUtils.hasText(statusModel.getStatusCode())) { + throw exception(ErrorCodeConstants.PERSONAL_ITEM_STATUS_MODEL_NOT_EXISTS_OR_DISABLED); + } + return statusModel.getStatusCode(); + } + + private PersonalItemRespVO toRespVO(PersonalItemDO item) { + return BeanUtils.toBean(item, PersonalItemRespVO.class, this::applyLifecycle); + } + + private void applyLifecycle(PersonalItemRespVO respVO) { + PersonalItemStatusViewService.PersonalItemLifecycleView lifecycle = + personalItemStatusViewService.getLifecycle(respVO.getStatusCode(), respVO.getProgressRate()); + respVO.setStatusName(lifecycle.statusName()); + respVO.setTerminal(lifecycle.terminal()); + respVO.setAllowEdit(lifecycle.allowEdit()); + respVO.setAvailableActions(lifecycle.availableActions()); + } + + private void fillOwnerNicknames(List list) { + if (CollectionUtils.isEmpty(list)) { + return; + } + Collection ownerIds = list.stream() + .map(PersonalItemRespVO::getOwnerId) + .filter(Objects::nonNull) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + Map nicknameMap = loadUserNicknameMap(ownerIds); + list.forEach(item -> item.setOwnerNickname(nicknameMap.get(item.getOwnerId()))); + } + + private void fillTotalSpentHours(List list) { + if (CollectionUtils.isEmpty(list)) { + return; + } + Collection itemIds = list.stream() + .map(PersonalItemRespVO::getId) + .filter(Objects::nonNull) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + Map spentHoursMap = loadTotalSpentHoursMap(itemIds); + list.forEach(item -> item.setTotalSpentHours( + spentHoursMap.getOrDefault(item.getId(), BigDecimal.ZERO))); + } + + private Map loadTotalSpentHoursMap(Collection itemIds) { + if (itemIds == null || itemIds.isEmpty()) { + return Collections.emptyMap(); + } + List> rows = taskWorklogMapper.sumDurationGroupByTaskIds(itemIds); + if (rows == null || rows.isEmpty()) { + return Collections.emptyMap(); + } + Map result = new HashMap<>(rows.size()); + for (Map row : rows) { + Long itemId = toLong(row.getOrDefault("taskId", row.get("task_id"))); + BigDecimal total = toBigDecimal(row.get("total")); + if (itemId != null && total != null) { + result.put(itemId, total); + } + } + return result; + } + + private Long toLong(Object value) { + if (value instanceof Number number) { + return number.longValue(); + } + if (value != null && StringUtils.hasText(value.toString())) { + return Long.valueOf(value.toString()); + } + return null; + } + + private BigDecimal toBigDecimal(Object value) { + if (value instanceof BigDecimal bigDecimal) { + return bigDecimal; + } + if (value != null && StringUtils.hasText(value.toString())) { + return new BigDecimal(value.toString()); + } + return null; + } + + private BigDecimal defaultDuration(BigDecimal value) { + return value == null ? BigDecimal.ZERO : value; + } + + private String loadUserNickname(Long userId) { + if (userId == null) { + return null; + } + return loadUserNicknameMap(List.of(userId)).get(userId); + } + + private void applyLifecycleFields(PersonalItemDO item, String fromStatus, String toStatus) { + ObjectStatusModelDO fromModel = objectStatusModelMapper + .selectByObjectTypeAndStatusCodeEnabled(PersonalItemConstants.STATUS_OBJECT_TYPE, fromStatus); + ObjectStatusModelDO toModel = objectStatusModelMapper + .selectByObjectTypeAndStatusCodeEnabled(PersonalItemConstants.STATUS_OBJECT_TYPE, toStatus); + if (fromModel == null || toModel == null) { + throw exception(ErrorCodeConstants.PERSONAL_ITEM_STATUS_MODEL_NOT_EXISTS_OR_DISABLED); + } + if (Boolean.TRUE.equals(fromModel.getInitialFlag()) && item.getActualStartDate() == null) { + item.setActualStartDate(LocalDate.now()); + } + if (Boolean.TRUE.equals(toModel.getTerminalFlag())) { + item.setActualEndDate(LocalDate.now()); + if (PersonalItemConstants.STATUS_COMPLETED.equals(toStatus)) { + item.setProgressRate(BigDecimal.valueOf(100).setScale(2, RoundingMode.HALF_UP)); + } + return; + } + if (Boolean.TRUE.equals(fromModel.getTerminalFlag()) && !Boolean.TRUE.equals(toModel.getTerminalFlag())) { + item.setActualEndDate(null); + } + } + + + private void writeAuditLog(PersonalItemDO item, + String actionType, + String fromStatus, + String toStatus, + String fieldChanges, + String reason) { + BizAuditLogDO auditLog = new BizAuditLogDO(); + auditLog.setBizType(PersonalItemConstants.BIZ_TYPE); + auditLog.setBizId(item.getId()); + auditLog.setActionType(actionType); + auditLog.setFromStatus(fromStatus); + auditLog.setToStatus(toStatus); + auditLog.setFieldChanges(fieldChanges); + auditLog.setReason(reason); + auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId()); + auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname())); + bizAuditLogMapper.insert(auditLog); + } + + private PersonalItemDO cloneItem(PersonalItemDO source) { + PersonalItemDO target = new PersonalItemDO(); + target.setId(source.getId()); + target.setTaskTitle(source.getTaskTitle()); + target.setOwnerId(source.getOwnerId()); + target.setStatusCode(source.getStatusCode()); + target.setProgressRate(source.getProgressRate()); + target.setPlannedStartDate(source.getPlannedStartDate()); + target.setPlannedEndDate(source.getPlannedEndDate()); + target.setActualStartDate(source.getActualStartDate()); + target.setActualEndDate(source.getActualEndDate()); + target.setTaskDesc(source.getTaskDesc()); + target.setLastStatusReason(source.getLastStatusReason()); + target.setAttachments(source.getAttachments()); + return target; + } + + private String buildFieldChanges(PersonalItemDO before, PersonalItemDO after) { + Map fieldChanges = new LinkedHashMap<>(); + appendFieldChange(fieldChanges, "taskTitle", valueOf(before, PersonalItemDO::getTaskTitle), + valueOf(after, PersonalItemDO::getTaskTitle)); + appendFieldChange(fieldChanges, "ownerId", valueOf(before, PersonalItemDO::getOwnerId), + valueOf(after, PersonalItemDO::getOwnerId)); + appendFieldChange(fieldChanges, "statusCode", valueOf(before, PersonalItemDO::getStatusCode), + valueOf(after, PersonalItemDO::getStatusCode)); + appendFieldChange(fieldChanges, "progressRate", valueOf(before, PersonalItemDO::getProgressRate), + valueOf(after, PersonalItemDO::getProgressRate)); + appendFieldChange(fieldChanges, "plannedStartDate", valueOf(before, PersonalItemDO::getPlannedStartDate), + valueOf(after, PersonalItemDO::getPlannedStartDate)); + appendFieldChange(fieldChanges, "plannedEndDate", valueOf(before, PersonalItemDO::getPlannedEndDate), + valueOf(after, PersonalItemDO::getPlannedEndDate)); + appendFieldChange(fieldChanges, "actualStartDate", valueOf(before, PersonalItemDO::getActualStartDate), + valueOf(after, PersonalItemDO::getActualStartDate)); + appendFieldChange(fieldChanges, "actualEndDate", valueOf(before, PersonalItemDO::getActualEndDate), + valueOf(after, PersonalItemDO::getActualEndDate)); + appendFieldChange(fieldChanges, "taskDesc", valueOf(before, PersonalItemDO::getTaskDesc), + valueOf(after, PersonalItemDO::getTaskDesc)); + appendFieldChange(fieldChanges, "lastStatusReason", valueOf(before, PersonalItemDO::getLastStatusReason), + valueOf(after, PersonalItemDO::getLastStatusReason)); + appendFieldChange(fieldChanges, "attachments", valueOf(before, PersonalItemDO::getAttachments), + valueOf(after, PersonalItemDO::getAttachments)); + return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges); + } + + private T valueOf(PersonalItemDO item, Function getter) { + return item == null ? null : getter.apply(item); + } + + private void appendFieldChange(Map fieldChanges, String fieldName, Object before, Object after) { + if (Objects.equals(before, after)) { + return; + } + Map value = new LinkedHashMap<>(); + value.put("before", before); + value.put("after", after); + fieldChanges.put(fieldName, value); + } + + private void validateDateRange(LocalDate startDate, LocalDate endDate, String message) { + if (startDate == null || endDate == null) { + return; + } + if (endDate.isBefore(startDate)) { + throw invalidParamException(message); + } + } + + private BigDecimal normalizeProgress(BigDecimal value) { + BigDecimal progress = value == null ? BigDecimal.ZERO : value; + return progress.setScale(2, RoundingMode.HALF_UP); + } + + private void validateDurationGranularity(BigDecimal durationHours) { + if (durationHours == null + || durationHours.compareTo(BigDecimal.ZERO) <= 0 + || durationHours.remainder(DURATION_GRANULARITY_HOURS).compareTo(BigDecimal.ZERO) != 0) { + throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DURATION_INVALID); + } + } + + private void validateNoOverlap(Long taskId, Long userId, LocalDate startDate, LocalDate endDate, Long excludeId) { + if (taskWorklogMapper.existsOverlapping(taskId, userId, startDate, endDate, excludeId)) { + throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DATE_OVERLAP); + } + } + + private void validateProgressMonotonicity(Long taskId, Long userId, LocalDate endDate, + BigDecimal progressRate, Long excludeId) { + if (progressRate == null || endDate == null) { + return; + } + TaskWorklogDO prev = taskWorklogMapper.selectPrevByEndDate(taskId, userId, endDate, excludeId); + if (prev != null && prev.getProgressRate() != null + && prev.getProgressRate().compareTo(progressRate) > 0) { + throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_PROGRESS_NOT_MONOTONIC); + } + TaskWorklogDO next = taskWorklogMapper.selectNextByEndDate(taskId, userId, endDate, excludeId); + if (next != null && next.getProgressRate() != null + && next.getProgressRate().compareTo(progressRate) < 0) { + throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_PROGRESS_NOT_MONOTONIC); + } + } + + private TaskWorklogDO buildWorklog(Long taskId, Long userId, TaskWorklogSaveReqVO reqVO) { + TaskWorklogDO worklog = new TaskWorklogDO(); + worklog.setTaskId(taskId); + worklog.setUserId(userId); + worklog.setStartDate(reqVO.getStartDate()); + worklog.setEndDate(reqVO.getEndDate()); + worklog.setDurationHours(reqVO.getDurationHours()); + worklog.setProgressRate(reqVO.getProgressRate()); + worklog.setWorkContent(normalizeNullableText(reqVO.getWorkContent())); + worklog.setAttachments(reqVO.getAttachments()); + worklog.setDifficulty(reqVO.getDifficulty()); + return worklog; + } + + private TaskWorklogDO loadWorklog(Long worklogId, Long taskId) { + if (worklogId == null) { + throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_NOT_EXISTS); + } + TaskWorklogDO worklog = taskWorklogMapper.selectByIdAndTaskId(worklogId, taskId); + if (worklog == null) { + throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_NOT_EXISTS); + } + return worklog; + } + + private void maybeAutoStartByWorklog(PersonalItemDO item) { + String fromStatus = item.getStatusCode(); + ObjectStatusModelDO fromModel = objectStatusModelMapper + .selectByObjectTypeAndStatusCodeEnabled(PersonalItemConstants.STATUS_OBJECT_TYPE, fromStatus); + if (fromModel == null || !Boolean.TRUE.equals(fromModel.getInitialFlag())) { + return; + } + ObjectStatusTransitionDO transition = objectStatusTransitionMapper + .selectByObjectTypeAndFromStatusAndAction(PersonalItemConstants.STATUS_OBJECT_TYPE, fromStatus, + WORKLOG_AUTO_START_ACTION); + if (transition == null || !StringUtils.hasText(transition.getToStatusCode())) { + return; + } + String toStatus = transition.getToStatusCode(); + int updateCount = personalItemMapper.updateStatusByIdAndStatus(item.getId(), fromStatus, toStatus, + WORKLOG_AUTO_START_REASON); + if (updateCount != 1) { + throw exception(ErrorCodeConstants.PERSONAL_ITEM_STATUS_CONCURRENT_MODIFIED); + } + item.setStatusCode(toStatus); + item.setLastStatusReason(WORKLOG_AUTO_START_REASON); + applyLifecycleFields(item, fromStatus, toStatus); + } + + private void syncOwnerProgressFromLatestWorklog(PersonalItemDO item) { + if (item == null || item.getId() == null || item.getOwnerId() == null) { + return; + } + TaskWorklogDO latest = taskWorklogMapper.selectLatestByTaskIdAndUserId(item.getId(), item.getOwnerId()); + if (latest == null || latest.getProgressRate() == null) { + return; + } + item.setProgressRate(normalizeProgress(latest.getProgressRate())); + } + + private Map loadUserNicknameMap(Collection userIds) { + if (userIds == null || userIds.isEmpty()) { + return Collections.emptyMap(); + } + Map userMap = adminUserApi.getUserMap(userIds); + if (userMap == null || userMap.isEmpty()) { + return Collections.emptyMap(); + } + Map nicknameMap = new HashMap<>(userMap.size()); + userMap.forEach((userId, user) -> nicknameMap.put(userId, user == null ? null : user.getNickname())); + return nicknameMap; + } + + private String normalizeRequiredTitle(String value) { + if (!StringUtils.hasText(value)) { + throw invalidParamException("个人事项标题不能为空"); + } + return value.trim(); + } + + private String normalizeRequiredActionCode(String value) { + if (!StringUtils.hasText(value)) { + throw invalidParamException("动作编码不能为空"); + } + return value.trim(); + } + + private String normalizeNullableText(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + return value.trim(); + } + + private String defaultText(String value) { + return StringUtils.hasText(value) ? value : ""; + } +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/personal/PersonalItemStatusViewService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/personal/PersonalItemStatusViewService.java new file mode 100644 index 0000000..7afef9b --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/personal/PersonalItemStatusViewService.java @@ -0,0 +1,74 @@ +package com.njcn.rdms.module.project.service.personal; + +import com.njcn.rdms.module.project.constant.ObjectActivityConstants; +import com.njcn.rdms.module.project.constant.PersonalItemConstants; +import com.njcn.rdms.module.project.controller.admin.personal.vo.item.PersonalItemLifecycleActionRespVO; +import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO; +import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO; +import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper; +import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper; +import com.njcn.rdms.module.project.enums.ErrorCodeConstants; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.Collections; +import java.util.List; + +import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception; + +@Service +public class PersonalItemStatusViewService { + + private static final String ACTION_COMPLETE = "complete"; + private static final BigDecimal COMPLETE_PROGRESS_THRESHOLD = BigDecimal.valueOf(100); + + @Resource + private ObjectStatusModelMapper objectStatusModelMapper; + @Resource + private ObjectStatusTransitionMapper objectStatusTransitionMapper; + + public PersonalItemLifecycleView getLifecycle(String statusCode, BigDecimal progressRate) { + ObjectStatusModelDO statusModel = objectStatusModelMapper + .selectByObjectTypeAndStatusCodeEnabled(PersonalItemConstants.STATUS_OBJECT_TYPE, statusCode); + if (statusModel == null) { + throw exception(ErrorCodeConstants.PERSONAL_ITEM_STATUS_MODEL_NOT_EXISTS_OR_DISABLED); + } + return new PersonalItemLifecycleView( + statusModel.getStatusName(), + statusModel.getTerminalFlag(), + statusModel.getAllowEdit(), + buildAvailableActions(statusCode, progressRate) + ); + } + + public List buildAvailableActions(String statusCode, BigDecimal progressRate) { + List transitions = objectStatusTransitionMapper + .selectListByObjectTypeAndFromStatus(PersonalItemConstants.STATUS_OBJECT_TYPE, statusCode); + if (transitions == null || transitions.isEmpty()) { + return Collections.emptyList(); + } + return transitions.stream() + .filter(transition -> !ObjectActivityConstants.TASK_ACTION_AUTO_START.equals(transition.getActionCode())) + .filter(transition -> !ACTION_COMPLETE.equals(transition.getActionCode()) + || isCompleteProgressSatisfied(progressRate)) + .map(transition -> { + PersonalItemLifecycleActionRespVO action = new PersonalItemLifecycleActionRespVO(); + action.setActionCode(transition.getActionCode()); + action.setActionName(transition.getActionName()); + action.setNeedReason(transition.getNeedReason()); + return action; + }) + .toList(); + } + + private boolean isCompleteProgressSatisfied(BigDecimal progressRate) { + return progressRate != null && progressRate.compareTo(COMPLETE_PROGRESS_THRESHOLD) >= 0; + } + + public record PersonalItemLifecycleView(String statusName, + Boolean terminal, + Boolean allowEdit, + List availableActions) { + } +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionService.java index a878719..1977774 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionService.java @@ -10,6 +10,8 @@ import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execut import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusActionReqVO; import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO; +import java.util.List; + /** * 执行主数据 Service。 */ @@ -33,6 +35,8 @@ public interface ProjectExecutionService { */ PageResult getExecutionRespVOPage(Long projectId, ProjectExecutionPageReqVO reqVO); + List getCurrentUserExecutionList(); + void changeOwner(Long projectId, Long executionId, ProjectExecutionOwnerChangeReqVO reqVO); /** diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImpl.java index a15ea8f..fbb17db 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImpl.java @@ -248,6 +248,40 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService { return voPageResult; } + @Override + public List getCurrentUserExecutionList() { + Long loginUserId = SecurityFrameworkUtils.getLoginUserId(); + List executions = projectExecutionMapper.selectListByOwnerId(loginUserId); + if (executions == null || executions.isEmpty()) { + return Collections.emptyList(); + } + List list = BeanUtils.toBean(executions, ProjectExecutionRespVO.class); + Map rootTasksAllCompletedMap = new HashMap<>(); + list.stream() + .filter(vo -> vo.getProjectId() != null) + .collect(Collectors.groupingBy(ProjectExecutionRespVO::getProjectId, LinkedHashMap::new, + Collectors.toList())) + .forEach((groupProjectId, projectList) -> { + fillExecutionProgress(groupProjectId, projectList); + rootTasksAllCompletedMap.putAll(loadExecutionRootTasksAllCompletedMap(groupProjectId, projectList)); + }); + Set ownerIds = list.stream() + .map(ProjectExecutionRespVO::getOwnerId) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + Map nicknameMap = loadOwnerNicknameMap(ownerIds); + list.forEach(vo -> { + vo.setOwnerNickname(nicknameMap.get(vo.getOwnerId())); + try { + applyLifecycle(vo, rootTasksAllCompletedMap.getOrDefault(vo.getId(), false)); + } catch (Exception e) { + log.warn("execution lifecycle apply failed in current user list assembly. executionId={}, statusCode={}, error={}", + vo.getId(), vo.getStatusCode(), e.getMessage()); + } + }); + return list; + } + @Override @Transactional(rollbackFor = Exception.class) @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/worklog/TaskWorklogServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/worklog/TaskWorklogServiceImpl.java index 8d04757..f314a46 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/worklog/TaskWorklogServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/worklog/TaskWorklogServiceImpl.java @@ -36,13 +36,7 @@ import org.springframework.util.StringUtils; import java.math.BigDecimal; import java.time.LocalDate; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; +import java.util.*; import java.util.stream.Collectors; import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception; @@ -218,6 +212,7 @@ public class TaskWorklogServiceImpl implements TaskWorklogService { worklog.setProgressRate(reqVO.getProgressRate()); worklog.setWorkContent(normalizeNullableText(reqVO.getWorkContent())); worklog.setAttachments(reqVO.getAttachments()); + worklog.setDifficulty(reqVO.getDifficulty()); return worklog; } diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImplTest.java index 825a57e..5cd9ee8 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImplTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImplTest.java @@ -2,6 +2,7 @@ package com.njcn.rdms.module.project.service.project.execution; import com.njcn.rdms.framework.common.exception.ServiceException; import com.njcn.rdms.framework.common.pojo.PageResult; +import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionOwnerChangeReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionPageReqVO; @@ -31,11 +32,13 @@ import com.njcn.rdms.module.project.enums.ErrorCodeConstants; import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants; import com.njcn.rdms.module.system.api.dict.DictDataApi; import com.njcn.rdms.module.system.api.user.AdminUserApi; +import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.MockedStatic; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -56,6 +59,7 @@ import static org.mockito.ArgumentMatchers.anyCollection; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -538,6 +542,43 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { assertEquals(new BigDecimal("10.00"), result.getList().get(1).getProgressRate()); } + @Test + void getCurrentUserExecutionList_shouldQueryByLoginUserAndAssembleAcrossProjects() { + Long loginUserId = 3001L; + ProjectExecutionDO first = createExecution(2001L, 5001L, loginUserId); + ProjectExecutionDO second = createExecution(2002L, 5002L, loginUserId); + + when(projectExecutionMapper.selectListByOwnerId(loginUserId)).thenReturn(List.of(first, second)); + when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task")) + .thenReturn(List.of("cancelled")); + when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(2001L), anyCollection(), eq(List.of("cancelled")))) + .thenReturn(List.of(Map.of("execution_id", 5001L, "progress_rate", new BigDecimal("25.555")))); + when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(2002L), anyCollection(), eq(List.of("cancelled")))) + .thenReturn(List.of(Map.of("execution_id", 5002L, "progress_rate", new BigDecimal("66.666")))); + when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(loginUserId), eq(new BigDecimal("25.56")), anyBoolean())) + .thenReturn(createLifecycleView()); + when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(loginUserId), eq(new BigDecimal("66.67")), anyBoolean())) + .thenReturn(createLifecycleView()); + when(adminUserApi.getUserMap(anyCollection())).thenReturn(Map.of(loginUserId, createUser(loginUserId, "测试负责人"))); + + try (MockedStatic mocked = mockStatic(SecurityFrameworkUtils.class)) { + mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId); + + List result = projectExecutionService.getCurrentUserExecutionList(); + + assertEquals(2, result.size()); + assertEquals(new BigDecimal("25.56"), result.get(0).getProgressRate()); + assertEquals(new BigDecimal("66.67"), result.get(1).getProgressRate()); + assertEquals("测试负责人", result.get(0).getOwnerNickname()); + assertEquals("测试负责人", result.get(1).getOwnerNickname()); + verify(projectExecutionMapper).selectListByOwnerId(loginUserId); + verify(projectTaskMapper).selectRootTaskAvgProgressGroupByExecutionIds(eq(2001L), anyCollection(), + eq(List.of("cancelled"))); + verify(projectTaskMapper).selectRootTaskAvgProgressGroupByExecutionIds(eq(2002L), anyCollection(), + eq(List.of("cancelled"))); + } + } + @Test void getExecutionPage_shouldDelegateMapper() { Long projectId = 2001L; @@ -616,4 +657,11 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { execution.setDeleted(Boolean.FALSE); return execution; } + + private AdminUserRespDTO createUser(Long userId, String nickname) { + AdminUserRespDTO user = new AdminUserRespDTO(); + user.setId(userId); + user.setNickname(nickname); + return user; + } }