恢复点(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>
442 lines
19 KiB
Java
442 lines
19 KiB
Java
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 关联了 Declaration,Declaration 绑定了 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;
|
||
}
|
||
}
|