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 competitors, String riskSummary) { } /** * 工法侵权风险联动:根据工法的 techField 联动查询对手专利,判断是否存在「重叠」侵权风险。 * 覆盖需求:法务风险部「专利侵权监控」与工法/专利模块的关联监控。 */ @GetMapping("/work-method/{id}/ip-risk") public ApiResp workMethodIpRisk(@PathVariable Long id) { WorkMethod m = methodRepo.findById(id) .orElseThrow(() -> new NotFoundException("工法不存在:" + id)); String techField = nz(m.getTechField()); List 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 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 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 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 profiles, String note) { } /** * 工法完成人 HR 联动:按 contributors(逗号分隔姓名)批量查询用户的部门/职务, * 为奖励分配按贡献比例计算提供结构化基础(需求功能7「发明人/工法完成人管理」)。 */ @GetMapping("/work-method/{id}/contributors") public ApiResp 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 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 names = Arrays.stream(csv.split("[,,、]")) .map(String::trim).filter(s -> !s.isEmpty()).toList(); List profiles = new ArrayList<>(); int notFound = 0; for (String name : names) { List 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 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 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 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 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 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> crossDeptSummary(@PathVariable Long id) { WorkMethod m = methodRepo.findById(id) .orElseThrow(() -> new NotFoundException("工法不存在:" + id)); Map 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 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 pending = archiveSubmissionRepo.findByStatus("待审核"); List 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 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 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> certPushPreview() { List approvedMethods = methodRepo.findByStage("已批准"); List grantedPatents = patentRepo.findByStatus("已授权"); List> methodRows = new ArrayList<>(); for (WorkMethod m : approvedMethods) { Map 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> patentRows = new ArrayList<>(); for (Patent p : grantedPatents) { Map 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 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 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; } }