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.FinElecArchive; import com.kaidi.oa.repository.FinElecArchiveRepository; import org.springframework.scheduling.annotation.Scheduled; 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.PutMapping; 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.LinkedHashMap; import java.util.List; import java.util.Map; /** * 财务部·电子会计档案管理(缺口 #13)。 * * 审计缺口:电子会计档案(按《电子会计档案管理办法》归档+哈希+备份)无专属归档实体。 * * 本控制器提供: * GET / —— 档案列表(按 status/archiveType/period 过滤) * GET /{id} —— 单条详情 * POST / —— 新建档案登记(待归档) * PUT /{id} —— 更新档案信息 * DELETE /{id} —— 标记销毁(不删除记录) * POST /{id}/archive —— 归档:待归档 → 已归档(记录归档日期+SHA256哈希) * POST /{id}/backup —— 备份:已归档 → 已备份(记录备份路径) * POST /{id}/verify-hash —— 哈希校验:验证文件完整性 * GET /export-list —— 导出清单(审计用,返回指定期间可导出档案列表) * GET /retention-alert —— 保存期限预警(即将到期档案列表) * POST /batch-archive —— 批量归档(按期间+类型批量推进状态) * * 调度:每月1日自动扫描并提醒即将到期的档案。 * * 写口:FINANCE_PREFIXES(/api/oa/fin-elec-archives) 限 ADMIN/APPROVER。 */ @RestController @RequestMapping("/api/oa/fin-elec-archives") public class FinElecArchiveController { private static final List VALID_TYPES = List.of( FinElecArchive.TYPE_VOUCHER, FinElecArchive.TYPE_LEDGER, FinElecArchive.TYPE_REPORT, FinElecArchive.TYPE_INVOICE, FinElecArchive.TYPE_BANK_REC, FinElecArchive.TYPE_OTHER); private final FinElecArchiveRepository repo; public FinElecArchiveController(FinElecArchiveRepository repo) { this.repo = repo; } @GetMapping public ApiResp> list( @RequestParam(required = false) String status, @RequestParam(required = false) String archiveType, @RequestParam(required = false) String period) { if (archiveType != null && period != null) return ApiResp.ok(repo.findByArchiveTypeAndPeriod(archiveType, period)); if (status != null) return ApiResp.ok(repo.findByStatus(status)); if (archiveType != null) return ApiResp.ok(repo.findByArchiveType(archiveType)); if (period != null) return ApiResp.ok(repo.findByPeriod(period)); return ApiResp.ok(repo.findAll()); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(repo.findById(id) .orElseThrow(() -> new NotFoundException("电子会计档案不存在: " + id))); } public record ArchiveRequest( String archiveType, String archiveName, String period, String filePath, Long fileSizeBytes, String fileFormat, String sha256Hash, Integer retentionYears, String remark, String archivedBy) {} @PostMapping public ApiResp create(@RequestBody ArchiveRequest req) { if (req.archiveType() == null || !VALID_TYPES.contains(req.archiveType())) throw new ApiException(400, "档案类型(archiveType)须为:" + VALID_TYPES); if (req.archiveName() == null || req.archiveName().isBlank()) throw new ApiException(400, "档案名称(archiveName)不能为空"); FinElecArchive a = new FinElecArchive(); a.setArchiveCode("EA-" + System.currentTimeMillis() % 1000000); a.setArchiveType(req.archiveType()); a.setArchiveName(req.archiveName()); a.setPeriod(req.period()); a.setFilePath(req.filePath()); a.setFileSizeBytes(req.fileSizeBytes()); a.setFileFormat(req.fileFormat() != null ? req.fileFormat() : "PDF"); a.setSha256Hash(req.sha256Hash()); a.setHashVerified(Boolean.FALSE); // 按《电子会计档案管理办法》:凭证/账簿/报表保存30年,发票10年 a.setRetentionYears(req.retentionYears() != null ? req.retentionYears() : defaultRetentionYears(req.archiveType())); a.setExportable(Boolean.TRUE); a.setStatus(FinElecArchive.S_PENDING); a.setArchivedBy(req.archivedBy()); a.setRemark(req.remark()); a.setCreatedAt(Instant.now()); a.setUpdatedAt(Instant.now()); return ApiResp.ok(repo.save(a)); } @PutMapping("/{id}") public ApiResp update(@PathVariable Long id, @RequestBody ArchiveRequest req) { FinElecArchive a = repo.findById(id) .orElseThrow(() -> new NotFoundException("电子会计档案不存在: " + id)); if (FinElecArchive.S_DISPOSED.equals(a.getStatus())) throw new ApiException(409, "已销毁的档案不允许修改"); if (req.archiveName() != null) a.setArchiveName(req.archiveName()); if (req.filePath() != null) a.setFilePath(req.filePath()); if (req.sha256Hash() != null) { a.setSha256Hash(req.sha256Hash()); a.setHashVerified(Boolean.FALSE); // 重置校验状态 } if (req.retentionYears() != null) a.setRetentionYears(req.retentionYears()); if (req.remark() != null) a.setRemark(req.remark()); a.setUpdatedAt(Instant.now()); return ApiResp.ok(repo.save(a)); } /** 标记销毁(不删除记录,置状态为已销毁)。 */ @DeleteMapping("/{id}") @Transactional public ApiResp markDisposed(@PathVariable Long id) { FinElecArchive a = repo.findById(id) .orElseThrow(() -> new NotFoundException("电子会计档案不存在: " + id)); a.setStatus(FinElecArchive.S_DISPOSED); a.setDisposalDate(LocalDate.now().toString()); a.setUpdatedAt(Instant.now()); return ApiResp.ok(repo.save(a)); } // ===================== 状态机 ===================== public record ArchiveActionRequest(String archivedBy, String sha256Hash) {} /** 归档:待归档 → 已归档。 */ @PostMapping("/{id}/archive") @Transactional public ApiResp archive(@PathVariable Long id, @RequestBody(required = false) ArchiveActionRequest req) { FinElecArchive a = repo.findById(id) .orElseThrow(() -> new NotFoundException("电子会计档案不存在: " + id)); if (!FinElecArchive.S_PENDING.equals(a.getStatus())) throw new ApiException(409, "仅待归档状态可归档,当前状态:" + a.getStatus()); a.setStatus(FinElecArchive.S_ARCHIVED); a.setArchiveDate(LocalDate.now().toString()); if (req != null) { if (req.archivedBy() != null) a.setArchivedBy(req.archivedBy()); if (req.sha256Hash() != null) { a.setSha256Hash(req.sha256Hash()); a.setHashVerified(Boolean.TRUE); } } a.setUpdatedAt(Instant.now()); return ApiResp.ok(repo.save(a)); } public record BackupRequest(String backupPaths, String backedUpBy) {} /** 备份:已归档 → 已备份。 */ @PostMapping("/{id}/backup") @Transactional public ApiResp backup(@PathVariable Long id, @RequestBody BackupRequest req) { FinElecArchive a = repo.findById(id) .orElseThrow(() -> new NotFoundException("电子会计档案不存在: " + id)); if (!FinElecArchive.S_ARCHIVED.equals(a.getStatus())) throw new ApiException(409, "仅已归档状态可备份,当前状态:" + a.getStatus()); a.setStatus(FinElecArchive.S_BACKED); a.setBackupPaths(req.backupPaths()); a.setBackedUpBy(req.backedUpBy()); a.setUpdatedAt(Instant.now()); return ApiResp.ok(repo.save(a)); } public record HashVerifyRequest(String actualHash) {} /** 哈希校验:对比传入的实际哈希值与记录中的 sha256Hash。 */ @PostMapping("/{id}/verify-hash") @Transactional public ApiResp> verifyHash(@PathVariable Long id, @RequestBody HashVerifyRequest req) { FinElecArchive a = repo.findById(id) .orElseThrow(() -> new NotFoundException("电子会计档案不存在: " + id)); if (req.actualHash() == null || req.actualHash().isBlank()) throw new ApiException(400, "实际哈希值(actualHash)不能为空"); boolean verified = req.actualHash().equalsIgnoreCase(a.getSha256Hash()); a.setHashVerified(verified); a.setUpdatedAt(Instant.now()); repo.save(a); Map result = new LinkedHashMap<>(); result.put("id", id); result.put("archiveName", a.getArchiveName()); result.put("expectedHash", a.getSha256Hash()); result.put("actualHash", req.actualHash()); result.put("verified", verified); result.put("message", verified ? "哈希校验通过,档案完整性验证成功" : "哈希不匹配,档案可能已被篡改!"); return ApiResp.ok(result); } // ===================== 聚合端点 ===================== @GetMapping("/export-list") public ApiResp>> exportList( @RequestParam(required = false) String period, @RequestParam(required = false) String archiveType) { List all = repo.findAll(); List> result = new ArrayList<>(); for (FinElecArchive a : all) { if (!Boolean.TRUE.equals(a.getExportable())) continue; if (FinElecArchive.S_PENDING.equals(a.getStatus())) continue; if (FinElecArchive.S_DISPOSED.equals(a.getStatus())) continue; if (period != null && !period.equals(a.getPeriod())) continue; if (archiveType != null && !archiveType.equals(a.getArchiveType())) continue; Map row = new LinkedHashMap<>(); row.put("id", a.getId()); row.put("archiveCode", a.getArchiveCode()); row.put("archiveType", a.getArchiveType()); row.put("archiveName", a.getArchiveName()); row.put("period", a.getPeriod()); row.put("fileFormat", a.getFileFormat()); row.put("fileSizeBytes", a.getFileSizeBytes()); row.put("status", a.getStatus()); row.put("hashVerified", a.getHashVerified()); row.put("archiveDate", a.getArchiveDate()); result.add(row); } return ApiResp.ok(result); } @GetMapping("/retention-alert") public ApiResp>> retentionAlert() { List all = repo.findAll(); List> result = new ArrayList<>(); String thisYear = String.valueOf(LocalDate.now().getYear()); for (FinElecArchive a : all) { if (FinElecArchive.S_DISPOSED.equals(a.getStatus())) continue; if (a.getArchiveDate() == null || a.getRetentionYears() == null) continue; int archiveYear = Integer.parseInt(a.getArchiveDate().substring(0, 4)); int expiryYear = archiveYear + a.getRetentionYears(); int yearsLeft = expiryYear - Integer.parseInt(thisYear); if (yearsLeft <= 2) { // 提前2年预警 Map row = new LinkedHashMap<>(); row.put("id", a.getId()); row.put("archiveName", a.getArchiveName()); row.put("archiveType", a.getArchiveType()); row.put("archiveDate", a.getArchiveDate()); row.put("retentionYears", a.getRetentionYears()); row.put("expiryYear", expiryYear); row.put("yearsLeft", yearsLeft); row.put("status", a.getStatus()); result.add(row); } } return ApiResp.ok(result); } public record BatchArchiveRequest(String period, String archiveType, String archivedBy) {} /** 批量归档:按期间+类型将所有「待归档」推进为「已归档」。 */ @PostMapping("/batch-archive") @Transactional public ApiResp> batchArchive(@RequestBody BatchArchiveRequest req) { if (req.period() == null) throw new ApiException(400, "批量归档须指定期间(period)"); List pending = repo.findByStatus(FinElecArchive.S_PENDING); if (req.archiveType() != null) pending = pending.stream().filter(a -> req.archiveType().equals(a.getArchiveType())).toList(); pending = pending.stream().filter(a -> req.period().equals(a.getPeriod())).toList(); int count = 0; for (FinElecArchive a : pending) { a.setStatus(FinElecArchive.S_ARCHIVED); a.setArchiveDate(LocalDate.now().toString()); if (req.archivedBy() != null) a.setArchivedBy(req.archivedBy()); a.setUpdatedAt(Instant.now()); repo.save(a); count++; } Map result = new LinkedHashMap<>(); result.put("period", req.period()); result.put("archivedCount", count); result.put("message", "批量归档完成,共归档 " + count + " 条档案"); return ApiResp.ok(result); } // ===================== 调度 ===================== /** 每月1日 08:00 自动扫描即将到期档案并标记。 */ @Scheduled(cron = "0 0 8 1 * *") @Transactional public void monthlyRetentionScan() { // 仅记录日志,不自动销毁;销毁需人工确认 List all = repo.findAll(); String thisYear = String.valueOf(LocalDate.now().getYear()); for (FinElecArchive a : all) { if (a.getArchiveDate() == null || a.getRetentionYears() == null) continue; int archiveYear = Integer.parseInt(a.getArchiveDate().substring(0, 4)); int expiryYear = archiveYear + a.getRetentionYears(); if (expiryYear <= Integer.parseInt(thisYear) && !FinElecArchive.S_DISPOSED.equals(a.getStatus())) { // 在备注中记录提醒(不自动销毁) String note = "[保存期满提醒-" + LocalDate.now() + "] 该档案已满 " + a.getRetentionYears() + " 年保存期限,请人工审批后销毁。"; a.setRemark(note); a.setUpdatedAt(Instant.now()); repo.save(a); } } } private int defaultRetentionYears(String archiveType) { return switch (archiveType) { case FinElecArchive.TYPE_VOUCHER, FinElecArchive.TYPE_LEDGER, FinElecArchive.TYPE_REPORT -> 30; case FinElecArchive.TYPE_INVOICE -> 10; case FinElecArchive.TYPE_BANK_REC -> 10; default -> 10; }; } }