ソースを参照

Merge branch 'expert_lib' of suiyingying/randd into master

yanghao 22 時間 前
親
コミット
fd724c157d
19 ファイル変更、1733 行追加、1 行削除
  1. 18 1
      yudao-module-rd/yudao-module-rd-api/src/main/java/cn/iocoder/yudao/module/rd/enums/ErrorCodeConstant.java
  2. 53 0
      yudao-module-rd/yudao-module-rd-api/src/main/java/cn/iocoder/yudao/module/rd/enums/ProjectApprovalConstants.java
  3. 146 0
      yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/controller/admin/researchProject/ResearchProjectController.java
  4. 23 0
      yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/controller/admin/researchProject/vo/ProjectApproveReqVO.java
  5. 23 0
      yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/controller/admin/researchProject/vo/ProjectAttachmentVO.java
  6. 60 0
      yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/controller/admin/researchProject/vo/ProjectPageReqVO.java
  7. 169 0
      yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/controller/admin/researchProject/vo/ProjectRespVO.java
  8. 118 0
      yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/controller/admin/researchProject/vo/ProjectSaveReqVO.java
  9. 40 0
      yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/dal/dataobject/researchProject/ProjectApprovalRecordDO.java
  10. 52 0
      yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/dal/dataobject/researchProject/ProjectBudgetDO.java
  11. 119 0
      yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/dal/dataobject/researchProject/ProjectDO.java
  12. 48 0
      yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/dal/dataobject/researchProject/ProjectScheduleDO.java
  13. 29 0
      yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/dal/mysql/researchProject/ApproverQueryMapper.java
  14. 45 0
      yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/dal/mysql/researchProject/ProjectApprovalRecordMapper.java
  15. 35 0
      yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/dal/mysql/researchProject/ProjectBudgetMapper.java
  16. 69 0
      yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/dal/mysql/researchProject/ProjectMapper.java
  17. 35 0
      yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/dal/mysql/researchProject/ProjectScheduleMapper.java
  18. 90 0
      yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/service/researchProject/ResearchProjectService.java
  19. 561 0
      yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/service/researchProject/ResearchProjectServiceImpl.java

+ 18 - 1
yudao-module-rd/yudao-module-rd-api/src/main/java/cn/iocoder/yudao/module/rd/enums/ErrorCodeConstant.java

@@ -3,7 +3,6 @@ package cn.iocoder.yudao.module.rd.enums;
 import cn.iocoder.yudao.framework.common.exception.ErrorCode;
 
 public interface ErrorCodeConstant {
-
     ErrorCode TEAM_NOT_EXISTS = new ErrorCode(100, "团队不存在");
     ErrorCode EXPERT_POOL_NOT_EXISTS = new ErrorCode(200, "专家库不存在");
     ErrorCode EXPERT_KEYWORD_NOT_EXISTS = new ErrorCode(201, "专家热词字典不存在");
@@ -23,4 +22,22 @@ public interface ErrorCodeConstant {
      */
     ErrorCode LAS_WELL_NOT_EXISTS = new ErrorCode(1000, "数字油藏LAS数据文件-井信息不存在");
     ErrorCode LAS_WELL_DATA_NOT_EXISTS = new ErrorCode(1001, "数字油藏LAS数据文件-测井数据不存在");
+
+
+    /***
+     * 科研项目
+     */
+
+    ErrorCode PROJECT_NOT_EXISTS = new ErrorCode(1002, "项目立项不存在");
+    ErrorCode PROJECT_ALREADY_SUBMITTED = new ErrorCode(1003, "项目已提交审批,不能重复提交");
+    ErrorCode PROJECT_NOT_IN_APPROVAL = new ErrorCode(1004, "项目不在审批中");
+    ErrorCode PROJECT_NOT_YOUR_TURN = new ErrorCode(1005, "当前不是您的审批节点");
+    ErrorCode PROJECT_PRE_STATUS_ILLEGAL = new ErrorCode(1006, "预审状态不允许此操作");
+    ErrorCode PROJECT_FORMAL_STATUS_ILLEGAL = new ErrorCode(1007, "正式立项状态不允许此操作");
+    ErrorCode PROJECT_PRE_NOT_PASSED = new ErrorCode(1008, "预审未通过,不能提交正式立项");
+    ErrorCode PROJECT_IN_APPROVAL_CANNOT_UPDATE = new ErrorCode(1009, "项目审批中,不能修改");
+    ErrorCode PROJECT_IN_APPROVAL_CANNOT_DELETE = new ErrorCode(1010, "项目审批中,不能删除");
+    ErrorCode PROJECT_APPROVER_NOT_FOUND = new ErrorCode(1011, "未找到审批人,请联系管理员");
+    ErrorCode PROJECT_APPLICATION_FILE_REQUIRED = new ErrorCode(1012, "请上传立项申请表");
+    ErrorCode PROJECT_OPENING_REPORT_REQUIRED = new ErrorCode(1013, "请上传开题报告");
 }

+ 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;
+
+}

+ 146 - 0
yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/controller/admin/researchProject/ResearchProjectController.java

@@ -0,0 +1,146 @@
+package cn.iocoder.yudao.module.rd.controller.admin.researchProject;
+
+import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog;
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.common.pojo.PageParam;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
+import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
+import cn.iocoder.yudao.module.rd.controller.admin.researchProject.vo.ProjectApproveReqVO;
+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.ProjectDO;
+import cn.iocoder.yudao.module.rd.service.researchProject.ResearchProjectService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletResponse;
+import javax.validation.Valid;
+import java.io.IOException;
+import java.util.List;
+
+import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+
+@Tag(name = "管理后台 - 项目立项")
+@RestController
+@RequestMapping("/rd/research-project")
+@Validated
+public class ResearchProjectController {
+
+    @Resource
+    private ResearchProjectService projectService;
+
+    @PostMapping("/create")
+    @Operation(summary = "创建项目立项")
+    @PreAuthorize("@ss.hasPermission('rd:project:create')")
+    public CommonResult<Long> createProject(@Valid @RequestBody ProjectSaveReqVO createReqVO) {
+        return success(projectService.createProject(createReqVO));
+    }
+
+    @PutMapping("/update")
+    @Operation(summary = "更新项目立项")
+    @PreAuthorize("@ss.hasPermission('rd:project:update')")
+    public CommonResult<Boolean> updateProject(@Valid @RequestBody ProjectSaveReqVO updateReqVO) {
+        projectService.updateProject(updateReqVO);
+        return success(true);
+    }
+
+    @DeleteMapping("/delete")
+    @Operation(summary = "删除项目立项")
+    @Parameter(name = "id", description = "编号", required = true)
+    @PreAuthorize("@ss.hasPermission('rd:project:delete')")
+    public CommonResult<Boolean> deleteProject(@RequestParam("id") Long id) {
+        projectService.deleteProject(id);
+        return success(true);
+    }
+
+    @GetMapping("/get")
+    @Operation(summary = "获得项目立项")
+    @Parameter(name = "id", description = "编号", required = true, example = "1024")
+    @PreAuthorize("@ss.hasPermission('rd:project:query')")
+    public CommonResult<ProjectRespVO> getProject(@RequestParam("id") Long id) {
+        return success(projectService.getProject(id));
+    }
+
+    @GetMapping("/page")
+    @Operation(summary = "获得项目立项分页")
+    @PreAuthorize("@ss.hasPermission('rd:project:query')")
+    public CommonResult<PageResult<ProjectRespVO>> getProjectPage(@Valid ProjectPageReqVO pageReqVO) {
+        return success(projectService.getProjectPage(pageReqVO));
+    }
+
+    @GetMapping("/export-excel")
+    @Operation(summary = "导出项目立项 Excel")
+    @PreAuthorize("@ss.hasPermission('rd:project:export')")
+    @ApiAccessLog(operateType = EXPORT)
+    public void exportProjectExcel(@Valid ProjectPageReqVO pageReqVO,
+                                   HttpServletResponse response) throws IOException {
+        pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE);
+        List<ProjectDO> list = projectService.getProjectList(pageReqVO);
+        // 导出 Excel
+        ExcelUtils.write(response, "项目立项.xls", "数据", ProjectRespVO.class,
+                BeanUtils.toBean(list, ProjectRespVO.class));
+    }
+
+
+    // ==================== 审批流 ====================
+
+    @GetMapping("/todo-page")
+    @Operation(summary = "待我审批的项目分页")
+    @PreAuthorize("@ss.hasPermission('rd:project:query')")
+    public CommonResult<PageResult<ProjectRespVO>> getTodoPage(@Valid ProjectPageReqVO pageReqVO) {
+        return success(projectService.getTodoPage(pageReqVO));
+    }
+
+    @PostMapping("/submit-pre")
+    @Operation(summary = "提交预审")
+    @Parameter(name = "id", description = "项目ID", required = true)
+    @PreAuthorize("@ss.hasPermission('rd:project:submit')")
+    public CommonResult<Boolean> submitPre(@RequestParam("id") Long id) {
+        projectService.submitPreApproval(id);
+        return success(true);
+    }
+
+    @PostMapping("/approve-pre")
+    @Operation(summary = "预审审批")
+    @PreAuthorize("@ss.hasPermission('rd:project:approve')")
+    public CommonResult<Boolean> approvePre(@Valid @RequestBody ProjectApproveReqVO reqVO) {
+        projectService.approvePre(reqVO);
+        return success(true);
+    }
+
+    @PostMapping("/submit-formal")
+    @Operation(summary = "提交正式立项")
+    @Parameter(name = "id", description = "项目ID", required = true)
+    @PreAuthorize("@ss.hasPermission('rd:project:submit')")
+    public CommonResult<Boolean> submitFormal(@RequestParam("id") Long id) {
+        projectService.submitFormalApproval(id);
+        return success(true);
+    }
+
+    @PostMapping("/approve-formal")
+    @Operation(summary = "正式立项审批")
+    @PreAuthorize("@ss.hasPermission('rd:project:approve')")
+    public CommonResult<Boolean> approveFormal(@Valid @RequestBody ProjectApproveReqVO reqVO) {
+        projectService.approveFormal(reqVO);
+        return success(true);
+    }
+
+    @PostMapping("/cancel")
+    @Operation(summary = "撤回审批")
+    @Parameter(name = "id", description = "项目ID", required = true)
+    @PreAuthorize("@ss.hasPermission('rd:project:submit')")
+    public CommonResult<Boolean> cancel(@RequestParam("id") Long id) {
+        projectService.cancelApproval(id);
+        return success(true);
+    }
+
+
+}

+ 23 - 0
yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/controller/admin/researchProject/vo/ProjectApproveReqVO.java

@@ -0,0 +1,23 @@
+package cn.iocoder.yudao.module.rd.controller.admin.researchProject.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+
+@Schema(description = "管理后台 - 项目审批 Request VO")
+@Data
+public class ProjectApproveReqVO {
+
+    @Schema(description = "项目ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+    @NotNull(message = "项目ID不能为空")
+    private Long projectId;
+
+    @Schema(description = "审批结果:1通过 2驳回", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+    @NotNull(message = "审批结果不能为空")
+    private Integer result;
+
+    @Schema(description = "审批意见", example = "同意立项")
+    private String opinion;
+
+}

+ 23 - 0
yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/controller/admin/researchProject/vo/ProjectAttachmentVO.java

@@ -0,0 +1,23 @@
+package cn.iocoder.yudao.module.rd.controller.admin.researchProject.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+public class ProjectAttachmentVO {
+
+    @Schema(description = "文件名", example = "立项申请表.pdf")
+    private String fileName;
+
+    @Schema(description = "文件MIME类型", example = "application/pdf")
+    private String fileType;
+
+    @Schema(description = "文件大小(字节)", example = "3088617")
+    private Long fileSize;
+
+    @Schema(description = "文件访问路径", example = "https://xxx.com/file/xxx.pdf")
+    private String filePath;
+
+    @Schema(description = "附件类型:APPLICATION-立项申请表 OPENING_REPORT-开题报告 OTHER-其他", example = "APPLICATION")
+    private String type;
+}

+ 60 - 0
yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/controller/admin/researchProject/vo/ProjectPageReqVO.java

@@ -0,0 +1,60 @@
+package cn.iocoder.yudao.module.rd.controller.admin.researchProject.vo;
+
+import cn.iocoder.yudao.framework.common.pojo.PageParam;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.time.LocalDateTime;
+
+import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
+
+@Schema(description = "管理后台 - 项目立项分页 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class ProjectPageReqVO extends PageParam {
+
+    @Schema(description = "项目级别", example = "国家级")
+    private String projectLevel;
+
+    @Schema(description = "执行单位", example = "某某研究院")
+    private String executingUnit;
+
+    @Schema(description = "项目名称", example = "智能仓储系统")
+    private String projectName;
+
+    @Schema(description = "项目负责人", example = "张三")
+    private String projectLeader;
+
+    @Schema(description = "项目组成员(多个逗号分隔)")
+    private String teamMembers;
+
+    @Schema(description = "项目类型", example = "研发类")
+    private String projectType;
+
+    @Schema(description = "目标和意义")
+    private String goalAndSignificance;
+
+    @Schema(description = "主要研究内容")
+    private String mainResearchContent;
+
+    @Schema(description = "创新点")
+    private String innovationPoint;
+
+    @Schema(description = "排序值")
+    private Integer sort;
+
+    @Schema(description = "状态(0启用 1禁用)", example = "0")
+    private Integer status;
+
+    @Schema(description = "备注", example = "你说的对")
+    private String remark;
+
+    @Schema(description = "创建时间")
+    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+    private LocalDateTime[] createTime;
+
+}

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

@@ -0,0 +1,169 @@
+package cn.iocoder.yudao.module.rd.controller.admin.researchProject.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Schema(description = "管理后台 - 项目立项 Response VO")
+@Data
+public class ProjectRespVO {
+
+    @Schema(description = "主键ID", example = "1024")
+    private Long id;
+
+    @Schema(description = "所属部门(专业公司)ID", example = "100")
+    private Long deptId;
+
+    @Schema(description = "所属部门名称", example = "XX石油技术公司")
+    private String deptName;
+
+    @Schema(description = "项目级别", example = "国家级")
+    private String projectLevel;
+
+    @Schema(description = "执行单位(部门ID)", example = "100")
+    private Long executingUnit;
+
+    @Schema(description = "执行单位名称", example = "XX石油技术公司")
+    private String executingUnitName;
+
+    @Schema(description = "项目名称", example = "智能仓储系统")
+    private String projectName;
+
+    @Schema(description = "项目负责人", example = "张三")
+    private String projectLeader;
+
+    @Schema(description = "项目组成员(多个逗号分隔)", example = "张三,李四,王五")
+    private String teamMembers;
+
+    @Schema(description = "项目类型", example = "研发类")
+    private String projectType;
+
+    @Schema(description = "目标和意义")
+    private String goalAndSignificance;
+
+    @Schema(description = "主要研究内容")
+    private String mainResearchContent;
+
+    @Schema(description = "创新点")
+    private String innovationPoint;
+
+    @Schema(description = "附件列表")
+    private List<ProjectAttachmentVO> attachments;
+
+    @Schema(description = "排序值", example = "1")
+    private Integer sort;
+
+    @Schema(description = "状态(0启用 1禁用)", example = "0")
+    private Integer status;
+
+    @Schema(description = "备注", example = "你说的对")
+    private String remark;
+
+    @Schema(description = "创建时间")
+    private LocalDateTime createTime;
+
+    // ==================== 预审阶段 ====================
+    @Schema(description = "预审状态:0未提交 1审批中 2通过 3驳回", example = "0")
+    private Integer preAuditStatus;
+
+    @Schema(description = "预审当前节点", example = "11")
+    private Integer preCurrentNode;
+
+    @Schema(description = "预审当前审批人ID", example = "1001")
+    private Long preCurrentApproverId;
+
+    @Schema(description = "预审当前节点角色code", example = "科技发展部负责人")
+    private String preCurrentRoleCode;
+
+    @Schema(description = "预审提交时间")
+    private LocalDateTime preSubmitTime;
+
+    // ==================== 正式立项阶段 ====================
+    @Schema(description = "正式审批状态:0未提交 1审批中 2通过 3驳回", example = "0")
+    private Integer formalAuditStatus;
+
+    @Schema(description = "正式当前节点", example = "21")
+    private Integer formalCurrentNode;
+
+    @Schema(description = "正式当前审批人ID", example = "1002")
+    private Long formalCurrentApproverId;
+
+    @Schema(description = "正式当前节点角色code", example = "技术副总")
+    private String formalCurrentRoleCode;
+
+    @Schema(description = "正式立项提交时间")
+    private LocalDateTime formalSubmitTime;
+
+    @Schema(description = "立项时间(正式立项通过时间)")
+    private LocalDateTime projectDate;
+
+    // ==================== 预算相关 ====================
+    @Schema(description = "是否涉及预算:0否 1是", example = "0")
+    private Integer involveBudget;
+
+    // ==================== 子表数据 ====================
+
+    @Schema(description = "进度安排列表")
+    private List<ScheduleVO> scheduleList;
+
+    @Schema(description = "费用预算明细列表")
+    private List<BudgetVO> budgetList;
+
+    @Schema(description = "审批记录列表")
+    private List<ApprovalRecordVO> approvalRecords;
+
+    // ==================== 子表 VO ====================
+
+    @Schema(description = "管理后台 - 项目进度安排 Response VO")
+    @Data
+    public static class ScheduleVO {
+        @Schema(description = "主键ID", example = "1")
+        private Long id;
+
+        @Schema(description = "工作计划", example = "完成需求调研")
+        private String workPlan;
+
+        @Schema(description = "截止时间", example = "2026-10-01")
+        private LocalDate deadline;
+
+        @Schema(description = "排序值", example = "0")
+        private Integer sort;
+    }
+
+    @Schema(description = "管理后台 - 项目费用预算 Response VO")
+    @Data
+    public static class BudgetVO {
+        @Schema(description = "主键ID", example = "1")
+        private Long id;
+
+        @Schema(description = "财务科目", example = "材料费")
+        private String financialSubject;
+
+        @Schema(description = "预算金额(元)", example = "10000.00")
+        private BigDecimal budgetAmount;
+
+        @Schema(description = "预算说明", example = "用于采购开发板")
+        private String budgetDescription;
+
+        @Schema(description = "排序值", example = "0")
+        private Integer sort;
+    }
+
+    @Schema(description = "项目审批记录 VO")
+    @Data
+    public static class ApprovalRecordVO {
+        private Long id;
+        private Integer stage;
+        private Integer node;
+        private String nodeName;
+        private Long approverId;
+        private String approverName;
+        private Integer result;
+        private String opinion;
+        private LocalDateTime approveTime;
+    }
+}

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

@@ -0,0 +1,118 @@
+package cn.iocoder.yudao.module.rd.controller.admin.researchProject.vo;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import javax.validation.Valid;
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Size;
+import java.util.List;
+
+@Schema(description = "管理后台 - 项目立项新增/修改 Request VO")
+@Data
+public class ProjectSaveReqVO {
+
+    @Schema(description = "主键ID", example = "1024")
+    private Long id;
+
+    @Schema(description = "创建部门(专业公司)ID")
+    private Long deptId;
+
+    @Schema(description = "项目级别", requiredMode = Schema.RequiredMode.REQUIRED, example = "国家级")
+    @NotEmpty(message = "项目级别不能为空")
+    private String projectLevel;
+
+    @Schema(description = "执行单位(部门ID)", requiredMode = Schema.RequiredMode.REQUIRED, example = "100")
+    @NotNull(message = "执行单位不能为空")
+    private Long executingUnit;
+
+    @Schema(description = "项目名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "智能仓储系统")
+    @NotEmpty(message = "项目名称不能为空")
+    @Size(max = 200, message = "项目名称长度不能超过 200 个字符")
+    private String projectName;
+
+    @Schema(description = "项目负责人", requiredMode = Schema.RequiredMode.REQUIRED, example = "张三")
+    @NotEmpty(message = "项目负责人不能为空")
+    private String projectLeader;
+
+    @Schema(description = "项目组成员(多个逗号分隔)", example = "张三,李四,王五")
+    private String teamMembers;
+
+    @Schema(description = "项目类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "研发类")
+    @NotEmpty(message = "项目类型不能为空")
+    private String projectType;
+
+    @Schema(description = "目标和意义")
+    @Size(max = 500, message = "目标和意义长度不能超过 500 个字符")
+    private String goalAndSignificance;
+
+    @Schema(description = "主要研究内容")
+    @Size(max = 2000, message = "主要研究内容长度不能超过 2000 个字符")
+    private String mainResearchContent;
+
+    @Schema(description = "创新点")
+    @Size(max = 500, message = "创新点长度不能超过 500 个字符")
+    private String innovationPoint;
+
+    @Schema(description = "排序值", example = "1")
+    private Integer sort;
+
+    @Schema(description = "附件列表")
+    private List<ProjectAttachmentVO> attachments;
+
+    @Schema(description = "状态(0启用 1禁用)", example = "0")
+    private Integer status;
+
+    @Schema(description = "备注", example = "你说的对")
+    private String remark;
+
+    // ==================== 子表数据 ====================
+
+    @Schema(description = "进度安排列表")
+    @Valid
+    private List<ScheduleVO> scheduleList;
+
+    @Schema(description = "费用预算明细列表")
+    @Valid
+    private List<BudgetVO> budgetList;
+
+    // ==================== 子表 VO ====================
+
+    @Schema(description = "管理后台 - 项目进度安排 VO")
+    @Data
+    public static class ScheduleVO {
+
+        @Schema(description = "工作计划", requiredMode = Schema.RequiredMode.REQUIRED, example = "完成需求调研")
+        @NotEmpty(message = "工作计划不能为空")
+        private String workPlan;
+
+        @Schema(description = "截止时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-10-01")
+        @NotNull(message = "截止时间不能为空")
+        private java.time.LocalDate deadline;
+
+        @Schema(description = "排序值", example = "0")
+        private Integer sort;
+
+    }
+
+    @Schema(description = "管理后台 - 项目费用预算 VO")
+    @Data
+    public static class BudgetVO {
+
+        @Schema(description = "财务科目", requiredMode = Schema.RequiredMode.REQUIRED, example = "材料费")
+        @NotEmpty(message = "财务科目不能为空")
+        private String financialSubject;
+
+        @Schema(description = "预算金额(元)", requiredMode = Schema.RequiredMode.REQUIRED, example = "10000.00")
+        @NotNull(message = "预算金额不能为空")
+        private java.math.BigDecimal budgetAmount;
+
+        @Schema(description = "预算说明", example = "用于采购开发板")
+        private String budgetDescription;
+
+        @Schema(description = "排序值", example = "0")
+        private Integer sort;
+
+    }
+
+}

+ 40 - 0
yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/dal/dataobject/researchProject/ProjectApprovalRecordDO.java

@@ -0,0 +1,40 @@
+package cn.iocoder.yudao.module.rd.dal.dataobject.researchProject;
+import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
+import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.*;
+import java.time.LocalDateTime;
+
+@TableName(value = "rd_project_approval_record", autoResultMap = true)
+@KeySequence("rd_project_approval_record_seq")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ProjectApprovalRecordDO extends BaseDO {
+
+    @TableId
+    private Long id;
+    /** 项目ID */
+    private Long projectId;
+    /** 审批阶段:1预审 2正式立项 */
+    private Integer stage;
+    /** 审批节点:11预审二级 21一级 22二级 23财务 */
+    private Integer node;
+    /** 节点名称 */
+    private String nodeName;
+    /** 审批人ID */
+    private Long approverId;
+    /** 审批人姓名 */
+    private String approverName;
+    /** 审批结果:1通过 2驳回 3撤回 4提交 */
+    private Integer result;
+    /** 审批意见 */
+    private String opinion;
+    /** 审批时间 */
+    private LocalDateTime approveTime;
+
+}

+ 52 - 0
yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/dal/dataobject/researchProject/ProjectBudgetDO.java

@@ -0,0 +1,52 @@
+package cn.iocoder.yudao.module.rd.dal.dataobject.researchProject;
+
+import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
+import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.*;
+
+import java.math.BigDecimal;
+
+/**
+ * 项目费用预算明细 DO
+ *
+ * @author ruiqi
+ */
+@TableName(value = "rd_project_budget", autoResultMap = true)
+@KeySequence("rd_project_budget_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ProjectBudgetDO extends BaseDO {
+
+    /**
+     * 主键id
+     */
+    @TableId
+    private Long id;
+    /**
+     * 关联项目主表id
+     */
+    private Long projectId;
+    /**
+     * 财务科目
+     */
+    private String financialSubject;
+    /**
+     * 预算金额(元)
+     */
+    private BigDecimal budgetAmount;
+    /**
+     * 预算说明
+     */
+    private String budgetDescription;
+    /**
+     * 排序值(前端表格行号)
+     */
+    private Integer sort;
+
+}

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

@@ -0,0 +1,119 @@
+package cn.iocoder.yudao.module.rd.dal.dataobject.researchProject;
+
+import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
+import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.*;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
+import cn.iocoder.yudao.module.rd.controller.admin.researchProject.vo.ProjectAttachmentVO;
+
+/**
+ * 项目立项 DO
+ *
+ * @author ruiqi
+ */
+@TableName(value = "rd_project", autoResultMap = true)
+@KeySequence("rd_project_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ProjectDO extends BaseDO {
+
+    /**
+     * 主键id
+     */
+    @TableId
+    private Long id;
+
+    private Long deptId;
+    /**
+     * 项目级别
+     */
+    private String projectLevel;
+    /**
+     * 执行单位
+     */
+    private Long executingUnit;
+    /**
+     * 项目名称
+     */
+    private String projectName;
+    /**
+     * 项目负责人
+     */
+    private String projectLeader;
+    /**
+     * 项目组成员(多个逗号分隔)
+     */
+    private String teamMembers;
+    /**
+     * 项目类型
+     */
+    private String projectType;
+    /**
+     * 目标和意义
+     */
+    private String goalAndSignificance;
+    /**
+     * 主要研究内容
+     */
+    private String mainResearchContent;
+    /**
+     * 创新点
+     */
+    private String innovationPoint;
+
+    /** 附件列表(JSON) */
+    @TableField(typeHandler = JacksonTypeHandler.class)
+    private List<ProjectAttachmentVO> attachments;
+    /**
+     * 排序值
+     */
+    private Integer sort;
+    /**
+     * 状态(0启用 1禁用)
+     */
+    private Integer status;
+    /**
+     * 备注
+     */
+    private String remark;
+
+    // ==================== 预审阶段 ====================
+    /** 预审状态:0未提交 1审批中 2通过 3驳回 */
+    private Integer preAuditStatus;
+    /** 预审当前节点:11预审二级 */
+    private Integer preCurrentNode;
+    /** 预审当前审批人ID */
+    private Long preCurrentApproverId;
+    /** 预审当前节点角色 code */
+    private String preCurrentRoleCode;
+    /** 预审提交时间 */
+    private LocalDateTime preSubmitTime;
+
+// ==================== 正式立项阶段 ====================
+    /** 正式审批状态:0未提交 1审批中 2通过 3驳回 */
+    private Integer formalAuditStatus;
+    /** 正式当前节点:21一级 22二级 23财务 */
+    private Integer formalCurrentNode;
+    /** 正式当前审批人ID */
+    private Long formalCurrentApproverId;
+    /** 正式当前节点角色 code */
+    private String formalCurrentRoleCode;
+    /** 正式立项提交时间 */
+    private LocalDateTime formalSubmitTime;
+
+
+// ==================== 预算相关 ====================
+    /** 是否涉及预算:0否 1是 */
+    private Integer involveBudget;
+
+}

+ 48 - 0
yudao-module-rd/yudao-module-rd-biz/src/main/java/cn/iocoder/yudao/module/rd/dal/dataobject/researchProject/ProjectScheduleDO.java

@@ -0,0 +1,48 @@
+package cn.iocoder.yudao.module.rd.dal.dataobject.researchProject;
+
+import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
+import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.*;
+
+import java.time.LocalDate;
+
+/**
+ * 项目进度安排 DO
+ *
+ * @author ruiqi
+ */
+@TableName(value = "rd_project_schedule", autoResultMap = true)
+@KeySequence("rd_project_schedule_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ProjectScheduleDO extends BaseDO {
+
+    /**
+     * 主键id
+     */
+    @TableId
+    private Long id;
+    /**
+     * 关联项目主表id
+     */
+    private Long projectId;
+    /**
+     * 工作计划
+     */
+    private String workPlan;
+    /**
+     * 截止时间
+     */
+    private LocalDate deadline;
+    /**
+     * 排序值(前端表格行号)
+     */
+    private Integer sort;
+
+}

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

@@ -0,0 +1,29 @@
+package cn.iocoder.yudao.module.rd.dal.mysql.researchProject;
+
+import cn.iocoder.yudao.framework.datapermission.core.annotation.DataPermission;
+import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+@Mapper
+@DataPermission(enable = false)   // ⭐ 跳过数据权限
+                   // ⭐ 跳过多租户(如果类上能加的话)
+public interface ApproverQueryMapper {
+
+    @TenantIgnore
+    @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);
+
+    @TenantIgnore
+    @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);
+
+}

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

@@ -0,0 +1,45 @@
+package cn.iocoder.yudao.module.rd.dal.mysql.researchProject;
+
+import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
+import cn.iocoder.yudao.module.rd.dal.dataobject.researchProject.ProjectApprovalRecordDO;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.List;
+
+/**
+ * 项目审批记录 Mapper
+ *
+ * @author ruiqi
+ */
+@Mapper
+public interface ProjectApprovalRecordMapper extends BaseMapperX<ProjectApprovalRecordDO> {
+
+    /**
+     * 根据项目 ID 查询审批记录列表(按审批时间升序)
+     */
+    default List<ProjectApprovalRecordDO> selectListByProjectId(Long projectId) {
+        return selectList(new LambdaQueryWrapperX<ProjectApprovalRecordDO>()
+                .eq(ProjectApprovalRecordDO::getProjectId, projectId)
+                .orderByAsc(ProjectApprovalRecordDO::getApproveTime));
+    }
+
+    /**
+     * 根据项目 ID 和阶段查询审批记录
+     */
+    default List<ProjectApprovalRecordDO> selectListByProjectIdAndStage(Long projectId, Integer stage) {
+        return selectList(new LambdaQueryWrapperX<ProjectApprovalRecordDO>()
+                .eq(ProjectApprovalRecordDO::getProjectId, projectId)
+                .eq(ProjectApprovalRecordDO::getStage, stage)
+                .orderByAsc(ProjectApprovalRecordDO::getApproveTime));
+    }
+
+    /**
+     * 根据项目 ID 删除审批记录
+     */
+    default void deleteByProjectId(Long projectId) {
+        delete(new LambdaQueryWrapperX<ProjectApprovalRecordDO>()
+                .eq(ProjectApprovalRecordDO::getProjectId, projectId));
+    }
+
+}

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

@@ -0,0 +1,35 @@
+package cn.iocoder.yudao.module.rd.dal.mysql.researchProject;
+
+import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
+import cn.iocoder.yudao.module.rd.dal.dataobject.researchProject.ProjectBudgetDO;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.List;
+
+/**
+ * 项目费用预算明细 Mapper
+ *
+ * @author ruiqi
+ */
+@Mapper
+public interface ProjectBudgetMapper extends BaseMapperX<ProjectBudgetDO> {
+
+    /**
+     * 根据项目 ID 查询预算明细列表
+     */
+    default List<ProjectBudgetDO> selectListByProjectId(Long projectId) {
+        return selectList(new LambdaQueryWrapperX<ProjectBudgetDO>()
+                .eq(ProjectBudgetDO::getProjectId, projectId)
+                .orderByAsc(ProjectBudgetDO::getSort));
+    }
+
+    /**
+     * 根据项目 ID 删除预算明细
+     */
+    default void deleteByProjectId(Long projectId) {
+        delete(new LambdaQueryWrapperX<ProjectBudgetDO>()
+                .eq(ProjectBudgetDO::getProjectId, projectId));
+    }
+
+}

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

@@ -0,0 +1,69 @@
+package cn.iocoder.yudao.module.rd.dal.mysql.researchProject;
+
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
+import cn.iocoder.yudao.module.rd.controller.admin.researchProject.vo.ProjectPageReqVO;
+import cn.iocoder.yudao.module.rd.dal.dataobject.researchProject.ProjectDO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Select;
+
+
+
+import java.util.List;
+
+/**
+ * 项目立项 Mapper
+ *
+ * @author ruiqi
+ */
+@Mapper
+public interface ProjectMapper extends BaseMapperX<ProjectDO> {
+
+    default PageResult<ProjectDO> selectPage(ProjectPageReqVO reqVO) {
+        return selectPage(reqVO, buildCommonQuery(reqVO)
+                .orderByDesc(ProjectDO::getId));
+    }
+
+    default List<ProjectDO> selectList(ProjectPageReqVO reqVO) {
+        return selectList(buildCommonQuery(reqVO)
+                .orderByDesc(ProjectDO::getId));
+    }
+
+    /**
+     * 构建公共查询条件
+     *
+     * @param reqVO 查询条件
+     */
+    default LambdaQueryWrapperX<ProjectDO> buildCommonQuery(ProjectPageReqVO reqVO) {
+        return new LambdaQueryWrapperX<ProjectDO>()
+                .likeIfPresent(ProjectDO::getProjectName, reqVO.getProjectName())
+                .eqIfPresent(ProjectDO::getProjectLevel, reqVO.getProjectLevel())
+                .eqIfPresent(ProjectDO::getExecutingUnit, reqVO.getExecutingUnit())   // ⭐ 改成 eqIfPresent
+                .eqIfPresent(ProjectDO::getProjectLeader, reqVO.getProjectLeader())
+                .eqIfPresent(ProjectDO::getProjectType, reqVO.getProjectType())
+                .eqIfPresent(ProjectDO::getStatus, reqVO.getStatus())
+                .betweenIfPresent(ProjectDO::getCreateTime, reqVO.getCreateTime());
+    }
+
+    /**
+     * 查询当前最大的 sort 值(用于新增时 sort + 1)
+     */
+    @Select("SELECT IFNULL(MAX(sort), 0) FROM rd_project WHERE deleted = 0")
+    Integer selectMaxSort();
+
+
+    default PageResult<ProjectDO> selectTodoPage(ProjectPageReqVO reqVO, Long userId) {
+        return selectPage(reqVO, new LambdaQueryWrapperX<ProjectDO>()
+                .likeIfPresent(ProjectDO::getProjectName, reqVO.getProjectName())   // ⭐ 放到前面
+                .and(w -> w
+                        .and(x -> x
+                                .eq(ProjectDO::getPreAuditStatus, 1)
+                                .eq(ProjectDO::getPreCurrentApproverId, userId))
+                        .or(x -> x
+                                .eq(ProjectDO::getFormalAuditStatus, 1)
+                                .eq(ProjectDO::getFormalCurrentApproverId, userId)))
+                .orderByDesc(ProjectDO::getId));
+    }
+
+}

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

@@ -0,0 +1,35 @@
+package cn.iocoder.yudao.module.rd.dal.mysql.researchProject;
+
+import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
+import cn.iocoder.yudao.module.rd.dal.dataobject.researchProject.ProjectScheduleDO;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.List;
+
+/**
+ * 项目进度安排 Mapper
+ *
+ * @author ruiqi
+ */
+@Mapper
+public interface ProjectScheduleMapper extends BaseMapperX<ProjectScheduleDO> {
+
+    /**
+     * 根据项目 ID 查询进度安排列表
+     */
+    default List<ProjectScheduleDO> selectListByProjectId(Long projectId) {
+        return selectList(new LambdaQueryWrapperX<ProjectScheduleDO>()
+                .eq(ProjectScheduleDO::getProjectId, projectId)
+                .orderByAsc(ProjectScheduleDO::getSort));
+    }
+
+    /**
+     * 根据项目 ID 删除进度安排
+     */
+    default void deleteByProjectId(Long projectId) {
+        delete(new LambdaQueryWrapperX<ProjectScheduleDO>()
+                .eq(ProjectScheduleDO::getProjectId, projectId));
+    }
+
+}

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

@@ -0,0 +1,90 @@
+package cn.iocoder.yudao.module.rd.service.researchProject;
+
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+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.ProjectDO;
+import cn.iocoder.yudao.module.rd.controller.admin.researchProject.vo.ProjectApproveReqVO;
+
+import javax.validation.Valid;
+import java.util.List;
+
+
+
+/**
+ * 项目立项 Service 接口
+ *
+ * @author ruiqi
+ */
+public interface ResearchProjectService {
+
+    /**
+     * 创建项目立项
+     *
+     * @param createReqVO 创建信息
+     * @return 编号
+     */
+    Long createProject(@Valid ProjectSaveReqVO createReqVO);
+
+    /**
+     * 更新项目立项
+     *
+     * @param updateReqVO 更新信息
+     */
+    void updateProject(@Valid ProjectSaveReqVO updateReqVO);
+
+    /**
+     * 删除项目立项
+     *
+     * @param id 编号
+     */
+    void deleteProject(Long id);
+
+    /**
+     * 获得项目立项详情(含进度安排、费用预算子表)
+     *
+     * @param id 编号
+     * @return 项目详情
+     */
+    ProjectRespVO getProject(Long id);
+
+    /**
+     * 获得项目立项分页
+     *
+     * @param pageReqVO 分页查询
+     * @return 项目分页
+     */
+    PageResult<ProjectRespVO> getProjectPage(ProjectPageReqVO pageReqVO);
+
+    /**
+     * 获得项目列表(用于导出 Excel)
+     *
+     * @param pageReqVO 列表查询
+     * @return 项目列表
+     */
+
+    List<ProjectDO> getProjectList(@Valid ProjectPageReqVO pageReqVO);
+
+    // ==================== 审批流 ====================
+    /** 待我审批 */
+    PageResult<ProjectRespVO> getTodoPage(ProjectPageReqVO pageReqVO);
+
+    /** 提交预审 */
+    void submitPreApproval(Long projectId);
+
+    /** 预审审批(通过/驳回) */
+    void approvePre(@Valid ProjectApproveReqVO reqVO);
+
+    /** 提交正式立项 */
+    void submitFormalApproval(Long projectId);
+
+    /** 正式立项审批(通过/驳回) */
+    void approveFormal(@Valid ProjectApproveReqVO reqVO);
+
+    /** 撤回审批(预审或正式阶段都可用,看状态判断) */
+    void cancelApproval(Long projectId);
+
+}
+
+

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

@@ -0,0 +1,561 @@
+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.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 javax.annotation.Resource;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Objects;
+import java.util.*;
+import java.util.stream.Collectors;
+
+import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
+import static cn.iocoder.yudao.module.rd.enums.ErrorCodeConstant.*;
+import static cn.iocoder.yudao.module.rd.enums.ProjectApprovalConstants.*;
+
+import cn.iocoder.yudao.module.system.api.dept.DeptApi;
+import cn.hutool.core.collection.CollUtil;
+import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO;
+
+@Service
+@Validated
+public class ResearchProjectServiceImpl implements ResearchProjectService {
+
+    @Resource
+    private ProjectMapper projectMapper;
+    @Resource
+    private ProjectScheduleMapper projectScheduleMapper;
+    @Resource
+    private ProjectBudgetMapper projectBudgetMapper;
+    @Resource
+    private ProjectApprovalRecordMapper approvalRecordMapper;
+    @Resource
+    private ApproverQueryMapper approverQueryMapper;
+    @Resource
+    private DeptApi deptApi;
+
+
+    /**
+     * 单条:填充部门名(deptId 和 executingUnit 两个字段一起翻译)
+     */
+    private void fillDeptNames(ProjectRespVO vo) {
+        if (vo == null) {
+            return;
+        }
+        fillDeptNames(Collections.singletonList(vo));
+    }
+
+    /**
+     * 批量:填充列表里所有项目的部门名
+     */
+    private void fillDeptNames(List<ProjectRespVO> list) {
+        if (CollUtil.isEmpty(list)) {
+            return;
+        }
+        // 1. 收集所有需要的部门 id
+        Set<Long> deptIds = new HashSet<>();
+        for (ProjectRespVO vo : list) {
+            if (vo.getDeptId() != null) deptIds.add(vo.getDeptId());
+            if (vo.getExecutingUnit() != null) deptIds.add(vo.getExecutingUnit());
+        }
+        if (deptIds.isEmpty()) {
+            return;
+        }
+
+        // 2. 一次批量查
+        Map<Long, String> deptNameMap = getDeptNameMap(deptIds);
+
+        // 3. 回填
+        for (ProjectRespVO vo : list) {
+            if (vo.getDeptId() != null) {
+                vo.setDeptName(deptNameMap.get(vo.getDeptId()));
+            }
+            if (vo.getExecutingUnit() != null) {
+                vo.setExecutingUnitName(deptNameMap.get(vo.getExecutingUnit()));
+            }
+        }
+    }
+
+    /**
+     * 调 DeptApi 批量查「id → name」
+     */
+    private Map<Long, String> getDeptNameMap(Set<Long> deptIds) {
+        if (CollUtil.isEmpty(deptIds)) {
+            return Collections.emptyMap();
+        }
+        List<DeptRespDTO> depts = deptApi.getDeptList(deptIds);
+        if (CollUtil.isEmpty(depts)) {
+            return Collections.emptyMap();
+        }
+        return depts.stream()
+                .collect(Collectors.toMap(DeptRespDTO::getId, DeptRespDTO::getName, (a, b) -> a));
+    }
+
+    // ==================== 新增 ====================
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Long createProject(ProjectSaveReqVO createReqVO) {
+        ProjectDO project = BeanUtils.toBean(createReqVO, ProjectDO.class);
+
+        // 排序
+        project.setSort(getMaxSort() + 1);
+
+        // ⭐ 关键:初始化审批状态为"草稿"
+        project.setPreAuditStatus(AUDIT_STATUS_DRAFT);          // 0 预审未提交
+        project.setPreCurrentNode(null);
+        project.setPreCurrentApproverId(null);
+        project.setPreSubmitTime(null);
+
+        project.setFormalAuditStatus(AUDIT_STATUS_DRAFT);       // 0 正式未提交
+        project.setFormalCurrentNode(null);
+        project.setFormalCurrentApproverId(null);
+        project.setFormalSubmitTime(null);
+
+        // 是否涉及预算:由前端传 or 后端根据预算合计判断
+        if (project.getInvolveBudget() == null) {
+            project.setInvolveBudget(0);
+        }
+
+        projectMapper.insert(project);
+
+        // 子表
+        insertScheduleList(createReqVO.getScheduleList(), project.getId());
+        insertBudgetList(createReqVO.getBudgetList(), project.getId());
+
+        return project.getId();
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void updateProject(ProjectSaveReqVO updateReqVO) {
+        ProjectDO exist = projectMapper.selectById(updateReqVO.getId());
+        if (exist == null) {
+            throw exception(PROJECT_NOT_EXISTS);
+        }
+
+        // ⚠️ 已提交审批的项目不允许随意修改(否则审批内容会被篡改)
+        if (exist.getPreAuditStatus() != null && exist.getPreAuditStatus() == AUDIT_STATUS_PROCESSING) {
+            throw exception(PROJECT_IN_APPROVAL_CANNOT_UPDATE);
+        }
+        if (exist.getFormalAuditStatus() != null && exist.getFormalAuditStatus() == AUDIT_STATUS_PROCESSING) {
+            throw exception(PROJECT_IN_APPROVAL_CANNOT_UPDATE);
+        }
+
+        ProjectDO updateObj = BeanUtils.toBean(updateReqVO, ProjectDO.class);
+        // ⚠️ 不允许前端改审批字段(安全)
+        updateObj.setPreAuditStatus(null);
+        updateObj.setPreCurrentNode(null);
+        updateObj.setPreCurrentApproverId(null);
+        updateObj.setFormalAuditStatus(null);
+        updateObj.setFormalCurrentNode(null);
+        updateObj.setFormalCurrentApproverId(null);
+
+        projectMapper.updateById(updateObj);
+
+        // 子表全删全插
+        projectScheduleMapper.deleteByProjectId(updateReqVO.getId());
+        projectBudgetMapper.deleteByProjectId(updateReqVO.getId());
+        insertScheduleList(updateReqVO.getScheduleList(), updateReqVO.getId());
+        insertBudgetList(updateReqVO.getBudgetList(), updateReqVO.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);
+    }
+
+    // ==================== 详情 & 分页 ====================
+
+    @Override
+    public ProjectRespVO getProject(Long id) {
+        ProjectDO project = projectMapper.selectById(id);
+        if (project == null) {
+            return null;
+        }
+        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);
+        PageResult<ProjectRespVO> voPage = BeanUtils.toBean(pageResult, ProjectRespVO.class);
+
+        // ⭐ 批量填部门名
+        fillDeptNames(voPage.getList());
+
+        return voPage;
+    }
+
+    @Override
+    public List<ProjectDO> getProjectList(ProjectPageReqVO reqVO) {
+        return projectMapper.selectList(reqVO);
+    }
+
+    // ==================== 提交预审 ====================
+
+    @Override
+    public PageResult<ProjectRespVO> getTodoPage(ProjectPageReqVO pageReqVO) {
+        Long userId = SecurityFrameworkUtils.getLoginUserId();
+        PageResult<ProjectDO> pageResult = projectMapper.selectTodoPage(pageReqVO, userId);
+        PageResult<ProjectRespVO> voPage = BeanUtils.toBean(pageResult, ProjectRespVO.class);
+        fillDeptNames(voPage.getList());
+        return voPage;
+    }
+
+    @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 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);
+            item.setSort(item.getSort() == null ? 0 : item.getSort());
+        });
+        scheduleList.forEach(projectScheduleMapper::insert);
+    }
+
+    private void insertBudgetList(List<ProjectSaveReqVO.BudgetVO> list, Long projectId) {
+        if (list == null || list.isEmpty()) return;
+        List<ProjectBudgetDO> budgetList = BeanUtils.toBean(list, ProjectBudgetDO.class);
+        budgetList.forEach(item -> {
+            item.setProjectId(projectId);
+            item.setSort(item.getSort() == null ? 0 : item.getSort());
+        });
+        budgetList.forEach(projectBudgetMapper::insert);
+    }
+
+    /** 写一条审批记录 */
+    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) {
+        List<ProjectAttachmentVO> attachments = project.getAttachments();
+        if (CollUtil.isEmpty(attachments)) {
+            throw exception(PROJECT_APPLICATION_FILE_REQUIRED);
+        }
+        boolean hasApplication = attachments.stream()
+                .anyMatch(a -> "APPLICATION".equals(a.getType()));
+        if (!hasApplication) {
+            throw exception(PROJECT_APPLICATION_FILE_REQUIRED);
+        }
+        boolean hasOpeningReport = attachments.stream()
+                .anyMatch(a -> "OPENING_REPORT".equals(a.getType()));
+        if (!hasOpeningReport) {
+            throw exception(PROJECT_OPENING_REPORT_REQUIRED);
+        }
+    }
+
+    private void validateBeforeSubmitFormal(ProjectDO project) {
+        // 例:正式立项前必须有预算
+        // 按需扩展
+    }
+
+}