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

266 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.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/APPROVERFINANCE_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<String> OP_TYPES = List.of(
"许可-独占", "许可-排他", "许可-普通", "转让", "质押");
private static final List<String> STATUSES = List.of("洽谈中", "合同签署", "生效", "终止");
public IpLicenseTransferController(IpLicenseTransferRepository ltRepo,
IpAssetRepository assetRepo,
IpAssetEventRepository eventRepo) {
this.ltRepo = ltRepo;
this.assetRepo = assetRepo;
this.eventRepo = eventRepo;
}
// ---------- 台账查询 ----------
@GetMapping
public ApiResp<List<IpLicenseTransfer>> 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<IpLicenseTransfer> get(@PathVariable Long id) {
return ApiResp.ok(find(id));
}
// ---------- IPC 分类与技术领域检索(补深技术成果库检索缺口) ----------
public record IpcBucket(String ipcCode, int count) {
}
/** 按 IPC 分类汇总统计所有知识产权运营记录(用于 IPC 标签检索与统计看板)。 */
@GetMapping("/ipc-stats")
public ApiResp<List<IpcBucket>> ipcStats() {
java.util.Map<String, Integer> 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<IpcBucket> out = new ArrayList<>();
for (java.util.Map.Entry<String, Integer> 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<List<ExpiryAlert>> expiryAlerts(
@RequestParam(required = false, defaultValue = "90") int days) {
LocalDate today = LocalDate.now();
List<ExpiryAlert> 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<IpLicenseTransfer> 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<IpLicenseTransfer> 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<IpLicenseTransfer> 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<Void> 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;
}
}