yeg
2026-09-03 76cd5948e95ddbde9dd4411b466ee75ba20b562e
实现测试预警异常管理

新增成功接收数据的阈值异常聚合、查询与负责人关闭审计。完善异常表开放记录约束和模拟器专项测试。
5个文件已添加
4个文件已修改
328 ■■■■■ 已修改文件
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestWarningExceptionController.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/db/migration/V41__test_warning_exceptions.sql 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestWarningExceptionServiceTest.java 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestWarningException.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestLiveServiceImpl.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImpl.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestWarningExceptionService.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestWarningExceptionController.java
New file
@@ -0,0 +1,47 @@
package com.ruoyi.web.controller.system;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.system.domain.TTestWarningException;
import com.ruoyi.system.mapper.TTestMapper;
import com.ruoyi.system.service.ITTestService;
import com.ruoyi.system.service.impl.TestWarningExceptionService;
/** 测试预警异常查询与人工关闭接口。 */
@RestController
@RequestMapping("/system/test-warning-exceptions")
public class TTestWarningExceptionController extends BaseController {
    @Autowired private TTestMapper mapper;
    @Autowired private ITTestService testService;
    @Autowired private TestWarningExceptionService warningExceptionService;
    /** 按测试和状态查询后端异常事实。 */
    @PreAuthorize("@ss.hasPermi('system:exception:query')")
    @GetMapping("/list")
    public TableDataInfo list(@RequestParam(required=false) Long testId, @RequestParam(required=false) String status) {
        startPage();
        List<TTestWarningException> rows = mapper.selectWarningExceptions(testId, status);
        return getDataTable(rows);
    }
    /** 校验测试负责人身份并关闭异常,同时追加测试审计事实。 */
    @PreAuthorize("@ss.hasPermi('system:exception:handle')")
    @Log(title = "测试预警异常", businessType = BusinessType.UPDATE)
    @PutMapping("/{exceptionId}/close")
    public AjaxResult close(@PathVariable Long exceptionId, @RequestBody CloseRequest request) {
        if (request == null || request.handlingNote() == null || request.handlingNote().trim().isEmpty()) return error("处理结果不能为空");
        TTestWarningException exception = mapper.selectWarningExceptionById(exceptionId);
        if (exception == null) return error("异常不存在");
        var test = testService.selectTestById(exception.getTestId());
        if (test == null || !getUserId().equals(test.getPrincipalId()))
            return error("仅测试负责人可以关闭预警异常");
        int rows = warningExceptionService.close(exceptionId, getUserId(), getUsername(), request.handlingNote().trim());
        return rows == 1 ? success(mapper.selectWarningExceptionById(exceptionId)) : error("异常已关闭或不存在");
    }
    public record CloseRequest(String handlingNote) {}
}
ruoyi-admin/src/main/resources/db/migration/V41__test_warning_exceptions.sql
New file
@@ -0,0 +1,23 @@
CREATE TABLE t_test_warning_exception (
    exception_id BIGINT NOT NULL AUTO_INCREMENT,
    test_id BIGINT NOT NULL,
    threshold_id BIGINT NOT NULL,
    source_type VARCHAR(32) NOT NULL,
    source_identity VARCHAR(255) NOT NULL DEFAULT '',
    metric_code VARCHAR(100) NOT NULL,
    abnormal_value DECIMAL(30,12) NOT NULL,
    unit VARCHAR(32) NOT NULL,
    rule_snapshot JSON NOT NULL,
    severity VARCHAR(32) NOT NULL DEFAULT 'WARNING',
    exception_status VARCHAR(16) NOT NULL DEFAULT 'OPEN',
    first_occurred_at_utc DATETIME(3) NOT NULL,
    last_occurred_at_utc DATETIME(3) NOT NULL,
    occurrence_count BIGINT NOT NULL DEFAULT 1,
    handled_by BIGINT NULL,
    handled_at_utc DATETIME(3) NULL,
    handling_note VARCHAR(1000) NULL,
    PRIMARY KEY (exception_id),
    open_threshold_id BIGINT GENERATED ALWAYS AS (IF(exception_status = 'OPEN', threshold_id, NULL)) STORED,
    UNIQUE KEY uk_warning_exception_open (test_id, metric_code, open_threshold_id),
    KEY idx_warning_exception_query (test_id, exception_status, last_occurred_at_utc)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='测试预警阈值异常';
ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestWarningExceptionServiceTest.java
New file
@@ -0,0 +1,60 @@
package com.ruoyi.system.service.impl;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import org.junit.jupiter.api.Test;
import com.ruoyi.system.domain.TTest;
import com.ruoyi.system.domain.TTestWarningException;
import com.ruoyi.system.domain.TTestWarningThreshold;
import com.ruoyi.system.mapper.TTestMapper;
/** 测试预警仅由成功接收测量值触发,并通过持久层原子聚合。 */
class TestWarningExceptionServiceTest {
    @Test
    void evaluatesOnlyOutOfRangeValues() {
        TTestMapper mapper = mock(TTestMapper.class);
        TestWarningExceptionService service = new TestWarningExceptionService(mapper);
        TTest test = testWithThreshold("TEMPERATURE", new BigDecimal("0"), new BigDecimal("30"));
        service.evaluate(test, "COLLECTOR", "adapter:9:S-01", "temperature", new BigDecimal("31"), "℃", new Date());
        service.evaluate(test, "COLLECTOR", "adapter:9:S-01", "temperature", new BigDecimal("20"), "℃", new Date());
        verify(mapper, times(1)).upsertWarningException(eq(1L), eq(11L), eq("COLLECTOR"), eq("adapter:9:S-01"),
                eq("temperature"), eq(new BigDecimal("31")), eq("℃"), anyString(), eq("WARNING"), any(Date.class));
    }
    @Test
    void closesOpenExceptionAndAppendsAuditInServiceOperation() {
        TTestMapper mapper = mock(TTestMapper.class);
        TestWarningExceptionService service = new TestWarningExceptionService(mapper);
        TTestWarningException exception = new TTestWarningException();
        exception.setExceptionId(7L);
        exception.setTestId(1L);
        when(mapper.selectWarningExceptionById(7L)).thenReturn(exception);
        when(mapper.closeWarningException(eq(7L), eq(22L), eq("负责人"), any(Date.class), eq("已核实"))).thenReturn(1);
        int rows = service.close(7L, 22L, "负责人", "已核实");
        assertEquals(1, rows);
        verify(mapper).insertTestAudit(eq(1L), eq("WARNING_EXCEPTION_CLOSED"), eq(22L), eq("负责人"), isNull(), contains("已核实"));
    }
    private static TTest testWithThreshold(String metric, BigDecimal min, BigDecimal max) {
        TTestWarningThreshold threshold = new TTestWarningThreshold();
        threshold.setThresholdId(11L);
        threshold.setMetricCode(metric);
        threshold.setMinValue(min);
        threshold.setMaxValue(max);
        threshold.setUnit("℃");
        threshold.setSeverity("WARNING");
        TTest test = new TTest();
        test.setTestId(1L);
        test.setThresholds(List.of(threshold));
        return test;
    }
}
ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestWarningException.java
New file
@@ -0,0 +1,46 @@
package com.ruoyi.system.domain;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/** 测试阈值预警异常事实。 */
public class TTestWarningException implements Serializable {
    private static final long serialVersionUID = 1L;
    private Long exceptionId;
    private Long testId;
    private String testName;
    private String sourceType;
    private String sourceIdentity;
    private String metricCode;
    private BigDecimal abnormalValue;
    private String unit;
    private String ruleSnapshot;
    private String severity;
    private String exceptionStatus;
    private Date firstOccurredAtUtc;
    private Date lastOccurredAtUtc;
    private Long occurrenceCount;
    private Long handledBy;
    private String handledByName;
    private Date handledAtUtc;
    private String handlingNote;
    public Long getExceptionId(){return exceptionId;} public void setExceptionId(Long v){exceptionId=v;}
    public Long getTestId(){return testId;} public void setTestId(Long v){testId=v;}
    public String getTestName(){return testName;} public void setTestName(String v){testName=v;}
    public String getSourceType(){return sourceType;} public void setSourceType(String v){sourceType=v;}
    public String getSourceIdentity(){return sourceIdentity;} public void setSourceIdentity(String v){sourceIdentity=v;}
    public String getMetricCode(){return metricCode;} public void setMetricCode(String v){metricCode=v;}
    public BigDecimal getAbnormalValue(){return abnormalValue;} public void setAbnormalValue(BigDecimal v){abnormalValue=v;}
    public String getUnit(){return unit;} public void setUnit(String v){unit=v;}
    public String getRuleSnapshot(){return ruleSnapshot;} public void setRuleSnapshot(String v){ruleSnapshot=v;}
    public String getSeverity(){return severity;} public void setSeverity(String v){severity=v;}
    public String getExceptionStatus(){return exceptionStatus;} public void setExceptionStatus(String v){exceptionStatus=v;}
    public Date getFirstOccurredAtUtc(){return firstOccurredAtUtc;} public void setFirstOccurredAtUtc(Date v){firstOccurredAtUtc=v;}
    public Date getLastOccurredAtUtc(){return lastOccurredAtUtc;} public void setLastOccurredAtUtc(Date v){lastOccurredAtUtc=v;}
    public Long getOccurrenceCount(){return occurrenceCount;} public void setOccurrenceCount(Long v){occurrenceCount=v;}
    public Long getHandledBy(){return handledBy;} public void setHandledBy(Long v){handledBy=v;}
    public String getHandledByName(){return handledByName;} public void setHandledByName(String v){handledByName=v;}
    public Date getHandledAtUtc(){return handledAtUtc;} public void setHandledAtUtc(Date v){handledAtUtc=v;}
    public String getHandlingNote(){return handlingNote;} public void setHandlingNote(String v){handlingNote=v;}
}
ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java
@@ -11,6 +11,7 @@
import com.ruoyi.system.domain.TTestPlanStep;
import com.ruoyi.system.domain.TTestResult;
import com.ruoyi.system.domain.TTestWarningThreshold;
import com.ruoyi.system.domain.TTestWarningException;
import com.ruoyi.system.domain.TTestLogicStepMapping;
import com.ruoyi.system.domain.TDetectionChannel;
import com.ruoyi.system.domain.TTestDataRevisionAction;
@@ -51,6 +52,20 @@
    List<TTestChannelBinding> selectTestChannelBindings(Long testId);
    List<TTestCollectorChannel> selectTestCollectorChannels(Long testId);
    List<TTestWarningThreshold> selectTestThresholds(Long testId);
    /** 以测试、指标和阈值规则为开放异常键原子聚合成功接收的超限值。 */
    int upsertWarningException(@Param("testId") Long testId, @Param("thresholdId") Long thresholdId,
            @Param("sourceType") String sourceType, @Param("sourceIdentity") String sourceIdentity,
            @Param("metricCode") String metricCode, @Param("value") java.math.BigDecimal value,
            @Param("unit") String unit, @Param("ruleSnapshot") String ruleSnapshot,
            @Param("severity") String severity, @Param("occurredAt") Date occurredAt);
    /** 查询异常事实;不从设备状态或缺失数据推导异常。 */
    List<TTestWarningException> selectWarningExceptions(@Param("testId") Long testId,
            @Param("status") String status);
    TTestWarningException selectWarningExceptionById(Long exceptionId);
    /** 仅将开放异常转为已关闭,原始异常事实仍保留。 */
    int closeWarningException(@Param("exceptionId") Long exceptionId, @Param("handledBy") Long handledBy,
            @Param("handledByName") String handledByName, @Param("handledAt") Date handledAt,
            @Param("handlingNote") String handlingNote);
    /** 查询当前正在接收的测试,供接收调度绑定测试事实。 */
    List<Long> selectReceivingTestIds();
    int insertRealtimeSnapshot(@Param("testId") Long testId, @Param("bindingId") Long bindingId,
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestLiveServiceImpl.java
@@ -13,6 +13,7 @@
import java.util.concurrent.TimeUnit;
import jakarta.annotation.PreDestroy;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import com.ruoyi.device.bts.BtsDevelopmentSimulator;
import com.ruoyi.device.bts.BtsDevelopmentSimulator.CollectorSnapshot;
@@ -26,6 +27,7 @@
import com.ruoyi.system.service.ITestLiveService.TestPlanRow;
import com.ruoyi.system.service.ITestLiveService.TestPlanSnapshot;
import com.ruoyi.system.service.ITTestService;
import com.ruoyi.system.mapper.TTestMapper;
/** 将设备最新快照转换为测试视角,并托管 SSE 连接生命周期。 */
@Service
@@ -34,11 +36,17 @@
    private final ITTestService tests;
    private final DeviceDiscoveryCandidateStore candidates;
    private final BtsDevelopmentSimulator simulator;
    private final TTestMapper mapper;
    private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
    @Autowired
    public TTestLiveServiceImpl(ITTestService tests, DeviceDiscoveryCandidateStore candidates,
            BtsDevelopmentSimulator simulator, TTestMapper mapper)
    { this.tests = tests; this.candidates = candidates; this.simulator = simulator; this.mapper = mapper; }
    public TTestLiveServiceImpl(ITTestService tests, DeviceDiscoveryCandidateStore candidates,
            BtsDevelopmentSimulator simulator)
    { this.tests = tests; this.candidates = candidates; this.simulator = simulator; }
    { this(tests, candidates, simulator, null); }
    @Override public LiveState current(Long testId)
    {
@@ -60,6 +68,12 @@
                .map(source -> Map.<String, Object>of("sourceType", source.sourceType(), "sourceId", source.sourceId(),
                        "batteryNo", source.batteryNo(), "status", source.deviceStatus(), "message", "数据来源当前不可观测"))
                .toList());
        if (mapper != null) {
            mapper.selectWarningExceptions(testId, "OPEN").forEach(item -> exceptions.add(Map.of(
                    "exceptionId", item.getExceptionId(), "sourceType", item.getSourceType(),
                    "metricCode", item.getMetricCode(), "value", item.getAbnormalValue(), "unit", item.getUnit(),
                    "status", item.getExceptionStatus(), "message", "测试预警异常")));
        }
        String testStatus = test.getTestStatus();
        TestPlanSnapshot testPlan = detectorTestPlan(test);
        return new LiveState(testId, testStatus, test.getReceptionStatus(), Instant.now(), sources, exceptions, testPlan);
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImpl.java
@@ -11,6 +11,7 @@
import java.util.List;
import com.alibaba.fastjson2.JSON;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.device.bts.BtsDevelopmentSimulator;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.BtsHistoryRecord;
@@ -30,15 +31,23 @@
    private final TTestMapper mapper;
    private final ITTestService tests;
    private final BtsDevelopmentSimulator simulator;
    private final TestWarningExceptionService warningExceptions;
    private final Map<String, Instant> lastSnapshotAt = new java.util.concurrent.ConcurrentHashMap<>();
    private final Map<Long, Instant> lastRawCaptureAt = new java.util.concurrent.ConcurrentHashMap<>();
    public TestDataReceptionServiceImpl(TTestMapper mapper, ITTestService tests, BtsDevelopmentSimulator simulator)
    @Autowired
    public TestDataReceptionServiceImpl(TTestMapper mapper, ITTestService tests, BtsDevelopmentSimulator simulator,
            TestWarningExceptionService warningExceptions)
    {
        this.mapper = mapper;
        this.tests = tests;
        this.simulator = simulator;
        this.warningExceptions = warningExceptions;
    }
    /** 保留单元测试及旧调用方构造契约。 */
    public TestDataReceptionServiceImpl(TTestMapper mapper, ITTestService tests, BtsDevelopmentSimulator simulator)
    { this(mapper, tests, simulator, new TestWarningExceptionService(mapper)); }
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -169,7 +178,15 @@
            {
                // 模拟器保留通道历史;只接收本次测试开始后产生的设备时间记录,避免顺序测试串入旧事实。
                if (startedAt != null && record.deviceTime().isBefore(startedAt.toInstant())) continue;
                save(test, binding, record);
                if (save(test, binding, record)) {
                    String source = String.join(":", java.util.Objects.toString(binding.getAdapterCode(), ""),
                            java.util.Objects.toString(binding.getDevtype(), ""), java.util.Objects.toString(binding.getDevid(), ""),
                            java.util.Objects.toString(binding.getSubdevid(), ""), java.util.Objects.toString(binding.getChlid(), ""));
                    warningExceptions.evaluate(test, "CABINET", source, "VOLTAGE", BigDecimal.valueOf(record.voltageV()), "V", Date.from(record.deviceTime()));
                    warningExceptions.evaluate(test, "CABINET", source, "CURRENT", BigDecimal.valueOf(record.currentA()), "A", Date.from(record.deviceTime()));
                    warningExceptions.evaluate(test, "CABINET", source, "CAPACITY", BigDecimal.valueOf(record.capacityAh()), "Ah", Date.from(record.deviceTime()));
                    warningExceptions.evaluate(test, "CABINET", source, "ENERGY", BigDecimal.valueOf(record.energyWh()), "Wh", Date.from(record.deviceTime()));
                }
            }
        }
    }
@@ -192,11 +209,12 @@
                    java.util.Objects.equals(mapping.getChannelCode(), measurement.channelCode())
                    && metricMatches(mapping.getMeasurementCode(), measurement.metricCode()));
            if (!mapped) continue;
            saveCollector(test, battery, measurement);
            if (saveCollector(test, battery, measurement))
                warningExceptions.evaluate(test, "COLLECTOR", measurement.adapterCode() + ":" + battery.getCollectorId() + ":" + measurement.channelCode(), measurement.metricCode(), measurement.value(), measurement.unit(), Date.from(measurement.deviceTime()));
        }
    }
    private void saveCollector(TTest test, TTestBattery battery, CollectorRealtimeMeasurement measurement)
    private boolean saveCollector(TTest test, TTestBattery battery, CollectorRealtimeMeasurement measurement)
    {
        byte[] hash = hash(measurement);
        String old = mapper.selectCollectorSampleContentHash(battery.getCollectorId(), measurement.channelCode(),
@@ -209,7 +227,7 @@
                        "采集器实时接收内容冲突 collector=" + battery.getCollectorId() + ", channel="
                                + measurement.channelCode() + ", metric=" + measurement.metricCode()
                                + ", seqid=" + measurement.sequenceId());
            return;
            return false;
        }
        Date receivedAt = new Date();
        int inserted = mapper.insertCollectorSample(test.getTestId(), battery.getBinding().getBindingId(), battery.getCollectorId(),
@@ -228,9 +246,10 @@
                                + measurement.channelCode() + ", metric=" + measurement.metricCode()
                                + ", seqid=" + measurement.sequenceId());
        }
        return inserted > 0;
    }
    private void save(TTest test, TTestChannelBinding binding,
    private boolean save(TTest test, TTestChannelBinding binding,
            BtsHistoryRecord record)
    {
        // 模拟器历史 testid 按通道固定;接收事实必须再带测试作用域,避免顺序测试共用全局幂等键。
@@ -242,7 +261,7 @@
            if (!old.equalsIgnoreCase(HexFormat.of().formatHex(hash)))
                mapper.insertSampleConflict(test.getTestId(), binding.getChannelId(),
                        "实时接收内容冲突 testid=" + btsTestId + ", seqid=" + record.sequenceId());
            return;
            return false;
        }
        int inserted = mapper.insertBtsSample(test.getTestId(), binding.getBindingId(), binding.getChannelId(),
                btsTestId, record.sequenceId(), Date.from(record.deviceTime()),
@@ -257,6 +276,7 @@
                mapper.insertSampleConflict(test.getTestId(), binding.getChannelId(),
                        "实时接收并发内容冲突 testid=" + btsTestId + ", seqid=" + record.sequenceId());
        }
        return inserted > 0;
    }
    private byte[] hash(BtsHistoryRecord record)
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestWarningExceptionService.java
New file
@@ -0,0 +1,51 @@
package com.ruoyi.system.service.impl;
import java.math.BigDecimal;
import java.util.Date;
import com.alibaba.fastjson2.JSON;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.system.domain.TTest;
import com.ruoyi.system.domain.TTestWarningException;
import com.ruoyi.system.domain.TTestWarningThreshold;
import com.ruoyi.system.mapper.TTestMapper;
/** 对成功接收的测量值执行测试阈值判断;设备状态和缺失数据不经过此服务。 */
@Service
public class TestWarningExceptionService {
    private final TTestMapper mapper;
    public TestWarningExceptionService(TTestMapper mapper) { this.mapper = mapper; }
    /** 仅对成功接收的非空测量值判定阈值,并聚合更新开放异常及规则快照。 */
    public void evaluate(TTest test, String sourceType, String sourceIdentity, String metricCode, BigDecimal value, String unit, Date occurredAt) {
        if (value == null || test == null || test.getThresholds() == null) return;
        for (TTestWarningThreshold rule : test.getThresholds()) {
            if (rule == null || rule.getMetricCode() == null || !matches(rule.getMetricCode(), metricCode)
                    || !outside(value, rule.getMinValue(), rule.getMaxValue())) continue;
            mapper.upsertWarningException(test.getTestId(), rule.getThresholdId(), sourceType, sourceIdentity, metricCode, value, unit,
                    JSON.toJSONString(rule), rule.getSeverity(), occurredAt == null ? new Date() : occurredAt);
        }
    }
    /** 由测试负责人关闭开放异常,并在同一事务追加不可变的测试审计事实。 */
    @Transactional(rollbackFor = Exception.class)
    public int close(Long exceptionId, Long actorId, String actorName, String handlingNote) {
        TTestWarningException exception = mapper.selectWarningExceptionById(exceptionId);
        if (exception == null) return 0;
        int rows = mapper.closeWarningException(exceptionId, actorId, actorName, new Date(), handlingNote);
        if (rows == 1) {
            mapper.insertTestAudit(exception.getTestId(), "WARNING_EXCEPTION_CLOSED", actorId, actorName, null,
                    JSON.toJSONString(java.util.Map.of("exceptionId", exceptionId, "handlingNote", handlingNote)));
        }
        return rows;
    }
    private boolean outside(BigDecimal value, BigDecimal min, BigDecimal max) { return (min != null && value.compareTo(min) < 0) || (max != null && value.compareTo(max) > 0); }
    private boolean matches(String configured, String actual) {
        if (actual == null) return false;
        if (configured.equalsIgnoreCase(actual)) return true;
        String c = configured.toUpperCase(), a = actual.toUpperCase();
        return (c.contains("VOLTAGE") && a.contains("VOLTAGE")) || (c.contains("CURRENT") && a.contains("CURRENT"))
                || (c.contains("CAPACITY") && a.contains("CAPACITY")) || (c.contains("ENERGY") && a.contains("ENERGY"))
                || (c.contains("TEMPERATURE") && a.contains("TEMPERATURE")) || (c.contains("POSITIVE_POTENTIAL") && a.contains("POSITIVE_POTENTIAL"))
                || (c.contains("NEGATIVE_POTENTIAL") && a.contains("NEGATIVE_POTENTIAL"));
    }
}
ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
@@ -100,6 +100,17 @@
        <result property="severity" column="severity" />
        <result property="delFlag" column="del_flag" />
    </resultMap>
    <resultMap id="TTestWarningExceptionResultMap" type="com.ruoyi.system.domain.TTestWarningException">
        <id property="exceptionId" column="exception_id"/><result property="testId" column="test_id"/>
        <result property="testName" column="test_name"/><result property="sourceType" column="source_type"/>
        <result property="sourceIdentity" column="source_identity"/><result property="metricCode" column="metric_code"/>
        <result property="abnormalValue" column="abnormal_value"/><result property="unit" column="unit"/>
        <result property="ruleSnapshot" column="rule_snapshot"/><result property="severity" column="severity"/>
        <result property="exceptionStatus" column="exception_status"/><result property="firstOccurredAtUtc" column="first_occurred_at_utc"/>
        <result property="lastOccurredAtUtc" column="last_occurred_at_utc"/><result property="occurrenceCount" column="occurrence_count"/>
        <result property="handledBy" column="handled_by"/><result property="handledByName" column="handled_by_name"/>
        <result property="handledAtUtc" column="handled_at_utc"/><result property="handlingNote" column="handling_note"/>
    </resultMap>
    <resultMap id="TTestResultResultMap" type="TTestResult">
        <id property="resultId" column="result_id" />
@@ -317,6 +328,31 @@
        select threshold_id, test_id, metric_code, min_value, max_value, unit, severity, del_flag
        from t_test_warning_threshold where test_id = #{testId} and del_flag = 0 order by threshold_id
    </select>
    <insert id="upsertWarningException">
        insert into t_test_warning_exception
        (test_id, threshold_id, source_type, source_identity, metric_code, abnormal_value, unit, rule_snapshot, severity,
         exception_status, first_occurred_at_utc, last_occurred_at_utc, occurrence_count)
        values (#{testId}, #{thresholdId}, #{sourceType}, #{sourceIdentity}, #{metricCode}, #{value}, #{unit},
                cast(#{ruleSnapshot} as json), #{severity}, 'OPEN', #{occurredAt}, #{occurredAt}, 1)
        on duplicate key update abnormal_value = values(abnormal_value), unit = values(unit),
            last_occurred_at_utc = values(last_occurred_at_utc), occurrence_count = occurrence_count + 1,
            source_identity = values(source_identity), severity = values(severity), rule_snapshot = values(rule_snapshot)
    </insert>
    <select id="selectWarningExceptions" resultMap="TTestWarningExceptionResultMap">
        select e.*, t.test_name, u.nick_name handled_by_name
        from t_test_warning_exception e join t_test t on t.test_id=e.test_id
        left join sys_user u on u.user_id=e.handled_by
        <where><if test="testId != null">e.test_id = #{testId}</if><if test="status != null and status != ''">and e.exception_status = #{status}</if></where>
        order by e.last_occurred_at_utc desc, e.exception_id desc
    </select>
    <select id="selectWarningExceptionById" parameterType="long" resultMap="TTestWarningExceptionResultMap">
        select e.*, t.test_name, u.nick_name handled_by_name from t_test_warning_exception e join t_test t on t.test_id=e.test_id
        left join sys_user u on u.user_id=e.handled_by where e.exception_id=#{exceptionId}
    </select>
    <update id="closeWarningException">
        update t_test_warning_exception set exception_status='CLOSED', handled_by=#{handledBy}, handled_at_utc=#{handledAt}, handling_note=#{handlingNote}
        where exception_id=#{exceptionId} and exception_status='OPEN'
    </update>
    <select id="selectReceivingTestIds" resultType="long">
        select test_id from t_test where del_flag = 0 and reception_status = 'RECEIVING' order by test_id
    </select>