yeg
6 天以前 e38aca8134570513435c003c25dc05f1ae51c9c9
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
package com.battery.device.neware;
 
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.temporal.ChronoField;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
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;
import com.battery.device.api.model.DetectedChannel;
import com.battery.device.api.model.DetectedDeviceMetadata;
import com.battery.device.api.model.DeviceConnectionStatus;
import com.battery.device.api.model.DeviceWorkStatus;
import com.battery.device.api.model.RealtimeSnapshot;
import com.battery.device.api.registry.AdapterInstanceConfig;
 
/**
 * 直连新威上位机 MySQL 库的只读适配器(存储形态 {@code NEWARE_MYSQL})。
 *
 * <p>现场确认的取数事实(见 {@code docs/commissioning/现场对接清单.md}):
 * <ul>
 *   <li>发现候选来自 {@code channel} 通道登记表,主键 {@code (dev_uid, unit_id, chl_id)};
 *       设备号 {@code dev_uid = devtype × 10000 + devid}。登记表不等于"当前在用",
 *       所以只把"最近数据时间"作为事实交出,登记哪些候选由操作员决定。</li>
 *   <li>逐点数据在 {@code data_<设备六位>_<年>_<月>_<日>} 分表里,表名由 {@code test} 表的
 *       {@code main_first_table} / {@code main_second_table} 直接给出,不按日期推算。</li>
 *   <li>最新测试按 {@code start_time} 取,不能按 {@code test_id} 排序(实测不单调)。</li>
 *   <li>活跃与否只看有没有新的采样行;{@code end_time} 是否为空与 8.0 的通道水位线列都不作为依据。</li>
 *   <li>厂商固定每 60 秒写一行,实时展示节奏与数据产生节奏天生分离。</li>
 *   <li>辅助表族实测没有数据,采集器辅助测量只能走导入,因此 {@link #latestCollector} 保持为空。</li>
 * </ul>
 *
 * <p>单位换算按实测公式:电压与电流除以 10000(原单位 0.1 mV / 0.1 mA),容量与能量除以 36000000
 * (原单位 1e-4 C / 1e-4 J)。工步类型保留设备给出的数字,不按电流符号猜测方向;
 * 内阻与温度的单位语义仍未确认,因此不对外暴露。
 */
public final class NewareMysqlAdapter implements DeviceAdapter, TestDataObservationAdapter
{
    public static final String ADAPTER_TYPE = "NEWARE_MYSQL";
    /** 连接口令的默认取值名:本地凭据文件的键、同名环境变量或同名启动参数,见 {@link NewareMysqlAdapterFactory#credential}。 */
    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;
    /**
     * 每条语句的查询截止时间。厂商库的服务端**没有**查询超时,客户端不设就是无限;
     * JDBC URL 里的 {@code socketTimeout} 只管单次读,慢速滴流的查询仍可能长期占住连接。
     */
    private static final int QUERY_TIMEOUT_SECONDS = 10;
    private static final Pattern SAFE_TABLE_NAME = Pattern.compile("[A-Za-z0-9_]{1,64}");
    /** 设备时间按适配器时区解释;来源列是秒精度 datetime,小数部分按需接受。 */
    private static final DateTimeFormatter DEVICE_TIME = new DateTimeFormatterBuilder()
            .appendPattern("yyyy-MM-dd HH:mm:ss")
            .optionalStart().appendFraction(ChronoField.NANO_OF_SECOND, 1, 9, true).optionalEnd()
            .toFormatter(Locale.ROOT);
    /** 水位回写来源时使用秒精度文本,与来源列一致;同秒内的区分由协议序号承担。 */
    private static final DateTimeFormatter DEVICE_TIME_TEXT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss",
            Locale.ROOT);
    private static final BigDecimal VOLTAGE_CURRENT_DIVISOR = BigDecimal.valueOf(10000);
    private static final BigDecimal CAPACITY_ENERGY_DIVISOR = BigDecimal.valueOf(36000000);
 
    private final AdapterInstanceConfig config;
    private final DataSource dataSource;
    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)
    {
        this(config, dataSource, () -> { }, Clock.systemUTC(), SAMPLE_INTERVAL_MS);
    }
 
    public NewareMysqlAdapter(AdapterInstanceConfig config, DataSource dataSource, Runnable connectionReset,
            Clock clock, long lastDataCacheMillis)
    {
        this.config = config;
        this.dataSource = dataSource;
        this.connectionReset = connectionReset;
        this.clock = clock;
        this.deviceZone = ZoneId.of(config.timezoneId());
        this.lastDataCacheMillis = Math.max(0, lastDataCacheMillis);
    }
 
    /** 由工厂使用:把驱动连接包上只读白名单,并在查询失败后作废连接。 */
    public static NewareMysqlAdapter create(AdapterInstanceConfig config, SingleConnectionVendorDataSource source)
    {
        return new NewareMysqlAdapter(config, new ReadOnlyVendorDataSource(source, config.adapterCode()),
                source::invalidate, Clock.systemUTC(), SAMPLE_INTERVAL_MS);
    }
 
    /** 返回本实例登记的适配器编码。 */
    @Override
    public String adapterCode()
    {
        return config.adapterCode();
    }
 
    /** 返回实例配置登记的能力标记。 */
    @Override
    public AdapterCapabilities capabilities()
    {
        return config.capabilities();
    }
 
    /** 发现候选来自厂商库的通道登记表;每条候选附带该通道最近的采样行时间。 */
    @Override
    public List<DetectedChannel> discoverChannels()
    {
        List<ChannelKey> registered = query("select dev_uid, unit_id, chl_id from channel "
                + "order by dev_uid, unit_id, chl_id", this::readRegisteredChannels);
        Map<Long, List<ChannelKey>> byDevice = new LinkedHashMap<>();
        for (ChannelKey key : registered)
            byDevice.computeIfAbsent(key.devUid(), ignored -> new ArrayList<>()).add(key);
        List<DetectedChannel> channels = new ArrayList<>();
        Instant observedAt = clock.instant();
        for (Map.Entry<Long, List<ChannelKey>> entry : byDevice.entrySet())
        {
            Map<ChannelKey, VendorTest> latestTests = latestTests(entry.getKey());
            for (ChannelKey key : entry.getValue())
            {
                VendorTest latest = latestTests.get(key);
                Instant lastDataAt = latest == null ? null : cachedLastSampleAt(key, latest, observedAt);
                channels.add(new DetectedChannel(identity(key), config.host(), true,
                        DetectedDeviceMetadata.empty(), lastDataAt));
            }
        }
        return List.copyOf(channels);
    }
 
    /** 实时快照取自该通道最新测试的最新一行;没有可用行时不返回该通道,避免沿用过期观测。 */
    @Override
    public Map<ChannelIdentity, RealtimeSnapshot> inquire(Set<ChannelIdentity> channels)
    {
        if (channels == null || channels.isEmpty()) return Map.of();
        Map<ChannelIdentity, RealtimeSnapshot> result = new LinkedHashMap<>();
        for (ChannelIdentity channel : channels)
        {
            CabinetRealtime latest = latestCabinet(channel);
            if (latest == null) continue;
            result.put(channel, snapshot(channel, latest));
        }
        return Map.copyOf(result);
    }
 
    /** 取该通道最新测试的最新一行采样作为柜端实时观测;没有测试或没有采样行时返回空值。 */
    @Override
    public CabinetRealtime latestCabinet(ChannelIdentity channel)
    {
        ChannelKey key = channelKey(channel);
        VendorTest latest = latestTest(key);
        if (latest == null) return null;
        SampleRow row = lastSample(key, latest);
        return row == null ? null : cabinetRealtime(row);
    }
 
    /** 新威辅助表族实测没有数据,采集器辅助测量只能靠导入(ADR-0064)。 */
    @Override
    public CollectorRealtime latestCollector(String adapterCode, String collectorCode)
    {
        return null;
    }
 
    /** 工步层记录与循环统计都读设备的 {@code cap_step} 表;该表不随采样分表滚动。 */
    @Override
    public List<StepRecord> stepRecords(ChannelIdentity channel)
    {
        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)
    {
        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 秒写库;持久化周期与之对齐,不做倍速。 */
    @Override
    public long persistenceIntervalMillis(ChannelIdentity channel)
    {
        return SAMPLE_INTERVAL_MS;
    }
 
    /** 水位是来源里已存在的最新一行:设备绝对时间与协议序号。 */
    @Override
    public SampleWatermark currentWatermark(ChannelIdentity channel)
    {
        ChannelKey key = channelKey(channel);
        VendorTest latest = latestTest(key);
        if (latest == null) return new SampleWatermark(null, 0L);
        SampleRow row = lastSample(key, latest);
        return row == null ? new SampleWatermark(null, 0L) : new SampleWatermark(row.deviceTime(), row.sequenceId());
    }
 
    /**
     * 严格晚于水位的设备数据行。同一绝对秒内的多行各自成立(实测工步切换点会在同一秒写入多行),
     * 所以按 {@code (test_atime, seq_id)} 过滤与排序,不按秒合并。
     *
     * <p>设备时间为空的水位表示边界时该通道在来源里没有任何行,之后出现的行全部属于本次接收。
     * 厂商侧测试编号不参与边界判定,只写进来源行身份;接收边界是本系统的接收边界。
     * 若测试期间分表滚动,两张表都会被读取,避免滚动点整段缺失。
     */
    @Override
    public List<CabinetRealtime> samplesSince(ChannelIdentity channel, SampleWatermark watermark)
    {
        if (watermark == null) return List.of();
        ChannelKey key = channelKey(channel);
        VendorTest latest = latestTest(key);
        if (latest == null) return List.of();
        boolean bounded = watermark.deviceTime() != null;
        String rawWatermark = bounded ? deviceTimeText(watermark.deviceTime()) : null;
        List<SampleRow> rows = new ArrayList<>();
        for (String table : latest.tables())
        {
            String predicate = bounded
                    ? "and (test_atime > ? or (test_atime = ? and seq_id > ?)) "
                    : "";
            Object[] parameters = bounded
                    ? new Object[] { key.unitId(), key.chlId(), rawWatermark, rawWatermark, watermark.sequenceId() }
                    : new Object[] { key.unitId(), key.chlId() };
            rows.addAll(query("select test_id, seq_id, cycle, step_id, step_type, test_time, test_vol, test_cur, "
                    + "test_capchg, test_engchg, test_capdchg, test_engdchg, test_atime from " + quote(table)
                    + " where unit_id = ? and chl_id = ? " + predicate
                    + "order by test_atime, seq_id limit " + SAMPLE_BATCH_LIMIT,
                    this::readSampleRows, parameters));
        }
        rows.sort(Comparator.comparing(SampleRow::deviceTime).thenComparingLong(SampleRow::sequenceId));
        return rows.stream().map(this::cabinetRealtime).toList();
    }
 
    // ------------------------------------------------------------------ 来源读取
 
    private List<ChannelKey> readRegisteredChannels(ResultSet rows) throws SQLException
    {
        List<ChannelKey> channels = new ArrayList<>();
        while (rows.next())
            channels.add(new ChannelKey(rows.getLong("dev_uid"), rows.getInt("unit_id"), rows.getInt("chl_id")));
        return channels;
    }
 
    /** 按起始时间取每台设备每个通道的最新测试;test_id 实测不单调,不能用来排序。 */
    private Map<ChannelKey, VendorTest> latestTests(long devUid)
    {
        return query("select unit_id, chl_id, test_id, main_first_table, main_second_table from test "
                + "where dev_uid = ? and start_time = (select max(start_time) from test newer "
                + "where newer.dev_uid = test.dev_uid and newer.unit_id = test.unit_id and newer.chl_id = test.chl_id) "
                + "order by unit_id, chl_id, test_id", rows -> {
                    Map<ChannelKey, VendorTest> result = new LinkedHashMap<>();
                    while (rows.next())
                    {
                        ChannelKey key = new ChannelKey(devUid, rows.getInt("unit_id"), rows.getInt("chl_id"));
                        result.putIfAbsent(key, vendorTest(rows.getLong("test_id"),
                                rows.getString("main_first_table"), rows.getString("main_second_table")));
                    }
                    return result;
                }, devUid);
    }
 
    private VendorTest latestTest(ChannelKey key)
    {
        List<VendorTest> tests = query("select test_id, main_first_table, main_second_table from test "
                + "where dev_uid = ? and unit_id = ? and chl_id = ? order by start_time desc limit 1",
                rows -> rows.next() ? List.of(vendorTest(rows.getLong("test_id"), rows.getString("main_first_table"),
                        rows.getString("main_second_table"))) : List.<VendorTest>of(),
                key.devUid(), key.unitId(), key.chlId());
        return tests.isEmpty() ? null : tests.get(0);
    }
 
    /** 测试数据可能跨分表滚动,两张表都要看;优先取第二张表,滚动后它就是最新的那张。 */
    private SampleRow lastSample(ChannelKey key, VendorTest test)
    {
        List<String> tables = new ArrayList<>(test.tables());
        Collections.reverse(tables);
        for (String table : tables)
        {
            List<SampleRow> rows = query("select test_id, seq_id, cycle, step_id, step_type, test_time, test_vol, "
                    + "test_cur, test_capchg, test_engchg, test_capdchg, test_engdchg, test_atime from " + quote(table)
                    + " where unit_id = ? and chl_id = ? and test_id = ? order by seq_id desc limit 1",
                    this::readSampleRows, key.unitId(), key.chlId(), test.testId());
            if (!rows.isEmpty()) return rows.get(0);
        }
        return null;
    }
 
    /** 最近数据时间来自采样行本身:没有新采样行就是没有,不看结束时间也不看通道水位线列。 */
    private Instant cachedLastSampleAt(ChannelKey key, VendorTest test, Instant now)
    {
        String cacheKey = key.devUid() + ":" + key.unitId() + ":" + key.chlId() + ":" + test.testId();
        CachedLastData cached = lastDataCache.get(cacheKey);
        if (cached != null && now.isBefore(cached.expiresAt())) return cached.lastDataAt();
        SampleRow row = lastSample(key, test);
        Instant lastDataAt = row == null ? null : row.deviceTime();
        if (lastDataCacheMillis > 0)
            lastDataCache.put(cacheKey, new CachedLastData(lastDataAt, now.plusMillis(lastDataCacheMillis)));
        return lastDataAt;
    }
 
    private List<SampleRow> readSampleRows(ResultSet rows) throws SQLException
    {
        List<SampleRow> result = new ArrayList<>();
        while (rows.next())
        {
            String raw = rows.getString("test_atime");
            LocalDateTime deviceTime = parseDeviceTime(raw);
            result.add(new SampleRow(rows.getLong("test_id"), 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"), raw, deviceTime.atZone(deviceZone).toInstant()));
        }
        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)
    {
        // 充放电容量按电流方向取对应的一组:设备界面对正在放电的通道显示放电容量。
        boolean charging = row.current().signum() >= 0;
        BigDecimal capacity = charging ? row.capacityCharge() : row.capacityDischarge();
        BigDecimal energy = charging ? row.energyCharge() : row.energyDischarge();
        return new CabinetRealtime(row.sequenceId(), row.deviceTime(), scaled(row.current(), VOLTAGE_CURRENT_DIVISOR),
                scaled(row.voltage(), VOLTAGE_CURRENT_DIVISOR), scaled(capacity, CAPACITY_ENERGY_DIVISOR),
                scaled(energy, CAPACITY_ENERGY_DIVISOR), row.cycle(), row.stepId(), null, row.stepType(),
                clock.instant(), row.deviceTimeRaw(), deviceZone.getId(),
                row.testTimeMillis() == null ? null : row.testTimeMillis() / 1000,
                "neware:" + row.testId() + ":" + row.sequenceId());
    }
 
    private RealtimeSnapshot snapshot(ChannelIdentity channel, CabinetRealtime latest)
    {
        // 读库路线下通道工作状态不可观测(ADR-0063):不按数据行推断,也不沿用上一次状态。
        return new RealtimeSnapshot(channel, DeviceWorkStatus.UNOBSERVABLE, latest.cycleNo(), latest.stepNo(),
                latest.stepType(), null, BigDecimal.valueOf(latest.currentA()), BigDecimal.valueOf(latest.voltageV()),
                BigDecimal.valueOf(latest.capacityAh()), BigDecimal.valueOf(latest.energyWh()), null,
                latest.relativeTimeSeconds(), null, latest.serverReceivedAt(), Map.of(),
                DeviceConnectionStatus.ONLINE, latest.deviceTime(), List.of(), null);
    }
 
    private static double scaled(BigDecimal value, BigDecimal divisor)
    {
        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)
    {
        // 设备号编码 devtype × 10000 + devid;unit_id 是该柜内的单元,作为 subdevid 保留。
        return new ChannelIdentity(config.adapterCode(), (int) (key.devUid() / 10000),
                (int) (key.devUid() % 10000), key.unitId(), key.chlId());
    }
 
    private ChannelKey channelKey(ChannelIdentity channel)
    {
        if (!config.adapterCode().equals(channel.adapterCode()))
            throw new DeviceAdapterException("通道身份不属于本实例:" + channel.adapterCode());
        return new ChannelKey(channel.devtype() * 10000L + channel.devid(), channel.subdevid(), channel.chlid());
    }
 
    private static VendorTest vendorTest(long testId, String firstTable, String secondTable)
    {
        List<String> tables = new ArrayList<>(2);
        if (isSafeTable(firstTable)) tables.add(firstTable.trim());
        if (isSafeTable(secondTable) && !tables.contains(secondTable.trim())) tables.add(secondTable.trim());
        return new VendorTest(testId, tables);
    }
 
    private static boolean isSafeTable(String table)
    {
        return table != null && SAFE_TABLE_NAME.matcher(table.trim()).matches();
    }
 
    private static String quote(String table)
    {
        // 表名来自厂商库自身的 test 记录,必须先按白名单校验再拼接,避免把库内容当 SQL。
        if (!isSafeTable(table)) throw new DeviceAdapterException("来源表名不合法:" + table);
        return "`" + table.trim() + "`";
    }
 
    private LocalDateTime parseDeviceTime(String raw)
    {
        if (raw == null || raw.isBlank())
            throw new DeviceAdapterException("PARSE_FAILED", "设备时间为空,无法解释为设备事实", null);
        String normalized = raw.trim().replace('T', ' ');
        if (normalized.endsWith("Z")) normalized = normalized.substring(0, normalized.length() - 1).trim();
        try
        {
            return LocalDateTime.parse(normalized, DEVICE_TIME);
        }
        catch (RuntimeException exception)
        {
            throw new DeviceAdapterException("PARSE_FAILED", "设备时间无法解释:" + raw, exception);
        }
    }
 
    private String deviceTimeText(Instant instant)
    {
        return instant.atZone(deviceZone).toLocalDateTime().format(DEVICE_TIME_TEXT);
    }
 
    // ------------------------------------------------------------------ JDBC
 
    /**
     * 厂商数据源交出的是长期复用的一条连接,这里只关闭语句与结果集,不关闭连接本身;
     * 查询失败时作废连接,下一轮取数重新建立。
     */
    private <T> T query(String sql, ResultSetReader<T> reader, Object... parameters)
    {
        try
        {
            Connection connection = dataSource.getConnection();
            try (PreparedStatement statement = connection.prepareStatement(sql))
            {
                // 读取超时有两层:连接串的 socketTimeout 管单次读,这里给整条语句一个截止时间。
                statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS);
                for (int index = 0; index < parameters.length; index++)
                    statement.setObject(index + 1, parameters[index]);
                try (ResultSet rows = statement.executeQuery())
                {
                    return reader.read(rows);
                }
            }
        }
        catch (NonQueryStatementException exception)
        {
            throw exception;
        }
        catch (SQLException exception)
        {
            connectionReset.run();
            throw new DeviceAdapterException("CONNECTION_FAILED",
                    "厂商库读取失败:" + config.host() + ":" + config.port() + ",原因:" + exception.getMessage(),
                    exception);
        }
    }
 
    private interface ResultSetReader<T>
    {
        T read(ResultSet rows) throws SQLException;
    }
 
    private record ChannelKey(long devUid, int unitId, int chlId) { }
 
    private record VendorTest(long testId, List<String> tables) { }
 
    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,
            String deviceTimeRaw, Instant deviceTime) { }
}