zhangwencui
4 天以前 b30f4c40c77a940a2a749a676e901787f55040b0
src/main/java/com/ruoyi/projectManagement/service/ProjectWorkflowService.java
@@ -5,6 +5,7 @@
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.projectManagement.dto.ProjectWorkflowDtos;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
@@ -16,10 +17,13 @@
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@Service
@RequiredArgsConstructor
@@ -30,6 +34,28 @@
    private final NamedParameterJdbcTemplate jdbc;
    private final ObjectMapper objectMapper;
    @PostConstruct
    public void ensurePhaseHistoryTable() {
        jdbc.getJdbcTemplate().execute("""
                create table if not exists project_management_phase_history (
                  id bigint not null auto_increment,
                  project_id bigint not null,
                  phase_code varchar(30) not null,
                  phase_name varchar(50) not null,
                  action_type varchar(30) not null,
                  action_label varchar(100) not null,
                  form_data json default null,
                  remark varchar(2000) default null,
                  attachments json default null,
                  operator_name varchar(100) default null,
                  action_time datetime not null default current_timestamp,
                  primary key (id),
                  key idx_phase_history_project (project_id,action_time),
                  key idx_phase_history_phase (project_id,phase_code)
                ) engine=InnoDB default charset=utf8mb4 comment='项目阶段办理历史'
                """);
    }
    public Map<String, Object> getWorkbench(Long projectId) {
        requireProject(projectId);
@@ -68,14 +94,23 @@
                       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
                where r.project_id=:projectId and r.is_deleted=0 order by r.module_id,r.report_date,r.id
                """, Map.of("projectId", projectId));
        parseAttachments(reports);
        Map<Long, BigDecimal> previousProgress = new LinkedHashMap<>();
        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")));
            Long moduleId = asLong(row.get("moduleId"));
            BigDecimal previous = previousProgress.getOrDefault(moduleId, BigDecimal.ZERO);
            BigDecimal cumulative = nvl((BigDecimal) row.get("progress"));
            row.put("previousProgress", previous);
            row.put("incrementProgress", cumulative.subtract(previous).max(BigDecimal.ZERO));
            previousProgress.put(moduleId, cumulative.max(previous));
        }
        reports.sort(Comparator.comparing((Map<String, Object> row) -> Objects.toString(row.get("reportDate"), ""))
                .thenComparing(row -> asLong(row.get("id")), Comparator.nullsLast(Long::compareTo)).reversed());
        List<Map<String, Object>> materials = query("""
                select id, project_id as projectId, material_name as materialName,
@@ -86,11 +121,31 @@
                """, Map.of("projectId", projectId));
        parseAttachments(materials);
        List<Map<String, Object>> phases = query("""
                select id,phase_code as phaseCode,phase_name as phaseName,status,form_data as formData,
                       remark,attachments,completed_time as completedTime,update_user_name as operatorName,
                       update_time as actionTime
                from project_management_phase where project_id=:projectId and is_deleted=0 order by phase_order
                """, Map.of("projectId", projectId));
        parseAttachments(phases);
        for (Map<String, Object> phase : phases) phase.put("formData", parseObjectMap(phase.get("formData")));
        List<Map<String, Object>> phaseHistories = query("""
                select id,phase_code as phaseCode,phase_name as phaseName,action_type as actionType,
                       action_label as actionLabel,form_data as formData,remark,attachments,
                       operator_name as operatorName,action_time as actionTime
                from project_management_phase_history where project_id=:projectId order by action_time desc,id desc
                """, Map.of("projectId", projectId));
        parseAttachments(phaseHistories);
        for (Map<String, Object> history : phaseHistories) history.put("formData", parseObjectMap(history.get("formData")));
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("modules", modules);
        result.put("followUps", followUps);
        result.put("reports", reports);
        result.put("materials", materials);
        result.put("phases", phases);
        result.put("phaseHistories", phaseHistories);
        result.put("sourceBusiness", getSourceBusiness(projectId));
        result.put("memberStatistics", memberStatistics(projectId));
        result.put("followWindow", getFollowWindow(projectId));
@@ -100,13 +155,20 @@
    @Transactional(rollbackFor = Exception.class)
    public void startProject(Long projectId) {
        requireProject(projectId);
        String currentPhase = jdbc.queryForObject("""
                select current_phase from project_management_info
                where id=:projectId and is_delete=0 for update
                """, Map.of("projectId", projectId), String.class);
        if (!"未开始".equals(currentPhase)) {
            throw new ServiceException("只有未开始的项目可以开始,当前阶段为“" + currentPhase + "”");
        }
        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
                where id=:projectId and is_delete=0 and project_category_id is not null
                and plan_start_time is not null and plan_end_time is not null
                and plan_end_time>=plan_start_time
                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("""
@@ -115,6 +177,15 @@
                from project_management_module where project_id=:projectId and is_deleted=0
                """, Map.of("projectId", projectId));
        if (modules.isEmpty()) throw new ServiceException("项目开始前请至少添加一个项目模块");
        Integer invalidModuleDates = jdbc.queryForObject("""
                select count(*) from project_management_module m
                join project_management_info p on p.id=m.project_id and p.is_delete=0
                where m.project_id=:projectId and m.is_deleted=0
                and (m.planned_start_date<p.plan_start_time or m.planned_start_date>p.plan_end_time)
                """, Map.of("projectId", projectId), Integer.class);
        if (invalidModuleDates != null && invalidModuleDates > 0) {
            throw new ServiceException("模块预计开始时间必须在项目计划开始和结束日期之间");
        }
        BigDecimal total = BigDecimal.ZERO;
        for (Map<String, Object> module : modules) {
            if (isBlank(Objects.toString(module.get("moduleName"), "")) || module.get("plannedStartDate") == null
@@ -132,15 +203,30 @@
        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));
        jdbc.update("""
                update project_management_info set current_phase='开发',
                actual_start_time=coalesce(actual_start_time,current_date),update_time=now()
                where id=:projectId
                """, Map.of("projectId", projectId));
        ensurePhases(projectId);
        jdbc.update("""
                update project_management_phase set status='COMPLETED',completed_time=coalesce(completed_time,now()),
                update_user_name=:userName,update_time=now()
                where project_id=:projectId and phase_code='INITIATION' and is_deleted=0
                """, new MapSqlParameterSource().addValue("projectId", projectId).addValue("userName", currentUserName()));
        jdbc.update("""
                update project_management_phase set status='IN_PROGRESS',completed_time=null,
                update_user_name=:userName,update_time=now()
                where project_id=:projectId and phase_code='DEVELOPMENT' and is_deleted=0
                """, new MapSqlParameterSource().addValue("projectId", projectId).addValue("userName", currentUserName()));
    }
    @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",
        ensurePhases(request.getProjectId());
        String current = jdbc.queryForObject("select current_phase from project_management_info where id=:id and is_delete=0 for update",
                Map.of("id", request.getProjectId()), String.class);
        if ("立项".equals(current)) current = "开发";
        Map<String, String> nextStages = Map.of(
                "开发", "申报",
                "申报", "申报结果",
@@ -151,6 +237,7 @@
        if (expected == null || !expected.equals(request.getTargetStage())) {
            throw new ServiceException("项目阶段只能按开发、申报、申报结果、验收、已完成的顺序推进");
        }
        validateTransitionForm(current, request.getFormData());
        if ("开发".equals(current)) {
            Integer unfinished = jdbc.queryForObject("""
                    select count(*) from project_management_module
@@ -160,31 +247,50 @@
                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("该项目执行人已有项目汇报工作占比,不能删除");
            }
        }
        Map<String, String> phaseCodes = Map.of(
                "开发", "DEVELOPMENT",
                "申报", "APPLICATION",
                "申报结果", "RESULT",
                "验收", "ACCEPTANCE"
        );
        MapSqlParameterSource phaseParams = new MapSqlParameterSource()
                .addValue("projectId", request.getProjectId())
                .addValue("userName", currentUserName())
                .addValue("formData", jsonObject(request.getFormData()))
                .addValue("remark", request.getRemark())
                .addValue("attachments", json(request.getAttachments()));
        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())));
                update project_management_phase set status='COMPLETED',completed_time=coalesce(completed_time,now()),
                form_data=case when :saveCurrent=1 then :formData else form_data end,
                remark=case when :saveCurrent=1 then :remark else remark end,
                attachments=case when :saveCurrent=1 then :attachments else attachments end,
                update_user_name=:userName,update_time=now()
                where project_id=:projectId and phase_code=:phaseCode and is_deleted=0
                """, phaseParams.addValue("phaseCode", phaseCodes.get(current)).addValue("saveCurrent", "已完成".equals(expected) ? 1 : 0));
        if (!"已完成".equals(expected)) {
            jdbc.update("""
                    update project_management_phase set status='IN_PROGRESS',completed_time=null,
                    form_data=:formData,remark=:remark,attachments=:attachments,
                    update_user_name=:userName,update_time=now()
                    where project_id=:projectId and phase_code=:phaseCode and is_deleted=0
                    """, new MapSqlParameterSource()
                    .addValue("projectId", request.getProjectId())
                    .addValue("userName", currentUserName())
                    .addValue("formData", jsonObject(request.getFormData()))
                    .addValue("remark", request.getRemark())
                    .addValue("attachments", json(request.getAttachments()))
                    .addValue("phaseCode", phaseCodes.get(expected)));
        }
        String historyPhase = "已完成".equals(expected) ? current : expected;
        appendPhaseHistory(request.getProjectId(), phaseCodes.get(historyPhase), historyPhase,
                stageHistoryAction(historyPhase, request.getFormData(), "已完成".equals(expected)),
                stageHistoryLabel(historyPhase, request.getFormData(), "已完成".equals(expected)),
                request.getFormData(), request.getRemark(), request.getAttachments());
        jdbc.update("""
                update project_management_info set current_phase=:stage,
                actual_end_time=case when :stage='已完成' then coalesce(actual_end_time,current_date) else actual_end_time end,
                update_time=now() where id=:projectId
                """, new MapSqlParameterSource().addValue("stage", expected).addValue("projectId", request.getProjectId()));
    }
    @Transactional(rollbackFor = Exception.class)
@@ -194,19 +300,16 @@
        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 (!"IN_PROGRESS".equals(status)) throw new ServiceException("阶段补充办理只能保持进行中状态");
        String currentPhase = jdbc.queryForObject("""
                select current_phase from project_management_info
                where id=:id and is_delete=0 for update
                """, Map.of("id", request.getProjectId()), String.class);
        String expectedCode = phaseCodeForName(currentPhase);
        if (!Objects.equals(expectedCode, request.getPhaseCode())) {
            throw new ServiceException("只能维护当前阶段“" + currentPhase + "”,不能跳转或倒退阶段");
        }
        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) + "”");
            }
        }
        validateSupplementalPhase(currentPhase, request.getFormData());
        MapSqlParameterSource p = new MapSqlParameterSource()
                .addValue("projectId", request.getProjectId()).addValue("phaseCode", request.getPhaseCode())
                .addValue("status", status).addValue("formData", jsonObject(request.getFormData()))
@@ -218,17 +321,67 @@
                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()));
        appendPhaseHistory(request.getProjectId(), request.getPhaseCode(), PHASE_NAMES.get(index),
                stageHistoryAction(PHASE_NAMES.get(index), request.getFormData(), false),
                stageHistoryLabel(PHASE_NAMES.get(index), request.getFormData(), false),
                request.getFormData(), request.getRemark(), request.getAttachments());
        return jdbc.queryForObject("select id from project_management_phase where project_id=:projectId and phase_code=:phaseCode and is_deleted=0",
                p, Long.class);
    }
    public Map<String, Object> getLifecycle(Long projectId) {
        Map<String, Object> project = query("""
                select id,title,source,current_phase as currentPhase,establish_time as establishTime,
                       plan_start_time as planStartTime,plan_end_time as planEndTime,
                       actual_start_time as actualStartTime,actual_end_time as actualEndTime,
                       create_time as createTime,update_time as updateTime
                from project_management_info where id=:projectId and is_delete=0
                """, Map.of("projectId", projectId)).stream()
                .findFirst().orElseThrow(() -> new ServiceException("项目不存在"));
        List<Map<String, Object>> phases = query("""
                select phase_code as phaseCode,phase_name as phaseName,status,completed_time as completedTime,
                       update_time as updateTime,update_user_name as updateUserName,remark
                from project_management_phase
                where project_id=:projectId and is_deleted=0 order by phase_order
                """, Map.of("projectId", projectId));
        Map<String, Map<String, Object>> phaseMap = new LinkedHashMap<>();
        for (Map<String, Object> phase : phases) phaseMap.put(Objects.toString(phase.get("phaseCode"), ""), phase);
        List<Map<String, Object>> phaseHistories = query("""
                select id,phase_code as phaseCode,phase_name as phaseName,action_type as actionType,
                       action_label as actionLabel,remark,operator_name as operatorName,action_time as actionTime
                from project_management_phase_history where project_id=:projectId order by action_time,id
                """, Map.of("projectId", projectId));
        List<Map<String, Object>> nodes = new ArrayList<>();
        Object establishTime = project.get("establishTime") == null ? project.get("createTime") : project.get("establishTime");
        nodes.add(lifecycleNode("INITIATION", "项目立项", establishTime, "COMPLETED",
                "项目已创建,来源:" + Objects.toString(project.get("source"), "手动创建")));
        nodes.add(lifecycleNode("PROJECT_START", "项目开始", project.get("actualStartTime"),
                project.get("actualStartTime") == null ? currentNodeStatus(project.get("currentPhase"), "未开始") : "COMPLETED",
                "计划开始:" + Objects.toString(project.get("planStartTime"), "未填写")));
        addPhaseLifecycleNode(nodes, phaseMap.get("DEVELOPMENT"), "DEVELOPMENT", "项目开发", project.get("currentPhase"), project.get("updateTime"));
        addPhaseHistoryNodes(nodes, phaseHistories, "DEVELOPMENT");
        addPhaseLifecycleNode(nodes, phaseMap.get("APPLICATION"), "APPLICATION", "项目申报", project.get("currentPhase"), project.get("updateTime"));
        addPhaseHistoryNodes(nodes, phaseHistories, "APPLICATION");
        addPhaseLifecycleNode(nodes, phaseMap.get("RESULT"), "RESULT", "申报结果", project.get("currentPhase"), project.get("updateTime"));
        addPhaseHistoryNodes(nodes, phaseHistories, "RESULT");
        addPhaseLifecycleNode(nodes, phaseMap.get("ACCEPTANCE"), "ACCEPTANCE", "项目验收", project.get("currentPhase"), project.get("updateTime"));
        addPhaseHistoryNodes(nodes, phaseHistories, "ACCEPTANCE");
        nodes.add(lifecycleNode("PROJECT_END", "项目结束", project.get("actualEndTime"),
                project.get("actualEndTime") == null ? ("已完成".equals(project.get("currentPhase")) ? "CURRENT" : "PENDING") : "COMPLETED",
                "计划结束:" + Objects.toString(project.get("planEndTime"), "未填写")));
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("project", project);
        result.put("nodes", nodes);
        return result;
    }
    @Transactional(rollbackFor = Exception.class)
    public Long saveModule(ProjectWorkflowDtos.ModuleSaveRequest request) {
        requireProject(request.getProjectId());
        ensureProjectMaintenanceEditable(request.getProjectId());
        if (isBlank(request.getModuleName())) {
            throw new ServiceException("请输入模块名称");
        }
@@ -245,15 +398,26 @@
                    + "%,当前模块最多可设置" + 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("模块执行人必须先加入项目执行人");
        }
        boolean started = isProjectStarted(request.getProjectId());
        if (!started) {
            Integer dateInProjectRange = jdbc.queryForObject("""
                    select count(*) from project_management_info
                    where id=:projectId and is_delete=0 and plan_start_time is not null and plan_end_time is not null
                    and :plannedStartDate between plan_start_time and plan_end_time
                    """, new MapSqlParameterSource().addValue("projectId", request.getProjectId())
                    .addValue("plannedStartDate", request.getPlannedStartDate()), Integer.class);
            if (dateInProjectRange == null || dateInProjectRange == 0) {
                throw new ServiceException("模块预计开始时间必须在项目计划开始和结束日期之间");
            }
        }
        if (started && request.getId() == null) {
            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) {
            if (started || (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
@@ -264,7 +428,9 @@
                        || !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("该模块已有进度汇报,模块基础信息和占比不能修改,仅可调整执行人");
                if (changed) throw new ServiceException(started
                        ? "项目开始后模块信息不能修改,仅可管理模块执行人"
                        : "该模块已有进度汇报,模块基础信息和占比不能修改,仅可调整模块执行人");
            }
            List<Long> oldMemberIds = query("""
                    select user_id as userId from project_management_module_member
@@ -318,7 +484,55 @@
    }
    @Transactional(rollbackFor = Exception.class)
    public void saveModulesBatch(ProjectWorkflowDtos.ModulesBatchRequest request) {
        requireProject(request.getProjectId());
        ensureProjectMaintenanceEditable(request.getProjectId());
        List<ProjectWorkflowDtos.ModuleSaveRequest> modules = request.getModules() == null
                ? new ArrayList<>() : new ArrayList<>(request.getModules());
        if (modules.isEmpty()) throw new ServiceException("请至少添加一个项目模块");
        BigDecimal total = BigDecimal.ZERO;
        Set<Long> requestedIds = new HashSet<>();
        for (ProjectWorkflowDtos.ModuleSaveRequest module : modules) {
            module.setProjectId(request.getProjectId());
            total = total.add(percent(module.getProjectWeight(), "模块在项目中的工作量占比"));
            if (module.getId() != null && !requestedIds.add(module.getId())) {
                throw new ServiceException("模块数据重复,请刷新后重试");
            }
        }
        if (total.compareTo(BigDecimal.valueOf(100)) != 0) {
            throw new ServiceException("所有模块的项目工作量占比合计必须为100%");
        }
        List<Map<String, Object>> existing = query("""
                select id,project_weight as projectWeight from project_management_module
                where project_id=:projectId and is_deleted=0
                """, Map.of("projectId", request.getProjectId()));
        Map<Long, BigDecimal> oldWeights = new LinkedHashMap<>();
        for (Map<String, Object> row : existing) oldWeights.put(asLong(row.get("id")), nvl((BigDecimal) row.get("projectWeight")));
        for (Long id : requestedIds) {
            if (!oldWeights.containsKey(id)) throw new ServiceException("项目模块不存在或不属于当前项目");
        }
        if (isProjectStarted(request.getProjectId()) && (!requestedIds.equals(oldWeights.keySet()) || modules.size() != existing.size())) {
            throw new ServiceException("项目开始后不能新增或删除模块,仅可管理模块执行人");
        }
        for (Long oldId : oldWeights.keySet()) {
            if (!requestedIds.contains(oldId)) deleteModule(oldId);
        }
        // 先保存占比减少的模块,给后续新增或增加占比的模块释放空间。
        modules.sort(Comparator.comparing(module -> nvl(module.getProjectWeight())
                .subtract(oldWeights.getOrDefault(module.getId(), BigDecimal.ZERO))));
        for (ProjectWorkflowDtos.ModuleSaveRequest module : modules) saveModule(module);
    }
    @Transactional(rollbackFor = Exception.class)
    public void deleteModule(Long id) {
        Long projectId = jdbc.query("select project_id from project_management_module where id=:id and is_deleted=0",
                Map.of("id", id), rs -> rs.next() ? rs.getLong(1) : null);
        if (projectId == null) throw new ServiceException("项目模块不存在");
        ensureProjectMaintenanceEditable(projectId);
        if (isProjectStarted(projectId)) throw new ServiceException("项目开始后不能删除模块");
        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));
@@ -328,15 +542,24 @@
    @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("只有项目负责人可以填写项目跟进");
        }
        ensureProjectMaintenanceEditable(request.getProjectId());
        ensureProjectLeader(request.getProjectId());
        if (request.getFollowDate() == null || isBlank(request.getDetail())) {
            throw new ServiceException("请填写跟进日期和跟进详情");
        }
        validateFollowWindow(request.getProjectId(), request.getFollowDate(), request.getId());
        if (request.getFollowDate().isAfter(LocalDate.now())) throw new ServiceException("跟进日期不能晚于今天");
        if (request.getId() == null) {
            validateFollowWindow(request.getProjectId(), request.getFollowDate(), null);
        } else {
            LocalDate oldDate = jdbc.query("""
                    select follow_date from project_management_follow_up
                    where id=:id and project_id=:projectId and is_deleted=0
                    """, new MapSqlParameterSource().addValue("id", request.getId())
                    .addValue("projectId", request.getProjectId()),
                    rs -> rs.next() ? rs.getDate(1).toLocalDate() : null);
            if (oldDate == null) throw new ServiceException("跟进记录不存在");
            if (!oldDate.equals(request.getFollowDate())) throw new ServiceException("已有跟进记录不能修改跟进日期");
        }
        MapSqlParameterSource p = new MapSqlParameterSource()
                .addValue("id", request.getId()).addValue("projectId", request.getProjectId())
                .addValue("followDate", request.getFollowDate()).addValue("detail", request.getDetail())
@@ -351,26 +574,43 @@
                    """, p, keys, new String[]{"id"});
            return Objects.requireNonNull(keys.getKey()).longValue();
        }
        jdbc.update("""
        int updated = 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);
        if (updated == 0) throw new ServiceException("跟进记录不存在");
        return request.getId();
    }
    @Transactional(rollbackFor = Exception.class)
    public void deleteFollowUp(Long id) {
        Long projectId = jdbc.query("""
                select project_id from project_management_follow_up where id=:id and is_deleted=0
                """, Map.of("id", id), rs -> rs.next() ? rs.getLong(1) : null);
        if (projectId == null) throw new ServiceException("跟进记录不存在");
        ensureProjectMaintenanceEditable(projectId);
        ensureProjectLeader(projectId);
        jdbc.update("update project_management_follow_up set is_deleted=1,update_time=now() where id=:id", Map.of("id", id));
    }
    @Transactional(rollbackFor = Exception.class)
    public Long saveReport(ProjectWorkflowDtos.ProgressReportRequest request) {
        requireProject(request.getProjectId());
        ensureProjectMaintenanceEditable(request.getProjectId());
        if (request.getModuleId() == null || request.getReportDate() == null) {
            throw new ServiceException("请选择项目模块和汇报时间");
        }
        BigDecimal progress = percent(request.getProgress(), "模块进度");
        if (request.getReportDate().isAfter(LocalDate.now())) {
            throw new ServiceException("汇报时间不能晚于今天");
        }
        BigDecimal increment = percent(request.getIncrementProgress(), "本次进度");
        if (increment.compareTo(BigDecimal.ZERO) <= 0) throw new ServiceException("本次进度必须大于0");
        BigDecimal current = jdbc.queryForObject("""
                select current_progress from project_management_module
                where id=:moduleId and project_id=:projectId and is_deleted=0
                for update
                """, 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("""
@@ -381,8 +621,45 @@
        if (reporterShares.stream().anyMatch(member -> !moduleMemberIds.contains(member.getUserId()))) {
            throw new ServiceException("汇报人员只能选择该模块执行人");
        }
        if (reporterShares.stream().noneMatch(member -> Objects.equals(member.getUserId(), SecurityUtils.getUserId()))) {
            throw new ServiceException("当前办理人必须包含在本次汇报人员中");
        }
        request.setReporterIds(reporterShares.stream().map(ProjectWorkflowDtos.MemberRequest::getUserId).toList());
        request.setReporterNames(reporterShares.stream().map(ProjectWorkflowDtos.MemberRequest::getUserName).toList());
        Long reportId = request.getId();
        BigDecimal progress;
        BigDecimal progressAdjustment = BigDecimal.ZERO;
        if (reportId == null) {
            BigDecimal finalProgress = current.add(increment);
            if (finalProgress.compareTo(BigDecimal.valueOf(100)) > 0) {
                throw new ServiceException("模块当前进度为" + current.stripTrailingZeros().toPlainString()
                        + "% ,本次进度最多可填写" + BigDecimal.valueOf(100).subtract(current).stripTrailingZeros().toPlainString() + "%");
            }
            progress = previousReportProgress(request.getModuleId(), request.getReportDate(), Long.MAX_VALUE).add(increment);
        } else {
            Map<String, Object> oldReport = query("""
                    select id,module_id as moduleId,report_date as reportDate,progress,reporter_ids as reporterIds
                    from project_management_progress_report
                    where id=:id and project_id=:projectId and is_deleted=0
                    """, new MapSqlParameterSource().addValue("id", reportId).addValue("projectId", request.getProjectId()))
                    .stream().findFirst().orElseThrow(() -> new ServiceException("进度汇报不存在"));
            if (!Objects.equals(asLong(oldReport.get("moduleId")), request.getModuleId())) {
                throw new ServiceException("已有进度汇报不能更换项目模块");
            }
            if (!parseLongList(oldReport.get("reporterIds")).contains(SecurityUtils.getUserId())) {
                throw new ServiceException("只有原汇报办理人可以编辑该进度汇报");
            }
            LocalDate oldDate = oldReport.get("reportDate") instanceof java.sql.Date date ? date.toLocalDate() : request.getReportDate();
            if (!Objects.equals(oldDate, request.getReportDate())) throw new ServiceException("已有进度汇报不能修改汇报时间");
            BigDecimal previous = previousReportProgress(request.getModuleId(), request.getReportDate(), reportId);
            BigDecimal oldProgress = nvl((BigDecimal) oldReport.get("progress"));
            progress = previous.add(increment);
            progressAdjustment = progress.subtract(oldProgress);
            BigDecimal adjustedCurrent = current.add(progressAdjustment);
            if (adjustedCurrent.compareTo(BigDecimal.ZERO) < 0 || adjustedCurrent.compareTo(BigDecimal.valueOf(100)) > 0) {
                throw new ServiceException("修改后模块进度必须在0%至100%之间");
            }
        }
        MapSqlParameterSource p = new MapSqlParameterSource()
                .addValue("id", request.getId()).addValue("projectId", request.getProjectId())
                .addValue("moduleId", request.getModuleId())
@@ -391,7 +668,6 @@
                .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("""
@@ -400,6 +676,11 @@
                    values (:projectId,:moduleId,:reporterIds,:reporterNames,:reporterShares,:progress,:reportDate,:remark,:attachments,0,now(),now())
                    """, p, keys, new String[]{"id"});
            reportId = Objects.requireNonNull(keys.getKey()).longValue();
            jdbc.update("""
                    update project_management_progress_report set progress=progress+:increment,update_time=now()
                    where module_id=:moduleId and is_deleted=0 and report_date>:reportDate
                    """, new MapSqlParameterSource().addValue("increment", increment)
                    .addValue("moduleId", request.getModuleId()).addValue("reportDate", request.getReportDate()));
        } else {
            jdbc.update("""
                    update project_management_progress_report set module_id=:moduleId,reporter_ids=:reporterIds,
@@ -407,16 +688,58 @@
                    attachments=:attachments,update_time=now()
                    where id=:id and project_id=:projectId and is_deleted=0
                    """, p);
            if (progressAdjustment.compareTo(BigDecimal.ZERO) != 0) {
                jdbc.update("""
                        update project_management_progress_report set progress=progress+:adjustment,update_time=now()
                        where module_id=:moduleId and is_deleted=0 and
                        (report_date>:reportDate or (report_date=:reportDate and id>:id))
                        """, new MapSqlParameterSource().addValue("adjustment", progressAdjustment)
                        .addValue("moduleId", request.getModuleId()).addValue("reportDate", request.getReportDate()).addValue("id", reportId));
            }
        }
        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()));
        refreshModuleProgress(request.getModuleId());
        return reportId;
    }
    @Transactional(rollbackFor = Exception.class)
    public void deleteReport(Long id) {
        Map<String, Object> report = query("""
                select id,project_id as projectId,module_id as moduleId,report_date as reportDate,progress,
                       reporter_ids as reporterIds
                from project_management_progress_report where id=:id and is_deleted=0
                """, Map.of("id", id)).stream().findFirst().orElseThrow(() -> new ServiceException("进度汇报不存在"));
        ensureProjectMaintenanceEditable(asLong(report.get("projectId")));
        Long moduleId = asLong(report.get("moduleId"));
        if (!parseLongList(report.get("reporterIds")).contains(SecurityUtils.getUserId())) {
            throw new ServiceException("只有原汇报办理人可以删除该进度汇报");
        }
        BigDecimal lockedProgress = jdbc.queryForObject("""
                select current_progress from project_management_module where id=:moduleId and is_deleted=0 for update
                """, Map.of("moduleId", moduleId), BigDecimal.class);
        if (lockedProgress == null) throw new ServiceException("项目模块不存在");
        LocalDate reportDate = report.get("reportDate") instanceof java.sql.Date date ? date.toLocalDate() : null;
        BigDecimal previous = previousReportProgress(moduleId, reportDate, id);
        BigDecimal increment = nvl((BigDecimal) report.get("progress")).subtract(previous).max(BigDecimal.ZERO);
        jdbc.update("update project_management_progress_report set is_deleted=1,update_time=now() where id=:id", Map.of("id", id));
        if (increment.compareTo(BigDecimal.ZERO) > 0) {
            jdbc.update("""
                    update project_management_progress_report set progress=greatest(0,progress-:increment),update_time=now()
                    where module_id=:moduleId and is_deleted=0 and
                    (report_date>:reportDate or (report_date=:reportDate and id>:id))
                    """, new MapSqlParameterSource().addValue("increment", increment).addValue("moduleId", moduleId)
                    .addValue("reportDate", reportDate).addValue("id", id));
        }
        refreshModuleProgress(moduleId);
    }
    @Transactional(rollbackFor = Exception.class)
    public Long saveMaterial(ProjectWorkflowDtos.MaterialRequest request) {
        requireProject(request.getProjectId());
        ensureProjectMaintenanceEditable(request.getProjectId());
        if (isBlank(request.getMaterialName())) throw new ServiceException("请输入资料名称");
        if (request.getAttachments() == null || request.getAttachments().isEmpty()) {
            throw new ServiceException("请上传至少一个项目资料文件");
        }
        MapSqlParameterSource p = new MapSqlParameterSource()
                .addValue("id", request.getId()).addValue("projectId", request.getProjectId())
                .addValue("materialName", request.getMaterialName()).addValue("materialType", request.getMaterialType())
@@ -431,11 +754,12 @@
                    """, p, keys, new String[]{"id"});
            return Objects.requireNonNull(keys.getKey()).longValue();
        }
        jdbc.update("""
        int updated = 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);
        if (updated == 0) throw new ServiceException("项目资料不存在");
        return request.getId();
    }
@@ -443,7 +767,14 @@
        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);
        if (!"government_policy_file".equals(table)) {
            Long projectId = jdbc.query("select project_id from " + table + " where id=:id and is_deleted=0",
                    Map.of("id", id), rs -> rs.next() ? rs.getLong(1) : null);
            if (projectId == null) throw new ServiceException("记录不存在");
            ensureProjectMaintenanceEditable(projectId);
        }
        int updated = jdbc.getJdbcTemplate().update("update " + table + " set is_deleted=1,update_time=now() where id=? and is_deleted=0", id);
        if (updated == 0) throw new ServiceException("记录不存在");
    }
    public List<Map<String, Object>> listCategories() {
@@ -458,6 +789,14 @@
        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("上级分类不能选择自己");
        validateCategoryParent(request.getId(), parentId);
        Integer duplicate = jdbc.queryForObject("""
                select count(*) from project_management_category
                where coalesce(parent_id,0)=:parentId and category_name=:categoryName and is_deleted=0
                and (:id is null or id<>:id)
                """, new MapSqlParameterSource().addValue("parentId", parentId)
                .addValue("categoryName", request.getCategoryName().trim()).addValue("id", request.getId()), Integer.class);
        if (duplicate != null && duplicate > 0) 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());
@@ -469,17 +808,41 @@
                    """, p, keys, new String[]{"id"});
            return Objects.requireNonNull(keys.getKey()).longValue();
        }
        jdbc.update("""
        int updated = 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);
        if (updated == 0) throw new ServiceException("项目分类不存在或已删除");
        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("请先删除下级分类");
        String categoryName = jdbc.query("select category_name from project_management_category where id=:id and is_deleted=0",
                Map.of("id", id), rs -> rs.next() ? rs.getString(1) : null);
        if (categoryName == null) throw new ServiceException("项目分类不存在");
        Integer used = jdbc.queryForObject("""
                select count(*) from project_management_info
                where is_delete=0 and (project_category_id=:id or
                (project_category_id is null and project_classification=:categoryName))
                """, new MapSqlParameterSource().addValue("id", id).addValue("categoryName", categoryName), Integer.class);
        if (used != null && used > 0) throw new ServiceException("该分类已被项目使用,不能删除");
        jdbc.update("update project_management_category set is_deleted=1,update_time=now() where id=:id", Map.of("id", id));
    }
    private void validateCategoryParent(Long categoryId, Long parentId) {
        if (parentId == null || parentId == 0L) return;
        Set<Long> visited = new HashSet<>();
        Long current = parentId;
        while (current != null && current != 0L) {
            if (Objects.equals(current, categoryId)) throw new ServiceException("上级分类不能选择当前分类或其下级分类");
            if (!visited.add(current)) throw new ServiceException("分类数据已存在循环,请先修复分类关系");
            current = jdbc.query("""
                    select parent_id from project_management_category where id=:id and is_deleted=0
                    """, Map.of("id", current), rs -> rs.next() ? rs.getLong(1) : null);
            if (current == null) throw new ServiceException("上级分类不存在或已删除");
        }
    }
    public List<Map<String, Object>> listPolicies(String keyword) {
@@ -512,11 +875,12 @@
                    """, p, keys, new String[]{"id"});
            return Objects.requireNonNull(keys.getKey()).longValue();
        }
        jdbc.update("""
        int updated = 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);
        if (updated == 0) throw new ServiceException("政策文件不存在或已删除");
        return request.getId();
    }
@@ -655,26 +1019,163 @@
        }
    }
    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 isProjectStarted(Long projectId) {
        String phase = jdbc.query("select current_phase from project_management_info where id=:id and is_delete=0",
                Map.of("id", projectId), rs -> rs.next() ? rs.getString(1) : null);
        return phase != null && !phase.isBlank() && !"未开始".equals(phase);
    }
    private Map<String, Object> lifecycleNode(String code, String name, Object time, String status, String description) {
        Map<String, Object> node = new LinkedHashMap<>();
        node.put("code", code);
        node.put("name", name);
        node.put("time", time);
        node.put("status", status);
        node.put("description", description);
        return node;
    }
    private void appendPhaseHistory(Long projectId, String phaseCode, String phaseName,
                                    String actionType, String actionLabel, Map<String, Object> formData,
                                    String remark, List<Map<String, Object>> attachments) {
        jdbc.update("""
                insert into project_management_phase_history
                (project_id,phase_code,phase_name,action_type,action_label,form_data,remark,attachments,operator_name,action_time)
                values (:projectId,:phaseCode,:phaseName,:actionType,:actionLabel,:formData,:remark,:attachments,:operatorName,now())
                """, new MapSqlParameterSource()
                .addValue("projectId", projectId).addValue("phaseCode", phaseCode).addValue("phaseName", phaseName)
                .addValue("actionType", actionType).addValue("actionLabel", actionLabel)
                .addValue("formData", jsonObject(formData)).addValue("remark", remark)
                .addValue("attachments", json(attachments)).addValue("operatorName", currentUserName()));
    }
    private String stageHistoryAction(String phaseName, Map<String, Object> formData, boolean completed) {
        if ("验收".equals(phaseName)) {
            return "RECTIFY".equals(Objects.toString(formData == null ? null : formData.get("acceptanceResult"), ""))
                    ? "RECTIFY" : completed ? "PASS" : "ENTER";
        }
        if ("申报".equals(phaseName)) {
            String result = Objects.toString(formData == null ? null : formData.get("resultStatus"), "");
            if ("SUPPLEMENT".equals(result)) return "SUPPLEMENT";
            if ("REJECTED".equals(result)) return "REJECTED";
            return "SUBMIT";
        }
        if ("申报结果".equals(phaseName)) return "PASS";
        return completed ? "COMPLETE" : "SAVE";
    }
    private String stageHistoryLabel(String phaseName, Map<String, Object> formData, boolean completed) {
        if ("验收".equals(phaseName)) {
            String result = Objects.toString(formData == null ? null : formData.get("acceptanceResult"), "");
            if ("RECTIFY".equals(result)) return "需要整改";
            return completed || "PASS".equals(result) ? "验收通过" : "进入验收";
        }
        if ("申报".equals(phaseName)) {
            String result = Objects.toString(formData == null ? null : formData.get("resultStatus"), "");
            return switch (result) {
                case "SUPPLEMENT" -> "需要补充";
                case "REJECTED" -> "申报未通过";
                default -> "提交申报";
            };
        }
        if ("申报结果".equals(phaseName)) return "申报通过";
        return completed ? "阶段完成" : "阶段办理";
    }
    private void addPhaseHistoryNodes(List<Map<String, Object>> nodes, List<Map<String, Object>> histories, String phaseCode) {
        histories.stream().filter(row -> phaseCode.equals(Objects.toString(row.get("phaseCode"), ""))).forEach(row -> {
            String actionLabel = Objects.toString(row.get("actionLabel"), "办理记录");
            String description = Objects.toString(row.get("remark"), actionLabel);
            if (!isBlank(Objects.toString(row.get("operatorName"), ""))) description += ";操作人:" + row.get("operatorName");
            Map<String, Object> node = lifecycleNode("HISTORY_" + row.get("id"),
                    row.get("phaseName") + " · " + actionLabel, row.get("actionTime"), "COMPLETED", description);
            node.put("eventType", row.get("actionType"));
            node.put("statusLabel", "办理记录");
            nodes.add(node);
        });
    }
    private void addPhaseLifecycleNode(List<Map<String, Object>> nodes, Map<String, Object> phase,
                                       String code, String name, Object currentPhase, Object currentPhaseTime) {
        String status;
        Object time = null;
        String description = "尚未进入该流程";
        if (phase != null) {
            String phaseStatus = Objects.toString(phase.get("status"), "NOT_STARTED");
            status = switch (phaseStatus) {
                case "COMPLETED" -> "COMPLETED";
                case "IN_PROGRESS" -> "CURRENT";
                default -> "PENDING";
            };
            int currentRank = lifecyclePhaseRank(Objects.toString(currentPhase, "未开始"));
            int nodeRank = lifecyclePhaseRank(name.replace("项目", ""));
            if ("PENDING".equals(status) && currentRank == nodeRank) {
                status = "CURRENT";
                time = currentPhaseTime;
                description = "当前正在进行(根据项目阶段更新时间补录)";
            } else if ("PENDING".equals(status) && currentRank > nodeRank) {
                status = "COMPLETED";
                description = "该流程已完成,历史时间未单独记录";
            }
            if (!"PENDING".equals(status)) {
                if (time == null) time = phase.get("completedTime") == null ? phase.get("updateTime") : phase.get("completedTime");
                if (!description.contains("补录")) description = "COMPLETED".equals(status) ? "该流程已完成" : "该流程正在进行";
                if (!isBlank(Objects.toString(phase.get("updateUserName"), ""))) {
                    description += ",维护人:" + phase.get("updateUserName");
                }
                if (!isBlank(Objects.toString(phase.get("remark"), ""))) description += ";" + phase.get("remark");
            }
        } else {
            int currentRank = lifecyclePhaseRank(Objects.toString(currentPhase, "未开始"));
            int nodeRank = lifecyclePhaseRank(name.replace("项目", ""));
            status = currentRank > nodeRank ? "COMPLETED" : currentRank == nodeRank ? "CURRENT" : "PENDING";
            if ("CURRENT".equals(status)) description = "当前正在进行";
            else if ("COMPLETED".equals(status)) description = "该流程已完成,暂无独立时间记录";
        }
        nodes.add(lifecycleNode(code, name, time, status, description));
    }
    private int lifecyclePhaseRank(String phase) {
        return switch (phase) {
            case "开发" -> 1;
            case "申报" -> 2;
            case "申报结果" -> 3;
            case "验收" -> 4;
            case "已完成" -> 5;
            default -> 0;
        };
    }
    private String currentNodeStatus(Object currentPhase, String pendingPhase) {
        return pendingPhase.equals(Objects.toString(currentPhase, "未开始")) ? "PENDING" : "CURRENT";
    }
    private BigDecimal previousReportProgress(Long moduleId, LocalDate reportDate, Long reportId) {
        BigDecimal previous = jdbc.query("""
                select progress from project_management_progress_report
                where module_id=:moduleId and is_deleted=0 and
                (report_date<:reportDate or (report_date=:reportDate and id<:id))
                order by report_date desc,id desc limit 1
                """, new MapSqlParameterSource().addValue("moduleId", moduleId)
                .addValue("reportDate", reportDate).addValue("id", reportId),
                rs -> rs.next() ? rs.getBigDecimal(1) : BigDecimal.ZERO);
        return nvl(previous);
    }
    private void refreshModuleProgress(Long moduleId) {
        BigDecimal latest = jdbc.query("""
                select progress from project_management_progress_report
                where module_id=:moduleId and is_deleted=0 order by report_date desc,id desc limit 1
                """, Map.of("moduleId", moduleId), rs -> rs.next() ? rs.getBigDecimal(1) : BigDecimal.ZERO);
        jdbc.update("update project_management_module set current_progress=:progress,update_time=now() where id=:moduleId",
                new MapSqlParameterSource().addValue("progress", nvl(latest)).addValue("moduleId", moduleId));
    }
    private boolean hasPositiveShare(List<Map<String, Object>> reports, Long userId) {
@@ -745,12 +1246,106 @@
        return val.setScale(2, RoundingMode.HALF_UP);
    }
    private void validateTransitionForm(String currentPhase, Map<String, Object> formData) {
        switch (currentPhase) {
            case "开发" -> {
                requireDate(formData, "applicationDate", "申报日期");
                requireText(formData, "applicationType", "申报类型");
                requireText(formData, "applicationName", "申报名称");
                requireText(formData, "description", "申报说明");
            }
            case "申报" -> {
                requireDate(formData, "resultDate", "结果日期");
                requireValue(formData, "resultStatus", "PASS", "只有申报通过后才能进入申报结果阶段");
                requireText(formData, "resultDescription", "结果说明");
            }
            case "申报结果" -> {
                requireDate(formData, "acceptancePlanDate", "计划验收日期");
                requireText(formData, "acceptor", "验收人/客户");
                requireText(formData, "acceptanceStandard", "验收标准");
            }
            case "验收" -> {
                requireDate(formData, "acceptanceDate", "验收日期");
                requireText(formData, "acceptor", "验收人/客户");
                requireValue(formData, "acceptanceResult", "PASS", "只有验收通过后才能完成项目");
                requireText(formData, "acceptanceDescription", "验收说明");
            }
            default -> throw new ServiceException("当前阶段不能办理流转");
        }
    }
    private void validateSupplementalPhase(String currentPhase, Map<String, Object> formData) {
        if ("申报".equals(currentPhase)) {
            requireDate(formData, "resultDate", "结果日期");
            String result = requireText(formData, "resultStatus", "申报结果");
            if (!List.of("SUPPLEMENT", "REJECTED").contains(result)) {
                throw new ServiceException("申报通过必须使用阶段推进操作");
            }
            requireText(formData, "resultDescription", "结果说明");
            return;
        }
        if ("验收".equals(currentPhase)) {
            requireDate(formData, "acceptanceDate", "验收日期");
            requireText(formData, "acceptor", "验收人/客户");
            requireValue(formData, "acceptanceResult", "RECTIFY", "验收通过必须使用完成项目操作");
            requireText(formData, "acceptanceDescription", "验收说明");
            return;
        }
        throw new ServiceException("当前阶段不支持补充办理,只能按流程推进");
    }
    private String phaseCodeForName(String phaseName) {
        return switch (Objects.toString(phaseName, "")) {
            case "立项" -> "INITIATION";
            case "开发" -> "DEVELOPMENT";
            case "申报" -> "APPLICATION";
            case "申报结果" -> "RESULT";
            case "验收" -> "ACCEPTANCE";
            default -> null;
        };
    }
    private String requireText(Map<String, Object> formData, String key, String label) {
        String value = Objects.toString(formData == null ? null : formData.get(key), "").trim();
        if (value.isEmpty()) throw new ServiceException("请填写" + label);
        return value;
    }
    private void requireDate(Map<String, Object> formData, String key, String label) {
        String value = requireText(formData, key, label);
        try {
            LocalDate.parse(value);
        } catch (Exception e) {
            throw new ServiceException(label + "格式不正确");
        }
    }
    private void requireValue(Map<String, Object> formData, String key, String expected, String message) {
        if (!expected.equals(requireText(formData, key, key))) throw new ServiceException(message);
    }
    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 void ensureProjectMaintenanceEditable(Long projectId) {
        String phase = jdbc.queryForObject("select current_phase from project_management_info where id=:id and is_delete=0",
                Map.of("id", projectId), String.class);
        if (List.of("申报", "申报结果", "验收", "已完成").contains(phase)) {
            throw new ServiceException("项目进入申报阶段后,模块、跟进、汇报和项目资料只能查看,不能新增、编辑或删除");
        }
    }
    private void ensureProjectLeader(Long projectId) {
        Long leaderId = jdbc.query("select project_leader_id from project_management_info where id=:id and is_delete=0",
                Map.of("id", projectId), rs -> rs.next() ? rs.getLong(1) : null);
        if (leaderId == null || !leaderId.equals(SecurityUtils.getUserId())) {
            throw new ServiceException("只有项目负责人可以维护项目跟进");
        }
    }
    private List<Map<String, Object>> query(String sql, Map<String, ?> params) {
        return jdbc.queryForList(sql, params);
    }