Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/OpsMaintController.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

381 lines
18 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.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;
}
}