yanghao vor 5 Tagen
Ursprung
Commit
53778aad93

+ 53 - 0
yudao-module-rd/yudao-module-rd-api/src/main/java/cn/iocoder/yudao/module/rd/enums/ProjectApprovalConstants.java

@@ -0,0 +1,53 @@
+package cn.iocoder.yudao.module.rd.enums;
+
+/**
+ * 项目审批相关常量
+ *
+ * @author ruiqi
+ */
+public interface ProjectApprovalConstants {
+
+    // ==================== 阶段 ====================
+    /** 阶段:预审 */
+    int STAGE_PRE = 1;
+    /** 阶段:正式立项 */
+    int STAGE_FORMAL = 2;
+
+    // ==================== 审批状态 ====================
+    /** 未提交(草稿) */
+    int AUDIT_STATUS_DRAFT = 0;
+    /** 审批中 */
+    int AUDIT_STATUS_PROCESSING = 1;
+    /** 审批通过 */
+    int AUDIT_STATUS_PASSED = 2;
+    /** 审批驳回 */
+    int AUDIT_STATUS_REJECTED = 3;
+
+    // ==================== 审批节点 ====================
+    /** 预审-科技发展部二级审批 */
+    int NODE_PRE_SECOND = 11;
+
+    /** 正式-专业公司一级审批 */
+    int NODE_FORMAL_FIRST = 21;
+    /** 正式-科技发展部二级审批 */
+    int NODE_FORMAL_SECOND = 22;
+    /** 正式-财务审批(条件节点) */
+    int NODE_FORMAL_FINANCE = 23;
+
+    // ==================== 审批结果 ====================
+    /** 通过 */
+    int RESULT_PASS = 1;
+    /** 驳回 */
+    int RESULT_REJECT = 2;
+    /** 撤回 */
+    int RESULT_CANCEL = 3;
+    /** 提交(项目负责人的动作) */
+    int RESULT_SUBMIT = 4;
+
+    // ==================== 是否涉及预算 ====================
+    /** 不涉及预算 */
+    int INVOLVE_BUDGET_NO = 0;
+    /** 涉及预算 */
+    int INVOLVE_BUDGET_YES = 1;
+
+}

+ 24 - 2
yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/controller/admin/researchProject/vo/ProjectRespVO.java

@@ -8,6 +8,7 @@ import java.time.LocalDateTime;
 import java.util.List;
 
 @Schema(description = "管理后台 - 项目立项 Response VO")
+
 @Data
 public class ProjectRespVO {
 
@@ -17,8 +18,11 @@ public class ProjectRespVO {
     @Schema(description = "项目级别", example = "国家级")
     private String projectLevel;
 
-    @Schema(description = "执行单位", example = "某某研究院")
-    private String executingUnit;
+    @Schema(description = "执行单位(部门ID)", example = "100")
+    private Long executingUnit;
+
+    @Schema(description = "执行单位名称", example = "XX石油技术公司")
+    private String executingUnitName;   // ⭐ 前端展示用,详情接口里翻译
 
     @Schema(description = "项目名称", example = "智能仓储系统")
     private String projectName;
@@ -111,4 +115,22 @@ public class ProjectRespVO {
 
     }
 
+
+    @Schema(description = "审批记录列表")
+    private List<ApprovalRecordVO> approvalRecords;
+
+    @Schema(description = "项目审批记录 VO")
+    @Data
+    public static class ApprovalRecordVO {
+        private Long id;
+        private Integer stage;         // 阶段:1预审 2正式
+        private Integer node;          // 节点:11、21、22、23
+        private String nodeName;       // 节点名称
+        private Long approverId;       // 审批人ID
+        private String approverName;   // 审批人姓名
+        private Integer result;        // 结果:1通过 2驳回 3撤回 4提交
+        private String opinion;        // 意见
+        private LocalDateTime approveTime;
+    }
+
 }

+ 3 - 3
yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/controller/admin/researchProject/vo/ProjectSaveReqVO.java

@@ -19,9 +19,9 @@ public class ProjectSaveReqVO {
     @NotEmpty(message = "项目级别不能为空")
     private String projectLevel;
 
-    @Schema(description = "执行单位", requiredMode = Schema.RequiredMode.REQUIRED, example = "某某研究院")
-    @NotEmpty(message = "执行单位不能为空")
-    private String executingUnit;
+    @Schema(description = "执行单位(部门ID)", requiredMode = Schema.RequiredMode.REQUIRED, example = "100")
+    @NotNull(message = "执行单位不能为空")
+    private Long executingUnit;
 
     @Schema(description = "项目名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "智能仓储系统")
     @NotEmpty(message = "项目名称不能为空")

+ 8 - 1
yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/dal/dataobject/researchProject/ProjectDO.java

@@ -28,6 +28,8 @@ public class ProjectDO extends BaseDO {
      */
     @TableId
     private Long id;
+
+    private Long deptId;
     /**
      * 项目级别
      */
@@ -35,7 +37,7 @@ public class ProjectDO extends BaseDO {
     /**
      * 执行单位
      */
-    private String executingUnit;
+    private Long executingUnit;
     /**
      * 项目名称
      */
@@ -96,6 +98,8 @@ public class ProjectDO extends BaseDO {
     private Integer preCurrentNode;
     /** 预审当前审批人ID */
     private Long preCurrentApproverId;
+    /** 预审当前节点角色 code */
+    private String preCurrentRoleCode;
     /** 预审提交时间 */
     private LocalDateTime preSubmitTime;
 
@@ -106,9 +110,12 @@ public class ProjectDO extends BaseDO {
     private Integer formalCurrentNode;
     /** 正式当前审批人ID */
     private Long formalCurrentApproverId;
+    /** 正式当前节点角色 code */
+    private String formalCurrentRoleCode;
     /** 正式立项提交时间 */
     private LocalDateTime formalSubmitTime;
 
+
 // ==================== 预算相关 ====================
     /** 是否涉及预算:0否 1是 */
     private Integer involveBudget;

+ 24 - 0
yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/dal/mysql/researchProject/ApproverQueryMapper.java

@@ -0,0 +1,24 @@
+package cn.iocoder.yudao.module.rd.dal.mysql.researchProject;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+@Mapper
+public interface ApproverQueryMapper {
+
+    @Select("SELECT u.id FROM system_users u " +
+            "JOIN system_user_role ur ON ur.user_id = u.id " +
+            "JOIN system_role r ON r.id = ur.role_id " +
+            "WHERE r.code = #{roleCode} AND u.deleted = 0 LIMIT 1")
+    Long selectUserIdByRoleCode(@Param("roleCode") String roleCode);
+
+    @Select("SELECT u.id FROM system_users u " +
+            "JOIN system_user_role ur ON ur.user_id = u.id " +
+            "JOIN system_role r ON r.id = ur.role_id " +
+            "WHERE r.code = #{roleCode} AND u.dept_id = #{deptId} AND u.deleted = 0 LIMIT 1")
+    Long selectUserIdByRoleCodeAndDept(@Param("roleCode") String roleCode,
+                                       @Param("deptId") Long deptId);
+
+}

+ 1 - 1
yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/dal/mysql/researchProject/ProjectMapper.java

@@ -37,7 +37,7 @@ public interface ProjectMapper extends BaseMapperX<ProjectDO> {
         return new LambdaQueryWrapperX<ProjectDO>()
                 .likeIfPresent(ProjectDO::getProjectName, reqVO.getProjectName())
                 .eqIfPresent(ProjectDO::getProjectLevel, reqVO.getProjectLevel())
-                .eqIfPresent(ProjectDO::getExecutingUnit, reqVO.getExecutingUnit())
+                .eqIfPresent(ProjectDO::getExecutingUnit, reqVO.getExecutingUnit())   // ⭐ 改成 eqIfPresent
                 .eqIfPresent(ProjectDO::getProjectLeader, reqVO.getProjectLeader())
                 .eqIfPresent(ProjectDO::getProjectType, reqVO.getProjectType())
                 .eqIfPresent(ProjectDO::getStatus, reqVO.getStatus())

+ 0 - 1
yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/service/researchProject/ResearchProjectService.java

@@ -61,7 +61,6 @@ public interface ResearchProjectService {
      * @param pageReqVO 列表查询
      * @return 项目列表
      */
-    List<ProjectDO> getProjects(ProjectPageReqVO pageReqVO);
 
     List<ProjectDO> getProjectList(@Valid ProjectPageReqVO pageReqVO);
 

+ 315 - 99
yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/service/researchProject/ResearchProjectServiceImpl.java

@@ -1,38 +1,25 @@
 package cn.iocoder.yudao.module.rd.service.researchProject;
 
+import cn.hutool.core.util.StrUtil;
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
-import cn.iocoder.yudao.module.rd.controller.admin.researchProject.vo.ProjectPageReqVO;
-import cn.iocoder.yudao.module.rd.controller.admin.researchProject.vo.ProjectRespVO;
-import cn.iocoder.yudao.module.rd.controller.admin.researchProject.vo.ProjectSaveReqVO;
-import cn.iocoder.yudao.module.rd.dal.dataobject.researchProject.ProjectBudgetDO;
-import cn.iocoder.yudao.module.rd.dal.dataobject.researchProject.ProjectDO;
-import cn.iocoder.yudao.module.rd.dal.dataobject.researchProject.ProjectScheduleDO;
-import cn.iocoder.yudao.module.rd.dal.mysql.researchProject.ProjectApprovalRecordMapper;
-import cn.iocoder.yudao.module.rd.dal.mysql.researchProject.ProjectBudgetMapper;
-import cn.iocoder.yudao.module.rd.dal.mysql.researchProject.ProjectMapper;
-import cn.iocoder.yudao.module.rd.dal.mysql.researchProject.ProjectScheduleMapper;
+import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
+import cn.iocoder.yudao.module.rd.controller.admin.researchProject.vo.*;
+import cn.iocoder.yudao.module.rd.dal.dataobject.researchProject.*;
+import cn.iocoder.yudao.module.rd.dal.mysql.researchProject.*;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.validation.annotation.Validated;
 
-import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
-import static cn.iocoder.yudao.module.rd.enums.ErrorCodeConstant.*;
-
 import javax.annotation.Resource;
+import java.time.LocalDateTime;
 import java.util.List;
 import java.util.Objects;
-import java.time.LocalDateTime;
 
 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
-import static cn.iocoder.yudao.module.rd.enums.ErrorCodeConstant.PROJECT_NOT_EXISTS;
-
+import static cn.iocoder.yudao.module.rd.enums.ErrorCodeConstant.*;
+import static cn.iocoder.yudao.module.rd.enums.ProjectApprovalConstants.*;
 
-/**
- * 项目立项 Service 实现类
- *
- * @author ruiqi
- */
 @Service
 @Validated
 public class ResearchProjectServiceImpl implements ResearchProjectService {
@@ -45,11 +32,11 @@ public class ResearchProjectServiceImpl implements ResearchProjectService {
     private ProjectBudgetMapper projectBudgetMapper;
     @Resource
     private ProjectApprovalRecordMapper approvalRecordMapper;
+    @Resource
+    private ApproverQueryMapper approverQueryMapper;
 
+    // ==================== 新增 ====================
 
-    /**
-     * 新增项目
-     */
     @Override
     @Transactional(rollbackFor = Exception.class)
     public Long createProject(ProjectSaveReqVO createReqVO) {
@@ -83,46 +70,6 @@ public class ResearchProjectServiceImpl implements ResearchProjectService {
         return project.getId();
     }
 
-
-    /**
-     * 删除项目
-     */
-    @Override
-    @Transactional(rollbackFor = Exception.class)
-    public void deleteProject(Long id) {
-        ProjectDO exist = projectMapper.selectById(id);
-        if (exist == null) {
-            throw exception(PROJECT_NOT_EXISTS);
-        }
-        // ⚠️ 已进入审批的项目禁止删除(安全)
-        if (exist.getPreAuditStatus() != null && exist.getPreAuditStatus() == AUDIT_STATUS_PROCESSING) {
-            throw exception(PROJECT_IN_APPROVAL_CANNOT_DELETE);
-        }
-        if (exist.getFormalAuditStatus() != null && exist.getFormalAuditStatus() == AUDIT_STATUS_PROCESSING) {
-            throw exception(PROJECT_IN_APPROVAL_CANNOT_DELETE);
-        }
-
-        projectMapper.deleteById(id);
-        projectScheduleMapper.deleteByProjectId(id);
-        projectBudgetMapper.deleteByProjectId(id);
-        // 审批记录保留(审计需求),如需级联删,放开下一行
-        // approvalRecordMapper.deleteByProjectId(id);
-    }
-
-
-
-    /**
-     * 获取当前最大的 sort 值,没有记录时返回 0
-     */
-    private int getMaxSort() {
-        Integer max = projectMapper.selectMaxSort();
-        return max == null ? 0 : max;
-    }
-
-
-    /**
-     * 更新项目
-     */
     @Override
     @Transactional(rollbackFor = Exception.class)
     public void updateProject(ProjectSaveReqVO updateReqVO) {
@@ -157,10 +104,6 @@ public class ResearchProjectServiceImpl implements ResearchProjectService {
         insertBudgetList(updateReqVO.getBudgetList(), updateReqVO.getId());
     }
 
-
-    /**
-     * 删除项目
-     */
     @Override
     @Transactional(rollbackFor = Exception.class)
     public void deleteProject(Long id) {
@@ -183,11 +126,7 @@ public class ResearchProjectServiceImpl implements ResearchProjectService {
         // approvalRecordMapper.deleteByProjectId(id);
     }
 
-    private void validateProjectExists(Long id) {
-        if (projectMapper.selectById(id) == null) {
-            throw exception(PROJECT_NOT_EXISTS);
-        }
-    }
+    // ==================== 详情 & 分页 ====================
 
     @Override
     public ProjectRespVO getProject(Long id) {
@@ -195,39 +134,224 @@ public class ResearchProjectServiceImpl implements ResearchProjectService {
         if (project == null) {
             return null;
         }
-        ProjectRespVO respVO = BeanUtils.toBean(project, ProjectRespVO.class);
-        // 子表:进度安排
-        List<ProjectScheduleDO> scheduleList = projectScheduleMapper.selectListByProjectId(id);
-        respVO.setScheduleList(BeanUtils.toBean(scheduleList, ProjectRespVO.ScheduleVO.class));
-        // 子表:费用预算
-        List<ProjectBudgetDO> budgetList = projectBudgetMapper.selectListByProjectId(id);
-        respVO.setBudgetList(BeanUtils.toBean(budgetList, ProjectRespVO.BudgetVO.class));
-        return respVO;
+        ProjectRespVO vo = BeanUtils.toBean(project, ProjectRespVO.class);
+
+        // 子表
+        vo.setScheduleList(BeanUtils.toBean(
+                projectScheduleMapper.selectListByProjectId(id), ProjectRespVO.ScheduleVO.class));
+        vo.setBudgetList(BeanUtils.toBean(
+                projectBudgetMapper.selectListByProjectId(id), ProjectRespVO.BudgetVO.class));
+
+        // 审批记录
+        vo.setApprovalRecords(BeanUtils.toBean(
+                approvalRecordMapper.selectListByProjectId(id), ProjectRespVO.ApprovalRecordVO.class));
+
+        return vo;
     }
 
     @Override
     public PageResult<ProjectRespVO> getProjectPage(ProjectPageReqVO pageReqVO) {
         PageResult<ProjectDO> pageResult = projectMapper.selectPage(pageReqVO);
-        // 列表页不带子表,只转主表字段
         return BeanUtils.toBean(pageResult, ProjectRespVO.class);
     }
 
     @Override
-    public List<ProjectDO> getProjects(ProjectPageReqVO reqVO) {
+    public List<ProjectDO> getProjectList(ProjectPageReqVO reqVO) {
         return projectMapper.selectList(reqVO);
     }
 
+    // ==================== 提交预审 ====================
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void submitPreApproval(Long projectId) {
+        ProjectDO project = getAndValidate(projectId);
+
+        // 只有"未提交"或"被驳回"可以提交
+        Integer status = project.getPreAuditStatus();
+        if (status != AUDIT_STATUS_DRAFT && status != AUDIT_STATUS_REJECTED) {
+            throw exception(PROJECT_PRE_STATUS_ILLEGAL);
+        }
+
+        // ⭐ 校验必填项(比如附件必须上传),防止用户草稿直接提交
+        validateBeforeSubmitPre(project);
+
+        // 找一级审批人(预审只有二级)
+        Long approverId = findApprover(NODE_PRE_SECOND, project.getDeptId());
+        if (approverId == null) {
+            throw exception(PROJECT_APPROVER_NOT_FOUND);
+        }
+
+        // 写一条"提交"记录
+        insertRecord(projectId, STAGE_PRE, NODE_PRE_SECOND, "提交预审", RESULT_SUBMIT, "提交预审材料");
+
+        // 更新主表
+        ProjectDO update = new ProjectDO();
+        update.setId(projectId);
+        update.setPreAuditStatus(AUDIT_STATUS_PROCESSING);
+        update.setPreCurrentNode(NODE_PRE_SECOND);
+        update.setPreCurrentApproverId(approverId);
+        update.setPreSubmitTime(LocalDateTime.now());
+        projectMapper.updateById(update);
+    }
+
+    // ==================== 预审审批 ====================
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void approvePre(ProjectApproveReqVO reqVO) {
+        ProjectDO project = getAndValidate(reqVO.getProjectId());
+
+        if (project.getPreAuditStatus() != AUDIT_STATUS_PROCESSING) {
+            throw exception(PROJECT_NOT_IN_APPROVAL);
+        }
+        if (!Objects.equals(project.getPreCurrentApproverId(), SecurityFrameworkUtils.getLoginUserId())) {
+            throw exception(PROJECT_NOT_YOUR_TURN);
+        }
+
+        // 写记录
+        insertRecord(project.getId(), STAGE_PRE, project.getPreCurrentNode(),
+                getNodeName(project.getPreCurrentNode()), reqVO.getResult(), reqVO.getOpinion());
+
+        // 更新主表
+        ProjectDO update = new ProjectDO();
+        update.setId(project.getId());
+        if (reqVO.getResult() == RESULT_REJECT) {
+            update.setPreAuditStatus(AUDIT_STATUS_REJECTED);
+        } else {
+            // 预审只有二级,通过就结束
+            update.setPreAuditStatus(AUDIT_STATUS_PASSED);
+        }
+        update.setPreCurrentNode(null);
+        update.setPreCurrentApproverId(null);
+        projectMapper.updateById(update);
+    }
+
+    // ==================== 提交正式立项 ====================
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void submitFormalApproval(Long projectId) {
+        ProjectDO project = getAndValidate(projectId);
+
+        // ⭐ 必须先预审通过
+        if (project.getPreAuditStatus() == null || project.getPreAuditStatus() != AUDIT_STATUS_PASSED) {
+            throw exception(PROJECT_PRE_NOT_PASSED);
+        }
+        Integer status = project.getFormalAuditStatus();
+        if (status != AUDIT_STATUS_DRAFT && status != AUDIT_STATUS_REJECTED) {
+            throw exception(PROJECT_FORMAL_STATUS_ILLEGAL);
+        }
+
+        validateBeforeSubmitFormal(project);
+
+        Long approverId = findApprover(NODE_FORMAL_FIRST, project.getDeptId());
+        if (approverId == null) {
+            throw exception(PROJECT_APPROVER_NOT_FOUND);
+        }
+
+        insertRecord(projectId, STAGE_FORMAL, NODE_FORMAL_FIRST, "提交正式立项",
+                RESULT_SUBMIT, "提交立项汇报");
+
+        ProjectDO update = new ProjectDO();
+        update.setId(projectId);
+        update.setFormalAuditStatus(AUDIT_STATUS_PROCESSING);
+        update.setFormalCurrentNode(NODE_FORMAL_FIRST);
+        update.setFormalCurrentApproverId(approverId);
+        update.setFormalSubmitTime(LocalDateTime.now());
+        projectMapper.updateById(update);
+    }
+
+    // ==================== 正式立项审批(含条件分支) ====================
 
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void approveFormal(ProjectApproveReqVO reqVO) {
+        ProjectDO project = getAndValidate(reqVO.getProjectId());
+
+        if (project.getFormalAuditStatus() != AUDIT_STATUS_PROCESSING) {
+            throw exception(PROJECT_NOT_IN_APPROVAL);
+        }
+        if (!Objects.equals(project.getFormalCurrentApproverId(), SecurityFrameworkUtils.getLoginUserId())) {
+            throw exception(PROJECT_NOT_YOUR_TURN);
+        }
+
+        insertRecord(project.getId(), STAGE_FORMAL, project.getFormalCurrentNode(),
+                getNodeName(project.getFormalCurrentNode()), reqVO.getResult(), reqVO.getOpinion());
+
+        ProjectDO update = new ProjectDO();
+        update.setId(project.getId());
+
+        if (reqVO.getResult() == RESULT_REJECT) {
+            update.setFormalAuditStatus(AUDIT_STATUS_REJECTED);
+            update.setFormalCurrentNode(null);
+            update.setFormalCurrentApproverId(null);
+        } else {
+            Integer nextNode = getNextFormalNode(project);
+            if (nextNode == null) {
+                update.setFormalAuditStatus(AUDIT_STATUS_PASSED);
+                update.setFormalCurrentNode(null);
+                update.setFormalCurrentApproverId(null);
+            } else {
+                Long nextApproverId = findApprover(nextNode, project.getDeptId());
+                if (nextApproverId == null) {
+                    throw exception(PROJECT_APPROVER_NOT_FOUND);
+                }
+                update.setFormalCurrentNode(nextNode);
+                update.setFormalCurrentApproverId(nextApproverId);
+            }
+        }
+        projectMapper.updateById(update);
+    }
+
+    // ==================== 撤回 ====================
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void cancelApproval(Long projectId) {
+        ProjectDO project = getAndValidate(projectId);
+
+        // 判断在哪个阶段
+        if (project.getPreAuditStatus() != null && project.getPreAuditStatus() == AUDIT_STATUS_PROCESSING) {
+            insertRecord(projectId, STAGE_PRE, project.getPreCurrentNode(),
+                    getNodeName(project.getPreCurrentNode()), RESULT_CANCEL, "撤回申请");
+            ProjectDO update = new ProjectDO();
+            update.setId(projectId);
+            update.setPreAuditStatus(AUDIT_STATUS_DRAFT);
+            update.setPreCurrentNode(null);
+            update.setPreCurrentApproverId(null);
+            projectMapper.updateById(update);
+        } else if (project.getFormalAuditStatus() != null && project.getFormalAuditStatus() == AUDIT_STATUS_PROCESSING) {
+            insertRecord(projectId, STAGE_FORMAL, project.getFormalCurrentNode(),
+                    getNodeName(project.getFormalCurrentNode()), RESULT_CANCEL, "撤回申请");
+            ProjectDO update = new ProjectDO();
+            update.setId(projectId);
+            update.setFormalAuditStatus(AUDIT_STATUS_DRAFT);
+            update.setFormalCurrentNode(null);
+            update.setFormalCurrentApproverId(null);
+            projectMapper.updateById(update);
+        } else {
+            throw exception(PROJECT_NOT_IN_APPROVAL);
+        }
+    }
 
     // ==================== 私有方法 ====================
 
-    /**
-     * 批量插入进度安排
-     */
-    private void insertScheduleList(List<ProjectSaveReqVO.ScheduleVO> list, Long projectId) {
-        if (list == null || list.isEmpty()) {
-            return;
+    private ProjectDO getAndValidate(Long id) {
+        ProjectDO project = projectMapper.selectById(id);
+        if (project == null) {
+            throw exception(PROJECT_NOT_EXISTS);
         }
+        return project;
+    }
+
+    private int getMaxSort() {
+        Integer max = projectMapper.selectMaxSort();
+        return max == null ? 0 : max;
+    }
+
+    private void insertScheduleList(List<ProjectSaveReqVO.ScheduleVO> list, Long projectId) {
+        if (list == null || list.isEmpty()) return;
         List<ProjectScheduleDO> scheduleList = BeanUtils.toBean(list, ProjectScheduleDO.class);
         scheduleList.forEach(item -> {
             item.setProjectId(projectId);
@@ -236,13 +360,8 @@ public class ResearchProjectServiceImpl implements ResearchProjectService {
         scheduleList.forEach(projectScheduleMapper::insert);
     }
 
-    /**
-     * 批量插入费用预算
-     */
     private void insertBudgetList(List<ProjectSaveReqVO.BudgetVO> list, Long projectId) {
-        if (list == null || list.isEmpty()) {
-            return;
-        }
+        if (list == null || list.isEmpty()) return;
         List<ProjectBudgetDO> budgetList = BeanUtils.toBean(list, ProjectBudgetDO.class);
         budgetList.forEach(item -> {
             item.setProjectId(projectId);
@@ -251,9 +370,106 @@ public class ResearchProjectServiceImpl implements ResearchProjectService {
         budgetList.forEach(projectBudgetMapper::insert);
     }
 
-    @Override
-    public List<ProjectDO> getProjectList(ProjectPageReqVO reqVO) {
-        return projectMapper.selectList(reqVO);
+    /** 写一条审批记录 */
+    private void insertRecord(Long projectId, Integer stage, Integer node, String nodeName,
+                              Integer result, String opinion) {
+        ProjectApprovalRecordDO record = new ProjectApprovalRecordDO();
+        record.setProjectId(projectId);
+        record.setStage(stage);
+        record.setNode(node);
+        record.setNodeName(nodeName);
+        record.setApproverId(SecurityFrameworkUtils.getLoginUserId());
+        record.setApproverName(SecurityFrameworkUtils.getLoginUserNickname());
+        record.setResult(result);
+        record.setOpinion(opinion);
+        record.setApproveTime(LocalDateTime.now());
+        approvalRecordMapper.insert(record);
+    }
+
+    /** 计算正式立项的下一节点(含"涉及预算"条件分支) */
+    private Integer getNextFormalNode(ProjectDO project) {
+        Integer currentNode = project.getFormalCurrentNode();
+        if (currentNode == null) return null;
+        switch (currentNode) {
+            case NODE_FORMAL_FIRST:
+                return NODE_FORMAL_SECOND;
+            case NODE_FORMAL_SECOND:
+                // ⭐ 条件分支:涉及预算 → 财务
+                if (project.getInvolveBudget() != null && project.getInvolveBudget() == 1) {
+                    return NODE_FORMAL_FINANCE;
+                }
+                return null;
+            case NODE_FORMAL_FINANCE:
+                return null;
+            default:
+                return null;
+        }
+    }
+
+    /**
+     * 按节点 + 部门查审批人
+     */
+    private Long findApprover(Integer node, Long deptId) {
+        if (node == null) {
+            return null;
+        }
+        String roleCode = getRoleCodeByNode(node);
+        if (StrUtil.isBlank(roleCode)) {
+            return null;
+        }
+
+        // ⭐ 二级审批是全局角色,不按部门过滤
+        if (node == NODE_PRE_SECOND || node == NODE_FORMAL_SECOND) {
+            return approverQueryMapper.selectUserIdByRoleCode(roleCode);
+        }
+
+        // ⭐ 一级、财务审批按部门过滤
+        if (deptId == null) {
+            return null;
+        }
+        return approverQueryMapper.selectUserIdByRoleCodeAndDept(roleCode, deptId);
+    }
+
+    private String getRoleCodeByNode(Integer node) {
+        if (node == null) return null;
+        switch (node) {
+            case NODE_PRE_SECOND:
+            case NODE_FORMAL_SECOND:
+                return "科技发展部负责人";
+            case NODE_FORMAL_FIRST:
+                return "技术副总";
+            case NODE_FORMAL_FINANCE:
+                return "财务负责人";
+            default:
+                return null;
+        }
+    }
+
+    private String getNodeName(Integer node) {
+        if (node == null) return "";
+        switch (node) {
+            case NODE_PRE_SECOND:    return "预审-科技发展部二级审批";
+            case NODE_FORMAL_FIRST:  return "正式-专业公司一级审批";
+            case NODE_FORMAL_SECOND: return "正式-科技发展部二级审批";
+            case NODE_FORMAL_FINANCE:return "正式-财务审批";
+            default: return "";
+        }
+    }
+
+    /** 提交预审前的必填校验 */
+    private void validateBeforeSubmitPre(ProjectDO project) {
+        // 例:附件必填
+        if (project.getApplicationFile() == null || project.getApplicationFile().isEmpty()) {
+            throw exception(PROJECT_APPLICATION_FILE_REQUIRED);
+        }
+        if (project.getOpeningReportFile() == null || project.getOpeningReportFile().isEmpty()) {
+            throw exception(PROJECT_OPENING_REPORT_REQUIRED);
+        }
+    }
+
+    private void validateBeforeSubmitFormal(ProjectDO project) {
+        // 例:正式立项前必须有预算
+        // 按需扩展
     }
 
-}
+}