yeg
2026-09-09 b837cc1f88e6fa14442f66eecb59c45464dd7bb3
battery-business/src/main/java/com/battery/business/report/FormationReportService.java
@@ -45,22 +45,23 @@
    { this.directions = directions; this.tests = tests; this.data = data; this.reports = reports; this.definitions = definitions; }
    public record Candidate(Selection selection, String name, String type, long groupId, String batteryGroup, String batteryNo, TTestConsoleDetail consoleDetail) { }
    public record ChargeStep(int stepNo, String name, String stepType, BigDecimal endVoltage, Direction direction) { }
    public record ChargeStep(int stepNo, String name, String stepType, BigDecimal endVoltage, Direction direction, BigDecimal capacity) { }
    public record Curve(Long zeroTime, List<Map<String, Object>> records) { }
    public record Item(Candidate source, List<Integer> cycleNos, List<Map<String, Object>> cycles,
            Map<String, Map<String, BigDecimal>> metrics, List<CurrentBatteryField> businessFields,
            Map<Integer, List<ChargeStep>> chargeSteps, Map<Integer, Curve> curves, Map<Integer, Map<Integer, Curve>> stepCurves) { }
    public record Matrix(List<Item> items, List<Integer> cycleNos) { }
    public record Summary(long id, String name, String creator, long createdAt, String testName, String batteryGroup) { }
    public record Summary(long id, String name, String creator, long createdAt, String testName, String batteryGroup, int versionNo) { }
    /** 列表来源名称取自已保存版本,测试删除后仍保留筛选与识别信息。 */
    public List<Summary> list()
    {
        return reports.list().stream().map(summary -> {
            var sources = items(JSON.parseObject(reports.get(summary.id()).snapshotJson()));
        return reports.listWithSnapshots().stream().map(summary -> {
            // 最新快照由一次列表 SQL 带出,避免每份报表再执行一次版本查询。
            var sources = items(JSON.parseObject(summary.snapshotJson()));
            return new Summary(summary.id(), summary.name(), summary.creator(), summary.createdAt(),
                    String.join("、", sources.stream().map(item -> item.source().name()).distinct().toList()),
                    String.join("、", sources.stream().map(item -> item.source().batteryGroup()).distinct().toList()));
                    String.join("、", sources.stream().map(item -> item.source().batteryGroup()).distinct().toList()), summary.versionNo());
        }).toList();
    }
@@ -98,7 +99,8 @@
            var actual = Stream.of(rows, realtime,
                    batteryRows(steps.computeIfAbsent(selection.testId(), data::steps), battery)).flatMap(List::stream)
                    .map(this::cycleNo).filter(no -> no > 0).distinct().sorted().toList();
            items.add(new Item(candidate(test, battery), actual, rows, metrics(rows, actual), battery.getFields(), chargeSteps(steps.get(selection.testId()), realtime, battery, actual), curves(realtime, actual), stepCurves(realtime, actual)));
            // 图表只需按循环共享的曲线;逐工步曲线会重复保存同一批采样,造成详情响应膨胀。
            items.add(new Item(candidate(test, battery), actual, rows, metrics(rows, actual), battery.getFields(), chargeSteps(steps.get(selection.testId()), realtime, battery, actual), curves(realtime, actual), null));
        }
        return new Matrix(items, items.stream().flatMap(item -> item.cycleNos().stream()).distinct().sorted().toList());
    }
@@ -118,6 +120,7 @@
        snapshot.put("curveMode", "actual-steps");
        snapshot.put("name", request.name().trim()); snapshot.put("description", request.description());
        snapshot.put("items", matrix.items()); snapshot.put("cycleNos", matrix.cycleNos()); snapshot.put("settings", settings);
        snapshot.put("calculatedValues", FormationReportCalculations.calculate(matrix.items(), settings));
        snapshot.put("units", Map.of("chargeCapacity", "Ah", "dischargeCapacity", "Ah", "chargeEnergy", "Wh", "dischargeEnergy", "Wh", "efficiency", "%", "energyEfficiency", "%"));
        snapshot.put("displayUnits", FormationField.METRIC_UNITS); snapshot.put("timeUnit", "epoch-milliseconds");
        curveUnits(snapshot);
@@ -148,6 +151,7 @@
        if ("actual-steps".equals(snapshot.getString("curveMode"))) settings = stepSettings(settings, items(snapshot), false);
        validateValues(settings, items(snapshot)); validateChargeSelections(settings, items(snapshot));
        snapshot.put("name", request.name().trim()); snapshot.put("description", request.description()); snapshot.put("settings", settings);
        snapshot.put("calculatedValues", FormationReportCalculations.calculate(items(snapshot), settings));
        reports.append(id, request.baseVersionNo() + 1, request.name().trim(), json(snapshot), userId, userName, request.changeSummary().trim());
        return get(id, null);
    }
@@ -168,6 +172,8 @@
        snapshot.put("curveMode", "actual-steps");
        snapshot.put("items", matrix.items()); snapshot.put("cycleNos", matrix.cycleNos());
        snapshot.put("settings", stepSettings(refreshedSettings, matrix.items(), true));
        validateValues(refreshedSettings, matrix.items());
        snapshot.put("calculatedValues", FormationReportCalculations.calculate(matrix.items(), refreshedSettings));
        curveUnits(snapshot);
        reports.append(id, request.baseVersionNo() + 1, snapshot.getString("name"), json(snapshot), userId, userName, "按最新来源生成:" + request.changeSummary().trim());
        return get(id, null);
@@ -188,8 +194,8 @@
            if (!"battery".equals(field.kind())) {
                // 仅新生成的字段结构纠正旧误写,读取历史快照时不改名。
                String corrected = Map.of("chargePerGram", "每克N充电量", "chargePerGramTotal", "每克F充电量",
                        "capacity1v", "1.0V容量", "capacity1vf", "1.0VF克容量", "dischargeInflection", "拐点容量",
                        "dischargeInflectionTotal", "拐点F克容量").get(field.key());
                        "capacity1v", "1.0V容量", "capacity1vf", "1.0VF完整容量", "dischargeInflection", "拐点容量",
                        "dischargeInflectionTotal", "拐点完整容量").get(field.key());
                return corrected == null ? field : new FormationField(field.key(), field.scope(), field.kind(), corrected, field.unit(), field.fieldDefinitionId());
            }
            Long id = field.fieldDefinitionId();
@@ -227,7 +233,7 @@
                return copy;
            }).toList();
            return new Item(item.source(), item.cycleNos(), rows, metrics(rows, item.cycleNos()), item.businessFields().stream()
                    .filter(value -> fields.stream().anyMatch(field -> "battery".equals(field.kind()) && (field.fieldDefinitionId() == null
                    .filter(value -> FormationReportCalculations.INPUTS.contains(value.getFieldName()) || fields.stream().anyMatch(field -> "battery".equals(field.kind()) && (field.fieldDefinitionId() == null
                            ? field.key().equals(value.getFieldName()) : field.fieldDefinitionId().equals(value.getFieldDefinitionId())))).toList(), item.chargeSteps(), item.curves(), item.stepCurves());
        }).toList();
        return new Matrix(frozen, matrix.cycleNos());
@@ -242,19 +248,24 @@
            // 详情只接受测试电池身份;曲线工步身份不能作为详情列提交。
            if (!sources.containsAll(settings.sourceDetailOverrides().keySet())) throw new ServiceException("详情调整不属于所选测试电池");
            for (var override : settings.sourceDetailOverrides().values()) {
                if (override == null || (override.experimentName() != null && override.experimentName().length() > 500)) throw new ServiceException("详情调整无效");
                if (override.fieldValues() != null && override.fieldValues().entrySet().stream().anyMatch(entry ->
                        entry.getKey() == null || !entry.getKey().matches("(?:battery|special)\\|.{1,100}")
                        || entry.getValue() == null || entry.getValue().length() > 500)) throw new ServiceException("详情字段名称或值无效");
                if (override == null) throw new ServiceException("详情调整无效");
                override.validate();
            }
        }
        for (Item item : items) if (item.stepCurves() != null) item.stepCurves().forEach((cycle, ranges) -> ranges.keySet().forEach(step -> sources.add(sourceKey(item) + "|" + cycle + "|" + step)));
        for (Item item : items) if (item.stepCurves() != null) item.chargeSteps().forEach((cycle, ranges) -> ranges.forEach(step -> sources.add(sourceKey(item) + "|" + cycle + "|" + step.stepNo())));
        if (settings.hiddenSeriesKeys() != null && !sources.containsAll(settings.hiddenSeriesKeys())) throw new ServiceException("隐藏曲线不属于当前来源工步");
        if (!sources.containsAll(settings.seriesColors().keySet()) || settings.seriesColors().values().stream()
                .anyMatch(color -> color == null || !color.matches("#[0-9a-fA-F]{6}"))) throw new ServiceException("化成曲线颜色或来源无效");
        if (settings.values() == null || !valueKeys(settings.fields(), items).containsAll(settings.values().keySet())
                || settings.values().values().stream().anyMatch(value -> value == null || value.length() > 500))
            throw new ServiceException("人工值不属于当前字段、测试电池或实际循环");
        for (var entry : settings.values().entrySet()) {
            String key = entry.getKey().substring(entry.getKey().lastIndexOf('|') + 1);
            if (FormationReportCalculations.RESULTS.contains(key)) throw new ServiceException("自动计算结果不能人工覆盖");
            if ("manual".equals(key) && !entry.getValue().isBlank()
                    && !entry.getValue().trim().matches("(?:\\d+(?:\\.\\d*)?|\\.\\d+)"))
                throw new ServiceException("数字仅允许非负整数或小数");
        }
    }
    private void curveUnits(Map<String, Object> snapshot)
@@ -301,43 +312,24 @@
    private Settings stepSettings(Settings settings, List<Item> items, boolean refresh)
    {
        Map<String, List<Integer>> manual = new LinkedHashMap<>();
        // 化成自动选择全部可靠实际工步,忽略旧展示设置中的人工分组和工步子集。
        Map<String, FormationReportRequest.StepSelection> selected = new LinkedHashMap<>();
        Set<String> allowed = new HashSet<>();
        for (Item item : items) for (int cycle : item.cycleNos()) {
            String key = sourceKey(item) + "|" + cycle;
            allowed.add(key);
            var steps = item.chargeSteps().getOrDefault(cycle, List.of());
            var unknown = steps.stream().filter(step -> step.direction() == Direction.UNKNOWN).map(ChargeStep::stepNo).toList();
            var requested = settings.manualChargeSteps() == null ? List.<Integer>of() : settings.manualChargeSteps().getOrDefault(key, List.of());
            if (refresh) requested = requested.stream().filter(unknown::contains).toList();
            if (requested == null || !unknown.containsAll(requested) || new HashSet<>(requested).size() != requested.size()) throw new ServiceException("人工充电工步不属于当前测试电池、循环或未知类型");
            manual.put(key, requested);
            var charge = steps.stream().filter(step -> step.direction() == Direction.CHARGE).map(ChargeStep::stepNo).toList();
            var discharge = steps.stream().filter(step -> step.direction() == Direction.DISCHARGE).map(ChargeStep::stepNo).toList();
            var chargeAllowed = new HashSet<>(charge); chargeAllowed.addAll(unknown);
            var dischargeAllowed = new HashSet<>(discharge); dischargeAllowed.addAll(unknown);
            var choice = settings.curveStepSelections() == null ? null : settings.curveStepSelections().get(key);
            if (choice == null) choice = new FormationReportRequest.StepSelection(charge.isEmpty() ? requested : charge, discharge);
            if (refresh) choice = new FormationReportRequest.StepSelection(choice.charge().stream().filter(chargeAllowed::contains).toList(), choice.discharge().stream().filter(dischargeAllowed::contains).toList());
            if (choice.charge() == null || choice.discharge() == null || !chargeAllowed.containsAll(choice.charge()) || !dischargeAllowed.containsAll(choice.discharge())
                    || new HashSet<>(choice.charge()).size() != choice.charge().size() || new HashSet<>(choice.discharge()).size() != choice.discharge().size()) throw new ServiceException("曲线工步不属于当前测试电池、循环或充放电分组");
            if (choice.charge().stream().anyMatch(choice.discharge()::contains) || requested.stream().anyMatch(choice.discharge()::contains))
                throw new ServiceException("同一实际工步不能同时指定为充电和放电");
            selected.put(key, choice);
            selected.put(sourceKey(item) + "|" + cycle, new FormationReportRequest.StepSelection(
                    steps.stream().filter(step -> step.direction() == Direction.CHARGE).map(ChargeStep::stepNo).toList(),
                    steps.stream().filter(step -> step.direction() == Direction.DISCHARGE).map(ChargeStep::stepNo).toList()));
        }
        if (!refresh && ((settings.manualChargeSteps() != null && !allowed.containsAll(settings.manualChargeSteps().keySet()))
                || (settings.curveStepSelections() != null && !allowed.containsAll(settings.curveStepSelections().keySet())))) throw new ServiceException("工步选择不属于所选测试电池和循环");
        var cycles = items.stream().flatMap(item -> item.cycleNos().stream()).distinct().sorted().toList();
        Integer active = settings.activeCycle();
        if (active != null && !cycles.contains(active) && !refresh) throw new ServiceException("曲线循环不属于当前报表");
        if (active == null || !cycles.contains(active)) active = cycles.isEmpty() ? null : cycles.get(0);
        Set<String> series = new HashSet<>();
        for (Item item : items) { series.add(sourceKey(item)); item.stepCurves().forEach((cycle, ranges) -> ranges.keySet().forEach(step -> series.add(sourceKey(item) + "|" + cycle + "|" + step))); }
        for (Item item : items) { series.add(sourceKey(item)); item.chargeSteps().forEach((cycle, ranges) -> ranges.forEach(step -> series.add(sourceKey(item) + "|" + cycle + "|" + step.stepNo()))); }
        var colors = new LinkedHashMap<>(settings.seriesColors());
        var hidden = settings.hiddenSeriesKeys() == null ? List.<String>of() : settings.hiddenSeriesKeys();
        if (refresh) { colors.keySet().retainAll(series); hidden = hidden.stream().filter(series::contains).toList(); }
        return new Settings(settings.fields(), settings.values(), settings.chargeStepSelections(), settings.metrics(), colors, manual, selected, hidden, active, settings.sourceDetailOverrides());
        return new Settings(settings.fields(), settings.values(), settings.chargeStepSelections(), settings.metrics(), colors, Map.of(), selected, hidden, active, settings.sourceDetailOverrides());
    }
    private void validateChargeSelections(Settings settings, List<Item> items)
@@ -362,18 +354,32 @@
            Map<Integer, ChargeStep> found = new java.util.TreeMap<>();
            // 实时记录只补充实际范围身份,不代替缺失的工步终止电压。
            for (var row : realtime) if (cycleNo(row) == cycle && number(row.get("stepNo")) > 0) {
                String type = String.valueOf(row.getOrDefault("stepType", ""));
                found.putIfAbsent(number(row.get("stepNo")), new ChargeStep(number(row.get("stepNo")), String.valueOf(row.getOrDefault("stepName", "")), type, null, directions.resolve(type)));
                String type = Objects.toString(row.get("stepType"), "");
                String name = Objects.toString(row.get("stepName"), "");
                found.putIfAbsent(number(row.get("stepNo")), new ChargeStep(number(row.get("stepNo")), name, type, null, stepDirection(type, name), null));
            }
            if (rows != null) for (var row : rows) if (Objects.equals(battery.getBatteryNo(), row.get("batteryNo")) && cycleNo(row) == cycle && number(row.get("stepNo")) > 0) {
                String type = String.valueOf(row.getOrDefault("stepType", ""));
                found.put(number(row.get("stepNo")), new ChargeStep(number(row.get("stepNo")), String.valueOf(row.getOrDefault("stepName", "")), type, decimal(row.get("endVoltage")), directions.resolve(type)));
                String type = Objects.toString(row.get("stepType"), "");
                String name = Objects.toString(row.get("stepName"), "");
                found.put(number(row.get("stepNo")), new ChargeStep(number(row.get("stepNo")), name, type, decimal(row.get("endVoltage")), stepDirection(type, name), decimal(row.get("capacity"))));
            }
            result.put(cycle, new ArrayList<>(found.values()));
        }
        return result;
    }
    private int number(Object value) { return value instanceof Number n ? n.intValue() : 0; }
    private Direction stepDirection(String type, String name)
    {
        // 甲方 Excel 只提供工步名称;有协议类型时禁止用名称覆盖未知/混合类型。
        if (!type.isBlank()) return directions.resolve(type);
        return switch (name.trim()) {
            case "恒流充电" -> Direction.CHARGE;
            case "恒流放电" -> Direction.DISCHARGE;
            case "搁置", "静置" -> Direction.REST;
            default -> Direction.UNKNOWN;
        };
    }
    private BigDecimal decimal(Object value) { return value instanceof Number n ? new BigDecimal(n.toString()) : null; }
    private Set<String> valueKeys(List<FormationField> fields, List<Item> items)
@@ -406,7 +412,7 @@
        return result;
    }
    private boolean eligible(TTest test) { return "COMPLETED".equals(test.getTestStatus()) && "化成".equals(test.getTestTypeName()); }
    private boolean eligible(TTest test) { return test != null && "COMPLETED".equals(test.getTestStatus()) && "化成".equals(test.getTestTypeName()); }
    private Candidate candidate(TTest test, TTestBattery battery)
    { return new Candidate(new Selection(test.getTestId(), battery.getTestBatteryId()), test.getTestName(), test.getTestTypeName(), battery.getGroupId(), battery.getGroupName(), battery.getBatteryNo(), consoleDetail(test, battery)); }
    private TTestConsoleDetail consoleDetail(TTest test, TTestBattery battery)