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( @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 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 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 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 delete(@PathVariable Long id) { alertRepo.delete(find(id)); return ApiResp.ok(null); } // ---------- 风险处置 ---------- public record DisposeReq(String disposeNote) { } @PostMapping("/{id}/dispose") @Transactional public ApiResp 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 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> scan() { List certs = certRepo.findAll(); List credentials = credRepo.findAll(); List 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> dashboard() { List 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()); } }