yeg
2026-09-29 b78295e8da96c3e5e76befeef9582ce5273dbb74
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
package com.battery.business.web;
 
import static org.junit.jupiter.api.Assertions.*;
import java.util.List;
import java.util.Map;
import java.util.LinkedHashMap;
import java.util.UUID;
import com.alibaba.fastjson2.JSON;
import com.battery.business.domain.TTestDataRevisionAction;
import com.battery.business.mapper.TTestMapper;
import com.battery.business.service.ITestConsoleDataService;
import com.battery.business.service.impl.TestConsoleDataServiceImpl;
import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.test.util.ReflectionTestUtils;
 
/** 使用生产 Mapper 和 Spring 事务验证导入;内存数据库不连接共享开发库。 */
public class ConsoleSingleSheetImportDatabaseTest
{
    private java.sql.Connection keepAlive;
    private TTestController controller;
    private TTestMapper mapper;
    private ITestConsoleDataService service;
    private JdbcTemplate jdbc;
 
    @BeforeEach void setUp() throws Exception
    {
        var source = new UnpooledDataSource("org.h2.Driver", "jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE", "sa", "");
        keepAlive = source.getConnection();
        jdbc = new JdbcTemplate(source);
        // H2 无 MySQL JSON_LENGTH;仅在测试中适配计数,真实 SQL 另在 MySQL 验证。
        jdbc.execute("create alias JSON_LENGTH for 'com.battery.business.web.ConsoleSingleSheetImportDatabaseTest.jsonLength'");
        jdbc.execute("create table sys_user(user_id bigint, nick_name varchar(64), del_flag varchar(1))");
        jdbc.execute("create table t_test_type(test_type_id bigint, test_type_name varchar(64), del_flag int)");
        jdbc.execute("create table t_test(test_id bigint primary key, test_name varchar(100), creator_id bigint, principal_id bigint, test_type_id bigint, test_purpose varchar(100), test_status varchar(20), reception_status varchar(20), monitor_timeout_minutes int, started_at_utc timestamp, reception_boundary_at_utc timestamp, ended_at_utc timestamp, del_flag int default 0, create_by varchar(64), create_time timestamp, update_by varchar(64), update_time timestamp, remark varchar(100), logic_step_mappings_json varchar(100), console_detail_json varchar(100))");
        jdbc.execute("insert into t_test(test_id,test_name,test_status,reception_status) values(1,'导入测试','COMPLETED','ENDED')");
        jdbc.execute("create table t_test_battery(test_battery_id bigint primary key, test_id bigint, battery_no varchar(100), del_flag int default 0)");
        jdbc.execute("insert into t_test_battery values(4,1,'BAT-1',0)");
        jdbc.execute("create table t_test_channel_binding(binding_id bigint, test_battery_id bigint, channel_id bigint)");
        jdbc.execute("insert into t_test_channel_binding values(5,4,11)");
        jdbc.execute("create table t_detection_channel(channel_id bigint, cabinet_id bigint, devtype int, devid int, subdevid int, chlid int)");
        jdbc.execute("insert into t_detection_channel values(11,1,27,1,1,2)");
        jdbc.execute("create table t_detection_cabinet(cabinet_id bigint, adapter_code varchar(64))");
        jdbc.execute("insert into t_detection_cabinet values(1,'adapter-a')");
        jdbc.execute("create table t_test_data_revision_action(action_id bigint auto_increment primary key, test_id bigint, data_type varchar(20), operation_type varchar(20), scope_summary varchar(200), identity_summary json, before_content_json json, changes_summary json, reason varchar(200), operator_id bigint, operator_name varchar(64), created_at_utc timestamp)");
        jdbc.execute("alter table t_test_data_revision_action add constraint chk_data_revision_type check (data_type in ('REALTIME','STEP','CYCLE'))");
        org.springframework.jdbc.datasource.init.ScriptUtils.executeSqlScript(keepAlive,
                new ClassPathResource("db/migration-final/V9__workbook_import_revision.sql"));
        String common = "test_id bigint not null, test_battery_id bigint not null, binding_id bigint not null, adapter_code varchar(64) not null, source_identity varchar(256) not null, source_record_id varchar(128), server_received_at_utc timestamp(3), cycle_no int, content_sha256 binary(32) not null, extension_measurements_json varchar(2000)";
        String metrics = "step_no int, step_name varchar(191), step_type varchar(64), voltage_v decimal(30,12), current_a decimal(30,12), capacity_ah decimal(30,12), energy_wh decimal(30,12) check (energy_wh <> 999)";
        jdbc.execute("create table t_test_realtime_record(record_id bigint auto_increment primary key," + common + "," + metrics + ", cabinet_devtype int, cabinet_devid int, cabinet_subdevid int, cabinet_chlid int, cabinet_sequence_id bigint, cabinet_device_time_utc timestamp(3), cabinet_device_time_raw varchar(64), cabinet_adapter_timezone_id varchar(64), capture_slot_utc timestamp(3) not null, temperature decimal(30,12), positive_potential decimal(30,12), negative_potential decimal(30,12), collector_id bigint, collector_channel_code varchar(100), collector_device_time_utc timestamp(3), unique(test_id,test_battery_id,capture_slot_utc))");
        // 从 V7 的非空槽位起点执行生产迁移,覆盖存量库升级路径。
        org.springframework.jdbc.datasource.init.ScriptUtils.executeSqlScript(keepAlive,
                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 + ",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"));
        mapper = new SqlSessionTemplate(factory.getObject()).getMapper(TTestMapper.class);
        var proxy = new ProxyFactory(new TestConsoleDataServiceImpl(mapper));
        proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource()));
        service = (ITestConsoleDataService) proxy.getProxy();
        controller = new TTestController() {
            @Override public Long getUserId() { return 1L; }
            @Override public String getUsername() { return "tester"; }
        };
        ReflectionTestUtils.setField(controller, "consoleDataService", service);
    }
 
    @AfterEach void closeDatabase() throws Exception { if (keepAlive != null) keepAlive.close(); }
 
    /** 模拟 JSON 数组长度;H2 的 JSON 参数可能多包一层字符串。 */
    public static Integer jsonLength(String value)
    {
        if (value == null) return null;
        Object parsed = JSON.parse(value);
        if (parsed instanceof String text) parsed = JSON.parse(text);
        return parsed instanceof List<?> rows ? rows.size() : 1;
    }
 
    @Test void threeTypesPersistActualBindingsTimesAndNumbersAndSkipRetries()
    {
        for (String type : List.of("REALTIME", "STEP", "CYCLE"))
        {
            assertEquals(1, submit(type, List.of(row())).get("importedCount"));
            assertEquals(1, submit(type, List.of(row())).get("skippedCount"));
            var records = mapper.selectImportDataRecords(1L, type);
            assertEquals(1, records.size());
            assertEquals("17", records.get(0).get("source_record_id"));
            assertEquals("adapter-a:27:1:1:2", records.get(0).get("source_identity"));
            String timeColumn = type.equals("REALTIME") ? "cabinet_device_time_raw" : "device_time_raw";
            assertEquals("2023-05-08 11:42:11", records.get(0).get(timeColumn));
            String storedTimeColumn = type.equals("REALTIME") ? "cabinet_device_time_utc" : "device_time_utc";
            assertEquals(1683517331000L, ((java.util.Date) records.get(0).get(storedTimeColumn)).getTime());
        }
        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()
    {
        var failing = row(); failing.put("recordNo", "18"); failing.put("recordedAt", 1683517391000L); failing.put("energy", 999);
        assertThrows(RuntimeException.class, () -> submit("REALTIME", List.of(row(), failing)));
        assertTrue(mapper.selectImportDataRecords(1L, "REALTIME").isEmpty());
        assertTrue(service.selectActions(new TTestDataRevisionAction()).isEmpty());
    }
 
    @Test void importAndDeleteDetailsReturnOnlySummaryWhileAuditRemainsStored() throws Exception
    {
        for (String operation : List.of("IMPORT", "DELETE"))
        {
            var action = new TTestDataRevisionAction();
            action.setTestId(1L); action.setDataType("REALTIME"); action.setOperationType(operation);
            action.setScopeSummary("批量8000条"); action.setReason("摘要查询验证");
            action.setIdentitySummary(JSON.toJSONString(java.util.stream.IntStream.range(0, 8000).mapToObj(i -> "realtime:" + i).toList()));
            action.setChangesSummary(JSON.toJSONString(java.util.Collections.nCopies(8000, Map.of("voltage", 1.5))));
            action.setBeforeContentJson(action.getChangesSummary());
            mapper.insertDataRevisionAction(action);
            var summary = service.selectActionById(action.getActionId());
            assertNull(summary.getIdentitySummary(), "详情查询不得将批量身份 JSON 加载到服务层");
            assertNull(summary.getChangesSummary(), "详情查询不得将批量变化 JSON 加载到服务层");
            assertNull(summary.getBeforeContentJson(), "详情查询不得加载完整审计快照");
            var details = new TTestDataRevisionController();
            ReflectionTestUtils.setField(details, "service", service);
            var json = new com.fasterxml.jackson.databind.ObjectMapper();
            String response = json.writeValueAsString(details.detail(action.getActionId()));
            assertEquals(8000, json.readTree(response).get("data").get("affectedRecordCount").intValue());
            assertTrue(response.length() < 1500, "摘要响应不能随批量明细膨胀");
            // 展示裁剪只发生在 SELECT;数据库审计原文仍完整保留。
            assertTrue(jdbc.queryForObject("select length(cast(changes_summary as varchar)) from t_test_data_revision_action where action_id=?", Integer.class, action.getActionId()) > 100000);
        }
        var list = service.selectActions(new TTestDataRevisionAction());
        assertEquals(2, list.size());
        assertTrue(list.stream().allMatch(row -> row.getIdentitySummary() == null && row.getChangesSummary() == null && row.getBeforeContentJson() == null));
    }
 
    @Test void stepMedianVoltagesPersistAndRemainIndependentOfLegacyPeaks()
    {
        var imported = row();
        imported.put("chargeMedianVoltage", 0);
        imported.put("dischargeMedianVoltage", 1.1709);
        submit("STEP", List.of(imported));
        var stored = mapper.selectImportDataRecords(1L, "STEP").get(0);
        var extension = JSON.parseObject(stored.get("extension_measurements_json").toString());
        assertNotNull(extension.getBigDecimal("chargeMedianVoltage"));
        assertEquals(0, extension.getBigDecimal("chargeMedianVoltage").signum());
        assertEquals(new java.math.BigDecimal("1.1709"), extension.getBigDecimal("dischargeMedianVoltage"));
        assertEquals(1, submit("STEP", List.of(imported)).get("skippedCount"));
 
        // 旧峰谷值仍是独立事实;不能在查询时作为缺失中值的回退来源。
        var queryMapper = org.mockito.Mockito.mock(TTestMapper.class);
        var query = new com.battery.business.service.impl.TestDataQueryServiceImpl(queryMapper,
                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}"));
        // 设备工步层的四组容量能量是独立事实:查询要交出它们,供方向未确认的工步核对。
        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("冲突拒绝"));
        assertEquals(1, service.selectActions(new TTestDataRevisionAction()).size());
    }
 
    @Test void workbookImportsThreeSheetsWithSeparateRevisionsAndSkipsRetry()
    {
        var sheets = Map.of("REALTIME", List.of(row(), row()), "STEP", List.of(row()), "CYCLE", List.of(row()));
        var result = submitWorkbook(sheets);
        assertEquals(3, result.get("importedCount"));
        assertEquals(1, result.get("skippedCount"));
        assertNotNull(result.get("actionId"));
        for (String type : List.of("REALTIME", "STEP", "CYCLE"))
        {
            assertEquals(1, mapper.selectImportDataRecords(1L, type).size());
            var query = new TTestDataRevisionAction(); query.setDataType(type);
            assertEquals(1, service.selectActions(query).size());
        }
        var actions = service.selectActions(new TTestDataRevisionAction());
        assertEquals(3, actions.size());
        assertEquals(3, ((List<?>) result.get("actionIds")).size());
        assertEquals(java.util.Set.of("REALTIME", "STEP", "CYCLE"), actions.stream().map(TTestDataRevisionAction::getDataType).collect(java.util.stream.Collectors.toSet()));
        for (var revision : actions)
        {
            String label = switch (revision.getDataType()) { case "REALTIME" -> "实时"; case "STEP" -> "工步"; default -> "循环"; };
            assertEquals("导入1条" + label + "数据", revision.getScopeSummary());
            assertEquals(1L, service.selectActionById(revision.getActionId()).getAffectedRecordCount());
        }
        assertEquals("三页验收", actions.get(0).getReason());
        var retry = submitWorkbook(sheets);
        assertEquals(0, retry.get("importedCount"));
        assertEquals(4, retry.get("skippedCount"));
        assertNull(retry.get("actionId"));
        assertEquals(List.of(), retry.get("actionIds"));
        assertEquals(3, service.selectActions(new TTestDataRevisionAction()).size());
    }
 
    private com.ruoyi.common.core.domain.AjaxResult submitWorkbook(Map<String, List<Map<String, Object>>> sheets)
    {
        return controller.dataAction(1L, new TTestController.ConsoleDataActionRequest("ALL", "IMPORT", "三 Sheet 导入", "[]",
                JSON.toJSONString(sheets), "三页验收", List.of()));
    }
 
    @Test void workbookLateDatabaseFailureRollsBackAllSheetsAndRevision()
    {
        var cycle = row(); cycle.put("dischargeEnergy", 999);
        jdbc.execute("alter table t_test_cycle_record add check (discharge_energy_wh <> 999)");
        assertThrows(RuntimeException.class, () -> submitWorkbook(Map.of("REALTIME", List.of(row()), "STEP", List.of(row()), "CYCLE", List.of(cycle))));
        assertWorkbookEmpty();
    }
 
    @Test void workbookRevisionFailureRollsBackEarlierRevisionsAndAllData()
    {
        jdbc.execute("alter table t_test_data_revision_action add check (data_type <> 'CYCLE')");
        assertThrows(RuntimeException.class, () -> submitWorkbook(Map.of("REALTIME", List.of(row()), "STEP", List.of(row()), "CYCLE", List.of(row()))));
        assertWorkbookEmpty();
    }
 
    @Test void partiallyRepeatedWorkbookRecordsActualPerTypeCounts()
    {
        submit("REALTIME", List.of(row()));
        var result = submitWorkbook(Map.of("REALTIME", List.of(row()), "STEP", List.of(row()), "CYCLE", List.of(row())));
        assertEquals(2, result.get("importedCount"));
        assertEquals(1, result.get("skippedCount"));
        assertEquals(3, ((List<?>) result.get("actionIds")).size());
        var realtime = new TTestDataRevisionAction(); realtime.setDataType("REALTIME");
        var revisions = service.selectActions(realtime);
        assertEquals(2, revisions.size());
        assertEquals("导入0条实时数据", revisions.get(0).getScopeSummary());
        assertEquals(0L, service.selectActionById(revisions.get(0).getActionId()).getAffectedRecordCount());
        var invalid = new TTestDataRevisionAction(); invalid.setDataType("ALL");
        assertThrows(RuntimeException.class, () -> service.selectActions(invalid));
    }
 
    @Test void workbookMissingEmptyInvalidOrConflictingSheetNeverWritesAnySheet()
    {
        var sheets = new LinkedHashMap<String, List<Map<String, Object>>>();
        sheets.put("REALTIME", List.of(row())); sheets.put("STEP", List.of(row()));
        assertThrows(RuntimeException.class, () -> submitWorkbook(sheets));
        assertWorkbookEmpty();
        sheets.put("CYCLE", List.of());
        assertThrows(RuntimeException.class, () -> submitWorkbook(sheets));
        assertWorkbookEmpty();
        var invalid = row(); invalid.put("cycleNo", 0); invalid.put("sourceRow", 23);
        sheets.put("CYCLE", List.of(invalid));
        assertTrue(assertThrows(RuntimeException.class, () -> submitWorkbook(sheets)).getMessage().contains("循环工作表,第23行"));
        assertWorkbookEmpty();
        var conflict = row(); conflict.put("dischargeCapacity", 88);
        sheets.put("CYCLE", List.of(row(), conflict));
        assertTrue(assertThrows(RuntimeException.class, () -> submitWorkbook(sheets)).getMessage().contains("冲突拒绝"));
        assertWorkbookEmpty();
    }
 
    @Test void workbookRejectsDifferentBatterySetsEvenWhenBothBatteriesBelongToTest()
    {
        jdbc.execute("insert into t_test_battery values(6,1,'BAT-2',0)");
        jdbc.execute("insert into t_test_channel_binding values(7,6,11)");
        var other = row(); other.put("batteryNo", "BAT-2");
        var sheets = Map.of("REALTIME", List.of(row()), "STEP", List.of(row()), "CYCLE", List.of(other));
        assertTrue(assertThrows(RuntimeException.class, () -> submitWorkbook(sheets)).getMessage().contains("电池号集合不一致"));
        assertWorkbookEmpty();
        other.put("batteryNo", "OTHER");
        assertTrue(assertThrows(RuntimeException.class, () -> submitWorkbook(sheets)).getMessage().contains("不属于当前测试"));
        assertWorkbookEmpty();
    }
 
    @Test void workbookConflictWithExistingCyclePreservesFactsAndRejectsOtherSheets()
    {
        submit("CYCLE", List.of(row()));
        var conflict = row(); conflict.put("dischargeCapacity", 88);
        assertThrows(RuntimeException.class, () -> submitWorkbook(Map.of("REALTIME", List.of(row()), "STEP", List.of(row()), "CYCLE", List.of(conflict))));
        assertTrue(mapper.selectImportDataRecords(1L, "REALTIME").isEmpty());
        assertTrue(mapper.selectImportDataRecords(1L, "STEP").isEmpty());
        assertEquals(1, mapper.selectImportDataRecords(1L, "CYCLE").size());
        assertEquals(1, service.selectActions(new TTestDataRevisionAction()).size());
    }
 
    @Test void workbookCannotImportBeforeTestCompletionOrWithoutReason()
    {
        var sheets = Map.of("REALTIME", List.of(row()), "STEP", List.of(row()), "CYCLE", List.of(row()));
        assertThrows(RuntimeException.class, () -> controller.dataAction(1L, new TTestController.ConsoleDataActionRequest("ALL", "IMPORT", "三 Sheet 导入", "[]", JSON.toJSONString(sheets), " ", List.of())));
        jdbc.update("update t_test set test_status='RUNNING', reception_status='RECEIVING' where test_id=1");
        assertThrows(RuntimeException.class, () -> submitWorkbook(sheets));
        assertWorkbookEmpty();
    }
 
    @Test void workbookRejectsExistingExtraStepMeasurementInsteadOfSkippingIt()
    {
        submit("STEP", List.of(row()));
        jdbc.update("update t_test_step_record set extension_measurements_json=?", "{\"stepDuration\":3752,\"auxTemperature\":99}");
        var sheets = Map.of("REALTIME", List.of(row()), "STEP", List.of(row()), "CYCLE", List.of(row()));
        assertTrue(assertThrows(RuntimeException.class, () -> submitWorkbook(sheets)).getMessage().contains("冲突拒绝"));
        assertTrue(mapper.selectImportDataRecords(1L, "REALTIME").isEmpty());
        assertTrue(mapper.selectImportDataRecords(1L, "CYCLE").isEmpty());
        assertEquals(1, service.selectActions(new TTestDataRevisionAction()).size());
    }
 
    private void assertWorkbookEmpty()
    {
        for (String type : List.of("REALTIME", "STEP", "CYCLE")) assertTrue(mapper.selectImportDataRecords(1L, type).isEmpty());
        assertTrue(service.selectActions(new TTestDataRevisionAction()).isEmpty());
    }
 
    @Test void differentRecordsInTheSameSecondPreserveDeviceTime()
    {
        var next = row(); next.put("recordNo", "18"); next.put("sequenceId", 18); next.put("stepNo", 2);
        assertEquals(2, submit("REALTIME", List.of(row(), next)).get("importedCount"));
        assertEquals(2, submit("REALTIME", List.of(row(), next)).get("skippedCount"));
        var records = mapper.selectImportDataRecords(1L, "REALTIME");
        assertEquals(2, records.size());
        for (var record : records) {
            assertNull(record.get("capture_slot_utc"));
            assertEquals(1683517331000L, ((java.util.Date) record.get("cabinet_device_time_utc")).getTime());
        }
    }
 
    @Test
    @org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable(named = "TICKET03_SAMPLE_JSON", matches = ".+")
    void suppliedPartyWorkbookImportsAllRowsAndSkipsEveryRetry() throws Exception
    {
        var sample = JSON.parseObject(java.nio.file.Files.readString(java.nio.file.Path.of(System.getenv("TICKET03_SAMPLE_JSON"))));
        Map<String, List<Map<String, Object>>> sheets = new LinkedHashMap<>();
        for (String type : List.of("REALTIME", "STEP", "CYCLE")) sheets.put(type, (List) sample.getJSONArray(type).toJavaList(Map.class));
        assertEquals(7767, submitWorkbook(sheets).get("importedCount"));
        assertEquals(7767, submitWorkbook(sheets).get("skippedCount"));
        for (String type : List.of("REALTIME", "STEP", "CYCLE"))
        {
            List<Map<String, Object>> rows = (List) sample.getJSONArray(type).toJavaList(Map.class);
            int count = switch (type) { case "REALTIME" -> 7605; case "STEP" -> 129; default -> 33; };
            assertEquals(count, mapper.selectImportDataRecords(1L, type).size());
            var query = new TTestDataRevisionAction(); query.setDataType(type);
            var actions = service.selectActions(query);
            assertEquals(1, actions.size());
            assertEquals((long) count, service.selectActionById(actions.get(0).getActionId()).getAffectedRecordCount());
            String label = switch (type) { case "REALTIME" -> "实时"; case "STEP" -> "工步"; default -> "循环"; };
            assertEquals("导入" + count + "条" + label + "数据", actions.get(0).getScopeSummary());
            if ("STEP".equals(type) || "REALTIME".equals(type))
            {
                var persisted = mapper.selectImportDataRecords(1L, type).stream().collect(java.util.stream.Collectors.toMap(
                        record -> record.get("source_record_id").toString(), record -> record));
                for (var input : rows)
                {
                    var stored = JSON.parseObject(persisted.get(input.get("recordNo").toString()).get("extension_measurements_json").toString());
                    for (String field : "STEP".equals(type) ? List.of("stepDuration", "chargeMedianVoltage", "dischargeMedianVoltage") : List.of("relativeTimeSeconds"))
                        if (input.get(field) == null) assertNull(stored.get(field));
                        else assertEquals(0, new java.math.BigDecimal(input.get(field).toString()).compareTo(stored.getBigDecimal(field)), field);
                }
            }
        }
        assertEquals(3, service.selectActions(new TTestDataRevisionAction()).size());
    }
 
    @Test void realtimeRelativeTimeSurvivesImportQueryAndDuplicateValidation()
    {
        var input = row(); input.put("relativeTimeSeconds", 3752.125);
        submit("REALTIME", List.of(input));
        var stored = mapper.selectImportDataRecords(1L, "REALTIME").get(0);
        assertNotNull(stored.get("extension_measurements_json"));
        var extension = JSON.parseObject(stored.get("extension_measurements_json").toString());
        assertEquals(0, new java.math.BigDecimal("3752.125").compareTo(extension.getBigDecimal("relativeTimeSeconds")));
        var queryMapper = org.mockito.Mockito.mock(TTestMapper.class);
        var query = new com.battery.business.service.impl.TestDataQueryServiceImpl(queryMapper,
                org.mockito.Mockito.mock(com.battery.business.service.ITTestService.class));
        var current = new LinkedHashMap<String, Object>(); current.put("extensionMeasurementsJson", extension.toJSONString());
        var legacy = new LinkedHashMap<String, Object>();
        org.mockito.Mockito.when(queryMapper.selectPersistedFinalRealtimeRecords(1L)).thenReturn(List.of(current, legacy));
        assertEquals(new java.math.BigDecimal("3752.125"), query.records(1L).get(0).get("relativeTimeSeconds"));
        assertNull(query.records(1L).get(1).get("relativeTimeSeconds"));
        assertEquals(1, submit("REALTIME", List.of(input)).get("skippedCount"));
        input.put("relativeTimeSeconds", 3752.126);
        assertThrows(RuntimeException.class, () -> submit("REALTIME", List.of(input)));
    }
 
    private Map<String, Object> row()
    {
        var row = new LinkedHashMap<String, Object>(Map.of("recordNo", "17", "batteryNo", "BAT-1", "recordedAt", 1683517331000L,
                "cycleNo", 1, "stepNo", 1, "stepName", "恒流放电", "sequenceId", 17, "capacity", 8.5122));
        row.put("deviceTimeRaw", "2023-05-08 11:42:11"); row.put("adapterTimezoneId", "Asia/Hong_Kong"); row.put("stepDuration", 3752);
        return row;
    }
 
    private com.ruoyi.common.core.domain.AjaxResult submit(String type, List<Map<String, Object>> rows)
    {
        return controller.dataAction(1L, new TTestController.ConsoleDataActionRequest(type, "IMPORT", "单页导入", "[]",
                JSON.toJSONString(rows), "开发测试", List.of()));
    }
}