Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/TechContractController.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

274 lines
12 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.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);
}
}