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 等外部系统):
*
* - 申报服务部:自动获取档案电子版及归档状态(DeclarationController 未调用 ArchiveRepository)→
* GET /archive-cross/declaration/{declId}/archive-status;
* - 法务风险部:合同/诉讼文书自动归档链(TriggerRuleEngine 仅有 project.toArchive,
* 无 contract.toArchive / legal.toArchive)→
* POST /archive-cross/contract/{contractId}/archive(触发合同自动归档)、
* POST /archive-cross/litigation/{caseId}/archive(诉讼文书归档);
* - 工程监理部:图纸版本归档对接(SupervisionInspectionController 未链接 ArchiveVersion)→
* GET /archive-cross/supervision/drawing-versions(取所有图纸类版本归档状态)、
* POST /archive-cross/supervision/drawing/{archiveId}/new-version(新建图纸版本);
* - 财务部:会计档案自动归档链 →
* POST /archive-cross/finance/{year}/accounting-archive(财务部按年度触发会计档案归档);
* - 人力资源部:员工档案自动归档链 →
* POST /archive-cross/hr/employee/{employeeNo}/archive(HR 事件触发员工档案流入资料室);
* - 知识产权部+质安部:证书/质量记录归档链 →
* POST /archive-cross/ip/cert/archive(知识产权证书归档)、
* POST /archive-cross/quality/record/archive(质量记录归档);
* - 聚合状态视图:GET /archive-cross/integration-dashboard — 各模块归档状态汇总。
*
*
* 幂等:每次自动触发前检查 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; }
}