From c19dbe36f39315c5bc4d2b17f2969517b442d0ad Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期二, 08 九月 2026 16:25:03 +0800
Subject: [PATCH] 实现化成工步方向与多工步曲线快照

---
 ruoyi-admin/src/main/java/com/battery/business/runtime/ReportStepDirectionConfig.java  |   14 ++++
 battery-device-bts/src/main/java/com/battery/device/bts/BtsStepDirectionResolver.java  |   19 ++++++
 battery-business/src/main/java/com/battery/business/report/FormationReportRequest.java |    8 ++
 battery-device-api/src/main/java/com/battery/device/api/StepDirectionResolver.java     |   10 +++
 battery-business/src/main/java/com/battery/business/report/FormationReportService.java |  111 ++++++++++++++++++++++++++++++------
 5 files changed, 142 insertions(+), 20 deletions(-)

diff --git a/battery-business/src/main/java/com/battery/business/report/FormationReportRequest.java b/battery-business/src/main/java/com/battery/business/report/FormationReportRequest.java
index 67190e8..ab88488 100644
--- a/battery-business/src/main/java/com/battery/business/report/FormationReportRequest.java
+++ b/battery-business/src/main/java/com/battery/business/report/FormationReportRequest.java
@@ -20,7 +20,10 @@
     public record Settings(@NotNull @Size(min = 1, max = 100) List<@NotNull @Valid FormationField> fields,
             @NotNull Map<String, @NotNull String> values,
             @NotNull Map<String, @NotNull Integer> chargeStepSelections,
-            List<@NotNull String> metrics, Map<String, @NotNull String> seriesColors)
+            List<@NotNull String> metrics, Map<String, @NotNull String> seriesColors,
+            Map<String, List<@NotNull Integer>> manualChargeSteps,
+            Map<String, @Valid StepSelection> curveStepSelections,
+            List<@NotNull String> hiddenSeriesKeys, Integer activeCycle)
     {
         /** 旧版缺少显隐设置时显示五类指标,不补造测量快照。 */
         public List<String> metrics() { return metrics == null ? List.of("voltage", "current", "positiveVoltage", "negativeVoltage", "temperature") : metrics; }
@@ -32,6 +35,9 @@
         public void rejectUnknown(String field, Object value) { throw new ServiceException("化成设置不允许提交属性:" + field); }
     }
 
+    /** 两组选择分别按来源循环归属;每个工步独立成线,不合并测量。 */
+    public record StepSelection(@NotNull List<@NotNull Integer> charge, @NotNull List<@NotNull Integer> discharge) { }
+
     /** 编辑保持后端冻结来源范围,不接收替代来源或测量。 */
     public record Edit(@Positive int baseVersionNo, @NotBlank @Size(max = 100) String name,
             @Size(max = 2000) String description, @NotNull @Valid Settings settings,
diff --git a/battery-business/src/main/java/com/battery/business/report/FormationReportService.java b/battery-business/src/main/java/com/battery/business/report/FormationReportService.java
index 6e8b9de..6cd9d20 100644
--- a/battery-business/src/main/java/com/battery/business/report/FormationReportService.java
+++ b/battery-business/src/main/java/com/battery/business/report/FormationReportService.java
@@ -14,6 +14,8 @@
 import com.alibaba.fastjson2.JSON;
 import com.alibaba.fastjson2.JSONObject;
 import com.alibaba.fastjson2.JSONWriter;
+import com.battery.device.api.StepDirectionResolver;
+import com.battery.device.api.StepDirectionResolver.Direction;
 import com.battery.business.domain.CurrentBatteryField;
 import com.battery.business.domain.TBatteryGroupField;
 import com.battery.business.domain.TTest;
@@ -31,21 +33,22 @@
 @Service
 public class FormationReportService
 {
+    private final StepDirectionResolver directions;
     private final ITTestService tests;
     private final ITestDataQueryService data;
     private final FormationReportRepository reports;
     private final ITBatteryGroupFieldService definitions;
 
     public FormationReportService(ITTestService tests, ITestDataQueryService data,
-            FormationReportRepository reports, ITBatteryGroupFieldService definitions)
-    { this.tests = tests; this.data = data; this.reports = reports; this.definitions = definitions; }
+            FormationReportRepository reports, ITBatteryGroupFieldService definitions, StepDirectionResolver directions)
+    { 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) { }
-    public record ChargeStep(int stepNo, String name, String stepType, BigDecimal endVoltage) { }
+    public record ChargeStep(int stepNo, String name, String stepType, BigDecimal endVoltage, Direction direction) { }
     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, 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) { }
 
@@ -94,7 +97,7 @@
             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()), battery, actual), curves(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), stepCurves(realtime, actual)));
         }
         return new Matrix(items, items.stream().flatMap(item -> item.cycleNos().stream()).distinct().sorted().toList());
     }
@@ -105,8 +108,10 @@
     {
         Settings settings = new Settings(validateFields(request.settings().fields(), true), request.settings().values(),
                 request.settings().chargeStepSelections() == null ? Map.of() : request.settings().chargeStepSelections(),
-                request.settings().metrics(), request.settings().seriesColors());
+                request.settings().metrics(), request.settings().seriesColors(), request.settings().manualChargeSteps(),
+                request.settings().curveStepSelections(), request.settings().hiddenSeriesKeys(), request.settings().activeCycle());
         Matrix matrix = freeze(preview(request.selections()), settings.fields());
+        settings = stepSettings(settings, matrix.items(), false);
         validateValues(settings, matrix.items()); validateChargeSelections(settings, matrix.items());
         var snapshot = new LinkedHashMap<String, Object>();
         snapshot.put("name", request.name().trim()); snapshot.put("description", request.description());
@@ -135,8 +140,9 @@
     {
         var snapshot = current(id, request.baseVersionNo());
         validateFields(request.settings().fields(), false);
-        validateValues(request.settings(), items(snapshot)); validateChargeSelections(request.settings(), items(snapshot));
-        snapshot.put("name", request.name().trim()); snapshot.put("description", request.description()); snapshot.put("settings", request.settings());
+        var settings = "actual-steps".equals(snapshot.getString("curveMode")) ? stepSettings(request.settings(), items(snapshot), false) : request.settings();
+        validateValues(settings, items(snapshot)); validateChargeSelections(settings, items(snapshot));
+        snapshot.put("name", request.name().trim()); snapshot.put("description", request.description()); snapshot.put("settings", settings);
         reports.append(id, request.baseVersionNo() + 1, request.name().trim(), json(snapshot), userId, userName, request.changeSummary().trim());
         return get(id, null);
     }
@@ -152,7 +158,7 @@
         Map<String, String> values = new LinkedHashMap<>();
         settings.values().forEach((key, value) -> { if (allowed.contains(key)) values.put(key, value); });
         snapshot.put("items", matrix.items()); snapshot.put("cycleNos", matrix.cycleNos()); snapshot.put("settings", new Settings(settings.fields(), values,
-                settings.chargeStepSelections() == null ? Map.of() : settings.chargeStepSelections(), settings.metrics(), settings.seriesColors()));
+                settings.chargeStepSelections() == null ? Map.of() : settings.chargeStepSelections(), settings.metrics(), settings.seriesColors(), settings.manualChargeSteps(), settings.curveStepSelections(), settings.hiddenSeriesKeys(), settings.activeCycle()));
         curveUnits(snapshot);
         reports.append(id, request.baseVersionNo() + 1, snapshot.getString("name"), json(snapshot), userId, userName, "按最新来源生成:" + request.changeSummary().trim());
         return get(id, null);
@@ -207,7 +213,7 @@
             }).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
-                            ? field.key().equals(value.getFieldName()) : field.fieldDefinitionId().equals(value.getFieldDefinitionId())))).toList(), item.chargeSteps(), item.curves());
+                            ? field.key().equals(value.getFieldName()) : field.fieldDefinitionId().equals(value.getFieldDefinitionId())))).toList(), item.chargeSteps(), item.curves(), item.stepCurves());
         }).toList();
         return new Matrix(frozen, matrix.cycleNos());
     }
@@ -216,7 +222,9 @@
     {
         if (settings.metrics().stream().anyMatch(value -> value == null || !Set.of("voltage", "current", "positiveVoltage", "negativeVoltage", "temperature").contains(value))
                 || new HashSet<>(settings.metrics()).size() != settings.metrics().size()) throw new ServiceException("化成曲线指标无效或重复");
-        Set<String> sources = new HashSet<>(items.stream().map(item -> item.source().selection().testId() + "|" + item.source().selection().testBatteryId()).toList());
+        Set<String> sources = new HashSet<>(items.stream().map(item -> sourceKey(item)).toList());
+        for (Item item : items) if (item.stepCurves() != null) item.stepCurves().forEach((cycle, ranges) -> ranges.keySet().forEach(step -> sources.add(sourceKey(item) + "|" + cycle + "|" + step)));
+        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())
@@ -249,8 +257,66 @@
         return result;
     }
 
+    private String sourceKey(Item item) { return item.source().selection().testId() + "|" + item.source().selection().testBatteryId(); }
+
+    private Map<Integer, Map<Integer, Curve>> stepCurves(List<Map<String, Object>> rows, List<Integer> cycles)
+    {
+        Map<Integer, Map<Integer, Curve>> result = new LinkedHashMap<>();
+        for (int cycle : cycles) {
+            Map<Integer, Curve> ranges = new LinkedHashMap<>();
+            rows.stream().filter(row -> cycleNo(row) == cycle).map(row -> number(row.get("stepNo"))).filter(no -> no > 0).distinct().sorted().forEach(step -> {
+                var selected = rows.stream().filter(row -> cycleNo(row) == cycle && number(row.get("stepNo")) == step).toList();
+                // 每实际工步按最早柜/采集器时间归零,显隐及选组均不改变冻结点。
+                ranges.put(step, curves(selected, List.of(cycle)).get(cycle));
+            });
+            result.put(cycle, ranges);
+        }
+        return result;
+    }
+
+    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)) throw new ServiceException("同一实际工步不能同时指定为充电和放电");
+            selected.put(key, choice);
+        }
+        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))); }
+        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);
+    }
+
     private void validateChargeSelections(Settings settings, List<Item> items)
     {
+        if (items.stream().allMatch(item -> item.stepCurves() != null)) return;
         Map<String, Integer> selections = settings.chargeStepSelections() == null ? Map.of() : settings.chargeStepSelections();
         for (Item item : items) for (int cycle : item.cycleNos()) {
             String key = item.source().selection().testId() + "|" + item.source().selection().testBatteryId() + "|" + cycle;
@@ -267,18 +333,25 @@
         }
     }
 
-    private Map<Integer, List<ChargeStep>> chargeSteps(List<Map<String, Object>> rows, TTestBattery battery, List<Integer> cycles)
+    private Map<Integer, List<ChargeStep>> chargeSteps(List<Map<String, Object>> rows, List<Map<String, Object>> realtime, TTestBattery battery, List<Integer> cycles)
     {
         Map<Integer, List<ChargeStep>> result = new LinkedHashMap<>();
-        for (int cycle : cycles) result.put(cycle, rows == null ? List.of() : rows.stream()
-                .filter(row -> Objects.equals(battery.getBatteryNo(), row.get("batteryNo")) && cycleNo(row) == cycle)
-                .map(row -> new ChargeStep(number(row.get("stepNo")), String.valueOf(row.getOrDefault("stepName", "")),
-                        String.valueOf(row.getOrDefault("stepType", "")), decimal(row.get("endVoltage"))))
-                // 无法可靠识别类型时保留该循环的全部实际工步,由操作员选择,不凭名称或电流猜测。
-                .filter(step -> step.stepNo() > 0).toList());
+        for (int cycle : cycles) {
+            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)));
+            }
+            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)));
+            }
+            result.put(cycle, new ArrayList<>(found.values()));
+        }
         return result;
     }
-    private boolean isReliableCharge(String type) { String value = type == null ? "" : type.trim().toLowerCase(); return value.equals("cc") || value.equals("charge") || value.equals("charging"); }
+    private boolean isReliableCharge(String type) { return directions.resolve(type) == Direction.CHARGE; }
     private int number(Object value) { return value instanceof Number n ? n.intValue() : 0; }
     private BigDecimal decimal(Object value) { return value instanceof Number n ? new BigDecimal(n.toString()) : null; }
 
diff --git a/battery-device-api/src/main/java/com/battery/device/api/StepDirectionResolver.java b/battery-device-api/src/main/java/com/battery/device/api/StepDirectionResolver.java
new file mode 100644
index 0000000..00efdf9
--- /dev/null
+++ b/battery-device-api/src/main/java/com/battery/device/api/StepDirectionResolver.java
@@ -0,0 +1,10 @@
+package com.battery.device.api;
+
+/** 将已保存的实际工步类型解释为充电、放电或未知,不依据测量值猜测。 */
+public interface StepDirectionResolver
+{
+    enum Direction { CHARGE, DISCHARGE, UNKNOWN }
+
+    /** 返回有明确协议依据的方向;未定义或混合类型保持未知。 */
+    Direction resolve(String stepType);
+}
diff --git a/battery-device-bts/src/main/java/com/battery/device/bts/BtsStepDirectionResolver.java b/battery-device-bts/src/main/java/com/battery/device/bts/BtsStepDirectionResolver.java
new file mode 100644
index 0000000..87bd68a
--- /dev/null
+++ b/battery-device-bts/src/main/java/com/battery/device/bts/BtsStepDirectionResolver.java
@@ -0,0 +1,19 @@
+package com.battery.device.bts;
+
+import java.util.Locale;
+import java.util.Set;
+import com.battery.device.api.StepDirectionResolver;
+
+/** BTS 类型解释仅留在协议模块,供已保存实际工步的报表读取使用。 */
+public final class BtsStepDirectionResolver implements StepDirectionResolver
+{
+    /** 按《BTS API技术协议》2.2 识别;pcccv、工况和脉冲未明确单向,不猜测。 */
+    @Override
+    public Direction resolve(String stepType)
+    {
+        String type = stepType == null ? "" : stepType.trim().toLowerCase(Locale.ROOT);
+        if (Set.of("cc", "cv", "cccv", "cp", "cr", "charge", "charging").contains(type)) return Direction.CHARGE;
+        if (Set.of("dc", "dp", "dr", "dv", "cccd", "discharge", "discharging").contains(type)) return Direction.DISCHARGE;
+        return Direction.UNKNOWN;
+    }
+}
diff --git a/ruoyi-admin/src/main/java/com/battery/business/runtime/ReportStepDirectionConfig.java b/ruoyi-admin/src/main/java/com/battery/business/runtime/ReportStepDirectionConfig.java
new file mode 100644
index 0000000..a245aff
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/battery/business/runtime/ReportStepDirectionConfig.java
@@ -0,0 +1,14 @@
+package com.battery.business.runtime;
+
+import com.battery.device.api.StepDirectionResolver;
+import com.battery.device.bts.BtsStepDirectionResolver;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/** 装配已保存 BTS 工步的方向解释,不触发任何设备请求。 */
+@Configuration
+public class ReportStepDirectionConfig
+{
+    @Bean
+    public StepDirectionResolver reportStepDirectionResolver() { return new BtsStepDirectionResolver(); }
+}

--
Gitblit v1.7.1