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:
Qiufeng
2026-06-15 19:19:15 +08:00
co-authored by Claude Opus 4.8
commit 5e51dc3f56
10584 changed files with 2501339 additions and 0 deletions
@@ -0,0 +1,245 @@
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.AuditArchive;
import com.kaidi.oa.domain.AuditProject;
import com.kaidi.oa.repository.AuditArchiveRepository;
import com.kaidi.oa.repository.AuditFindingRepository;
import com.kaidi.oa.repository.AuditProjectRepository;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
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.Year;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 审计档案库(内控部·审计监察部·第9模块)。
*
* 解决审计缺口:「无统一审计档案库实体(底稿/报告/证据/整改记录无统一归档入口)/
* 无审计发现库单独聚合视图」。
*
* 核心功能:
* - POST / 新建归档记录(状态=归档中);
* - PATCH /{id}/archive 确认归档(归档中→已归档);
* - GET /?year=&type= 按年度/类型过滤查询;
* - GET /search?q= 简易关键词检索(title/keywords/tags/description);
* - GET /findings-library 审计发现库聚合视图(跨审计项目汇总历史问题);
* - GET /summary 档案按年度/类型统计数量;
* - PUT /{id} 更新档案信息(已归档阶段只允许更新标签/关键词);
* - DELETE /{id} 删除(仅归档中阶段)。
*/
@RestController
@RequestMapping("/api/oa/audit-archives")
public class AuditArchiveController {
private final AuditArchiveRepository archiveRepo;
private final AuditProjectRepository projectRepo;
private final AuditFindingRepository findingRepo;
public AuditArchiveController(AuditArchiveRepository archiveRepo,
AuditProjectRepository projectRepo,
AuditFindingRepository findingRepo) {
this.archiveRepo = archiveRepo;
this.projectRepo = projectRepo;
this.findingRepo = findingRepo;
}
@GetMapping
public ApiResp<List<AuditArchive>> list(
@RequestParam(required = false) String year,
@RequestParam(required = false) String archiveType,
@RequestParam(required = false) Long projectId) {
if (projectId != null) {
return ApiResp.ok(archiveRepo.findByProjectId(projectId));
}
if (year != null && !year.isBlank()) {
return ApiResp.ok(archiveRepo.findByYear(year));
}
if (archiveType != null && !archiveType.isBlank()) {
return ApiResp.ok(archiveRepo.findByArchiveType(archiveType));
}
return ApiResp.ok(archiveRepo.findByOrderByYearDescIdDesc());
}
@GetMapping("/{id}")
public ApiResp<AuditArchive> get(@PathVariable Long id) {
return ApiResp.ok(load(id));
}
/**
* 简易关键词检索(title/keywords/tags 含匹配,不依赖 ES)。
*/
@GetMapping("/search")
public ApiResp<List<AuditArchive>> search(@RequestParam String q) {
if (q == null || q.isBlank()) {
return ApiResp.ok(List.of());
}
return ApiResp.ok(
archiveRepo.findByKeywordsContainingOrTitleContainingOrTagsContaining(
q, q, q));
}
/**
* 审计发现库聚合视图:按问题类别/风险等级聚合历史全部审计发现(含整改状态),
* 供新项目检索类似问题参考经验。返回发现列表(含 rootCause/category/severity/status)。
*/
@GetMapping("/findings-library")
public ApiResp<Object> findingsLibrary(
@RequestParam(required = false) String category,
@RequestParam(required = false) String severity) {
var all = findingRepo.findAll();
var filtered = all.stream()
.filter(f -> category == null || category.isBlank()
|| category.equals(f.getCategory()))
.filter(f -> severity == null || severity.isBlank()
|| severity.equals(f.getSeverity()))
.map(f -> Map.of(
"id", f.getId(),
"findingNo", f.getFindingNo() == null ? "" : f.getFindingNo(),
"title", f.getTitle() == null ? "" : f.getTitle(),
"category", f.getCategory() == null ? "" : f.getCategory(),
"severity", f.getSeverity() == null ? "" : f.getSeverity(),
"status", f.getStatus() == null ? "" : f.getStatus(),
"rootCause", f.getRootCause() == null ? "" : f.getRootCause(),
"projectName", f.getProjectName() == null ? "" : f.getProjectName(),
"auditee", f.getAuditee() == null ? "" : f.getAuditee()
))
.toList();
return ApiResp.ok(filtered);
}
/**
* 档案统计摘要:按年度/类型统计数量(辅助知识库全览)。
*/
@GetMapping("/summary")
public ApiResp<Map<String, Object>> summary() {
List<AuditArchive> all = archiveRepo.findAll();
Map<String, Long> byYear = all.stream()
.collect(Collectors.groupingBy(
a -> a.getYear() == null ? "未知" : a.getYear(),
Collectors.counting()));
Map<String, Long> byType = all.stream()
.collect(Collectors.groupingBy(
a -> a.getArchiveType() == null ? "其他" : a.getArchiveType(),
Collectors.counting()));
return ApiResp.ok(Map.of("total", (long) all.size(),
"byYear", byYear, "byType", byType));
}
public record CreateRequest(
Long projectId, String year, String archiveType, String title,
String description, String fileRef, String fileHash,
String tags, String keywords, int retentionYear, String archiver) {
}
@PostMapping
public ApiResp<AuditArchive> create(@RequestBody CreateRequest req) {
if (req.title() == null || req.title().isBlank()) {
throw new ApiException(400, "档案标题不能为空");
}
if (req.archiveType() == null || req.archiveType().isBlank()) {
throw new ApiException(400, "档案类型不能为空");
}
AuditArchive a = new AuditArchive();
a.setArchiveNo(nextArchiveNo());
a.setYear(req.year() != null && !req.year().isBlank()
? req.year() : String.valueOf(Year.now().getValue()));
a.setArchiveType(req.archiveType());
a.setTitle(req.title());
a.setDescription(req.description());
a.setFileRef(req.fileRef());
a.setFileHash(req.fileHash());
a.setTags(req.tags());
a.setKeywords(req.keywords());
a.setRetentionYear(req.retentionYear() <= 0 ? 10 : req.retentionYear());
a.setArchiver(req.archiver());
a.setStatus("归档中");
a.setCreatedAt(Instant.now());
if (req.projectId() != null) {
projectRepo.findById(req.projectId()).ifPresent(p -> {
a.setProjectId(p.getId());
a.setProjectName(p.getName());
a.setAuditee(p.getAuditee());
a.setAuditType(p.getAuditType());
});
}
return ApiResp.ok(archiveRepo.save(a));
}
public record UpdateRequest(
String title, String description, String tags, String keywords,
String fileRef, int retentionYear) {
}
@PutMapping("/{id}")
public ApiResp<AuditArchive> update(@PathVariable Long id, @RequestBody UpdateRequest req) {
AuditArchive a = load(id);
if ("已销毁".equals(a.getStatus())) {
throw new ApiException(409, "已销毁档案不允许修改");
}
// 已归档阶段只允许改标签/关键词/描述(核心字段锁定)。
boolean archived = "已归档".equals(a.getStatus());
if (req.tags() != null) a.setTags(req.tags());
if (req.keywords() != null) a.setKeywords(req.keywords());
if (req.description() != null) a.setDescription(req.description());
if (!archived) {
if (req.title() != null && !req.title().isBlank()) a.setTitle(req.title());
if (req.fileRef() != null) a.setFileRef(req.fileRef());
if (req.retentionYear() > 0) a.setRetentionYear(req.retentionYear());
}
return ApiResp.ok(archiveRepo.save(a));
}
public record ArchiveRequest(String archiver) {
}
/** 确认归档(归档中 → 已归档)。 */
@PatchMapping("/{id}/archive")
public ApiResp<AuditArchive> archive(@PathVariable Long id, @RequestBody ArchiveRequest req) {
AuditArchive a = load(id);
if (!"归档中".equals(a.getStatus())) {
throw new ApiException(409, "只有归档中状态可确认归档(当前:" + a.getStatus() + "");
}
a.setStatus("已归档");
a.setArchivedAt(Instant.now());
if (req.archiver() != null && !req.archiver().isBlank()) a.setArchiver(req.archiver());
return ApiResp.ok(archiveRepo.save(a));
}
/** 删除(仅归档中阶段)。 */
@DeleteMapping("/{id}")
public ApiResp<Void> delete(@PathVariable Long id) {
AuditArchive a = load(id);
if (!"归档中".equals(a.getStatus())) {
throw new ApiException(409, "只有归档中阶段的档案可以删除(当前:" + a.getStatus() + "");
}
archiveRepo.deleteById(id);
return ApiResp.ok(null);
}
private AuditArchive load(Long id) {
return archiveRepo.findById(id)
.orElseThrow(() -> new NotFoundException("审计档案不存在: " + id));
}
private String nextArchiveNo() {
String year = String.valueOf(Year.now().getValue());
long seq = archiveRepo.count() + 1;
return String.format("AC-%s-%03d", year, seq);
}
}