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>
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
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<String> 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<FinElecArchive>> 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<FinElecArchive> 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<FinElecArchive> 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<FinElecArchive> 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<FinElecArchive> 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<FinElecArchive> 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<FinElecArchive> 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<Map<String, Object>> 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<String, Object> 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<List<Map<String, Object>>> exportList(
|
||||
@RequestParam(required = false) String period,
|
||||
@RequestParam(required = false) String archiveType) {
|
||||
List<FinElecArchive> all = repo.findAll();
|
||||
List<Map<String, Object>> 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<String, Object> 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<List<Map<String, Object>>> retentionAlert() {
|
||||
List<FinElecArchive> all = repo.findAll();
|
||||
List<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> batchArchive(@RequestBody BatchArchiveRequest req) {
|
||||
if (req.period() == null)
|
||||
throw new ApiException(400, "批量归档须指定期间(period)");
|
||||
|
||||
List<FinElecArchive> 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<String, Object> 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<FinElecArchive> 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user