yeg
2026-09-28 9c93934b70ccfca081102944a47f3baafad3c218
实现新威 7.6 的工步与循环数据取数

数据列表「工步」页改由设备工步层 cap_step 取数,「循环」页由工步层按循环聚合。
聚合配方用现场真实数据核对后定稿:同一工步的行数不是常量(多数 2 行,另有 1 行与
3–10 行的少数协议),工步的累计容量与能量只出现在结束行上,且它是工步累计而不是测试
累计;因此每个已结束的工步取结束行一次,按方向归入本循环的充电或放电一侧并求和,效率
为放电容量除以充电容量,没有可用充电容量时留空。

循环统计的来源类别随记录落库(DEVICE / AGGREGATED / IMPORTED),推导值在数据里即可
与设备事实区分,界面与导出据此标注「工步层聚合」。工步方向按设备类型数字映射
(1 充电、2 放电、4 搁置),未确认取值保持未知且不参与聚合,四组容量能量原样保留在扩展
测量里供核对;中值电压改由设备工步层的 middle_vol 承载,不用峰值或谷值替代。仍在进行
的工步与循环不落库,暂停与继续不产生补算。

工步层读取按来源写库节奏限流,一次接收里工步与循环共用同一次读取,不跟着每秒的调度
节奏重查厂商库。
4个文件已添加
12个文件已修改
802 ■■■■■ 已修改文件
battery-business/src/main/java/com/battery/business/domain/TTestCycleRecord.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/service/impl/TestConsoleDataServiceImpl.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/service/impl/TestDataQueryServiceImpl.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/service/impl/TestDataReceptionServiceImpl.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/resources/mapper/battery/TTestMapper.xml 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-api/src/main/java/com/battery/device/api/TestDataObservationAdapter.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-neware/src/main/java/com/battery/device/neware/NewareMysqlAdapter.java 220 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-neware/src/main/java/com/battery/device/neware/NewareStepDirectionResolver.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-neware/src/test/java/com/battery/device/neware/FakeVendorDatabase.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-neware/src/test/java/com/battery/device/neware/NewareMysqlAdapterContractTest.java 194 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-neware/src/test/java/com/battery/device/neware/NewareStepDirectionResolverTest.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/battery/business/runtime/ReportStepDirectionConfig.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/db/migration-final/V23__cycle_record_source_kind.sql 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/runtime/CycleRecordSourceKindMigrationContractTest.java 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/service/impl/TestDataReceptionServiceImplTest.java 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/web/ConsoleSingleSheetImportDatabaseTest.java 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/domain/TTestCycleRecord.java
@@ -4,17 +4,18 @@
import java.math.BigDecimal;
import java.util.Date;
/** 检测柜直接返回的一条实际循环统计记录。 */
/** 一条循环统计记录;设备没有循环汇总行时由工步层按循环聚合,来源类别随之保存。 */
public class TTestCycleRecord implements Serializable
{
    private Long cycleRecordId, testId, testBatteryId, bindingId;
    private String adapterCode, sourceIdentity, sourceRecordId, adapterTimezoneId, dedupeKey, extensionMeasurementsJson;
    private String adapterCode, sourceIdentity, sourceRecordId, sourceKind, adapterTimezoneId, dedupeKey, extensionMeasurementsJson;
    private Date deviceTimeUtc, serverReceivedAtUtc;
    private String deviceTimeRaw;
    private Integer cycleNo;
    private BigDecimal chargeCapacityAh, dischargeCapacityAh, chargeEnergyWh, dischargeEnergyWh, efficiency;
    private byte[] contentSha256;
    public Long getCycleRecordId(){return cycleRecordId;} public void setCycleRecordId(Long v){cycleRecordId=v;} public Long getTestId(){return testId;} public void setTestId(Long v){testId=v;}
    public Long getCycleRecordId(){return cycleRecordId;} public void setCycleRecordId(Long v){cycleRecordId=v;}
    public String getSourceKind(){return sourceKind;} public void setSourceKind(String v){sourceKind=v;} public Long getTestId(){return testId;} public void setTestId(Long v){testId=v;}
    public Long getTestBatteryId(){return testBatteryId;} public void setTestBatteryId(Long v){testBatteryId=v;} public Long getBindingId(){return bindingId;} public void setBindingId(Long v){bindingId=v;}
    public String getAdapterCode(){return adapterCode;} public void setAdapterCode(String v){adapterCode=v;} public String getSourceIdentity(){return sourceIdentity;} public void setSourceIdentity(String v){sourceIdentity=v;}
    public String getSourceRecordId(){return sourceRecordId;} public void setSourceRecordId(String v){sourceRecordId=v;} public String getAdapterTimezoneId(){return adapterTimezoneId;} public void setAdapterTimezoneId(String v){adapterTimezoneId=v;}
battery-business/src/main/java/com/battery/business/service/impl/TestConsoleDataServiceImpl.java
@@ -18,6 +18,7 @@
import com.battery.business.domain.TTestDataRevisionAction;
import com.battery.business.mapper.TTestMapper;
import com.battery.business.service.ITestConsoleDataService;
import com.battery.device.api.TestDataObservationAdapter.CycleRecord;
import com.ruoyi.common.exception.ServiceException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -280,6 +281,8 @@
        values.put("source_identity", String.join(":", List.of("adapter_code", "devtype", "devid", "subdevid", "chlid")
                .stream().map(key -> String.valueOf(binding.get(key))).toList()));
        values.put("source_record_id", importText(row, "recordNo", "记录编号", 128));
        // 导入的循环统计来自授权文件,不是设备提供的循环汇总行,也不是本系统按工步层聚合的值。
        if ("CYCLE".equals(type)) values.put("source_kind", CycleRecord.SOURCE_IMPORTED);
        Date time;
        try {
            Object raw = row.get("recordedAt");
battery-business/src/main/java/com/battery/business/service/impl/TestDataQueryServiceImpl.java
@@ -46,13 +46,15 @@
            if (extension == null) continue;
            Map<String, Object> values = com.alibaba.fastjson2.JSON.parseObject(extension.toString());
            // 中值与历史峰谷值是不同测量,不将旧扩展字段回退映射为中值。
            for (String field : List.of("stepDuration", "startVoltage", "chargeMedianVoltage", "dischargeMedianVoltage"))
            // 四组容量能量是设备工步层事实:单项列无法表达未确认方向时,调用方读这四组核对。
            for (String field : List.of("stepDuration", "startVoltage", "chargeMedianVoltage", "dischargeMedianVoltage",
                    "chargeCapacity", "dischargeCapacity", "chargeEnergy", "dischargeEnergy"))
                if (values.containsKey(field)) row.put(field, values.get(field));
        }
        return rows;
    }
    /** 查询尚存循环记录,包含设备统计与授权历史导入。 */
    /** 查询尚存循环记录,包含设备统计、工步层聚合与授权历史导入;携带来源类别供界面标注聚合来源。 */
    @Override public List<Map<String, Object>> cycles(Long testId)
    {
        tests.selectTestById(testId);
battery-business/src/main/java/com/battery/business/service/impl/TestDataReceptionServiceImpl.java
@@ -48,6 +48,8 @@
    private final Clock clock;
    private final Map<String, Instant> lastSnapshotAt = new java.util.concurrent.ConcurrentHashMap<>();
    private final Map<Long, Instant> lastRealtimeCheckAt = new java.util.concurrent.ConcurrentHashMap<>();
    /** 每个测试电池上次读取工步与循环的时刻;读取节奏对齐来源写库周期,不跟着调度节奏。 */
    private final Map<String, Instant> lastAggregateAt = new java.util.concurrent.ConcurrentHashMap<>();
    /** 每个测试电池各自的接收水位;只存在内存中,重启后按来源里的最新一行重新取水位。 */
    private final Map<String, SampleCursor> sampleCursors = new java.util.concurrent.ConcurrentHashMap<>();
@@ -278,15 +280,20 @@
    private void saveDeviceAggregates(TTest test, TTestBattery battery)
    {
        // 工步、循环只接受设备直接返回的统计记录,并以来源编号或明确的时间组合执行幂等。
        // 工步、循环只接受来源返回的统计记录,并以来源编号或明确的时间组合执行幂等。
        if (adapter == null) return; TTestChannelBinding binding = battery.getBinding(); if (binding == null || binding.getDevid() == null || binding.getChlid() == null) return;
        String key = test.getTestId() + ":" + battery.getTestBatteryId();
        // 接收调度每秒跑一次,而来源按自己的写库节奏产出工步:不按来源节奏限流会每秒重查厂商库。
        Instant now = clock.instant(); Instant previous = lastAggregateAt.get(key);
        if (previous != null && now.isBefore(previous.plusMillis(persistenceInterval(binding)))) return;
        lastAggregateAt.put(key, now);
        ChannelIdentity channel = new ChannelIdentity(binding.getAdapterCode(), binding.getDevtype(), binding.getDevid(), binding.getSubdevid(), binding.getChlid());
        List<StepRecord> steps; List<CycleRecord> cycles; try { steps = adapter.stepRecords(channel); cycles = adapter.cycleRecords(channel); } catch (RuntimeException ex) { return; }
        Date receivedAt = Date.from(clock.instant()); String identity = sourceIdentity(binding);
        String identity = sourceIdentity(binding);
        for (StepRecord source : steps == null ? List.<StepRecord>of() : steps)
        {
            if (!accepted(test, source.endedAt(), source.serverReceivedAt())) continue;
            TTestStepRecord row = new TTestStepRecord(); row.setTestId(test.getTestId()); row.setTestBatteryId(battery.getTestBatteryId()); row.setBindingId(binding.getBindingId()); row.setAdapterCode(binding.getAdapterCode()); row.setSourceIdentity(identity); row.setSourceRecordId(blankToNull(source.sourceRecordId())); row.setAdapterTimezoneId(source.timezoneId()); row.setCycleNo(source.cycleNo()); row.setStepNo(source.stepNo()); row.setStepType(source.stepType()); row.setStepName(source.stepName()); row.setDeviceTimeUtc(Date.from(source.endedAt())); row.setDeviceTimeRaw(source.deviceTimeRaw()); row.setServerReceivedAtUtc(Date.from(source.serverReceivedAt())); row.setVoltageV(BigDecimal.valueOf(source.endVoltageV())); row.setCapacityAh(BigDecimal.valueOf(source.capacityAh())); row.setEnergyWh(BigDecimal.valueOf(source.energyWh())); row.setDedupeKey(row.getSourceRecordId() == null ? "fallback:" + source.endedAt() + ":" + source.cycleNo() + ":" + source.stepNo() : "record:" + row.getSourceRecordId()); row.setContentSha256(hash(row));
            TTestStepRecord row = new TTestStepRecord(); row.setTestId(test.getTestId()); row.setTestBatteryId(battery.getTestBatteryId()); row.setBindingId(binding.getBindingId()); row.setAdapterCode(binding.getAdapterCode()); row.setSourceIdentity(identity); row.setSourceRecordId(blankToNull(source.sourceRecordId())); row.setAdapterTimezoneId(source.timezoneId()); row.setCycleNo(source.cycleNo()); row.setStepNo(source.stepNo()); row.setStepType(source.stepType()); row.setStepName(source.stepName()); row.setDeviceTimeUtc(Date.from(source.endedAt())); row.setDeviceTimeRaw(source.deviceTimeRaw()); row.setServerReceivedAtUtc(Date.from(source.serverReceivedAt())); row.setVoltageV(BigDecimal.valueOf(source.endVoltageV())); row.setCurrentA(BigDecimal.valueOf(source.endCurrentA())); row.setCapacityAh(decimal(source.capacityAh())); row.setEnergyWh(decimal(source.energyWh())); row.setDedupeKey(row.getSourceRecordId() == null ? "fallback:" + source.endedAt() + ":" + source.cycleNo() + ":" + source.stepNo() : "record:" + row.getSourceRecordId()); row.setContentSha256(hash(row));
            // 物理删除依靠已有修订追溯识别来源,重复观测不能把已删除汇总重新写回。
            row.setExtensionMeasurementsJson(JSON.toJSONString(source.measurements()));
            row.setContentSha256(hash(row));
@@ -301,7 +308,7 @@
        for (CycleRecord source : cycles == null ? List.<CycleRecord>of() : cycles)
        {
            if (!accepted(test, source.endedAt(), source.serverReceivedAt())) continue;
            TTestCycleRecord row = new TTestCycleRecord(); row.setTestId(test.getTestId()); row.setTestBatteryId(battery.getTestBatteryId()); row.setBindingId(binding.getBindingId()); row.setAdapterCode(binding.getAdapterCode()); row.setSourceIdentity(identity); row.setAdapterTimezoneId(source.timezoneId()); row.setSourceRecordId(blankToNull(source.sourceRecordId())); row.setCycleNo(source.cycleNo()); row.setDeviceTimeUtc(Date.from(source.endedAt())); row.setDeviceTimeRaw(source.deviceTimeRaw()); row.setServerReceivedAtUtc(Date.from(source.serverReceivedAt())); row.setChargeCapacityAh(BigDecimal.valueOf(source.chargeCapacityAh())); row.setDischargeCapacityAh(BigDecimal.valueOf(source.dischargeCapacityAh())); row.setChargeEnergyWh(BigDecimal.valueOf(source.chargeEnergyWh())); row.setDischargeEnergyWh(BigDecimal.valueOf(source.dischargeEnergyWh())); row.setEfficiency(BigDecimal.valueOf(source.efficiency())); row.setDedupeKey(row.getSourceRecordId() == null ? "fallback:" + source.endedAt() + ":" + source.cycleNo() : "record:" + row.getSourceRecordId()); row.setContentSha256(hash(row));
            TTestCycleRecord row = new TTestCycleRecord(); row.setTestId(test.getTestId()); row.setTestBatteryId(battery.getTestBatteryId()); row.setBindingId(binding.getBindingId()); row.setAdapterCode(binding.getAdapterCode()); row.setSourceIdentity(identity); row.setAdapterTimezoneId(source.timezoneId()); row.setSourceRecordId(blankToNull(source.sourceRecordId())); row.setSourceKind(source.sourceKind()); row.setCycleNo(source.cycleNo()); row.setDeviceTimeUtc(Date.from(source.endedAt())); row.setDeviceTimeRaw(source.deviceTimeRaw()); row.setServerReceivedAtUtc(Date.from(source.serverReceivedAt())); row.setChargeCapacityAh(BigDecimal.valueOf(source.chargeCapacityAh())); row.setDischargeCapacityAh(BigDecimal.valueOf(source.dischargeCapacityAh())); row.setChargeEnergyWh(BigDecimal.valueOf(source.chargeEnergyWh())); row.setDischargeEnergyWh(BigDecimal.valueOf(source.dischargeEnergyWh())); row.setEfficiency(decimal(source.efficiency())); row.setDedupeKey(row.getSourceRecordId() == null ? "fallback:" + source.endedAt() + ":" + source.cycleNo() : "record:" + row.getSourceRecordId()); row.setContentSha256(hash(row));
            if (mapper.wasAggregateDeleted(test.getTestId(), "CYCLE", battery.getTestBatteryId(), identity, row.getDedupeKey())) continue;
            if (mapper.insertCycleRecord(row) == 0)
            {
@@ -347,6 +354,8 @@
    private static String sourceIdentity(TTestChannelBinding b) { return String.join(":", String.valueOf(b.getAdapterCode()), String.valueOf(b.getDevtype()), String.valueOf(b.getDevid()), String.valueOf(b.getSubdevid()), String.valueOf(b.getChlid())); }
    private static String timezone(TTestChannelBinding b) { return b.getAdapterTimezoneId() == null || b.getAdapterTimezoneId().isBlank() ? "Asia/Hong_Kong" : b.getAdapterTimezoneId(); }
    private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value; }
    /** 设备未提供的单项值保持为空:零是设备事实,缺失不能写成零。 */
    private static BigDecimal decimal(Double value) { return value == null ? null : BigDecimal.valueOf(value); }
    // 实时提醒与持久化共用归属边界,避免同一旧观测只在首页触发异常。
    static boolean accepted(TTest test, Instant deviceTime, Instant serverReceivedAt)
    {
battery-business/src/main/resources/mapper/battery/TTestMapper.xml
@@ -399,10 +399,10 @@
    </insert>
    <insert id="insertCycleRecord" parameterType="com.battery.business.domain.TTestCycleRecord" useGeneratedKeys="true" keyProperty="cycleRecordId">
        insert ignore into t_test_cycle_record
        (test_id, test_battery_id, binding_id, adapter_code, source_identity, source_record_id, dedupe_key, adapter_timezone_id,
        (test_id, test_battery_id, binding_id, adapter_code, source_identity, source_record_id, source_kind, dedupe_key, adapter_timezone_id,
         device_time_utc, device_time_raw, server_received_at_utc, cycle_no, charge_capacity_ah, discharge_capacity_ah,
         charge_energy_wh, discharge_energy_wh, efficiency, extension_measurements_json, content_sha256)
        values (#{testId}, #{testBatteryId}, #{bindingId}, #{adapterCode}, #{sourceIdentity}, #{sourceRecordId}, #{dedupeKey}, #{adapterTimezoneId},
        values (#{testId}, #{testBatteryId}, #{bindingId}, #{adapterCode}, #{sourceIdentity}, #{sourceRecordId}, #{sourceKind}, #{dedupeKey}, #{adapterTimezoneId},
         #{deviceTimeUtc}, #{deviceTimeRaw}, #{serverReceivedAtUtc}, #{cycleNo}, #{chargeCapacityAh}, #{dischargeCapacityAh},
         #{chargeEnergyWh}, #{dischargeEnergyWh}, #{efficiency}, cast(#{extensionMeasurementsJson} as json), #{contentSha256})
    </insert>
@@ -455,8 +455,8 @@
               r.device_time_utc absoluteTime, r.server_received_at_utc serverReceivedAt,
               r.charge_capacity_ah chargeCapacity, r.discharge_capacity_ah dischargeCapacity,
               r.charge_energy_wh chargeEnergy, r.discharge_energy_wh dischargeEnergy,
               r.efficiency efficiency, r.adapter_code adapterCode, r.source_identity sourceIdentity,
               r.source_record_id sourceRecordId
               r.efficiency efficiency, r.source_kind sourceKind, r.adapter_code adapterCode,
               r.source_identity sourceIdentity, r.source_record_id sourceRecordId
        from t_test_cycle_record r join t_test_battery b on b.test_battery_id = r.test_battery_id
        where r.test_id = #{testId} and b.del_flag = 0
        order by r.cycle_no, b.battery_no, r.cycle_record_id
battery-device-api/src/main/java/com/battery/device/api/TestDataObservationAdapter.java
@@ -22,7 +22,11 @@
    /** 返回设备直接提供的工步统计,不从实时采样推导。 */
    List<StepRecord> stepRecords(ChannelIdentity channel);
    /** 返回设备直接提供的循环统计,不补齐设备未返回的记录。 */
    /**
     * 返回该来源的循环统计。设备直接提供循环汇总行时原样返回;设备只有工步层时由适配器按循环聚合,
     * 并用 {@link CycleRecord#sourceKind()} 标注为聚合来源,使调用方与界面能区分推导值与设备事实。
     * 不补齐来源未提供的循环,也不跨循环借值。
     */
    List<CycleRecord> cycleRecords(ChannelIdentity channel);
    /** 返回适配器定义的实时持久化槽位间隔。 */
@@ -84,13 +88,44 @@
    record CollectorMeasurement(String channelCode, long sequenceId, Instant deviceTime, String deviceTimeRaw,
            Instant serverReceivedAt, String metricCode, BigDecimal value, String unit) { }
    /**
     * 一条设备工步层记录。{@code stepType} 保留设备给出的原始类型(新威为数字枚举),
     * 方向由调用方按该类型解释,不由适配器按电流符号推断。
     *
     * <p>{@code endVoltageV} 与 {@code endCurrentA} 是该工步结束时的设备读数。
     * {@code capacityAh} 与 {@code energyWh} 是该工步自身累计的单项值,只在方向已确认时给出;
     * 方向未知时保持为空,四个方向的容量能量仍在 {@code measurements} 中作为设备事实保留。
     */
    record StepRecord(String sourceRecordId, int cycleNo, int stepNo, String stepName, String stepType,
            Instant startedAt, Instant endedAt, double endVoltageV, double capacityAh, double energyWh,
            String deviceTimeRaw, String timezoneId, Instant serverReceivedAt,
            Instant startedAt, Instant endedAt, double endVoltageV, double endCurrentA, Double capacityAh,
            Double energyWh, String deviceTimeRaw, String timezoneId, Instant serverReceivedAt,
            java.util.Map<String, BigDecimal> measurements) { }
    /**
     * 一条循环统计。{@code sourceRecordId} 是来源行的稳定身份,供幂等与冲突判定使用。
     *
     * <p>设备没有循环汇总行时,循环统计由工步层按循环聚合得出;{@code sourceKind} 记录这个来源事实,
     * 使界面与导出能明确标注"这个数是聚合的还是设备给的",取值只能是下面三个常量之一。
     *
     * <p>{@code efficiency} 为单位为百分比的充放电效率。该循环没有可用的充电容量时保持为空:
     * 未知比率不用零代替,零值只表示设备确实给出零。
     */
    record CycleRecord(String sourceRecordId, int cycleNo, Instant startedAt, Instant endedAt,
            double chargeCapacityAh, double dischargeCapacityAh, double chargeEnergyWh,
            double dischargeEnergyWh, double efficiency, String deviceTimeRaw, String timezoneId,
            Instant serverReceivedAt) { }
            double dischargeEnergyWh, Double efficiency, String deviceTimeRaw, String timezoneId,
            Instant serverReceivedAt, String sourceKind)
    {
        /** 来源类别:设备直接提供。 */
        public static final String SOURCE_DEVICE = "DEVICE";
        /** 来源类别:来源没有循环汇总行,由工步层按循环聚合得出。 */
        public static final String SOURCE_AGGREGATED = "AGGREGATED";
        /** 来源类别:授权文件导入。 */
        public static final String SOURCE_IMPORTED = "IMPORTED";
        public CycleRecord
        {
            // 未标注一律按设备事实:引入聚合之前写进库的循环记录都来自设备或文件。
            sourceKind = sourceKind == null || sourceKind.isBlank() ? SOURCE_DEVICE : sourceKind;
        }
    }
}
battery-device-neware/src/main/java/com/battery/device/neware/NewareMysqlAdapter.java
@@ -26,6 +26,7 @@
import javax.sql.DataSource;
import com.battery.device.api.DeviceAdapter;
import com.battery.device.api.DeviceAdapterException;
import com.battery.device.api.StepDirectionResolver;
import com.battery.device.api.TestDataObservationAdapter;
import com.battery.device.api.model.AdapterCapabilities;
import com.battery.device.api.model.ChannelIdentity;
@@ -63,6 +64,8 @@
    public static final String DEFAULT_PASSWORD_ENV = "BATTERY_VENDOR_DB_PASSWORD";
    /** 厂商实测写库周期;它同时是持久化周期,展示节奏不随之加快。 */
    public static final long SAMPLE_INTERVAL_MS = 60000L;
    /** 工步层的共用读取窗口:足以覆盖一轮接收里工步与循环的连续调用,又不会压住新出现的工步行。 */
    private static final long CAP_STEP_CACHE_MS = 5000L;
    private static final int SAMPLE_BATCH_LIMIT = 500;
    private static final Pattern SAFE_TABLE_NAME = Pattern.compile("[A-Za-z0-9_]{1,64}");
    /** 设备时间按适配器时区解释;来源列是秒精度 datetime,小数部分按需接受。 */
@@ -81,9 +84,12 @@
    private final Runnable connectionReset;
    private final Clock clock;
    private final ZoneId deviceZone;
    /** 工步类型到方向的映射:正向聚合循环容量时需要它区分充、放、搁置与未知。 */
    private final StepDirectionResolver directions = new NewareStepDirectionResolver();
    /** 最近数据时间最快也要一分钟才可能变化,缓存使发现清单不必每轮重读全部通道。 */
    private final long lastDataCacheMillis;
    private final Map<String, CachedLastData> lastDataCache = new ConcurrentHashMap<>();
    private final Map<String, CachedCapSteps> capStepCache = new ConcurrentHashMap<>();
    public NewareMysqlAdapter(AdapterInstanceConfig config, DataSource dataSource)
    {
@@ -177,18 +183,47 @@
        return null;
    }
    /** 工步层记录由本单之后的一张单实现,这里不返回从采样推导出来的工步。 */
    /** 工步层记录与循环统计都读设备的 {@code cap_step} 表;该表不随采样分表滚动。 */
    @Override
    public List<StepRecord> stepRecords(ChannelIdentity channel)
    {
        return List.of();
        CapStepSource source = capStepSource(channel);
        if (source == null) return List.of();
        List<StepRecord> records = new ArrayList<>();
        for (StepGroup group : completedStepGroups(source.rows())) records.add(stepRecord(source.testId(), group));
        return List.copyOf(records);
    }
    /** 循环汇总表不存在,聚合配方须先用真实数据核对,这里不返回推导值。 */
    /**
     * 循环统计由工步层按循环聚合:设备没有循环汇总行(`cap_plat` 的充放电容量列实测全为 0,
     * 库内也没有任何按循环汇总的表)。
     *
     * <p>配方(行语义见 {@link #completedStepGroups}):
     * <ul>
     *   <li>每个已结束工步的累计容量与能量各计一次,重复接收不重复累计。</li>
     *   <li>按方向归入本循环的充电或放电一侧并<b>求和</b>:同一循环可能含多个同方向工步
     *       (该化成协议的前两个循环各有两个充电工步和两个放电工步),求和才是该循环充入/放出的总量。</li>
     *   <li>效率是放电容量除以充电容量;没有可用充电容量时留空,未知比率不写零。</li>
     *   <li>搁置与未知方向的工步不进任何一侧:容量无法归属到方向时,明细留在工步记录里供核对。</li>
     * </ul>
     *
     * <p>不插值、不补齐、不跨循环借值:聚合只用该循环自己的工步行,仍在进行的循环整体不出现。
     */
    @Override
    public List<CycleRecord> cycleRecords(ChannelIdentity channel)
    {
        return List.of();
        CapStepSource source = capStepSource(channel);
        if (source == null) return List.of();
        List<CapStepRow> rows = source.rows();
        Map<Integer, List<StepGroup>> byCycle = new LinkedHashMap<>();
        for (StepGroup group : completedStepGroups(rows))
            byCycle.computeIfAbsent(group.key().cycle(), ignored -> new ArrayList<>()).add(group);
        // 最后一行所属的循环仍在进行:它的工步行还没写完,聚合出来的是半个循环。
        int openCycle = rows.isEmpty() ? Integer.MIN_VALUE : rows.get(rows.size() - 1).cycle();
        List<CycleRecord> records = new ArrayList<>();
        for (Map.Entry<Integer, List<StepGroup>> entry : byCycle.entrySet())
            if (entry.getKey() != openCycle) records.add(cycleRecord(source.testId(), entry.getKey(), entry.getValue()));
        return List.copyOf(records);
    }
    /** 厂商固定 60 秒写库;持久化周期与之对齐,不做倍速。 */
@@ -328,6 +363,154 @@
        return result;
    }
    // ------------------------------------------------------------------ 工步层与循环聚合
    /** 读该通道最新测试在工步层的全部行;工步号与循环号都取自设备,不按采样分表推算。 */
    private CapStepSource capStepSource(ChannelIdentity channel)
    {
        ChannelKey key = channelKey(channel);
        String cacheKey = key.devUid() + ":" + key.unitId() + ":" + key.chlId();
        Instant now = clock.instant();
        CachedCapSteps cached = capStepCache.get(cacheKey);
        // 工步与循环在一次接收里被连续请求,同一次读取供两条路径共用,不重复查厂商库。
        if (cached != null && now.isBefore(cached.expiresAt())) return cached.source();
        VendorTest latest = latestTest(key);
        if (latest == null) return null;
        List<CapStepRow> rows = query("select seq_id, cycle, step_id, step_type, test_time, test_vol, test_cur, "
                + "test_capchg, test_engchg, test_capdchg, test_engdchg, middle_vol, test_atime from cap_step "
                + "where test_id = ? and dev_uid = ? and unit_id = ? and chl_id = ? order by seq_id",
                this::readCapStepRows, latest.testId(), key.devUid(), key.unitId(), key.chlId());
        CapStepSource source = new CapStepSource(latest.testId(), rows);
        capStepCache.put(cacheKey, new CachedCapSteps(source, now.plusMillis(CAP_STEP_CACHE_MS)));
        return source;
    }
    private List<CapStepRow> readCapStepRows(ResultSet rows) throws SQLException
    {
        List<CapStepRow> result = new ArrayList<>();
        while (rows.next())
        {
            String raw = rows.getString("test_atime");
            LocalDateTime deviceTime = parseDeviceTime(raw);
            result.add(new CapStepRow(rows.getLong("seq_id"), rows.getInt("cycle"), rows.getInt("step_id"),
                    rows.getString("step_type"), rows.getLong("test_time"), rows.getBigDecimal("test_vol"),
                    rows.getBigDecimal("test_cur"), rows.getBigDecimal("test_capchg"),
                    rows.getBigDecimal("test_engchg"), rows.getBigDecimal("test_capdchg"),
                    rows.getBigDecimal("test_engdchg"), rows.getBigDecimal("middle_vol"), raw,
                    deviceTime.atZone(deviceZone).toInstant()));
        }
        return result;
    }
    /**
     * 已结束的工步:按「循环号 + 工步号」分组,每组取设备写的最后一行作为该工步的累计事实。
     *
     * <p>实测行语义:同一工步的行数不是常量(多数为进入行加结束行的 2 行,另有 1 行与 3–10 行的少数协议),
     * 工步的累计容量与能量只出现在结束行上(进入行全为 0,结束行等于该工步的最大值),
     * 且它是**工步累计**而不是测试累计——同一工步在不同循环的值基本相同,不随测试推进累加。
     * 因此取最后一行,不按行求和、不取平均。
     *
     * <p>工步是否结束只按一条依据判断:设备是否已经写了<b>后续工步</b>的行。只有进入行的工步仍在进行中,
     * 不落库——否则它结束后的结束行会与已保存的行冲突。代价是设备停止后最后一个未闭合的工步不出现,
     * 这是"不把未确认的事实当事实"的落地形态,也保证暂停或继续不产生补算。
     */
    private List<StepGroup> completedStepGroups(List<CapStepRow> rows)
    {
        Map<StepKey, List<CapStepRow>> groups = new LinkedHashMap<>();
        for (CapStepRow row : rows)
            groups.computeIfAbsent(new StepKey(row.cycle(), row.stepId()), ignored -> new ArrayList<>()).add(row);
        List<StepGroup> completed = new ArrayList<>();
        int last = groups.size() - 1;
        int index = 0;
        for (Map.Entry<StepKey, List<CapStepRow>> entry : groups.entrySet())
            if (index++ < last) completed.add(new StepGroup(entry.getKey(), entry.getValue()));
        return completed;
    }
    /**
     * 一条工步记录。工步时间取设备给的 {@code test_time}(毫秒 → 秒),四组容量能量与中值电压
     * 放进扩展测量;单项容量只在方向已确认时给出。
     */
    private StepRecord stepRecord(long testId, StepGroup group)
    {
        CapStepRow first = group.rows().get(0);
        CapStepRow last = group.last();
        StepDirectionResolver.Direction direction = directions.resolve(last.stepType());
        Map<String, BigDecimal> measurements = new LinkedHashMap<>();
        measurements.put("stepDuration", BigDecimal.valueOf(last.testTimeMillis()).movePointLeft(3));
        measurements.put("startVoltage", scaledValue(first.voltage(), VOLTAGE_CURRENT_DIVISOR));
        // 中值电压按 ADR-0054 的口径改由设备工步层字段承载,不用峰值或谷值替代;设备给零就保留零。
        BigDecimal medianVoltage = scaledValue(last.middleVoltage(), VOLTAGE_CURRENT_DIVISOR);
        if (direction == StepDirectionResolver.Direction.CHARGE) measurements.put("chargeMedianVoltage", medianVoltage);
        if (direction == StepDirectionResolver.Direction.DISCHARGE) measurements.put("dischargeMedianVoltage", medianVoltage);
        // 四组容量能量原样保留设备事实:未知方向的工步也留着明细供核对。
        measurements.put("chargeCapacity", scaledValue(last.capacityCharge(), CAPACITY_ENERGY_DIVISOR));
        measurements.put("dischargeCapacity", scaledValue(last.capacityDischarge(), CAPACITY_ENERGY_DIVISOR));
        measurements.put("chargeEnergy", scaledValue(last.energyCharge(), CAPACITY_ENERGY_DIVISOR));
        measurements.put("dischargeEnergy", scaledValue(last.energyDischarge(), CAPACITY_ENERGY_DIVISOR));
        // 单项容量与能量只在方向已确认时给值:未知方向留空,不按数值大小猜它属于哪一组。
        BigDecimal capacity = null, energy = null;
        if (direction == StepDirectionResolver.Direction.CHARGE)
        {
            capacity = last.capacityCharge(); energy = last.energyCharge();
        }
        else if (direction == StepDirectionResolver.Direction.DISCHARGE)
        {
            capacity = last.capacityDischarge(); energy = last.energyDischarge();
        }
        else if (direction == StepDirectionResolver.Direction.REST)
        {
            // 搁置工步设备两向都为 0,取充电侧的 0 承载,使单项列与充放电工步同列可比。
            capacity = last.capacityCharge(); energy = last.energyCharge();
        }
        // 服务器接收时间取设备写结束行的时刻:接收边界据它在暂停与继续之间留白,不把暂停期的工步补回来。
        Double capacityAh = capacity == null ? null : scaled(capacity, CAPACITY_ENERGY_DIVISOR);
        Double energyWh = energy == null ? null : scaled(energy, CAPACITY_ENERGY_DIVISOR);
        return new StepRecord(sourceRecordId(testId, "step", group.key().cycle() + ":" + group.key().stepId()),
                group.key().cycle(), group.key().stepId(), null, last.stepType(), first.deviceTime(),
                last.deviceTime(), scaled(last.voltage(), VOLTAGE_CURRENT_DIVISOR),
                scaled(last.current(), VOLTAGE_CURRENT_DIVISOR), capacityAh, energyWh,
                last.deviceTimeRaw(), deviceZone.getId(), last.deviceTime(), measurements);
    }
    private CycleRecord cycleRecord(long testId, int cycleNo, List<StepGroup> groups)
    {
        BigDecimal chargeCapacity = BigDecimal.ZERO, dischargeCapacity = BigDecimal.ZERO;
        BigDecimal chargeEnergy = BigDecimal.ZERO, dischargeEnergy = BigDecimal.ZERO;
        for (StepGroup group : groups)
        {
            CapStepRow last = group.last();
            switch (directions.resolve(last.stepType()))
            {
                case CHARGE ->
                {
                    chargeCapacity = chargeCapacity.add(scaledValue(last.capacityCharge(), CAPACITY_ENERGY_DIVISOR));
                    chargeEnergy = chargeEnergy.add(scaledValue(last.energyCharge(), CAPACITY_ENERGY_DIVISOR));
                }
                case DISCHARGE ->
                {
                    dischargeCapacity = dischargeCapacity.add(scaledValue(last.capacityDischarge(), CAPACITY_ENERGY_DIVISOR));
                    dischargeEnergy = dischargeEnergy.add(scaledValue(last.energyDischarge(), CAPACITY_ENERGY_DIVISOR));
                }
                // 搁置与未知方向不进任何一侧:方向未确认时把容量算进某一侧就是凭数值猜方向。
                default -> { }
            }
        }
        CapStepRow last = groups.get(groups.size() - 1).last();
        return new CycleRecord(sourceRecordId(testId, "cycle", String.valueOf(cycleNo)), cycleNo,
                groups.get(0).rows().get(0).deviceTime(), last.deviceTime(), chargeCapacity.doubleValue(),
                dischargeCapacity.doubleValue(), chargeEnergy.doubleValue(), dischargeEnergy.doubleValue(),
                chargeCapacity.signum() == 0 ? null : dischargeCapacity.multiply(BigDecimal.valueOf(100))
                        .divide(chargeCapacity, 4, RoundingMode.HALF_UP).doubleValue(),
                last.deviceTimeRaw(), deviceZone.getId(), last.deviceTime(), CycleRecord.SOURCE_AGGREGATED);
    }
    /** 来源行身份:厂商测试编号 + 工步或循环标识;重复接收据此幂等。 */
    private static String sourceRecordId(long testId, String kind, String suffix)
    {
        return "neware:" + testId + ":" + kind + ":" + suffix;
    }
    private CabinetRealtime cabinetRealtime(SampleRow row)
    {
        // 充放电容量按电流方向取对应的一组:设备界面对正在放电的通道显示放电容量。
@@ -354,9 +537,14 @@
    private static double scaled(BigDecimal value, BigDecimal divisor)
    {
        if (value == null) return 0d;
        // 保留 12 位小数与业务列精度一致,避免在换算环节提前丢失设备原始分辨率。
        return value.divide(divisor, 12, RoundingMode.HALF_UP).doubleValue();
        return scaledValue(value, divisor).doubleValue();
    }
    /** 保留 12 位小数与业务列精度一致,避免在换算环节提前丢失设备原始分辨率。 */
    private static BigDecimal scaledValue(BigDecimal value, BigDecimal divisor)
    {
        if (value == null) return BigDecimal.ZERO;
        return value.divide(divisor, 12, RoundingMode.HALF_UP);
    }
    private ChannelIdentity identity(ChannelKey key)
@@ -459,6 +647,24 @@
    private record CachedLastData(Instant lastDataAt, Instant expiresAt) { }
    private record CachedCapSteps(CapStepSource source, Instant expiresAt) { }
    /** 该通道最新厂商测试在工步层的全部行;{@code testId} 同时用于来源行身份。 */
    private record CapStepSource(long testId, List<CapStepRow> rows) { }
    /** 工步号在同一个循环内唯一,所以「循环号 + 工步号」就是一条工步事实的身份。 */
    private record StepKey(int cycle, int stepId) { }
    private record StepGroup(StepKey key, List<CapStepRow> rows)
    {
        CapStepRow last() { return rows.get(rows.size() - 1); }
    }
    private record CapStepRow(long sequenceId, int cycle, int stepId, String stepType, long testTimeMillis,
            BigDecimal voltage, BigDecimal current, BigDecimal capacityCharge, BigDecimal energyCharge,
            BigDecimal capacityDischarge, BigDecimal energyDischarge, BigDecimal middleVoltage,
            String deviceTimeRaw, Instant deviceTime) { }
    private record SampleRow(long testId, long sequenceId, int cycle, int stepId, String stepType,
            Long testTimeMillis, BigDecimal voltage, BigDecimal current, BigDecimal capacityCharge,
            BigDecimal energyCharge, BigDecimal capacityDischarge, BigDecimal energyDischarge,
battery-device-neware/src/main/java/com/battery/device/neware/NewareStepDirectionResolver.java
New file
@@ -0,0 +1,25 @@
package com.battery.device.neware;
import com.battery.device.api.StepDirectionResolver;
/**
 * 新威工步层 {@code cap_step.step_type} 的数值枚举解释:{@code 1} 充电、{@code 2} 放电、{@code 4} 搁置。
 *
 * <p>其余取值(实测出现过 {@code 3}、{@code 5}、{@code 7}、{@code 13})语义未确认,保持未知:
 * 不按电流符号、也不按容量出现在哪一组去猜方向,明细照原样保存供核对。
 */
public final class NewareStepDirectionResolver implements StepDirectionResolver
{
    @Override
    public Direction resolve(String stepType)
    {
        if (stepType == null) return Direction.UNKNOWN;
        return switch (stepType.trim())
        {
            case "1" -> Direction.CHARGE;
            case "2" -> Direction.DISCHARGE;
            case "4" -> Direction.REST;
            default -> Direction.UNKNOWN;
        };
    }
}
battery-device-neware/src/test/java/com/battery/device/neware/FakeVendorDatabase.java
@@ -33,10 +33,16 @@
            String stepType, long testTimeMillis, int voltage, int current, int capacityCharge, int energyCharge,
            int capacityDischarge, int energyDischarge, String testAtime) { }
    /** 工步层一行:工步层不随采样分表滚动,所以没有表名。 */
    record CapStepRow(long testId, long devUid, int unitId, int chlId, long seqId, int cycle, int stepId,
            String stepType, long testTimeMillis, int voltage, int current, int capacityCharge, int energyCharge,
            int capacityDischarge, int energyDischarge, int middleVoltage, String testAtime) { }
    private final List<String> statements = new java.util.concurrent.CopyOnWriteArrayList<>();
    private final List<ChannelRow> channels = new ArrayList<>();
    private final List<TestRow> tests = new ArrayList<>();
    private final List<SampleRow> samples = new ArrayList<>();
    private final List<CapStepRow> capSteps = new ArrayList<>();
    FakeVendorDatabase registeredChannel(long devUid, int unitId, int chlId)
    {
@@ -53,6 +59,12 @@
    FakeVendorDatabase sample(SampleRow row)
    {
        samples.add(row);
        return this;
    }
    FakeVendorDatabase capStep(CapStepRow row)
    {
        capSteps.add(row);
        return this;
    }
@@ -186,6 +198,9 @@
            return latestTests(longValue(parameters, 0));
        if (normalized.contains("from test"))
            return latestTestForChannel(longValue(parameters, 0), intValue(parameters, 1), intValue(parameters, 2));
        if (normalized.contains("from cap_step"))
            return capStepRows(longValue(parameters, 0), longValue(parameters, 1), intValue(parameters, 2),
                    intValue(parameters, 3));
        if (normalized.contains("order by test_atime, seq_id limit"))
            return samplesAfter(tableOf(sql), parameters);
        return lastSampleOfTest(tableOf(sql), intValue(parameters, 0), intValue(parameters, 1),
@@ -231,6 +246,32 @@
                .map(this::sampleColumns).toList();
    }
    private List<Map<String, Object>> capStepRows(long testId, long devUid, int unitId, int chlId)
    {
        return capSteps.stream().filter(row -> row.testId() == testId && row.devUid() == devUid
                        && row.unitId() == unitId && row.chlId() == chlId)
                .sorted(Comparator.comparingLong(CapStepRow::seqId)).map(this::capStepColumns).toList();
    }
    private Map<String, Object> capStepColumns(CapStepRow row)
    {
        Map<String, Object> value = new LinkedHashMap<>();
        value.put("seq_id", row.seqId());
        value.put("cycle", row.cycle());
        value.put("step_id", row.stepId());
        value.put("step_type", row.stepType());
        value.put("test_time", row.testTimeMillis());
        value.put("test_vol", row.voltage());
        value.put("test_cur", row.current());
        value.put("test_capchg", row.capacityCharge());
        value.put("test_engchg", row.energyCharge());
        value.put("test_capdchg", row.capacityDischarge());
        value.put("test_engdchg", row.energyDischarge());
        value.put("middle_vol", row.middleVoltage());
        value.put("test_atime", row.testAtime());
        return value;
    }
    private static boolean after(String atime, long seqId, String watermark, long watermarkSeq)
    {
        int time = atime.compareTo(watermark);
battery-device-neware/src/test/java/com/battery/device/neware/NewareMysqlAdapterContractTest.java
@@ -13,7 +13,9 @@
import java.util.Set;
import org.junit.jupiter.api.Test;
import com.battery.device.api.TestDataObservationAdapter.CabinetRealtime;
import com.battery.device.api.TestDataObservationAdapter.CycleRecord;
import com.battery.device.api.TestDataObservationAdapter.SampleWatermark;
import com.battery.device.api.TestDataObservationAdapter.StepRecord;
import com.battery.device.api.model.AdapterCapabilities;
import com.battery.device.api.model.ChannelIdentity;
import com.battery.device.api.model.DetectedChannel;
@@ -205,6 +207,198 @@
                .allMatch(item -> ADAPTER_CODE.equals(item.identity().adapterCode())));
    }
    // ---------------------------------------------------------------- 工步层与循环聚合
    /**
     * 工步记录来自设备工步层:每工步一条,取该工步的结束行;四组容量能量与中值电压放在扩展测量里,
     * 中值电压由设备字段承载,不用峰值或谷值替代。
     */
    @Test
    void stepRecordsComeFromTheDeviceStepLayerAndCarryAllFourCapacityGroups()
    {
        List<StepRecord> steps = adapter(stepLayerDatabase()).stepRecords(CHANNEL);
        // 只有进入行的工步仍在进行中,不出现:设备还没写后续工步的行。
        assertEquals(9, steps.size());
        assertTrue(steps.stream().noneMatch(step -> step.cycleNo() == 2));
        StepRecord rest = step(steps, 0, 1);
        assertEquals("4", rest.stepType());
        assertNull(rest.stepName());
        assertEquals(Instant.parse("2026-09-28T01:00:00Z"), rest.startedAt());
        assertEquals(Instant.parse("2026-09-28T01:00:38Z"), rest.endedAt());
        assertEquals(1.3588, rest.endVoltageV(), 1e-9);
        // 工步记录同时带结束时的电流读数。
        assertEquals(0d, rest.endCurrentA(), 1e-9);
        assertEquals(-1.1889, step(steps, 1, 11).endCurrentA(), 1e-9);
        // 工步时间是设备给的 test_time(毫秒 → 秒),不是按采样时间推算的。
        assertEquals(0, new java.math.BigDecimal("38.800").compareTo(rest.measurements().get("stepDuration")));
        assertEquals(0, new java.math.BigDecimal("1.358800000000").compareTo(rest.measurements().get("startVoltage")));
        StepRecord charge = step(steps, 0, 4);
        assertEquals("1", charge.stepType());
        // 单项容量只在方向已确认时给出:充电工步取充电侧。
        assertEquals(119277040d / 36000000d, charge.capacityAh(), 1e-9);
        assertEquals(195220576d / 36000000d, charge.energyWh(), 1e-9);
        // 充电中值电压直接取设备工步层的 middle_vol,设备给零就保留零。
        assertEquals(0, java.math.BigDecimal.ZERO.compareTo(charge.measurements().get("chargeMedianVoltage")));
        assertNull(charge.measurements().get("dischargeMedianVoltage"));
        StepRecord discharge = step(steps, 0, 6);
        assertEquals("2", discharge.stepType());
        assertEquals(68462032d / 36000000d, discharge.capacityAh(), 1e-9);
        assertTrue(discharge.measurements().containsKey("dischargeMedianVoltage"));
        // 四组容量能量都是设备事实:每一组都保留,不只保留方向匹配的那一组。
        for (String field : List.of("chargeCapacity", "dischargeCapacity", "chargeEnergy", "dischargeEnergy"))
            assertTrue(charge.measurements().containsKey(field) && discharge.measurements().containsKey(field));
        assertEquals("neware:440:step:0:4", charge.sourceRecordId());
        assertEquals("Asia/Hong_Kong", charge.timezoneId());
        assertEquals(charge.endedAt(), charge.serverReceivedAt());
    }
    /** 未知工步类型保留明细并保持未知:不按电流符号,也不按容量出现在哪一组去猜方向。 */
    @Test
    void unknownStepTypeKeepsItsDetailAndStaysOutOfTheCycleAggregate()
    {
        List<StepRecord> steps = adapter(stepLayerDatabase()).stepRecords(CHANNEL);
        StepRecord unknown = step(steps, 1, 3);
        assertEquals("7", unknown.stepType());
        // 单项容量留空:方向未确认时不给出可归属的单值,但四组明细照原样保留供核对。
        assertNull(unknown.capacityAh());
        assertNull(unknown.energyWh());
        assertEquals(500000000d / 36000000d, unknown.measurements().get("chargeCapacity").doubleValue(), 1e-9);
        CycleRecord cycle = cycle(adapter(stepLayerDatabase()).cycleRecords(CHANNEL), 1);
        // 未知方向的那一步容量没有进任何一侧:本循环的充放电仍只由已知方向的工步构成。
        assertEquals((119267664d + 85510512d) / 36000000d, cycle.chargeCapacityAh(), 1e-9);
        assertEquals((67587520d + 47559464d) / 36000000d, cycle.dischargeCapacityAh(), 1e-9);
    }
    /**
     * 循环统计由工步层按循环聚合并标注为聚合来源:同一循环内同方向的工步容量求和
     * (实测该化成协议 cycle 1 有两个充电工步与两个放电工步),效率是放电容量除以充电容量。
     */
    @Test
    void cycleRecordsAreAggregatedFromTheStepLayerAndMarkedAsAggregated()
    {
        List<CycleRecord> cycles = adapter(stepLayerDatabase()).cycleRecords(CHANNEL);
        // 仍在进行的循环整体不出现:工步行还没写完,聚合出来的是半个循环。
        assertEquals(2, cycles.size());
        CycleRecord first = cycle(cycles, 0);
        assertEquals(119277040d / 36000000d, first.chargeCapacityAh(), 1e-9);
        // 两个放电工步求和:16219380 + 68462032。
        assertEquals(84681412d / 36000000d, first.dischargeCapacityAh(), 1e-9);
        assertEquals(195220576d / 36000000d, first.chargeEnergyWh(), 1e-9);
        assertEquals(40000000d / 36000000d, first.dischargeEnergyWh(), 1e-9);
        assertEquals(70.9956, first.efficiency(), 1e-3);
        // 来源类别是聚合:界面与导出据此标注"这个数是推导出来的"。
        assertEquals(CycleRecord.SOURCE_AGGREGATED, first.sourceKind());
        assertEquals("neware:440:cycle:0", first.sourceRecordId());
        // 循环的起止取该循环首末工步的设备时间,服务器接收时间同样取设备时刻,暂停与继续据此留白。
        assertEquals(Instant.parse("2026-09-28T01:00:00Z"), first.startedAt());
        assertEquals(Instant.parse("2026-09-28T14:04:47Z"), first.endedAt());
        assertEquals(first.endedAt(), first.serverReceivedAt());
        CycleRecord second = cycle(cycles, 1);
        assertEquals((119267664d + 85510512d) / 36000000d, second.chargeCapacityAh(), 1e-9);
        assertEquals((67587520d + 47559464d) / 36000000d, second.dischargeCapacityAh(), 1e-9);
        assertEquals(56.2301, second.efficiency(), 1e-3);
    }
    /** 循环没有可用充电容量时效率留空:未知比率不写成零。 */
    @Test
    void cycleEfficiencyStaysEmptyWhenThereIsNoChargeCapacity()
    {
        FakeVendorDatabase database = new FakeVendorDatabase()
                .registeredChannel(DEV_UID, 1, 5)
                .test(new FakeVendorDatabase.TestRow(DEV_UID, 1, 5, 440L, "2026-09-28 09:00:00", ACTIVE_TABLE, null))
                .capStep(new FakeVendorDatabase.CapStepRow(440L, DEV_UID, 1, 5, 1L, 9, 1, "2", 3600000L,
                        13000, -5000, 0, 0, 50000000, 9000000, 0, "2026-09-28 12:00:00"))
                .capStep(new FakeVendorDatabase.CapStepRow(440L, DEV_UID, 1, 5, 2L, 10, 1, "4", 60000L,
                        13000, 0, 0, 0, 0, 0, 0, "2026-09-28 12:01:00"));
        List<CycleRecord> cycles = adapter(database)
                .cycleRecords(new ChannelIdentity(ADAPTER_CODE, 21, 36, 1, 5));
        assertEquals(1, cycles.size());
        assertEquals(0d, cycles.get(0).chargeCapacityAh(), 1e-9);
        assertNull(cycles.get(0).efficiency());
    }
    /** 没有测试记录、没有工步行或通道属于别实例时,工步与循环都保持为空而不报错。 */
    @Test
    void stepAndCycleRecordsStayEmptyWithoutAStepLayerOrTest()
    {
        NewareMysqlAdapter withoutTest = adapter(new FakeVendorDatabase().registeredChannel(DEV_UID, 1, 2));
        assertTrue(withoutTest.stepRecords(CHANNEL).isEmpty());
        assertTrue(withoutTest.cycleRecords(CHANNEL).isEmpty());
        assertTrue(adapter(activeDeviceDatabase()).stepRecords(CHANNEL).isEmpty());
        org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class,
                () -> adapter(activeDeviceDatabase()).cycleRecords(new ChannelIdentity("other-source", 21, 36, 1, 2)));
    }
    private static StepRecord step(List<StepRecord> steps, int cycleNo, int stepNo)
    {
        return steps.stream().filter(item -> item.cycleNo() == cycleNo && item.stepNo() == stepNo)
                .findFirst().orElseThrow();
    }
    private static CycleRecord cycle(List<CycleRecord> cycles, int cycleNo)
    {
        return cycles.stream().filter(item -> item.cycleNo() == cycleNo).findFirst().orElseThrow();
    }
    /**
     * 工步层假厂商库:cycle 0 走完搁置、放电、充电、放电,cycle 1 有两个充电工步、两个放电工步
     * 与一个未知类型工步;末组(cycle 2 的 step 9)只有进入行——它仍在进行中。
     */
    private FakeVendorDatabase stepLayerDatabase()
    {
        return new FakeVendorDatabase()
                .registeredChannel(DEV_UID, 1, 2)
                .test(new FakeVendorDatabase.TestRow(DEV_UID, 1, 2, 440L, "2026-09-28 09:00:00",
                        ACTIVE_TABLE, null))
                // cycle 0:搁置、放电、充电、放电;结束行承载该工步累计容量与能量。
                .capStep(step(1L, 0, 1, "4", 0L, 13588, 0, 0, 0, 0, 0, "2026-09-28 09:00:00"))
                .capStep(step(2L, 0, 1, "4", 38800L, 13588, 0, 0, 0, 0, 0, "2026-09-28 09:00:38"))
                .capStep(step(3L, 0, 2, "2", 0L, 13222, -4735, 0, 0, 0, 0, "2026-09-28 09:00:38"))
                .capStep(step(4L, 0, 2, "2", 3425000L, 9998, -4736, 0, 0, 16219380, 10000000,
                        "2026-09-28 10:04:37"))
                .capStep(step(5L, 0, 4, "1", 0L, 13507, 4732, 0, 0, 0, 0, "2026-09-28 11:03:51"))
                .capStep(step(6L, 0, 4, "1", 25200000L, 17085, 4733, 119277040, 195220576, 0, 0,
                        "2026-09-28 18:03:51"))
                .capStep(step(7L, 0, 6, "2", 0L, 14574, -4735, 0, 0, 0, 0, "2026-09-28 18:03:51"))
                .capStep(step(8L, 0, 6, "2", 14455700L, 9998, -4736, 0, 0, 68462032, 30000000,
                        "2026-09-28 22:04:47"))
                // cycle 1:两个充电工步 + 两个放电工步 + 一个未知类型工步。
                .capStep(step(9L, 1, 1, "1", 0L, 13507, 4732, 0, 0, 0, 0, "2026-09-28 22:04:47"))
                .capStep(step(10L, 1, 1, "1", 25200000L, 17085, 4733, 119267664, 196020320, 0, 0,
                        "2026-09-29 05:04:47"))
                .capStep(step(11L, 1, 2, "2", 0L, 14574, -4735, 0, 0, 0, 0, "2026-09-29 05:04:47"))
                .capStep(step(12L, 1, 2, "2", 14271500L, 9998, -4736, 0, 0, 67587520, 86258072,
                        "2026-09-29 09:02:38"))
                // 未知类型 7:充电组有值,但方向未确认,所以不进聚合、单项容量留空。
                .capStep(step(13L, 1, 3, "7", 0L, 13000, 500, 500000000, 900000000, 0, 0, "2026-09-29 09:02:39"))
                .capStep(step(14L, 1, 3, "7", 600000L, 13200, 600, 500000000, 900000000, 0, 0,
                        "2026-09-29 09:12:39"))
                .capStep(step(15L, 1, 9, "1", 0L, 13092, 11860, 0, 0, 0, 0, "2026-09-29 09:12:40"))
                .capStep(step(16L, 1, 9, "1", 7200000L, 18176, 11875, 85510512, 145614320, 0, 0,
                        "2026-09-29 11:12:40"))
                .capStep(step(17L, 1, 11, "2", 0L, 14313, -11878, 0, 0, 0, 0, "2026-09-29 11:12:41"))
                .capStep(step(18L, 1, 11, "2", 4001000L, 9998, -11889, 0, 0, 47559464, 58816460,
                        "2026-09-29 12:19:21"))
                // 仍在进行的工步:只有进入行。
                .capStep(step(19L, 2, 9, "1", 0L, 13774, 11871, 0, 0, 0, 0, "2026-09-29 12:19:22"));
    }
    /** 工步层一行的简写:本用例只用本通道的数据,中值电压按现场实测给出零。 */
    private static FakeVendorDatabase.CapStepRow step(long seqId, int cycle, int stepId, String stepType,
            long testTimeMillis, int voltage, int current, int capacityCharge, int energyCharge,
            int capacityDischarge, int energyDischarge, String testAtime)
    {
        return new FakeVendorDatabase.CapStepRow(440L, DEV_UID, 1, 2, seqId, cycle, stepId, stepType,
                testTimeMillis, voltage, current, capacityCharge, energyCharge, capacityDischarge, energyDischarge,
                0, testAtime);
    }
    /** 凭据只用现场取得的服务端只读账号;已知的全权限账号不进入配置。 */
    @Test
    void credentialsFallBackToTheReadOnlyAccountAndRefuseFullPrivilegeAccounts()
battery-device-neware/src/test/java/com/battery/device/neware/NewareStepDirectionResolverTest.java
New file
@@ -0,0 +1,30 @@
package com.battery.device.neware;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
import com.battery.device.api.StepDirectionResolver.Direction;
/** 工步类型数字到方向的映射:已知取值照映射,未确认取值保持未知,不按电流符号猜。 */
class NewareStepDirectionResolverTest
{
    private final NewareStepDirectionResolver resolver = new NewareStepDirectionResolver();
    @Test
    void mapsTheConfirmedNumericStepTypes()
    {
        // 现场实测:1 充电、2 放电、4 搁置;空白按未知处理。
        assertEquals(Direction.CHARGE, resolver.resolve("1"));
        assertEquals(Direction.DISCHARGE, resolver.resolve("2"));
        assertEquals(Direction.REST, resolver.resolve(" 4 "));
    }
    @Test
    void keepsUnconfirmedNumericTypesUnknown()
    {
        // cap_step 里还出现过 3、5、7、13,语义未确认;未知就是未知,不能按数值或电流方向推断。
        for (String type : new String[] { "3", "5", "7", "13", "0", "99", "abc", "cc", "rest", " " })
            assertEquals(Direction.UNKNOWN, resolver.resolve(type), type);
        assertEquals(Direction.UNKNOWN, resolver.resolve(null));
    }
}
ruoyi-admin/src/main/java/com/battery/business/runtime/ReportStepDirectionConfig.java
@@ -1,14 +1,28 @@
package com.battery.business.runtime;
import com.battery.device.api.StepDirectionResolver;
import com.battery.device.api.StepDirectionResolver.Direction;
import com.battery.device.bts.BtsStepDirectionResolver;
import com.battery.device.neware.NewareStepDirectionResolver;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** 装配已保存 BTS 工步的方向解释,不触发任何设备请求。 */
/** 装配已保存工步的方向解释,不触发任何设备请求。 */
@Configuration
public class ReportStepDirectionConfig
{
    /**
     * 已保存工步的类型有两种形态:导入或协议路径给的是名称(`cc`、`charge`),
     * 直连新威取来的是设备数字枚举(`1`/`2`/`4`)。两种都解释,未确认取值保持未知。
     */
    @Bean
    public StepDirectionResolver reportStepDirectionResolver() { return new BtsStepDirectionResolver(); }
    public StepDirectionResolver reportStepDirectionResolver()
    {
        StepDirectionResolver deviceTypes = new NewareStepDirectionResolver();
        StepDirectionResolver names = new BtsStepDirectionResolver();
        return stepType -> {
            Direction direction = deviceTypes.resolve(stepType);
            return direction == Direction.UNKNOWN ? names.resolve(stepType) : direction;
        };
    }
}
ruoyi-admin/src/main/resources/db/migration-final/V23__cycle_record_source_kind.sql
New file
@@ -0,0 +1,5 @@
-- 循环统计的来源类别:设备直接提供、由工步层按循环聚合、授权文件导入。
-- 设备没有循环汇总行时循环统计是推导值,必须与设备事实可区分,界面与导出据此标注"聚合来源"。
-- 历史行按设备事实处理:本轮之前写入的循环记录没有聚合来源,语言上的默认值与存量一致。
ALTER TABLE t_test_cycle_record
    ADD COLUMN source_kind VARCHAR(16) NOT NULL DEFAULT 'DEVICE' AFTER source_record_id;
ruoyi-admin/src/test/java/com/battery/business/runtime/CycleRecordSourceKindMigrationContractTest.java
New file
@@ -0,0 +1,66 @@
package com.battery.business.runtime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.init.ScriptUtils;
/**
 * 循环记录来源类别的迁移契约:设备没有循环汇总行时循环统计由工步层聚合得出,
 * 该来源事实必须落在库里,界面与导出才能明确标注"这个数是推导的"。
 */
class CycleRecordSourceKindMigrationContractTest
{
    @Test
    void migrationAddsSourceKindColumnWithDeviceAsHistoricalDefault() throws Exception
    {
        String path = "db/migration-final/V23__cycle_record_source_kind.sql";
        String migration = resource(path);
        assertTrue(migration.contains("ADD COLUMN source_kind VARCHAR(16) NOT NULL DEFAULT 'DEVICE'"));
        assertTrue(migration.contains("AFTER source_record_id"));
        // 迁移只加列:不改写既有循环事实,也不写入任何数据。
        assertFalse(migration.matches("(?s).*\\b(INSERT|UPDATE|DELETE|DROP)\\b.*"));
        var source = new UnpooledDataSource("org.h2.Driver",
                "jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE", "sa", "");
        try (var connection = source.getConnection())
        {
            JdbcTemplate jdbc = new JdbcTemplate(source);
            jdbc.execute("create table t_test_cycle_record(cycle_record_id bigint auto_increment primary key,"
                    + "source_record_id varchar(128))");
            // 迁移之前写入的循环记录按设备事实处理:语言上的默认值与存量一致。
            jdbc.update("insert into t_test_cycle_record(source_record_id) values(?)", "CYCLE-1");
            ScriptUtils.executeSqlScript(connection, new ClassPathResource(path));
            assertEquals("DEVICE", jdbc.queryForObject(
                    "select source_kind from t_test_cycle_record where source_record_id = 'CYCLE-1'", String.class));
            // 聚合来源是显式写入的事实,不是从别处推断出来的。
            jdbc.update("insert into t_test_cycle_record(source_record_id, source_kind) values(?,?)",
                    "neware:442:cycle:3", "AGGREGATED");
            assertEquals(1, jdbc.queryForObject(
                    "select count(*) from t_test_cycle_record where source_kind = 'AGGREGATED'", Integer.class));
        }
    }
    private static String resource(String path)
    {
        try (InputStream stream = CycleRecordSourceKindMigrationContractTest.class.getClassLoader()
                .getResourceAsStream(path))
        {
            if (stream == null) throw new AssertionError("缺少资源 " + path);
            return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
        }
        catch (Exception exception)
        {
            throw new AssertionError("无法读取资源 " + path, exception);
        }
    }
}
ruoyi-admin/src/test/java/com/battery/business/service/impl/TestDataReceptionServiceImplTest.java
@@ -185,11 +185,12 @@
        Instant started = Instant.parse("2026-09-02T04:00:10Z");
        Instant ended = Instant.parse("2026-09-02T04:00:50Z");
        when(fixture.simulator.stepRecords(any(ChannelIdentity.class))).thenReturn(List.of(new StepRecord(
                "STEP-1", 1, 2, "恒流充电", "cc", started, ended, 3.4, 2.5, 8.4, ended.toString(), "UTC", ended,
                "STEP-1", 1, 2, "恒流充电", "cc", started, ended, 3.4, 2.6, 2.5, 8.4, ended.toString(), "UTC", ended,
                java.util.Map.of("stepDuration", new BigDecimal("40.125"), "startVoltage", new BigDecimal("3.1"),
                        "chargeMedianVoltage", new BigDecimal("3.25"), "dischargeMedianVoltage", BigDecimal.ZERO))));
        when(fixture.simulator.cycleRecords(any(ChannelIdentity.class))).thenReturn(List.of(new CycleRecord(
                "CYCLE-1-1-1", 1, started, ended, 2.5, 2.4, 8.4, 8.0, 96, ended.toString(), "UTC", ended)));
                "CYCLE-1-1-1", 1, started, ended, 2.5, 2.4, 8.4, 8.0, 96d, ended.toString(), "UTC", ended,
                CycleRecord.SOURCE_DEVICE)));
        when(fixture.mapper.insertStepRecord(any())).thenReturn(1);
        when(fixture.mapper.insertCycleRecord(any())).thenReturn(1);
@@ -207,8 +208,29 @@
        assertEquals(BigDecimal.ZERO, metrics.getBigDecimal("dischargeMedianVoltage"));
        assertEquals("CYCLE-1-1-1", cycle.getValue().getSourceRecordId());
        assertEquals("record:CYCLE-1-1-1", cycle.getValue().getDedupeKey());
        // 设备或文件直接提供的循环统计保留原有来源类别。
        assertEquals(CycleRecord.SOURCE_DEVICE, cycle.getValue().getSourceKind());
        // 工步结束时的设备读数:电压与电流一起落库。
        assertEquals(new BigDecimal("3.4"), step.getValue().getVoltageV());
        assertEquals(new BigDecimal("2.6"), step.getValue().getCurrentA());
        assertEquals(Date.from(ended), step.getValue().getServerReceivedAtUtc());
        assertEquals(Date.from(ended), cycle.getValue().getServerReceivedAtUtc());
    }
    /** 接收调度每秒运行,但来源按自己的写库节奏产出工步:不按来源节奏限流会每秒重查厂商库。 */
    @Test void readsTheStepLayerOncePerSourceCadenceInsteadOfEverySchedulerTick()
    {
        Fixture fixture = fixture();
        when(fixture.simulator.stepRecords(any(ChannelIdentity.class))).thenReturn(List.of());
        when(fixture.simulator.cycleRecords(any(ChannelIdentity.class))).thenReturn(List.of());
        TestDataReceptionServiceImpl service = fixture.service();
        service.captureReceivedData();
        service.captureReceivedData();
        service.captureReceivedData();
        verify(fixture.simulator, times(1)).stepRecords(any(ChannelIdentity.class));
        verify(fixture.simulator, times(1)).cycleRecords(any(ChannelIdentity.class));
    }
    @Test
@@ -251,19 +273,54 @@
        fixture.tests.selectTestById(1L).setReceptionBoundaryAtUtc(Date.from(CAPTURED_AT));
        Instant pausedAt = CAPTURED_AT.minusSeconds(10);
        when(fixture.simulator.stepRecords(any())).thenReturn(List.of(new StepRecord("paused", 1, 1, "工步", "cc",
                pausedAt.minusSeconds(20), pausedAt, 3, 1, 2, pausedAt.toString(), "UTC", pausedAt, java.util.Map.of())));
                pausedAt.minusSeconds(20), pausedAt, 3d, 1.5d, 1d, 2d, pausedAt.toString(), "UTC", pausedAt,
                java.util.Map.of())));
        when(fixture.simulator.cycleRecords(any())).thenReturn(List.of(new CycleRecord("paused", 1,
                pausedAt.minusSeconds(20), pausedAt, 1, 1, 2, 2, 100, pausedAt.toString(), "UTC", pausedAt)));
                pausedAt.minusSeconds(20), pausedAt, 1, 1, 2, 2, 100d, pausedAt.toString(), "UTC", pausedAt,
                CycleRecord.SOURCE_DEVICE)));
        fixture.service().captureReceivedData();
        verify(fixture.mapper, never()).insertStepRecord(any());
        verify(fixture.mapper, never()).insertCycleRecord(any());
    }
    /** 循环统计的来源类别随记录落库:聚合值必须能与设备或文件提供的值区分。 */
    @Test void persistsCycleSourceKindAndKeepsMissingMetricsEmpty()
    {
        Fixture fixture = fixture();
        when(fixture.simulator.cycleRecords(any(ChannelIdentity.class))).thenReturn(List.of(
                new CycleRecord("neware:442:cycle:3", 3, CAPTURED_AT.minusSeconds(600), CAPTURED_AT, 2.375, 1.383,
                        4.059, 1.715, null, CAPTURED_AT.toString(), "Asia/Hong_Kong", CAPTURED_AT,
                        CycleRecord.SOURCE_AGGREGATED)));
        when(fixture.simulator.stepRecords(any(ChannelIdentity.class))).thenReturn(List.of(new StepRecord(
                "neware:442:step:3:7", 3, 7, null, "7", CAPTURED_AT.minusSeconds(60), CAPTURED_AT, 1.3, 0.4, null, null,
                CAPTURED_AT.toString(), "Asia/Hong_Kong", CAPTURED_AT,
                java.util.Map.of("chargeCapacity", new BigDecimal("13.888888888889")))));
        when(fixture.mapper.insertCycleRecord(any())).thenReturn(1);
        when(fixture.mapper.insertStepRecord(any())).thenReturn(1);
        fixture.service().captureReceivedData();
        ArgumentCaptor<TTestCycleRecord> cycle = ArgumentCaptor.forClass(TTestCycleRecord.class);
        verify(fixture.mapper).insertCycleRecord(cycle.capture());
        assertEquals(CycleRecord.SOURCE_AGGREGATED, cycle.getValue().getSourceKind());
        // 没有可用充电容量就没有效率:空值不是零。
        assertNull(cycle.getValue().getEfficiency());
        assertEquals(new BigDecimal("2.375"), cycle.getValue().getChargeCapacityAh());
        ArgumentCaptor<TTestStepRecord> step = ArgumentCaptor.forClass(TTestStepRecord.class);
        verify(fixture.mapper).insertStepRecord(step.capture());
        // 方向未确认的工步没有单项容量与能量:落库保持空值,四组明细仍在扩展里。
        assertNull(step.getValue().getCapacityAh());
        assertNull(step.getValue().getEnergyWh());
        assertTrue(step.getValue().getExtensionMeasurementsJson().contains("chargeCapacity"));
    }
    @Test void deletedAggregateDoesNotReturnOnRepeatedObservation()
    {
        Fixture fixture = fixture();
        when(fixture.simulator.stepRecords(any())).thenReturn(List.of(new StepRecord("deleted", 1, 1, "工步", "cc",
                CAPTURED_AT.minusSeconds(10), CAPTURED_AT, 3, 1, 2, CAPTURED_AT.toString(), "UTC", CAPTURED_AT, java.util.Map.of())));
                CAPTURED_AT.minusSeconds(10), CAPTURED_AT, 3d, 1.5d, 1d, 2d, CAPTURED_AT.toString(), "UTC", CAPTURED_AT,
                java.util.Map.of())));
        when(fixture.mapper.wasAggregateDeleted(1L, "STEP", 4L, "dev-sim:22:1:1:1", "record:deleted")).thenReturn(true);
        fixture.service().captureReceivedData();
        verify(fixture.mapper, never()).insertStepRecord(any());
ruoyi-admin/src/test/java/com/battery/business/web/ConsoleSingleSheetImportDatabaseTest.java
@@ -64,7 +64,7 @@
                new ClassPathResource("db/migration-final/V8__import_records_without_polling_slots.sql"));
        String deviceTime = "dedupe_key varchar(512) not null, device_time_utc timestamp(3) not null, device_time_raw varchar(64) not null, adapter_timezone_id varchar(64) not null, unique(test_battery_id,source_identity,dedupe_key)";
        jdbc.execute("create table t_test_step_record(step_record_id bigint auto_increment primary key," + common + "," + metrics + "," + deviceTime + ")");
        jdbc.execute("create table t_test_cycle_record(cycle_record_id bigint auto_increment primary key," + common + ",charge_capacity_ah decimal(30,12),discharge_capacity_ah decimal(30,12),charge_energy_wh decimal(30,12),discharge_energy_wh decimal(30,12),efficiency decimal(30,12)," + deviceTime + ")");
        jdbc.execute("create table t_test_cycle_record(cycle_record_id bigint auto_increment primary key," + common + ",source_kind varchar(16) default 'DEVICE',charge_capacity_ah decimal(30,12),discharge_capacity_ah decimal(30,12),charge_energy_wh decimal(30,12),discharge_energy_wh decimal(30,12),efficiency decimal(30,12)," + deviceTime + ")");
        var factory = new SqlSessionFactoryBean(); factory.setDataSource(source);
        factory.setTypeAliasesPackage("com.battery.business.domain");
        factory.setMapperLocations(new ClassPathResource("mapper/battery/TTestMapper.xml"));
@@ -107,6 +107,40 @@
        }
        assertEquals("17", mapper.selectPersistedFinalRealtimeRecords(1L).get(0).get("recordno"));
        assertEquals(3, service.selectActions(new TTestDataRevisionAction()).size());
    }
    /**
     * 循环记录的来源类别随记录落库并经查询交回:导入的循环来自授权文件,
     * 由工步层聚合的循环标注为聚合,使界面与导出能区分"推导值"与"设备或文件给的值"。
     */
    @Test void cycleSourceKindDistinguishesImportedAndAggregatedRecords()
    {
        assertEquals(1, submit("CYCLE", List.of(row())).get("importedCount"));
        assertEquals("IMPORTED", mapper.selectPersistedFinalCycleRecords(1L).get(0).get("sourcekind"));
        var aggregated = new com.battery.business.domain.TTestCycleRecord();
        aggregated.setTestId(1L); aggregated.setTestBatteryId(4L); aggregated.setBindingId(5L);
        aggregated.setAdapterCode("adapter-a"); aggregated.setSourceIdentity("adapter-a:27:1:1:2");
        aggregated.setSourceRecordId("neware:442:cycle:3");
        aggregated.setSourceKind(com.battery.device.api.TestDataObservationAdapter.CycleRecord.SOURCE_AGGREGATED);
        aggregated.setDedupeKey("record:neware:442:cycle:3");
        aggregated.setAdapterTimezoneId("Asia/Hong_Kong");
        aggregated.setDeviceTimeUtc(new java.util.Date(1683517331000L));
        aggregated.setDeviceTimeRaw("2023-05-08 11:42:11");
        aggregated.setServerReceivedAtUtc(new java.util.Date(1683517331000L));
        aggregated.setCycleNo(3);
        aggregated.setChargeCapacityAh(new java.math.BigDecimal("2.375"));
        aggregated.setDischargeCapacityAh(new java.math.BigDecimal("1.383"));
        aggregated.setEfficiency(null);
        aggregated.setContentSha256(new byte[32]);
        assertEquals(1, mapper.insertCycleRecord(aggregated));
        var rows = mapper.selectPersistedFinalCycleRecords(1L);
        assertEquals(2, rows.size());
        var stored = rows.stream().filter(item -> "3".equals(String.valueOf(item.get("cycleno")))).findFirst().orElseThrow();
        assertEquals("AGGREGATED", stored.get("sourcekind"));
        // 没有可用充电容量就没有效率:空值不是零。
        assertNull(stored.get("efficiency"));
    }
    @Test void databaseFailureRollsBackEarlierRowsAndTheRevision()
@@ -165,12 +199,24 @@
                org.mockito.Mockito.mock(com.battery.business.service.ITTestService.class));
        var current = new LinkedHashMap<String, Object>(Map.of("extensionMeasurementsJson", extension.toJSONString()));
        var legacy = new LinkedHashMap<String, Object>(Map.of("extensionMeasurementsJson", "{\"chargePeakVoltage\":1.8,\"dischargeValleyVoltage\":0.8}"));
        org.mockito.Mockito.when(queryMapper.selectPersistedFinalStepRecords(1L)).thenReturn(List.of(current, legacy));
        // 设备工步层的四组容量能量是独立事实:查询要交出它们,供方向未确认的工步核对。
        var deviceStep = new LinkedHashMap<String, Object>(Map.of("extensionMeasurementsJson",
                "{\"chargeCapacity\":13.888888888889,\"dischargeCapacity\":0,\"chargeEnergy\":25,\"dischargeEnergy\":0}"));
        org.mockito.Mockito.when(queryMapper.selectPersistedFinalStepRecords(1L)).thenReturn(List.of(current, legacy, deviceStep));
        var result = query.steps(1L);
        assertEquals(new java.math.BigDecimal("1.1709"), result.get(0).get("dischargeMedianVoltage"));
        assertEquals(0.0, ((Number) result.get(0).get("chargeMedianVoltage")).doubleValue());
        assertNull(result.get(1).get("chargeMedianVoltage"));
        assertNull(result.get(1).get("dischargeMedianVoltage"));
        assertEquals(0, new java.math.BigDecimal("13.888888888889").compareTo(
                new java.math.BigDecimal(String.valueOf(result.get(2).get("chargeCapacity")))));
        assertEquals(0, java.math.BigDecimal.ZERO.compareTo(
                new java.math.BigDecimal(String.valueOf(result.get(2).get("dischargeCapacity")))));
        assertEquals(0, new java.math.BigDecimal("25.0").compareTo(
                new java.math.BigDecimal(String.valueOf(result.get(2).get("chargeEnergy")))));
        assertEquals(0, java.math.BigDecimal.ZERO.compareTo(
                new java.math.BigDecimal(String.valueOf(result.get(2).get("dischargeEnergy")))));
        assertNull(result.get(1).get("chargeCapacity"));
        imported.put("dischargeMedianVoltage", 1.2);
        assertTrue(assertThrows(RuntimeException.class, () -> submit("STEP", List.of(imported))).getMessage().contains("冲突拒绝"));