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,264 @@
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.BranchLicense;
import com.kaidi.oa.repository.BranchLicenseRepository;
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.List;
/**
* 分公司证照专项台账(分公司模块·行政与资产管理 §6)。
*
* 审计缺口补完:
* ① 营业执照号、经营范围、注册资本、登记机关等专项字段;
* ② 年检到期日 + 年检周期提醒(annualInspectDue<=30天触发临检,已过期触发逾期);
* ③ 有效期状态机(有效/临期/已过期),读时自动刷新;
* ④ GET /alerts/annual-inspect 年检分级预警端点,驱动到期提醒。
*
* 写口 default-denyADMIN/APPROVER)保护;含机密信息,读口属受限资源。
*/
@RestController
@RequestMapping("/api/oa/branch-licenses")
public class BranchLicenseController {
private final BranchLicenseRepository repo;
public BranchLicenseController(BranchLicenseRepository repo) {
this.repo = repo;
}
// ---------- CRUD ----------
@GetMapping
public ApiResp<List<BranchLicense>> list(@RequestParam(required = false) String org,
@RequestParam(required = false) String licenseType,
@RequestParam(required = false) String status) {
List<BranchLicense> all;
if (org != null && !org.isBlank()) {
all = repo.findByOrg(org);
} else if (licenseType != null && !licenseType.isBlank()) {
all = repo.findByLicenseType(licenseType);
} else {
all = repo.findAll();
}
for (BranchLicense lic : all) {
refreshExpiryView(lic);
}
if (status != null && !status.isBlank()) {
List<BranchLicense> filtered = new ArrayList<>();
for (BranchLicense lic : all) {
if (status.equals(lic.getStatus())) {
filtered.add(lic);
}
}
return ApiResp.ok(filtered);
}
return ApiResp.ok(all);
}
@GetMapping("/{id}")
public ApiResp<BranchLicense> get(@PathVariable Long id) {
BranchLicense lic = repo.findById(id)
.orElseThrow(() -> new NotFoundException("branch license not found: " + id));
refreshExpiryView(lic);
return ApiResp.ok(lic);
}
public record LicenseRequest(
String org, String licenseType, String name, String regNo,
String bizScope, String registeredCapital, String regAuthority,
String issueDate, String expiryDate, String annualInspectDue,
String remark, String owner) {
}
@PostMapping
public ApiResp<BranchLicense> create(@RequestBody LicenseRequest req) {
if (req.name() == null || req.name().isBlank()) {
throw new ApiException(400, "证照名称必填");
}
BranchLicense lic = new BranchLicense();
lic.setOrg(req.org());
lic.setLicenseType(req.licenseType() == null || req.licenseType().isBlank() ? "营业执照" : req.licenseType());
lic.setName(req.name());
lic.setRegNo(req.regNo());
lic.setBizScope(req.bizScope());
lic.setRegisteredCapital(req.registeredCapital());
lic.setRegAuthority(req.regAuthority());
lic.setIssueDate(req.issueDate());
lic.setExpiryDate(req.expiryDate());
lic.setAnnualInspectDue(req.annualInspectDue());
lic.setRemark(req.remark());
lic.setOwner(req.owner());
lic.setCreatedAt(Instant.now());
applyExpiryState(lic);
return ApiResp.ok(repo.save(lic));
}
@PatchMapping("/{id}")
public ApiResp<BranchLicense> update(@PathVariable Long id, @RequestBody LicenseRequest req) {
BranchLicense lic = repo.findById(id)
.orElseThrow(() -> new NotFoundException("branch license not found: " + id));
if (req.org() != null) lic.setOrg(req.org());
if (req.licenseType() != null && !req.licenseType().isBlank()) lic.setLicenseType(req.licenseType());
if (req.name() != null) {
if (req.name().isBlank()) throw new ApiException(400, "证照名称不能为空");
lic.setName(req.name());
}
if (req.regNo() != null) lic.setRegNo(req.regNo());
if (req.bizScope() != null) lic.setBizScope(req.bizScope());
if (req.registeredCapital() != null) lic.setRegisteredCapital(req.registeredCapital());
if (req.regAuthority() != null) lic.setRegAuthority(req.regAuthority());
if (req.issueDate() != null) lic.setIssueDate(req.issueDate());
if (req.expiryDate() != null) lic.setExpiryDate(req.expiryDate());
if (req.annualInspectDue() != null) lic.setAnnualInspectDue(req.annualInspectDue());
if (req.remark() != null) lic.setRemark(req.remark());
if (req.owner() != null) lic.setOwner(req.owner());
applyExpiryState(lic);
return ApiResp.ok(repo.save(lic));
}
@DeleteMapping("/{id}")
public ApiResp<Void> delete(@PathVariable Long id) {
if (!repo.existsById(id)) {
throw new NotFoundException("branch license not found: " + id);
}
repo.deleteById(id);
return ApiResp.ok(null);
}
// ---------- 年检分级预警端点 ----------
public record AnnualInspectAlert(
Long id, String org, String licenseType, String name, String regNo,
String annualInspectDue, long daysToInspect, String level) {
}
/**
* 年检周期提醒(需求 §6「有效期提醒/年检周期提醒」)。
* level: 逾期(已过年检截止) / 紧急(<=30天) / 临近(<=60天) / 关注(<=days参数)。
*/
@GetMapping("/alerts/annual-inspect")
public ApiResp<List<AnnualInspectAlert>> annualInspectAlerts(
@RequestParam(required = false, defaultValue = "60") int days) {
LocalDate today = LocalDate.now();
List<AnnualInspectAlert> out = new ArrayList<>();
for (BranchLicense lic : repo.findAll()) {
LocalDate due = parseDateOrNull(lic.getAnnualInspectDue());
if (due == null) {
continue;
}
long d = ChronoUnit.DAYS.between(today, due);
if (d > days) {
continue;
}
String level = d < 0 ? "逾期" : d <= 30 ? "紧急" : d <= 60 ? "临近" : "关注";
out.add(new AnnualInspectAlert(lic.getId(), lic.getOrg(), lic.getLicenseType(),
lic.getName(), lic.getRegNo(), lic.getAnnualInspectDue(), d, level));
}
out.sort((a, b) -> Long.compare(a.daysToInspect(), b.daysToInspect()));
return ApiResp.ok(out);
}
// ---------- 有效期预警端点 ----------
public record ExpiryAlert(
Long id, String org, String licenseType, String name,
String expiryDate, long daysToExpiry, String level) {
}
/**
* 有效期到期预警(与年检提醒分开,对应营业执照有效期/资质证书年检后续期)。
*/
@GetMapping("/alerts/expiry")
public ApiResp<List<ExpiryAlert>> expiryAlerts(
@RequestParam(required = false, defaultValue = "90") int days) {
LocalDate today = LocalDate.now();
List<ExpiryAlert> out = new ArrayList<>();
for (BranchLicense lic : repo.findAll()) {
LocalDate exp = parseDateOrNull(lic.getExpiryDate());
if (exp == null) {
continue;
}
long d = ChronoUnit.DAYS.between(today, exp);
if (d > days) {
continue;
}
String level = d < 0 ? "逾期" : d <= 30 ? "紧急" : d <= 60 ? "临近" : "关注";
out.add(new ExpiryAlert(lic.getId(), lic.getOrg(), lic.getLicenseType(),
lic.getName(), lic.getExpiryDate(), d, level));
}
out.sort((a, b) -> Long.compare(a.daysToExpiry(), b.daysToExpiry()));
return ApiResp.ok(out);
}
// ---------- helpers ----------
/**
* 有效期状态机(读时刷新,不落库):有效 / 临期(<=60天) / 已过期。
* 年检状态按 annualInspectDue 独立刷新:正常 / 临检(<=30天) / 逾期 / 免检(空)。
*/
private void applyExpiryState(BranchLicense lic) {
LocalDate today = LocalDate.now();
// 有效期状态
LocalDate exp = parseDateOrNull(lic.getExpiryDate());
if (exp == null) {
lic.setStatus("有效");
} else {
long d = ChronoUnit.DAYS.between(today, exp);
if (d < 0) {
lic.setStatus("已过期");
} else if (d <= 60) {
lic.setStatus("临期");
} else {
lic.setStatus("有效");
}
}
// 年检状态
LocalDate inspectDue = parseDateOrNull(lic.getAnnualInspectDue());
if (inspectDue == null) {
lic.setAnnualInspectStatus("免检");
} else {
long d = ChronoUnit.DAYS.between(today, inspectDue);
if (d < 0) {
lic.setAnnualInspectStatus("逾期");
} else if (d <= 30) {
lic.setAnnualInspectStatus("临检");
} else {
lic.setAnnualInspectStatus("正常");
}
}
}
private void refreshExpiryView(BranchLicense lic) {
applyExpiryState(lic);
}
private static LocalDate parseDateOrNull(String s) {
if (s == null || s.isBlank()) {
return null;
}
try {
return LocalDate.parse(s.trim().substring(0, Math.min(10, s.trim().length())));
} catch (DateTimeParseException | IndexOutOfBoundsException e) {
return null;
}
}
}