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.IpAssetEvent; import com.kaidi.oa.domain.IpLicenseTransfer; import com.kaidi.oa.repository.IpAssetEventRepository; import com.kaidi.oa.repository.IpAssetRepository; import com.kaidi.oa.repository.IpLicenseTransferRepository; 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.time.Instant; import java.time.LocalDate; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; /** * 知识产权许可/转让/质押运营台账(工程管理中心·专利工法办,需求功能2·专利应用与转化)。 * 补深审计 PARTIAL:新建 IpLicenseTransfer 实体补足许可/转让/质押转化运营缺口, * 并以 ipcCode 字段补足「IPC结构化分类标签」缺口(审计指名此两项均缺失)。 * 另提供按 IPC 分类/技术领域检索接口(补深技术成果库检索)。 * 端点:/api/oa/ip-license-transfers * 写口含金额,收口 ADMIN/APPROVER(FINANCE_PREFIXES);读口同收口(SENSITIVE_READ_PREFIXES)。 */ @RestController @RequestMapping("/api/oa/ip-license-transfers") public class IpLicenseTransferController { private final IpLicenseTransferRepository ltRepo; private final IpAssetRepository assetRepo; private final IpAssetEventRepository eventRepo; private static final List OP_TYPES = List.of( "许可-独占", "许可-排他", "许可-普通", "转让", "质押"); private static final List STATUSES = List.of("洽谈中", "合同签署", "生效", "终止"); public IpLicenseTransferController(IpLicenseTransferRepository ltRepo, IpAssetRepository assetRepo, IpAssetEventRepository eventRepo) { this.ltRepo = ltRepo; this.assetRepo = assetRepo; this.eventRepo = eventRepo; } // ---------- 台账查询 ---------- @GetMapping public ApiResp> list(@RequestParam(required = false) Long ipAssetId, @RequestParam(required = false) String status, @RequestParam(required = false) String opType, @RequestParam(required = false) String ipcCode) { if (ipAssetId != null) return ApiResp.ok(ltRepo.findByIpAssetId(ipAssetId)); if (status != null && !status.isBlank()) return ApiResp.ok(ltRepo.findByStatus(status)); if (opType != null && !opType.isBlank()) return ApiResp.ok(ltRepo.findByOpType(opType)); if (ipcCode != null && !ipcCode.isBlank()) return ApiResp.ok(ltRepo.findByIpcCodeContaining(ipcCode)); return ApiResp.ok(ltRepo.findAll()); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(find(id)); } // ---------- IPC 分类与技术领域检索(补深技术成果库检索缺口) ---------- public record IpcBucket(String ipcCode, int count) { } /** 按 IPC 分类汇总统计所有知识产权运营记录(用于 IPC 标签检索与统计看板)。 */ @GetMapping("/ipc-stats") public ApiResp> ipcStats() { java.util.Map map = new java.util.LinkedHashMap<>(); for (IpLicenseTransfer lt : ltRepo.findAll()) { String code = lt.getIpcCode(); if (code == null || code.isBlank()) continue; // IPC 号只取大类(前 4 字符,如 E02D)。 String major = code.length() >= 4 ? code.substring(0, 4) : code; map.merge(major, 1, Integer::sum); } List out = new ArrayList<>(); for (java.util.Map.Entry e : map.entrySet()) { out.add(new IpcBucket(e.getKey(), e.getValue())); } out.sort((a, b) -> b.count() - a.count()); return ApiResp.ok(out); } // ---------- 到期预警 ---------- public record ExpiryAlert(Long ltId, Long ipAssetId, String ipAssetName, String opType, String counterpart, String expireDate, long daysLeft, String level) { } /** * 许可/质押协议到期预警:返回生效中且在 days 天内到期的运营记录, * 提醒续签或到期终止处理。 */ @GetMapping("/expiry-alerts") public ApiResp> expiryAlerts( @RequestParam(required = false, defaultValue = "90") int days) { LocalDate today = LocalDate.now(); List out = new ArrayList<>(); for (IpLicenseTransfer lt : ltRepo.findByStatus("生效")) { if (lt.getExpireDate() == null || lt.getExpireDate().isBlank()) continue; try { LocalDate exp = LocalDate.parse(lt.getExpireDate().trim().substring(0, 10)); long daysLeft = ChronoUnit.DAYS.between(today, exp); if (daysLeft < 0 || daysLeft > days) continue; String level = daysLeft <= 15 ? "紧急" : daysLeft <= 30 ? "临近" : "提醒"; out.add(new ExpiryAlert(lt.getId(), lt.getIpAssetId(), lt.getIpAssetName(), lt.getOpType(), lt.getCounterpart(), lt.getExpireDate(), daysLeft, level)); } catch (Exception ignored) { // 日期格式不正确,跳过。 } } out.sort((a, b) -> Long.compare(a.daysLeft(), b.daysLeft())); return ApiResp.ok(out); } // ---------- CRUD ---------- public record LtRequest(Long ipAssetId, String opType, String counterpart, String contractNo, String signDate, String effectDate, String expireDate, Double amount, Double annualFee, String relatedProject, String ipcCode, String techTags, String remark, String handler) { } /** * 新建许可/转让/质押记录:关联 IpAsset,校验运营类型合法,金额用 Money.of, * 落「运营登记」事件到知识产权事件流(审计存证)。 */ @PostMapping @Transactional public ApiResp create(@RequestBody LtRequest req) { if (req.ipAssetId() == null) { throw new ApiException(400, "ipAssetId 不能为空"); } IpAsset asset = assetRepo.findById(req.ipAssetId()) .orElseThrow(() -> new NotFoundException("知识产权不存在:" + req.ipAssetId())); if (req.opType() == null || !OP_TYPES.contains(req.opType())) { throw new ApiException(400, "未知运营类型,可选:" + String.join("/", OP_TYPES)); } IpLicenseTransfer lt = new IpLicenseTransfer(); lt.setIpAssetId(asset.getId()); lt.setIpAssetName(asset.getName()); lt.setIpAssetNo(asset.getGrantNo() != null && !asset.getGrantNo().isBlank() ? asset.getGrantNo() : asset.getApplyNo() != null ? asset.getApplyNo() : asset.getProposalNo()); applyEditable(lt, req); lt.setStatus("洽谈中"); lt.setCreatedAt(Instant.now()); lt.setUpdatedAt(Instant.now()); IpLicenseTransfer saved = ltRepo.save(lt); // 落知识产权事件流(审计存证)。 addEvent(asset.getId(), "运营登记", "opType", null, req.opType(), req.opType() + " 登记,对方主体:" + nz(req.counterpart(), ""), req.handler()); return ApiResp.ok(saved); } @PatchMapping("/{id}") @Transactional public ApiResp update(@PathVariable Long id, @RequestBody LtRequest req) { IpLicenseTransfer lt = find(id); if ("终止".equals(lt.getStatus())) { throw new ApiException(409, "已终止的运营记录不可再编辑"); } applyEditable(lt, req); lt.setUpdatedAt(Instant.now()); return ApiResp.ok(ltRepo.save(lt)); } public record StatusRequest(String toStatus, String note, String handler) { } /** 状态流转:洽谈中 → 合同签署 → 生效 → 终止。 */ @PostMapping("/{id}/advance") @Transactional public ApiResp advance(@PathVariable Long id, @RequestBody StatusRequest req) { IpLicenseTransfer lt = find(id); String to = req.toStatus() == null ? "" : req.toStatus().trim(); if (!STATUSES.contains(to)) { throw new ApiException(400, "未知状态:" + to + ",可选:" + String.join("/", STATUSES)); } int fromIdx = STATUSES.indexOf(lt.getStatus()); int toIdx = STATUSES.indexOf(to); if (toIdx != fromIdx + 1 && toIdx != STATUSES.size() - 1) { throw new ApiException(409, "非法状态跳转:" + lt.getStatus() + " → " + to); } String from = lt.getStatus(); lt.setStatus(to); if ("生效".equals(to) && (lt.getEffectDate() == null || lt.getEffectDate().isBlank())) { lt.setEffectDate(LocalDate.now().toString()); } lt.setUpdatedAt(Instant.now()); ltRepo.save(lt); addEvent(lt.getIpAssetId(), "运营状态变更", "status", from, to, nz(req.note(), lt.getOpType() + " 状态推进:" + from + " → " + to), req.handler()); return ApiResp.ok(lt); } @DeleteMapping("/{id}") @Transactional public ApiResp delete(@PathVariable Long id) { IpLicenseTransfer lt = find(id); if ("生效".equals(lt.getStatus())) { throw new ApiException(409, "生效中的运营协议不可删除,请先终止再操作"); } ltRepo.deleteById(id); return ApiResp.ok(null); } // ---------- helpers ---------- private IpLicenseTransfer find(Long id) { return ltRepo.findById(id).orElseThrow(() -> new NotFoundException("运营记录不存在:" + id)); } private void applyEditable(IpLicenseTransfer lt, LtRequest req) { if (req.opType() != null && OP_TYPES.contains(req.opType())) lt.setOpType(req.opType()); if (req.counterpart() != null) lt.setCounterpart(req.counterpart()); if (req.contractNo() != null) lt.setContractNo(req.contractNo()); if (req.signDate() != null) lt.setSignDate(req.signDate()); if (req.effectDate() != null) lt.setEffectDate(req.effectDate()); if (req.expireDate() != null) lt.setExpireDate(req.expireDate()); if (req.amount() != null) lt.setAmount(Money.of(req.amount())); if (req.annualFee() != null) lt.setAnnualFee(Money.of(req.annualFee())); if (req.relatedProject() != null) lt.setRelatedProject(req.relatedProject()); if (req.ipcCode() != null) lt.setIpcCode(req.ipcCode()); if (req.techTags() != null) lt.setTechTags(req.techTags()); if (req.remark() != null) lt.setRemark(req.remark()); if (req.handler() != null) lt.setHandler(req.handler()); } private IpAssetEvent addEvent(Long assetId, String type, String field, String from, String to, String note, String operator) { IpAssetEvent ev = new IpAssetEvent(); ev.setIpAssetId(assetId); ev.setEventType(type); ev.setField(field); ev.setFromValue(from); ev.setToValue(to); ev.setNote(note); ev.setOperator(operator); ev.setCreatedAt(Instant.now()); return eventRepo.save(ev); } private static String nz(String s, String def) { return (s == null || s.isBlank()) ? def : s; } }