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
package com.battery.business.service.report;
 
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONWriter;
import com.battery.business.domain.CurrentBatteryField;
import com.battery.business.domain.TTest;
import com.battery.business.domain.TTestBattery;
import com.battery.business.domain.TTestLogicStepMapping;
import com.battery.business.domain.TTestConsoleDetail;
import com.battery.business.service.report.CompareReportRequest.Selection;
import com.battery.business.service.ITTestService;
import com.battery.business.service.ITestDataQueryService;
import com.ruoyi.common.exception.ServiceException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
/** 首次保存和主动更新解析当前来源,普通编辑及历史查看只读取独立快照。 */
@Service
public class CompareReportService
{
    private final ITTestService tests;
    private final ITestDataQueryService data;
    private final CompareReportRepository reports;
 
    public CompareReportService(ITTestService tests, ITestDataQueryService data, CompareReportRepository reports)
    { this.tests = tests; this.data = data; this.reports = reports; }
 
    public record Candidate(Selection selection, String name, String type, long groupId,
            String batteryGroup, String batteryNo, String process, String experimentName,
            TTestConsoleDetail consoleDetail) { }
    public record Item(Candidate source, Long zeroTime, List<Map<String, Object>> records,
            TTestLogicStepMapping logicalMapping, List<CurrentBatteryField> businessFields) { }
 
    /** 查询检测完成测试中实际存在的比对来源,不借用测试页面动作权限。 */
    public List<Candidate> sources()
    {
        TTest query = new TTest(); query.setTestStatus("COMPLETED");
        List<Candidate> result = new ArrayList<>();
        for (TTest summary : tests.selectTestList(query))
        {
            TTest test = tests.selectTestById(summary.getTestId());
            if (!"COMPLETED".equals(test.getTestStatus())) continue;
            var available = candidates(test, data.records(test.getTestId()));
            Set<String> mapped = new HashSet<>();
            for (var item : available) if (item.selection().logicalNo() != null)
                mapped.add(item.selection().testBatteryId() + "|" + item.selection().cycleNo());
            // 与控制台一致:有保存的逻辑工步时,新选入口展示合并结果;旧快照仍保留原选择。
            result.addAll(available.stream().filter(item -> item.selection().logicalNo() != null
                    || !mapped.contains(item.selection().testBatteryId() + "|" + item.selection().cycleNo())).toList());
        }
        return result;
    }
 
    /** 预览所选当前有效记录;相同电池的不同循环或工步是独立比对项。 */
    public List<Item> preview(List<Selection> selections)
    {
        if (selections == null || selections.isEmpty() || selections.stream().anyMatch(Objects::isNull)
                || new HashSet<>(selections).size() != selections.size())
            throw new ServiceException("请选择不重复的比对项");
        Map<Long, TTest> loadedTests = new HashMap<>();
        Map<Long, List<Map<String, Object>>> loadedRecords = new HashMap<>();
        List<Item> result = new ArrayList<>();
        for (Selection selection : selections)
        {
            if (selection.testId() == null) throw new ServiceException("测试身份不能为空");
            TTest test = loadedTests.computeIfAbsent(selection.testId(), tests::selectTestById);
            if (!"COMPLETED".equals(test.getTestStatus())) throw new ServiceException("仅可选择检测完成测试");
            List<Map<String, Object>> records = loadedRecords.computeIfAbsent(selection.testId(), data::records);
            Candidate source = candidates(test, records).stream().filter(item -> item.selection().equals(selection)).findFirst()
                    .orElseThrow(() -> new ServiceException("比对来源已失效,请重新选择电池、循环和工步"));
            TTestBattery battery = test.getBatteries().stream().filter(item -> item.getTestBatteryId().equals(selection.testBatteryId())).findFirst().orElseThrow();
            TTestLogicStepMapping mapping = selection.logicalNo() == null ? null : test.getLogicStepMappings().stream()
                    .filter(item -> item.getLogicalNo().equals(selection.logicalNo())).findFirst().orElseThrow();
            int firstStep = firstStep(records, selection.cycleNo());
            List<Map<String, Object>> selected = records.stream().filter(row -> Objects.equals(source.batteryNo(), row.get("batteryNo"))
                    && number(row, "cycleNo") == selection.cycleNo()
                    && (mapping == null ? number(row, "stepNo") == selection.stepNo()
                        : inMapping(number(row, "stepNo"), firstStep, mapping))).toList();
            // 柜与采集器使用各自设备时间,共享所选范围零点;隐藏指标不改变范围或零点。
            Long zero = selected.stream().flatMap(row -> List.of("deviceTime", "collectorDeviceTime").stream().map(row::get))
                    .filter(Number.class::isInstance).map(value -> ((Number) value).longValue()).min(Long::compareTo).orElse(null);
            result.add(new Item(source, zero, selected, mapping, battery.getFields()));
        }
        return result;
    }
 
    /** 从后端重新读取来源并原子保存身份与首版,任一失败不留下半份报表。 */
    @Transactional(rollbackFor = Exception.class)
    public Map<String, Object> create(CompareReportRequest request, long operatorId, String operatorName)
    {
        if (request == null || request.selections() == null || request.selections().size() < 2)
            throw new ServiceException("请至少添加两条比对项");
        validateSettings(request.settings());
        validateHiddenSeries(request.settings(), request.selections());
        List<Item> items = preview(request.selections()).stream().map(item -> new Item(item.source(), item.zeroTime(), item.records(), item.logicalMapping(),
                item.businessFields().stream().filter(field -> selectedBusinessField(field, request.settings())).toList())).toList();
        validateSourceOverrides(request.settings(), items);
        Map<String, Object> snapshot = new LinkedHashMap<>();
        snapshot.put("name", request.name().trim()); snapshot.put("description", request.description());
        snapshot.put("experimentName", request.experimentName()); snapshot.put("items", items);
        var settings = snapshotSettings(request.settings());
        settings.putIfAbsent("sourceDetailOverrides", Map.of());
        snapshot.put("settings", settings);
        snapshot.put("units", Map.of("voltage", "V", "current", "A", "temperature", "℃", "positiveVoltage", "V", "negativeVoltage", "V", "capacity", "Ah", "energy", "Wh"));
        snapshot.put("displayUnits", Map.of("voltage", "V", "current", "mA", "temperature", "℃", "positiveVoltage", "V", "negativeVoltage", "V", "capacity", "mAh", "energy", "mWh"));
        snapshot.put("timeUnit", "epoch-milliseconds");
        long id = reports.insert(request.name().trim(), JSON.toJSONString(snapshot, JSONWriter.Feature.WriteMapNullValue), operatorId, operatorName, System.currentTimeMillis());
        return get(id);
    }
 
    /** 重开只读取保存版本,来源测试、字段或映射已删除也不重新解析。 */
    public Map<String, Object> get(long id)
    { return get(id, null); }
 
    /** 指定版本按其冻结内容还原,操作信息来自正式版本记录。 */
    public Map<String, Object> get(long id, Integer versionNo)
    {
        var version = reports.get(id, versionNo);
        Map<String, Object> result = JSON.parseObject(version.snapshotJson());
        result.put("id", id); result.put("versionNo", version.versionNo());
        result.put("creator", version.report().creator()); result.put("createdAt", version.report().createdAt());
        result.put("operatorName", version.operatorName()); result.put("versionCreatedAt", version.versionCreatedAt());
        result.put("changeSummary", version.changeSummary());
        return result;
    }
 
    /** 以最新版本为基准追加展示版本,测量、来源身份和单位完全沿用快照。 */
    @Transactional(rollbackFor = Exception.class)
    public Map<String, Object> edit(long id, CompareReportEditRequest request, long operatorId, String operatorName)
    {
        var snapshot = currentSnapshot(id, request.baseVersionNo());
        validateSettings(request.settings());
        var items = JSON.parseArray(JSON.toJSONString(snapshot.get("items")), Item.class);
        validateHiddenSeries(request.settings(), items.stream().map(item -> item.source().selection()).toList());
        validateSourceOverrides(request.settings(), items);
        snapshot.put("name", request.name().trim()); snapshot.put("description", request.description());
        snapshot.put("experimentName", request.experimentName()); snapshot.put("settings", snapshotSettings(request.settings()));
        reports.append(id, request.baseVersionNo() + 1, request.name().trim(), JSON.toJSONString(snapshot, JSONWriter.Feature.WriteMapNullValue),
                operatorId, operatorName, request.changeSummary().trim());
        return get(id);
    }
 
    /** 基准必须为最新版本且来源仍有效;重读来源生成新快照,保留独立的人工表格值。 */
    @Transactional(rollbackFor = Exception.class)
    public Map<String, Object> refresh(long id, CompareReportEditRequest.Refresh request, long operatorId, String operatorName)
    {
        var snapshot = currentSnapshot(id, request.baseVersionNo());
        var items = JSON.parseArray(JSON.toJSONString(snapshot.get("items")), Item.class);
        var settings = JSON.parseObject(JSON.toJSONString(snapshot.get("settings")), CompareReportRequest.Settings.class);
        var updated = preview(items.stream().map(item -> item.source().selection()).toList()).stream()
                .map(item -> new Item(item.source(), item.zeroTime(), item.records(), item.logicalMapping(),
                        item.businessFields().stream().filter(field -> selectedBusinessField(field, settings)).toList())).toList();
        snapshot.put("items", updated);
        // 旧版仅在明确更新来源时取得新详情;原有按范围保存的人工值不迁移或合并。
        snapshot.getJSONObject("settings").putIfAbsent("sourceDetailOverrides", Map.of());
        reports.append(id, request.baseVersionNo() + 1, snapshot.getString("name"), JSON.toJSONString(snapshot, JSONWriter.Feature.WriteMapNullValue),
                operatorId, operatorName, "按最新来源生成:" + request.changeSummary().trim());
        return get(id);
    }
 
    private com.alibaba.fastjson2.JSONObject currentSnapshot(long id, int baseVersionNo)
    {
        // 同一报表内串行追加;陈旧页面需重新打开,不从历史版生成回滚版本。
        reports.lock(id);
        var current = reports.get(id);
        if (current.versionNo() != baseVersionNo) throw new ServiceException("报表已有新版本,请重新打开后编辑");
        return JSON.parseObject(current.snapshotJson());
    }
 
    /** 授权用户删除整份报表及全部版本,不影响来源测试;删除与最小日志同成同败。 */
    @Transactional(rollbackFor = Exception.class)
    public void delete(long id, long operatorId, String operatorName)
    {
        // 与版本追加共用身份锁,避免删除期间再写入孤立版本;重复删除不另记日志。
        reports.lock(id);
        reports.delete(id, operatorId, operatorName);
    }
 
    private void validateSettings(CompareReportRequest.Settings settings)
    {
        Set<String> metrics = Set.of("voltage", "current", "temperature", "positiveVoltage", "negativeVoltage");
        if (settings == null || !metrics.containsAll(settings.metrics())) throw new ServiceException("报表指标无效");
        if (settings.seriesColors().stream().anyMatch(color -> color == null || !color.matches("#[0-9a-fA-F]{6}")))
            throw new ServiceException("曲线颜色无效");
        List<String> fields = new ArrayList<>(settings.batteryTemplateFields()); fields.addAll(settings.specialFields());
        if (fields.stream().anyMatch(field -> field == null || field.isBlank() || field.length() > 100)
                || new HashSet<>(fields).size() != fields.size()) throw new ServiceException("报表字段无效或重复");
        if (settings.fieldStructure() != null)
        {
            var structure = settings.fieldStructure();
            if (structure.stream().anyMatch(Objects::isNull) || structure.size() != fields.size()
                    || !new HashSet<>(structure.stream().map(CompareField::name).toList()).equals(new HashSet<>(fields))
                    || structure.stream().anyMatch(field -> "battery".equals(field.category()) != settings.batteryTemplateFields().contains(field.name())))
                throw new ServiceException("报表字段结构与所选字段不一致");
        }
    }
 
    private boolean selectedBusinessField(CurrentBatteryField value, CompareReportRequest.Settings settings)
    {
        // 新结构按共享定义身份归属,字段改名不借用另一个同名字段的值;旧版本继续按其冻结名称读取。
        if (settings.fieldStructure() == null) return settings.batteryTemplateFields().contains(value.getFieldName());
        return settings.fieldStructure().stream().anyMatch(field -> "battery".equals(field.category())
                && (field.fieldDefinitionId() == null ? field.name().equals(value.getFieldName()) : field.fieldDefinitionId().equals(value.getFieldDefinitionId())));
    }
 
    private void validateHiddenSeries(CompareReportRequest.Settings settings, List<Selection> selections)
    {
        // 仅允许隐藏本版本的完整来源范围,不按同名测试或电池合并身份。
        var hidden = settings.hiddenSeriesKeys();
        if (hidden == null) return;
        var keys = selections.stream().map(item -> item.testId() + "|" + item.testBatteryId() + "|" + item.cycleNo()
                + "|" + Objects.toString(item.stepNo(), "") + "|" + Objects.toString(item.logicalNo(), "")).toList();
        if (hidden.stream().anyMatch(Objects::isNull) || new HashSet<>(hidden).size() != hidden.size() || !keys.containsAll(hidden))
            throw new ServiceException("隐藏曲线必须属于当前报表且不能重复");
    }
 
    private Map<String, Object> snapshotSettings(CompareReportRequest.Settings settings)
    {
        var result = new LinkedHashMap<String, Object>();
        result.put("metrics", settings.metrics()); result.put("seriesColors", settings.seriesColors());
        result.put("batteryTemplateFields", settings.batteryTemplateFields()); result.put("specialFields", settings.specialFields());
        result.put("detailFieldValues", settings.detailFieldValues());
        // 旧版缺少显隐设置时保持原快照结构,新增设置只随新版本冻结。
        if (settings.hiddenSeriesKeys() != null) result.put("hiddenSeriesKeys", settings.hiddenSeriesKeys());
        // 独立复制结构而非保存模板引用,重开、编辑与打印均不重新读取模板或共享定义。
        if (settings.fieldStructure() != null) result.put("fieldStructure", settings.fieldStructure());
        if (settings.sourceDetailOverrides() != null) result.put("sourceDetailOverrides", settings.sourceDetailOverrides());
        return result;
    }
 
    private void validateSourceOverrides(CompareReportRequest.Settings settings, List<Item> items)
    {
        if (settings.sourceDetailOverrides() == null) return;
        Set<String> identities = new HashSet<>();
        for (var item : items) identities.add(item.source().selection().testId() + "|" + item.source().selection().testBatteryId());
        for (var entry : settings.sourceDetailOverrides().entrySet())
        {
            // 报表人工值只能归入已冻结的来源电池,不接受电池号或列位置作为身份。
            if (!identities.contains(entry.getKey()) || entry.getValue() == null)
                throw new ServiceException("测试详情来源不属于当前报表");
            entry.getValue().validate();
        }
    }
 
    private List<Candidate> candidates(TTest test, List<Map<String, Object>> records)
    {
        List<Candidate> result = new ArrayList<>();
        for (TTestBattery battery : test.getBatteries())
        {
            var batteryRows = records.stream().filter(row -> battery.getBatteryNo().equals(row.get("batteryNo"))).toList();
            for (int cycle : batteryRows.stream().map(row -> number(row, "cycleNo")).filter(value -> value > 0).distinct().sorted().toList())
            {
                var cycleRows = batteryRows.stream().filter(row -> number(row, "cycleNo") == cycle).toList();
                var steps = cycleRows.stream().filter(row -> number(row, "stepNo") > 0)
                        .sorted(Comparator.comparingInt(row -> number(row, "stepNo"))).toList();
                Set<Integer> seen = new HashSet<>();
                for (var row : steps) if (seen.add(number(row, "stepNo")))
                    result.add(candidate(test, battery, cycle, number(row, "stepNo"), null, number(row, "stepNo") + "-" + Objects.toString(row.get("stepName"), "")));
                for (var mapping : test.getLogicStepMappings())
                    if (steps.stream().anyMatch(row -> inMapping(number(row, "stepNo"), firstStep(records, cycle), mapping)))
                        result.add(candidate(test, battery, cycle, null, mapping.getLogicalNo(), "逻辑工步 " + mapping.getLogicalNo() + "-" + mapping.getLogicalName()));
            }
        }
        return result;
    }
 
    private Candidate candidate(TTest test, TTestBattery battery, int cycle, Integer step, Integer logical, String process)
    {
        return new Candidate(new Selection(test.getTestId(), battery.getTestBatteryId(), cycle, step, logical),
                test.getTestName(), test.getTestTypeName(), battery.getGroupId(), battery.getGroupName(), battery.getBatteryNo(), process,
                test.getConsoleDetail() == null ? null : test.getConsoleDetail().getExperimentName(), selectedConsoleDetail(test, battery));
    }
 
    private TTestConsoleDetail selectedConsoleDetail(TTest test, TTestBattery battery)
    {
        var original = test.getConsoleDetail();
        if (original == null) return null;
        var detail = new TTestConsoleDetail();
        detail.setExperimentName(original.getExperimentName());
        detail.setBatteryFields(original.getBatteryFields());
        detail.setSpecialFields(original.getSpecialFields());
        // 快照只冻结所选电池的已保存字符串;不带入其他电池,也不由当前电池组值补缺。
        original.getFieldValues().forEach((field, values) -> {
            if (values.containsKey(battery.getBatteryNo()))
            {
                var selected = new LinkedHashMap<String, String>();
                selected.put(battery.getBatteryNo(), values.get(battery.getBatteryNo()));
                detail.getFieldValues().put(field, selected);
            }
        });
        return detail;
    }
 
    private int number(Map<String, Object> row, String key) { return row.get(key) instanceof Number value ? value.intValue() : 0; }
    private int firstStep(List<Map<String, Object>> records, int cycle)
    {
        return records.stream().filter(row -> number(row, "cycleNo") == cycle).mapToInt(row -> number(row, "stepNo")).filter(value -> value > 0).min().orElse(1);
    }
    private boolean inMapping(int step, int first, TTestLogicStepMapping mapping)
    {
        // 已完成测试不依赖临时设备方案,沿用控制台按循环首个已保存工步推算位置的口径,保留跳号。
        int position = step - first + 1;
        return position >= mapping.getStartStepNo() && position <= mapping.getEndStepNo();
    }
}