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,273 @@
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.IpAsset;
import com.kaidi.oa.domain.RdProject;
import com.kaidi.oa.domain.TechContract;
import com.kaidi.oa.repository.IpAssetRepository;
import com.kaidi.oa.repository.RdProjectRepository;
import com.kaidi.oa.repository.TechContractRepository;
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.time.Year;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 技术合同登记(知识产权部,需求功能10 — 补深 PARTIAL)。
*
* 补深三项审计缺口:
* (1) 技术合同专用字段(技术金额/登记状态/买卖方/合同类型)+ 登记号;
* (2) 自动生成登记所需材料(创新技术方案 + 承诺书)——generateMaterials 端点按合同要素套模板生成;
* (3) 合同与研发立项/知识产权产出关联 + 技术交易统计分析(stats:按类型/登记状态汇总技术金额)。
*
* 登记状态机:未登记 → 登记中 → 已登记 → 登记失效;进入「已登记」自动生成登记号。
*
* 写口含金额,收 ADMIN/APPROVERFINANCE_PREFIXES);读口含技术金额聚合,收敏感读门槛
* SENSITIVE_READ_PREFIXES)——见 sharedFileSnippets。
*/
@RestController
@RequestMapping("/api/oa/tech-contracts")
public class TechContractController {
private final TechContractRepository repo;
private final RdProjectRepository projectRepo;
private final IpAssetRepository ipRepo;
public TechContractController(TechContractRepository repo,
RdProjectRepository projectRepo,
IpAssetRepository ipRepo) {
this.repo = repo;
this.projectRepo = projectRepo;
this.ipRepo = ipRepo;
}
private static final Map<String, String> REG_NEXT = Map.of(
"未登记", "登记中",
"登记中", "已登记");
// ---------- 台账 CRUD ----------
@GetMapping
public ApiResp<List<TechContract>> list(@RequestParam(required = false) String registerStatus,
@RequestParam(required = false) String contractType,
@RequestParam(required = false) Long rdProjectId) {
if (registerStatus != null && !registerStatus.isBlank()) {
return ApiResp.ok(repo.findByRegisterStatus(registerStatus));
}
if (contractType != null && !contractType.isBlank()) {
return ApiResp.ok(repo.findByContractType(contractType));
}
if (rdProjectId != null) {
return ApiResp.ok(repo.findByRdProjectId(rdProjectId));
}
return ApiResp.ok(repo.findAll());
}
@GetMapping("/{id}")
public ApiResp<TechContract> get(@PathVariable Long id) {
return ApiResp.ok(find(id));
}
public record TcRequest(String projectName, String contractType, String buyer, String seller,
Double techAmount, String signDate, Long rdProjectId, Long ipAssetId,
String owner, String remark) {
}
@PostMapping
@Transactional
public ApiResp<TechContract> create(@RequestBody TcRequest req) {
if (req.projectName() == null || req.projectName().isBlank()) {
throw new ApiException(400, "项目名称不能为空");
}
TechContract c = new TechContract();
c.setCode(nextCode());
apply(c, req);
c.setRegisterStatus("未登记");
c.setCreatedAt(Instant.now());
c.setUpdatedAt(Instant.now());
return ApiResp.ok(repo.save(c));
}
@PatchMapping("/{id}")
@Transactional
public ApiResp<TechContract> update(@PathVariable Long id, @RequestBody TcRequest req) {
TechContract c = find(id);
if ("已登记".equals(c.getRegisterStatus()) || "登记失效".equals(c.getRegisterStatus())) {
throw new ApiException(409, "已登记/登记失效的技术合同不可直接编辑");
}
apply(c, req);
c.setUpdatedAt(Instant.now());
return ApiResp.ok(repo.save(c));
}
@DeleteMapping("/{id}")
@Transactional
public ApiResp<Void> delete(@PathVariable Long id) {
TechContract c = find(id);
if ("已登记".equals(c.getRegisterStatus())) {
throw new ApiException(409, "已登记的技术合同不可删除(登记证据),请走登记失效");
}
repo.deleteById(id);
return ApiResp.ok(null);
}
// ---------- 登记状态机 ----------
public record AdvanceRequest(String note) {
}
/** 推进登记状态:未登记→登记中→已登记。进入「已登记」自动生成登记号。 */
@PostMapping("/{id}/advance")
@Transactional
public ApiResp<TechContract> advance(@PathVariable Long id, @RequestBody AdvanceRequest req) {
TechContract c = find(id);
String next = REG_NEXT.get(c.getRegisterStatus());
if (next == null) {
throw new ApiException(409, "当前登记状态「" + c.getRegisterStatus() + "」无可推进的下一状态");
}
c.setRegisterStatus(next);
if ("已登记".equals(next) && (c.getRegisterNo() == null || c.getRegisterNo().isBlank())) {
c.setRegisterNo("JSHT-" + Year.now().getValue() + "-" + String.format("%05d", c.getId()));
}
c.setUpdatedAt(Instant.now());
return ApiResp.ok(repo.save(c));
}
@PostMapping("/{id}/invalidate")
@Transactional
public ApiResp<TechContract> invalidate(@PathVariable Long id, @RequestBody AdvanceRequest req) {
TechContract c = find(id);
c.setRegisterStatus("登记失效");
c.setUpdatedAt(Instant.now());
return ApiResp.ok(repo.save(c));
}
// ---------- 自动生成登记材料 ----------
/**
* 自动生成登记所需材料(创新技术方案 + 承诺书):按合同要素套用模板填充并回写。
* 幂等可重复生成(覆盖最新值)。
*/
@PostMapping("/{id}/generate-materials")
@Transactional
public ApiResp<TechContract> generateMaterials(@PathVariable Long id) {
TechContract c = find(id);
String seller = c.getSeller() == null ? "(卖方)" : c.getSeller();
String buyer = c.getBuyer() == null ? "(买方)" : c.getBuyer();
String amount = Money.nz(c.getTechAmount()).toPlainString();
c.setTechScheme("创新技术方案\n\n一、项目名称:" + c.getProjectName()
+ "\n二、合同类型:" + (c.getContractType() == null ? "技术开发" : c.getContractType())
+ "\n三、技术供需双方:卖方 " + seller + ";买方 " + buyer
+ "\n四、技术金额:人民币 " + amount + ""
+ "\n五、关联研发立项:" + (c.getRdProjectName() == null ? "" : c.getRdProjectName())
+ "\n六、关联知识产权:" + (c.getIpAssetName() == null ? "" : c.getIpAssetName())
+ "\n七、技术内容与创新点:(由起草人补充技术路线、关键指标、创新性说明)");
c.setCommitmentLetter("承诺书\n\n本单位(" + seller + ")就技术合同《" + c.getProjectName()
+ "》郑重承诺:合同所涉技术成果系本单位自主研发,不侵犯第三方知识产权;"
+ "技术资料真实有效,技术指标可达成;如有不实,愿承担相应法律责任。\n\n"
+ "承诺单位:" + seller + "\n日期:" + LocalDate.now());
c.setUpdatedAt(Instant.now());
return ApiResp.ok(repo.save(c));
}
// ---------- 技术交易统计分析 ----------
public record TypeStat(String key, double amount, int count, int registered) {
}
public record TechStats(double totalAmount, int total, int registered,
List<TypeStat> byType, List<TypeStat> byStatus) {
}
/** 技术交易统计:总技术金额/合同数/已登记数 + 按合同类型 + 按登记状态汇总。 */
@GetMapping("/stats")
public ApiResp<TechStats> stats() {
List<TechContract> all = repo.findAll();
BigDecimal total = Money.ZERO;
int registered = 0;
Map<String, BigDecimal> typeAmt = new LinkedHashMap<>();
Map<String, Integer> typeCnt = new LinkedHashMap<>();
Map<String, Integer> typeReg = new LinkedHashMap<>();
Map<String, BigDecimal> statAmt = new LinkedHashMap<>();
Map<String, Integer> statCnt = new LinkedHashMap<>();
for (TechContract c : all) {
BigDecimal amt = Money.nz(c.getTechAmount());
total = Money.add(total, amt);
boolean reg = "已登记".equals(c.getRegisterStatus());
if (reg) {
registered++;
}
String t = c.getContractType() == null || c.getContractType().isBlank() ? "其他" : c.getContractType();
typeAmt.merge(t, amt, Money::add);
typeCnt.merge(t, 1, Integer::sum);
typeReg.merge(t, reg ? 1 : 0, Integer::sum);
String st = c.getRegisterStatus() == null ? "未登记" : c.getRegisterStatus();
statAmt.merge(st, amt, Money::add);
statCnt.merge(st, 1, Integer::sum);
}
List<TypeStat> byType = new ArrayList<>();
for (String t : typeAmt.keySet()) {
byType.add(new TypeStat(t, typeAmt.get(t).doubleValue(), typeCnt.get(t), typeReg.getOrDefault(t, 0)));
}
List<TypeStat> byStatus = new ArrayList<>();
for (String st : statAmt.keySet()) {
byStatus.add(new TypeStat(st, statAmt.get(st).doubleValue(), statCnt.get(st), 0));
}
return ApiResp.ok(new TechStats(total.doubleValue(), all.size(), registered, byType, byStatus));
}
// ---------- helpers ----------
private TechContract find(Long id) {
return repo.findById(id)
.orElseThrow(() -> new NotFoundException("技术合同不存在:" + id));
}
private void apply(TechContract c, TcRequest req) {
if (req.projectName() != null && !req.projectName().isBlank()) c.setProjectName(req.projectName());
if (req.contractType() != null) c.setContractType(req.contractType());
if (req.buyer() != null) c.setBuyer(req.buyer());
if (req.seller() != null) c.setSeller(req.seller());
if (req.techAmount() != null) c.setTechAmount(Money.of(req.techAmount()));
if (req.signDate() != null) c.setSignDate(req.signDate());
if (req.owner() != null) c.setOwner(req.owner());
if (req.remark() != null) c.setRemark(req.remark());
if (req.rdProjectId() != null) {
c.setRdProjectId(req.rdProjectId());
RdProject p = projectRepo.findById(req.rdProjectId()).orElse(null);
c.setRdProjectName(p == null ? null : p.getName());
}
if (req.ipAssetId() != null) {
c.setIpAssetId(req.ipAssetId());
IpAsset a = ipRepo.findById(req.ipAssetId()).orElse(null);
c.setIpAssetName(a == null ? null : a.getName());
}
if (c.getContractType() == null || c.getContractType().isBlank()) {
c.setContractType("技术开发");
}
}
private String nextCode() {
String prefix = "TC-" + Year.now().getValue() + "-";
long n = repo.countByCodeStartingWith(prefix) + 1;
return prefix + String.format("%04d", n);
}
}