恢复点(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>
266 lines
12 KiB
Java
266 lines
12 KiB
Java
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<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;
|
||
}
|
||
}
|