1. 完成项目管理全流程优化,补充阶段推进、汇报统计及生命周期记录。
2. 完成业务与项目数据关联,支持查看项目进度、详情及相关附件。
3. 完成采购审批流程接入,支持配置审批人员及采购业务审批。
4. 精简用户、角色、部门、菜单和字典,完善操作日志及附件下载。
已修改19个文件
748 ■■■■■ 文件已修改
src/main/java/com/ruoyi/ai/config/EmbeddingStoreConfig.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/service/impl/KnowledgeRagServiceImpl.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/approve/controller/ApproveProcessConfigNodeController.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/service/impl/StorageBlobServiceImpl.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/home/controller/HomeController.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/projectManagement/controller/ProjectWorkflowController.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/projectManagement/dto/ProjectWorkflowDtos.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/projectManagement/dto/SaveInfoDto.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/projectManagement/pojo/Info.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/projectManagement/service/ProjectWorkflowService.java 535 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/projectManagement/service/impl/InfoServiceImpl.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/projectManagement/service/impl/handle/InfoHandleService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/projectManagement/vo/ListInfoVo.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/controller/PurchaseLedgerController.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/service/impl/PurchaseLedgerServiceImpl.java 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/controller/BusinessApprovalController.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/vo/SalesLedgerVo.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/projectManagement/InfoMapper.xml 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/sales/SalesLedgerMapper.xml 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/config/EmbeddingStoreConfig.java
@@ -11,6 +11,7 @@
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
/**
 * 向量存储配置
@@ -31,11 +32,13 @@
    private boolean pineconeEnabled;
    @Bean
    @Lazy
    public Pinecone pinecone() {
        return new Pinecone.Builder(pineconeApiKey).build();
    }
    @Bean
    @Lazy
    public Index pineconeIndex(Pinecone pinecone) {
        return pinecone.getIndexConnection(indexName);
    }
src/main/java/com/ruoyi/ai/service/impl/KnowledgeRagServiceImpl.java
@@ -17,6 +17,7 @@
import io.pinecone.clients.Index;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import java.io.File;
@@ -52,7 +53,7 @@
            EmbeddingModel embeddingModel,
            EmbeddingStore<TextSegment> embeddingStore,
            FileProperties fileProperties,
            Index pineconeIndex) {
            @Lazy Index pineconeIndex) {
        this.knowledgeBaseVectorService = knowledgeBaseVectorService;
        this.storageBlobService = storageBlobService;
        this.embeddingModel = embeddingModel;
src/main/java/com/ruoyi/approve/controller/ApproveProcessConfigNodeController.java
@@ -3,6 +3,8 @@
import com.ruoyi.approve.pojo.ApproveProcessConfigNode;
import com.ruoyi.approve.service.ApproveProcessConfigNodeService;
import com.ruoyi.framework.web.domain.R;
import com.ruoyi.framework.aspectj.lang.annotation.Log;
import com.ruoyi.framework.aspectj.lang.enums.BusinessType;
import io.swagger.annotations.ApiOperation;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.*;
@@ -42,6 +44,7 @@
     */
    @ApiOperation("添加审批节点")
    @PostMapping("/add")
    @Log(title = "审批流程配置", businessType = BusinessType.UPDATE)
    public R addApproveProcessConfigNodes(@RequestBody List<ApproveProcessConfigNode> approveProcessConfigNodes) {
        return R.ok(approveProcessConfigNodeService.addApproveProcessConfigNodes(approveProcessConfigNodes));
    }
src/main/java/com/ruoyi/basic/service/impl/StorageBlobServiceImpl.java
@@ -58,7 +58,8 @@
            String fileName = UUID.randomUUID() + "_" + originalFileName;
            String relativePath = fileUtil.buildRelativePath();
            File targetDirectory = new File(properties.getPath(), relativePath);
            if (!targetDirectory.exists() && !targetDirectory.mkdirs()) {
            // 并发上传时,另一个请求可能恰好已创建目录;此时 mkdirs 返回 false,但目录可正常使用。
            if (!targetDirectory.exists() && !targetDirectory.mkdirs() && !targetDirectory.isDirectory()) {
                throw new RuntimeException("创建上传目录失败");
            }
            File dest = new File(targetDirectory, fileName);
src/main/java/com/ruoyi/home/controller/HomeController.java
@@ -138,7 +138,7 @@
    }
    @GetMapping("/business")
    @Log(title = "销售-采购-库存数据", businessType = BusinessType.OTHER)
    @Log(title = "首页-经营数据概览", businessType = BusinessType.OTHER)
    @Operation(summary = "销售-采购-库存数据")
    public R business() {
        HomeBusinessDto homeBusinessDto = homeService.business();
src/main/java/com/ruoyi/projectManagement/controller/ProjectWorkflowController.java
@@ -1,6 +1,8 @@
package com.ruoyi.projectManagement.controller;
import com.ruoyi.framework.web.domain.AjaxResult;
import com.ruoyi.framework.aspectj.lang.annotation.Log;
import com.ruoyi.framework.aspectj.lang.enums.BusinessType;
import com.ruoyi.projectManagement.dto.ProjectWorkflowDtos;
import com.ruoyi.projectManagement.service.ProjectWorkflowService;
import io.swagger.v3.oas.annotations.Operation;
@@ -29,20 +31,22 @@
        return AjaxResult.success(service.getWorkbench(projectId));
    }
    @GetMapping("/lifecycle/{projectId}")
    @Operation(summary = "项目生命周期")
    public AjaxResult lifecycle(@PathVariable Long projectId) {
        return AjaxResult.success(service.getLifecycle(projectId));
    }
    @PreAuthorize("@ss.hasPermi('project:module:edit')")
    @PostMapping("/start/{projectId}")
    @Log(title = "项目管理-开始项目", businessType = BusinessType.UPDATE)
    public AjaxResult startProject(@PathVariable Long projectId) {
        service.startProject(projectId);
        return AjaxResult.success();
    }
    @PostMapping("/project-executors")
    public AjaxResult updateProjectExecutors(@RequestBody ProjectWorkflowDtos.ProjectExecutorsRequest request) {
        service.updateProjectExecutors(request);
        return AjaxResult.success();
    }
    @PostMapping("/transition")
    @Log(title = "项目管理-阶段推进", businessType = BusinessType.UPDATE)
    public AjaxResult transition(@RequestBody ProjectWorkflowDtos.StageTransitionRequest request) {
        service.transitionStage(request);
        return AjaxResult.success();
@@ -50,12 +54,22 @@
    @PreAuthorize("@ss.hasPermi('project:module:edit')")
    @PostMapping("/module")
    @Log(title = "项目管理-模块维护", businessType = BusinessType.UPDATE)
    public AjaxResult saveModule(@RequestBody ProjectWorkflowDtos.ModuleSaveRequest request) {
        return AjaxResult.success(service.saveModule(request));
    }
    @PreAuthorize("@ss.hasPermi('project:module:edit')")
    @PostMapping("/modules/batch")
    @Log(title = "项目管理-模块批量维护", businessType = BusinessType.UPDATE)
    public AjaxResult saveModulesBatch(@RequestBody ProjectWorkflowDtos.ModulesBatchRequest request) {
        service.saveModulesBatch(request);
        return AjaxResult.success();
    }
    @PreAuthorize("@ss.hasPermi('project:module:remove')")
    @DeleteMapping("/module/{id}")
    @Log(title = "项目管理-模块删除", businessType = BusinessType.DELETE)
    public AjaxResult deleteModule(@PathVariable Long id) {
        service.deleteModule(id);
        return AjaxResult.success();
@@ -63,12 +77,14 @@
    @PreAuthorize("@ss.hasPermi('project:follow:add')")
    @PostMapping("/follow-up")
    @Log(title = "项目管理-项目跟进", businessType = BusinessType.INSERT)
    public AjaxResult saveFollowUp(@RequestBody ProjectWorkflowDtos.FollowUpRequest request) {
        return AjaxResult.success(service.saveFollowUp(request));
    }
    @PreAuthorize("@ss.hasPermi('project:follow:remove')")
    @DeleteMapping("/follow-up/{id}")
    @Log(title = "项目管理-跟进删除", businessType = BusinessType.DELETE)
    public AjaxResult deleteFollowUp(@PathVariable Long id) {
        service.softDelete("project_management_follow_up", id);
        return AjaxResult.success();
@@ -76,31 +92,36 @@
    @PreAuthorize("@ss.hasPermi('project:report:add')")
    @PostMapping("/report")
    @Log(title = "项目管理-进度汇报", businessType = BusinessType.INSERT)
    public AjaxResult saveReport(@RequestBody ProjectWorkflowDtos.ProgressReportRequest request) {
        return AjaxResult.success(service.saveReport(request));
    }
    @PreAuthorize("@ss.hasPermi('project:report:remove')")
    @DeleteMapping("/report/{id}")
    @Log(title = "项目管理-汇报删除", businessType = BusinessType.DELETE)
    public AjaxResult deleteReport(@PathVariable Long id) {
        service.softDelete("project_management_progress_report", id);
        service.deleteReport(id);
        return AjaxResult.success();
    }
    @PreAuthorize("@ss.hasPermi('project:material:add')")
    @PostMapping("/material")
    @Log(title = "项目管理-资料维护", businessType = BusinessType.INSERT)
    public AjaxResult saveMaterial(@RequestBody ProjectWorkflowDtos.MaterialRequest request) {
        return AjaxResult.success(service.saveMaterial(request));
    }
    @PreAuthorize("@ss.hasPermi('project:phase:edit')")
    @PostMapping("/phase")
    @Log(title = "项目管理-阶段办理", businessType = BusinessType.UPDATE)
    public AjaxResult savePhase(@RequestBody ProjectWorkflowDtos.PhaseRequest request) {
        return AjaxResult.success(service.savePhase(request));
    }
    @PreAuthorize("@ss.hasPermi('project:material:remove')")
    @DeleteMapping("/material/{id}")
    @Log(title = "项目管理-资料删除", businessType = BusinessType.DELETE)
    public AjaxResult deleteMaterial(@PathVariable Long id) {
        service.softDelete("project_management_material", id);
        return AjaxResult.success();
@@ -113,12 +134,14 @@
    @PreAuthorize("@ss.hasPermi('project:category:edit')")
    @PostMapping("/category")
    @Log(title = "项目管理-分类维护", businessType = BusinessType.UPDATE)
    public AjaxResult saveCategory(@RequestBody ProjectWorkflowDtos.CategoryRequest request) {
        return AjaxResult.success(service.saveCategory(request));
    }
    @PreAuthorize("@ss.hasPermi('project:category:remove')")
    @DeleteMapping("/category/{id}")
    @Log(title = "项目管理-分类删除", businessType = BusinessType.DELETE)
    public AjaxResult deleteCategory(@PathVariable Long id) {
        service.deleteCategory(id);
        return AjaxResult.success();
@@ -131,12 +154,14 @@
    @PreAuthorize("@ss.hasPermi('policy:file:edit')")
    @PostMapping("/policy")
    @Log(title = "政府政策文件-维护", businessType = BusinessType.UPDATE)
    public AjaxResult savePolicy(@RequestBody ProjectWorkflowDtos.PolicyRequest request) {
        return AjaxResult.success(service.savePolicy(request));
    }
    @PreAuthorize("@ss.hasPermi('policy:file:remove')")
    @DeleteMapping("/policy/{id}")
    @Log(title = "政府政策文件-删除", businessType = BusinessType.DELETE)
    public AjaxResult deletePolicy(@PathVariable Long id) {
        service.softDelete("government_policy_file", id);
        return AjaxResult.success();
src/main/java/com/ruoyi/projectManagement/dto/ProjectWorkflowDtos.java
@@ -25,6 +25,12 @@
    }
    @Data
    public static class ModulesBatchRequest {
        private Long projectId;
        private List<ModuleSaveRequest> modules = new ArrayList<>();
    }
    @Data
    public static class MemberRequest {
        private Long userId;
        private String userName;
@@ -49,6 +55,8 @@
        private List<String> reporterNames = new ArrayList<>();
        /** 本次汇报中每位执行人的工作量分配,占比合计必须为100%。 */
        private List<MemberRequest> reporterShares = new ArrayList<>();
        /** 本次汇报新增的模块进度,不是模块累计进度。 */
        private BigDecimal incrementProgress;
        private BigDecimal progress;
        private LocalDate reportDate;
        private String remark;
@@ -66,15 +74,12 @@
    }
    @Data
    public static class ProjectExecutorsRequest {
        private Long projectId;
        private List<MemberRequest> executors = new ArrayList<>();
    }
    @Data
    public static class StageTransitionRequest {
        private Long projectId;
        private String targetStage;
        private Map<String, Object> formData;
        private String remark;
        private List<Map<String, Object>> attachments = new ArrayList<>();
    }
    @Data
src/main/java/com/ruoyi/projectManagement/dto/SaveInfoDto.java
@@ -47,8 +47,6 @@
    private String projectLeaderName;
    private List<Long> projectManagerIds;
    private List<String> projectManagerNames;
    private List<Long> projectExecutorIds;
    private List<String> projectExecutorNames;
    private String followCycle;
    private String followDetail;
    private String currentPhase;
src/main/java/com/ruoyi/projectManagement/pojo/Info.java
@@ -94,13 +94,6 @@
    @TableField(value = "project_manager_names", typeHandler = JacksonTypeHandler.class)
    private List<String> projectManagerNames;
    /** 项目执行人,可在项目开始后按规则调整。 */
    @TableField(value = "project_executor_ids", typeHandler = LongListJacksonTypeHandler.class)
    private List<Long> projectExecutorIds;
    @TableField(value = "project_executor_names", typeHandler = JacksonTypeHandler.class)
    private List<String> projectExecutorNames;
    /** MONTH / QUARTER / YEAR。 */
    @TableField(value = "follow_cycle")
    private String followCycle;
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 = "当前正在进行(根据项目阶段更新时间补录)";
            } 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) {
@@ -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);
    }
src/main/java/com/ruoyi/projectManagement/service/impl/InfoServiceImpl.java
@@ -4,6 +4,7 @@
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.common.enums.IsDeleteEnum;
import com.ruoyi.common.enums.SaleEnum;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.projectManagement.dto.ContractInfoDto;
import com.ruoyi.projectManagement.dto.SaveInfoDto;
import com.ruoyi.projectManagement.dto.ShippingAddressDto;
@@ -82,6 +83,13 @@
    @Override
    @Transactional
    public void deleteInfo(Long id) {
        Info current = infoMapper.selectById(id);
        if (current == null || Integer.valueOf(IsDeleteEnum.DELETED.getCode()).equals(current.getIsDelete())) {
            throw new ServiceException("项目不存在");
        }
        if (!"未开始".equals(current.getCurrentPhase())) {
            throw new ServiceException("项目已开始,不能删除");
        }
        LambdaUpdateWrapper<Info> updateWrapper = new LambdaUpdateWrapper<Info>();
        updateWrapper.eq(Info::getId, id);
        updateWrapper.set(Info::getIsDelete, IsDeleteEnum.DELETED.getCode());
src/main/java/com/ruoyi/projectManagement/service/impl/handle/InfoHandleService.java
@@ -72,7 +72,7 @@
            Info existing = infoMapper.selectById(info.getId());
            if (existing == null) throw new ServiceException("项目不存在");
            if (!StrUtil.isBlank(existing.getCurrentPhase()) && !"未开始".equals(existing.getCurrentPhase())) {
                throw new ServiceException("项目开始后基本信息和团队信息已锁定,仅可通过人员调整功能修改项目执行人");
                throw new ServiceException("项目开始后,项目基本信息、负责人和项目经理均不可修改");
            }
            infoMapper.updateById(info);
        }
src/main/java/com/ruoyi/projectManagement/vo/ListInfoVo.java
@@ -10,6 +10,7 @@
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
@@ -41,6 +42,7 @@
    private String createUserName; // 创建人名称
    private String updateUserName; // 更新人名称
    private BigDecimal projectProgress; // 模块占比加权后的项目总进度
    // 全部阶段信息
    private List<PlanStageDto> planStage;
}
src/main/java/com/ruoyi/purchase/controller/PurchaseLedgerController.java
@@ -177,6 +177,7 @@
     * 修改采购台账审批状态
     */
    @PostMapping("/updateApprovalStatus")
    @Log(title = "采购审批", businessType = BusinessType.UPDATE)
    public AjaxResult addOrEditPurchase(@RequestBody PurchaseLedger purchaseLedger){
        return toAjax(purchaseLedgerService.updateById(purchaseLedger));
    }
src/main/java/com/ruoyi/purchase/service/impl/PurchaseLedgerServiceImpl.java
@@ -8,6 +8,7 @@
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.approve.bean.dto.ApprovalInstanceDto;
import com.ruoyi.approve.bean.vo.ApproveProcessVO;
import com.ruoyi.approve.mapper.ApprovalTemplateMapper;
import com.ruoyi.approve.pojo.ApprovalInstance;
import com.ruoyi.approve.pojo.ApprovalTemplate;
@@ -182,7 +183,15 @@
            }
        }
        purchaseLedger.setApprovalStatus(1);
        boolean newPurchase = purchaseLedger.getId() == null;
        if (newPurchase) {
            purchaseLedger.setApprovalStatus(ApprovalStatusEnum.PENDING.getCode());
        } else {
            PurchaseLedger stored = purchaseLedgerMapper.selectById(purchaseLedger.getId());
            if (stored != null) {
                purchaseLedger.setApprovalStatus(stored.getApprovalStatus());
            }
        }
        // 实际合同金额始终等于合同金额
        purchaseLedger.setNetContractAmount(purchaseLedger.getContractAmount());
        // 3. 新增或更新主表
@@ -198,14 +207,15 @@
        }
        // 6.采购审核新增;审批管理未配置采购审批人时,审批服务会自动置为审批通过。
        //查询当前采购合同号是否已添加审批,如果有且不是驳回状态,则不重复添加
        ApprovalInstance oldApprovalInstance = approvalInstanceService.getOne(Wrappers.<ApprovalInstance>lambdaQuery()
                .eq(ApprovalInstance::getDeleted, 0)
                .eq(ApprovalInstance::getBusinessType, TypeEnums.PURCHASE_APPROVAL.getCode())
                .eq(ApprovalInstance::getBusinessId, purchaseLedger.getId())
                .ne(ApprovalInstance::getStatus,"REJECTED")
                .last("limit 1")
        ApproveProcess oldApprovalProcess = approveProcessService.getOne(
                Wrappers.<ApproveProcess>lambdaQuery()
                        .eq(ApproveProcess::getApproveType, TypeEnums.PURCHASE_APPROVAL.getCode())
                        .eq(ApproveProcess::getApproveReason, purchaseLedger.getPurchaseContractNumber())
                        .in(ApproveProcess::getApproveStatus, 0, 1)
                        .last("limit 1")
        );
        if(Objects.isNull(oldApprovalInstance)){
        if (Objects.isNull(oldApprovalProcess)
                && !ApprovalStatusEnum.APPROVED.getCode().equals(purchaseLedger.getApprovalStatus())) {
            addApproveByPurchase(loginUser, purchaseLedger, productList);
        }
        // 5. 迁移临时文件到正式目录
@@ -1139,37 +1149,18 @@
        return resultDto;
    }
    public void addApproveByPurchase(LoginUser loginUser, PurchaseLedger purchaseLedger, List<SalesLedgerProduct> productList) {
    public void addApproveByPurchase(LoginUser loginUser, PurchaseLedger purchaseLedger,
                                     List<SalesLedgerProduct> productList) throws Exception {
        if (loginUser == null) {
            return;
        }
        ApprovalTemplate approvalTemplate = approvalTemplateMapper.selectOne(
                new LambdaQueryWrapper<ApprovalTemplate>()
                        .eq(ApprovalTemplate::getBusinessType, 5L)
                        .eq(ApprovalTemplate::getDeleted, 0)
                        .orderByDesc(ApprovalTemplate::getId)
                        .last("LIMIT 1")
        );
        if (approvalTemplate == null) {
            throw new BaseException("请先配置采购审批模板");
        }
        ApprovalInstanceDto approvalInstance = new ApprovalInstanceDto();
        approvalInstance.setTemplateId(approvalTemplate.getId());
        approvalInstance.setTemplateName(approvalTemplate.getTemplateName());
        approvalInstance.setBusinessId(purchaseLedger.getId());
        approvalInstance.setBusinessType(5L);
        approvalInstance.setCurrentLevel(1);
        approvalInstance.setApplicantId(loginUser.getUserId());
        approvalInstance.setTitle(purchaseLedger.getPurchaseContractNumber()+"审批");
        approvalInstance.setApplicantName(loginUser.getNickName());
        approvalInstance.setApplyTime(LocalDateTime.now());
        PurchaseLedgerDto snapshot = new PurchaseLedgerDto();
        BeanUtils.copyProperties(purchaseLedger, snapshot);
        snapshot.setProductData(productList == null ? Collections.emptyList() : new ArrayList<>(productList));
        approvalInstance.setFormConfig(JSON.toJSONString(snapshot));
        approvalInstanceService.add(approvalInstance);
        ApproveProcessVO approval = new ApproveProcessVO();
        approval.setApproveType(TypeEnums.PURCHASE_APPROVAL.getCode().intValue());
        approval.setApproveDeptId(loginUser.getCurrentDeptId());
        approval.setApproveUser(loginUser.getUserId());
        approval.setApproveReason(purchaseLedger.getPurchaseContractNumber());
        approval.setPrice(purchaseLedger.getContractAmount());
        approveProcessService.addApprove(approval);
    }
    /**
src/main/java/com/ruoyi/sales/controller/BusinessApprovalController.java
@@ -1,6 +1,8 @@
package com.ruoyi.sales.controller;
import com.ruoyi.framework.web.domain.AjaxResult;
import com.ruoyi.framework.aspectj.lang.annotation.Log;
import com.ruoyi.framework.aspectj.lang.enums.BusinessType;
import com.ruoyi.sales.dto.BusinessReviewDto;
import com.ruoyi.sales.service.BusinessApprovalService;
import lombok.RequiredArgsConstructor;
@@ -20,6 +22,7 @@
    @PostMapping("/submit/{id}")
    @PreAuthorize("@ss.hasPermi('business:approval:submit')")
    @Log(title = "业务管理-提交审批", businessType = BusinessType.UPDATE)
    public AjaxResult submit(@PathVariable Long id) {
        businessApprovalService.submit(id);
        return AjaxResult.success();
@@ -27,6 +30,7 @@
    @PutMapping("/review")
    @PreAuthorize("@ss.hasPermi('business:approval:review')")
    @Log(title = "业务管理-业务审批", businessType = BusinessType.UPDATE)
    public AjaxResult review(@RequestBody BusinessReviewDto dto) {
        return AjaxResult.success(businessApprovalService.review(dto));
    }
src/main/java/com/ruoyi/sales/vo/SalesLedgerVo.java
@@ -4,10 +4,25 @@
import com.ruoyi.sales.pojo.SalesLedger;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
@Data
public class SalesLedgerVo extends SalesLedger {
    private List<StorageBlobVO> storageBlobVOs;
    /** 由当前业务生成的项目概况。 */
    private String projectNo;
    private String linkedProjectName;
    private String projectCurrentPhase;
    private String projectLeaderName;
    private LocalDateTime projectPlanStartTime;
    private LocalDateTime projectPlanEndTime;
    private LocalDateTime projectActualStartTime;
    private LocalDateTime projectActualEndTime;
    private BigDecimal projectProgress;
    private Integer projectModuleCount;
    private Integer projectCompletedModuleCount;
}
src/main/resources/mapper/projectManagement/InfoMapper.xml
@@ -21,10 +21,6 @@
                    typeHandler="com.ruoyi.common.handler.LongListJacksonTypeHandler"/>
            <result property="projectManagerNames" column="project_manager_names"
                    typeHandler="com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler"/>
            <result property="projectExecutorIds" column="project_executor_ids"
                    typeHandler="com.ruoyi.common.handler.LongListJacksonTypeHandler"/>
            <result property="projectExecutorNames" column="project_executor_names"
                    typeHandler="com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler"/>
            <result property="followCycle" column="follow_cycle" jdbcType="VARCHAR"/>
            <result property="followDetail" column="follow_detail" jdbcType="VARCHAR"/>
            <result property="currentPhase" column="current_phase" jdbcType="VARCHAR"/>
@@ -56,7 +52,6 @@
        client_id,client_name,project_management_info_parent_id,
        establish_time,project_management_plan_id,source,project_classification,
        project_leader_id,project_leader_name,project_manager_ids,project_manager_names,
        project_executor_ids,project_executor_names,
        follow_cycle,follow_detail,current_phase,source_business_id,
        manager_id,manager_name,salesman_id,
        salesman_name,is_delete,create_time,
@@ -74,14 +69,11 @@
                typeHandler="com.ruoyi.common.handler.LongListJacksonTypeHandler"/>
        <result column="project_manager_names" property="projectManagerNames"
                typeHandler="com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler"/>
        <result column="project_executor_ids" property="projectExecutorIds"
                typeHandler="com.ruoyi.common.handler.LongListJacksonTypeHandler"/>
        <result column="project_executor_names" property="projectExecutorNames"
                typeHandler="com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler"/>
        <result column="follow_cycle" property="followCycle"/>
        <result column="follow_detail" property="followDetail"/>
        <result column="current_phase" property="currentPhase"/>
        <result column="source_business_id" property="sourceBusinessId"/>
        <result column="project_progress" property="projectProgress"/>
        <result column="plan_stage"
                property="planStage"
@@ -89,7 +81,10 @@
    </resultMap>
    <select id="searchListInfo" resultMap="ListInfoVoMap">
        select t1.*, t2.name as project_management_plan_name,t3.title as projectManagementInfoParentName
        select t1.*, t2.name as project_management_plan_name,t3.title as projectManagementInfoParentName,
               coalesce((select sum(m.project_weight * m.current_progress) / 100
                         from project_management_module m
                         where m.project_id=t1.id and m.is_deleted=0), 0) as project_progress
        from project_management_info as t1
        left join project_management_plan as t2 on t1.project_management_plan_id = t2.id
        left join project_management_info as t3 on t1.project_management_info_parent_id = t3.id
src/main/resources/mapper/sales/SalesLedgerMapper.xml
@@ -77,9 +77,31 @@
        T1.payment_method,
        T1.delivery_date,
        DATEDIFF(T1.delivery_date, CURDATE()) AS delivery_days_diff,
        IFNULL(shipping_status_counts.is_all_shipped, FALSE) AS is_fh
        IFNULL(shipping_status_counts.is_all_shipped, FALSE) AS is_fh,
        project_info.no AS project_no,
        project_info.title AS linked_project_name,
        project_info.current_phase AS project_current_phase,
        project_info.project_leader_name AS project_leader_name,
        project_info.plan_start_time AS project_plan_start_time,
        project_info.plan_end_time AS project_plan_end_time,
        project_info.actual_start_time AS project_actual_start_time,
        project_info.actual_end_time AS project_actual_end_time,
        IFNULL(project_module_stats.project_progress, 0) AS project_progress,
        IFNULL(project_module_stats.module_count, 0) AS project_module_count,
        IFNULL(project_module_stats.completed_module_count, 0) AS project_completed_module_count
        FROM sales_ledger T1
        LEFT JOIN sys_user T2 ON T1.entry_person = T2.user_id
        LEFT JOIN project_management_info project_info
          ON project_info.id = T1.project_management_info_id AND project_info.is_delete = 0
        LEFT JOIN (
          SELECT project_id,
                 ROUND(IFNULL(SUM(project_weight * current_progress) / 100, 0), 2) AS project_progress,
                 COUNT(*) AS module_count,
                 SUM(CASE WHEN current_progress &gt;= 100 THEN 1 ELSE 0 END) AS completed_module_count
          FROM project_management_module
          WHERE is_deleted = 0
          GROUP BY project_id
        ) project_module_stats ON project_module_stats.project_id = project_info.id
        LEFT JOIN (
        SELECT sales_ledger_id,
        CASE