恢复点(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>
283 lines
13 KiB
Java
283 lines
13 KiB
Java
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.QualCert;
|
|
import com.kaidi.oa.domain.QualRiskAlert;
|
|
import com.kaidi.oa.domain.StaffCredential;
|
|
import com.kaidi.oa.repository.QualCertRepository;
|
|
import com.kaidi.oa.repository.QualRiskAlertRepository;
|
|
import com.kaidi.oa.repository.StaffCredentialRepository;
|
|
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.temporal.ChronoUnit;
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
|
|
/**
|
|
* 行政·资质管理办——合规与风险预警(Module 7 审计 MISSING 整模块无承载)。
|
|
*
|
|
* 做深的能力:
|
|
* 1) 风险台账 CRUD:手工录入(行政处罚/外部黑名单查询结果/安全事故);
|
|
* 2) 自动扫描 /scan:从 QualCert(资质台账)+ StaffCredential(人员证书库)自动生成:
|
|
* - 注册人员不足风险(当前在册、锁定期满、将到期的证书数量告警)
|
|
* - 资质过期未年报风险(年检类资质接近年检日期无处置记录)
|
|
* - 人员证书与注册单位不一致风险(挂靠检测:holder 与持证单位不一致)
|
|
* 3) 风险处置 /{id}/dispose:记录处置措施,推进至"已处置";
|
|
* 4) 关闭 /{id}/close:确认无风险关闭;
|
|
* 5) 看板 /dashboard:严重/重要/一般 分级计数,预警总体态势;
|
|
* 6) 黑名单手工录入(外部平台无法直接对接时,手工更新黑名单查询结果)。
|
|
*
|
|
* 写口已登记进 FINANCE_PREFIXES,读侧登记 SENSITIVE_READ_PREFIXES(见 sharedFileSnippets)。
|
|
*/
|
|
@RestController
|
|
@RequestMapping("/api/oa/qual-risk-alerts")
|
|
public class QualRiskAlertController {
|
|
|
|
private final QualRiskAlertRepository alertRepo;
|
|
private final QualCertRepository certRepo;
|
|
private final StaffCredentialRepository credRepo;
|
|
|
|
public QualRiskAlertController(QualRiskAlertRepository alertRepo,
|
|
QualCertRepository certRepo,
|
|
StaffCredentialRepository credRepo) {
|
|
this.alertRepo = alertRepo;
|
|
this.certRepo = certRepo;
|
|
this.credRepo = credRepo;
|
|
}
|
|
|
|
// ---------- 台账 CRUD ----------
|
|
|
|
@GetMapping
|
|
public ApiResp<List<QualRiskAlert>> list(
|
|
@RequestParam(required = false) String status,
|
|
@RequestParam(required = false) String severity,
|
|
@RequestParam(required = false) String riskType) {
|
|
if (status != null && !status.isBlank()) return ApiResp.ok(alertRepo.findByStatus(status));
|
|
if (severity != null && !severity.isBlank()) return ApiResp.ok(alertRepo.findBySeverity(severity));
|
|
if (riskType != null && !riskType.isBlank()) return ApiResp.ok(alertRepo.findByRiskType(riskType));
|
|
return ApiResp.ok(alertRepo.findAll());
|
|
}
|
|
|
|
@GetMapping("/{id}")
|
|
public ApiResp<QualRiskAlert> get(@PathVariable Long id) {
|
|
return ApiResp.ok(find(id));
|
|
}
|
|
|
|
public record AlertReq(String riskType, String severity, Long qualCertId, String qualCertName,
|
|
String description, String suggestion, String triggerValue,
|
|
String requiredValue, String assignee, String remark) {
|
|
}
|
|
|
|
@PostMapping
|
|
@Transactional
|
|
public ApiResp<QualRiskAlert> create(@RequestBody AlertReq req) {
|
|
if (req.riskType() == null || req.riskType().isBlank()) {
|
|
throw new ApiException(400, "风险类型(riskType) 不能为空");
|
|
}
|
|
QualRiskAlert a = new QualRiskAlert();
|
|
applyReq(a, req);
|
|
a.setStatus("预警中");
|
|
a.setCreatedAt(Instant.now());
|
|
a.setUpdatedAt(Instant.now());
|
|
return ApiResp.ok(alertRepo.save(a));
|
|
}
|
|
|
|
@PatchMapping("/{id}")
|
|
@Transactional
|
|
public ApiResp<QualRiskAlert> update(@PathVariable Long id, @RequestBody AlertReq req) {
|
|
QualRiskAlert a = find(id);
|
|
applyReq(a, req);
|
|
a.setUpdatedAt(Instant.now());
|
|
return ApiResp.ok(alertRepo.save(a));
|
|
}
|
|
|
|
@DeleteMapping("/{id}")
|
|
@Transactional
|
|
public ApiResp<Void> delete(@PathVariable Long id) {
|
|
alertRepo.delete(find(id));
|
|
return ApiResp.ok(null);
|
|
}
|
|
|
|
// ---------- 风险处置 ----------
|
|
|
|
public record DisposeReq(String disposeNote) {
|
|
}
|
|
|
|
@PostMapping("/{id}/dispose")
|
|
@Transactional
|
|
public ApiResp<QualRiskAlert> dispose(@PathVariable Long id, @RequestBody DisposeReq req) {
|
|
QualRiskAlert a = find(id);
|
|
if (!"预警中".equals(a.getStatus())) {
|
|
throw new ApiException(409, "只有「预警中」状态的风险可执行处置");
|
|
}
|
|
a.setStatus("已处置");
|
|
a.setDisposeNote(req.disposeNote());
|
|
a.setUpdatedAt(Instant.now());
|
|
return ApiResp.ok(alertRepo.save(a));
|
|
}
|
|
|
|
@PostMapping("/{id}/close")
|
|
@Transactional
|
|
public ApiResp<QualRiskAlert> close(@PathVariable Long id) {
|
|
QualRiskAlert a = find(id);
|
|
a.setStatus("已关闭");
|
|
a.setClosedAt(Instant.now());
|
|
a.setUpdatedAt(Instant.now());
|
|
return ApiResp.ok(alertRepo.save(a));
|
|
}
|
|
|
|
// ---------- 自动扫描:从资质台账 + 人员证书库生成风险预警 ----------
|
|
|
|
/**
|
|
* 自动扫描执行风险扫描,将以下情况写入新预警记录(幂等:相同 riskType+qualCertId 不重复插入预警中记录):
|
|
* 1) 资质"已过期"但状态未处置(过期未处置);
|
|
* 2) 资质"即将到期"且 reviewCycle 为"年检"(年检准备不足);
|
|
* 3) 在建锁定的人员证书,证书持证主体与资质台账持证主体不一致(挂靠风险)。
|
|
*/
|
|
@PostMapping("/scan")
|
|
@Transactional
|
|
public ApiResp<Map<String, Object>> scan() {
|
|
List<QualCert> certs = certRepo.findAll();
|
|
List<StaffCredential> credentials = credRepo.findAll();
|
|
List<QualRiskAlert> generated = new ArrayList<>();
|
|
LocalDate today = LocalDate.now();
|
|
|
|
for (QualCert cert : certs) {
|
|
// 1) 过期未处置
|
|
if ("已过期".equals(cert.getStatus())) {
|
|
boolean exists = alertRepo.findByQualCertId(cert.getId()).stream()
|
|
.anyMatch(a -> "资质过期未处置".equals(a.getRiskType()) && "预警中".equals(a.getStatus()));
|
|
if (!exists) {
|
|
QualRiskAlert a = buildAlert("资质过期未处置", "严重", cert,
|
|
"资质「" + cert.getName() + "」已过期,尚未启动延续或撤销处置",
|
|
"立即启动延续申报或撤销资质台账",
|
|
cert.getStatus(), "有效");
|
|
generated.add(alertRepo.save(a));
|
|
}
|
|
}
|
|
// 2) 年检资质即将到期(在 90 天内)
|
|
if ("即将到期".equals(cert.getStatus()) && "年检".equals(cert.getReviewCycle())) {
|
|
long daysLeft = 0;
|
|
if (cert.getExpireDate() != null) {
|
|
try {
|
|
daysLeft = ChronoUnit.DAYS.between(today, LocalDate.parse(cert.getExpireDate()));
|
|
} catch (Exception ignore) { /* skip */ }
|
|
}
|
|
final long dl = daysLeft;
|
|
boolean exists = alertRepo.findByQualCertId(cert.getId()).stream()
|
|
.anyMatch(a -> "年检准备不足".equals(a.getRiskType()) && "预警中".equals(a.getStatus()));
|
|
if (!exists) {
|
|
QualRiskAlert a = buildAlert("年检准备不足", "重要", cert,
|
|
"年检资质「" + cert.getName() + "」距年检截止仅剩 " + dl + " 天,请尽快准备年检材料",
|
|
"提交年检资料,完成在线填报",
|
|
dl + " 天", ">=90 天");
|
|
generated.add(alertRepo.save(a));
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3) 挂靠风险:在建锁定证书,issuer(发证机关/注册单位)不含"凯迪"时提示挂靠嫌疑。
|
|
// 注:StaffCredential 无 registeredOrg 字段,用 issuer 字段辅助判断,
|
|
// 以及 lockState="已占用" 且 issuer 显式为外部单位作为触发条件。
|
|
for (StaffCredential cred : credentials) {
|
|
String issuerOrg = cred.getIssuer();
|
|
String name = cred.getPersonName();
|
|
if ("已占用".equals(cred.getLockState())
|
|
&& issuerOrg != null && !issuerOrg.isBlank()
|
|
&& !issuerOrg.contains("凯迪") && !issuerOrg.contains("住建")) {
|
|
String safeName = name != null ? name : "(未知人员)";
|
|
boolean exists = alertRepo.findAll().stream()
|
|
.anyMatch(a -> "挂靠风险".equals(a.getRiskType())
|
|
&& a.getDescription() != null && a.getDescription().contains(safeName)
|
|
&& "预警中".equals(a.getStatus()));
|
|
if (!exists) {
|
|
QualRiskAlert ra = new QualRiskAlert();
|
|
ra.setRiskType("挂靠风险");
|
|
ra.setSeverity("严重");
|
|
ra.setDescription("人员「" + safeName + "」证书发证机关为「" + issuerOrg + "」,在建占用状态,请核查社保与注册单位一致性");
|
|
ra.setSuggestion("核查人员社保缴纳单位与注册单位是否一致,必要时限期整改");
|
|
ra.setTriggerValue(issuerOrg);
|
|
ra.setRequiredValue("凯迪科技");
|
|
ra.setStatus("预警中");
|
|
ra.setCreatedAt(Instant.now());
|
|
ra.setUpdatedAt(Instant.now());
|
|
generated.add(alertRepo.save(ra));
|
|
}
|
|
}
|
|
}
|
|
|
|
return ApiResp.ok(Map.of("scanned", generated.size(), "alerts", generated));
|
|
}
|
|
|
|
// ---------- 看板统计 ----------
|
|
|
|
@GetMapping("/dashboard")
|
|
public ApiResp<Map<String, Object>> dashboard() {
|
|
List<QualRiskAlert> all = alertRepo.findAll();
|
|
long serious = all.stream().filter(a -> "严重".equals(a.getSeverity()) && "预警中".equals(a.getStatus())).count();
|
|
long important = all.stream().filter(a -> "重要".equals(a.getSeverity()) && "预警中".equals(a.getStatus())).count();
|
|
long general = all.stream().filter(a -> "一般".equals(a.getSeverity()) && "预警中".equals(a.getStatus())).count();
|
|
long disposed = all.stream().filter(a -> "已处置".equals(a.getStatus())).count();
|
|
long closed = all.stream().filter(a -> "已关闭".equals(a.getStatus())).count();
|
|
long total = all.size();
|
|
return ApiResp.ok(Map.of(
|
|
"total", total,
|
|
"activeSerious", serious,
|
|
"activeImportant", important,
|
|
"activeGeneral", general,
|
|
"disposed", disposed,
|
|
"closed", closed
|
|
));
|
|
}
|
|
|
|
// ---------- private ----------
|
|
|
|
private QualRiskAlert buildAlert(String riskType, String severity, QualCert cert,
|
|
String description, String suggestion,
|
|
String triggerValue, String requiredValue) {
|
|
QualRiskAlert a = new QualRiskAlert();
|
|
a.setRiskType(riskType);
|
|
a.setSeverity(severity);
|
|
a.setQualCertId(cert.getId());
|
|
a.setQualCertName(cert.getName());
|
|
a.setDescription(description);
|
|
a.setSuggestion(suggestion);
|
|
a.setTriggerValue(triggerValue);
|
|
a.setRequiredValue(requiredValue);
|
|
a.setStatus("预警中");
|
|
a.setCreatedAt(Instant.now());
|
|
a.setUpdatedAt(Instant.now());
|
|
return a;
|
|
}
|
|
|
|
private QualRiskAlert find(Long id) {
|
|
return alertRepo.findById(id).orElseThrow(() -> new NotFoundException("风险预警不存在: " + id));
|
|
}
|
|
|
|
private void applyReq(QualRiskAlert a, AlertReq req) {
|
|
if (req.riskType() != null) a.setRiskType(req.riskType());
|
|
if (req.severity() != null) a.setSeverity(req.severity());
|
|
if (req.qualCertId() != null) a.setQualCertId(req.qualCertId());
|
|
if (req.qualCertName() != null) a.setQualCertName(req.qualCertName());
|
|
if (req.description() != null) a.setDescription(req.description());
|
|
if (req.suggestion() != null) a.setSuggestion(req.suggestion());
|
|
if (req.triggerValue() != null) a.setTriggerValue(req.triggerValue());
|
|
if (req.requiredValue() != null) a.setRequiredValue(req.requiredValue());
|
|
if (req.assignee() != null) a.setAssignee(req.assignee());
|
|
}
|
|
}
|