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,310 @@
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.CultureArchive;
import com.kaidi.oa.repository.CultureArchiveRepository;
import com.kaidi.oa.repository.CultureAwardRepository;
import com.kaidi.oa.repository.CultureCaseRepository;
import com.kaidi.oa.repository.CultureMaterialRepository;
import com.kaidi.oa.repository.CultureStoryRepository;
import com.kaidi.oa.repository.CultureSurveyRepository;
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.PatchMapping;
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.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 项目文化资产归档(品牌推广部·模块6,整模块从0建起)。
*
* - CRUD + 归档状态机(草稿→归档中→已归档/已撤销)。
* - /pack/{projectCode}:自动打包聚合——跨 CultureStory/CultureMaterial/CultureCase/
* CultureAward/CultureSurvey 取该项目各类资产计数,回填到 CultureArchive,一键生成档案。
* - /yearbook:文化年报——按年份聚合已归档项目的所有资产计数,自动生成年报摘要。
* - /showcase:数字展厅/文化墙——按项目返回归档亮点(代替地图/时间轴数据源),
* 含各项目文化资产总量与代表性故事/案例摘要。
*
* 含归档项目信息(机密管理),登记进 AuthInterceptor 的 SENSITIVE_READ_PREFIXES。
*/
@RestController
@RequestMapping("/api/oa/culture-archives")
public class CultureArchiveController {
private final CultureArchiveRepository archiveRepo;
private final CultureStoryRepository storyRepo;
private final CultureMaterialRepository materialRepo;
private final CultureCaseRepository caseRepo;
private final CultureAwardRepository awardRepo;
private final CultureSurveyRepository surveyRepo;
public CultureArchiveController(CultureArchiveRepository archiveRepo,
CultureStoryRepository storyRepo,
CultureMaterialRepository materialRepo,
CultureCaseRepository caseRepo,
CultureAwardRepository awardRepo,
CultureSurveyRepository surveyRepo) {
this.archiveRepo = archiveRepo;
this.storyRepo = storyRepo;
this.materialRepo = materialRepo;
this.caseRepo = caseRepo;
this.awardRepo = awardRepo;
this.surveyRepo = surveyRepo;
}
@GetMapping
public ApiResp<List<CultureArchive>> list(
@RequestParam(required = false) String status,
@RequestParam(required = false) String year) {
if (status != null && !status.isBlank()) {
return ApiResp.ok(archiveRepo.findByStatus(status));
}
if (year != null && !year.isBlank()) {
return ApiResp.ok(archiveRepo.findByArchiveDateStartingWith(year));
}
return ApiResp.ok(archiveRepo.findAll());
}
@GetMapping("/{id}")
public ApiResp<CultureArchive> get(@PathVariable Long id) {
return ApiResp.ok(require(id));
}
public record ArchiveRequest(
String code, String projectCode, String projectName,
String completionDate, String operator, String remark, String archiveFileUrl) {
}
@PostMapping
public ApiResp<CultureArchive> create(@RequestBody ArchiveRequest req) {
if (req.projectCode() == null || req.projectCode().isBlank()) {
throw new ApiException(400, "项目编号(projectCode) 不能为空");
}
if (archiveRepo.findByProjectCode(req.projectCode()).isPresent()) {
throw new ApiException(409, "项目「" + req.projectCode() + "」已存在文化档案,不能重复创建");
}
CultureArchive a = new CultureArchive();
a.setCode(req.code() == null || req.code().isBlank()
? "CA-" + (archiveRepo.count() + 1) : req.code());
a.setProjectCode(req.projectCode());
a.setProjectName(req.projectName());
a.setCompletionDate(req.completionDate());
a.setOperator(req.operator());
a.setRemark(req.remark());
a.setArchiveFileUrl(req.archiveFileUrl());
a.setStatus("草稿");
a.setCreatedAt(Instant.now());
return ApiResp.ok(archiveRepo.save(a));
}
@PatchMapping("/{id}")
public ApiResp<CultureArchive> update(@PathVariable Long id, @RequestBody ArchiveRequest req) {
CultureArchive a = require(id);
if ("已归档".equals(a.getStatus())) {
throw new ApiException(409, "已归档的档案不能修改");
}
if (req.projectName() != null) a.setProjectName(req.projectName());
if (req.completionDate() != null) a.setCompletionDate(req.completionDate());
if (req.operator() != null) a.setOperator(req.operator());
if (req.remark() != null) a.setRemark(req.remark());
if (req.archiveFileUrl() != null) a.setArchiveFileUrl(req.archiveFileUrl());
return ApiResp.ok(archiveRepo.save(a));
}
@DeleteMapping("/{id}")
@Transactional
public ApiResp<Void> delete(@PathVariable Long id) {
CultureArchive a = require(id);
if ("已归档".equals(a.getStatus())) {
throw new ApiException(409, "已归档档案不能删除");
}
archiveRepo.deleteById(id);
return ApiResp.ok(null);
}
// ---------- 自动打包聚合(核心能力)----------
/**
* 自动打包:跨模块聚合该项目的所有文化资产计数(故事/素材/案例/评优/调查),
* 回填到 CultureArchive,生成标准档案数据。若档案不存在则自动新建。
* 对应需求:"项目完工后自动打包文化资产归档"。
*/
@PostMapping("/pack/{projectCode}")
@Transactional
public ApiResp<CultureArchive> pack(@PathVariable String projectCode,
@RequestBody(required = false) PackRequest req) {
// 找到或新建档案
CultureArchive a = archiveRepo.findByProjectCode(projectCode).orElseGet(() -> {
CultureArchive na = new CultureArchive();
na.setCode("CA-" + (archiveRepo.count() + 1));
na.setProjectCode(projectCode);
na.setProjectName(req != null && req.projectName() != null ? req.projectName() : projectCode);
na.setStatus("归档中");
na.setCreatedAt(Instant.now());
return na;
});
if ("已归档".equals(a.getStatus())) {
throw new ApiException(409, "项目「" + projectCode + "」已完成归档,不可重复打包");
}
// 跨模块聚合:故事(已发布)
long stories = storyRepo.countByProjectCodeAndStatus(projectCode, "已发布");
a.setStoryCount((int) stories);
// 素材(已通过)
long materials = materialRepo.countByProjectCode(projectCode);
a.setMaterialCount((int) materials);
// 案例(已入库 + 优秀案例)
long cases = caseRepo.findByProjectCode(projectCode).stream()
.filter(c -> "已入库".equals(c.getStatus()) || "优秀案例".equals(c.getStatus()))
.count();
a.setCaseCount((int) cases);
// 评优(已表彰)
long awards = awardRepo.findAll().stream()
.filter(aw -> projectCode.equals(aw.getProjectCode()) && "已表彰".equals(aw.getStatus()))
.count();
a.setAwardCount((int) awards);
// 调查(已结束)
long surveys = surveyRepo.findAll().stream()
.filter(s -> projectCode.equals(s.getProjectCode()) && "已结束".equals(s.getStatus()))
.count();
a.setSurveyCount((int) surveys);
a.setStatus("归档中");
if (req != null && req.completionDate() != null) a.setCompletionDate(req.completionDate());
if (req != null && req.operator() != null) a.setOperator(req.operator());
return ApiResp.ok(archiveRepo.save(a));
}
public record PackRequest(String projectName, String completionDate, String operator) {
}
/** 确认归档:归档中 → 已归档,设定归档日。 */
@PostMapping("/{id}/confirm")
public ApiResp<CultureArchive> confirm(@PathVariable Long id) {
CultureArchive a = require(id);
if (!"归档中".equals(a.getStatus())) {
throw new ApiException(409, "仅「归档中」可确认归档,当前「" + a.getStatus() + "");
}
a.setStatus("已归档");
a.setArchiveDate(LocalDate.now().toString());
return ApiResp.ok(archiveRepo.save(a));
}
/** 撤销归档:已归档 → 已撤销(管理员操作)。 */
@PostMapping("/{id}/revoke")
public ApiResp<CultureArchive> revoke(@PathVariable Long id) {
CultureArchive a = require(id);
if (!"已归档".equals(a.getStatus())) {
throw new ApiException(409, "仅「已归档」可撤销,当前「" + a.getStatus() + "");
}
a.setStatus("已撤销");
return ApiResp.ok(archiveRepo.save(a));
}
// ---------- 文化年报(模块6 gap 2----------
public record YearbookEntry(
String year, int archiveCount, int totalStories, int totalMaterials,
int totalCases, int totalAwards, int totalSurveys, List<String> projectNames) {
}
/**
* 文化年报:按归档年份聚合已归档项目的文化资产总量,自动生成年报摘要。
* 对应需求:"每年编制项目文化年报,汇总各项目文化建设成果"。
*/
@GetMapping("/yearbook")
public ApiResp<List<YearbookEntry>> yearbook() {
List<CultureArchive> archived = archiveRepo.findByStatus("已归档");
Map<String, YearbookAccum> byYear = new LinkedHashMap<>();
for (CultureArchive a : archived) {
String year = a.getArchiveDate() != null && a.getArchiveDate().length() >= 4
? a.getArchiveDate().substring(0, 4) : "未知年份";
byYear.computeIfAbsent(year, k -> new YearbookAccum()).add(a);
}
List<YearbookEntry> result = new ArrayList<>();
for (Map.Entry<String, YearbookAccum> e : byYear.entrySet()) {
YearbookAccum acc = e.getValue();
result.add(new YearbookEntry(e.getKey(), acc.count,
acc.stories, acc.materials, acc.cases, acc.awards, acc.surveys,
acc.projectNames));
}
return ApiResp.ok(result);
}
private static class YearbookAccum {
int count, stories, materials, cases, awards, surveys;
List<String> projectNames = new ArrayList<>();
void add(CultureArchive a) {
count++;
stories += a.getStoryCount() == null ? 0 : a.getStoryCount();
materials += a.getMaterialCount() == null ? 0 : a.getMaterialCount();
cases += a.getCaseCount() == null ? 0 : a.getCaseCount();
awards += a.getAwardCount() == null ? 0 : a.getAwardCount();
surveys += a.getSurveyCount() == null ? 0 : a.getSurveyCount();
if (a.getProjectName() != null) projectNames.add(a.getProjectName());
}
}
// ---------- 数字展厅/文化墙(模块6 gap 3----------
public record ShowcaseItem(
String projectCode, String projectName, String completionDate,
int storyCount, int materialCount, int caseCount, int awardCount,
int totalAssets) {
}
/**
* 数字展厅/文化墙:返回所有已归档项目的文化亮点数据(按时间轴排列)。
* 前端可据此渲染时间轴卡片或地图标注。
* 对应需求:"以地图或时间轴形式展示各项目文化亮点"。
*/
@GetMapping("/showcase")
public ApiResp<List<ShowcaseItem>> showcase() {
List<CultureArchive> archived = archiveRepo.findByStatus("已归档");
List<ShowcaseItem> items = new ArrayList<>();
for (CultureArchive a : archived) {
int total = (a.getStoryCount() == null ? 0 : a.getStoryCount())
+ (a.getMaterialCount() == null ? 0 : a.getMaterialCount())
+ (a.getCaseCount() == null ? 0 : a.getCaseCount())
+ (a.getAwardCount() == null ? 0 : a.getAwardCount());
items.add(new ShowcaseItem(
a.getProjectCode(), a.getProjectName(), a.getCompletionDate(),
a.getStoryCount() == null ? 0 : a.getStoryCount(),
a.getMaterialCount() == null ? 0 : a.getMaterialCount(),
a.getCaseCount() == null ? 0 : a.getCaseCount(),
a.getAwardCount() == null ? 0 : a.getAwardCount(),
total));
}
// 按完工日倒排
items.sort((x, y) -> {
String a2 = x.completionDate() == null ? "" : x.completionDate();
String b2 = y.completionDate() == null ? "" : y.completionDate();
return b2.compareTo(a2);
});
return ApiResp.ok(items);
}
private CultureArchive require(Long id) {
return archiveRepo.findById(id)
.orElseThrow(() -> new NotFoundException("culture archive not found: " + id));
}
}