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:
@@ -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/APPROVER(FINANCE_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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user