Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/RdDeclPlanController.java
T
QiufengandClaude Opus 4.8 5e51dc3f56 SNAPSHOT W7 已部署稳定态 — 凯迪ERP+OA一体化平台 (MET 73.3%)
恢复点(restore point)。别人改崩后可 git reset --hard 回到此提交。

== 此快照内容 ==
- 后端 oa-backend: 734 控制器 / 711 实体 (Spring Boot 3.2.5 + SQLite, 端口8091)
- 前端 modern-ui/app: Vue3+Vite, 约700页 (构建产物已在 oa-backend/src/main/resources/static)
- 数据库 oa-backend/data/oa.db: 含全部演示数据 (强制入库, 6.6MB)
- 交接文档 go.md + go-code-reference/endpoints/entities/database.md
- 多代理建设脚本 .claude/wf-*.js

== 状态 ==
- 对 凯迪科技ERP_20260507.xlsx 合规 MET ~73.3% (PARTIAL 75: 34可建+6种子/bug+35外部硬天花板)
- 安全: 5轮红队+5轮复检, default-deny分级鉴权, 连续零可利用
- W3~W7 累计补完436缺口; W8末轮(40缺口)为半成品(源码树可编译但未集成)
- 运行: cd oa-backend; java -jar build/libs/oa-backend-0.1.0.jar --server.port=8091; admin/123456

== 排除(gitignore, 可再生) ==
node_modules / oa-backend/build / .jdks / *.log / Backup-ERP-* / 弃用的OFBiz核心(只保留modern-ui)
完整文件夹备份见同目录 Backup-ERP-20260615-191517/ (含上述全部, 仅缺 node_modules)

时间戳: 20260615-191517

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:19:15 +08:00

442 lines
19 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 政策与申报计划管理)。
*
* <p>补完缺口:{@link com.kaidi.oa.domain.WmDeclPlan} 是工程管理中心专属的专利/工法申报
* 计划,本控制器面向申报服务部的通用政策申报计划,支持:
* <ul>
* <li>计划 CRUD:含预算(BigDecimal)、预期收益、责任人/部门、计划时间表;</li>
* <li>状态机:草稿 → 审批中 → 已批准 → 执行中 → 已完成 / 已驳回 / 已取消;</li>
* <li>提交审批(接 WorkflowService,自动展开审批流);</li>
* <li>审批通过后自动发起 PolicyApplication(申报条件自查与进度跟踪联动);</li>
* <li>进度回写:从关联的 DeclarationStep 计算完成率自动回写 progress 字段。</li>
* </ul>
*
* 端点:/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<String> VALID_STATUS = List.of(
"草稿", "审批中", "已批准", "执行中", "已完成", "已驳回", "已取消");
private static final List<String> PERIOD_TYPES = List.of("年度", "季度");
private static final List<String> 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<RdDeclPlan>> 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<RdDeclPlan> 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<RdDeclPlan> 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<RdDeclPlan> 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<Void> 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<FormInstance> 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<Map<String, Object>> 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<String, Object> 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<RdDeclPlan> 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<RdDeclPlan> 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<RdDeclPlan> 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<RdDeclPlan> 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<RdDeclPlan> 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 关联了 DeclarationDeclaration 绑定了 DeclarationStep
* 前端点击「刷新进度」时调用。
*/
@PostMapping("/{id}/sync-progress")
@Transactional
public ApiResp<RdDeclPlan> 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<List<PlanProgress>> progress(@RequestParam(required = false) Integer planYear) {
List<RdDeclPlan> 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<DeclarationStep> 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;
}
}