feat(project): 新增工作台我的任务功能并优化团队负载统计
- 新增 MyTaskController 提供跨项目的我的任务分页查询接口 - 实现个人事项和任务的团队负载统计功能,支持临期/逾期计数 - 优化任务状态视图服务,支持批量加载生命周期视图 - 新增多用户工时周聚合查询功能 - 完善相关 VO 类定义和数据库映射配置 - 添加单元测试验证批量加载和权限过滤逻辑
This commit is contained in:
@@ -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));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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.controller.admin.personal.vo.item.PersonalItemPageReqVO;
|
||||||
import com.njcn.rdms.module.project.dal.dataobject.personal.PersonalItemDO;
|
import com.njcn.rdms.module.project.dal.dataobject.personal.PersonalItemDO;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface PersonalItemMapper extends BaseMapperX<PersonalItemDO> {
|
public interface PersonalItemMapper extends BaseMapperX<PersonalItemDO> {
|
||||||
@@ -77,4 +81,37 @@ public interface PersonalItemMapper extends BaseMapperX<PersonalItemDO> {
|
|||||||
queryWrapper.orderByAsc(PersonalItemDO::getId);
|
queryWrapper.orderByAsc(PersonalItemDO::getId);
|
||||||
return selectList(queryWrapper);
|
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 >= #{today}
|
||||||
|
AND planned_end_date <= #{dueSoonEnd}
|
||||||
|
THEN 1 ELSE 0 END) AS SIGNED) AS dueSoonCount,
|
||||||
|
CAST(SUM(CASE WHEN planned_end_date IS NOT NULL
|
||||||
|
AND planned_end_date < #{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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -859,4 +859,107 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
|
|||||||
List<ProjectTaskDO> selectInvolvedListByUserIdAndStatusNot(@Param("userId") Long userId,
|
List<ProjectTaskDO> selectInvolvedListByUserIdAndStatusNot(@Param("userId") Long userId,
|
||||||
@Param("excludedStatusCode") String excludedStatusCode);
|
@Param("excludedStatusCode") String excludedStatusCode);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作台「我的任务」:当前用户为负责人或在岗协办人的非终态任务(跨项目)。
|
||||||
|
* involveType:owner=仅我负责 / 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 >= #{today}
|
||||||
|
AND x.planned_end_date <= #{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 < #{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);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,4 +214,19 @@ public interface TaskWorklogMapper extends BaseMapperX<TaskWorklogDO> {
|
|||||||
.orderByAsc(TaskWorklogDO::getId));
|
.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));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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.ObjectActivityConstants;
|
||||||
import com.njcn.rdms.module.project.constant.ProjectTaskConstants;
|
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.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.ObjectStatusModelDO;
|
||||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
|
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
|
||||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
||||||
@@ -14,9 +15,13 @@ import org.springframework.stereotype.Service;
|
|||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
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;
|
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,
|
private List<ProjectTaskLifecycleActionRespVO> buildAvailableActions(String statusCode, Long ownerId,
|
||||||
BigDecimal progressRate) {
|
BigDecimal progressRate) {
|
||||||
List<ObjectStatusTransitionDO> transitions = objectStatusTransitionMapper
|
List<ObjectStatusTransitionDO> transitions = objectStatusTransitionMapper
|
||||||
.selectListByObjectTypeAndFromStatus(ProjectTaskConstants.OBJECT_TYPE, statusCode);
|
.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()) {
|
if (transitions == null || transitions.isEmpty()) {
|
||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
Long currentUserId = SecurityFrameworkUtils.getLoginUserId();
|
|
||||||
return transitions.stream()
|
return transitions.stream()
|
||||||
// 剔除系统级动作 auto_start:由工时填报触发,不暴露给前端按钮
|
// 剔除系统级动作 auto_start:由工时填报触发,不暴露给前端按钮
|
||||||
.filter(transition -> !ObjectActivityConstants.TASK_ACTION_AUTO_START.equals(transition.getActionCode()))
|
.filter(transition -> !ObjectActivityConstants.TASK_ACTION_AUTO_START.equals(transition.getActionCode()))
|
||||||
|
|||||||
@@ -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=6,dueSoon=0,overdue=2 → 压力 8</li>
|
||||||
|
* <li>用户 200:任务 2 + 个人 1=3,dueSoon=1,overdue=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 = 1;overdue:任务行 0 + 个人 1 = 1
|
||||||
|
assertEquals(1, members.get(2).getDueSoonCount());
|
||||||
|
assertEquals(1, members.get(2).getOverdueCount());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;任务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<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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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.security.core.util.SecurityFrameworkUtils;
|
||||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
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.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.ObjectStatusModelDO;
|
||||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
|
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
|
||||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
||||||
@@ -16,12 +17,15 @@ import org.mockito.MockedStatic;
|
|||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.Mockito.mockStatic;
|
import static org.mockito.Mockito.mockStatic;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest {
|
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
|
@Test
|
||||||
void getLifecycle_whenStatusModelMissing_shouldThrow() {
|
void getLifecycle_whenStatusModelMissing_shouldThrow() {
|
||||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "missing")).thenReturn(null);
|
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "missing")).thenReturn(null);
|
||||||
@@ -173,4 +209,19 @@ class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest {
|
|||||||
return transition;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 / 雪花 ID(productId、managerUserId、项目 id 等)在 JSON 中<b>按字符串返回</b>。Long 直接作为 JSON 数字返回会在前端丢精度。</td></tr>
|
|
||||||
<tr><td>4.2</td><td>"全部"状态集合</td><td>pending / active / paused / completed;cancelled / 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>
|
|
||||||
Reference in New Issue
Block a user