yeg
2026-09-08 59a93bdc51766da1387b71df7ba0342e97b068e2
修复三类报表测试详情的独立字段持久化

随整份报表保存完整详情字段结构与人工值,支持删除后重开且不回写控制台。新增三类报表保存、编辑、历史重读及来源隔离测试。
2个文件已添加
5个文件已修改
174 ■■■■■ 已修改文件
battery-business/src/main/java/com/battery/business/report/CompareReportRequest.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/report/CompareReportService.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/report/FormationReportRequest.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/report/FormationReportService.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/report/ReportSourceDetail.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/report/SpliceReportService.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/report/ReportTestDetailPersistenceTest.java 102 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/report/CompareReportRequest.java
@@ -24,7 +24,7 @@
            @NotNull Map<String, @NotNull Map<String, String>> detailFieldValues,
            List<@NotNull @Valid CompareField> fieldStructure,
            List<@NotNull String> hiddenSeriesKeys,
            Map<String, @NotNull @Valid SourceDetailOverride> sourceDetailOverrides)
            Map<String, @NotNull @Valid ReportSourceDetail> sourceDetailOverrides)
    {
        /** 展示设置不能夹带测量快照或其他未支持的修改。 */
        @com.fasterxml.jackson.annotation.JsonAnySetter
@@ -32,7 +32,4 @@
        { throw new com.ruoyi.common.exception.ServiceException("报表设置不允许提交字段:" + field); }
    }
    /** 只记录报表中明确修改的格;缺失与空字符串分别表示未修改和人工清空。 */
    public record SourceDetailOverride(@Size(max = 500) String experimentName,
            Map<String, @Size(max = 500) String> fieldValues) { }
}
battery-business/src/main/java/com/battery/business/report/CompareReportService.java
@@ -241,10 +241,7 @@
            // 报表人工值只能归入已冻结的来源电池,不接受电池号或列位置作为身份。
            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("测试详情字段或填写值无效");
            entry.getValue().validate();
        }
    }
battery-business/src/main/java/com/battery/business/report/FormationReportRequest.java
@@ -24,7 +24,7 @@
            Map<String, List<@NotNull Integer>> manualChargeSteps,
            Map<String, @Valid StepSelection> curveStepSelections,
            List<@NotNull String> hiddenSeriesKeys, Integer activeCycle,
            Map<String, @Valid DetailOverride> sourceDetailOverrides)
            Map<String, @Valid ReportSourceDetail> sourceDetailOverrides)
    {
        /** 旧版缺少显隐设置时显示五类指标,不补造测量快照。 */
        public List<String> metrics() { return metrics == null ? List.of("voltage", "current", "positiveVoltage", "negativeVoltage", "temperature") : metrics; }
@@ -34,13 +34,6 @@
        /** 展示设置拒绝来源和测量快照。 */
        @JsonAnySetter
        public void rejectUnknown(String field, Object value) { throw new ServiceException("化成设置不允许提交属性:" + field); }
    }
    /** 人工详情覆盖与来源快照分开保存,空字符串是明确填写值。 */
    public record DetailOverride(@Size(max = 500) String experimentName, Map<String, @NotNull @Size(max = 500) String> fieldValues)
    {
        @JsonAnySetter
        public void rejectUnknown(String field, Object value) { throw new ServiceException("详情调整不允许提交属性:" + field); }
    }
    /** 两组选择分别按来源循环归属;每个工步独立成线,不合并测量。 */
battery-business/src/main/java/com/battery/business/report/FormationReportService.java
@@ -242,10 +242,8 @@
            // 详情只接受测试电池身份;曲线工步身份不能作为详情列提交。
            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.chargeSteps().forEach((cycle, ranges) -> ranges.forEach(step -> sources.add(sourceKey(item) + "|" + cycle + "|" + step.stepNo())));
battery-business/src/main/java/com/battery/business/report/ReportSourceDetail.java
New file
@@ -0,0 +1,36 @@
package com.battery.business.report;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.ruoyi.common.exception.ServiceException;
/** 报表专属详情完整结构及人工值;空列表表示移除全部对应字段,不回退控制台。 */
public record ReportSourceDetail(List<String> batteryFields, List<String> specialFields, Map<String, String> fieldValues)
{
    /** 校验填写值属于本份详情的字段,名称不解释为共享编号、单位或设备事实。 */
    public void validate()
    {
        Set<String> keys = new HashSet<>();
        collect(keys, "battery", batteryFields);
        collect(keys, "special", specialFields);
        if (fieldValues == null || !keys.containsAll(fieldValues.keySet())
                || fieldValues.values().stream().anyMatch(value -> value == null || value.length() > 500))
            throw new ServiceException("测试详情字段或填写值无效");
    }
    private static void collect(Set<String> keys, String category, List<String> names)
    {
        if (names == null || names.size() > 100) throw new ServiceException("测试详情字段列表无效");
        for (String name : names)
            if (name == null || name.isBlank() || name.length() > 100 || name.contains("\n") || name.contains("\r")
                    || !keys.add(category + "|" + name)) throw new ServiceException("测试详情字段名称无效或重复");
    }
    /** 详情仅接收字段结构和值,不能夹带实验或测量快照。 */
    @JsonAnySetter
    public void rejectUnknown(String name, Object value)
    { throw new ServiceException("测试详情不允许提交属性:" + name); }
}
battery-business/src/main/java/com/battery/business/report/SpliceReportService.java
@@ -256,14 +256,9 @@
  // 详情按测试电池归属;名称与分类不解释为共享字段身份、单位或设备事实。
  if (!sources.containsAll(overrides.keySet())) throw new ServiceException("详情调整不属于所选测试电池");
  for (Object value : overrides.values()) {
   if (!(value instanceof Map<?,?> override) || !Set.of("experimentName", "fieldValues").containsAll(override.keySet())) throw new ServiceException("详情调整无效");
   if (override.containsKey("experimentName") && (!(override.get("experimentName") instanceof String name) || name.length() > 500)) throw new ServiceException("实验名称无效");
   if (!override.containsKey("fieldValues")) continue;
   if (!(override.get("fieldValues") instanceof Map<?,?> values)) throw new ServiceException("详情字段格式无效");
   for (var entry : values.entrySet()) {
    if (!(entry.getKey() instanceof String key) || !key.matches("(?:battery|special)\\|.{1,100}")
        || !(entry.getValue() instanceof String text) || text.length() > 500) throw new ServiceException("详情字段名称或值无效");
   }
   if (!(value instanceof Map<?,?> override) || !Set.of("batteryFields", "specialFields", "fieldValues").containsAll(override.keySet()))
    throw new ServiceException("详情调整无效");
   JSON.parseObject(JSON.toJSONString(override), ReportSourceDetail.class).validate();
  }
 }
ruoyi-admin/src/test/java/com/battery/business/report/ReportTestDetailPersistenceTest.java
New file
@@ -0,0 +1,102 @@
package com.battery.business.report;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.*;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.battery.business.domain.*;
import com.battery.business.service.*;
import com.battery.device.api.StepDirectionResolver;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.jdbc.core.JdbcTemplate;
class ReportTestDetailPersistenceTest
{
    private final ObjectMapper mapper = new ObjectMapper();
    @ParameterizedTest
    @ValueSource(strings = { "compare", "formation", "splice" })
    void reportOwnsAddedAndRemovedFieldsAcrossSaveAndReopen(String type) throws Exception
    {
        var database = new JdbcDataSource();
        database.setURL("jdbc:h2:mem:detail_" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
        var jdbc = new JdbcTemplate(database);
        jdbc.execute("CREATE TABLE t_" + type + "_report(report_id BIGINT AUTO_INCREMENT PRIMARY KEY, report_name VARCHAR(100), creator_id BIGINT, creator_name VARCHAR(100), created_at_utc TIMESTAMP)");
        jdbc.execute("CREATE TABLE t_" + type + "_report_version(report_id BIGINT, version_no INT, snapshot_json CLOB, operator_id BIGINT, operator_name VARCHAR(100), created_at_utc TIMESTAMP, change_summary VARCHAR(500), PRIMARY KEY(report_id,version_no))");
        var tests = mock(ITTestService.class);
        var data = mock(ITestDataQueryService.class);
        var definitions = mock(ITBatteryGroupFieldService.class);
        List<TTest> originals = new ArrayList<>();
        for (long id : List.of(1L, 2L)) {
            var battery = new TTestBattery();
            battery.setTestBatteryId(id); battery.setGroupId(id); battery.setGroupName("组" + id);
            battery.setBatteryNo("同名电池"); battery.setFields(List.of());
            var test = new TTest();
            test.setTestId(id); test.setTestName("测试" + id); test.setTestTypeName("化成");
            test.setTestStatus("COMPLETED"); test.setBatteries(List.of(battery)); test.setLogicStepMappings(List.of());
            var detail = new TTestConsoleDetail();
            detail.setExperimentName("仅控制台实验"); detail.setBatteryFields(List.of("额定容量")); detail.setSpecialFields(List.of("待删除"));
            detail.setFieldValues(new LinkedHashMap<>(Map.of("额定容量", Map.of("同名电池", "来源" + id), "待删除", Map.of("同名电池", "原值"))));
            test.setConsoleDetail(detail); originals.add(test);
            when(tests.selectTestById(id)).thenReturn(test);
            Map<String, Object> row = Map.of("testBatteryId", id, "batteryNo", "同名电池", "cycleNo", 1,
                    "stepNo", 1, "stepName", "充电", "sourceType", "SNAPSHOT", "deviceTime", 1000L, "voltage", 3);
            when(data.records(id)).thenReturn(List.of(row));
            when(data.cycles(id)).thenReturn(List.of()); when(data.steps(id)).thenReturn(List.of());
        }
        var compare = new CompareReportService(tests, data, new CompareReportRepository(database));
        var formation = new FormationReportService(tests, data, new FormationReportRepository(database), definitions, value -> StepDirectionResolver.Direction.UNKNOWN);
        var splice = new SpliceReportService(tests, data, new SpliceReportRepository(database), new SpliceFieldTemplateService(database, definitions));
        var settings = JSON.parseObject(switch (type) {
            case "compare" -> "{\"metrics\":[],\"seriesColors\":[],\"batteryTemplateFields\":[],\"specialFields\":[],\"fieldStructure\":[],\"detailFieldValues\":{}}";
            case "formation" -> "{\"fields\":[{\"key\":\"chargeCapacity\",\"scope\":\"cycle\",\"kind\":\"metric\",\"displayName\":\"充电容量\",\"unit\":\"mAh\"}],\"values\":{},\"chargeStepSelections\":{},\"metrics\":[],\"seriesColors\":{}}";
            default -> "{\"fieldStructure\":[],\"detailFieldValues\":{}}";
        });
        var details = JSON.parseObject("""
                {"1|1":{"batteryFields":["额定容量"],"specialFields":["新字段"],"fieldValues":{"battery|额定容量":"报表甲","special|新字段":""}},
                 "2|2":{"batteryFields":["额定容量"],"specialFields":["新字段"],"fieldValues":{"battery|额定容量":"报表乙","special|新字段":"乙值"}}}
                """);
        settings.put("sourceDetailOverrides", details);
        var request = new JSONObject(); request.put("name", "独立详情"); request.put("description", ""); request.put("settings", settings);
        request.put("selections", type.equals("compare") ? List.of(Map.of("testId", 1, "testBatteryId", 1, "cycleNo", 1, "stepNo", 1), Map.of("testId", 2, "testBatteryId", 2, "cycleNo", 1, "stepNo", 1))
                : List.of(Map.of("testId", 1, "testBatteryId", 1), Map.of("testId", 2, "testBatteryId", 2)));
        Map<String, Object> created = switch (type) {
            case "compare" -> compare.create(mapper.readValue(request.toJSONString(), CompareReportRequest.class), 1, "操作员");
            case "formation" -> formation.create(mapper.readValue(request.toJSONString(), FormationReportRequest.class), 1, "操作员");
            default -> splice.create(mapper.readValue(request.toJSONString(), SpliceReportRequest.class), 1, "操作员");
        };
        long id = ((Number) created.get("id")).longValue();
        assertEquals(details, JSON.parseObject(JSON.toJSONString(created.get("settings"))).getJSONObject("sourceDetailOverrides"));
        clearInvocations(tests, data);
        details.getJSONObject("1|1").put("batteryFields", List.of());
        details.getJSONObject("1|1").put("specialFields", List.of());
        details.getJSONObject("1|1").put("fieldValues", Map.of());
        details.getJSONObject("2|2").getJSONObject("fieldValues").put("special|新字段", "二次修改");
        request.remove("selections"); request.put("baseVersionNo", 1); request.put("changeSummary", "修改本报表详情");
        switch (type) {
            case "compare" -> compare.edit(id, mapper.readValue(request.toJSONString(), CompareReportEditRequest.class), 1, "操作员");
            case "formation" -> formation.edit(id, mapper.readValue(request.toJSONString(), FormationReportRequest.Edit.class), 1, "操作员");
            default -> splice.edit(id, mapper.readValue(request.toJSONString(), SpliceReportRequest.Edit.class), 1, "操作员");
        }
        Map<String, Object> reopened = switch (type) {
            case "compare" -> compare.get(id); case "formation" -> formation.get(id, null); default -> splice.get(id, null);
        };
        var savedDetails = JSON.parseObject(JSON.toJSONString(reopened.get("settings"))).getJSONObject("sourceDetailOverrides");
        assertEquals(details, savedDetails);
        assertTrue(savedDetails.getJSONObject("1|1").getJSONArray("specialFields").isEmpty());
        assertEquals("二次修改", savedDetails.getJSONObject("2|2").getJSONObject("fieldValues").getString("special|新字段"));
        Map<String, Object> history = switch (type) {
            case "compare" -> compare.get(id, 1); case "formation" -> formation.get(id, 1); default -> splice.get(id, 1);
        };
        assertEquals("", JSON.parseObject(JSON.toJSONString(history.get("settings"))).getJSONObject("sourceDetailOverrides").getJSONObject("1|1").getJSONObject("fieldValues").getString("special|新字段"));
        verifyNoInteractions(tests, data);
        for (var original : originals) {
            assertEquals(List.of("待删除"), original.getConsoleDetail().getSpecialFields());
            assertEquals("来源" + original.getTestId(), original.getConsoleDetail().getFieldValues().get("额定容量").get("同名电池"));
        }
    }
}