package com.kaidi.oa.web; import com.kaidi.oa.common.ApiException; import com.kaidi.oa.common.ApiResp; import com.kaidi.oa.common.NotFoundException; import com.kaidi.oa.domain.Archive; import com.kaidi.oa.domain.ArchiveSubmission; import com.kaidi.oa.domain.ArchiveVersion; import com.kaidi.oa.domain.Contract; import com.kaidi.oa.domain.Declaration; import com.kaidi.oa.domain.LitigationCase; import com.kaidi.oa.repository.ArchiveRepository; import com.kaidi.oa.repository.ArchiveSubmissionRepository; import com.kaidi.oa.repository.ArchiveVersionRepository; import com.kaidi.oa.repository.ContractRepository; import com.kaidi.oa.repository.DeclarationRepository; import com.kaidi.oa.repository.LitigationCaseRepository; import org.springframework.transaction.annotation.Transactional; 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.time.Instant; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; /** * 资料室·Module 8「与其它部门的接口要求」缺口补完。 * *

补审计「跨模块接口」六大缺口(跳过 IoT/BIM 等外部系统): *

* *

幂等:每次自动触发前检查 archiveRepo.findBySourceTypeAndSourceId(),已有则跳过。 * 写口受 default-deny(ADMIN/APPROVER) 保护;读口纳入 SENSITIVE_READ_PREFIXES。 */ @RestController @RequestMapping("/api/oa/archive-cross") public class ArchiveCrossModuleController { private final ArchiveRepository archiveRepo; private final ArchiveSubmissionRepository submissionRepo; private final ArchiveVersionRepository versionRepo; private final ContractRepository contractRepo; private final DeclarationRepository declRepo; private final LitigationCaseRepository litigationRepo; public ArchiveCrossModuleController(ArchiveRepository archiveRepo, ArchiveSubmissionRepository submissionRepo, ArchiveVersionRepository versionRepo, ContractRepository contractRepo, DeclarationRepository declRepo, LitigationCaseRepository litigationRepo) { this.archiveRepo = archiveRepo; this.submissionRepo = submissionRepo; this.versionRepo = versionRepo; this.contractRepo = contractRepo; this.declRepo = declRepo; this.litigationRepo = litigationRepo; } // ===================================================================== // 申报服务部:获取申报项目关联档案电子版及归档状态 // ===================================================================== /** * 申报服务部接口:自动获取某申报项目关联档案的电子版及归档状态。 * 查 archive 表 sourceType=申报&sourceId=declId(申报→档案自动联动) * + archive_submission 表按申报部门「申报服务部」过滤。 */ @GetMapping("/declaration/{declId}/archive-status") public ApiResp declArchiveStatus(@PathVariable Long declId) { Declaration decl = declRepo.findById(declId) .orElseThrow(() -> new NotFoundException("declaration not found: " + declId)); // 检查档案库:sourceType=申报,sourceId=declId List archives = archiveRepo.findBySourceTypeAndSourceId("申报", declId); // 检查归档申请:来源部门含「申报」 List submissions = submissionRepo.findBySourceDept("申报服务部"); List refs = archives.stream() .map(a -> new ArchiveRef(a.getId(), a.getTitle(), a.getCategory(), a.getAccessLevel(), a.getArchiveDate(), a.getFileUrl(), "已归档")) .toList(); String overallStatus = refs.isEmpty() ? "未归档" : "已归档"; long pendingCount = submissions.stream() .filter(s -> "待审核".equals(s.getStatus())) .count(); return ApiResp.ok(new DeclArchiveStatus( declId, decl.getName(), decl.getProgram(), decl.getStatus(), overallStatus, refs, pendingCount)); } public record ArchiveRef(Long archiveId, String title, String category, String accessLevel, String archiveDate, String fileUrl, String status) {} public record DeclArchiveStatus(Long declId, String declName, String program, String declStatus, String archiveStatus, List archives, long pendingSubmissions) {} // ===================================================================== // 法务风险部:合同/诉讼文书自动归档链(contract.toArchive + legal.toArchive) // ===================================================================== /** * 合同自动归档触发(补 TriggerRuleEngine「无 contract.toArchive」缺口)。 * 仅状态为「履约中」或「已结算」的合同可归档;幂等(已有合同档案则返回现有记录)。 */ @PostMapping("/contract/{contractId}/archive") @Transactional public ApiResp archiveContract(@PathVariable Long contractId, @RequestBody(required = false) OperatorReq req) { Contract c = contractRepo.findById(contractId) .orElseThrow(() -> new NotFoundException("contract not found: " + contractId)); // 幂等检查 List existing = archiveRepo.findBySourceTypeAndSourceId("合同", contractId); if (!existing.isEmpty()) { return ApiResp.ok(new AutoArchiveResult( existing.get(0).getId(), "合同", contractId, c.getName(), "幂等跳过:该合同已有档案记录 #" + existing.get(0).getId())); } if (!"履约中".equals(c.getStatus()) && !"已结算".equals(c.getStatus())) { throw new ApiException(409, "仅「履约中」或「已结算」合同可归档,当前状态:" + c.getStatus()); } Archive a = new Archive(); a.setCategory("合同档案"); a.setTitle(c.getName() + "(" + nz(c.getType()) + ")合同档案"); a.setSourceType("合同"); a.setSourceId(c.getId()); a.setFileName(c.getName()); a.setFileType("PDF"); a.setFileSize(0L); a.setUploader(req == null ? "系统" : nz(req.operator())); a.setArchiveDate(LocalDate.now().toString()); a.setTags("合同编号:" + nz(c.getCode()) + ",甲方:" + nz(c.getPartyA()) + ",乙方:" + nz(c.getPartyB())); a.setSummary("合同类型:" + nz(c.getType()) + ";签署日期:" + nz(c.getSignDate()) + ";状态:" + nz(c.getStatus()) + ";自动归档(合同→档案联动)"); a.setAccessLevel("内部"); a.setCreatedAt(Instant.now()); Archive saved = archiveRepo.save(a); return ApiResp.ok(new AutoArchiveResult( saved.getId(), "合同", contractId, c.getName(), "合同档案已自动归档,档案ID=" + saved.getId())); } /** * 诉讼文书自动归档触发(补 TriggerRuleEngine「无 legal.toArchive」缺口)。 * 已结案的诉讼文书归档到「合同档案/法律文书」类别;幂等。 */ @PostMapping("/litigation/{caseId}/archive") @Transactional public ApiResp archiveLitigation(@PathVariable Long caseId, @RequestBody(required = false) OperatorReq req) { LitigationCase lc = litigationRepo.findById(caseId) .orElseThrow(() -> new NotFoundException("litigation case not found: " + caseId)); List existing = archiveRepo.findBySourceTypeAndSourceId("诉讼", caseId); if (!existing.isEmpty()) { return ApiResp.ok(new AutoArchiveResult( existing.get(0).getId(), "诉讼", caseId, lc.getCaseName(), "幂等跳过:该诉讼案件已有档案记录 #" + existing.get(0).getId())); } Archive a = new Archive(); a.setCategory("合同档案"); a.setTitle(lc.getCaseName() + " 诉讼文书归档"); a.setSourceType("诉讼"); a.setSourceId(lc.getId()); a.setFileName(lc.getCaseName()); a.setFileType("PDF"); a.setFileSize(0L); a.setUploader(req == null ? "系统" : nz(req.operator())); a.setArchiveDate(LocalDate.now().toString()); a.setTags("案号:" + nz(lc.getCaseNo()) + ",阶段:" + nz(lc.getStage()) + ",结果:" + nz(lc.getStatus())); a.setSummary("诉讼案件「" + lc.getCaseName() + "」文书归档;立案:" + nz(lc.getFilingDate()) + ";阶段:" + nz(lc.getStage())); a.setAccessLevel("机密"); a.setCreatedAt(Instant.now()); Archive saved = archiveRepo.save(a); return ApiResp.ok(new AutoArchiveResult( saved.getId(), "诉讼", caseId, lc.getCaseName(), "诉讼文书已自动归档,档案ID=" + saved.getId())); } public record AutoArchiveResult(Long archiveId, String sourceType, Long sourceId, String sourceName, String message) {} public record OperatorReq(String operator, String remark) {} // ===================================================================== // 工程监理部:图纸版本归档对接 // ===================================================================== /** * 图纸版本归档状态聚合(补「SupervisionInspectionController 未链接 ArchiveVersion」缺口)。 * 查所有「图纸档案」类别的 Archive 记录 + 对应的 ArchiveVersion 清单, * 返回图纸版本归档状态(现行版本/作废版本数量)。 */ @GetMapping("/supervision/drawing-versions") public ApiResp> drawingVersions() { List drawings = archiveRepo.findByCategory("图纸档案"); List result = new ArrayList<>(); for (Archive a : drawings) { List versions = versionRepo.findByArchiveIdOrderByIdDesc(a.getId()); long currentCount = versions.stream().filter(v -> Boolean.TRUE.equals(v.getIsCurrent())).count(); long obsoleteCount = versions.stream().filter(v -> "作废".equals(v.getStatus())).count(); String latestVersionNo = versions.isEmpty() ? null : versions.get(0).getVersionNo(); result.add(new DrawingVersionStatus(a.getId(), a.getTitle(), a.getArchiveDate(), versions.size(), currentCount, obsoleteCount, latestVersionNo)); } return ApiResp.ok(result); } /** * 工程监理部为某图纸档案新建版本归档(链 ArchiveVersion,修复「未链接 ArchiveVersion」缺口)。 * 自动将旧现行版置「作废」,新版置「现行」。 */ @PostMapping("/supervision/drawing/{archiveId}/new-version") @Transactional public ApiResp newDrawingVersion(@PathVariable Long archiveId, @RequestBody DrawingVersionReq req) { Archive a = archiveRepo.findById(archiveId) .orElseThrow(() -> new NotFoundException("archive not found: " + archiveId)); if (req.versionNo() == null || req.versionNo().isBlank()) { throw new ApiException(400, "版本号(versionNo) 不能为空"); } // 旧现行版→作废 versionRepo.findByArchiveIdAndIsCurrentTrue(archiveId).ifPresent(old -> { old.setIsCurrent(Boolean.FALSE); old.setStatus("作废"); versionRepo.save(old); }); // 新版现行 ArchiveVersion v = new ArchiveVersion(); v.setArchiveId(archiveId); v.setArchiveTitle(a.getTitle()); v.setVersionNo(req.versionNo()); v.setIsCurrent(Boolean.TRUE); v.setStatus("现行"); v.setChangeNote(req.changeNote()); v.setAuthor(req.author()); v.setVersionDate(req.versionDate() == null || req.versionDate().isBlank() ? LocalDate.now().toString() : req.versionDate()); v.setCreatedAt(Instant.now()); return ApiResp.ok(versionRepo.save(v)); } public record DrawingVersionStatus(Long archiveId, String title, String archiveDate, int totalVersions, long currentVersions, long obsoleteVersions, String latestVersionNo) {} public record DrawingVersionReq(String versionNo, String changeNote, String author, String versionDate) {} // ===================================================================== // 财务部:会计档案自动归档链 // ===================================================================== /** * 财务部会计档案年度归档触发(补「财务部会计档案自动归档链缺失」缺口)。 * 按年度创建一条「财务档案」类别的档案记录(幂等:同年度已有则跳过)。 */ @PostMapping("/finance/{year}/accounting-archive") @Transactional public ApiResp financeAccountingArchive(@PathVariable int year, @RequestBody(required = false) OperatorReq req) { if (year < 2000 || year > 2099) { throw new ApiException(400, "年度参数不合法:" + year); } String title = year + "年度会计档案(凭证/账簿/报表)"; // 幂等:检查是否已有同年度财务档案 List existing = archiveRepo.findByCategory("财务档案").stream() .filter(a -> a.getTitle() != null && a.getTitle().startsWith(String.valueOf(year))) .toList(); if (!existing.isEmpty()) { return ApiResp.ok(new AutoArchiveResult( existing.get(0).getId(), "财务", (long) year, title, "幂等跳过:" + year + " 年度会计档案已归档,档案ID=" + existing.get(0).getId())); } Archive a = new Archive(); a.setCategory("财务档案"); a.setTitle(title); a.setSourceType("财务"); a.setSourceId((long) year); a.setFileName(title); a.setFileType("PDF"); a.setFileSize(0L); a.setUploader(req == null ? "财务部" : nz(req.operator())); a.setArchiveDate(LocalDate.now().toString()); a.setTags("年度:" + year + ",类型:会计档案,保存期限:30年"); a.setSummary(year + "年度财务档案自动归档,含凭证/账簿/报表,依《会计档案管理办法》保存30年"); a.setAccessLevel("机密"); a.setCreatedAt(Instant.now()); Archive saved = archiveRepo.save(a); return ApiResp.ok(new AutoArchiveResult( saved.getId(), "财务", (long) year, title, "财务会计档案已归档,档案ID=" + saved.getId())); } // ===================================================================== // 人力资源部:员工档案流入资料室 // ===================================================================== /** * HR 事件触发员工档案流入资料室(补「人力资源部员工档案自动归档链缺失」缺口)。 * HrEventArchiveController 是独立人事档案,不流入资料室 Archive 表; * 本端点将员工重要档案(离职/合同/年度评估)写入 Archive 表(幂等)。 */ @PostMapping("/hr/employee/{employeeNo}/archive") @Transactional public ApiResp hrEmployeeArchive(@PathVariable String employeeNo, @RequestBody HrArchiveReq req) { if (req.eventType() == null || req.eventType().isBlank()) { throw new ApiException(400, "人事事件类型(eventType) 不能为空"); } if (req.employeeName() == null || req.employeeName().isBlank()) { throw new ApiException(400, "员工姓名(employeeName) 不能为空"); } String category = "人事档案"; String title = req.employeeName() + "(" + employeeNo + ")" + req.eventType() + "档案"; String sourceType = "人事-" + req.eventType(); // 幂等:同员工同事件类型只归档一次(用 tags 匹配) List existing = archiveRepo.findByCategory(category).stream() .filter(a -> a.getTags() != null && a.getTags().contains("员工编号:" + employeeNo) && a.getTags().contains("事件:" + req.eventType())) .toList(); if (!existing.isEmpty()) { return ApiResp.ok(new AutoArchiveResult( existing.get(0).getId(), sourceType, null, title, "幂等跳过:该员工" + req.eventType() + "档案已归档")); } Archive a = new Archive(); a.setCategory(category); a.setTitle(title); a.setSourceType(sourceType); a.setFileName(title); a.setFileType("PDF"); a.setFileSize(0L); a.setUploader(req.operator() == null ? "人力资源部" : req.operator()); a.setArchiveDate(req.eventDate() == null ? LocalDate.now().toString() : req.eventDate()); a.setTags("员工编号:" + employeeNo + ",员工姓名:" + req.employeeName() + ",部门:" + nz(req.dept()) + ",事件:" + req.eventType()); a.setSummary("员工「" + req.employeeName() + "」" + req.eventType() + "档案,由 HR 事件联动自动归档"); a.setAccessLevel("机密"); a.setCreatedAt(Instant.now()); Archive saved = archiveRepo.save(a); return ApiResp.ok(new AutoArchiveResult( saved.getId(), sourceType, null, title, "员工档案已自动归档至资料室,档案ID=" + saved.getId())); } public record HrArchiveReq(String employeeName, String dept, String eventType, String eventDate, String operator, String remark) {} // ===================================================================== // 知识产权部:证书电子档同步归档 // ===================================================================== /** * 知识产权证书归档(补「知识产权部证书电子档同步链缺失」缺口)。 * 专利/商标/著作权证书获批后自动触发归档至资料室。幂等。 */ @PostMapping("/ip/cert/archive") @Transactional public ApiResp ipCertArchive(@RequestBody IpCertReq req) { if (req.certNo() == null || req.certNo().isBlank()) { throw new ApiException(400, "证书编号(certNo) 不能为空"); } if (req.certType() == null || req.certType().isBlank()) { throw new ApiException(400, "证书类型(certType) 不能为空"); } String title = req.certType() + ":" + nz(req.certName()) + "(" + req.certNo() + ")"; // 幂等:按证书编号查 tags List existing = archiveRepo.findByCategory("知识产权档案").stream() .filter(a -> a.getTags() != null && a.getTags().contains("证书编号:" + req.certNo())) .toList(); if (!existing.isEmpty()) { return ApiResp.ok(new AutoArchiveResult( existing.get(0).getId(), "知识产权", null, title, "幂等跳过:该证书已归档,档案ID=" + existing.get(0).getId())); } Archive a = new Archive(); a.setCategory("知识产权档案"); a.setTitle(title); a.setSourceType("知识产权"); a.setFileName(title); a.setFileType("PDF"); a.setFileSize(0L); a.setUploader(req.operator() == null ? "知识产权部" : req.operator()); a.setArchiveDate(req.grantDate() == null ? LocalDate.now().toString() : req.grantDate()); a.setTags("证书编号:" + req.certNo() + ",证书类型:" + req.certType() + ",权利人:" + nz(req.holder()) + ",有效期至:" + nz(req.validUntil())); a.setSummary("知识产权证书「" + title + "」电子档自动同步至资料室"); a.setAccessLevel("机密"); a.setCreatedAt(Instant.now()); Archive saved = archiveRepo.save(a); return ApiResp.ok(new AutoArchiveResult( saved.getId(), "知识产权", null, title, "知识产权证书已归档,档案ID=" + saved.getId())); } public record IpCertReq(String certNo, String certType, String certName, String holder, String grantDate, String validUntil, String operator) {} // ===================================================================== // 质安部:质量记录归档链 // ===================================================================== /** * 质量记录归档(补「质安部质量记录归档链缺失」缺口)。 * 质量记录(检验报告/体系文件/安全报告/特种设备档案)由质安部触发归档至资料室。幂等。 */ @PostMapping("/quality/record/archive") @Transactional public ApiResp qualityRecordArchive(@RequestBody QualityArchiveReq req) { if (req.recordNo() == null || req.recordNo().isBlank()) { throw new ApiException(400, "质量记录编号(recordNo) 不能为空"); } if (req.recordType() == null || req.recordType().isBlank()) { throw new ApiException(400, "记录类型(recordType) 不能为空"); } String title = req.recordType() + ":" + nz(req.title()) + "(" + req.recordNo() + ")"; // 幂等 List existing = archiveRepo.findByCategory("质量档案").stream() .filter(a -> a.getTags() != null && a.getTags().contains("记录编号:" + req.recordNo())) .toList(); if (!existing.isEmpty()) { return ApiResp.ok(new AutoArchiveResult( existing.get(0).getId(), "质量", null, title, "幂等跳过:该质量记录已归档,档案ID=" + existing.get(0).getId())); } Archive a = new Archive(); a.setCategory("质量档案"); a.setTitle(title); a.setSourceType("质量"); a.setFileName(title); a.setFileType("PDF"); a.setFileSize(0L); a.setUploader(req.operator() == null ? "质安部" : req.operator()); a.setArchiveDate(req.recordDate() == null ? LocalDate.now().toString() : req.recordDate()); a.setTags("记录编号:" + req.recordNo() + ",记录类型:" + req.recordType() + ",项目:" + nz(req.projectName())); a.setSummary("质安部质量记录「" + title + "」自动归档至资料室"); a.setAccessLevel("内部"); a.setCreatedAt(Instant.now()); Archive saved = archiveRepo.save(a); return ApiResp.ok(new AutoArchiveResult( saved.getId(), "质量", null, title, "质量记录已归档,档案ID=" + saved.getId())); } public record QualityArchiveReq(String recordNo, String recordType, String title, String projectName, String recordDate, String operator) {} // ===================================================================== // 综合归档状态看板(跨模块集成 Dashboard) // ===================================================================== /** * 各模块归档状态汇总:各类别档案数量 + 跨模块归档链覆盖情况。 * 供「跨模块集成」管理页展示全局归档状态。 */ @GetMapping("/integration-dashboard") public ApiResp integrationDashboard() { List all = archiveRepo.findAll(); long contractArchives = all.stream().filter(a -> "合同".equals(a.getSourceType())).count(); long projectArchives = all.stream().filter(a -> "项目".equals(a.getSourceType())).count(); long litigArchives = all.stream().filter(a -> "诉讼".equals(a.getSourceType())).count(); long hrArchives = all.stream().filter(a -> a.getSourceType() != null && a.getSourceType().startsWith("人事")).count(); long financeArchives = all.stream().filter(a -> "财务".equals(a.getSourceType())).count(); long ipArchives = all.stream().filter(a -> "知识产权".equals(a.getSourceType())).count(); long qualityArchives = all.stream().filter(a -> "质量".equals(a.getSourceType())).count(); long drawingArchives = all.stream().filter(a -> "图纸档案".equals(a.getCategory())).count(); long manualArchives = all.stream().filter(a -> "手动".equals(a.getSourceType())).count(); long archiveArchives = all.stream().filter(a -> "归档".equals(a.getSourceType())).count(); List stats = List.of( new ModuleArchiveStat("合同管理", "contract.toArchive", contractArchives, contractArchives > 0), new ModuleArchiveStat("项目管理", "project.toArchive", projectArchives, projectArchives > 0), new ModuleArchiveStat("法务风险(诉讼)", "legal.toArchive", litigArchives, litigArchives > 0), new ModuleArchiveStat("人力资源", "hr.toArchive", hrArchives, hrArchives > 0), new ModuleArchiveStat("财务部", "finance.toArchive", financeArchives, financeArchives > 0), new ModuleArchiveStat("知识产权", "ip.toArchive", ipArchives, ipArchives > 0), new ModuleArchiveStat("质安部", "quality.toArchive", qualityArchives, qualityArchives > 0), new ModuleArchiveStat("工程监理(图纸)", "supervision.drawing", drawingArchives, drawingArchives > 0), new ModuleArchiveStat("在线归档申请", "archive.submission", archiveArchives, archiveArchives > 0), new ModuleArchiveStat("手动上传", "manual", manualArchives, true) ); long activeChains = stats.stream().filter(ModuleArchiveStat::linked).count(); // 图纸版本统计 List drawings = archiveRepo.findByCategory("图纸档案"); long totalDrawingVersions = 0; for (Archive d : drawings) { totalDrawingVersions += versionRepo.findByArchiveIdOrderByIdDesc(d.getId()).size(); } return ApiResp.ok(new IntegrationDashboard( all.size(), stats, activeChains, stats.size(), drawings.size(), totalDrawingVersions, LocalDate.now().toString())); } public record ModuleArchiveStat(String module, String ruleKey, long archiveCount, boolean linked) {} public record IntegrationDashboard( long totalArchives, List moduleStats, long activeChains, long totalChains, long drawingArchiveCount, long drawingVersionCount, String reportDate) {} // ---- helpers ---- private static String nz(String s) { return s == null ? "" : s; } }