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)。 * *

覆盖:保养计划 {@link OpsMaintPlan} CRUD;到期保养工单批量生成(按 nextDueDate 命中即开保养工单 * 并滚动 nextDueDate);故障报修建单;工单状态机(待派工 → 处理中 → 已完工 / 已取消); * 完工填备件/工时/费用(金额 BigDecimal);按设备的 MTBF(平均故障间隔)/ MTTR(平均修复时长)统计。 * 维修费用属运营成本,受 AuthInterceptor default-deny + SENSITIVE_READ 门禁。

*/ @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> 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 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 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 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 orders) { } /** * 扫描启用的保养计划,nextDueDate 落在 [今天-逾期, 今天+aheadDays] 的,生成一张保养工单(待派工), * 并把计划 lastDate 置为今天、nextDueDate 滚动到下一周期。已有同计划未完工保养工单时跳过,避免重复开单。 */ @PostMapping("/plans/generate-due") @Transactional public ApiResp generateDue(@RequestParam(required = false, defaultValue = "0") int aheadDays) { LocalDate today = LocalDate.now(); List 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> 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 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 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 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 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> reliability() { List out = new ArrayList<>(); for (SewageEquipment eq : equipmentRepo.findAll()) { List orders = orderRepo.findByEquipmentId(eq.getId()); List 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; } }