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
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
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
package com.battery.business.service.impl;
 
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.battery.business.domain.TTest;
import com.battery.business.domain.TTestBattery;
import com.battery.business.domain.CurrentBatteryField;
import com.battery.business.domain.TTestChannelBinding;
import com.battery.business.domain.TTestCollectorChannel;
import com.battery.business.domain.TTestWarningThreshold;
import com.battery.business.domain.TTestLogicStepMapping;
import com.battery.business.domain.TTestConsoleDetail;
import com.battery.business.domain.TDetectionChannel;
import com.battery.business.domain.vo.TestConfigurationOptionsVo;
import com.battery.business.mapper.TTestMapper;
import com.battery.business.service.ITTestService;
 
/** 测试配置服务,负责状态边界、关系校验、事务写入和只追加审计。 */
@Service
public class TTestServiceImpl implements ITTestService
{
    private static final String WAITING = "WAITING";
    private static final String NOT_STARTED = "NOT_STARTED";
    /** 新流程只允许四种测试状态;历史状态仅在旧数据展示层做兼容映射。 */
    private static final Set<String> TEST_STATUSES = Set.of(WAITING, "RUNNING", "PAUSED", "COMPLETED");
    private static final Set<String> RECEPTION_STATUSES = Set.of(NOT_STARTED, "RECEIVING", "PAUSED", "ENDED");
    private static final Set<String> THRESHOLD_UNITS = Set.of("A", "V", "Ah", "Wh", "℃");
    private static final Set<String> REQUIRED_COLLECTOR_MEASUREMENTS = Set.of(
            "PRIMARY_TEMPERATURE", "PRIMARY_POSITIVE_POTENTIAL", "PRIMARY_NEGATIVE_POTENTIAL");
 
    @Autowired
    private TTestMapper testMapper;
 
    /**
     * 按测试主键读取测试配置,同时装配电池号、阈值、通道绑定等配置关系。
     *
     * @param testId 测试主键
     * @return 测试配置及其业务状态
     * @throws ServiceException 测试ID为空或测试不存在
     */
    @Override
    public TTest selectTestById(Long testId)
    {
        if (testId == null) throw new ServiceException("测试ID不能为空");
        TTest test = testMapper.selectTestById(testId);
        if (test == null) throw new ServiceException("测试不存在");
        loadConfiguration(test);
        return test;
    }
 
    /**
     * 按正式测试状态、接收状态及配置条件查询测试列表。
     *
     * <p>查询条件中的测试状态与接收状态都须是现行取值,历史兼容状态不参与筛选。</p>
     *
     * @param test 查询条件;为空时按无条件查询处理
     * @return 测试列表,含各测试绑定的电池号
     * @throws ServiceException 传入的测试状态或接收状态不是现行取值
     */
    @Override
    public List<TTest> selectTestList(TTest test)
    {
        if (test == null) test = new TTest();
        test.setTestName(trimToNull(test.getTestName()));
        test.setBatteryNo(trimToNull(test.getBatteryNo()));
        validateQueryStatus(test.getTestStatus(), TEST_STATUSES, "测试状态无效");
        validateQueryStatus(test.getReceptionStatus(), RECEPTION_STATUSES, "接收状态无效");
        List<TTest> tests = mutable(testMapper.selectTestList(test));
        populateBatteryNos(tests);
        return tests;
    }
 
    /**
     * 返回创建或编辑测试时可选的来源:启用用户、启用测试类型、启用电池组的电池号、已正式登记的通道与采集器。
     *
     * @return 测试配置选项,五类选项都可能为空列表
     */
    @Override
    public TestConfigurationOptionsVo selectConfigurationOptions()
    {
        TestConfigurationOptionsVo options = new TestConfigurationOptionsVo();
        options.setUsers(testMapper.selectEnabledUserOptions());
        options.setTestTypes(testMapper.selectEnabledTestTypeOptions());
        options.setBatteries(testMapper.selectEnabledBatteryOptions());
        options.setChannels(testMapper.selectRegisteredChannelOptions());
        options.setCollectors(testMapper.selectEnabledCollectorOptions());
        return options;
    }
 
    /**
     * 创建一条待测试记录,在同一事务内写入测试配置和电池号、阈值、通道绑定关系。
     *
     * <p>前置条件:调用方已从登录会话取得操作人,测试配置通过基本信息与配置关系校验。
     * 创建人取当前操作人,监测超时缺省 30 分钟,初始状态固定为“待测试 + 未开始接收”。</p>
     *
     * @param test 测试配置,含电池号、阈值、检测柜通道绑定与采集器映射
     * @return 新建测试主键
     * @throws ServiceException 测试信息为空、操作人缺失或配置校验不通过
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createTest(TTest test)
    {
        if (test == null) throw new ServiceException("测试信息不能为空");
        // STEP 1: 规范化系统赋值字段,创建人取当前操作人,监测超时缺省 30 分钟。
        test.setCreatorId(test.getOperatorId());
        if (test.getMonitorTimeoutMinutes() == null) test.setMonitorTimeoutMinutes(30);
        // STEP 2: 校验基本信息与电池号、阈值、通道绑定等配置关系。
        validateBase(test, false);
        validateConfiguration(test);
        // STEP 3: 初始化为“待测试 + 未开始接收”,测试状态只在开始接收时流转。
        test.setTestStatus(WAITING);
        test.setReceptionStatus(NOT_STARTED);
        test.setDelFlag("0");
        test.setCreateTime(new Date());
        testMapper.insertTest(test);
        // STEP 4: 在同一事务内写入电池号、阈值、通道绑定与采集器映射。
        saveConfiguration(test);
        // 最终基线移除了 t_test_audit;Controller 的 @Log 负责记录创建操作,业务表只保存配置事实。
        return test.getTestId();
    }
 
    /**
     * 仅在开始接收之前整体替换测试配置,测试状态与接收状态保持不变。
     *
     * <p>前置条件:测试处于“待测试 + 未开始接收”。开始接收后设备数据已按配置归属,
     * 配置不再允许修改;替换范围包括电池号、阈值和通道绑定,在同一事务内完成。</p>
     *
     * @param test 新的测试配置,须带测试主键
     * @return 更新行数,成功固定为 1
     * @throws ServiceException 测试ID为空、测试不存在、已开始接收或配置校验不通过
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int updateTest(TTest test)
    {
        if (test == null || test.getTestId() == null) throw new ServiceException("测试ID不能为空");
        // STEP 1: 取测试行锁并确认仍处于“待测试 + 未开始接收”。
        TTest current = testMapper.selectTestByIdForUpdate(test.getTestId());
        if (current == null) throw new ServiceException("测试不存在");
        loadConfiguration(current);
        if (!stateMatches(current, WAITING, NOT_STARTED))
            throw new ServiceException("开始接收后不能修改测试配置");
 
        // STEP 2: 保留创建人与两个状态字段,只接受配置类字段的修改。
        test.setCreatorId(current.getCreatorId());
        test.setTestStatus(current.getTestStatus());
        test.setReceptionStatus(current.getReceptionStatus());
        if (test.getMonitorTimeoutMinutes() == null)
            test.setMonitorTimeoutMinutes(current.getMonitorTimeoutMinutes());
        validateBase(test, true);
        validateConfiguration(test);
        test.setUpdateTime(new Date());
        int rows = testMapper.updateTest(test);
        if (rows != 1) throw new ServiceException("测试配置已变更,请刷新后重试");
 
        // STEP 3: 开始接收前没有数据归属事实,配置关系可在同一事务内整体替换;操作审计由 Controller @Log 追加。
        testMapper.deleteTestCollectorChannelsByTestId(test.getTestId());
        testMapper.deleteTestBindingsByTestId(test.getTestId());
        testMapper.deleteTestBatteriesByTestId(test.getTestId());
        testMapper.deleteTestThresholdsByTestId(test.getTestId());
        saveConfiguration(test);
        return rows;
    }
 
    /**
     * 开始接收:校验全部绑定来源后建立接收边界,边界之后的成功观测才归属本测试。
     *
     * <p>前置条件:测试处于“待测试 + 未开始接收”,每个电池号都绑定启用的检测柜通道与采集器,
     * 且同一检测柜通道在本测试内不重复。来源离线不阻断状态流转,允许从已运行设备中途接入;
     * 本动作只改变系统数据请求和测试状态,不向设备发送控制命令。</p>
     *
     * @param testId     测试主键
     * @param operatorId 操作人用户 ID
     * @param updateBy   更新人账号,写入测试行的 update_by
     * @return 更新行数,成功固定为 1
     * @throws ServiceException 入参为空、测试不存在、状态不匹配、来源校验失败或数据来源已被其他测试占用
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int startReception(Long testId, Long operatorId, String updateBy)
    {
        if (testId == null) throw new ServiceException("测试ID不能为空");
        if (operatorId == null) throw new ServiceException("操作人不能为空");
        // STEP 1: 取测试行锁,确认状态为“待测试 + 未开始接收”并加载绑定配置。
        TTest current = testMapper.selectTestByIdForUpdate(testId);
        if (current == null) throw new ServiceException("测试不存在");
        if (!stateMatches(current, WAITING, NOT_STARTED))
            throw new ServiceException("当前测试不是未开始接收状态");
        loadConfiguration(current);
        if (current.getBatteries().isEmpty()) throw new ServiceException("测试未配置电池号");
 
        // STEP 2: 逐电池号校验检测柜通道唯一、通道启用、采集器存在且映射完整。
        Date actionAt = new Date();
        Set<Long> channelIds = new HashSet<>();
        Set<String> collectorChannels = new HashSet<>();
        boolean runtimeAttach = false;
        for (TTestBattery battery : current.getBatteries())
        {
            TTestChannelBinding binding = battery.getBinding();
            if (binding == null || binding.getChannelId() == null)
                throw new ServiceException("电池号 " + battery.getBatteryNo() + " 未绑定检测柜通道");
            if (!channelIds.add(binding.getChannelId()))
                throw new ServiceException("检测柜通道重复绑定,无法开始接收");
            TDetectionChannel channel = testMapper.selectReceptionChannelForUpdate(binding.getChannelId());
            if (channel == null || !isEnabled(channel.getStatus()))
                throw new ServiceException("检测柜通道 " + binding.getChannelId() + " 不存在或未启用");
            // 只有设备事实为运行中时才标记“运行中接入”;设备暂停属于独立设备状态。
            runtimeAttach |= "WORKING".equalsIgnoreCase(channel.getWorkStatus())
                    || "RUNNING".equalsIgnoreCase(channel.getWorkStatus());
            if (battery.getCollectorId() == null || testMapper.countCollectorEnabled(battery.getCollectorId()) == 0)
                throw new ServiceException("电池号 " + battery.getBatteryNo() + " 的采集器不存在或未启用");
            Integer collectorChannelCount = testMapper.selectCollectorChannelCount(battery.getCollectorId());
            validateCollectorChannels(battery.getCollectorId(), collectorChannelCount == null ? 0 : collectorChannelCount,
                    battery.getCollectorChannels(), collectorChannels);
 
        }
 
        // STEP 3: 在测试行锁内推进为“检测中 + 接收中”,并把此刻写为接收边界。
        try
        {
            if (testMapper.updateReceptionStarted(testId, actionAt, runtimeAttach, updateBy) != 1)
                throw new ServiceException("测试状态已变更,请刷新后重试");
            return 1;
        }
        catch (DuplicateKeyException exception)
        {
            // 检测柜通道已被其他接收中的测试占用,唯一索引保证不会同时归属两个测试。
            throw new ServiceException("数据来源已被其他测试占用,请刷新后重试");
        }
    }
 
    /**
     * 暂停接收:停止为本次测试请求实时数据,保留已持久化的历史事实。
     *
     * <p>前置条件:测试处于“检测中 + 接收中”。设备不接收暂停命令,
     * 暂停期间停止请求数据且不补采,暂停空档不视为数据缺口。</p>
     *
     * @param testId     测试主键
     * @param operatorId 操作人用户 ID
     * @param updateBy   更新人账号,写入测试行的 update_by
     * @return 更新行数,成功固定为 1
     * @throws ServiceException 入参为空、测试不存在或当前不是接收中状态
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int pauseReception(Long testId, Long operatorId, String updateBy)
    {
        if (testId == null) throw new ServiceException("测试ID不能为空");
        if (operatorId == null) throw new ServiceException("操作人不能为空");
        // STEP 1: 取测试行锁,确认状态为“检测中 + 接收中”。
        TTest current = testMapper.selectTestByIdForUpdate(testId);
        if (current == null) throw new ServiceException("测试不存在");
        if (!stateMatches(current, "RUNNING", "RECEIVING"))
            throw new ServiceException("当前测试不是接收中状态");
 
        // STEP 2: 推进为“暂停 + 暂停接收”;不记录接收边界,恢复时再重置边界。
        Date actionAt = new Date();
        if (testMapper.updateReceptionPaused(testId, updateBy, actionAt) != 1)
            throw new ServiceException("测试状态已变更,请刷新后重试");
        // 暂停期间停止请求数据;继续接收后仅追加新的成功接收事实。
        return 1;
    }
 
    /**
     * 继续接收:为全部绑定来源恢复实时数据请求,只追加新的成功接收事实。
     *
     * <p>前置条件:测试处于“暂停 + 暂停接收”,且绑定来源仍可用。
     * 继续接收会刷新接收边界,暂停空档不补采;设备已经结束时不得继续接收。</p>
     *
     * @param testId     测试主键
     * @param operatorId 操作人用户 ID
     * @param updateBy   更新人账号,写入测试行的 update_by
     * @return 更新行数,成功固定为 1
     * @throws ServiceException 入参为空、测试不存在或当前不是暂停接收状态
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int resumeReception(Long testId, Long operatorId, String updateBy)
    {
        if (testId == null) throw new ServiceException("测试ID不能为空");
        if (operatorId == null) throw new ServiceException("操作人不能为空");
        // STEP 1: 取测试行锁,确认状态为“暂停 + 暂停接收”。
        TTest current = testMapper.selectTestByIdForUpdate(testId);
        if (current == null) throw new ServiceException("测试不存在");
        if (!stateMatches(current, "PAUSED", "PAUSED"))
            throw new ServiceException("当前测试不是暂停接收状态");
        // STEP 2: 加锁查询只返回测试主表,继续接收前重新加载绑定配置以校验来源仍可用。
        loadConfiguration(current);
        // STEP 3: 推进为“检测中 + 接收中”,并把此刻刷新为新的接收边界。
        Date actionAt = new Date();
        if (testMapper.updateReceptionResumed(testId, updateBy, actionAt) != 1)
            throw new ServiceException("测试状态已变更,请刷新后重试");
        return 1;
    }
 
    /**
     * 停止测试:停止数据请求并进入“检测完成 + 已结束接收”的不可恢复终态。
     *
     * <p>前置条件:测试处于“检测中 + 接收中”或“暂停 + 暂停接收”。数据缺失、序号跳跃和
     * 采样间隔异常不阻止完成;不向检测柜或采集器发送停止命令,设备仍按现场事实独立运行。</p>
     *
     * @param testId     测试主键
     * @param operatorId 操作人用户 ID
     * @param updateBy   更新人账号,写入测试行的 update_by
     * @return 更新行数,成功固定为 1
     * @throws ServiceException 入参为空、测试不存在或当前状态不允许停止
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int stopTest(Long testId, Long operatorId, String updateBy)
    {
        if (testId == null) throw new ServiceException("测试ID不能为空");
        if (operatorId == null) throw new ServiceException("操作人不能为空");
        // STEP 1: 取测试行锁,确认处于可停止的两种状态之一。
        TTest current = testMapper.selectTestByIdForUpdate(testId);
        if (current == null) throw new ServiceException("测试不存在");
        if (!(stateMatches(current, "RUNNING", "RECEIVING")
                || stateMatches(current, "PAUSED", "PAUSED")))
            throw new ServiceException("当前测试不能停止");
        // STEP 2: 推进为“检测完成 + 已结束接收”并记录结束时间,不执行完整性门禁。
        Date actionAt = new Date();
        if (testMapper.updateReceptionStopped(testId, updateBy, actionAt) != 1)
            throw new ServiceException("测试状态已变更,请刷新后重试");
        return 1;
    }
 
    private boolean isEnabled(String status)
    {
        return status == null || "0".equals(status);
    }
 
    /** 生命周期动作必须同时匹配测试状态和接收状态,避免并发请求后两个状态字段互相矛盾。 */
    private boolean stateMatches(TTest test, String expectedTestStatus, String expectedReceptionStatus)
    {
        return expectedReceptionStatus.equals(test.getReceptionStatus())
                && expectedTestStatus.equals(test.getTestStatus());
    }
 
    /**
     * 在任意生命周期整体删除测试及其全部子数据,删除后不可恢复。
     *
     * <p>前置条件:调用方已确认操作人身份。逐条测试加测试行锁后清理绑定、阈值、三类数据、
     * 逻辑工步、预警异常和修订记录,最后删除测试主记录;按 ADR-0047 不写操作日志、修订记录或墓碑,
     * 也不删除电池组、设备登记和独立保存的报表。</p>
     *
     * @param testIds    待删除的测试主键数组
     * @param operatorId 操作人用户 ID
     * @param updateBy   更新人账号
     * @return 实际删除的测试数量
     * @throws ServiceException 未选择测试或测试不存在
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int deleteTestByIds(Long[] testIds, Long operatorId, String updateBy)
    {
        if (testIds == null || testIds.length == 0) throw new ServiceException("请选择要删除的测试");
        int rows = 0;
        for (Long testId : testIds)
        {
            // STEP 1: 加测试行锁,与接收事务互斥;删除提交后不能再向该测试写入数据。
            TTest current = testMapper.selectTestByIdForUpdate(testId);
            if (current == null) throw new ServiceException("测试不存在");
            // STEP 2: 在同一事务内清理全部测试子数据,整体删除不留审计或墓碑。
            testMapper.deleteTestRealtimeRecordsByTestId(testId);
            testMapper.deleteTestStepRecordsByTestId(testId);
            testMapper.deleteTestCycleRecordsByTestId(testId);
            testMapper.deleteTestDataRevisionActionsByTestId(testId);
            testMapper.deleteTestWarningExceptionsByTestId(testId);
            testMapper.deleteTestLogicStepMappingsByTestId(testId);
            testMapper.deleteTestCollectorChannelsByTestId(testId);
            testMapper.deleteTestBindingsByTestId(testId);
            testMapper.deleteTestThresholdRowsByTestId(testId);
            testMapper.deleteTestBatteriesByTestId(testId);
            // STEP 3: 最后删除测试主记录,行数不为 1 说明配置已被并发改动。
            int deleted = testMapper.deleteTestById(testId, updateBy);
            if (deleted != 1) throw new ServiceException("测试配置已变更,请刷新后重试");
            rows += deleted;
        }
        return rows;
    }
 
    /**
     * 保存当前测试的逻辑工步合并规则,整体替换原有映射。
     *
     * <p>前置条件:映射须按循环内连续位置升序、互不重叠、编号从 1 起,且每项都有名称。
     * 逻辑工步只用于统计图表和报表等派生视图,不改写原始采样记录。</p>
     *
     * @param testId     测试主键
     * @param mappings   逻辑工步映射列表,为空表示清空
     * @param operatorId 操作人用户 ID
     * @param updateBy   更新人账号
     * @return 更新行数,成功固定为 1
     * @throws ServiceException 测试或操作人为空、测试不存在或映射不满足连续升序且互不重叠
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int saveLogicStepMappings(Long testId, List<TTestLogicStepMapping> mappings, Long operatorId, String updateBy)
    {
        if (testId == null || operatorId == null) throw new ServiceException("测试和操作人不能为空");
        TTest current = testMapper.selectTestByIdForUpdate(testId);
        if (current == null) throw new ServiceException("测试不存在");
        // STEP 1: 校验映射为连续的循环内位置区间,编号从 1 起且互不重叠。
        List<TTestLogicStepMapping> normalized = mappings == null ? List.of() : mappings;
        int previousEnd = 0;
        int expectedNo = 1;
        for (TTestLogicStepMapping mapping : normalized)
        {
            if (mapping == null || mapping.getLogicalNo() == null || mapping.getLogicalNo() != expectedNo
                    || mapping.getStartStepNo() == null || mapping.getEndStepNo() == null
                    || mapping.getStartStepNo() < 1 || mapping.getEndStepNo() < mapping.getStartStepNo()
                    || mapping.getStartStepNo() <= previousEnd || StringUtils.isEmpty(trimToNull(mapping.getLogicalName())))
                throw new ServiceException("逻辑工步必须按循环内连续位置升序且互不重叠");
            mapping.setLogicalName(trimToNull(mapping.getLogicalName()));
            previousEnd = mapping.getEndStepNo();
            expectedNo++;
        }
        // STEP 2: 整体替换映射 JSON,作用于该测试的全部循环。
        String newJson = JSON.toJSONString(normalized);
        int rows = testMapper.updateLogicStepMappings(testId, newJson, updateBy, new Date());
        if (rows != 1) throw new ServiceException("工步设置已变更,请刷新后重试");
        return rows;
    }
 
    /**
     * 保存测试详情当前最新版,允许测试处于任意状态。
     *
     * <p>前置条件:实验名称不超过 200 个字符,字段名称与字段值通过校验。
     * 详情只保留当前最新版,直接读取电池组当前字段值,不创建测试字段快照,也不产生修订记录。</p>
     *
     * @param testId     测试主键
     * @param detail     测试补充详情,含实验名称、字段名称与人工填写值
     * @param operatorId 操作人用户 ID
     * @param updateBy   更新人账号
     * @return 更新行数,成功固定为 1
     * @throws ServiceException 测试或操作人为空、测试不存在、实验名称或字段校验不通过
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int saveConsoleDetail(Long testId, TTestConsoleDetail detail, Long operatorId, String updateBy)
    {
        if (testId == null || operatorId == null || detail == null) throw new ServiceException("测试详情不能为空");
        // STEP 1: 加测试行锁并确认测试存在。
        TTest current = testMapper.selectTestByIdForUpdate(testId);
        if (current == null) throw new ServiceException("测试不存在");
        // STEP 2: 规范化并校验实验名称、字段名称与字段值。
        String experimentName = trimToNull(detail.getExperimentName());
        if (experimentName != null && experimentName.length() > 200) throw new ServiceException("实验名称长度不能超过200个字符");
        detail.setExperimentName(experimentName);
        validateConsoleFields(detail.getBatteryFields());
        validateConsoleFields(detail.getSpecialFields());
        validateConsoleValues(testId, detail);
        // STEP 3: 覆盖保存最新版详情,不追加修订记录。
        int rows = testMapper.updateConsoleDetail(testId, JSON.toJSONString(detail), updateBy, new Date());
        if (rows != 1) throw new ServiceException("测试详情保存失败,请刷新后重试");
        return rows;
    }
 
    private void validateConsoleFields(List<String> fields)
    {
        if (fields == null) return;
        Set<String> names = new HashSet<>();
        for (String field : fields)
        {
            String normalized = trimToNull(field);
            if (normalized == null || normalized.length() > 100 || !names.add(normalized))
                throw new ServiceException("测试详情字段不能为空、长度不能超过100且不可重复");
        }
    }
 
    private void validateConsoleValues(Long testId, TTestConsoleDetail detail)
    {
        Set<String> allowedFields = new HashSet<>();
        if (detail.getBatteryFields() != null) allowedFields.addAll(detail.getBatteryFields());
        if (detail.getSpecialFields() != null) allowedFields.addAll(detail.getSpecialFields());
        Set<String> batteryNos = new HashSet<>();
        List<TTestBattery> batteries = testMapper.selectTestBatteries(testId);
        if (batteries != null) for (TTestBattery battery : batteries) batteryNos.add(battery.getBatteryNo());
        if (detail.getFieldValues() == null) return;
        for (Map.Entry<String, Map<String, String>> entry : detail.getFieldValues().entrySet())
        {
            if (!allowedFields.contains(entry.getKey()) || entry.getValue() == null)
                throw new ServiceException("测试详情字段值结构无效");
            for (Map.Entry<String, String> value : entry.getValue().entrySet())
            {
                if (!batteryNos.contains(value.getKey()) || (value.getValue() != null && value.getValue().length() > 500))
                    throw new ServiceException("测试详情电池号或字段值无效");
            }
        }
    }
 
    private void loadConfiguration(TTest test)
    {
        Long testId = test.getTestId();
        List<TTestBattery> loadedBatteries = testMapper.selectTestBatteries(testId);
        List<TTestBattery> batteries = loadedBatteries == null || (loadedBatteries.isEmpty() && test.getBatteries() != null
                && !test.getBatteries().isEmpty()) ? mutable(test.getBatteries()) : mutable(loadedBatteries);
        // 字段属于电池组当前配置;每次查询测试都重新读取,避免产生测试字段快照。
        List<CurrentBatteryField> currentFields = testMapper.selectCurrentBatteryFields(testId);
        Map<Long, List<CurrentBatteryField>> fieldsByBattery = new HashMap<>();
        for (CurrentBatteryField field : mutable(currentFields))
            fieldsByBattery.computeIfAbsent(field.getRelationId(), ignored -> new ArrayList<>()).add(field);
        List<TTestCollectorChannel> loadedCollectorChannels = testMapper.selectTestCollectorChannels(testId);
        List<TTestCollectorChannel> collectorChannels = loadedCollectorChannels == null ? List.of() : mutable(loadedCollectorChannels);
        Map<Long, TTestChannelBinding> bindings = new HashMap<>();
        List<TTestChannelBinding> loadedBindings = testMapper.selectTestChannelBindings(testId);
        if (loadedBindings == null || (loadedBindings.isEmpty() && batteries.stream().anyMatch(item -> item.getBinding() != null)))
            for (TTestBattery battery : batteries)
                if (battery.getBinding() != null) bindings.put(battery.getRelationId(), battery.getBinding());
        for (TTestChannelBinding binding : mutable(loadedBindings))
            bindings.put(binding.getBatteryRelationId(), binding);
        for (TTestBattery battery : batteries)
        {
            battery.setBinding(bindings.get(battery.getRelationId()));
            battery.setFields(fieldsByBattery.getOrDefault(battery.getRelationId(), List.of()));
            if (loadedCollectorChannels != null && (!loadedCollectorChannels.isEmpty() || battery.getCollectorChannels() == null))
                battery.setCollectorChannels(collectorChannels.stream()
                        .filter(mapping -> battery.getRelationId().equals(mapping.getBatteryRelationId()))
                        .toList());
        }
        test.setBatteries(batteries);
        test.setBatteryNo(joinBatteryNos(batteries));
        List<TTestWarningThreshold> loadedThresholds = testMapper.selectTestThresholds(testId);
        test.setThresholds(loadedThresholds == null ? mutable(test.getThresholds()) : mutable(loadedThresholds));
        // 测试不保存字段快照;字段值始终随电池组当前配置读取。
        String mappingJson = test.getLogicStepMappingsJson();
        try { test.setLogicStepMappings(mappingJson == null || mappingJson.isBlank()
                ? new ArrayList<>() : JSON.parseArray(mappingJson, TTestLogicStepMapping.class)); }
        catch (JSONException exception) { throw new ServiceException("逻辑工步设置无法解析"); }
        String detailJson = test.getConsoleDetailJson();
        try { test.setConsoleDetail(detailJson == null || detailJson.isBlank()
                ? null : JSON.parseObject(detailJson, TTestConsoleDetail.class)); }
        catch (JSONException exception) { throw new ServiceException("测试详情无法解析"); }
    }
 
    private void validateBase(TTest test, boolean update)
    {
        test.setTestName(trimToNull(test.getTestName()));
        test.setTestPurpose(trimToNull(test.getTestPurpose()));
        test.setRemark(trimToNull(test.getRemark()));
        if (StringUtils.isEmpty(test.getTestName())) throw new ServiceException("测试名称不能为空");
        if (test.getTestName().length() > 100) throw new ServiceException("测试名称长度不能超过100个字符");
        if (StringUtils.isEmpty(test.getTestPurpose())) throw new ServiceException("测试目的不能为空");
        if (test.getRemark() != null && test.getRemark().length() > 500) throw new ServiceException("备注长度不能超过500个字符");
        if (test.getMonitorTimeoutMinutes() == null || test.getMonitorTimeoutMinutes() < 1
                || test.getMonitorTimeoutMinutes() > 1440)
            throw new ServiceException("监测超时分钟数必须在1到1440之间");
        if (test.getCreatorId() == null || testMapper.countUserEnabled(test.getCreatorId()) == 0)
            throw new ServiceException("创建人不存在或已停用");
        if (test.getPrincipalId() == null || testMapper.countUserEnabled(test.getPrincipalId()) == 0)
            throw new ServiceException("测试负责人不存在或已停用");
        if (test.getTestTypeId() == null || testMapper.countTestTypeEnabled(test.getTestTypeId()) == 0)
            throw new ServiceException("测试类型不存在或未启用");
        TTest same = testMapper.selectTestByName(test.getTestName());
        if (same != null && (!update || !test.getTestId().equals(same.getTestId())))
            throw new ServiceException("测试名称已存在");
    }
 
    private void validateConfiguration(TTest test)
    {
        // 计划工步来自设备观测,不属于测试创建配置,也不写入业务库。
        validateBatteries(test.getBatteries());
        validateThresholds(test.getThresholds());
    }
 
    private void validateBatteries(List<TTestBattery> batteries)
    {
        if (batteries == null || batteries.isEmpty()) throw new ServiceException("测试至少绑定一个电池号");
        Set<String> batteryNos = new HashSet<>();
        Set<Long> channelIds = new HashSet<>();
        Set<String> collectorChannelIdentities = new HashSet<>();
        Map<Long, Integer> collectorChannelCounts = new HashMap<>();
        Long groupId = null;
        for (TTestBattery battery : batteries)
        {
            if (battery == null || battery.getGroupId() == null) throw new ServiceException("电池组不能为空");
            if (groupId == null) groupId = battery.getGroupId();
            else if (!groupId.equals(battery.getGroupId())) throw new ServiceException("同一测试只能选择一个电池组");
            battery.setBatteryNo(trimToNull(battery.getBatteryNo()));
            if (battery.getBatteryNo() == null) throw new ServiceException("电池号不能为空");
            if (!batteryNos.add(battery.getBatteryNo())) throw new ServiceException("测试内电池号不可重复");
            if (testMapper.countBatteryInEnabledGroup(battery.getGroupId(), battery.getBatteryNo()) == 0)
                throw new ServiceException("电池号不属于所选启用电池组");
 
            TTestChannelBinding binding = battery.getBinding();
            if (binding == null || binding.getChannelId() == null) throw new ServiceException("主要检测柜通道不能为空");
            if (!channelIds.add(binding.getChannelId())) throw new ServiceException("同一检测柜通道不可重复绑定");
            if (testMapper.countRegisteredChannel(binding.getChannelId()) == 0)
                throw new ServiceException("检测柜通道不存在、未登记或未启用");
            binding.setExpectedBarcode(trimToNull(binding.getExpectedBarcode()));
            if (binding.getExpectedBarcode() != null && binding.getExpectedBarcode().length() > 100)
                throw new ServiceException("预期条码长度不能超过100个字符");
            if (battery.getCollectorId() == null) throw new ServiceException("采集器不能为空");
            if (testMapper.countCollectorEnabled(battery.getCollectorId()) == 0)
                throw new ServiceException("采集器不存在或未启用");
            Integer channelCount = collectorChannelCounts.computeIfAbsent(battery.getCollectorId(),
                    testMapper::selectCollectorChannelCount);
            if (channelCount == null || channelCount < 1) throw new ServiceException("采集器通道配置无效");
            validateCollectorChannels(battery.getCollectorId(), channelCount, battery.getCollectorChannels(),
                    collectorChannelIdentities);
            validateBatteryFields(battery.getFields());
        }
    }
 
    private void validateCollectorChannels(Long collectorId, int channelCount, List<TTestCollectorChannel> mappings,
            Set<String> collectorChannelIdentities)
    {
        if (mappings == null || mappings.isEmpty())
            throw new ServiceException("每个电池号必须绑定主温度、正电位主通道和负电位主通道");
        Set<String> measurementCodes = new HashSet<>();
        for (TTestCollectorChannel mapping : mappings)
        {
            if (mapping == null) throw new ServiceException("采集器通道映射不完整");
            mapping.setMeasurementCode(normalizeMeasurementCode(mapping.getMeasurementCode()));
            mapping.setMeasurementName(trimToNull(mapping.getMeasurementName()));
            mapping.setChannelCode(normalizeCollectorChannel(mapping.getChannelCode()));
            if (mapping.getMeasurementCode() == null || mapping.getMeasurementName() == null
                    || mapping.getChannelCode() == null)
                throw new ServiceException("采集器通道映射不完整");
            if (mapping.getMeasurementCode().length() > 100 || mapping.getMeasurementName().length() > 100)
                throw new ServiceException("采集器测量项长度不能超过100个字符");
            int channelNumber = Integer.parseInt(mapping.getChannelCode().substring(2));
            if (channelNumber < 1 || channelNumber > channelCount)
                throw new ServiceException("采集器通道不存在或超出登记范围");
            if (!measurementCodes.add(mapping.getMeasurementCode()))
                throw new ServiceException("同一电池号的采集器测量项不可重复");
            if (!collectorChannelIdentities.add(collectorId + ":" + mapping.getChannelCode()))
                throw new ServiceException("同一采集器通道不可重复映射");
        }
        if (!measurementCodes.containsAll(REQUIRED_COLLECTOR_MEASUREMENTS))
            throw new ServiceException("每个电池号必须绑定主温度、正电位主通道和负电位主通道");
    }
 
    private void validateBatteryFields(List<CurrentBatteryField> fields)
    {
        if (fields == null) return;
        Set<String> fieldNames = new HashSet<>();
        for (CurrentBatteryField field : fields)
        {
            if (field == null) throw new ServiceException("扩展属性不能为空");
            field.setFieldName(trimToNull(field.getFieldName()));
            if (field.getFieldName() == null || !fieldNames.add(field.getFieldName()))
                throw new ServiceException("扩展属性名称不能为空且不可重复");
        }
    }
 
    private void populateBatteryNos(List<TTest> tests)
    {
        if (tests.isEmpty()) return;
        Map<Long, List<TTestBattery>> batteriesByTest = new HashMap<>();
        List<Long> testIds = tests.stream().map(TTest::getTestId).toList();
        for (TTestBattery battery : mutable(testMapper.selectBatteryNosByTestIds(testIds)))
            batteriesByTest.computeIfAbsent(battery.getTestId(), ignored -> new ArrayList<>()).add(battery);
        for (TTest item : tests)
            item.setBatteryNo(joinBatteryNos(batteriesByTest.get(item.getTestId())));
    }
 
    private String joinBatteryNos(List<TTestBattery> batteries)
    {
        return mutable(batteries).stream().map(TTestBattery::getBatteryNo).collect(java.util.stream.Collectors.joining("、"));
    }
 
 
 
    private void validateThresholds(List<TTestWarningThreshold> thresholds)
    {
        if (thresholds == null || thresholds.isEmpty()) throw new ServiceException("阈值配置不能为空");
        Set<String> metricCodes = new HashSet<>();
        for (TTestWarningThreshold threshold : thresholds)
        {
            if (threshold == null) throw new ServiceException("阈值配置不完整");
            threshold.setMetricCode(trimToNull(threshold.getMetricCode()));
            threshold.setUnit(trimToNull(threshold.getUnit()));
            threshold.setSeverity(trimToNull(threshold.getSeverity()));
            if (threshold.getMetricCode() == null || !metricCodes.add(threshold.getMetricCode()))
                throw new ServiceException("阈值指标编码不能为空且不可重复");
            if (threshold.getMinValue() == null && threshold.getMaxValue() == null)
                throw new ServiceException("阈值最小值和最大值至少填写一项");
            if (threshold.getMinValue() != null && threshold.getMaxValue() != null
                    && threshold.getMinValue().compareTo(threshold.getMaxValue()) > 0)
                throw new ServiceException("阈值最小值不能大于最大值");
            if (!THRESHOLD_UNITS.contains(threshold.getUnit())) throw new ServiceException("阈值单位无效");
            if (threshold.getSeverity() == null) threshold.setSeverity("WARNING");
        }
    }
 
    private void saveConfiguration(TTest test)
    {
        for (TTestBattery battery : test.getBatteries())
        {
            battery.setTestId(test.getTestId());
            testMapper.insertTestBattery(battery);
            TTestChannelBinding binding = battery.getBinding();
            binding.setTestId(test.getTestId());
            binding.setBatteryRelationId(battery.getRelationId());
            binding.setClaimStatus("PENDING");
            testMapper.insertTestChannelBinding(binding);
            for (TTestCollectorChannel mapping : battery.getCollectorChannels())
            {
                mapping.setTestId(test.getTestId());
                mapping.setBatteryRelationId(battery.getRelationId());
                mapping.setCollectorId(battery.getCollectorId());
                testMapper.insertTestCollectorChannel(mapping);
            }
        }
        for (TTestWarningThreshold threshold : test.getThresholds())
        {
            threshold.setTestId(test.getTestId());
            testMapper.insertTestThreshold(threshold);
        }
    }
 
    private void validateQueryStatus(String status, Set<String> allowed, String message)
    {
        if (StringUtils.isNotEmpty(status) && !allowed.contains(status)) throw new ServiceException(message);
    }
 
    private String trimToNull(String value)
    {
        String trimmed = StringUtils.trim(value);
        return StringUtils.isEmpty(trimmed) ? null : trimmed;
    }
 
    private String normalizeCollectorChannel(String value)
    {
        String normalized = trimToNull(value);
        if (normalized == null || !normalized.toUpperCase().matches("S-\\d+"))
            throw new ServiceException("采集器通道必须使用S-01...S-N演示编码");
        try
        {
            int channelNumber = Integer.parseInt(normalized.substring(2));
            return String.format("S-%02d", channelNumber);
        }
        catch (NumberFormatException exception)
        {
            throw new ServiceException("采集器通道必须使用S-01...S-N演示编码");
        }
    }
 
    /** 测量编码与数据库不区分大小写排序保持一致,避免校验通过后插入冲突。 */
    private String normalizeMeasurementCode(String value)
    {
        String normalized = trimToNull(value);
        return normalized == null ? null : normalized.toUpperCase(Locale.ROOT);
    }
 
    private <T> List<T> mutable(List<T> values)
    {
        return values == null ? new ArrayList<>() : new ArrayList<>(values);
    }
}