yeg
2026-09-05 dd1af98f7e679290e5280e2aff58d7ff5237c5fb
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
package com.ruoyi.system.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.ruoyi.system.domain.TTest;
import com.ruoyi.system.domain.TTestBattery;
import com.ruoyi.system.domain.TTestBatteryField;
import com.ruoyi.system.domain.TTestChannelBinding;
import com.ruoyi.system.domain.TTestCollectorChannel;
import com.ruoyi.system.domain.TTestPlanStep;
import com.ruoyi.system.domain.TTestResult;
import com.ruoyi.system.domain.TTestWarningThreshold;
import com.ruoyi.system.domain.TTestLogicStepMapping;
import com.ruoyi.system.domain.TTestConsoleDetail;
import com.ruoyi.system.domain.TDetectionChannel;
import com.ruoyi.system.domain.vo.TestConfigurationOptionsVo;
import com.ruoyi.system.mapper.TTestMapper;
import com.ruoyi.system.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;
 
    @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;
    }
 
    @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;
    }
 
    @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;
    }
 
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createTest(TTest test)
    {
        if (test == null) throw new ServiceException("测试信息不能为空");
        test.setCreatorId(test.getOperatorId());
        if (test.getMonitorTimeoutMinutes() == null) test.setMonitorTimeoutMinutes(30);
        validateBase(test, false);
        validateConfiguration(test);
        test.setTestStatus(WAITING);
        test.setReceptionStatus(NOT_STARTED);
        test.setDelFlag("0");
        test.setCreateTime(new Date());
        testMapper.insertTest(test);
        saveConfiguration(test);
        // 最终基线移除了 t_test_audit;Controller 的 @Log 负责记录创建操作,业务表只保存配置事实。
        return test.getTestId();
    }
 
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int updateTest(TTest test)
    {
        if (test == null || test.getTestId() == null) throw new ServiceException("测试ID不能为空");
        TTest current = testMapper.selectTestByIdForUpdate(test.getTestId());
        if (current == null) throw new ServiceException("测试不存在");
        loadConfiguration(current);
        if (!stateMatches(current, WAITING, NOT_STARTED))
            throw new ServiceException("开始接收后不能修改测试配置");
 
        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("测试配置已变更,请刷新后重试");
 
        // 开始接收前没有数据归属事实,配置关系可在同一事务内整体替换;操作审计由 Controller @Log 追加。
        testMapper.deleteTestCollectorChannelsByTestId(test.getTestId());
        testMapper.deleteTestBindingsByTestId(test.getTestId());
        testMapper.deleteTestBatteriesByTestId(test.getTestId());
        testMapper.deleteTestThresholdsByTestId(test.getTestId());
        saveConfiguration(test);
        return rows;
    }
 
    @Override
    public int startTest(Long testId, String updateBy)
    {
        throw new 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("操作人不能为空");
        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("测试未配置电池号");
 
        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);
 
        }
 
        try
        {
            if (testMapper.updateReceptionStarted(testId, actionAt, runtimeAttach, updateBy) != 1)
                throw new ServiceException("测试状态已变更,请刷新后重试");
            testMapper.insertTestAudit(testId, "RECEPTION_STARTED", operatorId, updateBy, null,
                    JSON.toJSONString(Map.of("receptionStatus", "RECEIVING", "runtimeAttach", runtimeAttach)));
            return 1;
        }
        catch (DuplicateKeyException exception)
        {
            throw new 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("操作人不能为空");
        TTest current = testMapper.selectTestByIdForUpdate(testId);
        if (current == null) throw new ServiceException("测试不存在");
        if (!stateMatches(current, "RUNNING", "RECEIVING"))
            throw new ServiceException("当前测试不是接收中状态");
 
        Date actionAt = new Date();
        if (testMapper.updateReceptionPaused(testId, updateBy, actionAt) != 1)
            throw new ServiceException("测试状态已变更,请刷新后重试");
        // 暂停期间停止请求数据;继续接收后仅追加新的成功接收事实。
        testMapper.insertTestAudit(testId, "RECEPTION_PAUSED", operatorId, updateBy, null,
                JSON.toJSONString(Map.of("receptionStatus", "PAUSED", "polling", "STOPPED")));
        return 1;
    }
 
    @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("操作人不能为空");
        TTest current = testMapper.selectTestByIdForUpdate(testId);
        if (current == null) throw new ServiceException("测试不存在");
        if (!stateMatches(current, "PAUSED", "PAUSED"))
            throw new ServiceException("当前测试不是暂停接收状态");
        // 加锁查询只返回测试主表,继续接收前重新加载绑定配置以校验来源仍可用。
        loadConfiguration(current);
        Date actionAt = new Date();
        if (testMapper.updateReceptionResumed(testId, updateBy, actionAt) != 1)
            throw new ServiceException("测试状态已变更,请刷新后重试");
        testMapper.insertTestAudit(testId, "RECEPTION_RESUMED", operatorId, updateBy, null,
                JSON.toJSONString(Map.of("receptionStatus", "RECEIVING", "polling", "RESUMED")));
        return 1;
    }
 
    @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("操作人不能为空");
        TTest current = testMapper.selectTestByIdForUpdate(testId);
        if (current == null) throw new ServiceException("测试不存在");
        if (!(stateMatches(current, "RUNNING", "RECEIVING")
                || stateMatches(current, "PAUSED", "PAUSED")))
            throw new ServiceException("当前测试不能停止");
        Date actionAt = new Date();
        if (testMapper.updateReceptionStopped(testId, updateBy, actionAt) != 1)
            throw new ServiceException("测试状态已变更,请刷新后重试");
        testMapper.insertTestAudit(testId, "TEST_STOPPED", operatorId, updateBy, null,
                JSON.toJSONString(Map.of("testStatus", "COMPLETED", "receptionStatus", "ENDED",
                        "polling", "STOPPED", "release", "IMMEDIATE")));
        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());
    }
 
    @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)
        {
            TTest current = testMapper.selectTestByIdForUpdate(testId);
            if (current == null) throw new ServiceException("测试不存在");
            if (!stateMatches(current, WAITING, NOT_STARTED)) throw new ServiceException("开始接收后不能删除测试");
            loadConfiguration(current);
            // 测试整体删除是无痕例外:先清理全部测试绑定和阈值,再删除主记录,不写审计。
            testMapper.deleteTestRealtimeRecordsByTestId(testId);
            testMapper.deleteTestRealtimeSnapshotsByTestId(testId);
            testMapper.deleteTestStepRecordsByTestId(testId);
            testMapper.deleteTestCycleRecordsByTestId(testId);
            testMapper.deleteTestDataRevisionActionsByTestId(testId);
            testMapper.deleteTestDataOverridesByTestId(testId);
            testMapper.deleteTestConsoleImportRecordsByTestId(testId);
            testMapper.deleteTestCollectorChannelsByTestId(testId);
            testMapper.deleteTestBindingsByTestId(testId);
            testMapper.deleteTestThresholdRowsByTestId(testId);
            testMapper.deleteTestBatteriesByTestId(testId);
            int deleted = testMapper.deleteTestById(testId, updateBy);
            if (deleted != 1) throw new ServiceException("测试配置已变更,请刷新后重试");
            rows += deleted;
        }
        return rows;
    }
 
    @Override
    public List<TTestResult> selectTestResults(TTestResult result)
    {
        if (result == null || result.getTestId() == null) throw new ServiceException("测试ID不能为空");
        selectTestById(result.getTestId());
        return testMapper.selectTestResults(result);
    }
 
    @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("测试不存在");
        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++;
        }
        String oldJson = current.getLogicStepMappingsJson();
        String newJson = JSON.toJSONString(normalized);
        int rows = testMapper.updateLogicStepMappings(testId, newJson, updateBy, new Date());
        if (rows != 1) throw new ServiceException("工步设置已变更,请刷新后重试");
        testMapper.insertTestAudit(testId, "LOGIC_STEP_MAPPING_UPDATED", operatorId, updateBy, oldJson, newJson);
        return rows;
    }
 
    @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("测试详情不能为空");
        TTest current = testMapper.selectTestByIdForUpdate(testId);
        if (current == null) throw new ServiceException("测试不存在");
        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);
        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<TTestBatteryField> currentFields = testMapper.selectCurrentBatteryFields(testId);
        Map<Long, List<TTestBatteryField>> fieldsByBattery = new HashMap<>();
        for (TTestBatteryField 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));
        // 测试不保存字段快照;字段值始终随电池组当前配置读取。
        test.setFieldSnapshots(new ArrayList<>());
        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)
    {
        // 计划工步来自设备观测,不属于测试创建配置,也不写入业务库。
        test.setPlanSteps(new ArrayList<>());
        validateBatteries(test.getBatteries());
        validatePlanSteps(test.getPlanSteps());
        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<TTestBatteryField> fields)
    {
        if (fields == null) return;
        Set<String> fieldNames = new HashSet<>();
        for (TTestBatteryField 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 validatePlanSteps(List<TTestPlanStep> planSteps)
    {
        if (planSteps.isEmpty()) return;
        Set<String> identities = new HashSet<>();
        Set<Integer> orders = new HashSet<>();
        for (TTestPlanStep step : planSteps)
        {
            if (step == null || step.getCycleNo() == null || step.getCycleNo() < 1
                    || step.getStepNo() == null || step.getStepNo() < 1
                    || step.getSortOrder() == null || step.getSortOrder() < 1)
                throw new ServiceException("计划工步序号必须为正整数");
            step.setStepType(trimToNull(step.getStepType()));
            step.setStepName(trimToNull(step.getStepName()));
            if (step.getStepType() == null || step.getStepName() == null || step.getParameters() == null)
                throw new ServiceException("计划工步配置不完整");
            if (!identities.add(step.getCycleNo() + ":" + step.getStepNo()) || !orders.add(step.getSortOrder()))
                throw new ServiceException("计划工步序号不可重复");
            step.setParametersJson(JSON.toJSONString(step.getParameters()));
        }
    }
 
    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);
    }
}