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.dal.dataobject.personal.PersonalItemDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Mapper
|
||||
public interface PersonalItemMapper extends BaseMapperX<PersonalItemDO> {
|
||||
@@ -77,4 +81,37 @@ public interface PersonalItemMapper extends BaseMapperX<PersonalItemDO> {
|
||||
queryWrapper.orderByAsc(PersonalItemDO::getId);
|
||||
return selectList(queryWrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 团队负载:一批成员的非终态个人事项计数(按 owner_id 分组),带临期/逾期。
|
||||
* 个人事项状态机复用 task 对象类型,terminalStatusCodes 与任务同源。
|
||||
* 返回 Map:ownerId / itemCount / dueSoonCount / overdueCount(均 Long)。
|
||||
*/
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT owner_id AS ownerId,
|
||||
CAST(COUNT(*) AS SIGNED) AS itemCount,
|
||||
CAST(SUM(CASE WHEN planned_end_date IS NOT NULL
|
||||
AND planned_end_date >= #{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,
|
||||
@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));
|
||||
}
|
||||
|
||||
/** 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.ProjectTaskConstants;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskLifecycleActionRespVO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
|
||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
||||
@@ -14,9 +15,13 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
|
||||
@@ -62,14 +67,56 @@ public class ProjectTaskStatusViewService {
|
||||
);
|
||||
}
|
||||
|
||||
public Map<Long, ProjectTaskLifecycleView> getLifecycleMap(List<ProjectTaskDO> tasks) {
|
||||
if (tasks == null || tasks.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
Map<String, ObjectStatusModelDO> statusModelMap = objectStatusModelMapper
|
||||
.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE).stream()
|
||||
.collect(Collectors.toMap(ObjectStatusModelDO::getStatusCode, Function.identity(), (a, b) -> a));
|
||||
List<String> statusCodes = tasks.stream()
|
||||
.map(ProjectTaskDO::getStatusCode)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<String, List<ObjectStatusTransitionDO>> transitionMap = statusCodes.isEmpty()
|
||||
? Collections.emptyMap()
|
||||
: objectStatusTransitionMapper
|
||||
.selectListByObjectTypeAndFromStatuses(ProjectTaskConstants.OBJECT_TYPE, statusCodes).stream()
|
||||
.collect(Collectors.groupingBy(ObjectStatusTransitionDO::getFromStatusCode));
|
||||
Long currentUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
Map<Long, ProjectTaskLifecycleView> result = new LinkedHashMap<>();
|
||||
for (ProjectTaskDO task : tasks) {
|
||||
ObjectStatusModelDO statusModel = statusModelMap.get(task.getStatusCode());
|
||||
if (statusModel == null) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_MODEL_NOT_EXISTS_OR_DISABLED);
|
||||
}
|
||||
result.put(task.getId(), new ProjectTaskLifecycleView(
|
||||
statusModel.getStatusName(),
|
||||
statusModel.getTerminalFlag(),
|
||||
statusModel.getAllowEdit(),
|
||||
buildAvailableActions(
|
||||
transitionMap.getOrDefault(task.getStatusCode(), Collections.emptyList()),
|
||||
task.getOwnerId(), task.getProgressRate(), currentUserId)
|
||||
));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<ProjectTaskLifecycleActionRespVO> buildAvailableActions(String statusCode, Long ownerId,
|
||||
BigDecimal progressRate) {
|
||||
List<ObjectStatusTransitionDO> transitions = objectStatusTransitionMapper
|
||||
.selectListByObjectTypeAndFromStatus(ProjectTaskConstants.OBJECT_TYPE, statusCode);
|
||||
return buildAvailableActions(transitions, ownerId, progressRate, SecurityFrameworkUtils.getLoginUserId());
|
||||
}
|
||||
|
||||
private List<ProjectTaskLifecycleActionRespVO> buildAvailableActions(List<ObjectStatusTransitionDO> transitions,
|
||||
Long ownerId,
|
||||
BigDecimal progressRate,
|
||||
Long currentUserId) {
|
||||
if (transitions == null || transitions.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Long currentUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
return transitions.stream()
|
||||
// 剔除系统级动作 auto_start:由工时填报触发,不暴露给前端按钮
|
||||
.filter(transition -> !ObjectActivityConstants.TASK_ACTION_AUTO_START.equals(transition.getActionCode()))
|
||||
|
||||
Reference in New Issue
Block a user