feat(project): 新增工作台我的任务功能并优化团队负载统计

- 新增 MyTaskController 提供跨项目的我的任务分页查询接口
- 实现个人事项和任务的团队负载统计功能,支持临期/逾期计数
- 优化任务状态视图服务,支持批量加载生命周期视图
- 新增多用户工时周聚合查询功能
- 完善相关 VO 类定义和数据库映射配置
- 添加单元测试验证批量加载和权限过滤逻辑
This commit is contained in:
2026-06-12 19:50:02 +08:00
parent 54bcf7d8ae
commit 41fe5aa5ca
24 changed files with 1700 additions and 267 deletions

View File

@@ -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<TeamLoadRespVO> getTeamLoad() {
return success(myTeamService.getTeamLoad());
}
@GetMapping("/worklog-week")
@Operation(summary = "我的工时周聚合(逐日为均摊推算值;weekStart 任意日期自动归一到周一)")
public CommonResult<MyWorklogWeekRespVO> 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<TeamWorklogWeekRespVO> getTeamWorklogWeek(
@RequestParam("weekStart")
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) @NotNull(message = "weekStart 不能为空") LocalDate weekStart) {
return success(myWorklogService.getTeamWorklogWeek(weekStart));
}
}

View File

@@ -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<BigDecimal> dailyHours;
@Schema(description = "本周工时按归属分布(hours 降序,personal/other 排在项目后)", requiredMode = Schema.RequiredMode.REQUIRED)
private List<DistributionItemVO> 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;
}
}

View File

@@ -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<MemberVO> 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<LoadItemVO> 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;
}
}

View File

@@ -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<MemberVO> 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<MyWorklogWeekRespVO.DistributionItemVO> items;
}
}

View File

@@ -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<PageResult<MyTaskRespVO>> 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));
}
}

View File

@@ -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;
}

View File

@@ -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<ProjectTaskLifecycleActionRespVO> availableActions;
}

View File

@@ -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<PersonalItemDO> {
@@ -77,4 +81,37 @@ public interface PersonalItemMapper extends BaseMapperX<PersonalItemDO> {
queryWrapper.orderByAsc(PersonalItemDO::getId);
return selectList(queryWrapper);
}
/**
* 团队负载:一批成员的非终态个人事项计数(按 owner_id 分组),带临期/逾期。
* 个人事项状态机复用 task 对象类型,terminalStatusCodes 与任务同源。
* 返回 Map:ownerId / itemCount / dueSoonCount / overdueCount(均 Long)。
*/
@Select("""
<script>
SELECT owner_id AS ownerId,
CAST(COUNT(*) AS SIGNED) AS itemCount,
CAST(SUM(CASE WHEN planned_end_date IS NOT NULL
AND planned_end_date &gt;= #{today}
AND planned_end_date &lt;= #{dueSoonEnd}
THEN 1 ELSE 0 END) AS SIGNED) AS dueSoonCount,
CAST(SUM(CASE WHEN planned_end_date IS NOT NULL
AND planned_end_date &lt; #{today}
THEN 1 ELSE 0 END) AS SIGNED) AS overdueCount
FROM rdms_personal_item
WHERE deleted = b'0'
AND owner_id IN
<foreach collection="ownerIds" item="uid" open="(" separator="," close=")">#{uid}</foreach>
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
GROUP BY owner_id
</script>
""")
List<Map<String, Object>> selectLoadStatsByOwnerIds(
@Param("ownerIds") Collection<Long> ownerIds,
@Param("terminalStatusCodes") Collection<String> terminalStatusCodes,
@Param("today") LocalDate today,
@Param("dueSoonEnd") LocalDate dueSoonEnd);
}

View File

@@ -859,4 +859,107 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
List<ProjectTaskDO> selectInvolvedListByUserIdAndStatusNot(@Param("userId") Long userId,
@Param("excludedStatusCode") String excludedStatusCode);
/**
* 工作台「我的任务」:当前用户为负责人或在岗协办人的非终态任务(跨项目)。
* involveTypeowner=仅我负责 / collaborator=仅我协办(排除我同时是负责人的)/ 其他或 null=并集。
* 排序计划结束升序、空值最后同日按创建时间升序id 兜底。
*/
@Select("""
<script>
SELECT t.*
FROM rdms_task t
<where>
t.deleted = b'0'
<choose>
<when test="involveType == 'owner'">
AND t.owner_id = #{userId}
</when>
<when test="involveType == 'collaborator'">
AND (t.owner_id IS NULL OR t.owner_id != #{userId})
AND EXISTS (
SELECT 1 FROM rdms_task_assignee a
WHERE a.task_id = t.id
AND a.user_id = #{userId}
AND a.removed_at IS NULL
AND a.deleted = b'0'
)
</when>
<otherwise>
AND (
t.owner_id = #{userId}
OR EXISTS (
SELECT 1 FROM rdms_task_assignee a
WHERE a.task_id = t.id
AND a.user_id = #{userId}
AND a.removed_at IS NULL
AND a.deleted = b'0'
)
)
</otherwise>
</choose>
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND t.status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
</where>
ORDER BY (t.planned_end_date IS NULL) ASC, t.planned_end_date ASC, t.create_time ASC, t.id ASC
</script>
""")
List<ProjectTaskDO> selectMyUnfinishedInvolvedList(@Param("userId") Long userId,
@Param("involveType") String involveType,
@Param("terminalStatusCodes") Collection<String> terminalStatusCodes);
/**
* 团队负载:一批成员的非终态任务计数,按 (成员, 项目) 分组,一次扫表带出临期/逾期。
* 身份口径与「我的任务」一致(负责人 在岗协办);UNION ALL 两个视角,
* 协办分支排除"自己同时是负责人"的行防双计(owner_id 为 NULL 时保留)。
* 临期:today <= planned_end_date <= dueSoonEnd;逾期:planned_end_date < today。
* 返回 Map:userId / projectId / taskCount / dueSoonCount / overdueCount(均 Long)。
*/
@Select("""
<script>
SELECT x.user_id AS userId,
x.project_id AS projectId,
CAST(COUNT(*) AS SIGNED) AS taskCount,
CAST(SUM(CASE WHEN x.planned_end_date IS NOT NULL
AND x.planned_end_date &gt;= #{today}
AND x.planned_end_date &lt;= #{dueSoonEnd}
THEN 1 ELSE 0 END) AS SIGNED) AS dueSoonCount,
CAST(SUM(CASE WHEN x.planned_end_date IS NOT NULL
AND x.planned_end_date &lt; #{today}
THEN 1 ELSE 0 END) AS SIGNED) AS overdueCount
FROM (
SELECT t.owner_id AS user_id, t.project_id, t.planned_end_date
FROM rdms_task t
WHERE t.deleted = b'0'
AND t.owner_id IN
<foreach collection="userIds" item="uid" open="(" separator="," close=")">#{uid}</foreach>
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND t.status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
UNION ALL
SELECT a.user_id, t.project_id, t.planned_end_date
FROM rdms_task t
JOIN rdms_task_assignee a ON a.task_id = t.id
AND a.removed_at IS NULL
AND a.deleted = b'0'
WHERE t.deleted = b'0'
AND a.user_id IN
<foreach collection="userIds" item="uid" open="(" separator="," close=")">#{uid}</foreach>
AND (t.owner_id IS NULL OR t.owner_id != a.user_id)
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND t.status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
) x
GROUP BY x.user_id, x.project_id
</script>
""")
List<Map<String, Object>> selectInvolvedLoadStatsByUserIds(
@Param("userIds") Collection<Long> userIds,
@Param("terminalStatusCodes") Collection<String> terminalStatusCodes,
@Param("today") LocalDate today,
@Param("dueSoonEnd") LocalDate dueSoonEnd);
}

View File

@@ -214,4 +214,19 @@ public interface TaskWorklogMapper extends BaseMapperX<TaskWorklogDO> {
.orderByAsc(TaskWorklogDO::getId));
}
/** selectListByUserIdAndPeriod 的多用户版,团队工时周聚合用。段相交语义相同。 */
default List<TaskWorklogDO> selectListByUserIdsAndPeriod(Collection<Long> userIds,
LocalDate startDate, LocalDate endDate) {
if (userIds == null || userIds.isEmpty() || startDate == null || endDate == null) {
return List.of();
}
return selectList(new LambdaQueryWrapperX<TaskWorklogDO>()
.in(TaskWorklogDO::getUserId, userIds)
.le(TaskWorklogDO::getStartDate, endDate)
.ge(TaskWorklogDO::getEndDate, startDate)
.orderByAsc(TaskWorklogDO::getUserId)
.orderByAsc(TaskWorklogDO::getEndDate)
.orderByAsc(TaskWorklogDO::getId));
}
}

View File

@@ -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<TeamMember> resolveTeamMembers();
/** 工作台「团队负载」:每个团队成员的未完成任务/个人事项分布 + 临期/逾期。 */
TeamLoadRespVO getTeamLoad();
/** 团队成员(userId + 昵称)。 */
record TeamMember(Long userId, String nickname) {
}
}

View File

@@ -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<TeamMember> resolveTeamMembers() {
Long me = SecurityFrameworkUtils.getLoginUserId();
// 管理链路 RPC 不过滤生效期(system 侧直接按 manager 查),这里按 effectiveFrom/Until 过滤当前生效
List<UserManagementRelationRespDTO> relations = userManagementRelationApi
.getRelationListByManagerUserId(me).getCheckedData();
LocalDateTime now = LocalDateTime.now();
LinkedHashSet<Long> 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<Long, AdminUserRespDTO> 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<TeamMember> members = resolveTeamMembers();
List<Long> userIds = members.stream().map(TeamMember::userId).collect(Collectors.toList());
LocalDate today = LocalDate.now();
LocalDate dueSoonEnd = today.plusDays(DUE_SOON_DAYS);
// 个人事项状态机复用 task 对象类型,终态同源
List<String> terminal = objectStatusModelMapper
.selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
// 任务:按 (成员, 项目) 分组;个人事项:按成员分组。各一次扫表,无逐人查询
List<Map<String, Object>> taskRows = projectTaskMapper
.selectInvolvedLoadStatsByUserIds(userIds, terminal, today, dueSoonEnd);
List<Map<String, Object>> personalRows = personalItemMapper
.selectLoadStatsByOwnerIds(userIds, terminal, today, dueSoonEnd);
// 项目名批量回填
Set<Long> projectIds = taskRows.stream().map(r -> asLong(r.get("projectId")))
.filter(Objects::nonNull).collect(Collectors.toSet());
Map<Long, ProjectDO> projectMap = projectIds.isEmpty() ? Collections.emptyMap()
: projectMapper.selectBatchIds(projectIds).stream()
.collect(Collectors.toMap(ProjectDO::getId, Function.identity(), (a, b) -> a));
// 任务行按成员分组
Map<Long, List<Map<String, Object>>> taskRowsByUser = taskRows.stream()
.collect(Collectors.groupingBy(r -> asLong(r.get("userId")), LinkedHashMap::new, Collectors.toList()));
Map<Long, Map<String, Object>> personalRowByUser = personalRows.stream()
.collect(Collectors.toMap(r -> asLong(r.get("ownerId")), Function.identity(), (a, b) -> a));
// 组装每个成员
List<TeamLoadRespVO.MemberVO> memberVOs = members.stream().map(m -> {
TeamLoadRespVO.MemberVO vo = new TeamLoadRespVO.MemberVO();
vo.setUserId(m.userId());
vo.setUserNickname(m.nickname());
List<TeamLoadRespVO.LoadItemVO> items = new ArrayList<>();
long dueSoon = 0;
long overdue = 0;
for (Map<String, Object> 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<String, Object> 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<TeamLoadRespVO.MemberVO> rest = new ArrayList<>(memberVOs.subList(1, memberVOs.size()));
rest.sort(Comparator.comparingLong(MyTeamServiceImpl::pressure).reversed());
List<TeamLoadRespVO.MemberVO> 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();
}
}

View File

@@ -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);
}

View File

@@ -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<TaskWorklogDO> worklogs = taskWorklogMapper
.selectListByUserIdAndPeriod(me, monday, monday.plusDays(6));
// 逐日累计(scale=4)与每任务份额累计
Map<LocalDate, BigDecimal> daily = new LinkedHashMap<>();
Map<Long, BigDecimal> hoursByTask = new HashMap<>();
for (TaskWorklogDO w : worklogs) {
Map<LocalDate, BigDecimal> shares = MyWorklogWeekSupport
.apportionToWeek(w.getStartDate(), w.getEndDate(), w.getDurationHours(), monday);
for (Map.Entry<LocalDate, BigDecimal> e : shares.entrySet()) {
daily.merge(e.getKey(), e.getValue(), BigDecimal::add);
hoursByTask.merge(w.getTaskId(), e.getValue(), BigDecimal::add);
}
}
MyWorklogWeekRespVO resp = new MyWorklogWeekRespVO();
resp.setWeekStart(monday);
List<BigDecimal> 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<MyTeamService.TeamMember> members = myTeamService.resolveTeamMembers();
List<Long> userIds = members.stream().map(MyTeamService.TeamMember::userId).collect(Collectors.toList());
List<TaskWorklogDO> worklogs = taskWorklogMapper
.selectListByUserIdsAndPeriod(userIds, monday, monday.plusDays(6));
// 每成员每任务的本周份额合计
Map<Long, Map<Long, BigDecimal>> 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<MyWorklogWeekRespVO.DistributionItemVO> buildDistribution(Map<Long, BigDecimal> hoursByTask) {
if (hoursByTask.isEmpty()) {
return Collections.emptyList();
}
Set<Long> taskIds = hoursByTask.keySet();
Map<Long, ProjectTaskDO> taskMap = projectTaskMapper.selectBatchIds(taskIds).stream()
.collect(Collectors.toMap(ProjectTaskDO::getId, Function.identity(), (a, b) -> a));
Set<Long> personalIds = taskIds.stream()
.filter(id -> !taskMap.containsKey(id)).collect(Collectors.toSet());
Set<Long> personalHit = personalIds.isEmpty() ? Collections.emptySet()
: personalItemMapper.selectBatchIds(personalIds).stream()
.map(PersonalItemDO::getId).collect(Collectors.toSet());
// 聚合:projectId→hours / personal / other
Map<Long, BigDecimal> hoursByProject = new LinkedHashMap<>();
BigDecimal personalHours = BigDecimal.ZERO;
BigDecimal otherHours = BigDecimal.ZERO;
for (Map.Entry<Long, BigDecimal> e : hoursByTask.entrySet()) {
ProjectTaskDO task = taskMap.get(e.getKey());
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<Long, ProjectDO> projectMap = hoursByProject.isEmpty() ? Collections.emptyMap()
: projectMapper.selectBatchIds(hoursByProject.keySet()).stream()
.collect(Collectors.toMap(ProjectDO::getId, Function.identity(), (a, b) -> a));
List<MyWorklogWeekRespVO.DistributionItemVO> items = new ArrayList<>();
hoursByProject.entrySet().stream()
.sorted(Map.Entry.comparingByValue(Comparator.reverseOrder()))
.forEach(e -> {
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;
}
}

View File

@@ -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。
*
* <p>前置条件:weekMonday 必须已归一到 ISO 周的周一(调用方先调 {@link #normalizeToMonday})。
* 方法内不做防御归一,以便在调用方传入非周一时尽早暴露 bug。
*/
static Map<LocalDate, BigDecimal> apportionToWeek(LocalDate segStart, LocalDate segEnd,
BigDecimal hours, LocalDate weekMonday) {
Map<LocalDate, BigDecimal> result = new LinkedHashMap<>();
if (segStart == null || segEnd == null || hours == null || segStart.isAfter(segEnd)) {
return result;
}
LocalDate weekFriday = weekMonday.plusDays(4);
List<LocalDate> workdays = segStart.datesUntil(segEnd.plusDays(1))
.filter(d -> d.getDayOfWeek().getValue() <= 5)
.toList();
if (workdays.isEmpty()) {
// 纯周末段:全额归段结束日前最近的工作日(周六/周日 → 周五)
LocalDate fallback = segEnd;
while (fallback.getDayOfWeek().getValue() > 5) {
fallback = fallback.minusDays(1);
}
if (!fallback.isBefore(weekMonday) && !fallback.isAfter(weekFriday)) {
result.put(fallback, hours.setScale(4, RoundingMode.HALF_UP));
}
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;
}
}

View File

@@ -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<MyTaskRespVO> getMyTaskPage(MyTaskPageReqVO reqVO);
}

View File

@@ -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<MyTaskRespVO> getMyTaskPage(MyTaskPageReqVO reqVO) {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
// 终态运行时推导SQL 内对空集有 <if> 守卫
List<String> taskTerminal = objectStatusModelMapper
.selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
List<ProjectTaskDO> tasks = projectTaskMapper
.selectMyUnfinishedInvolvedList(loginUserId, reqVO.getInvolveType(), taskTerminal);
if (tasks.isEmpty()) {
return new PageResult<>(Collections.emptyList(), 0L);
}
// 项目名 / 执行名批量回填
Set<Long> projectIds = tasks.stream().map(ProjectTaskDO::getProjectId)
.filter(Objects::nonNull).collect(Collectors.toSet());
Map<Long, ProjectDO> projectMap = projectIds.isEmpty() ? Collections.emptyMap()
: projectMapper.selectBatchIds(projectIds).stream()
.collect(Collectors.toMap(ProjectDO::getId, Function.identity(), (a, b) -> a));
Set<Long> executionIds = tasks.stream().map(ProjectTaskDO::getExecutionId)
.filter(Objects::nonNull).collect(Collectors.toSet());
Map<Long, ProjectExecutionDO> executionMap = executionIds.isEmpty() ? Collections.emptyMap()
: projectExecutionMapper.selectBatchIds(executionIds).stream()
.collect(Collectors.toMap(ProjectExecutionDO::getId, Function.identity(), (a, b) -> a));
List<MyTaskRespVO> 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<ProjectTaskDO> tasks, List<MyTaskRespVO> vos) {
Map<Long, ProjectTaskStatusViewService.ProjectTaskLifecycleView> 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 <T> PageResult<T> paginate(List<T> 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);
}
}

View File

@@ -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<Long, ProjectTaskLifecycleView> getLifecycleMap(List<ProjectTaskDO> tasks) {
if (tasks == null || tasks.isEmpty()) {
return Collections.emptyMap();
}
Map<String, ObjectStatusModelDO> statusModelMap = objectStatusModelMapper
.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE).stream()
.collect(Collectors.toMap(ObjectStatusModelDO::getStatusCode, Function.identity(), (a, b) -> a));
List<String> statusCodes = tasks.stream()
.map(ProjectTaskDO::getStatusCode)
.filter(Objects::nonNull)
.distinct()
.toList();
Map<String, List<ObjectStatusTransitionDO>> transitionMap = statusCodes.isEmpty()
? Collections.emptyMap()
: objectStatusTransitionMapper
.selectListByObjectTypeAndFromStatuses(ProjectTaskConstants.OBJECT_TYPE, statusCodes).stream()
.collect(Collectors.groupingBy(ObjectStatusTransitionDO::getFromStatusCode));
Long currentUserId = SecurityFrameworkUtils.getLoginUserId();
Map<Long, ProjectTaskLifecycleView> 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<ProjectTaskLifecycleActionRespVO> buildAvailableActions(String statusCode, Long ownerId,
BigDecimal progressRate) {
List<ObjectStatusTransitionDO> transitions = objectStatusTransitionMapper
.selectListByObjectTypeAndFromStatus(ProjectTaskConstants.OBJECT_TYPE, statusCode);
return buildAvailableActions(transitions, ownerId, progressRate, SecurityFrameworkUtils.getLoginUserId());
}
private List<ProjectTaskLifecycleActionRespVO> buildAvailableActions(List<ObjectStatusTransitionDO> 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()))

View File

@@ -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<SecurityFrameworkUtils> 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<Long, AdminUserRespDTO> userMap = new HashMap<>();
userMap.put(100L, user(100L, ""));
userMap.put(200L, user(200L, "张三"));
when(adminUserApi.getUserMap(anyCollection())).thenReturn(userMap);
List<MyTeamService.TeamMember> 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<SecurityFrameworkUtils> 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 组装(任务行 + 个人事项行)、压力公式。
*
* <p>压力 = items 之和 + dueSoonCount + overdueCount
* <ul>
* <li>用户 300任务 5+1=6dueSoon=0overdue=2 → 压力 8</li>
* <li>用户 200任务 2 + 个人 1=3dueSoon=1overdue=1 → 压力 5</li>
* </ul>
* 故排序100(自己) → 300 → 200
*/
@Test
void getTeamLoad_multiMember_sortAndItemsAssembly() {
try (MockedStatic<SecurityFrameworkUtils> 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<Long, AdminUserRespDTO> 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<String, Object> taskRow200P10 = Map.of(
"userId", 200L,
"projectId", 10L,
"taskCount", 2L,
"dueSoonCount", 1L,
"overdueCount", 0L
);
Map<String, Object> taskRow300P10 = Map.of(
"userId", 300L,
"projectId", 10L,
"taskCount", 5L,
"dueSoonCount", 0L,
"overdueCount", 2L
);
Map<String, Object> 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<String, Object> 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<TeamLoadRespVO.MemberVO> 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<TeamLoadRespVO.LoadItemVO> 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<TeamLoadRespVO.LoadItemVO> 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 = 1overdue任务行 0 + 个人 1 = 1
assertEquals(1, members.get(2).getDueSoonCount());
assertEquals(1, members.get(2).getOverdueCount());
}
}
}

View File

@@ -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<SecurityFrameworkUtils> 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());
}
}

View File

@@ -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<LocalDate, BigDecimal> 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<LocalDate, BigDecimal> 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<LocalDate, BigDecimal> 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<LocalDate, BigDecimal> 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<LocalDate, BigDecimal> 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<LocalDate, BigDecimal> 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<LocalDate, BigDecimal> 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<LocalDate, BigDecimal> 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<LocalDate, BigDecimal> 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());
}
}

View File

@@ -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<SecurityFrameworkUtils> 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<MyTaskRespVO> result = myTaskService.getMyTaskPage(new MyTaskPageReqVO());
assertEquals(0L, result.getTotal());
}
}
@Test
void myRole_ownerWinsOverCollaborator() {
try (MockedStatic<SecurityFrameworkUtils> mock = mockStatic(SecurityFrameworkUtils.class)) {
mock.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(100L);
when(objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled(anyString()))
.thenReturn(List.of("completed", "cancelled"));
// 任务1我是 owner任务2owner 是别人我只能是协办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<MyTaskRespVO> 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<SecurityFrameworkUtils> 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<MyTaskRespVO> 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());
}
}
}

View File

@@ -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<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
Map<Long, ProjectTaskStatusViewService.ProjectTaskLifecycleView> 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;
}
}

View File

@@ -1,266 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>项目列表按产品分组:后端待确认事项与接口诉求</title>
<style>
:root {
--bg: #f6f7f9;
--panel: #ffffff;
--border: #e5e7eb;
--border-strong: #d1d5db;
--text: #1f2937;
--text-soft: #6b7280;
--text-muted: #9ca3af;
--primary: #2563eb;
--primary-soft: #dbeafe;
--ok: #047857;
--ok-bg: #d1fae5;
--warn: #b45309;
--warn-bg: #fef3c7;
--bad: #b91c1c;
--bad-bg: #fee2e2;
--code: #b45309;
--code-bg: #f3f4f6;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.7;
}
.wrap {
max-width: 980px;
margin: 0 auto;
padding: 32px 28px 80px;
}
.head { margin-bottom: 22px; }
.head h1 { margin: 0 0 6px; font-size: 24px; font-weight: 700; line-height: 1.4; }
.head .sub { color: var(--text-soft); font-size: 13px; }
section { margin-top: 28px; }
section > h2 {
font-size: 18px;
font-weight: 700;
margin: 0 0 14px;
padding-bottom: 8px;
border-bottom: 2px solid var(--border);
}
section h3 { font-size: 15px; font-weight: 700; margin: 18px 0 8px; }
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 18px 22px;
margin-bottom: 14px;
}
table.cmp {
width: 100%;
border-collapse: collapse;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
font-size: 13px;
margin: 10px 0 14px;
}
table.cmp th, table.cmp td {
border: 1px solid var(--border);
padding: 8px 12px;
text-align: left;
vertical-align: top;
}
table.cmp th { background: #f9fafb; font-weight: 700; white-space: nowrap; }
table.cmp td code, p code, li code {
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 12.5px;
color: var(--code);
background: var(--code-bg);
padding: 1px 5px;
border-radius: 4px;
}
pre {
background: #1f2937;
color: #e5e7eb;
border-radius: 8px;
padding: 14px 16px;
overflow-x: auto;
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 12.5px;
line-height: 1.6;
}
pre .c { color: #9ca3af; }
.tag-ok, .tag-warn, .tag-bad {
display: inline-block;
font-size: 11px;
font-weight: 700;
border-radius: 999px;
padding: 1px 10px;
vertical-align: 1px;
white-space: nowrap;
}
.tag-ok { color: var(--ok); background: var(--ok-bg); }
.tag-warn { color: var(--warn); background: var(--warn-bg); }
.tag-bad { color: var(--bad); background: var(--bad-bg); }
ul, ol { margin: 8px 0; padding-left: 22px; }
li { margin: 4px 0; }
.note { color: var(--text-soft); font-size: 13px; }
</style>
</head>
<body>
<div class="wrap">
<div class="head">
<h1>项目列表按产品分组:后端待确认事项与接口诉求</h1>
<div class="sub">2026-06-10 · 前端发起 · 涉及模块project/project项目、system/dict字典</div>
</div>
<section>
<h2>0. 背景</h2>
<div class="card">
<p>项目列表页将从"平铺分页"改为"按所属产品聚合展示":同一产品下的项目(基线/合同/技术支持等类型)需要聚拢在一起查看,不能被分页切散。现有 <code>GET /project/project/page</code> 平铺分页无法支撑该口径,需要后端补充以下能力。</p>
<p>前端已按本文档第 2~4 节的契约<b>先行开发</b>(过渡期用现有平铺接口在前端聚合模拟),后端接口就绪后联调切换,<b>不阻塞后端排期</b>,但请反馈预计时间点。</p>
</div>
</section>
<section>
<h2>1. 待查询确认rdms_project_type 字典数据 <span class="tag-warn">请尽快回复,阻塞前端一处常量</span></h2>
<div class="card">
<p>请提供字典 <code>rdms_project_type</code>(项目类型)的<b>完整字典数据清单</b>(每项的 value / label / 启用状态),特别是:</p>
<ul>
<li><b>"基线"类型对应的字典 value 是什么</b>(前端需写入常量,用于"建立基线"入口预填与缺基线判定);</li>
<li>除 基线 / 合同 / 技术支持 外是否还有其他在用类型值。</li>
</ul>
<p class="note">补充确认(口头已确认,烦请最终对齐):"基线项目每产品唯一"由后端在创建/更新项目时做唯一性校验兜底,前端仅做体验层提示。</p>
</div>
</section>
<section>
<h2>2. 接口诉求一:项目按产品分组分页查询(新增) <span class="tag-bad">核心依赖</span></h2>
<h3>2.1 建议路径</h3>
<div class="card">
<p><code>GET /project/project/group-page</code>(路径可由后端按规范调整,前端只在 service 层适配一处)</p>
<p>数据权限口径与现有 <code>/project/project/page</code> 一致:只返回当前用户可见的项目,按其归属产品聚合。</p>
</div>
<h3>2.2 入参</h3>
<table class="cmp">
<tr><th>参数</th><th>类型</th><th>必填</th><th>说明</th></tr>
<tr><td><code>pageNo</code> / <code>pageSize</code></td><td>int</td><td></td><td><b>产品组维度</b>分页:一页返回 pageSize 个产品组(含组内项目),不是项目行分页。前端当前 pageSize=10。</td></tr>
<tr><td><code>keyword</code></td><td>string</td><td></td><td>项目编码 / 名称模糊匹配(口径同现有 page 接口)。</td></tr>
<tr><td><code>productId</code></td><td>string</td><td></td><td>限定单个产品。</td></tr>
<tr><td><code>projectType</code></td><td>string</td><td></td><td>项目类型字典 value。</td></tr>
<tr><td><code>statusCode</code></td><td>string</td><td></td><td>单状态过滤。<b>不传 = "全部"口径pending / active / paused / completed 四种状态,不含 cancelled / archived。</b></td></tr>
<tr><td><code>orphanOnly</code></td><td>boolean</td><td></td><td>true = 仅返回"游离组"productId 为空的项目聚合为一个特殊组)。</td></tr>
<tr><td><code>topN</code></td><td>int</td><td></td><td>每组返回的项目条数上限,建议默认 5前端当前固定取 5</td></tr>
</table>
<h3>2.3 返回结构(示例)</h3>
<pre>{
"total": 12, <span class="c">// 当前筛选口径下产品组总数(分页 total含游离组</span>
"projectTotal": 47, <span class="c">// 当前筛选口径下项目总数</span>
"directionCount": 3, <span class="c">// 可见产品去重后的方向directionCode</span>
"orphanTotal": 3, <span class="c">// 游离项目数productId 为空)</span>
"list": [
{
"productId": "1923456789012345678", <span class="c">// 字符串!见 4.1</span>
"productName": "智能网关",
"productCode": "RDMS-P-001",
"directionCode": "platform",
"managerUserId": "1001",
"managerUserNickname": "张三",
"projectTotal": 6, <span class="c">// 当前筛选口径下该组项目总数</span>
"projects": [ /* 前 topN 条,字段同现有 page 接口的项目行 */ ],
"typeCounts": { "baseline值": 1, "合同值": 3, "技术支持值": 2 },
"hasBaseline": true,
"orphan": false
},
{
"productId": null, <span class="c">// 游离组:未挂产品的项目聚合</span>
"productName": "游离项目",
"productCode": null,
"directionCode": "",
"managerUserId": null,
"managerUserNickname": null,
"projectTotal": 3,
"projects": [ /* ... */ ],
"typeCounts": { "合同值": 2, "技术支持值": 1 },
"hasBaseline": false,
"orphan": true
}
]
}</pre>
<h3>2.4 口径约定(关键,请逐条确认)</h3>
<table class="cmp">
<tr><th>#</th><th>约定</th><th>说明</th></tr>
<tr><td>1</td><td>组内项目排序</td><td><code>updateTime</code> 倒序,返回前 topN 条;<code>projectTotal</code> 为该口径下组内全量计数。</td></tr>
<tr><td>2</td><td>无命中组不返回</td><td>传了 <code>statusCode</code> / <code>keyword</code> / <code>projectType</code> 任一筛选时,组内无命中项目的产品组不返回。</td></tr>
<tr><td>3</td><td>零项目产品组</td><td>"全部"口径statusCode 缺省)且无 keyword / projectType 筛选时,<b>返回当前用户可见、状态为 active / paused 的零项目产品组</b>projectTotal=0、projects=[]),用于"该产品暂无项目"占位与新增引导。</td></tr>
<tr><td>4</td><td>typeCounts / hasBaseline 统计口径</td><td>这两个字段是"产品属性"<b>恒按"全部"口径(四种状态)统计</b>,不随 statusCode 入参变化——避免"基线项目已完成时被误判为缺基线"。</td></tr>
<tr><td>5</td><td>游离组位置</td><td>游离组productId 为空)作为列表中<b>最后一个组</b>返回;状态筛选下同样适用约定 2无命中则不返回该组</td></tr>
<tr><td>6</td><td>组的排序</td><td>同方向directionCode的产品组相邻返回方向间顺序、方向内产品顺序由后端按现有产品列表默认排序即可</td></tr>
</table>
<h3>2.5 组内"展开剩余"的数据来源</h3>
<div class="card">
<p>不需要新接口:前端用现有 <code>GET /project/project/page</code><code>productId + statusCode</code> 拉取该组剩余项目。前提是该接口的排序与本接口组内排序一致updateTime 倒序)——若现有 page 接口默认排序不是 updateTime 倒序,请告知或支持排序参数。</p>
</div>
</section>
<section>
<h2>3. 接口诉求二、三:存量接口小改 <span class="tag-warn">两处补充</span></h2>
<h3>3.1 平铺分页支持"未挂产品"筛选</h3>
<div class="card">
<p><code>GET /project/project/page</code> 需要能表达 <b>productId 为空</b>的筛选语义(如新增 <code>orphanOnly=true</code> 参数,或约定 productId 传特殊值),用于查询"游离项目"(未关联任何产品的项目)。具体参数形式由后端定,前端适配。</p>
</div>
<h3>3.2 概览统计补游离计数</h3>
<div class="card">
<p><code>GET /project/project/overview-summary</code> 返回体增加:</p>
<pre>{
"statusCounts": { "active": 10, "pending": 2, ... }, <span class="c">// 现有</span>
"orphanCount": 3 <span class="c">// 新增:未挂产品的项目数(按"全部"口径:四种状态)</span>
}</pre>
</div>
</section>
<section>
<h2>4. 公共约定</h2>
<table class="cmp">
<tr><th>#</th><th>约定</th><th>说明</th></tr>
<tr><td>4.1</td><td>ID 一律字符串 <span class="tag-bad">必须</span></td><td>所有 Long / 雪花 IDproductId、managerUserId、项目 id 等)在 JSON 中<b>按字符串返回</b>。Long 直接作为 JSON 数字返回会在前端丢精度。</td></tr>
<tr><td>4.2</td><td>"全部"状态集合</td><td>pending / active / paused / completedcancelled / archived 仅在显式传对应 statusCode 时返回。</td></tr>
<tr><td>4.3</td><td>项目行字段</td><td>分组接口 <code>projects[]</code> 内的项目对象字段与现有 <code>/project/project/page</code> 返回行保持一致(含 productId / productName / managerUserNickname / progressRate / statusCode / updateTime 等),避免前端双口径。</td></tr>
</table>
</section>
<section>
<h2>5. 需要后端反馈的清单</h2>
<div class="card">
<ol>
<li><b>第 1 节</b><code>rdms_project_type</code> 字典数据清单 + "基线"的 value<span class="tag-warn">最优先</span>,一条查询即可,阻塞前端一处常量)。</li>
<li><b>第 2 节</b>:分组分页接口的可行性确认 + 2.4 六条口径逐条确认(有异议请直接批注替代方案)+ 排期。</li>
<li><b>第 3 节</b>page 接口游离筛选的参数形式 + overview-summary 补 orphanCount 的排期。</li>
<li><b>第 2.5 节</b>:现有 page 接口的默认排序是什么;是否支持/计划支持按 updateTime 倒序。</li>
</ol>
</div>
</section>
</div>
</body>
</html>