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.SewageOccHealth; import com.kaidi.oa.repository.SewageOccHealthRepository; 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; /** * 城镇污水运营·职业健康专项联动。 * 补深审计 med 缺口(安全环保与合规·职业健康专项联动): * 质安部有通用 health-records(HealthRecordController),但污水运营中心 * 高危岗位(噪声/H2S/NH3/污泥病原体)未建专属联动台账和到期预警。 * * 核心能力: * - CRUD:污水运营高危岗位员工职业健康台账(危害因素/劳保发放/体检结论)。 * - 自动计算下次体检日期(上次体检 + 12 个月)。 * - 体检到期预警(GET /overdue):下次体检日期已过或将于 30 天内到期的员工列表。 * - 状态机(POST /{id}/status):在岗 → 复查中 → 岗位调整中 / 正常。 * - 联动摘要(GET /summary):按水厂统计在岗/复查/禁忌人数及到期预警数。 * - 与质安部 health-records 通过 healthRecordRef 字段关联(加法式,不改共享表)。 * * 写口受 AuthInterceptor default-deny(ADMIN/APPROVER) 保护。 */ @RestController @RequestMapping("/api/oa/sewage-occ-health") public class SewageOccHealthController { /** 污水运营中心常见职业危害因素。 */ private static final List HAZARD_FACTORS = List.of("噪声", "硫化氢(H2S)", "氨气(NH3)", "污泥(含病原体)", "有机溶剂", "粉尘"); private final SewageOccHealthRepository repo; public SewageOccHealthController(SewageOccHealthRepository repo) { this.repo = repo; } // ---------- 基础 CRUD ---------- @GetMapping public ApiResp> list( @RequestParam(required = false) String plant, @RequestParam(required = false) String status, @RequestParam(required = false) String checkConclusion) { if (plant != null && status != null) { return ApiResp.ok(repo.findByPlantAndStatus(plant, status)); } if (plant != null) return ApiResp.ok(repo.findByPlant(plant)); if (status != null) return ApiResp.ok(repo.findByStatus(status)); if (checkConclusion != null) return ApiResp.ok(repo.findByCheckConclusion(checkConclusion)); return ApiResp.ok(repo.findAll()); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(repo.findById(id) .orElseThrow(() -> new NotFoundException("职业健康记录不存在: " + id))); } public record OccHealthRequest( String employeeName, String employeeNo, String plant, String position, String hazardFactors, String ppeRecord, String lastCheckDate, String checkInstitution, String checkConclusion, String healthRecordRef, Double exposureYears, String adjustmentNote, String remark) { } @PostMapping @Transactional public ApiResp create(@RequestBody OccHealthRequest req) { if (req.employeeName() == null || req.employeeName().isBlank()) { throw new ApiException(400, "员工姓名(employeeName)不能为空"); } SewageOccHealth r = new SewageOccHealth(); r.setCode("SOH-" + (repo.count() + 1)); applyFields(r, req); r.setStatus("在岗"); r.setCreatedAt(Instant.now()); r.setUpdatedAt(Instant.now()); return ApiResp.ok(repo.save(r)); } @PatchMapping("/{id}") @Transactional public ApiResp update(@PathVariable Long id, @RequestBody OccHealthRequest req) { SewageOccHealth r = repo.findById(id) .orElseThrow(() -> new NotFoundException("职业健康记录不存在: " + id)); applyFields(r, req); r.setUpdatedAt(Instant.now()); return ApiResp.ok(repo.save(r)); } @DeleteMapping("/{id}") @Transactional public ApiResp delete(@PathVariable Long id) { if (!repo.existsById(id)) throw new NotFoundException("职业健康记录不存在: " + id); repo.deleteById(id); return ApiResp.ok(null); } // ---------- 状态机 ---------- public record StatusRequest(String status, String adjustmentNote) { } private static final List VALID_STATUSES = List.of("在岗", "复查中", "岗位调整中", "离岗后观察"); /** * 状态迁移:在岗 / 复查中 / 岗位调整中 / 离岗后观察。 * 职业禁忌结论时自动建议岗位调整。 */ @PostMapping("/{id}/status") @Transactional public ApiResp changeStatus(@PathVariable Long id, @RequestBody StatusRequest req) { SewageOccHealth r = repo.findById(id) .orElseThrow(() -> new NotFoundException("职业健康记录不存在: " + id)); if (!VALID_STATUSES.contains(req.status())) { throw new ApiException(400, "无效状态,可选:" + String.join("/", VALID_STATUSES)); } r.setStatus(req.status()); if (req.adjustmentNote() != null) r.setAdjustmentNote(req.adjustmentNote()); r.setUpdatedAt(Instant.now()); return ApiResp.ok(repo.save(r)); } // ---------- 体检到期预警 ---------- public record OverdueRow(Long id, String code, String employeeName, String plant, String position, String hazardFactors, String nextCheckDate, long daysOverdue, String level) { } /** * 体检到期预警: * - 下次体检日期已过(daysOverdue > 0): level=逾期 * - 30 天内到期: level=预警 */ @GetMapping("/overdue") public ApiResp> overdue(@RequestParam(required = false) String plant) { String today = LocalDate.now().toString(); String deadline = LocalDate.now().plusDays(30).toString(); List due = plant != null && !plant.isBlank() ? repo.findByPlant(plant).stream() .filter(r -> r.getNextCheckDate() != null && r.getNextCheckDate().compareTo(deadline) <= 0) .toList() : repo.findByNextCheckDateLessThanEqual(deadline); List rows = new ArrayList<>(); for (SewageOccHealth r : due) { if (r.getNextCheckDate() == null) continue; LocalDate next = LocalDate.parse(r.getNextCheckDate()); long diff = ChronoUnit.DAYS.between(next, LocalDate.now()); String level = diff > 0 ? "逾期" : "预警"; rows.add(new OverdueRow(r.getId(), r.getCode(), r.getEmployeeName(), r.getPlant(), r.getPosition(), r.getHazardFactors(), r.getNextCheckDate(), diff, level)); } rows.sort((a, b) -> Long.compare(b.daysOverdue(), a.daysOverdue())); return ApiResp.ok(rows); } // ---------- 危害因素枚举 ---------- @GetMapping("/hazard-factors") public ApiResp> hazardFactors() { return ApiResp.ok(HAZARD_FACTORS); } // ---------- 联动摘要(按水厂) ---------- public record PlantSummary(String plant, long total, long inPost, long reviewing, long adjusted, long contraindicated, long overdueCnt) { } /** * 按水厂汇总职业健康状态分布,含到期预警人数(供安全合规看板取数)。 */ @GetMapping("/summary") public ApiResp> summary() { List all = repo.findAll(); java.util.Map agg = new java.util.LinkedHashMap<>(); // [total, inPost, reviewing, adjusted, contraindicated] for (SewageOccHealth r : all) { String plant = r.getPlant() == null ? "未分配" : r.getPlant(); long[] a = agg.computeIfAbsent(plant, k -> new long[5]); a[0]++; if ("在岗".equals(r.getStatus())) a[1]++; else if ("复查中".equals(r.getStatus())) a[2]++; else if ("岗位调整中".equals(r.getStatus())) a[3]++; if ("职业禁忌".equals(r.getCheckConclusion())) a[4]++; } // 逾期计数 String deadline = LocalDate.now().plusDays(30).toString(); List overdue = repo.findByNextCheckDateLessThanEqual(deadline); java.util.Map overdueCnts = new java.util.HashMap<>(); for (SewageOccHealth r : overdue) { String plant = r.getPlant() == null ? "未分配" : r.getPlant(); overdueCnts.merge(plant, 1L, Long::sum); } List rows = new ArrayList<>(); for (java.util.Map.Entry e : agg.entrySet()) { long[] a = e.getValue(); long oc = overdueCnts.getOrDefault(e.getKey(), 0L); rows.add(new PlantSummary(e.getKey(), a[0], a[1], a[2], a[3], a[4], oc)); } return ApiResp.ok(rows); } // ---------- helpers ---------- private void applyFields(SewageOccHealth r, OccHealthRequest req) { if (req.employeeName() != null) r.setEmployeeName(req.employeeName()); if (req.employeeNo() != null) r.setEmployeeNo(req.employeeNo()); if (req.plant() != null) r.setPlant(req.plant()); if (req.position() != null) r.setPosition(req.position()); if (req.hazardFactors() != null) r.setHazardFactors(req.hazardFactors()); if (req.ppeRecord() != null) r.setPpeRecord(req.ppeRecord()); if (req.checkInstitution() != null) r.setCheckInstitution(req.checkInstitution()); if (req.checkConclusion() != null) { r.setCheckConclusion(req.checkConclusion()); // 体检结论为"职业禁忌"或"疑似职业病"时自动建议岗位调整 if (("职业禁忌".equals(req.checkConclusion()) || "疑似职业病".equals(req.checkConclusion())) && (r.getAdjustmentNote() == null || r.getAdjustmentNote().isBlank())) { r.setAdjustmentNote("体检结论为" + req.checkConclusion() + ",请安全部门评估是否需岗位调整"); } } if (req.healthRecordRef() != null) r.setHealthRecordRef(req.healthRecordRef()); if (req.exposureYears() != null) r.setExposureYears(req.exposureYears()); if (req.adjustmentNote() != null) r.setAdjustmentNote(req.adjustmentNote()); if (req.remark() != null) r.setRemark(req.remark()); // 更新上次体检日期并自动推算下次体检日期(12 个月) if (req.lastCheckDate() != null && !req.lastCheckDate().isBlank()) { r.setLastCheckDate(req.lastCheckDate()); try { LocalDate last = LocalDate.parse(req.lastCheckDate()); r.setNextCheckDate(last.plusMonths(12).toString()); } catch (Exception ignored) { r.setNextCheckDate(null); } } } }