diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/MyWorkbenchController.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/MyWorkbenchController.java new file mode 100644 index 0000000..61e7283 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/MyWorkbenchController.java @@ -0,0 +1,57 @@ +package com.njcn.rdms.module.project.controller.admin.project.project; + +import com.njcn.rdms.framework.common.pojo.CommonResult; +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.TeamLoadRespVO; +import com.njcn.rdms.module.project.controller.admin.project.project.vo.workbench.TeamWorklogWeekRespVO; +import com.njcn.rdms.module.project.service.project.MyTeamService; +import com.njcn.rdms.module.project.service.project.MyWorklogService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.NotNull; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; + +import static com.njcn.rdms.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - 工作台统计(团队负载/工时周聚合)") +@RestController +@RequestMapping("/project/project/me") +@Validated +public class MyWorkbenchController { + + @Resource + private MyTeamService myTeamService; + @Resource + private MyWorklogService myWorklogService; + + @GetMapping("/team-load") + @Operation(summary = "团队负载统计(团队=自己+管理链路当前生效直接下级)") + public CommonResult getTeamLoad() { + return success(myTeamService.getTeamLoad()); + } + + @GetMapping("/worklog-week") + @Operation(summary = "我的工时周聚合(逐日为均摊推算值;weekStart 任意日期自动归一到周一)") + public CommonResult getMyWorklogWeek( + @RequestParam("weekStart") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) @NotNull(message = "weekStart 不能为空") LocalDate weekStart) { + return success(myWorklogService.getMyWorklogWeek(weekStart)); + } + + @GetMapping("/team-worklog-week") + @Operation(summary = "团队工时周聚合(成员集合与 team-load 同口径)") + public CommonResult getTeamWorklogWeek( + @RequestParam("weekStart") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) @NotNull(message = "weekStart 不能为空") LocalDate weekStart) { + return success(myWorklogService.getTeamWorklogWeek(weekStart)); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/workbench/MyWorklogWeekRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/workbench/MyWorklogWeekRespVO.java new file mode 100644 index 0000000..203199f --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/workbench/MyWorklogWeekRespVO.java @@ -0,0 +1,37 @@ +package com.njcn.rdms.module.project.controller.admin.project.project.vo.workbench; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +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") +@Data +public class MyWorklogWeekRespVO { + + @Schema(description = "所选周周一", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-06-08") + private LocalDate weekStart; + @Schema(description = "周一~周五逐日工时(固定 5 元素,均摊推算值,保留 2 位小数)", requiredMode = Schema.RequiredMode.REQUIRED) + private List dailyHours; + @Schema(description = "本周工时按归属分布(hours 降序,personal/other 排在项目后)", requiredMode = Schema.RequiredMode.REQUIRED) + private List distribution; + + @Schema(description = "工时分布项") + @Data + public static class DistributionItemVO { + @Schema(description = "项目编号;kind != project 时为 null", example = "1923456789012345678") + @JsonSerialize(using = ToStringSerializer.class) + private Long projectId; + @Schema(description = "项目名称;kind != project 时为 null", example = "收银台 V3") + private String projectName; + @Schema(description = "归属:project=项目任务 / personal=个人事项 / other=无法归类", requiredMode = Schema.RequiredMode.REQUIRED, example = "project") + private String kind; + @Schema(description = "本周工时合计(保留 2 位小数)", requiredMode = Schema.RequiredMode.REQUIRED, example = "12.5") + private BigDecimal hours; + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/workbench/TeamLoadRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/workbench/TeamLoadRespVO.java new file mode 100644 index 0000000..83fee1a --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/workbench/TeamLoadRespVO.java @@ -0,0 +1,47 @@ +package com.njcn.rdms.module.project.controller.admin.project.project.vo.workbench; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - 工作台「团队负载」Response VO") +@Data +public class TeamLoadRespVO { + + @Schema(description = "团队成员负载列表;members[0] 恒为当前用户,其余按压力降序", requiredMode = Schema.RequiredMode.REQUIRED) + private List members; + + @Schema(description = "单个成员的负载") + @Data + public static class MemberVO { + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001") + @JsonSerialize(using = ToStringSerializer.class) + private Long userId; + @Schema(description = "用户昵称", example = "张三") + private String userNickname; + @Schema(description = "未完成任务按归属分布(项目任务行 + 个人事项行)", requiredMode = Schema.RequiredMode.REQUIRED) + private List items; + @Schema(description = "临期数:今天 <= 计划结束 <= 今天+3,未完成(任务+个人事项)", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Integer dueSoonCount; + @Schema(description = "逾期数:计划结束 < 今天,未完成(任务+个人事项)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer overdueCount; + } + + @Schema(description = "负载分布项") + @Data + public static class LoadItemVO { + @Schema(description = "项目编号;kind != project 时为 null", example = "1923456789012345678") + @JsonSerialize(using = ToStringSerializer.class) + private Long projectId; + @Schema(description = "项目名称;kind != project 时为 null", example = "收银台 V3") + private String projectName; + @Schema(description = "归属类型:project=项目任务 / personal=个人事项", requiredMode = Schema.RequiredMode.REQUIRED, example = "project") + private String kind; + @Schema(description = "未完成数", requiredMode = Schema.RequiredMode.REQUIRED, example = "4") + private Integer count; + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/workbench/TeamWorklogWeekRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/workbench/TeamWorklogWeekRespVO.java new file mode 100644 index 0000000..051dba2 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/workbench/TeamWorklogWeekRespVO.java @@ -0,0 +1,32 @@ +package com.njcn.rdms.module.project.controller.admin.project.project.vo.workbench; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDate; +import java.util.List; + +@Schema(description = "管理后台 - 工作台「团队工时周聚合」Response VO") +@Data +public class TeamWorklogWeekRespVO { + + @Schema(description = "所选周周一", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-06-08") + private LocalDate weekStart; + @Schema(description = "团队成员工时列表;members[0] 恒为当前用户;该周无填报的成员 items 为空数组", requiredMode = Schema.RequiredMode.REQUIRED) + private List members; + + @Schema(description = "单个成员的周工时") + @Data + public static class MemberVO { + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001") + @JsonSerialize(using = ToStringSerializer.class) + private Long userId; + @Schema(description = "用户昵称", example = "张三") + private String userNickname; + @Schema(description = "本周工时按归属分布", requiredMode = Schema.RequiredMode.REQUIRED) + private List items; + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/MyTaskController.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/MyTaskController.java new file mode 100644 index 0000000..c67ac11 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/MyTaskController.java @@ -0,0 +1,39 @@ +package com.njcn.rdms.module.project.controller.admin.project.task; + +import com.njcn.rdms.framework.common.pojo.CommonResult; +import com.njcn.rdms.framework.common.pojo.PageParam; +import com.njcn.rdms.framework.common.pojo.PageResult; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.mytask.MyTaskPageReqVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.mytask.MyTaskRespVO; +import com.njcn.rdms.module.project.service.project.task.MyTaskService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static com.njcn.rdms.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - 工作台「我的任务」(跨项目)") +@RestController +@RequestMapping("/project/project/me/tasks") +@Validated +public class MyTaskController { + + @Resource + private MyTaskService myTaskService; + + @GetMapping("/page") + @Operation(summary = "分页获取当前登录用户负责或协办的非终态任务(跨项目)") + public CommonResult> getMyTaskPage(@Valid MyTaskPageReqVO reqVO) { + // 前端固定传 pageSize=-1 拉全部;负数统一归一为 PAGE_SIZE_NONE,与 me 系列一致 + if (reqVO.getPageSize() != null && reqVO.getPageSize() < 0) { + reqVO.setPageSize(PageParam.PAGE_SIZE_NONE); + } + return success(myTaskService.getMyTaskPage(reqVO)); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/mytask/MyTaskPageReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/mytask/MyTaskPageReqVO.java new file mode 100644 index 0000000..fa9df87 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/mytask/MyTaskPageReqVO.java @@ -0,0 +1,16 @@ +package com.njcn.rdms.module.project.controller.admin.project.task.vo.mytask; + +import com.njcn.rdms.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Schema(description = "管理后台 - 工作台「我的任务」分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class MyTaskPageReqVO extends PageParam { + + @Schema(description = "参与类型过滤:owner=我负责 / collaborator=我协办;缺省=并集", example = "owner") + private String involveType; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/mytask/MyTaskRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/mytask/MyTaskRespVO.java new file mode 100644 index 0000000..425d7cf --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/mytask/MyTaskRespVO.java @@ -0,0 +1,57 @@ +package com.njcn.rdms.module.project.controller.admin.project.task.vo.mytask; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskLifecycleActionRespVO; +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 MyTaskRespVO { + + @Schema(description = "任务编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1934567890123456789") + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + @Schema(description = "任务标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "支付回调接口联调遗留问题处理") + private String taskTitle; + @Schema(description = "所属项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1923456789012345678") + @JsonSerialize(using = ToStringSerializer.class) + private Long projectId; + @Schema(description = "所属项目名称", example = "收银台 V3") + private String projectName; + @Schema(description = "所属执行编号(可空)", example = "1928888888888888888") + @JsonSerialize(using = ToStringSerializer.class) + private Long executionId; + @Schema(description = "所属执行名称(可空)", example = "后端联调") + private String executionName; + @Schema(description = "任务状态编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "active") + private String statusCode; + @Schema(description = "任务状态名称", example = "进行中") + private String statusName; + @Schema(description = "优先级(字典 rdms_req_priority 原样返回)", example = "1") + private String priority; + @Schema(description = "计划结束日期(可空)", example = "2026-06-15") + private LocalDate plannedEndDate; + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + @Schema(description = "我的身份:owner=负责人 / collaborator=协办人;双重身份返回 owner", requiredMode = Schema.RequiredMode.REQUIRED, example = "owner") + private String myRole; + @Schema(description = "父任务编号(可空)", example = "1934567890123456788") + @JsonSerialize(using = ToStringSerializer.class) + private Long parentTaskId; + @Schema(description = "任务进度,范围 0-100", requiredMode = Schema.RequiredMode.REQUIRED, example = "60") + private BigDecimal progressRate; + @Schema(description = "是否终态", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + private Boolean terminal; + @Schema(description = "当前状态是否允许编辑", example = "true") + private Boolean allowEdit; + @Schema(description = "当前登录用户在当前状态下可执行的任务生命周期动作;无动作返回空数组") + private List availableActions; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/personal/PersonalItemMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/personal/PersonalItemMapper.java index b06e746..d0a1bae 100644 --- 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 @@ -7,10 +7,14 @@ 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.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.springframework.util.StringUtils; import java.time.LocalDate; +import java.util.Collection; import java.util.List; +import java.util.Map; @Mapper public interface PersonalItemMapper extends BaseMapperX { @@ -77,4 +81,37 @@ public interface PersonalItemMapper extends BaseMapperX { queryWrapper.orderByAsc(PersonalItemDO::getId); return selectList(queryWrapper); } + + /** + * 团队负载:一批成员的非终态个人事项计数(按 owner_id 分组),带临期/逾期。 + * 个人事项状态机复用 task 对象类型,terminalStatusCodes 与任务同源。 + * 返回 Map:ownerId / itemCount / dueSoonCount / overdueCount(均 Long)。 + */ + @Select(""" + + """) + List> selectLoadStatsByOwnerIds( + @Param("ownerIds") Collection ownerIds, + @Param("terminalStatusCodes") Collection terminalStatusCodes, + @Param("today") LocalDate today, + @Param("dueSoonEnd") LocalDate dueSoonEnd); } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskMapper.java index e518fa6..ac09eb3 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskMapper.java @@ -859,4 +859,107 @@ public interface ProjectTaskMapper extends BaseMapperX { List selectInvolvedListByUserIdAndStatusNot(@Param("userId") Long userId, @Param("excludedStatusCode") String excludedStatusCode); + /** + * 工作台「我的任务」:当前用户为负责人或在岗协办人的非终态任务(跨项目)。 + * involveType:owner=仅我负责 / collaborator=仅我协办(排除我同时是负责人的)/ 其他或 null=并集。 + * 排序:计划结束升序、空值最后,同日按创建时间升序,id 兜底。 + */ + @Select(""" + + """) + List selectMyUnfinishedInvolvedList(@Param("userId") Long userId, + @Param("involveType") String involveType, + @Param("terminalStatusCodes") Collection terminalStatusCodes); + + /** + * 团队负载:一批成员的非终态任务计数,按 (成员, 项目) 分组,一次扫表带出临期/逾期。 + * 身份口径与「我的任务」一致(负责人 ∪ 在岗协办);UNION ALL 两个视角, + * 协办分支排除"自己同时是负责人"的行防双计(owner_id 为 NULL 时保留)。 + * 临期:today <= planned_end_date <= dueSoonEnd;逾期:planned_end_date < today。 + * 返回 Map:userId / projectId / taskCount / dueSoonCount / overdueCount(均 Long)。 + */ + @Select(""" + + """) + List> selectInvolvedLoadStatsByUserIds( + @Param("userIds") Collection userIds, + @Param("terminalStatusCodes") Collection terminalStatusCodes, + @Param("today") LocalDate today, + @Param("dueSoonEnd") LocalDate dueSoonEnd); + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/TaskWorklogMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/TaskWorklogMapper.java index 4998d60..5399581 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/TaskWorklogMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/TaskWorklogMapper.java @@ -214,4 +214,19 @@ public interface TaskWorklogMapper extends BaseMapperX { .orderByAsc(TaskWorklogDO::getId)); } + /** selectListByUserIdAndPeriod 的多用户版,团队工时周聚合用。段相交语义相同。 */ + default List selectListByUserIdsAndPeriod(Collection userIds, + LocalDate startDate, LocalDate endDate) { + if (userIds == null || userIds.isEmpty() || startDate == null || endDate == null) { + return List.of(); + } + return selectList(new LambdaQueryWrapperX() + .in(TaskWorklogDO::getUserId, userIds) + .le(TaskWorklogDO::getStartDate, endDate) + .ge(TaskWorklogDO::getEndDate, startDate) + .orderByAsc(TaskWorklogDO::getUserId) + .orderByAsc(TaskWorklogDO::getEndDate) + .orderByAsc(TaskWorklogDO::getId)); + } + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyTeamService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyTeamService.java new file mode 100644 index 0000000..67abb0d --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyTeamService.java @@ -0,0 +1,22 @@ +package com.njcn.rdms.module.project.service.project; + +import com.njcn.rdms.module.project.controller.admin.project.project.vo.workbench.TeamLoadRespVO; + +import java.util.List; + +public interface MyTeamService { + + /** + * 解析当前登录用户的团队成员集合(口径:自己 + 管理链路当前生效的直接下级)。 + * 返回顺序:第 0 个恒为当前用户自己;昵称已回填。普通员工(无下级)只返回自己。 + */ + List resolveTeamMembers(); + + /** 工作台「团队负载」:每个团队成员的未完成任务/个人事项分布 + 临期/逾期。 */ + TeamLoadRespVO getTeamLoad(); + + /** 团队成员(userId + 昵称)。 */ + record TeamMember(Long userId, String nickname) { + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyTeamServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyTeamServiceImpl.java new file mode 100644 index 0000000..cff6fc8 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyTeamServiceImpl.java @@ -0,0 +1,162 @@ +package com.njcn.rdms.module.project.service.project; + +import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; +import com.njcn.rdms.module.project.constant.ProjectTaskConstants; +import com.njcn.rdms.module.project.controller.admin.project.project.vo.workbench.TeamLoadRespVO; +import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO; +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.status.ObjectStatusModelMapper; +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.UserManagementRelationRespDTO; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +public class MyTeamServiceImpl implements MyTeamService { + + /** 临期窗口天数:计划结束 <= 今天 + 3(2026-06-12 前端口径确认)。 */ + private static final int DUE_SOON_DAYS = 3; + + @Resource + private ProjectTaskMapper projectTaskMapper; + @Resource + private PersonalItemMapper personalItemMapper; + @Resource + private ProjectMapper projectMapper; + @Resource + private ObjectStatusModelMapper objectStatusModelMapper; + @Resource + private UserManagementRelationApi userManagementRelationApi; + @Resource + private AdminUserApi adminUserApi; + + @Override + public List resolveTeamMembers() { + Long me = SecurityFrameworkUtils.getLoginUserId(); + // 管理链路 RPC 不过滤生效期(system 侧直接按 manager 查),这里按 effectiveFrom/Until 过滤当前生效 + List relations = userManagementRelationApi + .getRelationListByManagerUserId(me).getCheckedData(); + LocalDateTime now = LocalDateTime.now(); + LinkedHashSet userIds = new LinkedHashSet<>(); + userIds.add(me); + if (relations != null) { + relations.stream() + .filter(r -> r.getSubordinateUserId() != null) + .filter(r -> r.getEffectiveFrom() == null || !r.getEffectiveFrom().isAfter(now)) + .filter(r -> r.getEffectiveUntil() == null || r.getEffectiveUntil().isAfter(now)) + .map(UserManagementRelationRespDTO::getSubordinateUserId) + .forEach(userIds::add); + } + Map userMap = adminUserApi.getUserMap(userIds); + return userIds.stream().map(uid -> { + AdminUserRespDTO user = userMap.get(uid); + return new TeamMember(uid, user == null ? null : user.getNickname()); + }).collect(Collectors.toList()); + } + + @Override + public TeamLoadRespVO getTeamLoad() { + List members = resolveTeamMembers(); + List userIds = members.stream().map(TeamMember::userId).collect(Collectors.toList()); + LocalDate today = LocalDate.now(); + LocalDate dueSoonEnd = today.plusDays(DUE_SOON_DAYS); + // 个人事项状态机复用 task 对象类型,终态同源 + List terminal = objectStatusModelMapper + .selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE); + // 任务:按 (成员, 项目) 分组;个人事项:按成员分组。各一次扫表,无逐人查询 + List> taskRows = projectTaskMapper + .selectInvolvedLoadStatsByUserIds(userIds, terminal, today, dueSoonEnd); + List> personalRows = personalItemMapper + .selectLoadStatsByOwnerIds(userIds, terminal, today, dueSoonEnd); + // 项目名批量回填 + Set projectIds = taskRows.stream().map(r -> asLong(r.get("projectId"))) + .filter(Objects::nonNull).collect(Collectors.toSet()); + Map projectMap = projectIds.isEmpty() ? Collections.emptyMap() + : projectMapper.selectBatchIds(projectIds).stream() + .collect(Collectors.toMap(ProjectDO::getId, Function.identity(), (a, b) -> a)); + // 任务行按成员分组 + Map>> taskRowsByUser = taskRows.stream() + .collect(Collectors.groupingBy(r -> asLong(r.get("userId")), LinkedHashMap::new, Collectors.toList())); + Map> personalRowByUser = personalRows.stream() + .collect(Collectors.toMap(r -> asLong(r.get("ownerId")), Function.identity(), (a, b) -> a)); + // 组装每个成员 + List memberVOs = members.stream().map(m -> { + TeamLoadRespVO.MemberVO vo = new TeamLoadRespVO.MemberVO(); + vo.setUserId(m.userId()); + vo.setUserNickname(m.nickname()); + List items = new ArrayList<>(); + long dueSoon = 0; + long overdue = 0; + for (Map row : taskRowsByUser.getOrDefault(m.userId(), Collections.emptyList())) { + TeamLoadRespVO.LoadItemVO item = new TeamLoadRespVO.LoadItemVO(); + Long pid = asLong(row.get("projectId")); + item.setProjectId(pid); + ProjectDO project = pid == null ? null : projectMap.get(pid); + item.setProjectName(project == null ? null : project.getProjectName()); + item.setKind("project"); + item.setCount((int) unbox(row.get("taskCount"))); + items.add(item); + dueSoon += unbox(row.get("dueSoonCount")); + overdue += unbox(row.get("overdueCount")); + } + // 项目行按 count 降序 + items.sort(Comparator.comparing(TeamLoadRespVO.LoadItemVO::getCount, + Comparator.reverseOrder())); + Map personal = personalRowByUser.get(m.userId()); + if (personal != null && unbox(personal.get("itemCount")) > 0) { + TeamLoadRespVO.LoadItemVO item = new TeamLoadRespVO.LoadItemVO(); + item.setKind("personal"); + item.setCount((int) unbox(personal.get("itemCount"))); + items.add(item); // 个人事项行固定排在项目行之后 + dueSoon += unbox(personal.get("dueSoonCount")); + overdue += unbox(personal.get("overdueCount")); + } + vo.setItems(items); + vo.setDueSoonCount((int) dueSoon); + vo.setOverdueCount((int) overdue); + return vo; + }).collect(Collectors.toList()); + // members[0] 恒为自己;其余按压力(总未完成+临期+逾期)降序 + TeamLoadRespVO.MemberVO self = memberVOs.get(0); + List rest = new ArrayList<>(memberVOs.subList(1, memberVOs.size())); + rest.sort(Comparator.comparingLong(MyTeamServiceImpl::pressure).reversed()); + List ordered = new ArrayList<>(); + ordered.add(self); + ordered.addAll(rest); + TeamLoadRespVO resp = new TeamLoadRespVO(); + resp.setMembers(ordered); + return resp; + } + + private static long pressure(TeamLoadRespVO.MemberVO vo) { + long total = vo.getItems().stream().mapToLong(TeamLoadRespVO.LoadItemVO::getCount).sum(); + return total + vo.getDueSoonCount() + vo.getOverdueCount(); + } + + private static Long asLong(Object v) { + return v == null ? null : ((Number) v).longValue(); + } + + private static long unbox(Object v) { + return v == null ? 0L : ((Number) v).longValue(); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyWorklogService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyWorklogService.java new file mode 100644 index 0000000..5e77656 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyWorklogService.java @@ -0,0 +1,16 @@ +package com.njcn.rdms.module.project.service.project; + +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 java.time.LocalDate; + +public interface MyWorklogService { + + /** 我的工时周聚合。weekStart 任意日期自动归一到所在周周一。 */ + MyWorklogWeekRespVO getMyWorklogWeek(LocalDate weekStart); + + /** 团队工时周聚合(成员集合与团队负载同口径)。 */ + TeamWorklogWeekRespVO getTeamWorklogWeek(LocalDate weekStart); + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyWorklogServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyWorklogServiceImpl.java new file mode 100644 index 0000000..1958ad0 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyWorklogServiceImpl.java @@ -0,0 +1,168 @@ +package com.njcn.rdms.module.project.service.project; + +import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; +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.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.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 java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +public class MyWorklogServiceImpl implements MyWorklogService { + + @Resource + private TaskWorklogMapper taskWorklogMapper; + @Resource + private ProjectTaskMapper projectTaskMapper; + @Resource + private PersonalItemMapper personalItemMapper; + @Resource + private ProjectMapper projectMapper; + @Resource + private MyTeamService myTeamService; + + @Override + public MyWorklogWeekRespVO getMyWorklogWeek(LocalDate weekStart) { + Long me = SecurityFrameworkUtils.getLoginUserId(); + LocalDate monday = MyWorklogWeekSupport.normalizeToMonday(weekStart); + // 查询区间含周末:周六/周日的段要兜底归周五,必须查回来 + List worklogs = taskWorklogMapper + .selectListByUserIdAndPeriod(me, monday, monday.plusDays(6)); + // 逐日累计(scale=4)与每任务份额累计 + Map daily = new LinkedHashMap<>(); + Map hoursByTask = new HashMap<>(); + for (TaskWorklogDO w : worklogs) { + Map shares = MyWorklogWeekSupport + .apportionToWeek(w.getStartDate(), w.getEndDate(), w.getDurationHours(), monday); + for (Map.Entry e : shares.entrySet()) { + daily.merge(e.getKey(), e.getValue(), BigDecimal::add); + hoursByTask.merge(w.getTaskId(), e.getValue(), BigDecimal::add); + } + } + MyWorklogWeekRespVO resp = new MyWorklogWeekRespVO(); + resp.setWeekStart(monday); + List dailyHours = new ArrayList<>(5); + for (int i = 0; i < 5; i++) { + dailyHours.add(daily.getOrDefault(monday.plusDays(i), BigDecimal.ZERO) + .setScale(2, RoundingMode.HALF_UP)); + } + resp.setDailyHours(dailyHours); + resp.setDistribution(buildDistribution(hoursByTask)); + return resp; + } + + @Override + public TeamWorklogWeekRespVO getTeamWorklogWeek(LocalDate weekStart) { + LocalDate monday = MyWorklogWeekSupport.normalizeToMonday(weekStart); + List members = myTeamService.resolveTeamMembers(); + List userIds = members.stream().map(MyTeamService.TeamMember::userId).collect(Collectors.toList()); + List worklogs = taskWorklogMapper + .selectListByUserIdsAndPeriod(userIds, monday, monday.plusDays(6)); + // 每成员每任务的本周份额合计 + Map> hoursByUserAndTask = new LinkedHashMap<>(); + for (TaskWorklogDO w : worklogs) { + BigDecimal weekTotal = MyWorklogWeekSupport + .apportionToWeek(w.getStartDate(), w.getEndDate(), w.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); + } + } + TeamWorklogWeekRespVO resp = new TeamWorklogWeekRespVO(); + resp.setWeekStart(monday); + resp.setMembers(members.stream().map(m -> { + TeamWorklogWeekRespVO.MemberVO vo = new TeamWorklogWeekRespVO.MemberVO(); + vo.setUserId(m.userId()); + vo.setUserNickname(m.nickname()); + vo.setItems(buildDistribution( + hoursByUserAndTask.getOrDefault(m.userId(), Collections.emptyMap()))); + return vo; + }).collect(Collectors.toList())); + return resp; + } + + /** + * 把 taskId→本周工时 聚成归属分布:工时表的 task_id 既可能指向任务也可能指向个人事项 + * (个人事项工时复用 rdms_task_worklog),先按任务表归 project,再按个人事项表归 personal, + * 都对不上归 other。排序:project 行按 hours 降序,personal/other 殿后。 + * 被软删的任务/事项的残留工时归 other(两表主键同为雪花 id 全局不撞,不存在误归类)。 + */ + private List buildDistribution(Map hoursByTask) { + if (hoursByTask.isEmpty()) { + return Collections.emptyList(); + } + Set taskIds = hoursByTask.keySet(); + Map taskMap = projectTaskMapper.selectBatchIds(taskIds).stream() + .collect(Collectors.toMap(ProjectTaskDO::getId, Function.identity(), (a, b) -> a)); + Set personalIds = taskIds.stream() + .filter(id -> !taskMap.containsKey(id)).collect(Collectors.toSet()); + Set personalHit = personalIds.isEmpty() ? Collections.emptySet() + : personalItemMapper.selectBatchIds(personalIds).stream() + .map(PersonalItemDO::getId).collect(Collectors.toSet()); + // 聚合:projectId→hours / personal / other + Map hoursByProject = new LinkedHashMap<>(); + BigDecimal personalHours = BigDecimal.ZERO; + BigDecimal otherHours = BigDecimal.ZERO; + for (Map.Entry e : hoursByTask.entrySet()) { + ProjectTaskDO task = taskMap.get(e.getKey()); + if (task != null) { + hoursByProject.merge(task.getProjectId(), e.getValue(), BigDecimal::add); + } else if (personalHit.contains(e.getKey())) { + personalHours = personalHours.add(e.getValue()); + } else { + otherHours = otherHours.add(e.getValue()); + } + } + Map projectMap = hoursByProject.isEmpty() ? Collections.emptyMap() + : projectMapper.selectBatchIds(hoursByProject.keySet()).stream() + .collect(Collectors.toMap(ProjectDO::getId, Function.identity(), (a, b) -> a)); + List items = new ArrayList<>(); + hoursByProject.entrySet().stream() + .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())) + .forEach(e -> { + MyWorklogWeekRespVO.DistributionItemVO item = new MyWorklogWeekRespVO.DistributionItemVO(); + item.setProjectId(e.getKey()); + ProjectDO project = projectMap.get(e.getKey()); + item.setProjectName(project == null ? null : project.getProjectName()); + item.setKind("project"); + item.setHours(e.getValue().setScale(2, RoundingMode.HALF_UP)); + items.add(item); + }); + if (personalHours.signum() > 0) { + MyWorklogWeekRespVO.DistributionItemVO item = new MyWorklogWeekRespVO.DistributionItemVO(); + item.setKind("personal"); + item.setHours(personalHours.setScale(2, RoundingMode.HALF_UP)); + items.add(item); + } + if (otherHours.signum() > 0) { + MyWorklogWeekRespVO.DistributionItemVO item = new MyWorklogWeekRespVO.DistributionItemVO(); + item.setKind("other"); + item.setHours(otherHours.setScale(2, RoundingMode.HALF_UP)); + items.add(item); + } + return items; + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyWorklogWeekSupport.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyWorklogWeekSupport.java new file mode 100644 index 0000000..ce9fd78 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyWorklogWeekSupport.java @@ -0,0 +1,63 @@ +package com.njcn.rdms.module.project.service.project; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 工时周聚合的纯函数支撑:周一归一 + 按段均摊。 + * 口径(2026-06-12 与用户确认):工时按段填报无逐日明细,均摊分母 = 段内工作日(周一~周五)数; + * 纯周末段全额兜底归段结束日前最近的工作日;份额 scale=4,最终展示由调用方聚合后 setScale(2)。 + */ +final class MyWorklogWeekSupport { + + private MyWorklogWeekSupport() { + } + + /** 任意日期归一到所在 ISO 周的周一(weekStart 入参容错,不强制前端必须传周一)。 */ + static LocalDate normalizeToMonday(LocalDate date) { + return date.with(DayOfWeek.MONDAY); + } + + /** + * 把一笔按段填报的工时摊到所选周的工作日。 + * 返回 [weekMonday, weekMonday+4] 内各工作日的份额(scale=4);无份额的日不出现在 map。 + * + *

前置条件:weekMonday 必须已归一到 ISO 周的周一(调用方先调 {@link #normalizeToMonday})。 + * 方法内不做防御归一,以便在调用方传入非周一时尽早暴露 bug。 + */ + static Map apportionToWeek(LocalDate segStart, LocalDate segEnd, + BigDecimal hours, LocalDate weekMonday) { + Map result = new LinkedHashMap<>(); + if (segStart == null || segEnd == null || hours == null || segStart.isAfter(segEnd)) { + return result; + } + LocalDate weekFriday = weekMonday.plusDays(4); + List 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)); + } + 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); + } + } + return result; + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/MyTaskService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/MyTaskService.java new file mode 100644 index 0000000..fd26f14 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/MyTaskService.java @@ -0,0 +1,12 @@ +package com.njcn.rdms.module.project.service.project.task; + +import com.njcn.rdms.framework.common.pojo.PageResult; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.mytask.MyTaskPageReqVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.mytask.MyTaskRespVO; + +public interface MyTaskService { + + /** 工作台「我的任务」:当前登录用户负责或在岗协办的非终态任务,跨项目分页。 */ + PageResult getMyTaskPage(MyTaskPageReqVO reqVO); + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/MyTaskServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/MyTaskServiceImpl.java new file mode 100644 index 0000000..01524a7 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/MyTaskServiceImpl.java @@ -0,0 +1,121 @@ +package com.njcn.rdms.module.project.service.project.task; + +import com.njcn.rdms.framework.common.pojo.PageParam; +import com.njcn.rdms.framework.common.pojo.PageResult; +import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; +import com.njcn.rdms.module.project.constant.ProjectTaskConstants; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.mytask.MyTaskPageReqVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.mytask.MyTaskRespVO; +import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO; +import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO; +import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO; +import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper; +import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper; +import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper; +import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +public class MyTaskServiceImpl implements MyTaskService { + + @Resource + private ProjectTaskMapper projectTaskMapper; + @Resource + private ProjectMapper projectMapper; + @Resource + private ProjectExecutionMapper projectExecutionMapper; + @Resource + private ObjectStatusModelMapper objectStatusModelMapper; + @Resource + private ProjectTaskStatusViewService projectTaskStatusViewService; + + @Override + public PageResult getMyTaskPage(MyTaskPageReqVO reqVO) { + Long loginUserId = SecurityFrameworkUtils.getLoginUserId(); + // 终态运行时推导;SQL 内对空集有 守卫 + List taskTerminal = objectStatusModelMapper + .selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE); + List tasks = projectTaskMapper + .selectMyUnfinishedInvolvedList(loginUserId, reqVO.getInvolveType(), taskTerminal); + if (tasks.isEmpty()) { + return new PageResult<>(Collections.emptyList(), 0L); + } + // 项目名 / 执行名批量回填 + Set projectIds = tasks.stream().map(ProjectTaskDO::getProjectId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + Map projectMap = projectIds.isEmpty() ? Collections.emptyMap() + : projectMapper.selectBatchIds(projectIds).stream() + .collect(Collectors.toMap(ProjectDO::getId, Function.identity(), (a, b) -> a)); + Set executionIds = tasks.stream().map(ProjectTaskDO::getExecutionId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + Map executionMap = executionIds.isEmpty() ? Collections.emptyMap() + : projectExecutionMapper.selectBatchIds(executionIds).stream() + .collect(Collectors.toMap(ProjectExecutionDO::getId, Function.identity(), (a, b) -> a)); + List all = tasks.stream().map(t -> { + MyTaskRespVO vo = new MyTaskRespVO(); + vo.setId(t.getId()); + vo.setTaskTitle(t.getTaskTitle()); + vo.setProjectId(t.getProjectId()); + ProjectDO project = projectMap.get(t.getProjectId()); + vo.setProjectName(project == null ? null : project.getProjectName()); + vo.setExecutionId(t.getExecutionId()); + ProjectExecutionDO execution = executionMap.get(t.getExecutionId()); + vo.setExecutionName(execution == null ? null : execution.getExecutionName()); + vo.setStatusCode(t.getStatusCode()); + vo.setPriority(t.getPriority()); + vo.setPlannedEndDate(t.getPlannedEndDate()); + vo.setCreateTime(t.getCreateTime()); + vo.setParentTaskId(t.getParentTaskId()); + vo.setProgressRate(normalizeProgress(t.getProgressRate())); + // SQL 已保证至少一种身份;owner 优先(双重身份返 owner) + vo.setMyRole(Objects.equals(t.getOwnerId(), loginUserId) ? "owner" : "collaborator"); + return vo; + }).collect(Collectors.toList()); + applyLifecycle(tasks, all); + return paginate(all, reqVO); + } + + private void applyLifecycle(List tasks, List vos) { + Map lifecycleMap = + projectTaskStatusViewService.getLifecycleMap(tasks); + for (MyTaskRespVO vo : vos) { + ProjectTaskStatusViewService.ProjectTaskLifecycleView lifecycle = lifecycleMap.get(vo.getId()); + if (lifecycle == null) { + vo.setAvailableActions(Collections.emptyList()); + continue; + } + vo.setStatusName(lifecycle.statusName()); + vo.setTerminal(lifecycle.terminal()); + vo.setAllowEdit(lifecycle.allowEdit()); + vo.setAvailableActions(lifecycle.availableActions()); + } + } + + private BigDecimal normalizeProgress(BigDecimal progressRate) { + return progressRate == null ? BigDecimal.ZERO : progressRate; + } + + /** 内存分页,与 MyProjectServiceImpl#paginate 同款(pageSize<0 拉全部)。 */ + private PageResult paginate(List all, PageParam reqVO) { + long total = all.size(); + Integer pageSize = reqVO.getPageSize(); + if (pageSize == null || pageSize < 0) { + return new PageResult<>(all, total); + } + int pageNo = reqVO.getPageNo() == null || reqVO.getPageNo() < 1 ? 1 : reqVO.getPageNo(); + int fromIndex = Math.min((pageNo - 1) * pageSize, all.size()); + int toIndex = Math.min(fromIndex + pageSize, all.size()); + return new PageResult<>(all.subList(fromIndex, toIndex), total); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskStatusViewService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskStatusViewService.java index c1db60b..48b3020 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskStatusViewService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskStatusViewService.java @@ -4,6 +4,7 @@ import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; import com.njcn.rdms.module.project.constant.ObjectActivityConstants; import com.njcn.rdms.module.project.constant.ProjectTaskConstants; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskLifecycleActionRespVO; +import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO; 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; @@ -14,9 +15,13 @@ import org.springframework.stereotype.Service; import java.math.BigDecimal; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception; @@ -62,14 +67,56 @@ public class ProjectTaskStatusViewService { ); } + public Map getLifecycleMap(List tasks) { + if (tasks == null || tasks.isEmpty()) { + return Collections.emptyMap(); + } + Map statusModelMap = objectStatusModelMapper + .selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE).stream() + .collect(Collectors.toMap(ObjectStatusModelDO::getStatusCode, Function.identity(), (a, b) -> a)); + List statusCodes = tasks.stream() + .map(ProjectTaskDO::getStatusCode) + .filter(Objects::nonNull) + .distinct() + .toList(); + Map> transitionMap = statusCodes.isEmpty() + ? Collections.emptyMap() + : objectStatusTransitionMapper + .selectListByObjectTypeAndFromStatuses(ProjectTaskConstants.OBJECT_TYPE, statusCodes).stream() + .collect(Collectors.groupingBy(ObjectStatusTransitionDO::getFromStatusCode)); + Long currentUserId = SecurityFrameworkUtils.getLoginUserId(); + Map result = new LinkedHashMap<>(); + for (ProjectTaskDO task : tasks) { + ObjectStatusModelDO statusModel = statusModelMap.get(task.getStatusCode()); + if (statusModel == null) { + throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_MODEL_NOT_EXISTS_OR_DISABLED); + } + result.put(task.getId(), new ProjectTaskLifecycleView( + statusModel.getStatusName(), + statusModel.getTerminalFlag(), + statusModel.getAllowEdit(), + buildAvailableActions( + transitionMap.getOrDefault(task.getStatusCode(), Collections.emptyList()), + task.getOwnerId(), task.getProgressRate(), currentUserId) + )); + } + return result; + } + private List buildAvailableActions(String statusCode, Long ownerId, BigDecimal progressRate) { List transitions = objectStatusTransitionMapper .selectListByObjectTypeAndFromStatus(ProjectTaskConstants.OBJECT_TYPE, statusCode); + return buildAvailableActions(transitions, ownerId, progressRate, SecurityFrameworkUtils.getLoginUserId()); + } + + private List buildAvailableActions(List transitions, + Long ownerId, + BigDecimal progressRate, + Long currentUserId) { if (transitions == null || transitions.isEmpty()) { return Collections.emptyList(); } - Long currentUserId = SecurityFrameworkUtils.getLoginUserId(); return transitions.stream() // 剔除系统级动作 auto_start:由工时填报触发,不暴露给前端按钮 .filter(transition -> !ObjectActivityConstants.TASK_ACTION_AUTO_START.equals(transition.getActionCode())) diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/MyTeamServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/MyTeamServiceImplTest.java new file mode 100644 index 0000000..5ca23ea --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/MyTeamServiceImplTest.java @@ -0,0 +1,234 @@ +package com.njcn.rdms.module.project.service.project; + +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.project.vo.workbench.TeamLoadRespVO; +import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO; +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.status.ObjectStatusModelMapper; +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.UserManagementRelationRespDTO; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.njcn.rdms.framework.common.pojo.CommonResult.success; +import static java.util.Collections.emptyList; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +class MyTeamServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private MyTeamServiceImpl myTeamService; + @Mock + private ProjectTaskMapper projectTaskMapper; + @Mock + private PersonalItemMapper personalItemMapper; + @Mock + private ProjectMapper projectMapper; + @Mock + private ObjectStatusModelMapper objectStatusModelMapper; + @Mock + private UserManagementRelationApi userManagementRelationApi; + @Mock + private AdminUserApi adminUserApi; + + private UserManagementRelationRespDTO relation(Long subordinateId, + LocalDateTime from, LocalDateTime until) { + UserManagementRelationRespDTO dto = new UserManagementRelationRespDTO(); + dto.setSubordinateUserId(subordinateId); + dto.setEffectiveFrom(from); + dto.setEffectiveUntil(until); + return dto; + } + + private AdminUserRespDTO user(Long id, String nickname) { + AdminUserRespDTO dto = new AdminUserRespDTO(); + dto.setId(id); + dto.setNickname(nickname); + return dto; + } + + @Test + void resolveTeamMembers_selfFirst_expiredRelationExcluded() { + try (MockedStatic mock = mockStatic(SecurityFrameworkUtils.class)) { + mock.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(100L); + LocalDateTime now = LocalDateTime.now(); + when(userManagementRelationApi.getRelationListByManagerUserId(100L)).thenReturn(success(List.of( + relation(200L, null, null), // 生效中(无期限) + relation(300L, now.minusDays(1), now.minusHours(1)) // 已失效 → 排除 + ))); + Map userMap = new HashMap<>(); + userMap.put(100L, user(100L, "我")); + userMap.put(200L, user(200L, "张三")); + when(adminUserApi.getUserMap(anyCollection())).thenReturn(userMap); + List members = myTeamService.resolveTeamMembers(); + assertEquals(2, members.size()); + assertEquals(100L, members.get(0).userId()); // self first + assertEquals("张三", members.get(1).nickname()); + } + } + + @Test + void getTeamLoad_noSubordinates_selfOnlyWithEmptyItems() { + try (MockedStatic mock = mockStatic(SecurityFrameworkUtils.class)) { + mock.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(100L); + when(userManagementRelationApi.getRelationListByManagerUserId(100L)).thenReturn(success(emptyList())); + when(adminUserApi.getUserMap(anyCollection())) + .thenReturn(Map.of(100L, user(100L, "我"))); + when(objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled(anyString())) + .thenReturn(List.of("completed", "cancelled")); + when(projectTaskMapper.selectInvolvedLoadStatsByUserIds(anyCollection(), anyCollection(), any(), any())) + .thenReturn(emptyList()); + when(personalItemMapper.selectLoadStatsByOwnerIds(anyCollection(), anyCollection(), any(), any())) + .thenReturn(emptyList()); + TeamLoadRespVO resp = myTeamService.getTeamLoad(); + assertEquals(1, resp.getMembers().size()); + assertEquals(100L, resp.getMembers().get(0).getUserId()); + assertEquals(0, resp.getMembers().get(0).getItems().size()); // 无任务也返回,items 空数组 + assertEquals(0, resp.getMembers().get(0).getDueSoonCount()); + } + } + + /** + * 多成员场景:排序、items 组装(任务行 + 个人事项行)、压力公式。 + * + *

压力 = items 之和 + dueSoonCount + overdueCount + *

    + *
  • 用户 300:任务 5+1=6,dueSoon=0,overdue=2 → 压力 8
  • + *
  • 用户 200:任务 2 + 个人 1=3,dueSoon=1,overdue=1 → 压力 5
  • + *
+ * 故排序:100(自己) → 300 → 200 + */ + @Test + void getTeamLoad_multiMember_sortAndItemsAssembly() { + try (MockedStatic mock = mockStatic(SecurityFrameworkUtils.class)) { + mock.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(100L); + + // --- 下级关系:200 和 300 均生效中(无期限限制)--- + LocalDateTime now = LocalDateTime.now(); + when(userManagementRelationApi.getRelationListByManagerUserId(100L)).thenReturn(success(List.of( + relation(200L, null, null), + relation(300L, now.minusDays(10), null) + ))); + + // --- 用户基础信息 --- + Map userMap = new HashMap<>(); + userMap.put(100L, user(100L, "我")); + userMap.put(200L, user(200L, "张三")); + userMap.put(300L, user(300L, "李四")); + when(adminUserApi.getUserMap(anyCollection())).thenReturn(userMap); + + // --- 终态码(与其他用例保持一致)--- + when(objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled(anyString())) + .thenReturn(List.of("completed", "cancelled")); + + // --- 任务聚合:3 行(userId/projectId/taskCount/dueSoonCount/overdueCount)--- + // 键名以被测类 row.get(...) 实际读取键为准 + Map taskRow200P10 = Map.of( + "userId", 200L, + "projectId", 10L, + "taskCount", 2L, + "dueSoonCount", 1L, + "overdueCount", 0L + ); + Map taskRow300P10 = Map.of( + "userId", 300L, + "projectId", 10L, + "taskCount", 5L, + "dueSoonCount", 0L, + "overdueCount", 2L + ); + Map taskRow300P20 = Map.of( + "userId", 300L, + "projectId", 20L, + "taskCount", 1L, + "dueSoonCount", 0L, + "overdueCount", 0L + ); + when(projectTaskMapper.selectInvolvedLoadStatsByUserIds(anyCollection(), anyCollection(), any(), any())) + .thenReturn(List.of(taskRow200P10, taskRow300P10, taskRow300P20)); + + // --- 个人事项聚合:只有用户 200(键名以被测类 personal.get(...) 实际读取键为准)--- + Map personalRow200 = Map.of( + "ownerId", 200L, + "itemCount", 1L, + "dueSoonCount", 0L, + "overdueCount", 1L + ); + when(personalItemMapper.selectLoadStatsByOwnerIds(anyCollection(), anyCollection(), any(), any())) + .thenReturn(List.of(personalRow200)); + + // --- 项目名批量回填 --- + ProjectDO project10 = new ProjectDO(); + project10.setId(10L); + project10.setProjectName("收银台 V3"); + ProjectDO project20 = new ProjectDO(); + project20.setId(20L); + project20.setProjectName("中台"); + when(projectMapper.selectBatchIds(anyCollection())).thenReturn(List.of(project10, project20)); + + // ===== 执行 ===== + TeamLoadRespVO resp = myTeamService.getTeamLoad(); + List members = resp.getMembers(); + + // --- 成员数量 --- + assertEquals(3, members.size()); + + // --- members[0]:自己,items 空 --- + assertEquals(100L, members.get(0).getUserId()); + assertEquals(0, members.get(0).getItems().size()); + + // --- members[1]:300(压力 8 > 200 的压力 5,排前)--- + assertEquals(300L, members.get(1).getUserId()); + List items300 = members.get(1).getItems(); + assertEquals(2, items300.size()); + // 项目行按 count 降序:项目 10(count=5) 在前,项目 20(count=1) 在后 + assertEquals("project", items300.get(0).getKind()); + assertEquals(10L, items300.get(0).getProjectId()); + assertEquals("收银台 V3", items300.get(0).getProjectName()); + assertEquals(5, items300.get(0).getCount()); + assertEquals("project", items300.get(1).getKind()); + assertEquals(20L, items300.get(1).getProjectId()); + assertEquals("中台", items300.get(1).getProjectName()); + assertEquals(1, items300.get(1).getCount()); + // 300 没有个人事项行 + assertEquals(0, members.get(1).getDueSoonCount()); + assertEquals(2, members.get(1).getOverdueCount()); + + // --- members[2]:200(压力 5)--- + assertEquals(200L, members.get(2).getUserId()); + List items200 = members.get(2).getItems(); + // 1 行 project + 1 行 personal(项目行降序后,personal 固定追加在末尾) + assertEquals(2, items200.size()); + assertEquals("project", items200.get(0).getKind()); + assertEquals(10L, items200.get(0).getProjectId()); + assertEquals("收银台 V3", items200.get(0).getProjectName()); + assertEquals(2, items200.get(0).getCount()); + assertEquals("personal", items200.get(1).getKind()); + assertNull(items200.get(1).getProjectId()); + assertNull(items200.get(1).getProjectName()); + assertEquals(1, items200.get(1).getCount()); + // dueSoon:任务行 1 + 个人 0 = 1;overdue:任务行 0 + 个人 1 = 1 + assertEquals(1, members.get(2).getDueSoonCount()); + assertEquals(1, members.get(2).getOverdueCount()); + } + } + +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/MyWorklogServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/MyWorklogServiceImplTest.java new file mode 100644 index 0000000..c30e349 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/MyWorklogServiceImplTest.java @@ -0,0 +1,105 @@ +package com.njcn.rdms.module.project.service.project; + +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.project.vo.workbench.MyWorklogWeekRespVO; +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.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 org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +class MyWorklogServiceImplTest extends BaseMockitoUnitTest { + + private static final LocalDate MONDAY = LocalDate.of(2026, 6, 8); + + @InjectMocks + private MyWorklogServiceImpl myWorklogService; + @Mock + private TaskWorklogMapper taskWorklogMapper; + @Mock + private ProjectTaskMapper projectTaskMapper; + @Mock + private PersonalItemMapper personalItemMapper; + @Mock + private ProjectMapper projectMapper; + @Mock + private MyTeamService myTeamService; + + private TaskWorklogDO worklog(Long taskId, LocalDate start, LocalDate end, String hours) { + TaskWorklogDO w = new TaskWorklogDO(); + w.setTaskId(taskId); + w.setUserId(100L); + w.setStartDate(start); + w.setEndDate(end); + w.setDurationHours(new BigDecimal(hours)); + return w; + } + + @Test + void myWorklogWeek_dailyAndDistribution_kindResolution() { + try (MockedStatic mock = mockStatic(SecurityFrameworkUtils.class)) { + mock.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(100L); + // 任务1(项目10):周一~周三 12h → 每天 4h;事项2(个人事项):周三单天 3h + when(taskWorklogMapper.selectListByUserIdAndPeriod(eq(100L), any(), any())).thenReturn(List.of( + worklog(1L, MONDAY, MONDAY.plusDays(2), "12"), + worklog(2L, MONDAY.plusDays(2), MONDAY.plusDays(2), "3"))); + ProjectTaskDO task = new ProjectTaskDO(); + task.setId(1L); + task.setProjectId(10L); + when(projectTaskMapper.selectBatchIds(anyCollection())).thenReturn(List.of(task)); + PersonalItemDO item = new PersonalItemDO(); + item.setId(2L); + when(personalItemMapper.selectBatchIds(anyCollection())).thenReturn(List.of(item)); + ProjectDO project = new ProjectDO(); + project.setId(10L); + project.setProjectName("收银台 V3"); + when(projectMapper.selectBatchIds(anyCollection())).thenReturn(List.of(project)); + + MyWorklogWeekRespVO resp = myWorklogService.getMyWorklogWeek(MONDAY); + assertEquals(MONDAY, resp.getWeekStart()); + assertEquals(5, resp.getDailyHours().size()); + assertEquals(0, new BigDecimal("4.00").compareTo(resp.getDailyHours().get(0))); // 周一 + assertEquals(0, new BigDecimal("7.00").compareTo(resp.getDailyHours().get(2))); // 周三 4+3 + assertEquals(0, new BigDecimal("0.00").compareTo(resp.getDailyHours().get(3))); // 周四 + assertEquals(2, resp.getDistribution().size()); + assertEquals("project", resp.getDistribution().get(0).getKind()); + assertEquals(0, new BigDecimal("12.00").compareTo(resp.getDistribution().get(0).getHours())); + assertEquals("personal", resp.getDistribution().get(1).getKind()); + assertEquals(0, new BigDecimal("3.00").compareTo(resp.getDistribution().get(1).getHours())); + } + } + + @Test + void teamWorklogWeek_memberWithoutWorklog_hasEmptyItems() { + when(myTeamService.resolveTeamMembers()).thenReturn(List.of( + new MyTeamService.TeamMember(100L, "我"), + new MyTeamService.TeamMember(200L, "张三"))); + when(taskWorklogMapper.selectListByUserIdsAndPeriod(anyCollection(), any(), any())) + .thenReturn(List.of()); + var resp = myWorklogService.getTeamWorklogWeek(MONDAY); + assertEquals(2, resp.getMembers().size()); + assertEquals(100L, resp.getMembers().get(0).getUserId()); + assertEquals(0, resp.getMembers().get(0).getItems().size()); + assertEquals(0, resp.getMembers().get(1).getItems().size()); + } + +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/MyWorklogWeekSupportTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/MyWorklogWeekSupportTest.java new file mode 100644 index 0000000..4ba27aa --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/MyWorklogWeekSupportTest.java @@ -0,0 +1,115 @@ +package com.njcn.rdms.module.project.service.project; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MyWorklogWeekSupportTest { + + /** 2026-06-08 是周一。 */ + private static final LocalDate MONDAY = LocalDate.of(2026, 6, 8); + + @Test + void normalizeToMonday_anyDayOfWeek() { + assertEquals(MONDAY, MyWorklogWeekSupport.normalizeToMonday(LocalDate.of(2026, 6, 10))); // 周三 + assertEquals(MONDAY, MyWorklogWeekSupport.normalizeToMonday(LocalDate.of(2026, 6, 14))); // 周日 + assertEquals(MONDAY, MyWorklogWeekSupport.normalizeToMonday(MONDAY)); + } + + @Test + void apportion_singleDay() { + // 单天段(周三 8h)→ 全额落周三 + Map r = MyWorklogWeekSupport.apportionToWeek( + LocalDate.of(2026, 6, 10), LocalDate.of(2026, 6, 10), new BigDecimal("8"), MONDAY); + assertEquals(1, r.size()); + assertEquals(0, new BigDecimal("8").compareTo(r.get(LocalDate.of(2026, 6, 10)))); + } + + @Test + void apportion_multiDayWithinWeek() { + // 周一~周三 12h → 每天 4h + Map r = MyWorklogWeekSupport.apportionToWeek( + MONDAY, LocalDate.of(2026, 6, 10), new BigDecimal("12"), MONDAY); + assertEquals(3, r.size()); + assertEquals(0, new BigDecimal("4").compareTo(r.get(MONDAY))); + assertEquals(0, new BigDecimal("4").compareTo(r.get(LocalDate.of(2026, 6, 10)))); + } + + @Test + void apportion_spanningWeekend_workdaysOnly() { + // 周五(6-12)~下周一(6-15) 10h:工作日只有周五+下周一,各 5h;本周(6-8 起)只收周五份额 + Map r = MyWorklogWeekSupport.apportionToWeek( + LocalDate.of(2026, 6, 12), LocalDate.of(2026, 6, 15), new BigDecimal("10"), MONDAY); + assertEquals(1, r.size()); + assertEquals(0, new BigDecimal("5").compareTo(r.get(LocalDate.of(2026, 6, 12)))); + } + + @Test + void apportion_crossWeekSegment_nextWeekView() { + // 同一段(6-12 ~ 6-15)从下周视角(weekMonday=6-15)看:只收下周一 5h + LocalDate nextMonday = LocalDate.of(2026, 6, 15); + Map r = MyWorklogWeekSupport.apportionToWeek( + LocalDate.of(2026, 6, 12), nextMonday, new BigDecimal("10"), nextMonday); + assertEquals(1, r.size()); + assertEquals(0, new BigDecimal("5").compareTo(r.get(nextMonday))); + } + + @Test + void apportion_pureWeekendSegment_fallbackToFriday() { + // 纯周末段(周六 6-13 ~ 周日 6-14)3h → 全额兜底归周五 6-12 + Map r = MyWorklogWeekSupport.apportionToWeek( + LocalDate.of(2026, 6, 13), LocalDate.of(2026, 6, 14), new BigDecimal("3"), MONDAY); + assertEquals(1, r.size()); + assertEquals(0, new BigDecimal("3").compareTo(r.get(LocalDate.of(2026, 6, 12)))); + } + + @Test + void apportion_segmentOutsideWeek_empty() { + // 上上周的段,与所选周无交集 → 空 + Map r = MyWorklogWeekSupport.apportionToWeek( + LocalDate.of(2026, 5, 25), LocalDate.of(2026, 5, 27), new BigDecimal("8"), MONDAY); + assertTrue(r.isEmpty()); + } + + @Test + void apportion_roundingScale4() { + // 10h 摊 3 个工作日 → 每天 3.3333(scale=4) + Map r = MyWorklogWeekSupport.apportionToWeek( + MONDAY, LocalDate.of(2026, 6, 10), new BigDecimal("10"), MONDAY); + assertEquals(0, new BigDecimal("3.3333").compareTo(r.get(MONDAY))); + } + + @Test + void apportion_pureWeekendSegment_nextWeekView_empty() { + // 纯周末段(6-13 周六 ~ 6-14 周日)从下一周视角(weekMonday=6-15)查: + // 兜底周五 6-12 不在 [6-15, 6-19] 范围内 → 返回空 map + LocalDate nextMonday = LocalDate.of(2026, 6, 15); + Map r = MyWorklogWeekSupport.apportionToWeek( + LocalDate.of(2026, 6, 13), LocalDate.of(2026, 6, 14), new BigDecimal("3"), nextMonday); + assertTrue(r.isEmpty()); + } + + @Test + void apportion_singleWeekendDay_fallbackToFriday() { + // 单个周末日段(segStart==segEnd==6-13 周六,2h,weekMonday=6-8)→ 全额归周五 6-12 + Map r = MyWorklogWeekSupport.apportionToWeek( + LocalDate.of(2026, 6, 13), LocalDate.of(2026, 6, 13), new BigDecimal("2"), MONDAY); + assertEquals(1, r.size()); + assertEquals(0, new BigDecimal("2").compareTo(r.get(LocalDate.of(2026, 6, 12)))); + } + + @Test + void apportion_invalidParams_empty() { + // null hours → 空 map + assertTrue(MyWorklogWeekSupport.apportionToWeek(MONDAY, MONDAY, null, MONDAY).isEmpty()); + // segStart 晚于 segEnd → 空 map + assertTrue(MyWorklogWeekSupport.apportionToWeek( + LocalDate.of(2026, 6, 10), LocalDate.of(2026, 6, 9), new BigDecimal("8"), MONDAY).isEmpty()); + } + +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/MyTaskServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/MyTaskServiceImplTest.java new file mode 100644 index 0000000..498cdf8 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/MyTaskServiceImplTest.java @@ -0,0 +1,143 @@ +package com.njcn.rdms.module.project.service.project.task; + +import com.njcn.rdms.framework.common.pojo.PageResult; +import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; +import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskLifecycleActionRespVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.mytask.MyTaskPageReqVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.mytask.MyTaskRespVO; +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.mysql.project.ProjectMapper; +import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper; +import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper; +import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +import static java.util.Collections.emptyList; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +class MyTaskServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private MyTaskServiceImpl myTaskService; + @Mock + private ProjectTaskMapper projectTaskMapper; + @Mock + private ProjectMapper projectMapper; + @Mock + private ProjectExecutionMapper projectExecutionMapper; + @Mock + private ObjectStatusModelMapper objectStatusModelMapper; + @Mock + private ProjectTaskStatusViewService projectTaskStatusViewService; + + private ProjectTaskDO task(Long id, Long ownerId, Long projectId) { + ProjectTaskDO t = new ProjectTaskDO(); + t.setId(id); + t.setOwnerId(ownerId); + t.setProjectId(projectId); + t.setStatusCode("active"); + return t; + } + + private ProjectTaskLifecycleActionRespVO action(String actionCode, String actionName, Boolean needReason) { + ProjectTaskLifecycleActionRespVO action = new ProjectTaskLifecycleActionRespVO(); + action.setActionCode(actionCode); + action.setActionName(actionName); + action.setNeedReason(needReason); + return action; + } + + @Test + void emptyWhenNoTasks() { + try (MockedStatic mock = mockStatic(SecurityFrameworkUtils.class)) { + mock.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(100L); + when(objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled(anyString())) + .thenReturn(List.of("completed", "cancelled")); + when(projectTaskMapper.selectMyUnfinishedInvolvedList(eq(100L), any(), anyCollection())) + .thenReturn(emptyList()); + PageResult result = myTaskService.getMyTaskPage(new MyTaskPageReqVO()); + assertEquals(0L, result.getTotal()); + } + } + + @Test + void myRole_ownerWinsOverCollaborator() { + try (MockedStatic mock = mockStatic(SecurityFrameworkUtils.class)) { + mock.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(100L); + when(objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled(anyString())) + .thenReturn(List.of("completed", "cancelled")); + // 任务1:我是 owner;任务2:owner 是别人(我只能是协办,SQL 才会返回它) + when(projectTaskMapper.selectMyUnfinishedInvolvedList(eq(100L), any(), anyCollection())) + .thenReturn(List.of(task(1L, 100L, 10L), task(2L, 200L, 10L))); + when(projectTaskStatusViewService.getLifecycleMap(any())).thenReturn(Map.of()); + ProjectDO project = new ProjectDO(); + project.setId(10L); + project.setProjectName("收银台 V3"); + when(projectMapper.selectBatchIds(anyCollection())).thenReturn(List.of(project)); + PageResult result = myTaskService.getMyTaskPage(new MyTaskPageReqVO()); + assertEquals(2L, result.getTotal()); + assertEquals("owner", result.getList().get(0).getMyRole()); + assertEquals("collaborator", result.getList().get(1).getMyRole()); + assertEquals("收银台 V3", result.getList().get(0).getProjectName()); + } + } + + @Test + void getMyTaskPage_shouldReturnProgressLifecycleAndParentTaskFields() { + try (MockedStatic mock = mockStatic(SecurityFrameworkUtils.class)) { + mock.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(100L); + when(objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled(anyString())) + .thenReturn(List.of("completed", "cancelled")); + + ProjectTaskDO ownerTask = task(1L, 100L, 10L); + ownerTask.setProgressRate(new BigDecimal("100.00")); + ProjectTaskDO collaboratorTask = task(2L, 200L, 10L); + collaboratorTask.setParentTaskId(1L); + collaboratorTask.setProgressRate(null); + when(projectTaskMapper.selectMyUnfinishedInvolvedList(eq(100L), any(), anyCollection())) + .thenReturn(List.of(ownerTask, collaboratorTask)); + when(projectTaskStatusViewService.getLifecycleMap(any())).thenReturn(Map.of( + 1L, new ProjectTaskStatusViewService.ProjectTaskLifecycleView("进行中", false, true, + List.of(action("pause", "暂停", true), action("complete", "完成", false))), + 2L, new ProjectTaskStatusViewService.ProjectTaskLifecycleView("进行中", false, true, + emptyList()))); + + PageResult result = myTaskService.getMyTaskPage(new MyTaskPageReqVO()); + + MyTaskRespVO owner = result.getList().get(0); + assertEquals(new BigDecimal("100.00"), owner.getProgressRate()); + assertFalse(owner.getTerminal()); + assertEquals(true, owner.getAllowEdit()); + assertNull(owner.getParentTaskId()); + assertEquals(List.of("pause", "complete"), owner.getAvailableActions().stream() + .map(action -> action.getActionCode()) + .toList()); + assertEquals(List.of(true, false), owner.getAvailableActions().stream() + .map(action -> action.getNeedReason()) + .toList()); + + MyTaskRespVO collaborator = result.getList().get(1); + assertEquals(BigDecimal.ZERO, collaborator.getProgressRate()); + assertEquals(1L, collaborator.getParentTaskId()); + assertEquals(emptyList(), collaborator.getAvailableActions()); + } + } + +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskStatusViewServiceTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskStatusViewServiceTest.java index bd56c4d..efe8955 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskStatusViewServiceTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskStatusViewServiceTest.java @@ -4,6 +4,7 @@ import com.njcn.rdms.framework.common.exception.ServiceException; import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskLifecycleActionRespVO; +import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO; 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; @@ -16,12 +17,15 @@ import org.mockito.MockedStatic; import java.math.BigDecimal; import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest { @@ -146,6 +150,38 @@ class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest { } } + @Test + void getLifecycleMap_shouldBatchLoadTransitionsAndApplyActionFilters() { + Long ownerId = 3001L; + Long collaboratorOwnerId = 3002L; + ProjectTaskDO ownerTask = createTask(1L, ownerId, "active", new BigDecimal("100.00")); + ProjectTaskDO collaboratorTask = createTask(2L, collaboratorOwnerId, "active", new BigDecimal("100.00")); + ObjectStatusModelDO statusModel = createStatusModel(); + ObjectStatusTransitionDO pause = createTransition("active", "pause", "暂停"); + ObjectStatusTransitionDO complete = createTransition("active", "complete", "完成"); + ObjectStatusTransitionDO autoStart = createTransition("active", "auto_start", "自动开始"); + when(objectStatusModelMapper.selectListByObjectTypeEnabled("task")).thenReturn(List.of(statusModel)); + when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatuses("task", List.of("active"))) + .thenReturn(List.of(pause, complete, autoStart)); + + try (MockedStatic mocked = mockStatic(SecurityFrameworkUtils.class)) { + mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId); + + Map map = + projectTaskStatusViewService.getLifecycleMap(List.of(ownerTask, collaboratorTask)); + + assertEquals("进行中", map.get(1L).statusName()); + assertFalse(map.get(1L).terminal()); + assertTrue(map.get(1L).allowEdit()); + assertEquals(List.of("pause", "complete"), map.get(1L).availableActions().stream() + .map(ProjectTaskLifecycleActionRespVO::getActionCode) + .toList()); + assertTrue(map.get(2L).availableActions().isEmpty()); + verify(objectStatusTransitionMapper).selectListByObjectTypeAndFromStatuses("task", List.of("active")); + verify(objectStatusTransitionMapper, never()).selectListByObjectTypeAndFromStatus("task", "active"); + } + } + @Test void getLifecycle_whenStatusModelMissing_shouldThrow() { when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "missing")).thenReturn(null); @@ -173,4 +209,19 @@ class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest { return transition; } + private ObjectStatusTransitionDO createTransition(String fromStatusCode, String actionCode, String actionName) { + ObjectStatusTransitionDO transition = createTransition(actionCode, actionName); + transition.setFromStatusCode(fromStatusCode); + return transition; + } + + private ProjectTaskDO createTask(Long id, Long ownerId, String statusCode, BigDecimal progressRate) { + ProjectTaskDO task = new ProjectTaskDO(); + task.setId(id); + task.setOwnerId(ownerId); + task.setStatusCode(statusCode); + task.setProgressRate(progressRate); + return task; + } + } diff --git a/项目列表产品分组-后端接口诉求-2026-06-10.html b/项目列表产品分组-后端接口诉求-2026-06-10.html deleted file mode 100644 index 7a6e3e8..0000000 --- a/项目列表产品分组-后端接口诉求-2026-06-10.html +++ /dev/null @@ -1,266 +0,0 @@ - - - - - -项目列表按产品分组:后端待确认事项与接口诉求 - - - -
- -
-

项目列表按产品分组:后端待确认事项与接口诉求

-
2026-06-10 · 前端发起 · 涉及模块:project/project(项目)、system/dict(字典)
-
- -
-

0. 背景

-
-

项目列表页将从"平铺分页"改为"按所属产品聚合展示":同一产品下的项目(基线/合同/技术支持等类型)需要聚拢在一起查看,不能被分页切散。现有 GET /project/project/page 平铺分页无法支撑该口径,需要后端补充以下能力。

-

前端已按本文档第 2~4 节的契约先行开发(过渡期用现有平铺接口在前端聚合模拟),后端接口就绪后联调切换,不阻塞后端排期,但请反馈预计时间点。

-
-
- -
-

1. 待查询确认:rdms_project_type 字典数据 请尽快回复,阻塞前端一处常量

-
-

请提供字典 rdms_project_type(项目类型)的完整字典数据清单(每项的 value / label / 启用状态),特别是:

-
    -
  • "基线"类型对应的字典 value 是什么(前端需写入常量,用于"建立基线"入口预填与缺基线判定);
  • -
  • 除 基线 / 合同 / 技术支持 外是否还有其他在用类型值。
  • -
-

补充确认(口头已确认,烦请最终对齐):"基线项目每产品唯一"由后端在创建/更新项目时做唯一性校验兜底,前端仅做体验层提示。

-
-
- -
-

2. 接口诉求一:项目按产品分组分页查询(新增) 核心依赖

- -

2.1 建议路径

-
-

GET /project/project/group-page(路径可由后端按规范调整,前端只在 service 层适配一处)

-

数据权限口径与现有 /project/project/page 一致:只返回当前用户可见的项目,按其归属产品聚合。

-
- -

2.2 入参

- - - - - - - - - -
参数类型必填说明
pageNo / pageSizeint产品组维度分页:一页返回 pageSize 个产品组(含组内项目),不是项目行分页。前端当前 pageSize=10。
keywordstring项目编码 / 名称模糊匹配(口径同现有 page 接口)。
productIdstring限定单个产品。
projectTypestring项目类型字典 value。
statusCodestring单状态过滤。不传 = "全部"口径:pending / active / paused / completed 四种状态,不含 cancelled / archived。
orphanOnlybooleantrue = 仅返回"游离组"(productId 为空的项目聚合为一个特殊组)。
topNint每组返回的项目条数上限,建议默认 5(前端当前固定取 5)。
- -

2.3 返回结构(示例)

-
{
-  "total": 12,            // 当前筛选口径下产品组总数(分页 total,含游离组)
-  "projectTotal": 47,     // 当前筛选口径下项目总数
-  "directionCount": 3,    // 可见产品去重后的方向(directionCode)数
-  "orphanTotal": 3,       // 游离项目数(productId 为空)
-  "list": [
-    {
-      "productId": "1923456789012345678",   // 字符串!见 4.1
-      "productName": "智能网关",
-      "productCode": "RDMS-P-001",
-      "directionCode": "platform",
-      "managerUserId": "1001",
-      "managerUserNickname": "张三",
-      "projectTotal": 6,                    // 当前筛选口径下该组项目总数
-      "projects": [ /* 前 topN 条,字段同现有 page 接口的项目行 */ ],
-      "typeCounts": { "baseline值": 1, "合同值": 3, "技术支持值": 2 },
-      "hasBaseline": true,
-      "orphan": false
-    },
-    {
-      "productId": null,                    // 游离组:未挂产品的项目聚合
-      "productName": "游离项目",
-      "productCode": null,
-      "directionCode": "",
-      "managerUserId": null,
-      "managerUserNickname": null,
-      "projectTotal": 3,
-      "projects": [ /* ... */ ],
-      "typeCounts": { "合同值": 2, "技术支持值": 1 },
-      "hasBaseline": false,
-      "orphan": true
-    }
-  ]
-}
- -

2.4 口径约定(关键,请逐条确认)

- - - - - - - - -
#约定说明
1组内项目排序updateTime 倒序,返回前 topN 条;projectTotal 为该口径下组内全量计数。
2无命中组不返回传了 statusCode / keyword / projectType 任一筛选时,组内无命中项目的产品组不返回。
3零项目产品组"全部"口径(statusCode 缺省)且无 keyword / projectType 筛选时,返回当前用户可见、状态为 active / paused 的零项目产品组(projectTotal=0、projects=[]),用于"该产品暂无项目"占位与新增引导。
4typeCounts / hasBaseline 统计口径这两个字段是"产品属性",恒按"全部"口径(四种状态)统计,不随 statusCode 入参变化——避免"基线项目已完成时被误判为缺基线"。
5游离组位置游离组(productId 为空)作为列表中最后一个组返回;状态筛选下同样适用约定 2(无命中则不返回该组)。
6组的排序同方向(directionCode)的产品组相邻返回(方向间顺序、方向内产品顺序由后端按现有产品列表默认排序即可)。
- -

2.5 组内"展开剩余"的数据来源

-
-

不需要新接口:前端用现有 GET /project/project/pageproductId + statusCode 拉取该组剩余项目。前提是该接口的排序与本接口组内排序一致(updateTime 倒序)——若现有 page 接口默认排序不是 updateTime 倒序,请告知或支持排序参数。

-
-
- -
-

3. 接口诉求二、三:存量接口小改 两处补充

- -

3.1 平铺分页支持"未挂产品"筛选

-
-

GET /project/project/page 需要能表达 productId 为空的筛选语义(如新增 orphanOnly=true 参数,或约定 productId 传特殊值),用于查询"游离项目"(未关联任何产品的项目)。具体参数形式由后端定,前端适配。

-
- -

3.2 概览统计补游离计数

-
-

GET /project/project/overview-summary 返回体增加:

-
{
-  "statusCounts": { "active": 10, "pending": 2, ... },  // 现有
-  "orphanCount": 3                                       // 新增:未挂产品的项目数(按"全部"口径:四种状态)
-}
-
-
- -
-

4. 公共约定

- - - - - -
#约定说明
4.1ID 一律字符串 必须所有 Long / 雪花 ID(productId、managerUserId、项目 id 等)在 JSON 中按字符串返回。Long 直接作为 JSON 数字返回会在前端丢精度。
4.2"全部"状态集合pending / active / paused / completed;cancelled / archived 仅在显式传对应 statusCode 时返回。
4.3项目行字段分组接口 projects[] 内的项目对象字段与现有 /project/project/page 返回行保持一致(含 productId / productName / managerUserNickname / progressRate / statusCode / updateTime 等),避免前端双口径。
-
- -
-

5. 需要后端反馈的清单

-
-
    -
  1. 第 1 节rdms_project_type 字典数据清单 + "基线"的 value(最优先,一条查询即可,阻塞前端一处常量)。
  2. -
  3. 第 2 节:分组分页接口的可行性确认 + 2.4 六条口径逐条确认(有异议请直接批注替代方案)+ 排期。
  4. -
  5. 第 3 节:page 接口游离筛选的参数形式 + overview-summary 补 orphanCount 的排期。
  6. -
  7. 第 2.5 节:现有 page 接口的默认排序是什么;是否支持/计划支持按 updateTime 倒序。
  8. -
-
-
- -
- -