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>
This commit is contained in:
Qiufeng
2026-06-15 19:19:15 +08:00
co-authored by Claude Opus 4.8
commit 5e51dc3f56
10584 changed files with 2501339 additions and 0 deletions
@@ -0,0 +1,296 @@
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.LabIncident;
import com.kaidi.oa.domain.LabSafetyCheck;
import com.kaidi.oa.domain.LabWaste;
import com.kaidi.oa.repository.LabIncidentRepository;
import com.kaidi.oa.repository.LabSafetyCheckRepository;
import com.kaidi.oa.repository.LabWasteRepository;
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;
/**
* 实验室·安全与环境 EHS(§8 深化)。三块实验室专项能力,区别于运营/质安部通用域:
*
* 1) 废弃物管理:实验室废弃物(含生物废物)产生 → 转移 → 处置,暂存位/环保台账对接(sync-epb);
* 2) 安全巡检:灭火器/洗眼器/通风橱 项化 checklist,按周期 plan 自动生成巡检计划 → 逐项打勾 → 异常触发整改;
* 3) 事件上报:实验室微小事故(化学品泄漏/灼伤)在线上报 → 处理 → 整改 → 关闭闭环。
*
* 写口默认受 default-deny(ADMIN/APPROVER) 保护。
*/
@RestController
@RequestMapping("/api/oa/lab-ehs")
public class LabEhsController {
/** 实验室安全巡检标准检查项(项化 checklist)。 */
private static final List<String> CHECK_ITEMS = List.of(
"灭火器", "洗眼器", "通风橱", "气瓶固定", "危化品柜双锁", "应急喷淋", "急救箱");
private static final List<String> WASTE_CATEGORIES = List.of(
"废液", "固废", "生物废物", "废气吸收液", "实验动物尸体");
private final LabWasteRepository wasteRepo;
private final LabSafetyCheckRepository checkRepo;
private final LabIncidentRepository incidentRepo;
public LabEhsController(LabWasteRepository wasteRepo,
LabSafetyCheckRepository checkRepo,
LabIncidentRepository incidentRepo) {
this.wasteRepo = wasteRepo;
this.checkRepo = checkRepo;
this.incidentRepo = incidentRepo;
}
// ==================== 废弃物管理 ====================
@GetMapping("/wastes")
public ApiResp<List<LabWaste>> wastes(@RequestParam(required = false) String status,
@RequestParam(required = false) String category) {
if (status != null && !status.isBlank()) {
return ApiResp.ok(wasteRepo.findByStatus(status));
}
if (category != null && !category.isBlank()) {
return ApiResp.ok(wasteRepo.findByCategory(category));
}
return ApiResp.ok(wasteRepo.findAll());
}
public record WasteRequest(String name, String category, String quantity, String source,
String storageLocation, String generatedDate, String custodian) {
}
/** 登记废弃物产生(落「暂存」)。 */
@PostMapping("/wastes")
@Transactional
public ApiResp<LabWaste> createWaste(@RequestBody WasteRequest req) {
if (req.name() == null || req.name().isBlank()) {
throw new ApiException(400, "废弃物名称(name) 不能为空");
}
if (req.category() != null && !req.category().isBlank() && !WASTE_CATEGORIES.contains(req.category())) {
throw new ApiException(400, "分类只能是 " + WASTE_CATEGORIES);
}
LabWaste w = new LabWaste();
w.setWasteNo("LW-" + (wasteRepo.count() + 1));
w.setName(req.name());
w.setCategory(req.category());
w.setQuantity(req.quantity());
w.setSource(req.source());
w.setStorageLocation(req.storageLocation());
w.setGeneratedDate(req.generatedDate() == null || req.generatedDate().isBlank()
? LocalDate.now().toString() : req.generatedDate());
w.setCustodian(req.custodian());
w.setStatus("暂存");
w.setSyncEpb(false);
w.setCreatedAt(Instant.now());
return ApiResp.ok(wasteRepo.save(w));
}
public record TransferRequest(String transferTo, String transferDate) {
}
/** 转移:暂存 → 转移。 */
@PostMapping("/wastes/{id}/transfer")
@Transactional
public ApiResp<LabWaste> transferWaste(@PathVariable Long id, @RequestBody TransferRequest req) {
LabWaste w = mustWaste(id);
if (!"暂存".equals(w.getStatus())) {
throw new ApiException(409, "仅暂存态可转移,当前:" + w.getStatus());
}
if (req.transferTo() == null || req.transferTo().isBlank()) {
throw new ApiException(400, "承运方/转移单号(transferTo) 不能为空");
}
w.setTransferTo(req.transferTo());
w.setTransferDate(req.transferDate() == null || req.transferDate().isBlank()
? LocalDate.now().toString() : req.transferDate());
w.setStatus("转移");
return ApiResp.ok(wasteRepo.save(w));
}
public record DisposeWasteRequest(String disposalMethod, String disposedDate) {
}
/** 处置:转移 → 已处置。 */
@PostMapping("/wastes/{id}/dispose")
@Transactional
public ApiResp<LabWaste> disposeWaste(@PathVariable Long id, @RequestBody DisposeWasteRequest req) {
LabWaste w = mustWaste(id);
if (!"转移".equals(w.getStatus()) && !"暂存".equals(w.getStatus())) {
throw new ApiException(409, "当前状态不可处置:" + w.getStatus());
}
if (req.disposalMethod() == null || req.disposalMethod().isBlank()) {
throw new ApiException(400, "处置方式(disposalMethod) 不能为空");
}
w.setDisposalMethod(req.disposalMethod());
w.setDisposedDate(req.disposedDate() == null || req.disposedDate().isBlank()
? LocalDate.now().toString() : req.disposedDate());
w.setStatus("已处置");
return ApiResp.ok(wasteRepo.save(w));
}
/** 对接环保台账:标记 syncEpb=true(模拟上报环保台账)。 */
@PostMapping("/wastes/{id}/sync-epb")
@Transactional
public ApiResp<LabWaste> syncEpb(@PathVariable Long id) {
LabWaste w = mustWaste(id);
w.setSyncEpb(true);
return ApiResp.ok(wasteRepo.save(w));
}
// ==================== 安全巡检 ====================
@GetMapping("/safety-checks")
public ApiResp<List<LabSafetyCheck>> safetyChecks(@RequestParam(required = false) String status) {
if (status != null && !status.isBlank()) {
return ApiResp.ok(checkRepo.findByStatus(status));
}
return ApiResp.ok(checkRepo.findAll());
}
/** 标准检查项清单(前端构建 checklist 用)。 */
@GetMapping("/safety-check-items")
public ApiResp<List<String>> safetyCheckItems() {
return ApiResp.ok(CHECK_ITEMS);
}
public record PlanRequest(String area, String planDate, String inspector) {
}
/** 生成巡检计划:按标准项化清单建一张「待巡检」单,预置全部检查项(待巡检)。 */
@PostMapping("/safety-checks/plan")
@Transactional
public ApiResp<LabSafetyCheck> planCheck(@RequestBody PlanRequest req) {
if (req.area() == null || req.area().isBlank()) {
throw new ApiException(400, "实验室区域(area) 不能为空");
}
StringBuilder items = new StringBuilder("[");
for (int i = 0; i < CHECK_ITEMS.size(); i++) {
if (i > 0) items.append(",");
items.append("{\"item\":\"").append(CHECK_ITEMS.get(i)).append("\",\"ok\":null,\"note\":\"\"}");
}
items.append("]");
LabSafetyCheck c = new LabSafetyCheck();
c.setCheckNo("SC-" + (checkRepo.count() + 1));
c.setArea(req.area());
c.setPlanDate(req.planDate() == null || req.planDate().isBlank()
? LocalDate.now().toString() : req.planDate());
c.setInspector(req.inspector());
c.setItemsJson(items.toString());
c.setAbnormalCount(0);
c.setStatus("待巡检");
c.setCreatedAt(Instant.now());
return ApiResp.ok(checkRepo.save(c));
}
public record SubmitCheckRequest(String inspector, String itemsJson, int abnormalCount, String remark) {
}
/** 提交巡检结果:写逐项结果与异常数,置「已完成」。 */
@PostMapping("/safety-checks/{id}/submit")
@Transactional
public ApiResp<LabSafetyCheck> submitCheck(@PathVariable Long id, @RequestBody SubmitCheckRequest req) {
LabSafetyCheck c = mustCheck(id);
if ("已完成".equals(c.getStatus())) {
throw new ApiException(409, "巡检已完成");
}
if (req.inspector() != null && !req.inspector().isBlank()) c.setInspector(req.inspector());
if (req.itemsJson() != null && !req.itemsJson().isBlank()) c.setItemsJson(req.itemsJson());
c.setAbnormalCount(Math.max(0, req.abnormalCount()));
c.setRemark(req.remark());
c.setCheckedDate(LocalDate.now().toString());
c.setStatus("已完成");
return ApiResp.ok(checkRepo.save(c));
}
// ==================== 事件上报 ====================
@GetMapping("/incidents")
public ApiResp<List<LabIncident>> incidents(@RequestParam(required = false) String status) {
if (status != null && !status.isBlank()) {
return ApiResp.ok(incidentRepo.findByStatus(status));
}
return ApiResp.ok(incidentRepo.findAll());
}
public record IncidentRequest(String incidentType, String severity, String area,
String occurredAt, String reporter, String description) {
}
/** 上报事件(落「上报」)。 */
@PostMapping("/incidents")
@Transactional
public ApiResp<LabIncident> reportIncident(@RequestBody IncidentRequest req) {
if (req.incidentType() == null || req.incidentType().isBlank()) {
throw new ApiException(400, "事件类型(incidentType) 不能为空");
}
LabIncident i = new LabIncident();
i.setIncidentNo("LI-" + (incidentRepo.count() + 1));
i.setIncidentType(req.incidentType());
i.setSeverity(req.severity() == null || req.severity().isBlank() ? "轻微" : req.severity());
i.setArea(req.area());
i.setOccurredAt(req.occurredAt());
i.setReporter(req.reporter());
i.setDescription(req.description());
i.setStatus("上报");
i.setCreatedAt(Instant.now());
return ApiResp.ok(incidentRepo.save(i));
}
public record HandleIncidentRequest(String handler, String rectification) {
}
/** 处理:上报 → 处理中 → 已整改(写整改措施)。 */
@PostMapping("/incidents/{id}/handle")
@Transactional
public ApiResp<LabIncident> handleIncident(@PathVariable Long id, @RequestBody HandleIncidentRequest req) {
LabIncident i = mustIncident(id);
if ("已关闭".equals(i.getStatus())) {
throw new ApiException(409, "事件已关闭");
}
if (req.handler() != null) i.setHandler(req.handler());
if (req.rectification() != null) i.setRectification(req.rectification());
i.setStatus(req.rectification() != null && !req.rectification().isBlank() ? "已整改" : "处理中");
return ApiResp.ok(incidentRepo.save(i));
}
/** 关闭:已整改 → 已关闭。 */
@PostMapping("/incidents/{id}/close")
@Transactional
public ApiResp<LabIncident> closeIncident(@PathVariable Long id) {
LabIncident i = mustIncident(id);
if (!"已整改".equals(i.getStatus())) {
throw new ApiException(409, "仅已整改的事件可关闭,当前:" + i.getStatus());
}
i.setStatus("已关闭");
i.setClosedDate(LocalDate.now().toString());
return ApiResp.ok(incidentRepo.save(i));
}
// ---------- helpers ----------
private LabWaste mustWaste(Long id) {
return wasteRepo.findById(id)
.orElseThrow(() -> new NotFoundException("lab waste not found: " + id));
}
private LabSafetyCheck mustCheck(Long id) {
return checkRepo.findById(id)
.orElseThrow(() -> new NotFoundException("safety check not found: " + id));
}
private LabIncident mustIncident(Long id) {
return incidentRepo.findById(id)
.orElseThrow(() -> new NotFoundException("lab incident not found: " + id));
}
}