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.LabDeviation; import com.kaidi.oa.domain.TestTask; import com.kaidi.oa.repository.LabDeviationRepository; 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.time.LocalDate; import java.util.List; /** * 实验室·偏差报告 OOS/OOT 调查闭环(实验任务与检测流程 §2 深化)。 * * 把 TestTask 命中 OOS偏差 后“仅置态”补深为真正的偏差闭环: * 登记(open) → investigate 调查(写根因/结论,置「待决策」)→ decide 决策: * - 复检:基于原任务克隆一张复检 TestTask(解锁结果待重测),偏差置「复检」; * - 放行:经审核放行,偏差置「已放行」,原任务回到「待复核」; * - 驳回:偏差置「已驳回」。 * * 与 TestTask 真关联(taskId),跨实体推进。写口默认受 default-deny(ADMIN/APPROVER) 保护。 */ @RestController @RequestMapping("/api/oa/lab-deviations") public class LabDeviationController { private static final List TYPES = List.of("OOS", "OOT", "设备异常"); private static final List DECISIONS = List.of("复检", "放行", "驳回"); private final LabDeviationRepository devRepo; private final TestTaskRepository taskRepo; public LabDeviationController(LabDeviationRepository devRepo, TestTaskRepository taskRepo) { this.devRepo = devRepo; this.taskRepo = taskRepo; } @GetMapping public ApiResp> list(@RequestParam(required = false) String status, @RequestParam(required = false) Long taskId) { if (taskId != null) { return ApiResp.ok(devRepo.findByTaskId(taskId)); } if (status != null && !status.isBlank()) { return ApiResp.ok(devRepo.findByStatus(status)); } return ApiResp.ok(devRepo.findAll()); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(must(id)); } public record OpenRequest(Long taskId, String deviationType, String reporter, String description) { } /** 登记偏差:从检测任务带出上下文,落「登记」。 */ @PostMapping @Transactional public ApiResp open(@RequestBody OpenRequest req) { if (req.taskId() == null) { throw new ApiException(400, "检测任务(taskId) 不能为空"); } TestTask t = taskRepo.findById(req.taskId()) .orElseThrow(() -> new ApiException(400, "检测任务不存在: " + req.taskId())); String type = req.deviationType() == null || req.deviationType().isBlank() ? "OOS" : req.deviationType(); if (!TYPES.contains(type)) { throw new ApiException(400, "偏差类型只能是 " + TYPES); } LabDeviation d = new LabDeviation(); d.setDeviationNo(type + "-" + (devRepo.count() + 1)); d.setTaskId(t.getId()); d.setTaskNo(t.getTaskNo()); d.setSampleName(t.getSampleName()); d.setDetectItem(t.getDetectItem()); d.setDeviationType(type); d.setResultValue(t.getResultValue()); d.setStandardLimit(t.getStandardLimit()); d.setReporter(req.reporter()); d.setDescription(req.description()); d.setStatus("登记"); d.setCreatedAt(Instant.now()); // 任务进入偏差处理态。 t.setStatus("偏差处理"); taskRepo.save(t); return ApiResp.ok(devRepo.save(d)); } public record InvestigateRequest(String investigator, String rootCause, String investigation) { } /** 调查:写调查人/根因/结论,置「待决策」。 */ @PostMapping("/{id}/investigate") @Transactional public ApiResp investigate(@PathVariable Long id, @RequestBody InvestigateRequest req) { LabDeviation d = must(id); if ("已放行".equals(d.getStatus()) || "已驳回".equals(d.getStatus())) { throw new ApiException(409, "偏差已结案,不能再调查"); } if (req.investigator() == null || req.investigator().isBlank()) { throw new ApiException(400, "调查人(investigator) 不能为空"); } d.setInvestigator(req.investigator()); d.setRootCause(req.rootCause()); d.setInvestigation(req.investigation()); d.setStatus("待决策"); return ApiResp.ok(devRepo.save(d)); } public record DecideRequest(String decision, String approver, String remark) { } /** 决策:复检(克隆复检任务)/ 放行 / 驳回。须先完成调查(待决策态)。 */ @PostMapping("/{id}/decide") @Transactional public ApiResp decide(@PathVariable Long id, @RequestBody DecideRequest req) { LabDeviation d = must(id); if (!"待决策".equals(d.getStatus())) { throw new ApiException(409, "须先完成调查(待决策)才能决策,当前:" + d.getStatus()); } String dec = req.decision(); if (dec == null || !DECISIONS.contains(dec)) { throw new ApiException(400, "决策只能是 " + DECISIONS); } if (req.approver() == null || req.approver().isBlank()) { throw new ApiException(400, "决策人(approver) 不能为空"); } d.setDecision(dec); d.setApprover(req.approver()); TestTask orig = d.getTaskId() == null ? null : taskRepo.findById(d.getTaskId()).orElse(null); switch (dec) { case "复检" -> { if (orig == null) { throw new ApiException(409, "原检测任务已不存在,无法复检"); } TestTask re = cloneForRecheck(orig); TestTask saved = taskRepo.save(re); d.setRecheckTaskId(saved.getId()); d.setStatus("复检"); } case "放行" -> { if (orig != null) { // 放行:偏差经审核接受,任务回到待复核继续主流程。 orig.setStatus("待复核"); taskRepo.save(orig); } d.setStatus("已放行"); d.setClosedDate(LocalDate.now().toString()); } default -> { // 驳回 d.setStatus("已驳回"); d.setClosedDate(LocalDate.now().toString()); } } return ApiResp.ok(devRepo.save(d)); } // ---------- helpers ---------- private LabDeviation must(Long id) { return devRepo.findById(id) .orElseThrow(() -> new NotFoundException("deviation not found: " + id)); } /** 克隆原任务为复检任务:保留样品/项目/方法/仪器,清空结果与锁,置「检测中」。 */ private TestTask cloneForRecheck(TestTask src) { TestTask re = new TestTask(); re.setTaskNo("FJ-" + (taskRepo.count() + 1)); re.setSampleId(src.getSampleId()); re.setSampleNo(src.getSampleNo()); re.setSampleName(src.getSampleName()); re.setDetectItem(src.getDetectItem()); re.setMethodNo(src.getMethodNo()); re.setMethodName(src.getMethodName()); re.setInstrumentId(src.getInstrumentId()); re.setInstrumentName(src.getInstrumentName()); re.setTester(src.getTester()); re.setRelatedTo(src.getRelatedTo()); re.setPriority("高"); re.setStandardLimit(src.getStandardLimit()); re.setResultUnit(src.getResultUnit()); re.setStatus("检测中"); re.setResultLocked(false); re.setRemark("OOS偏差复检(源任务 " + src.getTaskNo() + ")"); re.setCreatedAt(Instant.now()); return re; } }