恢复点(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>
381 lines
18 KiB
Java
381 lines
18 KiB
Java
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;
|
||
}
|
||
}
|