From 3bca27254bbe893511cd81af4bc95e02e5430c0f Mon Sep 17 00:00:00 2001 From: JiaoTianBo Date: Wed, 1 Apr 2026 11:18:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(analysis):=20=E6=B7=BB=E5=8A=A0=E6=97=A5?= =?UTF-8?q?=E6=8A=A5=E5=88=86=E6=9E=90=E8=BF=9B=E5=BA=A6=E5=9B=9E=E5=86=99?= =?UTF-8?q?=E5=BB=BA=E8=AE=AE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增日报分析建议服务,支持获取和应用进度更新建议 - 添加进度回写建议数据结构,支持任务和里程碑状态/进度更新 - 移除项目时间节点相关功能,简化项目数据结构 - 扩展AI分析提示词,要求生成可回写的进度建议 - 新增相关Mapper、Controller和服务实现 - 优化JsonbTypeHandler以支持日期序列化 --- .../common/handler/JsonbTypeHandler.java | 5 +- .../DailyReportAnalysisController.java | 97 ++++++ .../ApplyDailyReportSuggestionsRequest.java | 18 + .../domain/dto/DailyReportAnalysisResult.java | 46 +++ .../entity/DailyReportAnalysisRecord.java | 39 +++ .../entity/DailyReportUpdateSuggestion.java | 49 +++ .../vo/DailyReportAnalysisSuggestionsVO.java | 24 ++ .../vo/DailyReportUpdateSuggestionVO.java | 32 ++ .../yinlihupo/domain/vo/ProjectDetailVO.java | 5 - .../domain/vo/ProjectInitResult.java | 24 -- .../DailyReportAnalysisRecordMapper.java | 10 + .../DailyReportUpdateSuggestionMapper.java | 10 + .../mapper/ProjectTimelineMapper.java | 12 - .../DailyReportSuggestionService.java | 16 + .../impl/DailyReportAnalysisServiceImpl.java | 74 ++++- .../DailyReportSuggestionServiceImpl.java | 308 ++++++++++++++++++ .../project/impl/ProjectServiceImpl.java | 61 ---- 17 files changed, 725 insertions(+), 105 deletions(-) create mode 100644 src/main/java/cn/yinlihupo/controller/analysis/DailyReportAnalysisController.java create mode 100644 src/main/java/cn/yinlihupo/domain/dto/ApplyDailyReportSuggestionsRequest.java create mode 100644 src/main/java/cn/yinlihupo/domain/entity/DailyReportAnalysisRecord.java create mode 100644 src/main/java/cn/yinlihupo/domain/entity/DailyReportUpdateSuggestion.java create mode 100644 src/main/java/cn/yinlihupo/domain/vo/DailyReportAnalysisSuggestionsVO.java create mode 100644 src/main/java/cn/yinlihupo/domain/vo/DailyReportUpdateSuggestionVO.java create mode 100644 src/main/java/cn/yinlihupo/mapper/DailyReportAnalysisRecordMapper.java create mode 100644 src/main/java/cn/yinlihupo/mapper/DailyReportUpdateSuggestionMapper.java delete mode 100644 src/main/java/cn/yinlihupo/mapper/ProjectTimelineMapper.java create mode 100644 src/main/java/cn/yinlihupo/service/analysis/DailyReportSuggestionService.java create mode 100644 src/main/java/cn/yinlihupo/service/analysis/impl/DailyReportSuggestionServiceImpl.java diff --git a/src/main/java/cn/yinlihupo/common/handler/JsonbTypeHandler.java b/src/main/java/cn/yinlihupo/common/handler/JsonbTypeHandler.java index e562c02..3b6a63f 100644 --- a/src/main/java/cn/yinlihupo/common/handler/JsonbTypeHandler.java +++ b/src/main/java/cn/yinlihupo/common/handler/JsonbTypeHandler.java @@ -2,6 +2,7 @@ package cn.yinlihupo.common.handler; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; import org.apache.ibatis.type.BaseTypeHandler; import org.apache.ibatis.type.JdbcType; import org.postgresql.util.PGobject; @@ -17,7 +18,9 @@ import java.sql.SQLException; */ public class JsonbTypeHandler extends BaseTypeHandler { - private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final ObjectMapper objectMapper = new ObjectMapper() + .findAndRegisterModules() + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); private static final String JSONB_TYPE = "jsonb"; @Override diff --git a/src/main/java/cn/yinlihupo/controller/analysis/DailyReportAnalysisController.java b/src/main/java/cn/yinlihupo/controller/analysis/DailyReportAnalysisController.java new file mode 100644 index 0000000..2bd2bb4 --- /dev/null +++ b/src/main/java/cn/yinlihupo/controller/analysis/DailyReportAnalysisController.java @@ -0,0 +1,97 @@ +package cn.yinlihupo.controller.analysis; + +import cn.yinlihupo.common.core.BaseResponse; +import cn.yinlihupo.common.enums.ErrorCode; +import cn.yinlihupo.common.exception.BusinessException; +import cn.yinlihupo.common.util.ResultUtils; +import cn.yinlihupo.common.util.SecurityUtils; +import cn.yinlihupo.domain.dto.ApplyDailyReportSuggestionsRequest; +import cn.yinlihupo.domain.entity.DailyReportUpdateSuggestion; +import cn.yinlihupo.domain.vo.DailyReportAnalysisSuggestionsVO; +import cn.yinlihupo.mapper.DailyReportUpdateSuggestionMapper; +import cn.yinlihupo.service.analysis.DailyReportSuggestionService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * 日报分析建议 + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/daily-report/analysis") +@RequiredArgsConstructor +public class DailyReportAnalysisController { + + private final DailyReportSuggestionService dailyReportSuggestionService; + private final DailyReportUpdateSuggestionMapper dailyReportUpdateSuggestionMapper; + + /** + * 获取日报进度更新建议 + * + * @param projectId 项目ID + * @param reportId 日报ID + * @param reportDate 日报日期 + * @param submitterUsername 日报提交人用户名 + */ + @GetMapping("/suggestions") + public BaseResponse getSuggestions( + @RequestParam Long projectId, + @RequestParam(required = false) Long reportId, + @RequestParam(required = false) LocalDate reportDate, + @RequestParam(required = false) String submitterUsername) { + + DailyReportAnalysisSuggestionsVO vo; + if (reportId != null) { + vo = dailyReportSuggestionService.getLatestSuggestionsByReportId(projectId, reportId); + } else { + vo = dailyReportSuggestionService.getLatestSuggestionsByUniqueKey(projectId, reportDate, submitterUsername); + } + return ResultUtils.success("查询成功", vo); + } + + /** + * 应用日报进度回写建议 + * + * @param request 建议ID列表 + * @return 应用结果 + */ + @PostMapping("/suggestions/apply") + public BaseResponse applySuggestions(@RequestBody @Valid ApplyDailyReportSuggestionsRequest request) { + Long userId = SecurityUtils.getCurrentUserId(); + if (userId == null) { + return ResultUtils.error(ErrorCode.NOT_LOGIN_ERROR, "用户未登录"); + } + + List suggestions = dailyReportUpdateSuggestionMapper.selectBatchIds(request.getSuggestionIds()); + if (suggestions == null || suggestions.isEmpty()) { + return ResultUtils.error(ErrorCode.NOT_FOUND_ERROR, "建议不存在"); + } + + Set types = new HashSet<>(); + for (DailyReportUpdateSuggestion s : suggestions) { + if (s != null && StringUtils.hasText(s.getTargetType())) { + types.add(s.getTargetType().toLowerCase()); + } + } + + if (types.contains("task") && !SecurityUtils.hasPermission("project:task:update")) { + throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "无权限回写任务"); + } + if (types.contains("milestone") && !SecurityUtils.hasPermission("project:milestone:update")) { + throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "无权限回写里程碑"); + } + + int applied = dailyReportSuggestionService.applySuggestions(request.getProjectId(), request.getSuggestionIds(), userId); + log.info("应用日报进度回写建议成功, projectId={}, appliedCount={}, userId={}", request.getProjectId(), applied, userId); + return ResultUtils.success("应用成功", applied); + } +} + diff --git a/src/main/java/cn/yinlihupo/domain/dto/ApplyDailyReportSuggestionsRequest.java b/src/main/java/cn/yinlihupo/domain/dto/ApplyDailyReportSuggestionsRequest.java new file mode 100644 index 0000000..322f2ea --- /dev/null +++ b/src/main/java/cn/yinlihupo/domain/dto/ApplyDailyReportSuggestionsRequest.java @@ -0,0 +1,18 @@ +package cn.yinlihupo.domain.dto; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Data +public class ApplyDailyReportSuggestionsRequest { + + @NotNull(message = "projectId不能为空") + private Long projectId; + + @NotEmpty(message = "suggestionIds不能为空") + private List suggestionIds; +} + diff --git a/src/main/java/cn/yinlihupo/domain/dto/DailyReportAnalysisResult.java b/src/main/java/cn/yinlihupo/domain/dto/DailyReportAnalysisResult.java index 4087208..842b79e 100644 --- a/src/main/java/cn/yinlihupo/domain/dto/DailyReportAnalysisResult.java +++ b/src/main/java/cn/yinlihupo/domain/dto/DailyReportAnalysisResult.java @@ -12,6 +12,11 @@ import java.util.List; @Data public class DailyReportAnalysisResult { + /** + * 日报ID + */ + private Long reportId; + /** * 项目 ID */ @@ -47,6 +52,11 @@ public class DailyReportAnalysisResult { */ private List progressSuggestions; + /** + * 可直接回写到任务/里程碑的进度更新建议(需要用户确认后才执行) + */ + private List progressUpdateRecommendations; + /** * 识别的风险列表 (直接入库) */ @@ -191,6 +201,42 @@ public class DailyReportAnalysisResult { private String expectedEffect; } + /** + * 进度更新建议(可回写) + */ + @Data + public static class ProgressUpdateRecommendation { + /** + * 建议作用对象:task / milestone + */ + private String targetType; + + /** + * 任务ID或里程碑ID + */ + private Long targetId; + + /** + * 建议状态:pending / in_progress / completed / delayed 等(与现有状态体系保持一致) + */ + private String suggestedStatus; + + /** + * 建议进度 0-100 + */ + private Integer suggestedProgress; + + /** + * 建议理由 + */ + private String reason; + + /** + * 置信度 0-1 + */ + private BigDecimal confidence; + } + /** * 识别的风险 (直接入库) */ diff --git a/src/main/java/cn/yinlihupo/domain/entity/DailyReportAnalysisRecord.java b/src/main/java/cn/yinlihupo/domain/entity/DailyReportAnalysisRecord.java new file mode 100644 index 0000000..fd41c6b --- /dev/null +++ b/src/main/java/cn/yinlihupo/domain/entity/DailyReportAnalysisRecord.java @@ -0,0 +1,39 @@ +package cn.yinlihupo.domain.entity; + +import cn.yinlihupo.common.handler.JsonbTypeHandler; +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("daily_report_analysis") +public class DailyReportAnalysisRecord { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long reportId; + + private Long projectId; + + private LocalDate reportDate; + + private String submitterUsername; + + @TableField(typeHandler = JsonbTypeHandler.class) + private Object analysisResult; + + private String status; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + @TableLogic + private Integer deleted; +} + diff --git a/src/main/java/cn/yinlihupo/domain/entity/DailyReportUpdateSuggestion.java b/src/main/java/cn/yinlihupo/domain/entity/DailyReportUpdateSuggestion.java new file mode 100644 index 0000000..087fc96 --- /dev/null +++ b/src/main/java/cn/yinlihupo/domain/entity/DailyReportUpdateSuggestion.java @@ -0,0 +1,49 @@ +package cn.yinlihupo.domain.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("daily_report_update_suggestion") +public class DailyReportUpdateSuggestion { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long analysisId; + + private Long reportId; + + private Long projectId; + + private String targetType; + + private Long targetId; + + private String suggestedStatus; + + private Integer suggestedProgress; + + private String reason; + + private BigDecimal confidence; + + private String status; + + private Long appliedBy; + + private LocalDateTime appliedTime; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + @TableLogic + private Integer deleted; +} + diff --git a/src/main/java/cn/yinlihupo/domain/vo/DailyReportAnalysisSuggestionsVO.java b/src/main/java/cn/yinlihupo/domain/vo/DailyReportAnalysisSuggestionsVO.java new file mode 100644 index 0000000..85baa85 --- /dev/null +++ b/src/main/java/cn/yinlihupo/domain/vo/DailyReportAnalysisSuggestionsVO.java @@ -0,0 +1,24 @@ +package cn.yinlihupo.domain.vo; + +import cn.yinlihupo.domain.dto.DailyReportAnalysisResult; +import lombok.Data; + +import java.time.LocalDate; +import java.util.List; + +@Data +public class DailyReportAnalysisSuggestionsVO { + + private Long analysisId; + + private Long reportId; + + private Long projectId; + + private LocalDate reportDate; + + private DailyReportAnalysisResult.OverallProgressAssessment overallProgressAssessment; + + private List suggestions; +} + diff --git a/src/main/java/cn/yinlihupo/domain/vo/DailyReportUpdateSuggestionVO.java b/src/main/java/cn/yinlihupo/domain/vo/DailyReportUpdateSuggestionVO.java new file mode 100644 index 0000000..28e3d16 --- /dev/null +++ b/src/main/java/cn/yinlihupo/domain/vo/DailyReportUpdateSuggestionVO.java @@ -0,0 +1,32 @@ +package cn.yinlihupo.domain.vo; + +import lombok.Data; + +import java.math.BigDecimal; + +@Data +public class DailyReportUpdateSuggestionVO { + + private Long suggestionId; + + private String targetType; + + private Long targetId; + + private String targetName; + + private String currentStatus; + + private Integer currentProgress; + + private String suggestedStatus; + + private Integer suggestedProgress; + + private String reason; + + private BigDecimal confidence; + + private String status; +} + diff --git a/src/main/java/cn/yinlihupo/domain/vo/ProjectDetailVO.java b/src/main/java/cn/yinlihupo/domain/vo/ProjectDetailVO.java index 8a3f1ce..8ae5e48 100644 --- a/src/main/java/cn/yinlihupo/domain/vo/ProjectDetailVO.java +++ b/src/main/java/cn/yinlihupo/domain/vo/ProjectDetailVO.java @@ -200,11 +200,6 @@ public class ProjectDetailVO { */ private List risks; - /** - * 时间节点列表 - */ - private List timelineNodes; - // ==================== 内部类定义 ==================== /** diff --git a/src/main/java/cn/yinlihupo/domain/vo/ProjectInitResult.java b/src/main/java/cn/yinlihupo/domain/vo/ProjectInitResult.java index ceed5e8..578d320 100644 --- a/src/main/java/cn/yinlihupo/domain/vo/ProjectInitResult.java +++ b/src/main/java/cn/yinlihupo/domain/vo/ProjectInitResult.java @@ -50,12 +50,6 @@ public class ProjectInitResult { @JsonProperty("risks") private List risks; - /** - * 时间节点 - */ - @JsonProperty("timeline_nodes") - private List timelineNodes; - // ==================== 内部类定义 ==================== @Data @@ -204,22 +198,4 @@ public class ProjectInitResult { @JsonProperty("mitigation_plan") private String mitigationPlan; } - - @Data - public static class TimelineNodeInfo { - @JsonProperty("node_name") - private String nodeName; - - @JsonProperty("node_type") - private String nodeType; - - @JsonProperty("plan_date") - private LocalDate planDate; - - @JsonProperty("description") - private String description; - - @JsonProperty("kb_scope") - private List kbScope; - } } diff --git a/src/main/java/cn/yinlihupo/mapper/DailyReportAnalysisRecordMapper.java b/src/main/java/cn/yinlihupo/mapper/DailyReportAnalysisRecordMapper.java new file mode 100644 index 0000000..c672c88 --- /dev/null +++ b/src/main/java/cn/yinlihupo/mapper/DailyReportAnalysisRecordMapper.java @@ -0,0 +1,10 @@ +package cn.yinlihupo.mapper; + +import cn.yinlihupo.domain.entity.DailyReportAnalysisRecord; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface DailyReportAnalysisRecordMapper extends BaseMapper { +} + diff --git a/src/main/java/cn/yinlihupo/mapper/DailyReportUpdateSuggestionMapper.java b/src/main/java/cn/yinlihupo/mapper/DailyReportUpdateSuggestionMapper.java new file mode 100644 index 0000000..0ac88e9 --- /dev/null +++ b/src/main/java/cn/yinlihupo/mapper/DailyReportUpdateSuggestionMapper.java @@ -0,0 +1,10 @@ +package cn.yinlihupo.mapper; + +import cn.yinlihupo.domain.entity.DailyReportUpdateSuggestion; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface DailyReportUpdateSuggestionMapper extends BaseMapper { +} + diff --git a/src/main/java/cn/yinlihupo/mapper/ProjectTimelineMapper.java b/src/main/java/cn/yinlihupo/mapper/ProjectTimelineMapper.java deleted file mode 100644 index ff298cc..0000000 --- a/src/main/java/cn/yinlihupo/mapper/ProjectTimelineMapper.java +++ /dev/null @@ -1,12 +0,0 @@ -package cn.yinlihupo.mapper; - -import cn.yinlihupo.domain.entity.ProjectTimeline; -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import org.apache.ibatis.annotations.Mapper; - -/** - * 项目时间节点Mapper接口 - */ -@Mapper -public interface ProjectTimelineMapper extends BaseMapper { -} diff --git a/src/main/java/cn/yinlihupo/service/analysis/DailyReportSuggestionService.java b/src/main/java/cn/yinlihupo/service/analysis/DailyReportSuggestionService.java new file mode 100644 index 0000000..ac31664 --- /dev/null +++ b/src/main/java/cn/yinlihupo/service/analysis/DailyReportSuggestionService.java @@ -0,0 +1,16 @@ +package cn.yinlihupo.service.analysis; + +import cn.yinlihupo.domain.vo.DailyReportAnalysisSuggestionsVO; + +import java.time.LocalDate; +import java.util.List; + +public interface DailyReportSuggestionService { + + DailyReportAnalysisSuggestionsVO getLatestSuggestionsByReportId(Long projectId, Long reportId); + + DailyReportAnalysisSuggestionsVO getLatestSuggestionsByUniqueKey(Long projectId, LocalDate reportDate, String submitterUsername); + + int applySuggestions(Long projectId, List suggestionIds, Long appliedBy); +} + diff --git a/src/main/java/cn/yinlihupo/service/analysis/impl/DailyReportAnalysisServiceImpl.java b/src/main/java/cn/yinlihupo/service/analysis/impl/DailyReportAnalysisServiceImpl.java index 1b67c70..421cd96 100644 --- a/src/main/java/cn/yinlihupo/service/analysis/impl/DailyReportAnalysisServiceImpl.java +++ b/src/main/java/cn/yinlihupo/service/analysis/impl/DailyReportAnalysisServiceImpl.java @@ -37,6 +37,8 @@ public class DailyReportAnalysisServiceImpl implements DailyReportAnalysisServic private final ResourceMapper resourceMapper; private final RiskMapper riskMapper; private final ProjectDailyReportMapper projectDailyReportMapper; + private final DailyReportAnalysisRecordMapper dailyReportAnalysisRecordMapper; + private final DailyReportUpdateSuggestionMapper dailyReportUpdateSuggestionMapper; /** * AI 分析系统提示词模板 @@ -55,6 +57,7 @@ public class DailyReportAnalysisServiceImpl implements DailyReportAnalysisServic 3. **资源需求分析**: 识别是否需要新增人力、物料、设备等资源 4. **进度建议**: 针对当前进度提出可操作的调整建议 5. **风险识别**: 识别潜在的项目风险并评估 + 6. **进度回写建议**: 给出可直接回写到任务/里程碑的【状态/进度】更新建议(必须带 targetType+targetId,便于后续用户确认并回写) # 输出格式 @@ -99,6 +102,24 @@ public class DailyReportAnalysisServiceImpl implements DailyReportAnalysisServic "expectedEffect": "预期效果" } ], + "progressUpdateRecommendations": [ + { + "targetType": "task", + "targetId": 456, + "suggestedStatus": "in_progress", + "suggestedProgress": 60, + "reason": "依据日报内容与当前任务状态判断已推进到 60%", + "confidence": 0.72 + }, + { + "targetType": "milestone", + "targetId": 123, + "suggestedStatus": "delayed", + "suggestedProgress": 40, + "reason": "里程碑风险高且预计延期 5 天", + "confidence": 0.68 + } + ], "identifiedRisks": [ { "riskName": "风险名称", @@ -125,6 +146,8 @@ public class DailyReportAnalysisServiceImpl implements DailyReportAnalysisServic 4. 识别的风险应该具体且可操作,不要泛泛而谈 5. 建议要结合日报中的实际工作内容,有针对性 6. 如果日报中没有明显风险,可以返回空数组,不要强行编造 + 7. progressUpdateRecommendations 中的 targetId 必须来自【任务列表】或【里程碑信息】中给出的 ID + 8. suggestedProgress 必须是 0-100 的整数;如无法判断,返回 null """; @Override @@ -194,6 +217,7 @@ public class DailyReportAnalysisServiceImpl implements DailyReportAnalysisServic // 4. 补充项目信息 if (result != null) { + result.setReportId(report.getId()); result.setProjectId(projectId); result.setReportDate(report.getReportDate()); @@ -236,10 +260,53 @@ public class DailyReportAnalysisServiceImpl implements DailyReportAnalysisServic if (result.getOverallProgressAssessment() != null) { updateProjectProgress(projectId, result.getOverallProgressAssessment()); } + + saveDailyReportAnalysis(projectId, result, now); log.info("[日报 AI 分析] 结果保存完成,projectId={}", projectId); } + private void saveDailyReportAnalysis(Long projectId, DailyReportAnalysisResult result, LocalDateTime now) { + if (result.getReportId() == null) { + return; + } + + ProjectDailyReport report = projectDailyReportMapper.selectById(result.getReportId()); + + DailyReportAnalysisRecord record = new DailyReportAnalysisRecord(); + record.setProjectId(projectId); + record.setReportId(result.getReportId()); + record.setReportDate(result.getReportDate()); + record.setSubmitterUsername(report != null ? report.getSubmitterUsername() : null); + record.setAnalysisResult(result); + record.setStatus("completed"); + dailyReportAnalysisRecordMapper.insert(record); + + if (result.getProgressUpdateRecommendations() == null || result.getProgressUpdateRecommendations().isEmpty()) { + return; + } + + for (DailyReportAnalysisResult.ProgressUpdateRecommendation rec : result.getProgressUpdateRecommendations()) { + if (rec == null || rec.getTargetId() == null || rec.getTargetType() == null) { + continue; + } + DailyReportUpdateSuggestion suggestion = new DailyReportUpdateSuggestion(); + suggestion.setAnalysisId(record.getId()); + suggestion.setReportId(result.getReportId()); + suggestion.setProjectId(projectId); + suggestion.setTargetType(rec.getTargetType()); + suggestion.setTargetId(rec.getTargetId()); + suggestion.setSuggestedStatus(rec.getSuggestedStatus()); + suggestion.setSuggestedProgress(rec.getSuggestedProgress()); + suggestion.setReason(rec.getReason()); + suggestion.setConfidence(rec.getConfidence()); + suggestion.setStatus("pending"); + suggestion.setAppliedBy(null); + suggestion.setAppliedTime(null); + dailyReportUpdateSuggestionMapper.insert(suggestion); + } + } + /** * 构建项目上下文数据 */ @@ -272,7 +339,8 @@ public class DailyReportAnalysisServiceImpl implements DailyReportAnalysisServic if (!milestones.isEmpty()) { sb.append("【里程碑信息】\n"); for (ProjectMilestone milestone : milestones) { - sb.append(String.format("- %s (计划:%s, 状态:%s, 进度:%d%%)\n", + sb.append(String.format("- [milestoneId=%d] %s (计划:%s, 状态:%s, 进度:%d%%)\n", + milestone.getId(), milestone.getMilestoneName(), milestone.getPlanDate(), milestone.getStatus(), @@ -293,10 +361,12 @@ public class DailyReportAnalysisServiceImpl implements DailyReportAnalysisServic sb.append("【任务列表】\n"); for (Task task : tasks) { String assigneeInfo = task.getAssigneeId() != null ? "(负责人 ID: " + task.getAssigneeId() + ")" : "(未分配)"; - sb.append(String.format("- %s [%s] %s (计划:%s ~ %s, 状态:%s, 进度:%d%%)\n", + sb.append(String.format("- [taskId=%d] %s [%s] %s %s (计划:%s ~ %s, 状态:%s, 进度:%d%%)\n", + task.getId(), task.getTaskCode() != null ? task.getTaskCode() : "", task.getTaskType() != null ? task.getTaskType() : "task", task.getTaskName(), + assigneeInfo, task.getPlanStartDate(), task.getPlanEndDate(), task.getStatus(), diff --git a/src/main/java/cn/yinlihupo/service/analysis/impl/DailyReportSuggestionServiceImpl.java b/src/main/java/cn/yinlihupo/service/analysis/impl/DailyReportSuggestionServiceImpl.java new file mode 100644 index 0000000..8334f54 --- /dev/null +++ b/src/main/java/cn/yinlihupo/service/analysis/impl/DailyReportSuggestionServiceImpl.java @@ -0,0 +1,308 @@ +package cn.yinlihupo.service.analysis.impl; + +import cn.yinlihupo.common.enums.ErrorCode; +import cn.yinlihupo.common.exception.BusinessException; +import cn.yinlihupo.domain.dto.DailyReportAnalysisResult; +import cn.yinlihupo.domain.entity.DailyReportAnalysisRecord; +import cn.yinlihupo.domain.entity.DailyReportUpdateSuggestion; +import cn.yinlihupo.domain.entity.ProjectDailyReport; +import cn.yinlihupo.domain.entity.ProjectMilestone; +import cn.yinlihupo.domain.entity.Task; +import cn.yinlihupo.domain.vo.DailyReportAnalysisSuggestionsVO; +import cn.yinlihupo.domain.vo.DailyReportUpdateSuggestionVO; +import cn.yinlihupo.mapper.DailyReportAnalysisRecordMapper; +import cn.yinlihupo.mapper.DailyReportUpdateSuggestionMapper; +import cn.yinlihupo.mapper.ProjectDailyReportMapper; +import cn.yinlihupo.mapper.ProjectMilestoneMapper; +import cn.yinlihupo.mapper.TaskMapper; +import cn.yinlihupo.service.analysis.DailyReportSuggestionService; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class DailyReportSuggestionServiceImpl implements DailyReportSuggestionService { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper() + .findAndRegisterModules() + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + private final ProjectDailyReportMapper projectDailyReportMapper; + private final DailyReportAnalysisRecordMapper dailyReportAnalysisRecordMapper; + private final DailyReportUpdateSuggestionMapper dailyReportUpdateSuggestionMapper; + private final TaskMapper taskMapper; + private final ProjectMilestoneMapper projectMilestoneMapper; + + @Override + public DailyReportAnalysisSuggestionsVO getLatestSuggestionsByReportId(Long projectId, Long reportId) { + if (projectId == null || reportId == null) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "projectId/reportId不能为空"); + } + + DailyReportAnalysisRecord record = dailyReportAnalysisRecordMapper.selectOne( + new LambdaQueryWrapper() + .eq(DailyReportAnalysisRecord::getProjectId, projectId) + .eq(DailyReportAnalysisRecord::getReportId, reportId) + .eq(DailyReportAnalysisRecord::getDeleted, 0) + .orderByDesc(DailyReportAnalysisRecord::getCreateTime) + .last("LIMIT 1") + ); + if (record == null) { + throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "未找到日报分析结果"); + } + + return buildSuggestionsVO(record, "pending"); + } + + @Override + public DailyReportAnalysisSuggestionsVO getLatestSuggestionsByUniqueKey(Long projectId, LocalDate reportDate, String submitterUsername) { + if (projectId == null || reportDate == null || !StringUtils.hasText(submitterUsername)) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "projectId/reportDate/submitterUsername不能为空"); + } + + ProjectDailyReport report = projectDailyReportMapper.selectOne( + new LambdaQueryWrapper() + .eq(ProjectDailyReport::getProjectId, projectId) + .eq(ProjectDailyReport::getReportDate, reportDate) + .eq(ProjectDailyReport::getSubmitterUsername, submitterUsername) + .eq(ProjectDailyReport::getDeleted, 0) + .last("LIMIT 1") + ); + if (report == null) { + throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "未找到日报"); + } + + return getLatestSuggestionsByReportId(projectId, report.getId()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int applySuggestions(Long projectId, List suggestionIds, Long appliedBy) { + if (projectId == null || suggestionIds == null || suggestionIds.isEmpty()) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "projectId/suggestionIds不能为空"); + } + if (appliedBy == null) { + throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR, "用户未登录"); + } + + List suggestions = dailyReportUpdateSuggestionMapper.selectBatchIds(suggestionIds); + if (suggestions == null || suggestions.isEmpty()) { + throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "建议不存在"); + } + + Map suggestionMap = suggestions.stream() + .filter(Objects::nonNull) + .collect(Collectors.toMap(DailyReportUpdateSuggestion::getId, Function.identity(), (a, b) -> a)); + + for (Long id : suggestionIds) { + if (!suggestionMap.containsKey(id)) { + throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "建议不存在: " + id); + } + } + + for (DailyReportUpdateSuggestion suggestion : suggestions) { + if (suggestion == null) { + continue; + } + if (!Objects.equals(projectId, suggestion.getProjectId())) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "建议不属于该项目: " + suggestion.getId()); + } + if (suggestion.getDeleted() != null && suggestion.getDeleted() == 1) { + throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "建议已被删除: " + suggestion.getId()); + } + if (!"pending".equals(suggestion.getStatus())) { + throw new BusinessException(ErrorCode.OPERATION_ERROR, "建议状态不可应用: " + suggestion.getId()); + } + } + + LocalDateTime now = LocalDateTime.now(); + int appliedCount = 0; + for (DailyReportUpdateSuggestion suggestion : suggestions) { + if (suggestion == null) { + continue; + } + applySingleSuggestion(suggestion, now); + suggestion.setStatus("applied"); + suggestion.setAppliedBy(appliedBy); + suggestion.setAppliedTime(now); + dailyReportUpdateSuggestionMapper.updateById(suggestion); + appliedCount++; + } + return appliedCount; + } + + private void applySingleSuggestion(DailyReportUpdateSuggestion suggestion, LocalDateTime now) { + String targetType = suggestion.getTargetType(); + if (!StringUtils.hasText(targetType) || suggestion.getTargetId() == null) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "建议目标不完整: " + suggestion.getId()); + } + + if ("task".equalsIgnoreCase(targetType)) { + Task task = taskMapper.selectById(suggestion.getTargetId()); + if (task == null || task.getDeleted() == 1) { + throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "任务不存在: " + suggestion.getTargetId()); + } + if (!Objects.equals(suggestion.getProjectId(), task.getProjectId())) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "任务不属于该项目: " + suggestion.getTargetId()); + } + + Integer progress = suggestion.getSuggestedProgress(); + String status = suggestion.getSuggestedStatus(); + + if (progress != null) { + if (progress < 0 || progress > 100) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "任务进度建议必须在0-100之间: " + suggestion.getId()); + } + task.setProgress(progress); + if (progress == 100) { + task.setStatus("completed"); + } else if (progress > 0) { + task.setStatus("in_progress"); + } + } + + if (StringUtils.hasText(status)) { + task.setStatus(status); + if ("completed".equals(status)) { + task.setProgress(100); + } + } + + taskMapper.updateById(task); + return; + } + + if ("milestone".equalsIgnoreCase(targetType)) { + ProjectMilestone milestone = projectMilestoneMapper.selectById(suggestion.getTargetId()); + if (milestone == null || milestone.getDeleted() == 1) { + throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "里程碑不存在: " + suggestion.getTargetId()); + } + if (!Objects.equals(suggestion.getProjectId(), milestone.getProjectId())) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "里程碑不属于该项目: " + suggestion.getTargetId()); + } + + Integer progress = suggestion.getSuggestedProgress(); + String status = suggestion.getSuggestedStatus(); + + if (progress != null) { + if (progress < 0 || progress > 100) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "里程碑进度建议必须在0-100之间: " + suggestion.getId()); + } + milestone.setProgress(progress); + if (progress == 100) { + milestone.setStatus("completed"); + milestone.setActualDate(now.toLocalDate()); + } else if (progress > 0) { + milestone.setStatus("in_progress"); + } + } + + if (StringUtils.hasText(status)) { + milestone.setStatus(status); + if ("completed".equals(status)) { + milestone.setProgress(100); + milestone.setActualDate(now.toLocalDate()); + } + } + + projectMilestoneMapper.updateById(milestone); + return; + } + + throw new BusinessException(ErrorCode.PARAMS_ERROR, "不支持的targetType: " + targetType); + } + + private DailyReportAnalysisSuggestionsVO buildSuggestionsVO(DailyReportAnalysisRecord record, String suggestionStatus) { + DailyReportAnalysisResult analysisResult = null; + if (record.getAnalysisResult() != null) { + analysisResult = OBJECT_MAPPER.convertValue(record.getAnalysisResult(), DailyReportAnalysisResult.class); + } + + List suggestions = dailyReportUpdateSuggestionMapper.selectList( + new LambdaQueryWrapper() + .eq(DailyReportUpdateSuggestion::getAnalysisId, record.getId()) + .eq(DailyReportUpdateSuggestion::getDeleted, 0) + .eq(StringUtils.hasText(suggestionStatus), DailyReportUpdateSuggestion::getStatus, suggestionStatus) + .orderByAsc(DailyReportUpdateSuggestion::getCreateTime) + ); + + List taskIds = new ArrayList<>(); + List milestoneIds = new ArrayList<>(); + for (DailyReportUpdateSuggestion suggestion : suggestions) { + if (suggestion == null || suggestion.getTargetId() == null) { + continue; + } + if ("task".equalsIgnoreCase(suggestion.getTargetType())) { + taskIds.add(suggestion.getTargetId()); + } else if ("milestone".equalsIgnoreCase(suggestion.getTargetType())) { + milestoneIds.add(suggestion.getTargetId()); + } + } + + Map taskMap = taskIds.isEmpty() + ? Collections.emptyMap() + : taskMapper.selectBatchIds(taskIds).stream() + .filter(Objects::nonNull) + .collect(Collectors.toMap(Task::getId, Function.identity(), (a, b) -> a)); + + Map milestoneMap = milestoneIds.isEmpty() + ? Collections.emptyMap() + : projectMilestoneMapper.selectBatchIds(milestoneIds).stream() + .filter(Objects::nonNull) + .collect(Collectors.toMap(ProjectMilestone::getId, Function.identity(), (a, b) -> a)); + + List voList = new ArrayList<>(); + for (DailyReportUpdateSuggestion suggestion : suggestions) { + if (suggestion == null) { + continue; + } + DailyReportUpdateSuggestionVO vo = new DailyReportUpdateSuggestionVO(); + vo.setSuggestionId(suggestion.getId()); + vo.setTargetType(suggestion.getTargetType()); + vo.setTargetId(suggestion.getTargetId()); + vo.setSuggestedStatus(suggestion.getSuggestedStatus()); + vo.setSuggestedProgress(suggestion.getSuggestedProgress()); + vo.setReason(suggestion.getReason()); + vo.setConfidence(suggestion.getConfidence()); + vo.setStatus(suggestion.getStatus()); + + if ("task".equalsIgnoreCase(suggestion.getTargetType())) { + Task task = taskMap.get(suggestion.getTargetId()); + if (task != null) { + vo.setTargetName(task.getTaskName()); + vo.setCurrentStatus(task.getStatus()); + vo.setCurrentProgress(task.getProgress()); + } + } else if ("milestone".equalsIgnoreCase(suggestion.getTargetType())) { + ProjectMilestone milestone = milestoneMap.get(suggestion.getTargetId()); + if (milestone != null) { + vo.setTargetName(milestone.getMilestoneName()); + vo.setCurrentStatus(milestone.getStatus()); + vo.setCurrentProgress(milestone.getProgress()); + } + } + + voList.add(vo); + } + + DailyReportAnalysisSuggestionsVO vo = new DailyReportAnalysisSuggestionsVO(); + vo.setAnalysisId(record.getId()); + vo.setReportId(record.getReportId()); + vo.setProjectId(record.getProjectId()); + vo.setReportDate(record.getReportDate()); + vo.setOverallProgressAssessment(analysisResult != null ? analysisResult.getOverallProgressAssessment() : null); + vo.setSuggestions(voList); + return vo; + } +} diff --git a/src/main/java/cn/yinlihupo/service/project/impl/ProjectServiceImpl.java b/src/main/java/cn/yinlihupo/service/project/impl/ProjectServiceImpl.java index 61d866e..74b8887 100644 --- a/src/main/java/cn/yinlihupo/service/project/impl/ProjectServiceImpl.java +++ b/src/main/java/cn/yinlihupo/service/project/impl/ProjectServiceImpl.java @@ -44,7 +44,6 @@ public class ProjectServiceImpl implements ProjectService { private final ProjectMemberMapper projectMemberMapper; private final ResourceMapper resourceMapper; private final RiskMapper riskMapper; - private final ProjectTimelineMapper projectTimelineMapper; private final SysUserMapper sysUserMapper; /** @@ -136,15 +135,6 @@ public class ProjectServiceImpl implements ProjectService { "impact": 4, "mitigation_plan": "缓解措施" } - ], - "timeline_nodes": [ - { - "node_name": "时间节点名称", - "node_type": "milestone/phase/event", - "plan_date": "2024-06-01", - "description": "描述", - "kb_scope": ["report", "file", "risk", "ticket"] - } ] } ``` @@ -379,17 +369,6 @@ public class ProjectServiceImpl implements ProjectService { log.info("保存了 {} 个风险", result.getRisks().size()); } - // 7. 保存时间节点 - if (result.getTimelineNodes() != null && !result.getTimelineNodes().isEmpty()) { - for (int i = 0; i < result.getTimelineNodes().size(); i++) { - ProjectInitResult.TimelineNodeInfo nodeInfo = result.getTimelineNodes().get(i); - ProjectTimeline timeline = convertToTimeline(nodeInfo, projectId); - timeline.setSortOrder(i); - projectTimelineMapper.insert(timeline); - } - log.info("保存了 {} 个时间节点", result.getTimelineNodes().size()); - } - return projectId; } @@ -549,21 +528,6 @@ public class ProjectServiceImpl implements ProjectService { } } - /** - * 转换时间节点信息 - */ - private ProjectTimeline convertToTimeline(ProjectInitResult.TimelineNodeInfo info, Long projectId) { - ProjectTimeline timeline = new ProjectTimeline(); - timeline.setProjectId(projectId); - timeline.setNodeName(info.getNodeName()); - timeline.setNodeType(info.getNodeType()); - timeline.setPlanDate(info.getPlanDate()); - timeline.setDescription(info.getDescription()); - timeline.setStatus("pending"); - timeline.setKbScope(info.getKbScope()); - return timeline; - } - // ==================== 项目查询相关实现 ==================== @Override @@ -1105,31 +1069,6 @@ public class ProjectServiceImpl implements ProjectService { .count(); detailVO.setHighRiskCount(highRiskCount); - // 8. 查询时间节点 - List timelines = projectTimelineMapper.selectList( - new LambdaQueryWrapper() - .eq(ProjectTimeline::getProjectId, projectId) - .eq(ProjectTimeline::getDeleted, 0) - .orderByAsc(ProjectTimeline::getSortOrder) - ); - - List timelineVOList = timelines.stream() - .map(timeline -> { - ProjectDetailVO.TimelineInfo timelineInfo = new ProjectDetailVO.TimelineInfo(); - timelineInfo.setId(timeline.getId()); - timelineInfo.setNodeName(timeline.getNodeName()); - timelineInfo.setNodeType(timeline.getNodeType()); - timelineInfo.setPlanDate(timeline.getPlanDate()); - timelineInfo.setActualDate(timeline.getActualDate()); - timelineInfo.setDescription(timeline.getDescription()); - timelineInfo.setStatus(timeline.getStatus()); - timelineInfo.setSortOrder(timeline.getSortOrder()); - timelineInfo.setKbScope(timeline.getKbScope()); - return timelineInfo; - }) - .collect(Collectors.toList()); - detailVO.setTimelineNodes(timelineVOList); - return detailVO; } }