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,380 @@
package com.kaidi.oa.web;
import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.common.Money;
import com.kaidi.oa.common.NotFoundException;
import com.kaidi.oa.domain.OpsMaintOrder;
import com.kaidi.oa.domain.OpsMaintPlan;
import com.kaidi.oa.domain.SewageEquipment;
import com.kaidi.oa.repository.OpsMaintOrderRepository;
import com.kaidi.oa.repository.OpsMaintPlanRepository;
import com.kaidi.oa.repository.SewageEquipmentRepository;
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.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* 设备预防性维护 + 故障报修(运营管理中心·工业废水运营,需求 §3 预防性维护 / 故障报修 / MTBF MTTR)。
*
* <p>覆盖:保养计划 {@link OpsMaintPlan} CRUD;到期保养工单批量生成(按 nextDueDate 命中即开保养工单
* 并滚动 nextDueDate);故障报修建单;工单状态机(待派工 → 处理中 → 已完工 / 已取消);
* 完工填备件/工时/费用(金额 BigDecimal);按设备的 MTBF(平均故障间隔)/ MTTR(平均修复时长)统计。
* 维修费用属运营成本,受 AuthInterceptor default-deny + SENSITIVE_READ 门禁。</p>
*/
@RestController
@RequestMapping("/api/oa/ops-maint")
public class OpsMaintController {
private final OpsMaintPlanRepository planRepo;
private final OpsMaintOrderRepository orderRepo;
private final SewageEquipmentRepository equipmentRepo;
public OpsMaintController(OpsMaintPlanRepository planRepo,
OpsMaintOrderRepository orderRepo,
SewageEquipmentRepository equipmentRepo) {
this.planRepo = planRepo;
this.orderRepo = orderRepo;
this.equipmentRepo = equipmentRepo;
}
// ---------- 保养计划 CRUD ----------
@GetMapping("/plans")
public ApiResp<List<OpsMaintPlan>> listPlans(@RequestParam(required = false) Long equipmentId) {
if (equipmentId != null) {
return ApiResp.ok(planRepo.findByEquipmentId(equipmentId));
}
return ApiResp.ok(planRepo.findAll());
}
public record PlanRequest(String code, Long equipmentId, String equipmentName, String section,
String maintItem, String cycleType, Integer cycleDays, Double cycleHours,
Double cycleVolume, String lastDate, String nextDueDate, String team,
Boolean enabled, String owner) {
}
@PostMapping("/plans")
public ApiResp<OpsMaintPlan> createPlan(@RequestBody PlanRequest req) {
if (req.equipmentId() == null) {
throw new ApiException(400, "设备(equipmentId) 不能为空");
}
SewageEquipment eq = equipmentRepo.findById(req.equipmentId())
.orElseThrow(() -> new ApiException(400, "设备不存在: " + req.equipmentId()));
OpsMaintPlan p = new OpsMaintPlan();
p.setCode(req.code() == null || req.code().isBlank() ? "PM-" + (planRepo.count() + 1) : req.code());
p.setEquipmentId(req.equipmentId());
p.setEquipmentName(req.equipmentName() == null || req.equipmentName().isBlank()
? eq.getName() : req.equipmentName());
p.setSection(req.section() == null || req.section().isBlank() ? eq.getProcessSection() : req.section());
p.setMaintItem(req.maintItem());
p.setCycleType(req.cycleType() == null || req.cycleType().isBlank() ? "日历" : req.cycleType());
p.setCycleDays(req.cycleDays() == null ? 30 : req.cycleDays());
p.setCycleHours(req.cycleHours());
p.setCycleVolume(req.cycleVolume());
p.setLastDate(req.lastDate());
// nextDueDate 缺省按 lastDate + cycleDays 推算。
p.setNextDueDate(req.nextDueDate() != null && !req.nextDueDate().isBlank()
? req.nextDueDate() : rollNext(req.lastDate(), p.getCycleDays()));
p.setTeam(req.team());
p.setEnabled(req.enabled() == null ? Boolean.TRUE : req.enabled());
p.setOwner(req.owner());
p.setCreatedAt(Instant.now());
return ApiResp.ok(planRepo.save(p));
}
@PatchMapping("/plans/{id}")
public ApiResp<OpsMaintPlan> updatePlan(@PathVariable Long id, @RequestBody PlanRequest req) {
OpsMaintPlan p = planRepo.findById(id)
.orElseThrow(() -> new NotFoundException("maint plan not found: " + id));
if (req.equipmentName() != null) p.setEquipmentName(req.equipmentName());
if (req.section() != null) p.setSection(req.section());
if (req.maintItem() != null) p.setMaintItem(req.maintItem());
if (req.cycleType() != null && !req.cycleType().isBlank()) p.setCycleType(req.cycleType());
if (req.cycleDays() != null) p.setCycleDays(req.cycleDays());
if (req.cycleHours() != null) p.setCycleHours(req.cycleHours());
if (req.cycleVolume() != null) p.setCycleVolume(req.cycleVolume());
if (req.lastDate() != null) p.setLastDate(req.lastDate());
if (req.nextDueDate() != null) p.setNextDueDate(req.nextDueDate());
if (req.team() != null) p.setTeam(req.team());
if (req.enabled() != null) p.setEnabled(req.enabled());
if (req.owner() != null) p.setOwner(req.owner());
return ApiResp.ok(planRepo.save(p));
}
@DeleteMapping("/plans/{id}")
public ApiResp<Void> deletePlan(@PathVariable Long id) {
if (!planRepo.existsById(id)) {
throw new NotFoundException("maint plan not found: " + id);
}
planRepo.deleteById(id);
return ApiResp.ok(null);
}
// ---------- 到期保养工单批量生成 ----------
public record GenerateResult(int generated, List<OpsMaintOrder> orders) {
}
/**
* 扫描启用的保养计划,nextDueDate 落在 [今天-逾期, 今天+aheadDays] 的,生成一张保养工单(待派工),
* 并把计划 lastDate 置为今天、nextDueDate 滚动到下一周期。已有同计划未完工保养工单时跳过,避免重复开单。
*/
@PostMapping("/plans/generate-due")
@Transactional
public ApiResp<GenerateResult> generateDue(@RequestParam(required = false, defaultValue = "0") int aheadDays) {
LocalDate today = LocalDate.now();
List<OpsMaintOrder> created = new ArrayList<>();
for (OpsMaintPlan p : planRepo.findByEnabled(Boolean.TRUE)) {
LocalDate due = parseDateOrNull(p.getNextDueDate());
if (due == null) {
continue;
}
long daysTo = ChronoUnit.DAYS.between(today, due);
if (daysTo > aheadDays) {
continue;
}
// 跳过已有未完工的本计划保养工单。
boolean openExists = orderRepo.findByPlanId(p.getId()).stream()
.anyMatch(o -> !"已完工".equals(o.getStatus()) && !"已取消".equals(o.getStatus()));
if (openExists) {
continue;
}
OpsMaintOrder o = new OpsMaintOrder();
o.setCode("MO-PM" + p.getId() + "-" + today.toString().replace("-", ""));
o.setOrderType("保养");
o.setEquipmentId(p.getEquipmentId());
o.setEquipmentName(p.getEquipmentName());
o.setSection(p.getSection());
o.setPlanId(p.getId());
o.setDescription("预防性保养:" + (p.getMaintItem() == null ? "" : p.getMaintItem()));
o.setAssignee(p.getTeam());
o.setStatus("待派工");
o.setOwner(p.getOwner());
o.setCreatedAt(Instant.now());
created.add(orderRepo.save(o));
// 滚动计划。
p.setLastDate(today.toString());
p.setNextDueDate(rollNext(today.toString(), p.getCycleDays()));
planRepo.save(p);
}
return ApiResp.ok(new GenerateResult(created.size(), created));
}
// ---------- 工单 CRUD + 报修 ----------
@GetMapping("/orders")
public ApiResp<List<OpsMaintOrder>> listOrders(@RequestParam(required = false) String status,
@RequestParam(required = false) String orderType,
@RequestParam(required = false) Long equipmentId) {
if (equipmentId != null) {
return ApiResp.ok(orderRepo.findByEquipmentId(equipmentId));
}
if (status != null && !status.isBlank()) {
return ApiResp.ok(orderRepo.findByStatus(status));
}
if (orderType != null && !orderType.isBlank()) {
return ApiResp.ok(orderRepo.findByOrderType(orderType));
}
return ApiResp.ok(orderRepo.findAll());
}
public record RepairRequest(Long equipmentId, String equipmentName, String section,
String description, String photo, String faultTime, String owner) {
}
/** 故障报修建单(维修工单,待派工)。faultTime 用于后续 MTTR 统计。 */
@PostMapping("/orders/report")
public ApiResp<OpsMaintOrder> reportFault(@RequestBody RepairRequest req) {
if (req.equipmentId() == null) {
throw new ApiException(400, "设备(equipmentId) 不能为空");
}
SewageEquipment eq = equipmentRepo.findById(req.equipmentId())
.orElseThrow(() -> new ApiException(400, "设备不存在: " + req.equipmentId()));
OpsMaintOrder o = new OpsMaintOrder();
o.setCode("MO-RP-" + (orderRepo.count() + 1));
o.setOrderType("维修");
o.setEquipmentId(req.equipmentId());
o.setEquipmentName(req.equipmentName() == null || req.equipmentName().isBlank()
? eq.getName() : req.equipmentName());
o.setSection(req.section() == null || req.section().isBlank() ? eq.getProcessSection() : req.section());
o.setDescription(req.description());
o.setPhoto(req.photo());
o.setFaultTime(req.faultTime() == null || req.faultTime().isBlank()
? LocalDate.now().toString() : req.faultTime());
o.setStatus("待派工");
o.setOwner(req.owner());
o.setCreatedAt(Instant.now());
return ApiResp.ok(orderRepo.save(o));
}
public record DispatchRequest(String assignee, String dispatchedDate) {
}
/** 派工:待派工 → 处理中。 */
@PostMapping("/orders/{id}/dispatch")
@Transactional
public ApiResp<OpsMaintOrder> dispatch(@PathVariable Long id, @RequestBody DispatchRequest req) {
OpsMaintOrder o = orderRepo.findById(id)
.orElseThrow(() -> new NotFoundException("maint order not found: " + id));
if (!"待派工".equals(o.getStatus())) {
throw new ApiException(409, "仅「待派工」工单可派工,当前:" + o.getStatus());
}
if (req.assignee() == null || req.assignee().isBlank()) {
throw new ApiException(400, "处理人/班组(assignee) 不能为空");
}
o.setAssignee(req.assignee());
o.setDispatchedDate(req.dispatchedDate() == null || req.dispatchedDate().isBlank()
? LocalDate.now().toString() : req.dispatchedDate());
o.setStatus("处理中");
return ApiResp.ok(orderRepo.save(o));
}
public record FinishRequest(String sparePart, Double laborHours, Double cost, String result,
String finishedDate) {
}
/** 完工:处理中 → 已完工,回填备件/工时/费用/试机结果,并累加设备故障次数(维修单)。 */
@PostMapping("/orders/{id}/finish")
@Transactional
public ApiResp<OpsMaintOrder> finish(@PathVariable Long id, @RequestBody FinishRequest req) {
OpsMaintOrder o = orderRepo.findById(id)
.orElseThrow(() -> new NotFoundException("maint order not found: " + id));
if (!"处理中".equals(o.getStatus()) && !"待派工".equals(o.getStatus())) {
throw new ApiException(409, "仅「待派工/处理中」工单可完工,当前:" + o.getStatus());
}
o.setSparePart(req.sparePart());
o.setLaborHours(req.laborHours() == null ? 0d : req.laborHours());
o.setCost(Money.of(req.cost()));
o.setResult(req.result());
o.setFinishedDate(req.finishedDate() == null || req.finishedDate().isBlank()
? LocalDate.now().toString() : req.finishedDate());
o.setStatus("已完工");
OpsMaintOrder saved = orderRepo.save(o);
// 维修单完工:设备故障次数 +1(用于 MTBF)。
if ("维修".equals(o.getOrderType()) && o.getEquipmentId() != null) {
equipmentRepo.findById(o.getEquipmentId()).ifPresent(eq -> {
eq.setFaultCount((eq.getFaultCount() == null ? 0 : eq.getFaultCount()) + 1);
equipmentRepo.save(eq);
});
}
return ApiResp.ok(saved);
}
/** 取消工单。 */
@PostMapping("/orders/{id}/cancel")
@Transactional
public ApiResp<OpsMaintOrder> cancel(@PathVariable Long id) {
OpsMaintOrder o = orderRepo.findById(id)
.orElseThrow(() -> new NotFoundException("maint order not found: " + id));
if ("已完工".equals(o.getStatus())) {
throw new ApiException(409, "已完工工单不可取消");
}
o.setStatus("已取消");
return ApiResp.ok(orderRepo.save(o));
}
// ---------- MTBF / MTTR 统计 ----------
public record ReliabilityRow(Long equipmentId, String equipmentName, int faultCount,
double mtbfDays, double mttrDays, double totalRepairCost) {
}
/**
* 按设备统计可靠性:
* MTTR(平均修复时长,天)= 已完工维修单 (finishedDate faultTime) 的均值;
* MTBF(平均故障间隔,天)= 同设备相邻两次维修 faultTime 之差的均值;
* totalRepairCost = 该设备所有维修单费用合计。
*/
@GetMapping("/reliability")
public ApiResp<List<ReliabilityRow>> reliability() {
List<ReliabilityRow> out = new ArrayList<>();
for (SewageEquipment eq : equipmentRepo.findAll()) {
List<OpsMaintOrder> orders = orderRepo.findByEquipmentId(eq.getId());
List<LocalDate> faultDates = new ArrayList<>();
long repairCountForMttr = 0;
long mttrSumDays = 0;
java.math.BigDecimal cost = java.math.BigDecimal.ZERO;
int faultCount = 0;
for (OpsMaintOrder o : orders) {
if (!"维修".equals(o.getOrderType())) {
continue;
}
faultCount++;
cost = Money.add(cost, o.getCost());
LocalDate ft = parseDateOrNull(o.getFaultTime());
if (ft != null) {
faultDates.add(ft);
}
if ("已完工".equals(o.getStatus())) {
LocalDate fin = parseDateOrNull(o.getFinishedDate());
if (ft != null && fin != null) {
long d = ChronoUnit.DAYS.between(ft, fin);
if (d < 0) {
d = 0;
}
mttrSumDays += d;
repairCountForMttr++;
}
}
}
double mttr = repairCountForMttr > 0 ? (double) mttrSumDays / repairCountForMttr : 0d;
// MTBF:相邻故障间隔均值。
double mtbf = 0d;
if (faultDates.size() >= 2) {
Collections.sort(faultDates);
long sumGap = 0;
for (int i = 1; i < faultDates.size(); i++) {
sumGap += ChronoUnit.DAYS.between(faultDates.get(i - 1), faultDates.get(i));
}
mtbf = (double) sumGap / (faultDates.size() - 1);
}
if (faultCount == 0) {
continue; // 无维修历史的设备不进可靠性表。
}
out.add(new ReliabilityRow(eq.getId(), eq.getName(), faultCount,
round2(mtbf), round2(mttr), cost.doubleValue()));
}
out.sort((a, b) -> Integer.compare(b.faultCount(), a.faultCount()));
return ApiResp.ok(out);
}
private static String rollNext(String fromDate, Integer cycleDays) {
LocalDate base = parseDateOrNull(fromDate);
int days = cycleDays == null || cycleDays <= 0 ? 30 : cycleDays;
LocalDate from = base == null ? LocalDate.now() : base;
return from.plusDays(days).toString();
}
private static LocalDate parseDateOrNull(String s) {
if (s == null || s.isBlank()) {
return null;
}
try {
return LocalDate.parse(s.trim().substring(0, Math.min(10, s.trim().length())));
} catch (DateTimeParseException | IndexOutOfBoundsException e) {
return null;
}
}
private static double round2(double v) {
return Math.round(v * 100d) / 100d;
}
}