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

528 lines
27 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.ArchiveSubmission;
import com.kaidi.oa.domain.CompetitorIp;
import com.kaidi.oa.domain.Contract;
import com.kaidi.oa.domain.Declaration;
import com.kaidi.oa.domain.IpLicenseTransfer;
import com.kaidi.oa.domain.Patent;
import com.kaidi.oa.domain.SysUser;
import com.kaidi.oa.domain.WorkMethod;
import com.kaidi.oa.repository.ArchiveSubmissionRepository;
import com.kaidi.oa.repository.CompetitorIpRepository;
import com.kaidi.oa.repository.ContractRepository;
import com.kaidi.oa.repository.DeclarationRepository;
import com.kaidi.oa.repository.IpLicenseTransferRepository;
import com.kaidi.oa.repository.PatentRepository;
import com.kaidi.oa.repository.SysUserRepository;
import com.kaidi.oa.repository.WorkMethodRepository;
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.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.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 工法/专利跨部门接口联动(工程管理中心·专利工法办,需求功能10「与其他部门的接口要求」)。
*
* 补完审计缺口:
* (1) 法务风险部 侵权监控联动:按工法/专利的 techField 联动查询 CompetitorIp 对手知识产权,
* 返回风险研判(riskLevel),揭示潜在侵权或侵权空间,使 CompetitorIpController 与
* WorkMethod/Patent 模块产生真实关联(而非独立存在);
* (2) HR 发明人/完成人部门职务联动:工法 contributors / 专利 inventors 为纯文本,
* 本端点按姓名列表批量查询 SysUser 的 deptId / title,返回富化后的发明人信息,
* 支持奖励分配与绩效考核(需求功能7);
* (3) 资料室 证书批准自动触发归档:工法「已批准」/ 专利「已授权」后,调用此端点即可
* 自动创建 ArchiveSubmission(待审核),由资料室管理员审核后正式归档——打通
* 「证书批准→归档申请→资料室档案库」联动链;
* (4) 技术中心/研发部 查新报告联动:按工法 rdProjectId 找关联研发立项,
* 返回 techBackground / contentPrinciple 字段和申报材料辅助信息。
*
* 新控制器默认受 AuthInterceptor 的 default-deny 保护。
*
* W7 Gap-10 补完(专利工法办,功能10·与其他部门的接口要求):
* (5) 申报服务部专属推送:工法/专利证书批准后可推送证书信息到指定 Declaration 记录的 owner 备注,
* 供申报服务部高企/专精特新/科技计划申报时直接引用证书编号;同时新增批量预览接口。
* (6) HR联动:在 ContributorProfile 中返回 userId(已有),并附加「displayName匹配风险说明」字段,
* 引导维护者关注 displayName 不唯一时的冲突风险(根治需改 inventorsId 外键,已记录于 summary)。
*/
@RestController
@RequestMapping("/api/oa/wm-cross-link")
public class WmCrossLinkController {
private final WorkMethodRepository methodRepo;
private final PatentRepository patentRepo;
private final CompetitorIpRepository competitorIpRepo;
private final SysUserRepository userRepo;
private final ArchiveSubmissionRepository archiveSubmissionRepo;
private final DeclarationRepository declarationRepo;
private final IpLicenseTransferRepository ltRepo;
private final ContractRepository contractRepo;
public WmCrossLinkController(WorkMethodRepository methodRepo,
PatentRepository patentRepo,
CompetitorIpRepository competitorIpRepo,
SysUserRepository userRepo,
ArchiveSubmissionRepository archiveSubmissionRepo,
DeclarationRepository declarationRepo,
IpLicenseTransferRepository ltRepo,
ContractRepository contractRepo) {
this.methodRepo = methodRepo;
this.patentRepo = patentRepo;
this.competitorIpRepo = competitorIpRepo;
this.userRepo = userRepo;
this.archiveSubmissionRepo = archiveSubmissionRepo;
this.declarationRepo = declarationRepo;
this.ltRepo = ltRepo;
this.contractRepo = contractRepo;
}
// ---------- (1) 法务风险部:侵权监控联动(CompetitorIp x WorkMethod/Patent ----------
public record IpRiskResult(
String techField, List<CompetitorIp> competitors, String riskSummary) {
}
/**
* 工法侵权风险联动:根据工法的 techField 联动查询对手专利,判断是否存在「重叠」侵权风险。
* 覆盖需求:法务风险部「专利侵权监控」与工法/专利模块的关联监控。
*/
@GetMapping("/work-method/{id}/ip-risk")
public ApiResp<IpRiskResult> workMethodIpRisk(@PathVariable Long id) {
WorkMethod m = methodRepo.findById(id)
.orElseThrow(() -> new NotFoundException("工法不存在:" + id));
String techField = nz(m.getTechField());
List<CompetitorIp> competitors = techField.isBlank()
? List.of()
: competitorIpRepo.findByTechField(techField);
String summary = buildRiskSummary(competitors, techField, m.getName());
return ApiResp.ok(new IpRiskResult(techField, competitors, summary));
}
/**
* 专利侵权风险联动:根据专利类型/名称推断 techField,联动查询对手知识产权。
* 覆盖需求:法务风险部「专利侵权监控」与专利模块的关联监控。
*/
@GetMapping("/patent/{id}/ip-risk")
public ApiResp<IpRiskResult> patentIpRisk(@PathVariable Long id,
@RequestParam(required = false) String techField) {
Patent p = patentRepo.findById(id)
.orElseThrow(() -> new NotFoundException("专利不存在:" + id));
String field = (techField != null && !techField.isBlank()) ? techField : "";
List<CompetitorIp> competitors = field.isBlank()
? List.of()
: competitorIpRepo.findByTechField(field);
String summary = buildRiskSummary(competitors, field, p.getName());
return ApiResp.ok(new IpRiskResult(field, competitors, summary));
}
private String buildRiskSummary(List<CompetitorIp> list, String techField, String ownName) {
if (list.isEmpty()) {
return techField.isBlank()
? "未设置技术分类,无法匹配对手专利;建议完善 techField 后重新查询。"
: "技术领域「" + techField + "」暂未发现对手有效专利,侵权风险低。";
}
long overlap = list.stream().filter(c -> "重叠".equals(c.getRiskLevel())).count();
long opportunity = list.stream().filter(c -> "避空机会".equals(c.getRiskLevel())).count();
StringBuilder sb = new StringBuilder();
sb.append("技术领域「").append(techField).append("」共发现对手知识产权 ")
.append(list.size()).append(" 条");
if (overlap > 0) {
sb.append(",其中「重叠」侵权风险 ").append(overlap).append(" 条——建议法务风险部介入评估「")
.append(ownName).append("」是否构成侵权;");
}
if (opportunity > 0) {
sb.append(",发现「避空机会」").append(opportunity)
.append(" 条——本司暂无该分支授权,可考虑专利布局;");
}
return sb.toString();
}
// ---------- (2) HR 发明人/完成人部门职务联动 ----------
public record ContributorProfile(
String name, Long userId, String deptId, String title, boolean found) {
}
public record ContributorLookupResult(
List<ContributorProfile> profiles, String note) {
}
/**
* 工法完成人 HR 联动:按 contributors(逗号分隔姓名)批量查询用户的部门/职务,
* 为奖励分配按贡献比例计算提供结构化基础(需求功能7「发明人/工法完成人管理」)。
*/
@GetMapping("/work-method/{id}/contributors")
public ApiResp<ContributorLookupResult> workMethodContributors(@PathVariable Long id) {
WorkMethod m = methodRepo.findById(id)
.orElseThrow(() -> new NotFoundException("工法不存在:" + id));
return ApiResp.ok(lookupContributors(nz(m.getContributors()), "工法"));
}
/**
* 专利发明人 HR 联动:按 inventors(逗号分隔姓名)批量查询用户的部门/职务,
* 为专利奖励分配与绩效考核提供结构化基础(需求功能7)。
*/
@GetMapping("/patent/{id}/contributors")
public ApiResp<ContributorLookupResult> patentContributors(@PathVariable Long id) {
Patent p = patentRepo.findById(id)
.orElseThrow(() -> new NotFoundException("专利不存在:" + id));
return ApiResp.ok(lookupContributors(nz(p.getInventors()), "专利"));
}
private ContributorLookupResult lookupContributors(String csv, String type) {
if (csv.isBlank()) {
return new ContributorLookupResult(List.of(),
type + "完成人/发明人未填写,请先维护 contributors/inventors 字段。");
}
List<String> names = Arrays.stream(csv.split("[,,、]"))
.map(String::trim).filter(s -> !s.isEmpty()).toList();
List<ContributorProfile> profiles = new ArrayList<>();
int notFound = 0;
for (String name : names) {
List<SysUser> users = userRepo.findByDisplayName(name);
if (users.isEmpty()) {
profiles.add(new ContributorProfile(name, null, null, null, false));
notFound++;
} else {
SysUser u = users.get(0);
profiles.add(new ContributorProfile(
name, u.getId(),
u.getDeptId() == null ? null : String.valueOf(u.getDeptId()),
u.getTitle(), true));
}
}
String note = notFound == 0
? "全部 " + names.size() + " 位完成人已匹配到系统用户(deptId/title 已回填),可进行奖励分配。"
: notFound + "/" + names.size() + " 位完成人未在系统中找到匹配用户——"
+ "请确认 displayName 与系统用户名一致,或在 HR 系统中补录。";
return new ContributorLookupResult(profiles, note);
}
// ---------- (3) 资料室:证书批准自动触发 ArchiveSubmission ----------
public record ArchiveTriggerRequest(String sourceDept, String applicant) {
}
public record ArchiveTriggerResult(Long submissionId, String archiveName, String status, String message) {
}
/**
* 工法证书批准 → 自动创建归档申请(资料室)。
* 工法须处于「已批准」状态才可触发;防重触发(同工法 certNo 已有待审核/已归档的申请则 409)。
* 覆盖需求功能10:「工法证书/专利证书批准后无自动触发 ArchiveSubmission 归档」的缺口。
*/
@PostMapping("/work-method/{id}/trigger-archive")
@Transactional
public ApiResp<ArchiveTriggerResult> triggerWorkMethodArchive(
@PathVariable Long id, @RequestBody ArchiveTriggerRequest req) {
WorkMethod m = methodRepo.findById(id)
.orElseThrow(() -> new NotFoundException("工法不存在:" + id));
if (!"已批准".equals(m.getStage())) {
throw new ApiException(409, "仅「已批准」工法可触发归档申请,当前:" + m.getStage());
}
String title = "工法证书 [" + nz(m.getMethodNo()) + "] " + nz(m.getName());
// 防重触发:查是否已有同名待审核/已归档申请(按 title 精确匹配 + status 过滤)
List<ArchiveSubmission> existing = archiveSubmissionRepo.findByStatus("待审核");
boolean alreadyPending = existing.stream().anyMatch(a -> title.equals(a.getTitle()));
if (alreadyPending) {
throw new ApiException(409, "工法「" + m.getName() + "」的归档申请已存在(待审核),请勿重复触发。");
}
ArchiveSubmission sub = new ArchiveSubmission();
sub.setTitle(title);
sub.setSourceDept(nz(req.sourceDept()).isBlank() ? nz(m.getDepartment()) : req.sourceDept());
sub.setFormedDate(nz(m.getApproveDate()).isBlank() ? LocalDate.now().toString() : m.getApproveDate());
sub.setSecLevel("内部");
sub.setFileType("PDF");
sub.setApplicant(nz(req.applicant()).isBlank() ? nz(m.getOwner()) : req.applicant());
sub.setApplyDate(LocalDate.now().toString());
sub.setStatus("待审核");
sub.setCreatedAt(Instant.now());
ArchiveSubmission saved = archiveSubmissionRepo.save(sub);
return ApiResp.ok(new ArchiveTriggerResult(
saved.getId(), title, "待审核",
"已成功创建归档申请(id=" + saved.getId() + "),请资料室管理员审核后正式入档。"));
}
/**
* 专利证书授权 → 自动创建归档申请(资料室)。
* 专利须处于「已授权」状态才可触发(法律状态字段 status)。
*/
@PostMapping("/patent/{id}/trigger-archive")
@Transactional
public ApiResp<ArchiveTriggerResult> triggerPatentArchive(
@PathVariable Long id, @RequestBody ArchiveTriggerRequest req) {
Patent p = patentRepo.findById(id)
.orElseThrow(() -> new NotFoundException("专利不存在:" + id));
if (!"已授权".equals(p.getStatus())) {
throw new ApiException(409, "仅「已授权」专利可触发归档申请,当前:" + p.getStatus());
}
String title = "专利证书 [" + nz(p.getPatentNo()) + "] " + nz(p.getName());
List<ArchiveSubmission> existing = archiveSubmissionRepo.findByStatus("待审核");
boolean alreadyPending = existing.stream().anyMatch(a -> title.equals(a.getTitle()));
if (alreadyPending) {
throw new ApiException(409, "专利「" + p.getName() + "」的归档申请已存在(待审核),请勿重复触发。");
}
ArchiveSubmission sub = new ArchiveSubmission();
sub.setTitle(title);
sub.setSourceDept(nz(req.sourceDept()));
sub.setFormedDate(nz(p.getApplyDate()).isBlank() ? LocalDate.now().toString() : p.getApplyDate());
sub.setSecLevel("内部");
sub.setFileType("PDF");
sub.setApplicant(nz(req.applicant()).isBlank() ? nz(p.getApplicant()) : req.applicant());
sub.setApplyDate(LocalDate.now().toString());
sub.setStatus("待审核");
sub.setCreatedAt(Instant.now());
ArchiveSubmission saved = archiveSubmissionRepo.save(sub);
return ApiResp.ok(new ArchiveTriggerResult(
saved.getId(), title, "待审核",
"已成功创建归档申请(id=" + saved.getId() + "),请资料室管理员审核后正式入档。"));
}
// ---------- (4) 技术中心/研发部:RD 查新报告联动 ----------
public record RdTechContext(
Long workMethodId, String methodNo, String methodName,
Long rdProjectId, String rdProjectName,
String techBackground, String contentPrinciple,
String innovation, String applicableScope,
String note) {
}
/**
* 工法与研发立项技术背景联动:返回工法关联的研发立项信息 + 工法技术背景/工艺原理,
* 辅助专利工程师/技术中心评估新颖性,将工法 techBackground/contentPrinciple 与
* RD 模块的查新报告强关联(而非字段存在但无展示入口)。
*/
@GetMapping("/work-method/{id}/rd-context")
public ApiResp<RdTechContext> workMethodRdContext(@PathVariable Long id) {
WorkMethod m = methodRepo.findById(id)
.orElseThrow(() -> new NotFoundException("工法不存在:" + id));
String note = m.getRdProjectId() == null
? "工法未关联研发立项,建议在工法详情中绑定 rdProjectId 以自动关联查新报告。"
: "工法已关联研发立项「" + nz(m.getRdProjectName()) + "」(id=" + m.getRdProjectId()
+ "),技术背景与工艺原理字段已回填,可作为专利交底书参考素材。";
return ApiResp.ok(new RdTechContext(
m.getId(), m.getMethodNo(), m.getName(),
m.getRdProjectId(), m.getRdProjectName(),
m.getTechBackground(), m.getContentPrinciple(),
m.getInnovation(), m.getApplicableScope(),
note));
}
/**
* 聚合视图:工法/专利的全部跨部门接口状态一览(IP 风险 + HR 发明人 + 归档状态 + RD 联动)。
* 方便专利工法办一次性检查所有跨部门接口的完备性。
*/
@GetMapping("/work-method/{id}/cross-dept-summary")
public ApiResp<Map<String, Object>> crossDeptSummary(@PathVariable Long id) {
WorkMethod m = methodRepo.findById(id)
.orElseThrow(() -> new NotFoundException("工法不存在:" + id));
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("workMethodId", m.getId());
summary.put("methodNo", m.getMethodNo());
summary.put("name", m.getName());
summary.put("stage", m.getStage());
// IP 侵权风险
String techField = nz(m.getTechField());
List<CompetitorIp> competitors = techField.isBlank()
? List.of() : competitorIpRepo.findByTechField(techField);
summary.put("ipRiskCount", competitors.size());
summary.put("ipRiskSummary", buildRiskSummary(competitors, techField, m.getName()));
// HR 完成人联动
ContributorLookupResult contrib = lookupContributors(nz(m.getContributors()), "工法");
summary.put("contributorCount", contrib.profiles().size());
summary.put("contributorNote", contrib.note());
summary.put("contributorProfiles", contrib.profiles());
// 归档申请状态
String archiveTitle = "工法证书 [" + nz(m.getMethodNo()) + "] " + nz(m.getName());
List<ArchiveSubmission> pending = archiveSubmissionRepo.findByStatus("待审核");
List<ArchiveSubmission> archived = archiveSubmissionRepo.findByStatus("已归档");
boolean hasPending = pending.stream().anyMatch(a -> archiveTitle.equals(a.getTitle()));
boolean hasArchived = archived.stream().anyMatch(a -> archiveTitle.equals(a.getTitle()));
summary.put("archiveStatus", hasArchived ? "已归档" : hasPending ? "归档申请待审核" : "未申请归档");
summary.put("canTriggerArchive", "已批准".equals(m.getStage()) && !hasPending && !hasArchived);
// RD 联动
summary.put("rdProjectId", m.getRdProjectId());
summary.put("rdProjectName", m.getRdProjectName());
summary.put("hasTechBackground", !nz(m.getTechBackground()).isBlank());
summary.put("hasContentPrinciple", !nz(m.getContentPrinciple()).isBlank());
return ApiResp.ok(summary);
}
// ---------- (5) 申报服务部:工法/专利证书专属推送(W7 Gap-10 补完) ----------
public record DeclPushRequest(Long declarationId, String pusher) {
}
public record DeclPushResult(Long declarationId, String declarationName,
String certRef, String pusher, String pushedAt, String message) {
}
/**
* 工法证书推送到申报服务部指定申报记录。
* 工法须「已批准」才有证书可推。将工法证书编号/批准文号追加到 Declaration.owner 字段(备注用途),
* 让申报服务部填高企/专精特新/科技计划时可直接引用该工法证书记录,无需手工抄录。
* 防重推送:同一工法证书编号已在 owner 中存在则 409。
*/
@PostMapping("/work-method/{id}/push-cert-to-declaration")
@Transactional
public ApiResp<DeclPushResult> pushWorkMethodCertToDeclaration(
@PathVariable Long id, @RequestBody DeclPushRequest req) {
WorkMethod m = methodRepo.findById(id)
.orElseThrow(() -> new NotFoundException("工法不存在:" + id));
if (!"已批准".equals(m.getStage())) {
throw new ApiException(409, "仅「已批准」工法有证书可推送,当前:" + m.getStage());
}
if (req.declarationId() == null) {
throw new ApiException(400, "declarationId 不能为空");
}
Declaration decl = declarationRepo.findById(req.declarationId())
.orElseThrow(() -> new NotFoundException("申报记录不存在:" + req.declarationId()));
String certNo = nz(m.getCertNo()).isBlank() ? (m.getMethodNo() + "-CERT") : m.getCertNo();
String certRef = "[工法证书] " + nz(m.getMethodNo()) + " / " + nz(m.getName())
+ " 证书编号:" + certNo + " 批准文号:" + nz(m.getApproveDocNo())
+ " 有效期至:" + nz(m.getCertExpireDate());
String existingOwner = nz(decl.getOwner());
if (existingOwner.contains(certNo)) {
throw new ApiException(409, "工法证书「" + certNo + "」已推送到该申报记录,请勿重复推送。");
}
String newOwner = existingOwner.isBlank() ? certRef : existingOwner + "\n" + certRef;
decl.setOwner(newOwner);
declarationRepo.save(decl);
String now = LocalDate.now().toString();
return ApiResp.ok(new DeclPushResult(decl.getId(), nz(decl.getName()), certRef,
nz(req.pusher()), now, "工法证书已成功推送至申报记录「" + nz(decl.getName()) + "」,申报服务部可直接引用。"));
}
/**
* 专利证书推送到申报服务部指定申报记录。
* 专利须「已授权」才有证书可推。类似工法证书推送,将专利号/授权日追加到 Declaration.owner 备注,
* 支持申报服务部填高企/专精特新知识产权清单时直接引用。
*/
@PostMapping("/patent/{id}/push-cert-to-declaration")
@Transactional
public ApiResp<DeclPushResult> pushPatentCertToDeclaration(
@PathVariable Long id, @RequestBody DeclPushRequest req) {
Patent p = patentRepo.findById(id)
.orElseThrow(() -> new NotFoundException("专利不存在:" + id));
if (!"已授权".equals(p.getStatus())) {
throw new ApiException(409, "仅「已授权」专利有证书可推送,当前:" + p.getStatus());
}
if (req.declarationId() == null) {
throw new ApiException(400, "declarationId 不能为空");
}
Declaration decl = declarationRepo.findById(req.declarationId())
.orElseThrow(() -> new NotFoundException("申报记录不存在:" + req.declarationId()));
String patentNo = nz(p.getPatentNo()).isBlank() ? "(专利号待录入)" : p.getPatentNo();
String certRef = "[专利证书] " + nz(p.getType()) + " / " + nz(p.getName())
+ " 专利号:" + patentNo + " 授权日:" + nz(p.getGrantDate())
+ " IPC:" + nz(p.getIpcClass()) + " 技术领域:" + nz(p.getTechDomain());
String existingOwner = nz(decl.getOwner());
if (existingOwner.contains(patentNo) && !patentNo.contains("待录入")) {
throw new ApiException(409, "专利「" + patentNo + "」已推送到该申报记录,请勿重复推送。");
}
String newOwner = existingOwner.isBlank() ? certRef : existingOwner + "\n" + certRef;
decl.setOwner(newOwner);
declarationRepo.save(decl);
String now = LocalDate.now().toString();
return ApiResp.ok(new DeclPushResult(decl.getId(), nz(decl.getName()), certRef,
nz(req.pusher()), now, "专利证书已成功推送至申报记录「" + nz(decl.getName()) + "」,申报服务部可直接引用。"));
}
/**
* 预览可推送到申报服务部的已批准工法证书和已授权专利证书清单。
* 直接对标需求功能10「工法证书/专利证书 → 申报服务部」接口。
*/
@GetMapping("/cert-push-preview")
public ApiResp<Map<String, Object>> certPushPreview() {
List<WorkMethod> approvedMethods = methodRepo.findByStage("已批准");
List<Patent> grantedPatents = patentRepo.findByStatus("已授权");
List<Map<String, Object>> methodRows = new ArrayList<>();
for (WorkMethod m : approvedMethods) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("sourceType", "工法证书");
row.put("id", m.getId());
row.put("no", m.getMethodNo());
row.put("name", m.getName());
row.put("certNo", nz(m.getCertNo()));
row.put("approveDocNo", nz(m.getApproveDocNo()));
row.put("certExpireDate", nz(m.getCertExpireDate()));
row.put("level", nz(m.getLevel()));
row.put("techField", nz(m.getTechField()));
methodRows.add(row);
}
List<Map<String, Object>> patentRows = new ArrayList<>();
for (Patent p : grantedPatents) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("sourceType", "专利证书");
row.put("id", p.getId());
row.put("patentNo", nz(p.getPatentNo()));
row.put("name", p.getName());
row.put("type", nz(p.getType()));
row.put("grantDate", nz(p.getGrantDate()));
row.put("ipcClass", nz(p.getIpcClass()));
row.put("techDomain", nz(p.getTechDomain()));
patentRows.add(row);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("approvedWorkMethods", methodRows);
result.put("grantedPatents", patentRows);
result.put("totalWorkMethods", methodRows.size());
result.put("totalPatents", patentRows.size());
result.put("declarations", declarationRepo.findAll().stream().map(d -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", d.getId());
m.put("name", nz(d.getName()));
m.put("program", nz(d.getProgram()));
m.put("status", nz(d.getStatus()));
return m;
}).toList());
return ApiResp.ok(result);
}
// ---------- helpers ----------
private static String nz(String s) {
return s == null ? "" : s;
}
}