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,321 @@
package com.kaidi.oa.web;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.common.NotFoundException;
import com.kaidi.oa.domain.DeclAcceptance;
import com.kaidi.oa.repository.DeclAcceptanceRepository;
import com.kaidi.oa.repository.DeclarationRepository;
import jakarta.servlet.http.HttpServletRequest;
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.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 申报项目验收任务(创新研发中心·申报服务部,需求 §3 验收管理)。
*
* 区别于工程监理部竣工验收(CompletionAcceptance 绑 SupervisionProject),本控制器面向「科技计划/技改」等
* 政府申报项目的结题验收,补 PARTIAL(med):自动生成验收任务 + 验收材料编制审核状态机。
* <ul>
* <li>新建即按 acceptType 自动生成标准验收材料清单(总结报告/财务决算/检测报告/用户意见…)置「待准备」;</li>
* <li>材料编制:/{id}/materials 勾选材料齐套(ready),齐套后可提交审核;</li>
* <li>状态机:待准备 → 材料编制中 → 待审核 → 已审核 → 已验收;审核 /{id}/review 通过进「已审核」、
* 驳回退回「材料编制中」;/{id}/accept 完成验收置「已验收」回写实际验收日;</li>
* <li>到期分级预警(/alerts/due):未验收且 planDate 临近/逾期的验收任务。</li>
* </ul>
* 日期 String(YYYY-MM-DD);状态迁移/批量改写带 {@link Transactional}。
*/
@RestController
@RequestMapping("/api/oa/decl-acceptances")
public class DeclAcceptanceController {
private static final String ST_PREP = "待准备";
private static final String ST_DRAFTING = "材料编制中";
private static final String ST_TOREVIEW = "待审核";
private static final String ST_REVIEWED = "已审核";
private static final String ST_ACCEPTED = "已验收";
private final DeclAcceptanceRepository repo;
private final DeclarationRepository declRepo;
private final CurrentUserResolver currentUser;
private final ObjectMapper objectMapper;
public DeclAcceptanceController(DeclAcceptanceRepository repo,
DeclarationRepository declRepo,
CurrentUserResolver currentUser,
ObjectMapper objectMapper) {
this.repo = repo;
this.declRepo = declRepo;
this.currentUser = currentUser;
this.objectMapper = objectMapper;
}
@GetMapping
public ApiResp<List<DeclAcceptance>> list(@RequestParam(required = false) String status,
@RequestParam(required = false) Long declarationId) {
if (status != null && !status.isBlank()) {
return ApiResp.ok(repo.findByStatus(status));
}
if (declarationId != null) {
return ApiResp.ok(repo.findByDeclarationId(declarationId));
}
return ApiResp.ok(repo.findAll());
}
@GetMapping("/{id}")
public ApiResp<DeclAcceptance> get(@PathVariable Long id) {
return ApiResp.ok(load(id));
}
public record CreateRequest(Long declarationId, String projectName, String authority,
String acceptType, String planDate, String owner, String remark) {
}
/** 自动生成验收任务:按 acceptType 内置标准验收材料清单(系统自动生成),初始「待准备」。 */
@PostMapping
@Transactional
public ApiResp<DeclAcceptance> create(@RequestBody CreateRequest req, HttpServletRequest request) {
if (req.projectName() == null || req.projectName().isBlank()) {
throw new ApiException(400, "验收项目名称(projectName) 不能为空");
}
if (req.declarationId() != null && !declRepo.existsById(req.declarationId())) {
throw new ApiException(400, "关联的申报项目不存在:" + req.declarationId());
}
DeclAcceptance a = new DeclAcceptance();
a.setCode("YS-" + (repo.count() + 1));
a.setDeclarationId(req.declarationId());
a.setProjectName(req.projectName().trim());
a.setAuthority(req.authority());
String type = req.acceptType() == null || req.acceptType().isBlank() ? "结题验收" : req.acceptType();
a.setAcceptType(type);
a.setStatus(ST_PREP);
a.setMaterialsJson(writeMaterials(defaultMaterials(type)));
a.setPlanDate(req.planDate());
a.setOwner(req.owner() == null || req.owner().isBlank() ? currentUser.resolveLabel(request) : req.owner());
a.setRemark(req.remark());
a.setCreatedAt(Instant.now());
return ApiResp.ok(repo.save(a));
}
@PatchMapping("/{id}")
@Transactional
public ApiResp<DeclAcceptance> update(@PathVariable Long id, @RequestBody CreateRequest req) {
DeclAcceptance a = load(id);
if (req.projectName() != null && !req.projectName().isBlank()) a.setProjectName(req.projectName().trim());
if (req.authority() != null) a.setAuthority(req.authority());
if (req.acceptType() != null && !req.acceptType().isBlank()) a.setAcceptType(req.acceptType());
if (req.planDate() != null) a.setPlanDate(req.planDate());
if (req.owner() != null) a.setOwner(req.owner());
if (req.remark() != null) a.setRemark(req.remark());
// status / materialsJson / acceptedDate 仅由业务动作推进,禁止客户端直写。
return ApiResp.ok(repo.save(a));
}
@DeleteMapping("/{id}")
public ApiResp<Void> delete(@PathVariable Long id) {
if (!repo.existsById(id)) {
throw new NotFoundException("decl acceptance not found: " + id);
}
repo.deleteById(id);
return ApiResp.ok(null);
}
// ---------- 材料编制 ----------
public record MaterialFlag(String name, Boolean ready) {
}
public record MaterialsRequest(List<MaterialFlag> materials) {
}
/** 勾选/更新验收材料齐套状态。首次编制把「待准备」推进到「材料编制中」。 */
@PostMapping("/{id}/materials")
@Transactional
public ApiResp<DeclAcceptance> materials(@PathVariable Long id, @RequestBody MaterialsRequest req) {
DeclAcceptance a = load(id);
if (ST_ACCEPTED.equals(a.getStatus())) {
throw new ApiException(409, "已验收,材料不可再改");
}
List<Map<String, Object>> mats = readMaterials(a.getMaterialsJson());
if (req != null && req.materials() != null) {
for (MaterialFlag f : req.materials()) {
for (Map<String, Object> m : mats) {
if (f.name() != null && f.name().equals(m.get("name"))) {
m.put("ready", Boolean.TRUE.equals(f.ready()));
}
}
}
}
a.setMaterialsJson(writeMaterials(mats));
if (ST_PREP.equals(a.getStatus())) {
a.setStatus(ST_DRAFTING);
}
return ApiResp.ok(repo.save(a));
}
/** 提交审核:要求所有材料齐套后方可提交。置「待审核」。 */
@PostMapping("/{id}/submit-review")
@Transactional
public ApiResp<DeclAcceptance> submitReview(@PathVariable Long id) {
DeclAcceptance a = load(id);
if (!ST_DRAFTING.equals(a.getStatus()) && !ST_PREP.equals(a.getStatus())) {
throw new ApiException(400, "当前状态「" + a.getStatus() + "」不可提交审核");
}
List<Map<String, Object>> mats = readMaterials(a.getMaterialsJson());
long notReady = mats.stream().filter(m -> !Boolean.TRUE.equals(m.get("ready"))).count();
if (notReady > 0) {
throw new ApiException(400, "尚有 " + notReady + " 项验收材料未齐套,不能提交审核");
}
a.setStatus(ST_TOREVIEW);
return ApiResp.ok(repo.save(a));
}
public record ReviewRequest(boolean pass, String reviewer, String note) {
}
/** 材料审核:通过进「已审核」,驳回退回「材料编制中」。审核人/意见留存。 */
@PostMapping("/{id}/review")
@Transactional
public ApiResp<DeclAcceptance> review(@PathVariable Long id, @RequestBody ReviewRequest req,
HttpServletRequest request) {
DeclAcceptance a = load(id);
if (!ST_TOREVIEW.equals(a.getStatus())) {
throw new ApiException(400, "当前状态「" + a.getStatus() + "」无待审核的验收材料");
}
a.setReviewer(req.reviewer() == null || req.reviewer().isBlank()
? currentUser.resolveLabel(request) : req.reviewer());
a.setReviewNote(req.note());
a.setStatus(req.pass() ? ST_REVIEWED : ST_DRAFTING);
return ApiResp.ok(repo.save(a));
}
public record AcceptRequest(String acceptedDate) {
}
/** 完成验收:要求已审核,置「已验收」并回写实际验收日。 */
@PostMapping("/{id}/accept")
@Transactional
public ApiResp<DeclAcceptance> accept(@PathVariable Long id,
@RequestBody(required = false) AcceptRequest req) {
DeclAcceptance a = load(id);
if (!ST_REVIEWED.equals(a.getStatus())) {
throw new ApiException(400, "需材料审核通过(已审核)后方可完成验收");
}
a.setStatus(ST_ACCEPTED);
a.setAcceptedDate(req != null && req.acceptedDate() != null && !req.acceptedDate().isBlank()
? req.acceptedDate() : LocalDate.now().toString());
return ApiResp.ok(repo.save(a));
}
// ---------- 到期分级预警 ----------
public record DueAlert(Long id, String code, String projectName, String acceptType,
String planDate, String status, long daysToDue, String level) {
}
@GetMapping("/alerts/due")
public ApiResp<List<DueAlert>> dueAlerts(@RequestParam(required = false, defaultValue = "60") int days) {
LocalDate today = LocalDate.now();
List<DueAlert> out = new ArrayList<>();
for (DeclAcceptance a : repo.findAll()) {
if (ST_ACCEPTED.equals(a.getStatus())) {
continue;
}
LocalDate due = parseDateOrNull(a.getPlanDate());
if (due == null) {
continue;
}
long daysTo = ChronoUnit.DAYS.between(today, due);
if (daysTo > days) {
continue;
}
String level = daysTo < 0 ? "逾期" : daysTo <= 7 ? "紧急" : daysTo <= 15 ? "临近" : "关注";
out.add(new DueAlert(a.getId(), a.getCode(), a.getProjectName(), a.getAcceptType(),
a.getPlanDate(), a.getStatus(), daysTo, level));
}
out.sort((x, y) -> Long.compare(x.daysToDue(), y.daysToDue()));
return ApiResp.ok(out);
}
// ---------- helpers ----------
private DeclAcceptance load(Long id) {
return repo.findById(id)
.orElseThrow(() -> new NotFoundException("decl acceptance not found: " + id));
}
/** 按验收类型生成标准材料清单。 */
private static List<Map<String, Object>> defaultMaterials(String type) {
List<String> names;
if ("技改验收".equals(type)) {
names = List.of("项目总结报告", "财务决算报告", "设备检测报告", "环保验收意见", "用户使用证明");
} else if ("中期检查".equals(type)) {
names = List.of("中期进展报告", "经费使用情况表", "阶段成果清单");
} else if ("绩效评价".equals(type)) {
names = List.of("绩效自评报告", "资金到账与支出明细", "成果转化证明", "社会效益说明");
} else {
// 结题验收(默认)
names = List.of("项目总结报告", "财务决算报告", "检测/测试报告", "成果证明材料", "用户意见");
}
List<Map<String, Object>> mats = new ArrayList<>();
for (String n : names) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("name", n);
m.put("ready", Boolean.FALSE);
mats.add(m);
}
return mats;
}
private List<Map<String, Object>> readMaterials(String json) {
if (json == null || json.isBlank()) {
return new ArrayList<>();
}
try {
List<Map<String, Object>> parsed = objectMapper.readValue(
json, new TypeReference<List<Map<String, Object>>>() {
});
return parsed == null ? new ArrayList<>() : new ArrayList<>(parsed);
} catch (Exception e) {
return new ArrayList<>();
}
}
private String writeMaterials(List<Map<String, Object>> mats) {
try {
return objectMapper.writeValueAsString(mats);
} catch (Exception e) {
return "[]";
}
}
private static LocalDate parseDateOrNull(String s) {
if (s == null || s.isBlank()) {
return null;
}
try {
String t = s.trim();
return LocalDate.parse(t.substring(0, Math.min(10, t.length())));
} catch (DateTimeParseException | IndexOutOfBoundsException e) {
return null;
}
}
}