Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/SewageOccHealthController.java
T
QiufengandClaude Opus 4.8 5e51dc3f56 SNAPSHOT W7 已部署稳定态 — 凯迪ERP+OA一体化平台 (MET 73.3%)
恢复点(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>
2026-06-15 19:19:15 +08:00

260 lines
12 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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-recordsHealthRecordController),但污水运营中心
* 高危岗位(噪声/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<String> HAZARD_FACTORS =
List.of("噪声", "硫化氢(H2S)", "氨气(NH3)", "污泥(含病原体)", "有机溶剂", "粉尘");
private final SewageOccHealthRepository repo;
public SewageOccHealthController(SewageOccHealthRepository repo) {
this.repo = repo;
}
// ---------- 基础 CRUD ----------
@GetMapping
public ApiResp<List<SewageOccHealth>> 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<SewageOccHealth> 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<SewageOccHealth> 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<SewageOccHealth> 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<Void> 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<String> VALID_STATUSES =
List.of("在岗", "复查中", "岗位调整中", "离岗后观察");
/**
* 状态迁移:在岗 / 复查中 / 岗位调整中 / 离岗后观察。
* 职业禁忌结论时自动建议岗位调整。
*/
@PostMapping("/{id}/status")
@Transactional
public ApiResp<SewageOccHealth> 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<List<OverdueRow>> overdue(@RequestParam(required = false) String plant) {
String today = LocalDate.now().toString();
String deadline = LocalDate.now().plusDays(30).toString();
List<SewageOccHealth> due = plant != null && !plant.isBlank()
? repo.findByPlant(plant).stream()
.filter(r -> r.getNextCheckDate() != null && r.getNextCheckDate().compareTo(deadline) <= 0)
.toList()
: repo.findByNextCheckDateLessThanEqual(deadline);
List<OverdueRow> 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<List<String>> 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<List<PlantSummary>> summary() {
List<SewageOccHealth> all = repo.findAll();
java.util.Map<String, long[]> 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<SewageOccHealth> overdue = repo.findByNextCheckDateLessThanEqual(deadline);
java.util.Map<String, Long> overdueCnts = new java.util.HashMap<>();
for (SewageOccHealth r : overdue) {
String plant = r.getPlant() == null ? "未分配" : r.getPlant();
overdueCnts.merge(plant, 1L, Long::sum);
}
List<PlantSummary> rows = new ArrayList<>();
for (java.util.Map.Entry<String, long[]> 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);
}
}
}
}