zhangwencui
8 天以前 f76bf27bf93e523ae4572d9e10e9ca541c324185
部分改动
已添加4个文件
已修改8个文件
1265 ■■■■■ 文件已修改
src/main/java/com/ruoyi/ai/config/EmbeddingStoreConfig.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/common/handler/LongListJacksonTypeHandler.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/projectManagement/controller/ProjectWorkflowController.java 144 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/projectManagement/dto/ProjectWorkflowDtos.java 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/projectManagement/dto/SaveInfoDto.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/projectManagement/pojo/Info.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/projectManagement/service/ProjectWorkflowService.java 833 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/projectManagement/service/impl/InfoServiceImpl.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/projectManagement/service/impl/handle/InfoHandleService.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/BusinessApprovalService.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/application-zxsq.yml 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/projectManagement/InfoMapper.xml 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/config/EmbeddingStoreConfig.java
@@ -3,6 +3,7 @@
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.store.embedding.EmbeddingStore;
import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore;
import dev.langchain4j.store.embedding.pinecone.PineconeEmbeddingStore;
import dev.langchain4j.store.embedding.pinecone.PineconeServerlessIndexConfig;
import io.pinecone.clients.Index;
@@ -26,6 +27,9 @@
    @Value("${pinecone.namespace:knowledge-base}")
    private String namespace;
    @Value("${pinecone.enabled:false}")
    private boolean pineconeEnabled;
    @Bean
    public Pinecone pinecone() {
        return new Pinecone.Builder(pineconeApiKey).build();
@@ -38,6 +42,11 @@
    @Bean
    public EmbeddingStore<TextSegment> embeddingStore(EmbeddingModel embeddingModel) {
        // é¡¹ç›®ç®¡ç†ç­‰æ ¸å¿ƒä¸šåŠ¡ä¸åº”å› å¤–éƒ¨å‘é‡åº“ä¸å¯ç”¨è€Œæ— æ³•å¯åŠ¨ã€‚
        // ç”Ÿäº§çŽ¯å¢ƒæ˜Žç¡®é…ç½® pinecone.enabled=true æ—¶ä»ä½¿ç”¨åŽŸ Pinecone å­˜å‚¨ã€‚
        if (!pineconeEnabled) {
            return new InMemoryEmbeddingStore<>();
        }
        return PineconeEmbeddingStore.builder()
                .apiKey(pineconeApiKey)
                .index(indexName)
@@ -49,4 +58,4 @@
                        .build())
                .build();
    }
}
}
src/main/java/com/ruoyi/common/handler/LongListJacksonTypeHandler.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,34 @@
package com.ruoyi.common.handler;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import java.lang.reflect.Field;
import java.util.Collections;
import java.util.List;
/**
 * Ensures numeric JSON arrays are materialized as Long values.
 * Jackson otherwise reads small JSON numbers as Integer when MyBatis only
 * supplies the raw List class, which later breaks typed response serialization.
 */
public class LongListJacksonTypeHandler extends JacksonTypeHandler {
    public LongListJacksonTypeHandler(Class<?> type) {
        super(type);
    }
    public LongListJacksonTypeHandler(Class<?> type, Field field) {
        super(type, field);
    }
    @Override
    public Object parse(String json) {
        Object value = super.parse(json);
        if (!(value instanceof List<?> values)) {
            return Collections.emptyList();
        }
        return values.stream()
                .map(item -> item == null ? null : ((Number) item).longValue())
                .toList();
    }
}
src/main/java/com/ruoyi/projectManagement/controller/ProjectWorkflowController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,144 @@
package com.ruoyi.projectManagement.controller;
import com.ruoyi.framework.web.domain.AjaxResult;
import com.ruoyi.projectManagement.dto.ProjectWorkflowDtos;
import com.ruoyi.projectManagement.service.ProjectWorkflowService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/projectManagement/workflow")
@Tag(name = "项目执行工作台")
@RequiredArgsConstructor
public class ProjectWorkflowController {
    private final ProjectWorkflowService service;
    @GetMapping("/{projectId}")
    @Operation(summary = "项目模块、跟进、汇报、统计和资料")
    public AjaxResult workbench(@PathVariable Long projectId) {
        return AjaxResult.success(service.getWorkbench(projectId));
    }
    @PreAuthorize("@ss.hasPermi('project:module:edit')")
    @PostMapping("/start/{projectId}")
    public AjaxResult startProject(@PathVariable Long projectId) {
        service.startProject(projectId);
        return AjaxResult.success();
    }
    @PostMapping("/project-executors")
    public AjaxResult updateProjectExecutors(@RequestBody ProjectWorkflowDtos.ProjectExecutorsRequest request) {
        service.updateProjectExecutors(request);
        return AjaxResult.success();
    }
    @PostMapping("/transition")
    public AjaxResult transition(@RequestBody ProjectWorkflowDtos.StageTransitionRequest request) {
        service.transitionStage(request);
        return AjaxResult.success();
    }
    @PreAuthorize("@ss.hasPermi('project:module:edit')")
    @PostMapping("/module")
    public AjaxResult saveModule(@RequestBody ProjectWorkflowDtos.ModuleSaveRequest request) {
        return AjaxResult.success(service.saveModule(request));
    }
    @PreAuthorize("@ss.hasPermi('project:module:remove')")
    @DeleteMapping("/module/{id}")
    public AjaxResult deleteModule(@PathVariable Long id) {
        service.deleteModule(id);
        return AjaxResult.success();
    }
    @PreAuthorize("@ss.hasPermi('project:follow:add')")
    @PostMapping("/follow-up")
    public AjaxResult saveFollowUp(@RequestBody ProjectWorkflowDtos.FollowUpRequest request) {
        return AjaxResult.success(service.saveFollowUp(request));
    }
    @PreAuthorize("@ss.hasPermi('project:follow:remove')")
    @DeleteMapping("/follow-up/{id}")
    public AjaxResult deleteFollowUp(@PathVariable Long id) {
        service.softDelete("project_management_follow_up", id);
        return AjaxResult.success();
    }
    @PreAuthorize("@ss.hasPermi('project:report:add')")
    @PostMapping("/report")
    public AjaxResult saveReport(@RequestBody ProjectWorkflowDtos.ProgressReportRequest request) {
        return AjaxResult.success(service.saveReport(request));
    }
    @PreAuthorize("@ss.hasPermi('project:report:remove')")
    @DeleteMapping("/report/{id}")
    public AjaxResult deleteReport(@PathVariable Long id) {
        service.softDelete("project_management_progress_report", id);
        return AjaxResult.success();
    }
    @PreAuthorize("@ss.hasPermi('project:material:add')")
    @PostMapping("/material")
    public AjaxResult saveMaterial(@RequestBody ProjectWorkflowDtos.MaterialRequest request) {
        return AjaxResult.success(service.saveMaterial(request));
    }
    @PreAuthorize("@ss.hasPermi('project:phase:edit')")
    @PostMapping("/phase")
    public AjaxResult savePhase(@RequestBody ProjectWorkflowDtos.PhaseRequest request) {
        return AjaxResult.success(service.savePhase(request));
    }
    @PreAuthorize("@ss.hasPermi('project:material:remove')")
    @DeleteMapping("/material/{id}")
    public AjaxResult deleteMaterial(@PathVariable Long id) {
        service.softDelete("project_management_material", id);
        return AjaxResult.success();
    }
    @GetMapping("/category/list")
    public AjaxResult categoryList() {
        return AjaxResult.success(service.listCategories());
    }
    @PreAuthorize("@ss.hasPermi('project:category:edit')")
    @PostMapping("/category")
    public AjaxResult saveCategory(@RequestBody ProjectWorkflowDtos.CategoryRequest request) {
        return AjaxResult.success(service.saveCategory(request));
    }
    @PreAuthorize("@ss.hasPermi('project:category:remove')")
    @DeleteMapping("/category/{id}")
    public AjaxResult deleteCategory(@PathVariable Long id) {
        service.deleteCategory(id);
        return AjaxResult.success();
    }
    @GetMapping("/policy/list")
    public AjaxResult policyList(@RequestParam(required = false) String keyword) {
        return AjaxResult.success(service.listPolicies(keyword));
    }
    @PreAuthorize("@ss.hasPermi('policy:file:edit')")
    @PostMapping("/policy")
    public AjaxResult savePolicy(@RequestBody ProjectWorkflowDtos.PolicyRequest request) {
        return AjaxResult.success(service.savePolicy(request));
    }
    @PreAuthorize("@ss.hasPermi('policy:file:remove')")
    @DeleteMapping("/policy/{id}")
    public AjaxResult deletePolicy(@PathVariable Long id) {
        service.softDelete("government_policy_file", id);
        return AjaxResult.success();
    }
}
src/main/java/com/ruoyi/projectManagement/dto/ProjectWorkflowDtos.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,109 @@
package com.ruoyi.projectManagement.dto;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/** é¡¹ç›®æ‰§è¡Œå·¥ä½œå°ä½¿ç”¨çš„轻量请求对象。 */
public final class ProjectWorkflowDtos {
    private ProjectWorkflowDtos() {
    }
    @Data
    public static class ModuleSaveRequest {
        private Long id;
        private Long projectId;
        private String moduleName;
        private LocalDate plannedStartDate;
        private String summary;
        private BigDecimal projectWeight;
        private List<MemberRequest> members = new ArrayList<>();
    }
    @Data
    public static class MemberRequest {
        private Long userId;
        private String userName;
        private BigDecimal moduleShare;
    }
    @Data
    public static class FollowUpRequest {
        private Long id;
        private Long projectId;
        private LocalDate followDate;
        private String detail;
        private List<Map<String, Object>> attachments = new ArrayList<>();
    }
    @Data
    public static class ProgressReportRequest {
        private Long id;
        private Long projectId;
        private Long moduleId;
        private List<Long> reporterIds = new ArrayList<>();
        private List<String> reporterNames = new ArrayList<>();
        /** æœ¬æ¬¡æ±‡æŠ¥ä¸­æ¯ä½æ‰§è¡Œäººçš„工作量分配,占比合计必须为100%。 */
        private List<MemberRequest> reporterShares = new ArrayList<>();
        private BigDecimal progress;
        private LocalDate reportDate;
        private String remark;
        private List<Map<String, Object>> attachments = new ArrayList<>();
    }
    @Data
    public static class MaterialRequest {
        private Long id;
        private Long projectId;
        private String materialName;
        private String materialType;
        private String remark;
        private List<Map<String, Object>> attachments = new ArrayList<>();
    }
    @Data
    public static class ProjectExecutorsRequest {
        private Long projectId;
        private List<MemberRequest> executors = new ArrayList<>();
    }
    @Data
    public static class StageTransitionRequest {
        private Long projectId;
        private String targetStage;
    }
    @Data
    public static class PhaseRequest {
        private Long projectId;
        private String phaseCode;
        private String status;
        private Map<String, Object> formData;
        private String remark;
        private List<Map<String, Object>> attachments = new ArrayList<>();
    }
    @Data
    public static class CategoryRequest {
        private Long id;
        private Long parentId;
        private String categoryName;
        private Integer sort;
        private String remark;
    }
    @Data
    public static class PolicyRequest {
        private Long id;
        private String policyName;
        private LocalDate publishDate;
        private String policyUrl;
        private String maintainerName;
        private String remark;
        private List<Map<String, Object>> attachments = new ArrayList<>();
    }
}
src/main/java/com/ruoyi/projectManagement/dto/SaveInfoDto.java
@@ -9,7 +9,6 @@
import org.springframework.format.annotation.DateTimeFormat;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.io.Serializable;
import java.time.LocalDate;
import java.time.LocalDateTime;
@@ -37,7 +36,6 @@
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate establishTime;
    // é¡¹ç›®ç±»åž‹ id
    @NotNull(message = "projectManagementPlanId为空")
    private Long projectManagementPlanId;
    private String source;
@@ -45,6 +43,17 @@
    // é¡¹ç›®åˆ†ç±»
    private String projectClassification;
    private Long projectLeaderId;
    private String projectLeaderName;
    private List<Long> projectManagerIds;
    private List<String> projectManagerNames;
    private List<Long> projectExecutorIds;
    private List<String> projectExecutorNames;
    private String followCycle;
    private String followDetail;
    private String currentPhase;
    private Long sourceBusinessId;
    private Long managerId;
    private String managerName;
    private Long salesmanId;
src/main/java/com/ruoyi/projectManagement/pojo/Info.java
@@ -8,6 +8,7 @@
import java.util.List;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.ruoyi.common.handler.LongListJacksonTypeHandler;
import com.ruoyi.projectManagement.dto.PlanStageDto;
import com.ruoyi.projectManagement.dto.TeamDto;
import lombok.Data;
@@ -78,6 +79,41 @@
     */
    @TableField(value = "project_classification")
    private String projectClassification;
    /** é¡¹ç›®è´Ÿè´£äººã€‚ */
    @TableField(value = "project_leader_id")
    private Long projectLeaderId;
    @TableField(value = "project_leader_name")
    private String projectLeaderName;
    /** å¤šåé¡¹ç›®ç»ç†ï¼Œä½¿ç”¨ JSON æ•°ç»„保存账号与显示名。 */
    @TableField(value = "project_manager_ids", typeHandler = LongListJacksonTypeHandler.class)
    private List<Long> projectManagerIds;
    @TableField(value = "project_manager_names", typeHandler = JacksonTypeHandler.class)
    private List<String> projectManagerNames;
    /** é¡¹ç›®æ‰§è¡Œäººï¼Œå¯åœ¨é¡¹ç›®å¼€å§‹åŽæŒ‰è§„则调整。 */
    @TableField(value = "project_executor_ids", typeHandler = LongListJacksonTypeHandler.class)
    private List<Long> projectExecutorIds;
    @TableField(value = "project_executor_names", typeHandler = JacksonTypeHandler.class)
    private List<String> projectExecutorNames;
    /** MONTH / QUARTER / YEAR。 */
    @TableField(value = "follow_cycle")
    private String followCycle;
    @TableField(value = "follow_detail")
    private String followDetail;
    /** ç«‹é¡¹ã€å¼€å‘、申报、申报结果、验收。 */
    @TableField(value = "current_phase")
    private String currentPhase;
    @TableField(value = "source_business_id")
    private Long sourceBusinessId;
    /**
     * é¡¹ç›®ç»ç†id
@@ -222,4 +258,4 @@
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
}
}
src/main/java/com/ruoyi/projectManagement/service/ProjectWorkflowService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,833 @@
package com.ruoyi.projectManagement.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.projectManagement.dto.ProjectWorkflowDtos;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Service
@RequiredArgsConstructor
public class ProjectWorkflowService {
    private static final TypeReference<List<Map<String, Object>>> FILE_LIST_TYPE = new TypeReference<>() { };
    private static final List<String> PHASE_CODES = List.of("INITIATION", "DEVELOPMENT", "APPLICATION", "RESULT", "ACCEPTANCE");
    private static final List<String> PHASE_NAMES = List.of("立项", "开发", "申报", "申报结果", "验收");
    private final NamedParameterJdbcTemplate jdbc;
    private final ObjectMapper objectMapper;
    public Map<String, Object> getWorkbench(Long projectId) {
        requireProject(projectId);
        List<Map<String, Object>> modules = query("""
                select m.id, m.project_id as projectId, m.module_name as moduleName,
                       m.planned_start_date as plannedStartDate, m.summary,
                       m.project_weight as projectWeight, m.current_progress as currentProgress,
                       (select count(*) from project_management_progress_report r where r.module_id=m.id and r.is_deleted=0) as reportCount,
                       m.create_time as createTime, m.update_time as updateTime
                from project_management_module m
                where m.project_id=:projectId and m.is_deleted=0 order by m.id
                """, Map.of("projectId", projectId));
        for (Map<String, Object> module : modules) {
            Long moduleId = asLong(module.get("id"));
            module.put("members", query("""
                    select id, module_id as moduleId, user_id as userId, user_name as userName,
                           module_share as moduleShare
                    from project_management_module_member
                    where module_id=:moduleId and is_deleted=0 order by id
                    """, Map.of("moduleId", moduleId)));
        }
        List<Map<String, Object>> followUps = query("""
                select id, project_id as projectId, follow_date as followDate, detail,
                       attachments, create_user_id as createUserId, create_user_name as createUserName,
                       create_time as createTime
                from project_management_follow_up
                where project_id=:projectId and is_deleted=0 order by follow_date desc,id desc
                """, Map.of("projectId", projectId));
        parseAttachments(followUps);
        List<Map<String, Object>> reports = query("""
                select r.id, r.project_id as projectId, r.module_id as moduleId,
                       m.module_name as moduleName, r.reporter_ids as reporterIds,
                       r.reporter_names as reporterNames, r.progress, r.report_date as reportDate,
                       r.reporter_shares as reporterShares, r.remark, r.attachments, r.create_time as createTime
                from project_management_progress_report r
                left join project_management_module m on m.id=r.module_id
                where r.project_id=:projectId and r.is_deleted=0 order by r.report_date desc,r.id desc
                """, Map.of("projectId", projectId));
        parseAttachments(reports);
        for (Map<String, Object> row : reports) {
            row.put("reporterIdList", parseLongList(row.get("reporterIds")));
            row.put("reporterNameList", parseStringList(row.get("reporterNames")));
            row.put("reporterShareList", parseFileList(row.get("reporterShares")));
        }
        List<Map<String, Object>> materials = query("""
                select id, project_id as projectId, material_name as materialName,
                       material_type as materialType, remark, attachments,
                       create_user_name as createUserName, create_time as createTime
                from project_management_material
                where project_id=:projectId and is_deleted=0 order by id desc
                """, Map.of("projectId", projectId));
        parseAttachments(materials);
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("modules", modules);
        result.put("followUps", followUps);
        result.put("reports", reports);
        result.put("materials", materials);
        result.put("sourceBusiness", getSourceBusiness(projectId));
        result.put("memberStatistics", memberStatistics(projectId));
        result.put("followWindow", getFollowWindow(projectId));
        return result;
    }
    @Transactional(rollbackFor = Exception.class)
    public void startProject(Long projectId) {
        requireProject(projectId);
        Integer basicReady = jdbc.queryForObject("""
                select count(*) from project_management_info
                where id=:projectId and is_delete=0 and project_classification is not null
                and project_classification<>'' and plan_start_time is not null and plan_end_time is not null
                and project_leader_id is not null and project_manager_ids is not null
                and json_length(project_manager_ids)>0 and follow_cycle is not null and follow_cycle<>''
                and project_executor_ids is not null and json_length(project_executor_ids)>0
                """, Map.of("projectId", projectId), Integer.class);
        if (basicReady == null || basicReady == 0) throw new ServiceException("请先完整填写项目基本情况");
        List<Map<String, Object>> modules = query("""
                select id,module_name as moduleName,planned_start_date as plannedStartDate,
                       summary,project_weight as projectWeight
                from project_management_module where project_id=:projectId and is_deleted=0
                """, Map.of("projectId", projectId));
        if (modules.isEmpty()) throw new ServiceException("项目开始前请至少添加一个项目模块");
        BigDecimal total = BigDecimal.ZERO;
        for (Map<String, Object> module : modules) {
            if (isBlank(Objects.toString(module.get("moduleName"), "")) || module.get("plannedStartDate") == null
                    || isBlank(Objects.toString(module.get("summary"), ""))) {
                throw new ServiceException("请完整填写每个模块的名称、预计开始时间和模块概要");
            }
            total = total.add(nvl((BigDecimal) module.get("projectWeight")));
            Long moduleId = asLong(module.get("id"));
            Integer memberCount = jdbc.queryForObject("""
                    select count(*) from project_management_module_member
                    where module_id=:moduleId and is_deleted=0
                    """, Map.of("moduleId", moduleId), Integer.class);
            if (memberCount == null || memberCount == 0) throw new ServiceException("每个模块至少选择一名执行人");
        }
        if (total.setScale(2, RoundingMode.HALF_UP).compareTo(BigDecimal.valueOf(100).setScale(2)) != 0) {
            throw new ServiceException("项目开始前,所有模块的项目工作量占比合计必须为100%");
        }
        jdbc.update("update project_management_info set current_phase='开发',update_time=now() where id=:projectId", Map.of("projectId", projectId));
    }
    @Transactional(rollbackFor = Exception.class)
    public void transitionStage(ProjectWorkflowDtos.StageTransitionRequest request) {
        requireProject(request.getProjectId());
        String current = jdbc.queryForObject("select current_phase from project_management_info where id=:id and is_delete=0",
                Map.of("id", request.getProjectId()), String.class);
        if ("立项".equals(current)) current = "开发";
        Map<String, String> nextStages = Map.of(
                "开发", "申报",
                "申报", "申报结果",
                "申报结果", "验收",
                "验收", "已完成"
        );
        String expected = nextStages.get(current);
        if (expected == null || !expected.equals(request.getTargetStage())) {
            throw new ServiceException("项目阶段只能按开发、申报、申报结果、验收、已完成的顺序推进");
        }
        if ("开发".equals(current)) {
            Integer unfinished = jdbc.queryForObject("""
                    select count(*) from project_management_module
                    where project_id=:projectId and is_deleted=0 and current_progress<100
                    """, Map.of("projectId", request.getProjectId()), Integer.class);
            if (unfinished != null && unfinished > 0) {
                throw new ServiceException("所有项目模块进度达到100%后才能进入申报阶段");
            }
        }
        jdbc.update("update project_management_info set current_phase=:stage,update_time=now() where id=:projectId",
                new MapSqlParameterSource().addValue("stage", expected).addValue("projectId", request.getProjectId()));
    }
    @Transactional(rollbackFor = Exception.class)
    public void updateProjectExecutors(ProjectWorkflowDtos.ProjectExecutorsRequest request) {
        requireProject(request.getProjectId());
        validateMembers(request.getExecutors());
        List<Map<String, Object>> rows = query("""
                select project_executor_ids as executorIds from project_management_info
                where id=:projectId and is_delete=0
                """, Map.of("projectId", request.getProjectId()));
        List<Long> oldIds = rows.isEmpty() ? List.of() : parseLongList(rows.getFirst().get("executorIds"));
        List<Long> newIds = request.getExecutors().stream().map(ProjectWorkflowDtos.MemberRequest::getUserId).toList();
        for (Long removedId : oldIds.stream().filter(id -> !newIds.contains(id)).toList()) {
            if (hasPositiveProjectShare(request.getProjectId(), removedId)) {
                throw new ServiceException("该项目执行人已有项目汇报工作占比,不能删除");
            }
        }
        jdbc.update("""
                update project_management_info set project_executor_ids=:ids,project_executor_names=:names,update_time=now()
                where id=:projectId and is_delete=0
                """, new MapSqlParameterSource().addValue("projectId", request.getProjectId())
                .addValue("ids", json(newIds))
                .addValue("names", json(request.getExecutors().stream().map(ProjectWorkflowDtos.MemberRequest::getUserName).toList())));
    }
    @Transactional(rollbackFor = Exception.class)
    public Long savePhase(ProjectWorkflowDtos.PhaseRequest request) {
        requireProject(request.getProjectId());
        ensurePhases(request.getProjectId());
        int index = PHASE_CODES.indexOf(Objects.toString(request.getPhaseCode(), ""));
        if (index < 0) throw new ServiceException("项目阶段不正确");
        String status = Objects.toString(request.getStatus(), "IN_PROGRESS").toUpperCase();
        if (!List.of("NOT_STARTED", "IN_PROGRESS", "COMPLETED").contains(status)) {
            throw new ServiceException("阶段状态不正确");
        }
        if (index > 0 && !"NOT_STARTED".equals(status)) {
            Integer previousDone = jdbc.queryForObject("""
                    select count(*) from project_management_phase
                    where project_id=:projectId and phase_code=:phaseCode and status='COMPLETED' and is_deleted=0
                    """, new MapSqlParameterSource().addValue("projectId", request.getProjectId())
                    .addValue("phaseCode", PHASE_CODES.get(index - 1)), Integer.class);
            if (previousDone == null || previousDone == 0) {
                throw new ServiceException("请先完成上一阶段“" + PHASE_NAMES.get(index - 1) + "”");
            }
        }
        MapSqlParameterSource p = new MapSqlParameterSource()
                .addValue("projectId", request.getProjectId()).addValue("phaseCode", request.getPhaseCode())
                .addValue("status", status).addValue("formData", jsonObject(request.getFormData()))
                .addValue("remark", request.getRemark()).addValue("attachments", json(request.getAttachments()))
                .addValue("userName", currentUserName());
        jdbc.update("""
                update project_management_phase set status=:status,form_data=:formData,remark=:remark,
                attachments=:attachments,completed_time=case when :status='COMPLETED' then now() else null end,
                update_user_name=:userName,update_time=now()
                where project_id=:projectId and phase_code=:phaseCode and is_deleted=0
                """, p);
        String currentPhase = "COMPLETED".equals(status) && index == PHASE_CODES.size() - 1
                ? "已完成" : PHASE_NAMES.get(index);
        jdbc.update("update project_management_info set current_phase=:phase,update_time=now() where id=:projectId",
                new MapSqlParameterSource().addValue("phase", currentPhase).addValue("projectId", request.getProjectId()));
        return jdbc.queryForObject("select id from project_management_phase where project_id=:projectId and phase_code=:phaseCode and is_deleted=0",
                p, Long.class);
    }
    @Transactional(rollbackFor = Exception.class)
    public Long saveModule(ProjectWorkflowDtos.ModuleSaveRequest request) {
        requireProject(request.getProjectId());
        if (isBlank(request.getModuleName())) {
            throw new ServiceException("请输入模块名称");
        }
        if (request.getPlannedStartDate() == null) throw new ServiceException("请选择模块预计开始时间");
        if (isBlank(request.getSummary())) throw new ServiceException("请输入模块概要");
        BigDecimal weight = percent(request.getProjectWeight(), "模块在项目中的工作量占比");
        BigDecimal otherWeight = jdbc.queryForObject("""
                select coalesce(sum(project_weight),0) from project_management_module
                where project_id=:projectId and is_deleted=0 and (:id is null or id<>:id)
                """, new MapSqlParameterSource().addValue("projectId", request.getProjectId()).addValue("id", request.getId()), BigDecimal.class);
        BigDecimal available = BigDecimal.valueOf(100).subtract(nvl(otherWeight));
        if (weight.compareTo(available) > 0) {
            throw new ServiceException("其他模块已占" + nvl(otherWeight).stripTrailingZeros().toPlainString()
                    + "%,当前模块最多可设置" + available.stripTrailingZeros().toPlainString() + "%");
        }
        validateMembers(request.getMembers());
        List<Long> projectExecutorIds = projectExecutorIds(request.getProjectId());
        if (request.getMembers().stream().anyMatch(member -> !projectExecutorIds.contains(member.getUserId()))) {
            throw new ServiceException("模块执行人必须先加入项目执行人");
        }
        if (request.getId() != null) {
            Integer reportCount = jdbc.queryForObject("select count(*) from project_management_progress_report where module_id=:id and is_deleted=0",
                    Map.of("id", request.getId()), Integer.class);
            if (reportCount != null && reportCount > 0) {
                Map<String, Object> old = query("""
                        select module_name as moduleName,planned_start_date as plannedStartDate,
                               summary,project_weight as projectWeight from project_management_module
                        where id=:id and project_id=:projectId and is_deleted=0
                        """, new MapSqlParameterSource().addValue("id", request.getId()).addValue("projectId", request.getProjectId())).stream()
                        .findFirst().orElseThrow(() -> new ServiceException("项目模块不存在"));
                boolean changed = !Objects.equals(Objects.toString(old.get("moduleName"), ""), request.getModuleName().trim())
                        || !Objects.equals(Objects.toString(old.get("plannedStartDate"), ""), Objects.toString(request.getPlannedStartDate(), ""))
                        || !Objects.equals(Objects.toString(old.get("summary"), ""), Objects.toString(request.getSummary(), ""))
                        || nvl((BigDecimal) old.get("projectWeight")).compareTo(weight) != 0;
                if (changed) throw new ServiceException("该模块已有进度汇报,模块基础信息和占比不能修改,仅可调整执行人");
            }
            List<Long> oldMemberIds = query("""
                    select user_id as userId from project_management_module_member
                    where module_id=:moduleId and is_deleted=0
                    """, Map.of("moduleId", request.getId())).stream().map(row -> asLong(row.get("userId"))).toList();
            List<Long> newMemberIds = request.getMembers().stream().map(ProjectWorkflowDtos.MemberRequest::getUserId).toList();
            for (Long removedId : oldMemberIds.stream().filter(id -> !newMemberIds.contains(id)).toList()) {
                if (hasPositiveModuleShare(request.getId(), removedId)) {
                    throw new ServiceException("该模块执行人已有本模块工作占比,不能删除");
                }
            }
        }
        Long moduleId = request.getId();
        MapSqlParameterSource params = new MapSqlParameterSource()
                .addValue("projectId", request.getProjectId())
                .addValue("moduleName", request.getModuleName().trim())
                .addValue("plannedStartDate", request.getPlannedStartDate())
                .addValue("summary", request.getSummary())
                .addValue("projectWeight", weight);
        if (moduleId == null) {
            GeneratedKeyHolder keyHolder = new GeneratedKeyHolder();
            jdbc.update("""
                    insert into project_management_module
                    (project_id,module_name,planned_start_date,summary,project_weight,current_progress,is_deleted,create_time,update_time)
                    values (:projectId,:moduleName,:plannedStartDate,:summary,:projectWeight,0,0,now(),now())
                    """, params, keyHolder, new String[]{"id"});
            moduleId = Objects.requireNonNull(keyHolder.getKey()).longValue();
        } else {
            params.addValue("id", moduleId);
            int rows = jdbc.update("""
                    update project_management_module set module_name=:moduleName,
                    planned_start_date=:plannedStartDate,summary=:summary,project_weight=:projectWeight,update_time=now()
                    where id=:id and project_id=:projectId and is_deleted=0
                    """, params);
            if (rows == 0) throw new ServiceException("项目模块不存在");
            jdbc.update("update project_management_module_member set is_deleted=1 where module_id=:id", Map.of("id", moduleId));
        }
        for (ProjectWorkflowDtos.MemberRequest member : request.getMembers()) {
            jdbc.update("""
                    insert into project_management_module_member
                    (module_id,user_id,user_name,module_share,is_deleted,create_time,update_time)
                    values (:moduleId,:userId,:userName,:moduleShare,0,now(),now())
                    """, new MapSqlParameterSource()
                    .addValue("moduleId", moduleId)
                    .addValue("userId", member.getUserId())
                    .addValue("userName", member.getUserName())
                    .addValue("moduleShare", BigDecimal.ZERO));
        }
        return moduleId;
    }
    @Transactional(rollbackFor = Exception.class)
    public void deleteModule(Long id) {
        int count = jdbc.queryForObject("select count(*) from project_management_progress_report where module_id=:id and is_deleted=0", Map.of("id", id), Integer.class);
        if (count > 0) throw new ServiceException("该模块已有进度汇报,不能删除");
        jdbc.update("update project_management_module set is_deleted=1,update_time=now() where id=:id", Map.of("id", id));
        jdbc.update("update project_management_module_member set is_deleted=1,update_time=now() where module_id=:id", Map.of("id", id));
    }
    @Transactional(rollbackFor = Exception.class)
    public Long saveFollowUp(ProjectWorkflowDtos.FollowUpRequest request) {
        requireProject(request.getProjectId());
        Long leaderId = jdbc.queryForObject("select project_leader_id from project_management_info where id=:id",
                Map.of("id", request.getProjectId()), Long.class);
        if (leaderId == null || !leaderId.equals(SecurityUtils.getUserId())) {
            throw new ServiceException("只有项目负责人可以填写项目跟进");
        }
        if (request.getFollowDate() == null || isBlank(request.getDetail())) {
            throw new ServiceException("请填写跟进日期和跟进详情");
        }
        validateFollowWindow(request.getProjectId(), request.getFollowDate(), request.getId());
        MapSqlParameterSource p = new MapSqlParameterSource()
                .addValue("id", request.getId()).addValue("projectId", request.getProjectId())
                .addValue("followDate", request.getFollowDate()).addValue("detail", request.getDetail())
                .addValue("attachments", json(request.getAttachments()))
                .addValue("userId", SecurityUtils.getUserId()).addValue("userName", currentUserName());
        if (request.getId() == null) {
            GeneratedKeyHolder keys = new GeneratedKeyHolder();
            jdbc.update("""
                    insert into project_management_follow_up
                    (project_id,follow_date,detail,attachments,create_user_id,create_user_name,is_deleted,create_time,update_time)
                    values (:projectId,:followDate,:detail,:attachments,:userId,:userName,0,now(),now())
                    """, p, keys, new String[]{"id"});
            return Objects.requireNonNull(keys.getKey()).longValue();
        }
        jdbc.update("""
                update project_management_follow_up set follow_date=:followDate,detail=:detail,
                attachments=:attachments,update_time=now() where id=:id and project_id=:projectId and is_deleted=0
                """, p);
        return request.getId();
    }
    @Transactional(rollbackFor = Exception.class)
    public Long saveReport(ProjectWorkflowDtos.ProgressReportRequest request) {
        requireProject(request.getProjectId());
        if (request.getModuleId() == null || request.getReportDate() == null) {
            throw new ServiceException("请选择项目模块和汇报时间");
        }
        BigDecimal progress = percent(request.getProgress(), "模块进度");
        BigDecimal current = jdbc.queryForObject("""
                select current_progress from project_management_module
                where id=:moduleId and project_id=:projectId and is_deleted=0
                """, new MapSqlParameterSource().addValue("moduleId", request.getModuleId()).addValue("projectId", request.getProjectId()), BigDecimal.class);
        if (current == null) throw new ServiceException("项目模块不存在");
        if (progress.compareTo(current) < 0) throw new ServiceException("本次进度不能低于模块当前进度");
        List<ProjectWorkflowDtos.MemberRequest> reporterShares = normalizeReporterShares(request);
        validateReporterShares(reporterShares);
        List<Long> moduleMemberIds = query("""
                select user_id as userId from project_management_module_member
                where module_id=:moduleId and is_deleted=0
                """, Map.of("moduleId", request.getModuleId())).stream().map(row -> asLong(row.get("userId"))).toList();
        if (!moduleMemberIds.contains(SecurityUtils.getUserId())) throw new ServiceException("只有该模块执行人可以提交进度汇报");
        if (reporterShares.stream().anyMatch(member -> !moduleMemberIds.contains(member.getUserId()))) {
            throw new ServiceException("汇报人员只能选择该模块执行人");
        }
        request.setReporterIds(reporterShares.stream().map(ProjectWorkflowDtos.MemberRequest::getUserId).toList());
        request.setReporterNames(reporterShares.stream().map(ProjectWorkflowDtos.MemberRequest::getUserName).toList());
        MapSqlParameterSource p = new MapSqlParameterSource()
                .addValue("id", request.getId()).addValue("projectId", request.getProjectId())
                .addValue("moduleId", request.getModuleId())
                .addValue("reporterIds", json(request.getReporterIds()))
                .addValue("reporterNames", json(request.getReporterNames()))
                .addValue("reporterShares", json(reporterShares))
                .addValue("progress", progress).addValue("reportDate", request.getReportDate())
                .addValue("remark", request.getRemark()).addValue("attachments", json(request.getAttachments()));
        Long reportId = request.getId();
        if (reportId == null) {
            GeneratedKeyHolder keys = new GeneratedKeyHolder();
            jdbc.update("""
                    insert into project_management_progress_report
                    (project_id,module_id,reporter_ids,reporter_names,reporter_shares,progress,report_date,remark,attachments,is_deleted,create_time,update_time)
                    values (:projectId,:moduleId,:reporterIds,:reporterNames,:reporterShares,:progress,:reportDate,:remark,:attachments,0,now(),now())
                    """, p, keys, new String[]{"id"});
            reportId = Objects.requireNonNull(keys.getKey()).longValue();
        } else {
            jdbc.update("""
                    update project_management_progress_report set module_id=:moduleId,reporter_ids=:reporterIds,
                    reporter_names=:reporterNames,reporter_shares=:reporterShares,progress=:progress,report_date=:reportDate,remark=:remark,
                    attachments=:attachments,update_time=now()
                    where id=:id and project_id=:projectId and is_deleted=0
                    """, p);
        }
        jdbc.update("update project_management_module set current_progress=:progress,update_time=now() where id=:moduleId",
                new MapSqlParameterSource().addValue("progress", progress).addValue("moduleId", request.getModuleId()));
        return reportId;
    }
    @Transactional(rollbackFor = Exception.class)
    public Long saveMaterial(ProjectWorkflowDtos.MaterialRequest request) {
        requireProject(request.getProjectId());
        if (isBlank(request.getMaterialName())) throw new ServiceException("请输入资料名称");
        MapSqlParameterSource p = new MapSqlParameterSource()
                .addValue("id", request.getId()).addValue("projectId", request.getProjectId())
                .addValue("materialName", request.getMaterialName()).addValue("materialType", request.getMaterialType())
                .addValue("remark", request.getRemark()).addValue("attachments", json(request.getAttachments()))
                .addValue("userId", SecurityUtils.getUserId()).addValue("userName", currentUserName());
        if (request.getId() == null) {
            GeneratedKeyHolder keys = new GeneratedKeyHolder();
            jdbc.update("""
                    insert into project_management_material
                    (project_id,material_name,material_type,remark,attachments,create_user_id,create_user_name,is_deleted,create_time,update_time)
                    values (:projectId,:materialName,:materialType,:remark,:attachments,:userId,:userName,0,now(),now())
                    """, p, keys, new String[]{"id"});
            return Objects.requireNonNull(keys.getKey()).longValue();
        }
        jdbc.update("""
                update project_management_material set material_name=:materialName,material_type=:materialType,
                remark=:remark,attachments=:attachments,update_time=now()
                where id=:id and project_id=:projectId and is_deleted=0
                """, p);
        return request.getId();
    }
    public void softDelete(String table, Long id) {
        if (!List.of("project_management_follow_up", "project_management_progress_report", "project_management_material", "government_policy_file").contains(table)) {
            throw new ServiceException("不支持的删除类型");
        }
        jdbc.getJdbcTemplate().update("update " + table + " set is_deleted=1,update_time=now() where id=?", id);
    }
    public List<Map<String, Object>> listCategories() {
        return query("""
                select id,parent_id as parentId,category_name as categoryName,sort,remark
                from project_management_category where is_deleted=0 order by sort,id
                """, Map.of());
    }
    @Transactional(rollbackFor = Exception.class)
    public Long saveCategory(ProjectWorkflowDtos.CategoryRequest request) {
        if (isBlank(request.getCategoryName())) throw new ServiceException("请输入分类名称");
        Long parentId = request.getParentId() == null ? 0L : request.getParentId();
        if (request.getId() != null && request.getId().equals(parentId)) throw new ServiceException("上级分类不能选择自己");
        MapSqlParameterSource p = new MapSqlParameterSource().addValue("id", request.getId())
                .addValue("parentId", parentId).addValue("categoryName", request.getCategoryName().trim())
                .addValue("sort", request.getSort() == null ? 0 : request.getSort()).addValue("remark", request.getRemark());
        if (request.getId() == null) {
            GeneratedKeyHolder keys = new GeneratedKeyHolder();
            jdbc.update("""
                    insert into project_management_category(parent_id,category_name,sort,remark,is_deleted,create_time,update_time)
                    values (:parentId,:categoryName,:sort,:remark,0,now(),now())
                    """, p, keys, new String[]{"id"});
            return Objects.requireNonNull(keys.getKey()).longValue();
        }
        jdbc.update("""
                update project_management_category set parent_id=:parentId,category_name=:categoryName,
                sort=:sort,remark=:remark,update_time=now() where id=:id and is_deleted=0
                """, p);
        return request.getId();
    }
    public void deleteCategory(Long id) {
        int children = jdbc.queryForObject("select count(*) from project_management_category where parent_id=:id and is_deleted=0", Map.of("id", id), Integer.class);
        if (children > 0) throw new ServiceException("请先删除下级分类");
        jdbc.update("update project_management_category set is_deleted=1,update_time=now() where id=:id", Map.of("id", id));
    }
    public List<Map<String, Object>> listPolicies(String keyword) {
        MapSqlParameterSource p = new MapSqlParameterSource().addValue("keyword", keyword == null ? "" : keyword.trim());
        List<Map<String, Object>> rows = query("""
                select id,policy_name as policyName,publish_date as publishDate,policy_url as policyUrl,
                       attachments,maintainer_name as maintainerName,remark,create_time as createTime
                from government_policy_file where is_deleted=0
                and (:keyword='' or policy_name like concat('%',:keyword,'%'))
                order by publish_date desc,id desc
                """, p);
        parseAttachments(rows);
        return rows;
    }
    @Transactional(rollbackFor = Exception.class)
    public Long savePolicy(ProjectWorkflowDtos.PolicyRequest request) {
        if (isBlank(request.getPolicyName())) throw new ServiceException("请输入政策名称");
        String maintainer = isBlank(request.getMaintainerName()) ? currentUserName() : request.getMaintainerName();
        MapSqlParameterSource p = new MapSqlParameterSource().addValue("id", request.getId())
                .addValue("policyName", request.getPolicyName()).addValue("publishDate", request.getPublishDate())
                .addValue("policyUrl", request.getPolicyUrl()).addValue("attachments", json(request.getAttachments()))
                .addValue("maintainer", maintainer).addValue("remark", request.getRemark());
        if (request.getId() == null) {
            GeneratedKeyHolder keys = new GeneratedKeyHolder();
            jdbc.update("""
                    insert into government_policy_file
                    (policy_name,publish_date,policy_url,attachments,maintainer_name,remark,is_deleted,create_time,update_time)
                    values (:policyName,:publishDate,:policyUrl,:attachments,:maintainer,:remark,0,now(),now())
                    """, p, keys, new String[]{"id"});
            return Objects.requireNonNull(keys.getKey()).longValue();
        }
        jdbc.update("""
                update government_policy_file set policy_name=:policyName,publish_date=:publishDate,
                policy_url=:policyUrl,attachments=:attachments,maintainer_name=:maintainer,
                remark=:remark,update_time=now() where id=:id and is_deleted=0
                """, p);
        return request.getId();
    }
    private List<Map<String, Object>> memberStatistics(Long projectId) {
        List<Map<String, Object>> reports = query("""
                select r.module_id as moduleId,r.progress,r.reporter_ids as reporterIds,
                       r.reporter_names as reporterNames,r.reporter_shares as reporterShares,
                       m.project_weight as projectWeight
                from project_management_progress_report r
                join project_management_module m on m.id=r.module_id and m.is_deleted=0
                where r.project_id=:projectId and r.is_deleted=0
                order by r.module_id,r.report_date,r.id
                """, Map.of("projectId", projectId));
        Map<Long, BigDecimal> previousProgress = new LinkedHashMap<>();
        Map<Long, Map<String, Object>> totals = new LinkedHashMap<>();
        for (Map<String, Object> report : reports) {
            Long moduleId = asLong(report.get("moduleId"));
            BigDecimal progress = nvl((BigDecimal) report.get("progress"));
            BigDecimal previous = previousProgress.getOrDefault(moduleId, BigDecimal.ZERO);
            BigDecimal delta = progress.subtract(previous).max(BigDecimal.ZERO);
            previousProgress.put(moduleId, progress.max(previous));
            List<Map<String, Object>> shares = parseFileList(report.get("reporterShares"));
            if (shares.isEmpty()) shares = equalReporterShares(report.get("reporterIds"), report.get("reporterNames"));
            BigDecimal moduleWeight = nvl((BigDecimal) report.get("projectWeight"));
            for (Map<String, Object> share : shares) {
                Long userId = asLong(share.get("userId"));
                if (userId == null) continue;
                String userName = Objects.toString(share.get("userName"), "");
                BigDecimal memberShare = new BigDecimal(Objects.toString(share.get("moduleShare"), "0"));
                BigDecimal contribution = moduleWeight.multiply(delta).multiply(memberShare)
                        .divide(BigDecimal.valueOf(10000), 2, RoundingMode.HALF_UP);
                Map<String, Object> total = totals.computeIfAbsent(userId, id -> {
                    Map<String, Object> item = new LinkedHashMap<>();
                    item.put("userId", id); item.put("userName", userName); item.put("value", BigDecimal.ZERO);
                    return item;
                });
                total.put("value", ((BigDecimal) total.get("value")).add(contribution));
            }
        }
        return totals.values().stream()
                .sorted((a, b) -> ((BigDecimal) b.get("value")).compareTo((BigDecimal) a.get("value")))
                .toList();
    }
    private Map<String, Object> getFollowWindow(Long projectId) {
        List<Map<String, Object>> projectRows = query("""
                select follow_cycle as followCycle,plan_start_time as planStartTime
                from project_management_info where id=:projectId and is_delete=0
                """, Map.of("projectId", projectId));
        if (projectRows.isEmpty()) return Map.of();
        String cycle = Objects.toString(projectRows.getFirst().get("followCycle"), "");
        LocalDate last = jdbc.query("""
                select follow_date from project_management_follow_up
                where project_id=:projectId and is_deleted=0 order by follow_date desc,id desc limit 1
                """, Map.of("projectId", projectId), rs -> rs.next() ? rs.getDate(1).toLocalDate() : null);
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("cycle", cycle);
        result.put("lastFollowDate", last);
        LocalDate planStart = projectRows.getFirst().get("planStartTime") instanceof java.sql.Date date ? date.toLocalDate() : null;
        LocalDate base = last == null ? planStart : last;
        if (base != null) {
            LocalDate due = nextDue(base, cycle);
            result.put("dueDate", due);
            result.put("windowStart", due.minusDays(3));
            result.put("windowEnd", due.plusDays(3));
        }
        return result;
    }
    private void ensurePhases(Long projectId) {
        for (int i = 0; i < PHASE_CODES.size(); i++) {
            jdbc.update("""
                    insert into project_management_phase
                    (project_id,phase_code,phase_name,phase_order,status,is_deleted,create_time,update_time)
                    select :projectId,:phaseCode,:phaseName,:phaseOrder,'NOT_STARTED',0,now(),now()
                    where not exists(select 1 from project_management_phase
                      where project_id=:projectId and phase_code=:phaseCode and is_deleted=0)
                    """, new MapSqlParameterSource().addValue("projectId", projectId)
                    .addValue("phaseCode", PHASE_CODES.get(i)).addValue("phaseName", PHASE_NAMES.get(i))
                    .addValue("phaseOrder", i + 1));
        }
    }
    private Map<String, Object> getSourceBusiness(Long projectId) {
        List<Map<String, Object>> rows = query("""
                select sl.id,sl.project_name as projectName,sl.customer_id as customerId,
                       sl.customer_name as customerName,sl.salesman,sl.entry_date as entryDate,
                       sl.remarks,sl.attachment_materials as attachmentMaterials,
                       sl.business_approval_status as approvalStatus,
                       sl.business_approval_remark as approvalRemark
                from project_management_info p
                join sales_ledger sl on sl.id=p.source_business_id
                where p.id=:projectId and p.source_business_id is not null
                """, Map.of("projectId", projectId));
        if (rows.isEmpty()) return Map.of();
        Map<String, Object> row = rows.getFirst();
        row.put("attachmentList", parseFileList(row.get("attachmentMaterials")));
        return row;
    }
    private void validateFollowWindow(Long projectId, LocalDate followDate, Long excludeId) {
        MapSqlParameterSource p = new MapSqlParameterSource().addValue("projectId", projectId).addValue("excludeId", excludeId);
        LocalDate last = jdbc.query("""
                select follow_date from project_management_follow_up
                where project_id=:projectId and is_deleted=0 and (:excludeId is null or id<>:excludeId)
                order by follow_date desc,id desc limit 1
                """, p, rs -> rs.next() ? rs.getDate(1).toLocalDate() : null);
        String cycle = jdbc.queryForObject("select follow_cycle from project_management_info where id=:projectId", Map.of("projectId", projectId), String.class);
        LocalDate base = last;
        if (base == null) {
            base = jdbc.query("select plan_start_time from project_management_info where id=:projectId", Map.of("projectId", projectId),
                    rs -> rs.next() && rs.getDate(1) != null ? rs.getDate(1).toLocalDate() : null);
        }
        if (base == null) throw new ServiceException("请先填写项目预计开始时间");
        LocalDate due = nextDue(base, cycle);
        if (followDate.isBefore(due.minusDays(3)) || followDate.isAfter(due.plusDays(3))) {
            throw new ServiceException("本次跟进应在" + due.minusDays(3) + "至" + due.plusDays(3) + "之间填写");
        }
    }
    private LocalDate nextDue(LocalDate base, String cycle) {
        return switch (Objects.toString(cycle, "MONTH").toUpperCase()) {
            case "QUARTER", "季度", "每季度" -> base.plusMonths(3);
            case "YEAR", "年度", "每年" -> base.plusYears(1);
            default -> base.plusMonths(1);
        };
    }
    private void validateMembers(List<ProjectWorkflowDtos.MemberRequest> members) {
        if (members == null || members.isEmpty()) throw new ServiceException("请至少选择一名模块执行人");
        List<Long> ids = new ArrayList<>();
        for (ProjectWorkflowDtos.MemberRequest member : members) {
            if (member.getUserId() == null || isBlank(member.getUserName())) throw new ServiceException("模块执行人不能为空");
            if (ids.contains(member.getUserId())) throw new ServiceException("模块执行人不能重复");
            ids.add(member.getUserId());
        }
    }
    private List<Long> projectExecutorIds(Long projectId) {
        List<Map<String, Object>> rows = query("""
                select project_executor_ids as executorIds from project_management_info
                where id=:projectId and is_delete=0
                """, Map.of("projectId", projectId));
        return rows.isEmpty() ? List.of() : parseLongList(rows.getFirst().get("executorIds"));
    }
    private boolean hasPositiveProjectShare(Long projectId, Long userId) {
        return hasPositiveShare(query("""
                select reporter_ids as reporterIds,reporter_names as reporterNames,reporter_shares as reporterShares
                from project_management_progress_report where project_id=:projectId and is_deleted=0
                """, Map.of("projectId", projectId)), userId);
    }
    private boolean hasPositiveModuleShare(Long moduleId, Long userId) {
        return hasPositiveShare(query("""
                select reporter_ids as reporterIds,reporter_names as reporterNames,reporter_shares as reporterShares
                from project_management_progress_report where module_id=:moduleId and is_deleted=0
                """, Map.of("moduleId", moduleId)), userId);
    }
    private boolean hasPositiveShare(List<Map<String, Object>> reports, Long userId) {
        for (Map<String, Object> report : reports) {
            List<Map<String, Object>> shares = parseFileList(report.get("reporterShares"));
            if (shares.isEmpty()) shares = equalReporterShares(report.get("reporterIds"), report.get("reporterNames"));
            for (Map<String, Object> share : shares) {
                if (Objects.equals(asLong(share.get("userId")), userId)
                        && new BigDecimal(Objects.toString(share.get("moduleShare"), "0")).compareTo(BigDecimal.ZERO) > 0) {
                    return true;
                }
            }
        }
        return false;
    }
    private void validateReporterShares(List<ProjectWorkflowDtos.MemberRequest> members) {
        if (members == null || members.isEmpty()) throw new ServiceException("请至少选择一名汇报人");
        BigDecimal sum = BigDecimal.ZERO;
        List<Long> ids = new ArrayList<>();
        for (ProjectWorkflowDtos.MemberRequest member : members) {
            if (member.getUserId() == null || isBlank(member.getUserName())) throw new ServiceException("汇报人不能为空");
            if (ids.contains(member.getUserId())) throw new ServiceException("汇报人不能重复");
            ids.add(member.getUserId());
            sum = sum.add(percent(member.getModuleShare(), "本次汇报人员比重"));
        }
        if (sum.setScale(2, RoundingMode.HALF_UP).compareTo(BigDecimal.valueOf(100).setScale(2)) != 0) {
            throw new ServiceException("本次汇报人员比重合计必须为100%");
        }
    }
    private List<ProjectWorkflowDtos.MemberRequest> normalizeReporterShares(ProjectWorkflowDtos.ProgressReportRequest request) {
        if (request.getReporterShares() != null && !request.getReporterShares().isEmpty()) return request.getReporterShares();
        List<Long> ids = request.getReporterIds() == null ? List.of() : request.getReporterIds();
        List<String> names = request.getReporterNames() == null ? List.of() : request.getReporterNames();
        if (ids.isEmpty()) return new ArrayList<>();
        BigDecimal share = BigDecimal.valueOf(100).divide(BigDecimal.valueOf(ids.size()), 2, RoundingMode.DOWN);
        List<ProjectWorkflowDtos.MemberRequest> result = new ArrayList<>();
        for (int i = 0; i < ids.size(); i++) {
            ProjectWorkflowDtos.MemberRequest member = new ProjectWorkflowDtos.MemberRequest();
            member.setUserId(ids.get(i)); member.setUserName(i < names.size() ? names.get(i) : "");
            member.setModuleShare(i == ids.size() - 1 ? BigDecimal.valueOf(100).subtract(share.multiply(BigDecimal.valueOf(i))) : share);
            result.add(member);
        }
        return result;
    }
    private List<Map<String, Object>> equalReporterShares(Object rawIds, Object rawNames) {
        List<Long> ids = parseLongList(rawIds);
        List<String> names = parseStringList(rawNames);
        if (ids.isEmpty()) return new ArrayList<>();
        BigDecimal share = BigDecimal.valueOf(100).divide(BigDecimal.valueOf(ids.size()), 2, RoundingMode.DOWN);
        List<Map<String, Object>> result = new ArrayList<>();
        for (int i = 0; i < ids.size(); i++) {
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("userId", ids.get(i)); item.put("userName", i < names.size() ? names.get(i) : "");
            item.put("moduleShare", i == ids.size() - 1 ? BigDecimal.valueOf(100).subtract(share.multiply(BigDecimal.valueOf(i))) : share);
            result.add(item);
        }
        return result;
    }
    private BigDecimal percent(BigDecimal value, String label) {
        BigDecimal val = nvl(value);
        if (val.compareTo(BigDecimal.ZERO) < 0 || val.compareTo(BigDecimal.valueOf(100)) > 0) {
            throw new ServiceException(label + "必须在0到100之间");
        }
        return val.setScale(2, RoundingMode.HALF_UP);
    }
    private void requireProject(Long projectId) {
        if (projectId == null) throw new ServiceException("项目不能为空");
        Integer count = jdbc.queryForObject("select count(*) from project_management_info where id=:id and is_delete=0", Map.of("id", projectId), Integer.class);
        if (count == null || count == 0) throw new ServiceException("项目不存在");
    }
    private List<Map<String, Object>> query(String sql, Map<String, ?> params) {
        return jdbc.queryForList(sql, params);
    }
    private List<Map<String, Object>> query(String sql, MapSqlParameterSource params) {
        return jdbc.queryForList(sql, params);
    }
    private void parseAttachments(List<Map<String, Object>> rows) {
        for (Map<String, Object> row : rows) row.put("attachmentList", parseFileList(row.get("attachments")));
    }
    private List<Map<String, Object>> parseFileList(Object raw) {
        if (raw == null || raw.toString().isBlank()) return new ArrayList<>();
        try {
            return objectMapper.readValue(raw.toString(), FILE_LIST_TYPE);
        } catch (Exception ignored) {
            return new ArrayList<>();
        }
    }
    private List<Long> parseLongList(Object raw) {
        if (raw == null || raw.toString().isBlank()) return new ArrayList<>();
        try {
            return objectMapper.readValue(raw.toString(), new TypeReference<>() { });
        } catch (Exception ignored) {
            return new ArrayList<>();
        }
    }
    private List<String> parseStringList(Object raw) {
        if (raw == null || raw.toString().isBlank()) return new ArrayList<>();
        try {
            return objectMapper.readValue(raw.toString(), new TypeReference<>() { });
        } catch (Exception ignored) {
            return new ArrayList<>();
        }
    }
    private Map<String, Object> parseObjectMap(Object raw) {
        if (raw == null || raw.toString().isBlank()) return new LinkedHashMap<>();
        try {
            return objectMapper.readValue(raw.toString(), new TypeReference<>() { });
        } catch (Exception ignored) {
            return new LinkedHashMap<>();
        }
    }
    private String json(Object value) {
        try {
            return objectMapper.writeValueAsString(value == null ? List.of() : value);
        } catch (Exception e) {
            throw new ServiceException("附件或人员数据格式不正确");
        }
    }
    private String jsonObject(Object value) {
        try {
            return objectMapper.writeValueAsString(value == null ? Map.of() : value);
        } catch (Exception e) {
            throw new ServiceException("阶段信息格式不正确");
        }
    }
    private String currentUserName() {
        return SecurityUtils.getLoginUser() == null ? "" : SecurityUtils.getLoginUser().getNickName();
    }
    private boolean isBlank(String value) {
        return value == null || value.trim().isEmpty();
    }
    private BigDecimal nvl(BigDecimal value) {
        return value == null ? BigDecimal.ZERO : value;
    }
    private Long asLong(Object value) {
        return value == null ? null : Long.valueOf(value.toString());
    }
}
src/main/java/com/ruoyi/projectManagement/service/impl/InfoServiceImpl.java
@@ -85,6 +85,7 @@
        LambdaUpdateWrapper<Info> updateWrapper = new LambdaUpdateWrapper<Info>();
        updateWrapper.eq(Info::getId, id);
        updateWrapper.set(Info::getIsDelete, IsDeleteEnum.DELETED.getCode());
        infoMapper.update(null, updateWrapper);
        // å¯¹åº”附表信息是否删除待确定(因为后续可能会进行引用所以先不删除
    }
src/main/java/com/ruoyi/projectManagement/service/impl/handle/InfoHandleService.java
@@ -9,6 +9,7 @@
import com.ruoyi.basic.service.CustomerFollowUpFileService;
import com.ruoyi.common.enums.PlanStageEnum;
import com.ruoyi.common.enums.ReviewStatusEnum;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.projectManagement.dto.InfoStageDto;
import com.ruoyi.projectManagement.dto.PlanStageDto;
import com.ruoyi.projectManagement.dto.SaveInfoDto;
@@ -58,13 +59,21 @@
        }
        info.setTeam(saveInfoDto.getTeamList());
        if (info.getId() == null) {
            // ç”Ÿæˆå¯¹åº”的阶段关系数据
            info.setPlanStage(getPlanStageList(info.getProjectManagementPlanId()));
            // æ–°é¡¹ç›®é˜¶æ®µç”± project_management_phase ç‹¬ç«‹ç»´æŠ¤ï¼Œä¸å†ç”Ÿæˆæ—§è®¡åˆ’模板阶段。
            info.setPlanStage(Collections.emptyList());
            if (StrUtil.isBlank(info.getCurrentPhase())) {
                info.setCurrentPhase("未开始");
            }
            // æ’入默认状态
            info.setStatus(PlanStageEnum.TO_BEGIN.getCode());
            info.setReviewStatus(ReviewStatusEnum.PENDING_REVIEW.getCode());
            infoMapper.insert(info);
        } else {
            Info existing = infoMapper.selectById(info.getId());
            if (existing == null) throw new ServiceException("项目不存在");
            if (!StrUtil.isBlank(existing.getCurrentPhase()) && !"未开始".equals(existing.getCurrentPhase())) {
                throw new ServiceException("项目开始后基本信息和团队信息已锁定,仅可通过人员调整功能修改项目执行人");
            }
            infoMapper.updateById(info);
        }
        return info.getId();
src/main/java/com/ruoyi/sales/service/BusinessApprovalService.java
@@ -1,17 +1,11 @@
package com.ruoyi.sales.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.ruoyi.common.enums.PlanStageEnum;
import com.ruoyi.common.enums.ReviewStatusEnum;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.projectManagement.dto.PlanStageDto;
import com.ruoyi.projectManagement.mapper.InfoMapper;
import com.ruoyi.projectManagement.mapper.PlanMapper;
import com.ruoyi.projectManagement.mapper.PlanNodeMapper;
import com.ruoyi.projectManagement.pojo.Info;
import com.ruoyi.projectManagement.pojo.Plan;
import com.ruoyi.projectManagement.pojo.PlanNode;
import com.ruoyi.sales.dto.BusinessReviewDto;
import com.ruoyi.sales.mapper.SalesLedgerMapper;
import com.ruoyi.sales.pojo.SalesLedger;
@@ -22,15 +16,12 @@
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
@Service
@RequiredArgsConstructor
public class BusinessApprovalService {
    private final SalesLedgerMapper salesLedgerMapper;
    private final InfoMapper infoMapper;
    private final PlanMapper planMapper;
    private final PlanNodeMapper planNodeMapper;
    @Transactional(rollbackFor = Exception.class)
    public void submit(Long id) {
@@ -74,18 +65,6 @@
    }
    private Long createProject(SalesLedger ledger, Long selectedPlanId) {
        Plan plan = selectedPlanId == null ? planMapper.selectOne(new LambdaQueryWrapper<Plan>()
                .eq(Plan::getName, "项目申报六阶段")
                .eq(Plan::getIsDelete, 0).last("limit 1")) : planMapper.selectById(selectedPlanId);
        if (plan == null) {
            throw new ServiceException("未找到“项目申报六阶段”计划,请先执行升级SQL");
        }
        List<PlanNode> nodes = planNodeMapper.selectList(new LambdaQueryWrapper<PlanNode>()
                .eq(PlanNode::getProjectManagementPlanId, plan.getId())
                .eq(PlanNode::getIsDelete, 0).orderByAsc(PlanNode::getSort));
        if (nodes.isEmpty()) {
            throw new ServiceException("项目六阶段尚未配置");
        }
        Info info = new Info();
        info.setNo("XM" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)
                + String.format("%06d", ledger.getId()));
@@ -94,22 +73,16 @@
        info.setClientId(ledger.getCustomerId());
        info.setClientName(ledger.getCustomerName());
        info.setEstablishTime(LocalDate.now());
        info.setProjectManagementPlanId(plan.getId());
        info.setSource("业务审批");
        info.setManagerId(SecurityUtils.getUserId());
        info.setManagerName(SecurityUtils.getLoginUser().getNickName());
        info.setSource("业务生成");
        info.setSourceBusinessId(ledger.getId());
        info.setCurrentPhase("未开始");
        info.setSalesmanName(ledger.getSalesman());
        info.setIsDelete(0);
        info.setPlanStartTime(LocalDate.now());
        info.setReviewStatus(ReviewStatusEnum.APPROVED.getCode());
        info.setStatus(PlanStageEnum.TO_BEGIN.getCode());
        info.setOrderDate(ledger.getExecutionDate());
        info.setOrderAmount(ledger.getContractAmount() == null ? null : ledger.getContractAmount().toPlainString());
        info.setRemark(ledger.getRemarks());
        info.setAttachment(ledger.getAttachmentMaterials());
        info.setDeptId(ledger.getDeptId());
        info.setPlanStage(nodes.stream().map(node -> new PlanStageDto(node.getId(), node.getName(),
                PlanStageEnum.TO_BEGIN, node.getSort(), node.getEstimatedDuration())).toList());
        infoMapper.insert(info);
        return info.getId();
    }
src/main/resources/application-zxsq.yml
@@ -18,7 +18,7 @@
# å¼€å‘环境配置
server:
  # æœåŠ¡å™¨çš„HTTP端口,默认为8080
  port: 9005
  port: 9006
  servlet:
    # åº”用的访问路径
    context-path: /
src/main/resources/mapper/projectManagement/InfoMapper.xml
@@ -15,6 +15,20 @@
            <result property="projectManagementPlanId" column="project_management_plan_id" jdbcType="BIGINT"/>
            <result property="source" column="source" jdbcType="VARCHAR"/>
            <result property="projectClassification" column="project_classification" jdbcType="VARCHAR"/>
            <result property="projectLeaderId" column="project_leader_id" jdbcType="BIGINT"/>
            <result property="projectLeaderName" column="project_leader_name" jdbcType="VARCHAR"/>
            <result property="projectManagerIds" column="project_manager_ids"
                    typeHandler="com.ruoyi.common.handler.LongListJacksonTypeHandler"/>
            <result property="projectManagerNames" column="project_manager_names"
                    typeHandler="com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler"/>
            <result property="projectExecutorIds" column="project_executor_ids"
                    typeHandler="com.ruoyi.common.handler.LongListJacksonTypeHandler"/>
            <result property="projectExecutorNames" column="project_executor_names"
                    typeHandler="com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler"/>
            <result property="followCycle" column="follow_cycle" jdbcType="VARCHAR"/>
            <result property="followDetail" column="follow_detail" jdbcType="VARCHAR"/>
            <result property="currentPhase" column="current_phase" jdbcType="VARCHAR"/>
            <result property="sourceBusinessId" column="source_business_id" jdbcType="BIGINT"/>
            <result property="managerId" column="manager_id" jdbcType="BIGINT"/>
            <result property="managerName" column="manager_name" jdbcType="VARCHAR"/>
            <result property="salesmanId" column="salesman_id" jdbcType="BIGINT"/>
@@ -40,7 +54,10 @@
    <sql id="Base_Column_List">
        id,no,title,
        client_id,client_name,project_management_info_parent_id,
        establish_time,project_management_plan_id,source,
        establish_time,project_management_plan_id,source,project_classification,
        project_leader_id,project_leader_name,project_manager_ids,project_manager_names,
        project_executor_ids,project_executor_names,
        follow_cycle,follow_detail,current_phase,source_business_id,
        manager_id,manager_name,salesman_id,
        salesman_name,is_delete,create_time,
        update_time,create_user,update_user,
@@ -51,6 +68,21 @@
    </sql>
    <resultMap id="ListInfoVoMap" type="com.ruoyi.projectManagement.vo.ListInfoVo">
        <result column="project_leader_id" property="projectLeaderId"/>
        <result column="project_leader_name" property="projectLeaderName"/>
        <result column="project_manager_ids" property="projectManagerIds"
                typeHandler="com.ruoyi.common.handler.LongListJacksonTypeHandler"/>
        <result column="project_manager_names" property="projectManagerNames"
                typeHandler="com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler"/>
        <result column="project_executor_ids" property="projectExecutorIds"
                typeHandler="com.ruoyi.common.handler.LongListJacksonTypeHandler"/>
        <result column="project_executor_names" property="projectExecutorNames"
                typeHandler="com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler"/>
        <result column="follow_cycle" property="followCycle"/>
        <result column="follow_detail" property="followDetail"/>
        <result column="current_phase" property="currentPhase"/>
        <result column="source_business_id" property="sourceBusinessId"/>
        <result column="plan_stage"
                property="planStage"
                typeHandler="com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler"/>