From b2cf27e4e124608820062ceddea1803d2c2e4b6e Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期日, 06 九月 2026 16:57:07 +0800
Subject: [PATCH] 实现对比报表首次保存与独立快照重开

---
 battery-business/src/main/java/com/battery/business/report/CompareReportRepository.java |   55 +++++++++
 battery-business/src/main/java/com/battery/business/report/CompareReportRequest.java    |   25 ++++
 battery-business/src/main/java/com/battery/business/report/CompareReportService.java    |  167 +++++++++++++++++++++++++++
 ruoyi-admin/src/main/java/com/battery/business/web/CompareReportController.java         |   54 +++++++++
 ruoyi-admin/src/main/resources/db/migration-final/V10__compare_report_first_version.sql |   30 +++++
 5 files changed, 331 insertions(+), 0 deletions(-)

diff --git a/battery-business/src/main/java/com/battery/business/report/CompareReportRepository.java b/battery-business/src/main/java/com/battery/business/report/CompareReportRepository.java
new file mode 100644
index 0000000..f7ffd21
--- /dev/null
+++ b/battery-business/src/main/java/com/battery/business/report/CompareReportRepository.java
@@ -0,0 +1,55 @@
+package com.battery.business.report;
+
+import java.sql.Timestamp;
+import java.util.List;
+import javax.sql.DataSource;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.stereotype.Repository;
+import com.ruoyi.common.exception.ServiceException;
+
+/** 报表身份和版本存储;重读只访问报表表,不联查来源测试。 */
+@Repository
+public class CompareReportRepository
+{
+    private final JdbcTemplate jdbc;
+
+    public CompareReportRepository(DataSource dataSource) { jdbc = new JdbcTemplate(dataSource); }
+
+    public record Summary(long id, String name, String creator, long createdAt) { }
+    public record Version(Summary report, int versionNo, String snapshotJson) { }
+
+    /** 查询所有已保存报表摘要,允许授权用户跨创建人查看。 */
+    public List<Summary> list()
+    {
+        return jdbc.query("select report_id, report_name, creator_name, created_at_utc from t_compare_report order by created_at_utc desc, report_id desc",
+                (rs, index) -> new Summary(rs.getLong(1), rs.getString(2), rs.getString(3), rs.getTimestamp(4).getTime()));
+    }
+
+    /** 重读首个完整版本;来源不存在不影响结果。 */
+    public Version get(long id)
+    {
+        var rows = jdbc.query("select r.report_id, r.report_name, r.creator_name, r.created_at_utc, v.version_no, v.snapshot_json "
+                + "from t_compare_report r join t_compare_report_version v on v.report_id=r.report_id and v.version_no=1 where r.report_id=?",
+                (rs, index) -> new Version(new Summary(rs.getLong(1), rs.getString(2), rs.getString(3), rs.getTimestamp(4).getTime()),
+                        rs.getInt(5), rs.getString(6)), id);
+        if (rows.isEmpty()) throw new ServiceException("对比报表不存在");
+        return rows.get(0);
+    }
+
+    /** 由业务事务调用,身份与首个版本任一写入失败必须一起回滚。 */
+    public long insert(String name, String snapshot, long operatorId, String operatorName, long createdAt)
+    {
+        var keys = new GeneratedKeyHolder();
+        jdbc.update(connection -> {
+            var statement = connection.prepareStatement("insert into t_compare_report(report_name,creator_id,creator_name,created_at_utc) values(?,?,?,?)", java.sql.Statement.RETURN_GENERATED_KEYS);
+            statement.setString(1, name); statement.setLong(2, operatorId); statement.setString(3, operatorName);
+            statement.setTimestamp(4, new Timestamp(createdAt));
+            return statement;
+        }, keys);
+        long id = keys.getKey().longValue();
+        jdbc.update("insert into t_compare_report_version(report_id,version_no,snapshot_json,operator_id,operator_name,created_at_utc,change_summary) values(?,1,?,?,?,?,?)",
+                id, snapshot, operatorId, operatorName, new Timestamp(createdAt), "首次保存对比报表");
+        return id;
+    }
+}
diff --git a/battery-business/src/main/java/com/battery/business/report/CompareReportRequest.java b/battery-business/src/main/java/com/battery/business/report/CompareReportRequest.java
new file mode 100644
index 0000000..7672837
--- /dev/null
+++ b/battery-business/src/main/java/com/battery/business/report/CompareReportRequest.java
@@ -0,0 +1,25 @@
+package com.battery.business.report;
+
+import java.util.List;
+import java.util.Map;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;
+import jakarta.validation.constraints.Size;
+
+/** 首次保存仅接收来源身份和报表展示内容,不接收测量快照。 */
+public record CompareReportRequest(
+        @NotBlank @Size(max = 100) String name,
+        @Size(max = 2000) String description,
+        @Size(max = 500) String experimentName,
+        @NotNull @Size(min = 2) List<@NotNull @Valid Selection> selections,
+        @NotNull @Valid Settings settings)
+{
+    public record Selection(@NotNull @Positive Long testId, @NotNull @Positive Long testBatteryId,
+            @NotNull @Positive Integer cycleNo, @Positive Integer stepNo, @Positive Integer logicalNo) { }
+
+    public record Settings(@NotNull List<String> metrics, @NotNull List<String> seriesColors,
+            @NotNull List<String> batteryTemplateFields, @NotNull List<String> specialFields,
+            @NotNull Map<String, Map<String, String>> detailFieldValues) { }
+}
diff --git a/battery-business/src/main/java/com/battery/business/report/CompareReportService.java b/battery-business/src/main/java/com/battery/business/report/CompareReportService.java
new file mode 100644
index 0000000..e953489
--- /dev/null
+++ b/battery-business/src/main/java/com/battery/business/report/CompareReportService.java
@@ -0,0 +1,167 @@
+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.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) { }
+    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;
+            result.addAll(candidates(test, data.records(test.getTestId())));
+        }
+        return result;
+    }
+
+    /** 预览所选当前有效记录;相同电池的不同循环或工步是独立比对项。 */
+    public List<Item> preview(List<Selection> selections)
+    {
+        if (selections == null || selections.isEmpty() || selections.contains(null)
+                || 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());
+        List<Item> items = preview(request.selections()).stream().map(item -> new Item(item.source(), item.zeroTime(), item.records(), item.logicalMapping(),
+                item.businessFields().stream().filter(field -> request.settings().batteryTemplateFields().contains(field.getFieldName())).toList())).toList();
+        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); snapshot.put("settings", request.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)
+    {
+        var version = reports.get(id);
+        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());
+        return result;
+    }
+
+    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("报表字段无效或重复");
+    }
+
+    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);
+    }
+
+    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();
+    }
+}
diff --git a/ruoyi-admin/src/main/java/com/battery/business/web/CompareReportController.java b/ruoyi-admin/src/main/java/com/battery/business/web/CompareReportController.java
new file mode 100644
index 0000000..9570eaa
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/battery/business/web/CompareReportController.java
@@ -0,0 +1,54 @@
+package com.battery.business.web;
+
+import com.battery.business.report.CompareReportRepository;
+import com.battery.business.report.CompareReportService;
+import com.battery.business.report.CompareReportRequest;
+import jakarta.validation.Valid;
+import java.util.List;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+
+/** 对比报表按正式动作授权,创建人不限制报表可见范围。 */
+@RestController
+@RequestMapping("/system/compare-report")
+public class CompareReportController extends BaseController
+{
+    private final CompareReportRepository reports;
+    private final CompareReportService service;
+    public CompareReportController(CompareReportRepository reports, CompareReportService service)
+    { this.reports = reports; this.service = service; }
+
+    /** 查询报表摘要;筛选与分页沿用现有列表交互。 */
+    @PreAuthorize("@ss.hasPermi('system:compare-report:query')")
+    @GetMapping("/list")
+    public AjaxResult list() { return success().put("rows", reports.list()); }
+
+    /** 新建页查询可选真实来源。 */
+    @PreAuthorize("@ss.hasPermi('system:compare-report:add')")
+    @GetMapping("/sources")
+    public AjaxResult sources() { return success(service.sources()); }
+
+    /** 预览范围由后端验证并读取,不接收浏览器测量事实。 */
+    @PreAuthorize("@ss.hasPermi('system:compare-report:add')")
+    @PostMapping("/preview")
+    public AjaxResult preview(@RequestBody List<CompareReportRequest.Selection> selections)
+    { return success(service.preview(selections)); }
+
+    /** 首次保存报表及独立版本。 */
+    @PreAuthorize("@ss.hasPermi('system:compare-report:add')")
+    @PostMapping
+    public AjaxResult create(@Valid @RequestBody CompareReportRequest request)
+    { return success(service.create(request, getUserId(), getUsername())); }
+
+    /** 读取已保存版本,不检查来源测试是否仍存在。 */
+    @PreAuthorize("@ss.hasPermi('system:compare-report:query')")
+    @GetMapping("/{id}")
+    public AjaxResult get(@PathVariable long id) { return success(service.get(id)); }
+}
diff --git a/ruoyi-admin/src/main/resources/db/migration-final/V10__compare_report_first_version.sql b/ruoyi-admin/src/main/resources/db/migration-final/V10__compare_report_first_version.sql
new file mode 100644
index 0000000..f97551d
--- /dev/null
+++ b/ruoyi-admin/src/main/resources/db/migration-final/V10__compare_report_first_version.sql
@@ -0,0 +1,30 @@
+-- 报表是独立交付内容,不引用测试外键,也不进入测试整体删除范围。
+CREATE TABLE t_compare_report (
+    report_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+    report_name VARCHAR(100) NOT NULL,
+    creator_id BIGINT NOT NULL,
+    creator_name VARCHAR(64) NOT NULL,
+    created_at_utc DATETIME(3) NOT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='对比报表身份';
+
+CREATE TABLE t_compare_report_version (
+    version_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+    report_id BIGINT NOT NULL,
+    version_no INT NOT NULL,
+    snapshot_json JSON NOT NULL,
+    operator_id BIGINT NOT NULL,
+    operator_name VARCHAR(64) NOT NULL,
+    created_at_utc DATETIME(3) NOT NULL,
+    change_summary VARCHAR(500) NOT NULL,
+    UNIQUE KEY uk_compare_report_version (report_id, version_no)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='对比报表独立版本';
+
+INSERT INTO sys_menu(menu_name,parent_id,order_num,path,menu_type,visible,status,perms,create_by,create_time)
+VALUES ('数据分析',0,6,'data-analysis','M','0','0','','system',UTC_TIMESTAMP(3));
+SET @analysis_menu_id = LAST_INSERT_ID();
+INSERT INTO sys_menu(menu_name,parent_id,order_num,path,menu_type,visible,status,perms,create_by,create_time)
+VALUES ('对比报表',@analysis_menu_id,1,'report-compare','C','0','0','','system',UTC_TIMESTAMP(3));
+SET @compare_menu_id = LAST_INSERT_ID();
+INSERT INTO sys_menu(menu_name,parent_id,order_num,path,menu_type,visible,status,perms,create_by,create_time)
+VALUES ('查看',@compare_menu_id,1,'#','F','0','0','system:compare-report:query','system',UTC_TIMESTAMP(3)),
+       ('新增',@compare_menu_id,2,'#','F','0','0','system:compare-report:add','system',UTC_TIMESTAMP(3));

--
Gitblit v1.7.1