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>
This commit is contained in:
Qiufeng
2026-06-15 19:19:15 +08:00
co-authored by Claude Opus 4.8
commit 5e51dc3f56
10584 changed files with 2501339 additions and 0 deletions
@@ -0,0 +1,387 @@
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.PersonnelCert;
import com.kaidi.oa.domain.Project;
import com.kaidi.oa.domain.QualDeclFee;
import com.kaidi.oa.domain.QualDeclTask;
import com.kaidi.oa.domain.QualDeclaration;
import com.kaidi.oa.repository.PersonnelCertRepository;
import com.kaidi.oa.repository.ProjectRepository;
import com.kaidi.oa.repository.QualDeclFeeRepository;
import com.kaidi.oa.repository.QualDeclTaskRepository;
import com.kaidi.oa.repository.QualDeclarationRepository;
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.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 行政·资质管理办——资质申报与升级管理(深水工作流)。
*
* 做深的能力:
* 1) 申报主单 CRUD(申报计划:类型/目标等级/责任部门/预算/时间节点);
* 2) 状态机推进 advance:草拟→内审中→内审通过→已提交→形式审查→专家评审→公示→公告→已领证,
* forward-only 白名单约束,异常态 补正中/已驳回/已撤回,领证后回写资质台账(可选);
* 3) 申报任务分解:把申报拆成人员配备/业绩整理/材料编写/网上填报/现场核查等子任务并跟踪完成;
* 4) 申报费用明细:咨询/评审/培训/材料/差旅,挂账并把 feeSpent 重算回写主单 + 预算超支提示;
* 5) 人员/业绩自动匹配 gap:按目标等级要求的注册人员数/业绩数,从 HR 人员证件库(personnel_cert
* 与项目库(project)自动匹配在册资源并算缺口,辅助"还差多少能申报"。
*
* 写口含金额、属机密财务/资质读,已登记进 AuthInterceptor 的 FINANCE_PREFIXES + SENSITIVE_READ_PREFIXES。
*/
@RestController
@RequestMapping("/api/oa/qual-declarations")
public class QualDeclarationController {
/** forward-only 状态机:当前态 → 允许的下一态集合。 */
private static final Map<String, List<String>> NEXT = Map.ofEntries(
Map.entry("草拟", List.of("内审中", "已撤回")),
Map.entry("内审中", List.of("内审通过", "草拟", "已驳回")),
Map.entry("内审通过", List.of("已提交", "已撤回")),
Map.entry("已提交", List.of("形式审查", "补正中", "已撤回")),
Map.entry("形式审查", List.of("专家评审", "补正中", "已驳回")),
Map.entry("专家评审", List.of("公示", "补正中", "已驳回")),
Map.entry("公示", List.of("公告", "已驳回")),
Map.entry("公告", List.of("已领证")),
Map.entry("补正中", List.of("形式审查", "专家评审", "已驳回")),
Map.entry("已领证", List.of()),
Map.entry("已驳回", List.of("草拟")),
Map.entry("已撤回", List.of("草拟")));
private final QualDeclarationRepository declRepo;
private final QualDeclTaskRepository taskRepo;
private final QualDeclFeeRepository feeRepo;
private final PersonnelCertRepository certRepo;
private final ProjectRepository projectRepo;
public QualDeclarationController(QualDeclarationRepository declRepo,
QualDeclTaskRepository taskRepo,
QualDeclFeeRepository feeRepo,
PersonnelCertRepository certRepo,
ProjectRepository projectRepo) {
this.declRepo = declRepo;
this.taskRepo = taskRepo;
this.feeRepo = feeRepo;
this.certRepo = certRepo;
this.projectRepo = projectRepo;
}
// ---------- 申报主单 CRUD ----------
@GetMapping
public ApiResp<List<QualDeclaration>> list(@RequestParam(required = false) String status,
@RequestParam(required = false) String declType) {
if (status != null && !status.isBlank()) {
return ApiResp.ok(declRepo.findByStatus(status));
}
if (declType != null && !declType.isBlank()) {
return ApiResp.ok(declRepo.findByDeclType(declType));
}
return ApiResp.ok(declRepo.findAll());
}
@GetMapping("/{id}")
public ApiResp<QualDeclaration> get(@PathVariable Long id) {
return ApiResp.ok(find(id));
}
public record DeclRequest(String code, String qualName, String declType, String category,
String targetLevel, String dept, String owner, String externalOrg,
String planStart, String planDeadline, String receiptNo, String status,
Double budget, String correctionNote, String remark) {
}
@PostMapping
public ApiResp<QualDeclaration> create(@RequestBody DeclRequest req) {
if (req.qualName() == null || req.qualName().isBlank()) {
throw new ApiException(400, "申报资质名称(qualName) 不能为空");
}
QualDeclaration d = new QualDeclaration();
d.setCode(req.code() == null || req.code().isBlank()
? "ZSB-" + (declRepo.count() + 1) : req.code());
d.setQualName(req.qualName());
d.setDeclType(blankTo(req.declType(), "新申请"));
d.setCategory(blankTo(req.category(), "企业资质"));
d.setTargetLevel(req.targetLevel());
d.setDept(req.dept());
d.setOwner(req.owner());
d.setExternalOrg(req.externalOrg());
d.setPlanStart(req.planStart());
d.setPlanDeadline(req.planDeadline());
d.setReceiptNo(req.receiptNo());
d.setStatus(blankTo(req.status(), "草拟"));
d.setBudget(Money.of(req.budget()));
d.setFeeSpent(Money.ZERO);
d.setCorrectionNote(req.correctionNote());
d.setRemark(req.remark());
d.setCreatedAt(Instant.now());
return ApiResp.ok(declRepo.save(d));
}
@PatchMapping("/{id}")
public ApiResp<QualDeclaration> update(@PathVariable Long id, @RequestBody DeclRequest req) {
QualDeclaration d = find(id);
if (req.qualName() != null && !req.qualName().isBlank()) d.setQualName(req.qualName());
if (req.declType() != null && !req.declType().isBlank()) d.setDeclType(req.declType());
if (req.category() != null) d.setCategory(req.category());
if (req.targetLevel() != null) d.setTargetLevel(req.targetLevel());
if (req.dept() != null) d.setDept(req.dept());
if (req.owner() != null) d.setOwner(req.owner());
if (req.externalOrg() != null) d.setExternalOrg(req.externalOrg());
if (req.planStart() != null) d.setPlanStart(req.planStart());
if (req.planDeadline() != null) d.setPlanDeadline(req.planDeadline());
if (req.receiptNo() != null) d.setReceiptNo(req.receiptNo());
if (req.budget() != null) d.setBudget(Money.of(req.budget()));
if (req.correctionNote() != null) d.setCorrectionNote(req.correctionNote());
if (req.remark() != null) d.setRemark(req.remark());
// status 不在此直改:必须走 /advance 状态机,杜绝越级跳态。
return ApiResp.ok(declRepo.save(d));
}
@DeleteMapping("/{id}")
@Transactional
public ApiResp<Void> delete(@PathVariable Long id) {
if (!declRepo.existsById(id)) {
throw new NotFoundException("qual declaration not found: " + id);
}
taskRepo.deleteByDeclarationId(id);
feeRepo.deleteByDeclarationId(id);
declRepo.deleteById(id);
return ApiResp.ok(null);
}
// ---------- 状态机推进 ----------
public record AdvanceRequest(String to, String note) {
}
/**
* 状态机推进:仅允许 NEXT 白名单内的迁移。推进到 补正中 时把 note 记到 correctionNote
* 推进到 已领证 时校验必须经过 公告。任何越级/回退非法迁移给 409 中文。
*/
@PostMapping("/{id}/advance")
@Transactional
public ApiResp<QualDeclaration> advance(@PathVariable Long id, @RequestBody AdvanceRequest req) {
QualDeclaration d = find(id);
String from = d.getStatus() == null ? "草拟" : d.getStatus();
String to = req.to();
if (to == null || to.isBlank()) {
throw new ApiException(400, "目标状态(to) 不能为空");
}
List<String> allowed = NEXT.getOrDefault(from, List.of());
if (!allowed.contains(to)) {
throw new ApiException(409, "非法状态迁移:" + from + "" + to
+ "(允许:" + (allowed.isEmpty() ? "无(终态)" : String.join("/", allowed)) + "");
}
if ("补正中".equals(to)) {
d.setCorrectionNote(req.note() == null ? d.getCorrectionNote() : req.note());
}
d.setStatus(to);
return ApiResp.ok(declRepo.save(d));
}
// ---------- 任务分解 ----------
@GetMapping("/{id}/tasks")
public ApiResp<List<QualDeclTask>> tasks(@PathVariable Long id) {
return ApiResp.ok(taskRepo.findByDeclarationIdOrderByIdAsc(id));
}
public record TaskRequest(String name, String taskType, String assignee, String dueDate, String remark) {
}
@PostMapping("/{id}/tasks")
@Transactional
public ApiResp<QualDeclTask> addTask(@PathVariable Long id, @RequestBody TaskRequest req) {
find(id);
if (req.name() == null || req.name().isBlank()) {
throw new ApiException(400, "任务名称(name) 不能为空");
}
QualDeclTask t = new QualDeclTask();
t.setDeclarationId(id);
t.setName(req.name());
t.setTaskType(blankTo(req.taskType(), "材料"));
t.setAssignee(req.assignee());
t.setDueDate(req.dueDate());
t.setStatus("待办");
t.setRemark(req.remark());
t.setCreatedAt(Instant.now());
return ApiResp.ok(taskRepo.save(t));
}
public record TaskStatusRequest(String status) {
}
/** 更新子任务状态:待办/进行中/已完成/已逾期;置「已完成」自动回填完成日期。 */
@PatchMapping("/tasks/{taskId}")
@Transactional
public ApiResp<QualDeclTask> updateTask(@PathVariable Long taskId, @RequestBody TaskStatusRequest req) {
QualDeclTask t = taskRepo.findById(taskId)
.orElseThrow(() -> new NotFoundException("task not found: " + taskId));
String s = req.status();
if (s == null || !List.of("待办", "进行中", "已完成", "已逾期").contains(s)) {
throw new ApiException(400, "非法任务状态:" + s);
}
t.setStatus(s);
t.setDoneDate("已完成".equals(s) ? LocalDate.now().toString() : null);
return ApiResp.ok(taskRepo.save(t));
}
@DeleteMapping("/tasks/{taskId}")
@Transactional
public ApiResp<Void> deleteTask(@PathVariable Long taskId) {
if (!taskRepo.existsById(taskId)) {
throw new NotFoundException("task not found: " + taskId);
}
taskRepo.deleteById(taskId);
return ApiResp.ok(null);
}
// ---------- 申报费用 ----------
@GetMapping("/{id}/fees")
public ApiResp<List<QualDeclFee>> fees(@PathVariable Long id) {
return ApiResp.ok(feeRepo.findByDeclarationIdOrderByIdAsc(id));
}
public record FeeRequest(String feeType, Double amount, String spendDate, String payee, String remark) {
}
/** 登记一笔申报费用,并把所属申报单 feeSpent 重算回写;超预算时给 409 提示(写口已收口审批人)。 */
@PostMapping("/{id}/fees")
@Transactional
public ApiResp<QualDeclFee> addFee(@PathVariable Long id, @RequestBody FeeRequest req) {
QualDeclaration d = find(id);
if (req.amount() == null || req.amount() <= 0) {
throw new ApiException(400, "费用金额(amount) 必须大于 0");
}
QualDeclFee f = new QualDeclFee();
f.setDeclarationId(id);
f.setFeeType(blankTo(req.feeType(), "其他"));
f.setAmount(Money.of(req.amount()));
f.setSpendDate(blankTo(req.spendDate(), LocalDate.now().toString()));
f.setPayee(req.payee());
f.setRemark(req.remark());
f.setCreatedAt(Instant.now());
QualDeclFee saved = feeRepo.save(f);
recomputeFeeSpent(d);
return ApiResp.ok(saved);
}
@DeleteMapping("/fees/{feeId}")
@Transactional
public ApiResp<Void> deleteFee(@PathVariable Long feeId) {
QualDeclFee f = feeRepo.findById(feeId)
.orElseThrow(() -> new NotFoundException("fee not found: " + feeId));
Long declId = f.getDeclarationId();
feeRepo.deleteById(feeId);
declRepo.findById(declId).ifPresent(this::recomputeFeeSpent);
return ApiResp.ok(null);
}
private void recomputeFeeSpent(QualDeclaration d) {
BigDecimal total = Money.ZERO;
for (QualDeclFee f : feeRepo.findByDeclarationIdOrderByIdAsc(d.getId())) {
total = Money.add(total, Money.nz(f.getAmount()));
}
d.setFeeSpent(total);
declRepo.save(d);
}
// ---------- 人员/业绩自动匹配(跨模块取数 gap 分析) ----------
public record GapResult(String targetLevel, int requiredPersons, int matchedPersons,
int personGap, int requiredPerformances, int matchedPerformances,
int performanceGap, boolean ready, List<String> matchedPersonNames,
List<String> matchedProjectNames) {
}
/**
* 按目标等级的硬性门槛,从 HR 人员证件库(personnel_cert,状态=有效 且 证件类型为注册/建造师类)
* 与项目库(project,状态=已完工/竣工)自动匹配在册可用人员与可用业绩,算出"还差多少"。
*
* 门槛口径(可后续做成可配置标准库):一级=注册人员10名/业绩6项;二级=5名/3项;
* 三级/乙级=3名/2项;其余=2名/1项。
*/
@GetMapping("/{id}/gap")
public ApiResp<GapResult> gap(@PathVariable Long id) {
QualDeclaration d = find(id);
String level = d.getTargetLevel() == null ? "" : d.getTargetLevel();
int reqPersons;
int reqPerf;
if (level.contains("一级") || level.contains("")) {
reqPersons = 10;
reqPerf = 6;
} else if (level.contains("二级")) {
reqPersons = 5;
reqPerf = 3;
} else if (level.contains("三级") || level.contains("")) {
reqPersons = 3;
reqPerf = 2;
} else {
reqPersons = 2;
reqPerf = 1;
}
// 匹配在册可用注册人员:personnel_cert 状态有效 + 证件类型属注册/建造师/职称类。
List<String> persons = new ArrayList<>();
for (PersonnelCert c : certRepo.findAll()) {
if (!"有效".equals(c.getStatus())) {
continue;
}
String t = c.getCertType() == null ? "" : c.getCertType();
boolean registerLike = t.contains("建造师") || t.contains("注册")
|| t.contains("职称") || t.contains("造价") || t.contains("结构") || t.contains("岩土");
if (registerLike && c.getPersonName() != null && !persons.contains(c.getPersonName())) {
persons.add(c.getPersonName());
}
}
// 匹配可用业绩:已完工/竣工的项目。
List<String> projects = new ArrayList<>();
for (Project p : projectRepo.findAll()) {
String st = p.getStatus() == null ? "" : p.getStatus();
if ((st.contains("完工") || st.contains("竣工") || st.contains("完成")
|| st.contains("验收")) && p.getName() != null) {
projects.add(p.getName());
}
}
int matchedPersons = persons.size();
int matchedPerf = projects.size();
int personGap = Math.max(0, reqPersons - matchedPersons);
int perfGap = Math.max(0, reqPerf - matchedPerf);
boolean ready = personGap == 0 && perfGap == 0;
return ApiResp.ok(new GapResult(level.isBlank() ? "(未设目标等级)" : level,
reqPersons, matchedPersons, personGap, reqPerf, matchedPerf, perfGap, ready,
persons, projects));
}
// ---------- helpers ----------
private QualDeclaration find(Long id) {
return declRepo.findById(id)
.orElseThrow(() -> new NotFoundException("qual declaration not found: " + id));
}
private static String blankTo(String v, String dflt) {
return v == null || v.isBlank() ? dflt : v;
}
}