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:
Qiufeng
2026-06-15 19:19:15 +08:00
co-authored by Claude Opus 4.8
commit 5e51dc3f56
10584 changed files with 2501339 additions and 0 deletions
@@ -0,0 +1,265 @@
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;
}
}