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