From fd3ffc2b7948c69bbcb885439e3fd9db55e089e4 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Tue, 24 Mar 2026 00:53:35 +0800 Subject: [PATCH] feat: add equipment health and failure tracking --- .../controller/EquipmentHealthController.java | 111 ++++++++ .../mdm/entity/EquipmentFailureHistory.java | 155 +++++++++++ .../pms/mdm/entity/EquipmentHealthScore.java | 140 ++++++++++ .../EquipmentFailureHistoryRepository.java | 28 ++ .../EquipmentHealthScoreRepository.java | 29 ++ .../mdm/service/EquipmentHealthService.java | 52 ++++ .../impl/EquipmentHealthServiceImpl.java | 254 ++++++++++++++++++ 7 files changed, 769 insertions(+) create mode 100644 module-mdm/src/main/java/com/ether/pms/mdm/controller/EquipmentHealthController.java create mode 100644 module-mdm/src/main/java/com/ether/pms/mdm/entity/EquipmentFailureHistory.java create mode 100644 module-mdm/src/main/java/com/ether/pms/mdm/entity/EquipmentHealthScore.java create mode 100644 module-mdm/src/main/java/com/ether/pms/mdm/repository/EquipmentFailureHistoryRepository.java create mode 100644 module-mdm/src/main/java/com/ether/pms/mdm/repository/EquipmentHealthScoreRepository.java create mode 100644 module-mdm/src/main/java/com/ether/pms/mdm/service/EquipmentHealthService.java create mode 100644 module-mdm/src/main/java/com/ether/pms/mdm/service/impl/EquipmentHealthServiceImpl.java diff --git a/module-mdm/src/main/java/com/ether/pms/mdm/controller/EquipmentHealthController.java b/module-mdm/src/main/java/com/ether/pms/mdm/controller/EquipmentHealthController.java new file mode 100644 index 0000000..81c75cb --- /dev/null +++ b/module-mdm/src/main/java/com/ether/pms/mdm/controller/EquipmentHealthController.java @@ -0,0 +1,111 @@ +package com.ether.pms.mdm.controller; + +import com.ether.pms.common.ApiResponse; +import com.ether.pms.mdm.entity.EquipmentFailureHistory; +import com.ether.pms.mdm.entity.EquipmentHealthScore; +import com.ether.pms.mdm.service.EquipmentHealthService; +import jakarta.validation.Valid; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/ops") +@RequiredArgsConstructor +public class EquipmentHealthController { + + private final EquipmentHealthService equipmentHealthService; + + /** + * 获取设备健康度 + */ + @GetMapping("/equipment-health/{equipmentId}") + public ApiResponse getEquipmentHealth(@PathVariable UUID equipmentId) { + return ApiResponse.success(equipmentHealthService.getLatestHealthScore(equipmentId)); + } + + /** + * 获取设备健康度历史 + */ + @GetMapping("/equipment-health/{equipmentId}/history") + public ApiResponse> getHealthHistory(@PathVariable UUID equipmentId) { + return ApiResponse.success(equipmentHealthService.getHealthHistory(equipmentId)); + } + + /** + * 计算设备健康度 + */ + @PostMapping("/equipment-health/calculate") + public ApiResponse calculateHealthScore(@RequestBody CalculateHealthRequest request) { + return ApiResponse.success(equipmentHealthService.calculateHealthScore(request.getEquipmentId())); + } + + /** + * 记录故障 + */ + @PostMapping("/equipment-failure-history") + public ApiResponse recordFailure(@Valid @RequestBody EquipmentFailureHistory failure) { + return ApiResponse.success(equipmentHealthService.recordFailure(failure)); + } + + /** + * 获取设备故障历史 + */ + @GetMapping("/equipment-failure-history/{equipmentId}") + public ApiResponse> getFailureHistory(@PathVariable UUID equipmentId) { + return ApiResponse.success(equipmentHealthService.getFailureHistory(equipmentId)); + } + + /** + * 获取设备MTBF(平均故障间隔时间) + */ + @GetMapping("/equipment-mtbf/{equipmentId}") + public ApiResponse getEquipmentMTBF( + @PathVariable UUID equipmentId, + @RequestParam(required = false, defaultValue = "30") Integer days) { + BigDecimal mtbf = equipmentHealthService.calculateMTBF(equipmentId, days); + MTBFResponse response = new MTBFResponse(); + response.setEquipmentId(equipmentId); + response.setDays(days); + response.setMtbfHours(mtbf); + return ApiResponse.success(response); + } + + /** + * 获取设备MTTR(平均修复时间) + */ + @GetMapping("/equipment-mttr/{equipmentId}") + public ApiResponse getEquipmentMTTR( + @PathVariable UUID equipmentId, + @RequestParam(required = false, defaultValue = "30") Integer days) { + BigDecimal mttr = equipmentHealthService.calculateMTTR(equipmentId, days); + MTTRResponse response = new MTTRResponse(); + response.setEquipmentId(equipmentId); + response.setDays(days); + response.setMttrHours(mttr); + return ApiResponse.success(response); + } + + @Data + public static class CalculateHealthRequest { + private UUID equipmentId; + } + + @Data + public static class MTBFResponse { + private UUID equipmentId; + private Integer days; + private BigDecimal mtbfHours; + } + + @Data + public static class MTTRResponse { + private UUID equipmentId; + private Integer days; + private BigDecimal mttrHours; + } +} \ No newline at end of file diff --git a/module-mdm/src/main/java/com/ether/pms/mdm/entity/EquipmentFailureHistory.java b/module-mdm/src/main/java/com/ether/pms/mdm/entity/EquipmentFailureHistory.java new file mode 100644 index 0000000..e7aed1b --- /dev/null +++ b/module-mdm/src/main/java/com/ether/pms/mdm/entity/EquipmentFailureHistory.java @@ -0,0 +1,155 @@ +package com.ether.pms.mdm.entity; + +import jakarta.persistence.*; +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "ops_equipment_failure_history", indexes = { + @Index(name = "idx_failure_equipment", columnList = "equipment_id"), + @Index(name = "idx_failure_time", columnList = "failure_time"), + @Index(name = "idx_failure_project", columnList = "project_id") +}) +@Data +public class EquipmentFailureHistory { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "project_id", nullable = false) + private UUID projectId; + + @Column(name = "equipment_id", nullable = false) + private UUID equipmentId; + + @Column(name = "equipment_code", length = 50) + private String equipmentCode; + + @Column(name = "equipment_name", length = 100) + private String equipmentName; + + @Column(name = "failure_time", nullable = false) + private LocalDateTime failureTime; + + @Column(name = "failure_type", length = 50) + @Enumerated(EnumType.STRING) + private FailureType failureType; + + @Column(name = "failure_level", length = 20) + @Enumerated(EnumType.STRING) + private FailureLevel failureLevel; + + @Column(name = "failure_reason", columnDefinition = "TEXT") + private String failureReason; + + @Column(name = "failure_description", columnDefinition = "TEXT") + private String failureDescription; + + @Column(name = "repair_start_time") + private LocalDateTime repairStartTime; + + @Column(name = "repair_end_time") + private LocalDateTime repairEndTime; + + @Column(name = "repair_duration_hours", precision = 10, scale = 2) + private Double repairDurationHours; + + @Column(name = "repair_person", length = 100) + private String repairPerson; + + @Column(name = "repair_result", length = 20) + @Enumerated(EnumType.STRING) + private RepairResult repairResult; + + @Column(name = "downtime_hours", precision = 10, scale = 2) + private Double downtimeHours; + + @Column(name = "maintenance_cost", precision = 12, scale = 2) + private java.math.BigDecimal maintenanceCost; + + @Column(name = "is_scheduled", nullable = false) + private Boolean isScheduled = false; + + @Column(name = "related_task_id") + private UUID relatedTaskId; + + @Column(name = "remarks", columnDefinition = "TEXT") + private String remarks; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @Column(name = "created_by") + private UUID createdBy; + + @PrePersist + public void prePersist() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + if (isScheduled == null) { + isScheduled = false; + } + } + + @PreUpdate + public void preUpdate() { + updatedAt = LocalDateTime.now(); + } + + public enum FailureType { + SUDDEN("突发故障"), + SCHEDULED("计划停机"), + WARNING("预警"), + OTHER("其他"); + + private final String desc; + + FailureType(String desc) { + this.desc = desc; + } + + public String getDesc() { + return desc; + } + } + + public enum FailureLevel { + LOW("轻微"), + MEDIUM("一般"), + HIGH("严重"), + CRITICAL("危急"); + + private final String desc; + + FailureLevel(String desc) { + this.desc = desc; + } + + public String getDesc() { + return desc; + } + } + + public enum RepairResult { + FIXED("已修复"), + PARTIAL_FIXED("部分修复"), + REPLACED("更换新设备"), + PENDING("待处理"); + + private final String desc; + + RepairResult(String desc) { + this.desc = desc; + } + + public String getDesc() { + return desc; + } + } +} \ No newline at end of file diff --git a/module-mdm/src/main/java/com/ether/pms/mdm/entity/EquipmentHealthScore.java b/module-mdm/src/main/java/com/ether/pms/mdm/entity/EquipmentHealthScore.java new file mode 100644 index 0000000..12e9264 --- /dev/null +++ b/module-mdm/src/main/java/com/ether/pms/mdm/entity/EquipmentHealthScore.java @@ -0,0 +1,140 @@ +package com.ether.pms.mdm.entity; + +import jakarta.persistence.*; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "ops_equipment_health_score", indexes = { + @Index(name = "idx_health_equipment", columnList = "equipment_id"), + @Index(name = "idx_health_calc_time", columnList = "calculated_at"), + @Index(name = "idx_health_project", columnList = "project_id") +}) +@Data +public class EquipmentHealthScore { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "project_id", nullable = false) + private UUID projectId; + + @Column(name = "equipment_id", nullable = false) + private UUID equipmentId; + + @Column(name = "equipment_code", length = 50) + private String equipmentCode; + + @Column(name = "equipment_name", length = 100) + private String equipmentName; + + @Column(name = "health_score", nullable = false, precision = 5, scale = 2) + private BigDecimal healthScore; + + @Column(name = "failure_deduction", precision = 5, scale = 2) + private BigDecimal failureDeduction; + + @Column(name = "maintenance_deduction", precision = 5, scale = 2) + private BigDecimal maintenanceDeduction; + + @Column(name = "age_deduction", precision = 5, scale = 2) + private BigDecimal ageDeduction; + + @Column(name = "failure_count_30d") + private Integer failureCount30d; + + @Column(name = "maintenance_completion_rate", precision = 5, scale = 4) + private BigDecimal maintenanceCompletionRate; + + @Column(name = "equipment_age_years", precision = 10, scale = 2) + private BigDecimal equipmentAgeYears; + + @Column(name = "operation_hours_30d", precision = 12, scale = 2) + private BigDecimal operationHours30d; + + @Column(name = "mtbf_hours", precision = 12, scale = 2) + private BigDecimal mtbfHours; + + @Column(name = "mttr_hours", precision = 12, scale = 2) + private BigDecimal mttrHours; + + @Column(name = "health_level", length = 20) + @Enumerated(EnumType.STRING) + private HealthLevel healthLevel; + + @Column(name = "calculated_at", nullable = false) + private LocalDateTime calculatedAt; + + @Column(name = "calculation_period_days") + private Integer calculationPeriodDays; + + @Column(name = "remarks", columnDefinition = "TEXT") + private String remarks; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + public void prePersist() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + if (calculatedAt == null) { + calculatedAt = LocalDateTime.now(); + } + } + + @PreUpdate + public void preUpdate() { + updatedAt = LocalDateTime.now(); + } + + public enum HealthLevel { + EXCELLENT("优秀", 90, 100), + GOOD("良好", 75, 90), + FAIR("一般", 60, 75), + POOR("较差", 40, 60), + CRITICAL("危急", 0, 40); + + private final String desc; + private final int minScore; + private final int maxScore; + + HealthLevel(String desc, int minScore, int maxScore) { + this.desc = desc; + this.minScore = minScore; + this.maxScore = maxScore; + } + + public String getDesc() { + return desc; + } + + public int getMinScore() { + return minScore; + } + + public int getMaxScore() { + return maxScore; + } + + public static HealthLevel fromScore(BigDecimal score) { + if (score == null) { + return CRITICAL; + } + double s = score.doubleValue(); + for (HealthLevel level : values()) { + if (s >= level.minScore && s < level.maxScore) { + return level; + } + } + return CRITICAL; + } + } +} \ No newline at end of file diff --git a/module-mdm/src/main/java/com/ether/pms/mdm/repository/EquipmentFailureHistoryRepository.java b/module-mdm/src/main/java/com/ether/pms/mdm/repository/EquipmentFailureHistoryRepository.java new file mode 100644 index 0000000..619ff6f --- /dev/null +++ b/module-mdm/src/main/java/com/ether/pms/mdm/repository/EquipmentFailureHistoryRepository.java @@ -0,0 +1,28 @@ +package com.ether.pms.mdm.repository; + +import com.ether.pms.mdm.entity.EquipmentFailureHistory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Repository +public interface EquipmentFailureHistoryRepository extends JpaRepository { + + List findByEquipmentIdOrderByFailureTimeDesc(UUID equipmentId); + + List findByProjectIdAndFailureTimeBetweenOrderByFailureTimeDesc( + UUID projectId, LocalDateTime startTime, LocalDateTime endTime); + + @Query("SELECT f FROM EquipmentFailureHistory f WHERE f.equipmentId = :equipmentId AND f.failureTime >= :since ORDER BY f.failureTime DESC") + List findByEquipmentIdSince(@Param("equipmentId") UUID equipmentId, @Param("since") LocalDateTime since); + + @Query("SELECT COUNT(f) FROM EquipmentFailureHistory f WHERE f.equipmentId = :equipmentId AND f.failureTime >= :since") + long countByEquipmentIdSince(@Param("equipmentId") UUID equipmentId, @Param("since") LocalDateTime since); + + @Query("SELECT f FROM EquipmentFailureHistory f WHERE f.equipmentId = :equipmentId AND f.repairEndTime IS NOT NULL AND f.failureTime >= :since ORDER BY f.failureTime DESC") + List findRepairedFailuresByEquipmentIdSince(@Param("equipmentId") UUID equipmentId, @Param("since") LocalDateTime since); +} \ No newline at end of file diff --git a/module-mdm/src/main/java/com/ether/pms/mdm/repository/EquipmentHealthScoreRepository.java b/module-mdm/src/main/java/com/ether/pms/mdm/repository/EquipmentHealthScoreRepository.java new file mode 100644 index 0000000..7e7b7ae --- /dev/null +++ b/module-mdm/src/main/java/com/ether/pms/mdm/repository/EquipmentHealthScoreRepository.java @@ -0,0 +1,29 @@ +package com.ether.pms.mdm.repository; + +import com.ether.pms.mdm.entity.EquipmentHealthScore; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface EquipmentHealthScoreRepository extends JpaRepository { + + List findByEquipmentIdOrderByCalculatedAtDesc(UUID equipmentId); + + @Query("SELECT h FROM EquipmentHealthScore h WHERE h.equipmentId = :equipmentId ORDER BY h.calculatedAt DESC LIMIT 1") + Optional findLatestByEquipmentId(@Param("equipmentId") UUID equipmentId); + + @Query("SELECT h FROM EquipmentHealthScore h WHERE h.equipmentId = :equipmentId AND h.calculatedAt >= :since ORDER BY h.calculatedAt DESC") + List findByEquipmentIdSince(@Param("equipmentId") UUID equipmentId, @Param("since") LocalDateTime since); + + @Query("SELECT h FROM EquipmentHealthScore h WHERE h.projectId = :projectId ORDER BY h.calculatedAt DESC") + List findByProjectIdOrderByCalculatedAtDesc(@Param("projectId") UUID projectId); + + @Query("SELECT h FROM EquipmentHealthScore h WHERE h.projectId = :projectId AND h.healthLevel IN :levels ORDER BY h.healthScore ASC") + List findByProjectIdAndHealthLevelIn(@Param("projectId") UUID projectId, @Param("levels") List levels); +} \ No newline at end of file diff --git a/module-mdm/src/main/java/com/ether/pms/mdm/service/EquipmentHealthService.java b/module-mdm/src/main/java/com/ether/pms/mdm/service/EquipmentHealthService.java new file mode 100644 index 0000000..590df52 --- /dev/null +++ b/module-mdm/src/main/java/com/ether/pms/mdm/service/EquipmentHealthService.java @@ -0,0 +1,52 @@ +package com.ether.pms.mdm.service; + +import com.ether.pms.mdm.entity.EquipmentFailureHistory; +import com.ether.pms.mdm.entity.EquipmentHealthScore; +import java.math.BigDecimal; +import java.util.List; +import java.util.UUID; + +public interface EquipmentHealthService { + + /** + * 计算设备健康度 + * 基础分 = 100 + * 故障率扣分 = 故障次数 × 5(近30天每故障扣5分) + * 维保扣分 = (1 - 维保完成率) × 20 + * 年龄扣分 = 投入使用年限 × 2(最高扣10分) + * 健康度 = 基础分 - 故障率扣分 - 维保扣分 - 年龄扣分 + */ + EquipmentHealthScore calculateHealthScore(UUID equipmentId); + + /** + * 计算平均故障间隔时间 (MTBF) + * MTBF = 运行时间 / 故障次数 + */ + BigDecimal calculateMTBF(UUID equipmentId, Integer days); + + /** + * 计算平均修复时间 (MTTR) + * MTTR = 总修复时间 / 修复次数 + */ + BigDecimal calculateMTTR(UUID equipmentId, Integer days); + + /** + * 记录故障 + */ + EquipmentFailureHistory recordFailure(EquipmentFailureHistory failure); + + /** + * 获取设备健康度历史 + */ + List getHealthHistory(UUID equipmentId); + + /** + * 获取设备最新健康度 + */ + EquipmentHealthScore getLatestHealthScore(UUID equipmentId); + + /** + * 获取设备的故障历史 + */ + List getFailureHistory(UUID equipmentId); +} \ No newline at end of file diff --git a/module-mdm/src/main/java/com/ether/pms/mdm/service/impl/EquipmentHealthServiceImpl.java b/module-mdm/src/main/java/com/ether/pms/mdm/service/impl/EquipmentHealthServiceImpl.java new file mode 100644 index 0000000..110ead9 --- /dev/null +++ b/module-mdm/src/main/java/com/ether/pms/mdm/service/impl/EquipmentHealthServiceImpl.java @@ -0,0 +1,254 @@ +package com.ether.pms.mdm.service.impl; + +import com.ether.pms.common.BusinessException; +import com.ether.pms.mdm.entity.EquipmentFailureHistory; +import com.ether.pms.mdm.entity.EquipmentHealthScore; +import com.ether.pms.mdm.entity.MaintenanceTask; +import com.ether.pms.mdm.entity.SpaceNode; +import com.ether.pms.mdm.repository.EquipmentFailureHistoryRepository; +import com.ether.pms.mdm.repository.EquipmentHealthScoreRepository; +import com.ether.pms.mdm.repository.MaintenanceTaskRepository; +import com.ether.pms.mdm.repository.SpaceNodeRepository; +import com.ether.pms.mdm.service.EquipmentHealthService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.Period; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class EquipmentHealthServiceImpl implements EquipmentHealthService { + + private final EquipmentFailureHistoryRepository failureHistoryRepository; + private final EquipmentHealthScoreRepository healthScoreRepository; + private final SpaceNodeRepository spaceNodeRepository; + private final MaintenanceTaskRepository maintenanceTaskRepository; + + private static final BigDecimal BASE_SCORE = new BigDecimal("100"); + private static final BigDecimal FAILURE_DEDUCTION_PER_COUNT = new BigDecimal("5"); + private static final BigDecimal MAINTENANCE_DEDUCTION_FACTOR = new BigDecimal("20"); + private static final BigDecimal AGE_DEDUCTION_PER_YEAR = new BigDecimal("2"); + private static final BigDecimal MAX_AGE_DEDUCTION = new BigDecimal("10"); + private static final int DEFAULT_DAYS = 30; + + @Override + @Transactional + public EquipmentHealthScore calculateHealthScore(UUID equipmentId) { + SpaceNode equipment = spaceNodeRepository.findByIdAndIsDeletedFalse(equipmentId) + .orElseThrow(() -> new BusinessException(6001, "设备不存在")); + + if (!Boolean.TRUE.equals(equipment.getIsEquipment())) { + throw new BusinessException(6002, "该空间节点不是设备"); + } + + // 获取近30天故障次数 + LocalDateTime thirtyDaysAgo = LocalDateTime.now().minusDays(30); + long failureCount30d = failureHistoryRepository.countByEquipmentIdSince(equipmentId, thirtyDaysAgo); + + // 计算故障率扣分 + BigDecimal failureDeduction = FAILURE_DEDUCTION_PER_COUNT.multiply(BigDecimal.valueOf(failureCount30d)); + + // 计算维保完成率 + List tasks = maintenanceTaskRepository.findByEquipmentIdAndStatusNot(equipmentId, MaintenanceTask.Status.CANCELLED); + long totalTasks = tasks.size(); + long completedTasks = tasks.stream() + .filter(t -> t.getStatus() == MaintenanceTask.Status.COMPLETED) + .count(); + + BigDecimal maintenanceCompletionRate = BigDecimal.ONE; + if (totalTasks > 0) { + maintenanceCompletionRate = BigDecimal.valueOf(completedTasks) + .divide(BigDecimal.valueOf(totalTasks), 4, RoundingMode.HALF_UP); + } + BigDecimal maintenanceDeduction = BigDecimal.ONE.subtract(maintenanceCompletionRate) + .multiply(MAINTENANCE_DEDUCTION_FACTOR); + + // 计算年龄扣分 + BigDecimal equipmentAgeYears = calculateEquipmentAge(equipment); + BigDecimal ageDeduction = equipmentAgeYears.multiply(AGE_DEDUCTION_PER_YEAR); + if (ageDeduction.compareTo(MAX_AGE_DEDUCTION) > 0) { + ageDeduction = MAX_AGE_DEDUCTION; + } + + // 计算健康度 + BigDecimal healthScore = BASE_SCORE + .subtract(failureDeduction) + .subtract(maintenanceDeduction) + .subtract(ageDeduction); + + // 确保健康度不低于0 + if (healthScore.compareTo(BigDecimal.ZERO) < 0) { + healthScore = BigDecimal.ZERO; + } + + // 计算MTBF和MTTR + BigDecimal mtbf = calculateMTBF(equipmentId, DEFAULT_DAYS); + BigDecimal mttr = calculateMTTR(equipmentId, DEFAULT_DAYS); + + // 创建健康度记录 + EquipmentHealthScore health = new EquipmentHealthScore(); + health.setProjectId(equipment.getProjectId()); + health.setEquipmentId(equipmentId); + health.setEquipmentCode(equipment.getCode()); + health.setEquipmentName(equipment.getName()); + health.setHealthScore(healthScore.setScale(2, RoundingMode.HALF_UP)); + health.setFailureDeduction(failureDeduction.setScale(2, RoundingMode.HALF_UP)); + health.setMaintenanceDeduction(maintenanceDeduction.setScale(2, RoundingMode.HALF_UP)); + health.setAgeDeduction(ageDeduction.setScale(2, RoundingMode.HALF_UP)); + health.setFailureCount30d((int) failureCount30d); + health.setMaintenanceCompletionRate(maintenanceCompletionRate.setScale(4, RoundingMode.HALF_UP)); + health.setEquipmentAgeYears(equipmentAgeYears.setScale(2, RoundingMode.HALF_UP)); + health.setMtbfHours(mtbf != null ? mtbf : BigDecimal.ZERO); + health.setMttrHours(mttr != null ? mttr : BigDecimal.ZERO); + health.setHealthLevel(EquipmentHealthScore.HealthLevel.fromScore(healthScore)); + health.setCalculatedAt(LocalDateTime.now()); + health.setCalculationPeriodDays(DEFAULT_DAYS); + + return healthScoreRepository.save(health); + } + + @Override + public BigDecimal calculateMTBF(UUID equipmentId, Integer days) { + if (days == null || days <= 0) { + days = DEFAULT_DAYS; + } + + LocalDateTime since = LocalDateTime.now().minusDays(days); + List failures = failureHistoryRepository.findByEquipmentIdSince(equipmentId, since); + + if (failures.isEmpty()) { + return BigDecimal.ZERO; + } + + // 计算运行时间(从第一个故障到最后一个故障,或者到现在) + LocalDateTime firstFailure = failures.get(failures.size() - 1).getFailureTime(); + LocalDateTime lastFailure = failures.get(0).getFailureTime(); + + // 如果只有一个故障,MTBF = 总时间 / 故障次数 = 0(因为还在运行) + // 这里我们用总时间跨度 / 故障次数 + long totalHours = ChronoUnit.HOURS.between(firstFailure, lastFailure); + if (totalHours <= 0) { + totalHours = days * 24L; // 使用默认期间 + } + + // MTBF = 运行时间 / 故障次数 + BigDecimal mtbf = BigDecimal.valueOf(totalHours) + .divide(BigDecimal.valueOf(failures.size()), 2, RoundingMode.HALF_UP); + + return mtbf; + } + + @Override + public BigDecimal calculateMTTR(UUID equipmentId, Integer days) { + if (days == null || days <= 0) { + days = DEFAULT_DAYS; + } + + LocalDateTime since = LocalDateTime.now().minusDays(days); + List repairedFailures = failureHistoryRepository + .findRepairedFailuresByEquipmentIdSince(equipmentId, since); + + if (repairedFailures.isEmpty()) { + return BigDecimal.ZERO; + } + + // 计算总修复时间 + double totalRepairHours = repairedFailures.stream() + .filter(f -> f.getRepairDurationHours() != null) + .mapToDouble(EquipmentFailureHistory::getRepairDurationHours) + .sum(); + + // MTTR = 总修复时间 / 修复次数 + BigDecimal mttr = BigDecimal.valueOf(totalRepairHours) + .divide(BigDecimal.valueOf(repairedFailures.size()), 2, RoundingMode.HALF_UP); + + return mttr; + } + + @Override + @Transactional + public EquipmentFailureHistory recordFailure(EquipmentFailureHistory failure) { + // 校验设备存在 + SpaceNode equipment = spaceNodeRepository.findByIdAndIsDeletedFalse(failure.getEquipmentId()) + .orElseThrow(() -> new BusinessException(6001, "设备不存在")); + + if (!Boolean.TRUE.equals(equipment.getIsEquipment())) { + throw new BusinessException(6002, "该空间节点不是设备"); + } + + // 设置项目ID + if (failure.getProjectId() == null) { + failure.setProjectId(equipment.getProjectId()); + } + + // 设置设备信息 + if (failure.getEquipmentCode() == null) { + failure.setEquipmentCode(equipment.getCode()); + } + if (failure.getEquipmentName() == null) { + failure.setEquipmentName(equipment.getName()); + } + + // 计算修复时长 + if (failure.getRepairStartTime() != null && failure.getRepairEndTime() != null) { + long minutes = ChronoUnit.MINUTES.between(failure.getRepairStartTime(), failure.getRepairEndTime()); + double hours = minutes / 60.0; + failure.setRepairDurationHours(hours); + + // 计算停机时长(如果故障时间和修复开始时间不同) + if (failure.getFailureTime() != null && failure.getRepairStartTime().isAfter(failure.getFailureTime())) { + long downtimeMinutes = ChronoUnit.MINUTES.between(failure.getFailureTime(), failure.getRepairStartTime()); + failure.setDowntimeHours(downtimeMinutes / 60.0); + } + } + + return failureHistoryRepository.save(failure); + } + + @Override + public List getHealthHistory(UUID equipmentId) { + return healthScoreRepository.findByEquipmentIdOrderByCalculatedAtDesc(equipmentId); + } + + @Override + public EquipmentHealthScore getLatestHealthScore(UUID equipmentId) { + return healthScoreRepository.findLatestByEquipmentId(equipmentId) + .orElseThrow(() -> new BusinessException(6003, "没有健康度记录")); + } + + @Override + public List getFailureHistory(UUID equipmentId) { + return failureHistoryRepository.findByEquipmentIdOrderByFailureTimeDesc(equipmentId); + } + + /** + * 计算设备年龄(年) + */ + private BigDecimal calculateEquipmentAge(SpaceNode equipment) { + LocalDate installationDate = equipment.getMaintenanceContractStart(); + if (installationDate == null) { + // 如果没有安装日期,使用创建日期 + if (equipment.getCreatedAt() != null) { + installationDate = equipment.getCreatedAt().toLocalDate(); + } else { + return BigDecimal.ZERO; + } + } + + Period period = Period.between(installationDate, LocalDate.now()); + int years = period.getYears(); + int months = period.getMonths(); + + return BigDecimal.valueOf(years).add(BigDecimal.valueOf(months).divide(BigDecimal.valueOf(12), 2, RoundingMode.HALF_UP)); + } +} \ No newline at end of file