From 2dd645b5346f510d1207346347f1fa70a204d9d3 Mon Sep 17 00:00:00 2001
From: zhangwencui <1064582902@qq.com>
Date: 星期四, 05 十一月 2026 16:11:34 +0800
Subject: [PATCH] 1. 完成项目管理全流程优化,补充阶段推进、汇报统计及生命周期记录。 2. 完成业务与项目数据关联,支持查看项目进度、详情及相关附件。 3. 完成采购审批流程接入,支持配置审批人员及采购业务审批。 4. 精简用户、角色、部门、菜单和字典,完善操作日志及附件下载。

---
 src/main/java/com/ruoyi/projectManagement/service/ProjectWorkflowService.java |  535 +++++++++++++++++++++++++++++++++++++++++++++++++++++------
 1 files changed, 479 insertions(+), 56 deletions(-)

diff --git a/src/main/java/com/ruoyi/projectManagement/service/ProjectWorkflowService.java b/src/main/java/com/ruoyi/projectManagement/service/ProjectWorkflowService.java
index 3241827..f2e9b48 100644
--- a/src/main/java/com/ruoyi/projectManagement/service/ProjectWorkflowService.java
+++ b/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));
@@ -106,7 +161,6 @@
                 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("""
@@ -132,12 +186,28 @@
         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());
+        ensurePhases(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 = "寮�鍙�";
@@ -160,31 +230,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)
@@ -204,7 +293,16 @@
                     """, 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) + "鈥�");
+                String projectPhase = jdbc.queryForObject("select current_phase from project_management_info where id=:id and is_delete=0",
+                        Map.of("id", request.getProjectId()), String.class);
+                if (lifecyclePhaseRank(projectPhase) < index) {
+                    throw new ServiceException("璇峰厛瀹屾垚涓婁竴闃舵鈥�" + PHASE_NAMES.get(index - 1) + "鈥�");
+                }
+                jdbc.update("""
+                        update project_management_phase set status='COMPLETED',completed_time=coalesce(completed_time,now()),
+                        update_time=now() where project_id=:projectId and phase_code=:phaseCode and is_deleted=0
+                        """, new MapSqlParameterSource().addValue("projectId", request.getProjectId())
+                        .addValue("phaseCode", PHASE_CODES.get(index - 1)));
             }
         }
         MapSqlParameterSource p = new MapSqlParameterSource()
@@ -218,17 +316,74 @@
                 update_user_name=:userName,update_time=now()
                 where project_id=:projectId and phase_code=:phaseCode and is_deleted=0
                 """, p);
+        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());
         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()));
+        jdbc.update("""
+                update project_management_info set current_phase=:phase,
+                actual_end_time=case when :phase='宸插畬鎴�' then coalesce(actual_end_time,current_date) else actual_end_time end,
+                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);
+    }
+
+    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 +400,15 @@
                     + "%锛屽綋鍓嶆ā鍧楁渶澶氬彲璁剧疆" + 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 && 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 +419,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 +475,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,6 +533,7 @@
     @Transactional(rollbackFor = Exception.class)
     public Long saveFollowUp(ProjectWorkflowDtos.FollowUpRequest request) {
         requireProject(request.getProjectId());
+        ensureProjectMaintenanceEditable(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())) {
@@ -361,16 +567,17 @@
     @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(), "妯″潡杩涘害");
+        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
                 """, 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("""
@@ -383,6 +590,36 @@
         }
         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) {
+            progress = current.add(increment);
+            if (progress.compareTo(BigDecimal.valueOf(100)) > 0) {
+                throw new ServiceException("妯″潡褰撳墠杩涘害涓�" + current.stripTrailingZeros().toPlainString()
+                        + "% 锛屾湰娆¤繘搴︽渶澶氬彲濉啓" + BigDecimal.valueOf(100).subtract(current).stripTrailingZeros().toPlainString() + "%");
+            }
+        } else {
+            Map<String, Object> oldReport = query("""
+                    select id,module_id as moduleId,report_date as reportDate,progress
+                    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("宸叉湁杩涘害姹囨姤涓嶈兘鏇存崲椤圭洰妯″潡");
+            }
+            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 +628,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("""
@@ -407,15 +643,51 @@
                     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
+                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"));
+        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", moduleId)).stream().map(row -> asLong(row.get("userId"))).toList();
+        if (!moduleMemberIds.contains(SecurityUtils.getUserId())) 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("璇疯緭鍏ヨ祫鏂欏悕绉�");
         MapSqlParameterSource p = new MapSqlParameterSource()
                 .addValue("id", request.getId()).addValue("projectId", request.getProjectId())
@@ -442,6 +714,12 @@
     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("涓嶆敮鎸佺殑鍒犻櫎绫诲瀷");
+        }
+        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);
         }
         jdbc.getJdbcTemplate().update("update " + table + " set is_deleted=1,update_time=now() where id=?", id);
     }
@@ -655,26 +933,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 = "褰撳墠姝e湪杩涜锛堟牴鎹」鐩樁娈垫洿鏂版椂闂磋ˉ褰曪級";
+            } 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 = "褰撳墠姝e湪杩涜";
+            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) {
@@ -751,6 +1166,14 @@
         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 List<Map<String, Object>> query(String sql, Map<String, ?> params) {
         return jdbc.queryForList(sql, params);
     }

--
Gitblit v1.9.3