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 REG_NEXT = Map.of( "未登记", "登记中", "登记中", "已登记"); // ---------- 台账 CRUD ---------- @GetMapping public ApiResp> 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 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 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 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 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 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 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 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 byType, List byStatus) { } /** 技术交易统计:总技术金额/合同数/已登记数 + 按合同类型 + 按登记状态汇总。 */ @GetMapping("/stats") public ApiResp stats() { List all = repo.findAll(); BigDecimal total = Money.ZERO; int registered = 0; Map typeAmt = new LinkedHashMap<>(); Map typeCnt = new LinkedHashMap<>(); Map typeReg = new LinkedHashMap<>(); Map statAmt = new LinkedHashMap<>(); Map 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 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 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); } }