package com.battery.business.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.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> records, TTestLogicStepMapping logicalMapping, List businessFields) { } /** 查询检测完成测试中实际存在的比对来源,不借用测试页面动作权限。 */ public List sources() { TTest query = new TTest(); query.setTestStatus("COMPLETED"); List result = new ArrayList<>(); for (TTest summary : tests.selectTestList(query)) { TTest test = tests.selectTestById(summary.getTestId()); if (!"COMPLETED".equals(test.getTestStatus())) continue; result.addAll(candidates(test, data.records(test.getTestId()))); } return result; } /** 预览所选当前有效记录;相同电池的不同循环或工步是独立比对项。 */ public List preview(List selections) { if (selections == null || selections.isEmpty() || selections.contains(null) || new HashSet<>(selections).size() != selections.size()) throw new ServiceException("请选择不重复的比对项"); Map loadedTests = new HashMap<>(); Map>> loadedRecords = new HashMap<>(); List 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> 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> 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 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 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 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 get(long id) { return get(id, null); } /** 指定版本按其冻结内容还原,操作信息来自正式版本记录。 */ public Map get(long id, Integer versionNo) { var version = reports.get(id, versionNo); Map 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 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 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 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 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 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 snapshotSettings(CompareReportRequest.Settings settings) { var result = new LinkedHashMap(); 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 items) { if (settings.sourceDetailOverrides() == null) return; Set 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("测试详情来源不属于当前报表"); var fields = entry.getValue().fieldValues(); if (fields != null && fields.entrySet().stream().anyMatch(field -> field.getKey() == null || !field.getKey().matches("(?:battery|special)\\|.{1,100}") || field.getValue() == null || field.getValue().length() > 500)) throw new ServiceException("测试详情字段或填写值无效"); } } private List candidates(TTest test, List> records) { List 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 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(); selected.put(battery.getBatteryNo(), values.get(battery.getBatteryNo())); detail.getFieldValues().put(field, selected); } }); return detail; } private int number(Map row, String key) { return row.get(key) instanceof Number value ? value.intValue() : 0; } private int firstStep(List> 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(); } }