package com.kaidi.oa.web; import com.kaidi.oa.common.ApiException; import com.kaidi.oa.common.ApiResp; import com.kaidi.oa.common.Money; import com.kaidi.oa.common.NotFoundException; import com.kaidi.oa.domain.DeclarationStep; import com.kaidi.oa.domain.FormInstance; import com.kaidi.oa.domain.PolicyApplication; import com.kaidi.oa.domain.RdDeclPlan; import com.kaidi.oa.repository.DeclarationStepRepository; import com.kaidi.oa.repository.FormInstanceRepository; import com.kaidi.oa.repository.PolicyApplicationRepository; import com.kaidi.oa.repository.RdDeclPlanRepository; import com.kaidi.oa.service.WorkflowService; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.time.Instant; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 创新研发中心·申报服务部 — 年度/季度通用申报计划(需求 §1 政策与申报计划管理)。 * *

补完缺口:{@link com.kaidi.oa.domain.WmDeclPlan} 是工程管理中心专属的专利/工法申报 * 计划,本控制器面向申报服务部的通用政策申报计划,支持: *

* * 端点:/api/oa/rd-decl-plans * 写口受 AuthInterceptor default-deny(ADMIN/APPROVER) 保护;聚合读已登记到 * SENSITIVE_READ_PREFIXES(含金额聚合)。 */ @RestController @RequestMapping("/api/oa/rd-decl-plans") public class RdDeclPlanController { /** 申报计划审批流模板 ID(需在 TemplateSeedData 中注册)。 */ private static final String PLAN_TEMPLATE_ID = "rd-decl-plan-approve"; private static final List VALID_STATUS = List.of( "草稿", "审批中", "已批准", "执行中", "已完成", "已驳回", "已取消"); private static final List PERIOD_TYPES = List.of("年度", "季度"); private static final List POLICY_CATEGORIES = List.of("资质类", "资金类", "荣誉类", "人才类"); private final RdDeclPlanRepository planRepo; private final DeclarationStepRepository stepRepo; private final PolicyApplicationRepository appRepo; private final FormInstanceRepository instanceRepo; private final WorkflowService workflowService; public RdDeclPlanController(RdDeclPlanRepository planRepo, DeclarationStepRepository stepRepo, PolicyApplicationRepository appRepo, FormInstanceRepository instanceRepo, WorkflowService workflowService) { this.planRepo = planRepo; this.stepRepo = stepRepo; this.appRepo = appRepo; this.instanceRepo = instanceRepo; this.workflowService = workflowService; } // ---------- 台账 CRUD ---------- @GetMapping public ApiResp> list(@RequestParam(required = false) Integer planYear, @RequestParam(required = false) String status, @RequestParam(required = false) String policyCategory) { if (planYear != null && status != null && !status.isBlank()) { return ApiResp.ok(planRepo.findByPlanYearAndStatus(planYear, status)); } if (planYear != null) { return ApiResp.ok(planRepo.findByPlanYear(planYear)); } if (status != null && !status.isBlank()) { return ApiResp.ok(planRepo.findByStatus(status)); } if (policyCategory != null && !policyCategory.isBlank()) { return ApiResp.ok(planRepo.findByPolicyCategoryAndStatus(policyCategory, "已批准")); } return ApiResp.ok(planRepo.findAll()); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(find(id)); } public record PlanRequest( String name, String periodType, Integer planYear, String planQuarter, String policyCategory, String programName, String department, String owner, Double budget, Double expectedBenefit, String planApplyDate, String planEndDate, String remark) { } @PostMapping @Transactional public ApiResp create(@RequestBody PlanRequest req) { validatePlanRequest(req); RdDeclPlan p = new RdDeclPlan(); applyEditable(p, req); p.setStatus("草稿"); p.setProgress(0); p.setCreatedAt(Instant.now()); p.setUpdatedAt(Instant.now()); return ApiResp.ok(planRepo.save(p)); } @PatchMapping("/{id}") @Transactional public ApiResp update(@PathVariable Long id, @RequestBody PlanRequest req) { RdDeclPlan p = find(id); if (!List.of("草稿", "已驳回").contains(p.getStatus())) { throw new ApiException(409, "仅「草稿」或「已驳回」计划可编辑,当前状态:" + p.getStatus()); } applyEditable(p, req); p.setUpdatedAt(Instant.now()); return ApiResp.ok(planRepo.save(p)); } @DeleteMapping("/{id}") @Transactional public ApiResp delete(@PathVariable Long id) { RdDeclPlan p = find(id); if (!"草稿".equals(p.getStatus())) { throw new ApiException(409, "仅「草稿」计划可删除,当前状态:" + p.getStatus()); } planRepo.deleteById(id); return ApiResp.ok(null); } // ---------- 状态机:提交审批 ---------- public record ApprovalSubmitRequest(String applicant) { } /** * 提交审批(草稿/已驳回 → 审批中)。 * 接 WorkflowService 自动展开审批流,同一计划不可重复提交进行中的流程。 */ @PostMapping("/{id}/submit-approval") @Transactional public ApiResp submitApproval(@PathVariable Long id, @RequestBody(required = false) ApprovalSubmitRequest req) { RdDeclPlan p = find(id); if (!List.of("草稿", "已驳回").contains(p.getStatus())) { throw new ApiException(409, "当前状态不允许提交审批:" + p.getStatus()); } String applicant = req != null && req.applicant() != null && !req.applicant().isBlank() ? req.applicant() : (p.getOwner() != null ? p.getOwner() : "admin"); // 检查是否已有进行中的审批实例 boolean alreadyPending = instanceRepo.findAll().stream() .anyMatch(inst -> PLAN_TEMPLATE_ID.equals(inst.getTemplateId()) && inst.getTitle() != null && inst.getTitle().contains(p.getName()) && !List.of("已驳回", "已撤回", "已办结").contains(inst.getStatus())); if (alreadyPending) { throw new ApiException(409, "该申报计划已有进行中的审批流程,请勿重复提交"); } String dataJson = String.format( "{\"planName\":\"%s\",\"periodType\":\"%s\",\"planYear\":%d," + "\"policyCategory\":\"%s\",\"programName\":\"%s\"," + "\"budget\":%s,\"expectedBenefit\":%s,\"owner\":\"%s\",\"applicant\":\"%s\"}", nvl(p.getName()), nvl(p.getPeriodType()), p.getPlanYear() == null ? 0 : p.getPlanYear(), nvl(p.getPolicyCategory()), nvl(p.getProgramName()), Money.nz(p.getBudget()).toPlainString(), Money.nz(p.getExpectedBenefit()).toPlainString(), nvl(p.getOwner()), applicant); String title = "申报计划审批-" + p.getName(); FormInstance inst = workflowService.submit(PLAN_TEMPLATE_ID, dataJson, title, applicant); p.setStatus("审批中"); p.setInstanceId(inst.getId()); p.setUpdatedAt(Instant.now()); planRepo.save(p); return ApiResp.ok(inst); } /** * 查询计划关联的审批状态(最新实例)。 */ @GetMapping("/{id}/approval-status") public ApiResp> approvalStatus(@PathVariable Long id) { RdDeclPlan p = find(id); FormInstance latest = instanceRepo.findAll().stream() .filter(inst -> PLAN_TEMPLATE_ID.equals(inst.getTemplateId()) && inst.getTitle() != null && inst.getTitle().contains(p.getName())) .max(Comparator.comparing(FormInstance::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder()))) .orElse(null); Map result = new LinkedHashMap<>(); result.put("planId", p.getId()); result.put("planName", p.getName()); result.put("planStatus", p.getStatus()); if (latest != null) { result.put("instanceId", latest.getId()); result.put("instanceStatus", latest.getStatus()); result.put("currentNode", latest.getCurrentNode()); result.put("title", latest.getTitle()); result.put("createdAt", latest.getCreatedAt()); } else { result.put("instanceId", null); result.put("instanceStatus", "未提交"); result.put("currentNode", null); } return ApiResp.ok(result); } // ---------- 状态机:批准 / 驳回 / 取消 / 启动执行 / 完成 ---------- public record StatusTransitionRequest(String note) { } /** * 审批批准(审批中 → 已批准)。 * 批准后自动在 PolicyApplication 中创建一条申报记录,实现计划→执行联动。 */ @PostMapping("/{id}/approve") @Transactional public ApiResp approve(@PathVariable Long id, @RequestBody(required = false) StatusTransitionRequest req) { RdDeclPlan p = find(id); if (!"审批中".equals(p.getStatus())) { throw new ApiException(409, "仅「审批中」计划可批准,当前:" + p.getStatus()); } p.setStatus("已批准"); p.setUpdatedAt(Instant.now()); // 自动在 PolicyApplication 创建对应申报记录(条件自查与进度跟踪联动) PolicyApplication app = new PolicyApplication(); app.setPolicyTitle(p.getProgramName() != null ? p.getProgramName() : p.getName()); app.setProjectName(p.getName()); app.setApplicant(p.getOwner()); app.setDept(p.getDepartment()); app.setStatus("申报中"); app.setAppliedAmount(Money.nz(p.getBudget())); app.setGrantedAmount(java.math.BigDecimal.ZERO); app.setAppliedAt(Instant.now()); PolicyApplication savedApp = appRepo.save(app); p.setPolicyApplicationId(savedApp.getId()); planRepo.save(p); return ApiResp.ok(p); } /** 审批驳回(审批中 → 已驳回)。 */ @PostMapping("/{id}/reject") @Transactional public ApiResp reject(@PathVariable Long id, @RequestBody(required = false) StatusTransitionRequest req) { RdDeclPlan p = find(id); if (!"审批中".equals(p.getStatus())) { throw new ApiException(409, "仅「审批中」计划可驳回,当前:" + p.getStatus()); } p.setStatus("已驳回"); p.setUpdatedAt(Instant.now()); planRepo.save(p); return ApiResp.ok(p); } /** 取消计划(草稿/已批准 → 已取消)。 */ @PostMapping("/{id}/cancel") @Transactional public ApiResp cancel(@PathVariable Long id) { RdDeclPlan p = find(id); if (!List.of("草稿", "已批准").contains(p.getStatus())) { throw new ApiException(409, "仅「草稿」或「已批准」计划可取消,当前:" + p.getStatus()); } p.setStatus("已取消"); p.setUpdatedAt(Instant.now()); planRepo.save(p); return ApiResp.ok(p); } /** 启动执行(已批准 → 执行中)。 */ @PostMapping("/{id}/start") @Transactional public ApiResp start(@PathVariable Long id) { RdDeclPlan p = find(id); if (!"已批准".equals(p.getStatus())) { throw new ApiException(409, "仅「已批准」计划可启动执行,当前:" + p.getStatus()); } p.setStatus("执行中"); p.setUpdatedAt(Instant.now()); planRepo.save(p); return ApiResp.ok(p); } /** 标记完成(执行中 → 已完成)。 */ @PostMapping("/{id}/complete") @Transactional public ApiResp complete(@PathVariable Long id) { RdDeclPlan p = find(id); if (!"执行中".equals(p.getStatus())) { throw new ApiException(409, "仅「执行中」计划可标记完成,当前:" + p.getStatus()); } p.setStatus("已完成"); p.setProgress(100); p.setUpdatedAt(Instant.now()); planRepo.save(p); return ApiResp.ok(p); } // ---------- 进度回写:从关联 PolicyApplication 的 DeclarationStep 聚合 ---------- /** * 从关联的 DeclarationStep 重新计算完成率并回写 progress。 * 适用于:PolicyApplication 关联了 Declaration,Declaration 绑定了 DeclarationStep, * 前端点击「刷新进度」时调用。 */ @PostMapping("/{id}/sync-progress") @Transactional public ApiResp syncProgress(@PathVariable Long id) { RdDeclPlan p = find(id); if (!List.of("已批准", "执行中").contains(p.getStatus())) { throw new ApiException(409, "仅「已批准」或「执行中」计划可同步进度"); } // 通过 policyApplicationId → Declaration → DeclarationStep 推算完成率 int progress = calcProgress(p); p.setProgress(progress); if (progress >= 100 && "执行中".equals(p.getStatus())) { p.setStatus("已完成"); } else if (progress > 0 && "已批准".equals(p.getStatus())) { p.setStatus("执行中"); } p.setUpdatedAt(Instant.now()); planRepo.save(p); return ApiResp.ok(p); } // ---------- 进度看板聚合 ---------- public record PlanProgress(Long id, String name, String periodType, Integer planYear, String policyCategory, String programName, String owner, String status, Integer progress, double budget, double expectedBenefit, String planApplyDate, String planEndDate) { } /** 计划进度看板:含完成率、预算、收益,按年度过滤。 */ @GetMapping("/progress") public ApiResp> progress(@RequestParam(required = false) Integer planYear) { List plans = planYear != null ? planRepo.findByPlanYear(planYear) : planRepo.findAll(); return ApiResp.ok(plans.stream().map(p -> new PlanProgress( p.getId(), p.getName(), p.getPeriodType(), p.getPlanYear(), p.getPolicyCategory(), p.getProgramName(), p.getOwner(), p.getStatus(), p.getProgress() == null ? 0 : p.getProgress(), Money.nz(p.getBudget()).doubleValue(), Money.nz(p.getExpectedBenefit()).doubleValue(), p.getPlanApplyDate(), p.getPlanEndDate() )).toList()); } // ---------- helpers ---------- private RdDeclPlan find(Long id) { return planRepo.findById(id) .orElseThrow(() -> new NotFoundException("申报计划不存在:" + id)); } private void validatePlanRequest(PlanRequest req) { if (req.name() == null || req.name().isBlank()) { throw new ApiException(400, "计划名称(name)不能为空"); } if (req.planYear() == null) { throw new ApiException(400, "计划年度(planYear)不能为空"); } if (req.periodType() != null && !PERIOD_TYPES.contains(req.periodType())) { throw new ApiException(400, "periodType 可选值:" + String.join(" / ", PERIOD_TYPES)); } if (req.policyCategory() != null && !POLICY_CATEGORIES.contains(req.policyCategory())) { throw new ApiException(400, "policyCategory 可选值:" + String.join(" / ", POLICY_CATEGORIES)); } } private void applyEditable(RdDeclPlan p, PlanRequest req) { if (req.name() != null && !req.name().isBlank()) p.setName(req.name()); if (req.periodType() != null) p.setPeriodType(req.periodType()); if (req.planYear() != null) p.setPlanYear(req.planYear()); if (req.planQuarter() != null) p.setPlanQuarter(req.planQuarter()); if (req.policyCategory() != null) p.setPolicyCategory(req.policyCategory()); if (req.programName() != null) p.setProgramName(req.programName()); if (req.department() != null) p.setDepartment(req.department()); if (req.owner() != null) p.setOwner(req.owner()); if (req.budget() != null) p.setBudget(Money.of(req.budget())); if (req.expectedBenefit() != null) p.setExpectedBenefit(Money.of(req.expectedBenefit())); if (req.planApplyDate() != null) p.setPlanApplyDate(req.planApplyDate()); if (req.planEndDate() != null) p.setPlanEndDate(req.planEndDate()); if (req.remark() != null) p.setRemark(req.remark()); } /** * 从关联的 PolicyApplication → Declaration(按 projectName 关联)→ DeclarationStep * 推算完成率。未关联时返回当前进度。 */ private int calcProgress(RdDeclPlan p) { if (p.getPolicyApplicationId() == null) { return p.getProgress() == null ? 0 : p.getProgress(); } PolicyApplication app = appRepo.findById(p.getPolicyApplicationId()).orElse(null); if (app == null) { return p.getProgress() == null ? 0 : p.getProgress(); } // PolicyApplication 没有直接的 DeclarationId,用 projectName 模糊找 steps List steps = stepRepo.findAll().stream() .filter(s -> app.getProjectName() != null && app.getProjectName().equals(s.getDeclarationName())) .toList(); if (steps.isEmpty()) { return p.getProgress() == null ? 0 : p.getProgress(); } long done = steps.stream().filter(s -> "已完成".equals(s.getStatus())).count(); return (int) Math.round((double) done / steps.size() * 100); } private static String nvl(String s) { return s == null ? "" : s; } }