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.LabEln; import com.kaidi.oa.domain.LabInternalAudit; import com.kaidi.oa.domain.TestTask; import com.kaidi.oa.repository.LabElnRepository; import com.kaidi.oa.repository.LabInternalAuditRepository; import com.kaidi.oa.repository.TestTaskRepository; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.GetMapping; 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.util.ArrayList; import java.util.Collections; import java.util.List; /** * 实验室·内部审核(质量管理与合规 §6 深化)。按周期自动抽取一定比例实验记录做合规检查并生成审核报告。 * * - generate 按 scope/samplePct 从 ELN/检测任务中随机抽样,自动跑机检合规项(签名/复核/原始数据齐全), * 产出初步 findings 与合规率,落「检查中」; * - report 定稿审核报告:补审核员人工结论,置「已出报告」。 * * 机检规则示例:ELN 须已签名且数据完整;检测任务须双人复核(reviewer≠tester)且有结果值。 * 写口默认受 default-deny(ADMIN/APPROVER) 保护。 */ @RestController @RequestMapping("/api/oa/lab-internal-audits") public class LabInternalAuditController { private static final List SCOPES = List.of("ELN", "检测任务", "全部"); private final LabInternalAuditRepository auditRepo; private final LabElnRepository elnRepo; private final TestTaskRepository taskRepo; public LabInternalAuditController(LabInternalAuditRepository auditRepo, LabElnRepository elnRepo, TestTaskRepository taskRepo) { this.auditRepo = auditRepo; this.elnRepo = elnRepo; this.taskRepo = taskRepo; } @GetMapping public ApiResp> list(@RequestParam(required = false) String status) { if (status != null && !status.isBlank()) { return ApiResp.ok(auditRepo.findByStatus(status)); } return ApiResp.ok(auditRepo.findAll()); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(must(id)); } public record GenerateRequest(String period, String scope, Integer samplePct, String auditor) { } /** 抽样并机检:随机抽 samplePct% 的记录,跑合规项,算合规率,落「检查中」。 */ @PostMapping("/generate") @Transactional public ApiResp generate(@RequestBody GenerateRequest req) { String scope = req.scope() == null || req.scope().isBlank() ? "全部" : req.scope(); if (!SCOPES.contains(scope)) { throw new ApiException(400, "审核范围只能是 " + SCOPES); } int pct = req.samplePct() == null ? 20 : req.samplePct(); if (pct < 1 || pct > 100) { throw new ApiException(400, "抽样比例(samplePct) 必须在 1-100 之间"); } // 收集候选记录(ref + 机检结果)。 List sampledRefs = new ArrayList<>(); List findings = new ArrayList<>(); int total = 0; int passed = 0; if ("ELN".equals(scope) || "全部".equals(scope)) { List elns = new ArrayList<>(elnRepo.findAll()); Collections.shuffle(elns); int take = (int) Math.ceil(elns.size() * pct / 100.0); for (int i = 0; i < Math.min(take, elns.size()); i++) { LabEln e = elns.get(i); boolean ok = Boolean.TRUE.equals(e.getSigned()) && e.getRawData() != null && !e.getRawData().isBlank(); String result = ok ? "合规" : "不合规"; String note = Boolean.TRUE.equals(e.getSigned()) ? (ok ? "已签名且原始数据完整" : "缺原始数据") : "未电子签名"; sampledRefs.add("ELN#" + e.getId() + " " + nz(e.getElnNo())); findings.add("{\"ref\":\"ELN#" + e.getId() + "\",\"item\":\"签名+原始数据\",\"result\":\"" + result + "\",\"note\":\"" + note + "\"}"); total++; if (ok) passed++; } } if ("检测任务".equals(scope) || "全部".equals(scope)) { List tasks = new ArrayList<>(taskRepo.findAll()); Collections.shuffle(tasks); int take = (int) Math.ceil(tasks.size() * pct / 100.0); for (int i = 0; i < Math.min(take, tasks.size()); i++) { TestTask t = tasks.get(i); boolean hasResult = t.getResultValue() != null && !t.getResultValue().isBlank(); boolean dualReview = t.getReviewer() != null && !t.getReviewer().isBlank() && !t.getReviewer().equals(t.getTester()); boolean ok = hasResult && dualReview; String result = ok ? "合规" : "不合规"; String note = !hasResult ? "无结果值" : (!dualReview ? "未双人复核" : "结果+双人复核齐全"); sampledRefs.add("TASK#" + t.getId() + " " + nz(t.getTaskNo())); findings.add("{\"ref\":\"TASK#" + t.getId() + "\",\"item\":\"结果+双人复核\",\"result\":\"" + result + "\",\"note\":\"" + note + "\"}"); total++; if (ok) passed++; } } double rate = total == 0 ? 100.0 : Math.round(passed * 10000.0 / total) / 100.0; LabInternalAudit a = new LabInternalAudit(); a.setAuditNo("IA-" + (auditRepo.count() + 1)); a.setPeriod(req.period()); a.setScope(scope); a.setSamplePct(pct); a.setSampledCount(total); a.setSampledJson("[\"" + String.join("\",\"", sampledRefs) + "\"]"); a.setFindingsJson("[" + String.join(",", findings) + "]"); a.setPassRate(rate); a.setAuditor(req.auditor()); a.setStatus("检查中"); a.setConclusion(total == 0 ? "无可抽样记录" : (rate >= 90 ? "整体合规" : "存在不符合项,需整改")); a.setCreatedAt(Instant.now()); return ApiResp.ok(auditRepo.save(a)); } public record ReportRequest(String auditor, String conclusion) { } /** 定稿审核报告:补人工结论,置「已出报告」。 */ @PostMapping("/{id}/report") @Transactional public ApiResp report(@PathVariable Long id, @RequestBody ReportRequest req) { LabInternalAudit a = must(id); if ("已出报告".equals(a.getStatus())) { throw new ApiException(409, "审核报告已定稿"); } if (req.auditor() != null && !req.auditor().isBlank()) a.setAuditor(req.auditor()); if (req.conclusion() != null && !req.conclusion().isBlank()) a.setConclusion(req.conclusion()); a.setStatus("已出报告"); return ApiResp.ok(auditRepo.save(a)); } private LabInternalAudit must(Long id) { return auditRepo.findById(id) .orElseThrow(() -> new NotFoundException("internal audit not found: " + id)); } private static String nz(String v) { return v == null ? "" : v; } }