实现对比报表首次保存与独立快照重开
按正式动作权限读取来源和报表,原子保存报表身份与首版测量、字段、映射和显示设置。来源测试修订或删除不改变既有报表。
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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) { } |
| | | } |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| New file |
| | |
| | | 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)); } |
| | | } |
| New file |
| | |
| | | -- 报表是独立交付内容,不引用测试外键,也不进入测试整体删除范围。 |
| | | 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)); |