yeg
2026-09-09 7100c8a4f7dd3fc7d53ffead4475d7a8abc51c12
完善循环拼接与首页待办真实数据接口
7个文件已添加
6个文件已修改
475 ■■■■■ 已修改文件
battery-business/src/main/java/com/battery/business/report/CompareReportService.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/report/SpliceReportService.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/service/DashboardService.java 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/service/TodoService.java 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/battery/business/web/DashboardController.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/battery/business/web/TodoController.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/SysOperlogController.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/db/migration-final/V21__dashboard_and_todo_permissions.sql 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/report/CompareMergedSourcesTest.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/report/ReportTestDetailPersistenceTest.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/report/SpliceProjectCyclesTest.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/service/DashboardServiceTest.java 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/service/TodoServiceTest.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/report/CompareReportService.java
@@ -49,7 +49,13 @@
        {
            TTest test = tests.selectTestById(summary.getTestId());
            if (!"COMPLETED".equals(test.getTestStatus())) continue;
            result.addAll(candidates(test, data.records(test.getTestId())));
            var available = candidates(test, data.records(test.getTestId()));
            Set<String> mapped = new HashSet<>();
            for (var item : available) if (item.selection().logicalNo() != null)
                mapped.add(item.selection().testBatteryId() + "|" + item.selection().cycleNo());
            // 与控制台一致:有保存的逻辑工步时,新选入口展示合并结果;旧快照仍保留原选择。
            result.addAll(available.stream().filter(item -> item.selection().logicalNo() != null
                    || !mapped.contains(item.selection().testBatteryId() + "|" + item.selection().cycleNo())).toList());
        }
        return result;
    }
@@ -57,7 +63,7 @@
    /** 预览所选当前有效记录;相同电池的不同循环或工步是独立比对项。 */
    public List<Item> preview(List<Selection> selections)
    {
        if (selections == null || selections.isEmpty() || selections.contains(null)
        if (selections == null || selections.isEmpty() || selections.stream().anyMatch(Objects::isNull)
                || new HashSet<>(selections).size() != selections.size())
            throw new ServiceException("请选择不重复的比对项");
        Map<Long, TTest> loadedTests = new HashMap<>();
battery-business/src/main/java/com/battery/business/report/SpliceReportService.java
@@ -9,6 +9,7 @@
import com.battery.business.domain.TTest;
import com.battery.business.domain.TTestBattery;
import com.battery.business.service.ITTestService;
import com.battery.business.service.ITBatteryGroupService;
import com.battery.business.service.ITestDataQueryService;
import com.ruoyi.common.exception.ServiceException;
import org.springframework.stereotype.Service;
@@ -21,8 +22,9 @@
 private final ITestDataQueryService data;
 private final SpliceReportRepository reports;
 private final SpliceFieldTemplateService templates;
 public SpliceReportService(ITTestService tests, ITestDataQueryService data, SpliceReportRepository reports, SpliceFieldTemplateService templates) {
  this.tests = tests; this.data = data; this.reports = reports; this.templates = templates;
 private final ITBatteryGroupService groups;
 public SpliceReportService(ITTestService tests, ITestDataQueryService data, SpliceReportRepository reports, SpliceFieldTemplateService templates, ITBatteryGroupService groups) {
  this.tests = tests; this.data = data; this.reports = reports; this.templates = templates; this.groups = groups;
 }
 public record Candidate(SpliceReportRequest.Selection selection,String name,String type,long groupId,String group,String batteryNo,long startedAt){}
 public record Matrix(List<Map<String,Object>> segments,List<Integer> cycleNos){}
@@ -39,7 +41,7 @@
 }
 /** 校验每个测试电池来源并按提交顺序逐段接续真实循环。 */
 public Matrix preview(List<SpliceReportRequest.Selection> selections) {
  if (selections == null || selections.size() < 2 || selections.contains(null)
  if (selections == null || selections.size() < 2 || selections.stream().anyMatch(Objects::isNull)
      || new HashSet<>(selections).size() != selections.size())
   throw new ServiceException("请选择至少两个不重复的检测完成测试电池");
  Map<Long, TTest> loaded = new LinkedHashMap<>();
@@ -54,14 +56,14 @@
  }
  List<Map<String, Object>> segments = new ArrayList<>();
  Set<Integer> mappedCycles = new TreeSet<>();
  Integer previousEnd = null;
  int position = 0;
  for (var entry : selected.entrySet()) {
   long testId = entry.getKey().testId();
   List<Map<String, Object>> cycles = data.cycles(testId), steps = data.steps(testId), records = data.records(testId);
   List<Map<String, Object>> cycles = data.cycles(testId), steps = data.steps(testId);
   TTestBattery battery = entry.getValue();
    var rows = batteryRows(cycles, battery);
    var stepRows = batteryRows(steps, battery);
    var nos = java.util.stream.Stream.of(rows, stepRows, batteryRows(records, battery)).flatMap(List::stream)
    var nos = java.util.stream.Stream.of(rows, stepRows).flatMap(List::stream)
        .map(this::cycle).filter(no -> no > 0).distinct().sorted().toList();
    Map<String, Object> segment = new LinkedHashMap<>();
    segment.put("source", candidate(loaded.get(testId), battery));
@@ -79,12 +81,14 @@
     });
     frozen.put("fieldValues", values); segment.put("consoleDetail", frozen);
    }
    segment.put("originalCycleNos", nos); segment.put("statistics", statistics(rows, stepRows, nos));
   // 每个测试电池独立计算偏移;空段不占用循环,各指标共用同一定位。
   int offset = previousEnd == null || nos.isEmpty() ? 0 : previousEnd + 1 - nos.get(0);
   segment.put("offset", offset);
   for (int no : nos) mappedCycles.add(no + offset);
   if (!nos.isEmpty()) previousEnd = nos.get(nos.size() - 1) + offset;
    BigDecimal factor = capacityFactor(battery);
    segment.put("capacityFactor", factor);
    segment.put("originalCycleNos", nos); segment.put("statistics", statistics(rows, stepRows, nos, factor));
   // 累计位置只计实际循环点;原循环号单独冻结,不能用数值偏移保留缺号空位。
   Map<String,Integer> positions = new LinkedHashMap<>();
   segment.put("offset", position);
   for (int no : nos) { positions.put(Integer.toString(no), ++position); mappedCycles.add(position); }
   segment.put("cyclePositions", positions);
   segments.add(segment);
  }
  return new Matrix(segments, new ArrayList<>(mappedCycles));
@@ -92,7 +96,13 @@
 private List<Map<String, Object>> batteryRows(List<Map<String, Object>> rows, TTestBattery battery) {
  return rows.stream().filter(row -> Objects.equals(row.get("batteryNo"), battery.getBatteryNo())).toList();
 }
 private Map<String,Object> statistics(List<Map<String,Object>> rows, List<Map<String,Object>> stepRows, List<Integer> nos) {
 private BigDecimal capacityFactor(TTestBattery battery) {
  var group = groups.selectBatteryGroupById(battery.getGroupId());
  if (group == null || group.getF() == null) return null;
  try { var value = new BigDecimal(group.getF().trim()); return value.signum() == 0 ? null : value; }
  catch (NumberFormatException error) { return null; }
 }
 private Map<String,Object> statistics(List<Map<String,Object>> rows, List<Map<String,Object>> stepRows, List<Integer> nos, BigDecimal factor) {
  Map<String,Object> result = new LinkedHashMap<>();
  for (int no : nos) {
   Map<String,Object> values = new LinkedHashMap<>();
@@ -105,9 +115,14 @@
    if (charge != null && discharge != null && charge.doubleValue() > 0)
     values.put("energyEfficiency", new BigDecimal(discharge.toString()).multiply(BigDecimal.valueOf(100))
         .divide(new BigDecimal(charge.toString()), MathContext.DECIMAL128));
    // 与项目图表一致:Ah 转 mAh 后除以来源电池组 F,缺失或零分母不补值。
    Number capacity = num(values.get("dischargeCapacity"));
    if (capacity != null && factor != null)
     values.put("dischargeCapacityF", new BigDecimal(capacity.toString()).multiply(BigDecimal.valueOf(1000)).divide(factor, MathContext.DECIMAL128));
   }
   // 查询接口按实际发生顺序返回工步;先选末记录再取值,空值不得向前回退。
   var actualSteps = stepRows.stream().filter(row -> cycle(row) == no).toList();
   var actualSteps = stepRows.stream().filter(row -> cycle(row) == no)
       .sorted(Comparator.comparingInt(row -> row.get("stepNo") instanceof Number value ? value.intValue() : 0)).toList();
   if (!actualSteps.isEmpty()) values.put("finalVoltage1", actualSteps.get(actualSteps.size() - 1).get("endVoltage"));
   result.put(Integer.toString(no), values);
  }
@@ -124,7 +139,7 @@
  validateStatistics(settings, matrix.segments());
  validateSourceDetails(settings, matrix.segments());
  Map<String,Object> snapshot = new LinkedHashMap<>();
  snapshot.put("spliceMode", "battery-segments");
  snapshot.put("spliceMode", "project-cycles");
  snapshot.put("name", request.name().trim()); snapshot.put("description", request.description());
  snapshot.put("selections", request.selections()); snapshot.put("segments", matrix.segments());
  snapshot.put("cycleNos", matrix.cycleNos()); snapshot.put("settings", settings);
@@ -179,7 +194,7 @@
  overrides.keySet().retainAll(statisticKeys(matrix.segments()));
  settings.put("statisticValues", overrides);
  snapshot.put("settings", settings); snapshot.put("segments", matrix.segments()); snapshot.put("cycleNos", matrix.cycleNos());
  snapshot.put("spliceMode", "battery-segments");
  snapshot.put("spliceMode", "project-cycles");
  reports.append(id, base + 1, snapshot.getString("name"), snapshot.toJSONString(), uid, user, "按最新来源生成:" + summary.trim());
  return get(id, null);
 }
battery-business/src/main/java/com/battery/business/service/DashboardService.java
New file
@@ -0,0 +1,91 @@
package com.battery.business.service;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import javax.sql.DataSource;
import com.battery.business.domain.*;
import com.battery.business.mapper.TTestMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/** 首页只读聚合已有事实,不发起设备控制或为展示新增采样。 */
@Service
public class DashboardService {
    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Hong_Kong");
    private final ITTestService tests;
    private final ITDetectionCabinetService cabinets;
    private final TTestMapper testMapper;
    private final JdbcTemplate jdbc;
    public DashboardService(ITTestService tests, ITDetectionCabinetService cabinets, TTestMapper testMapper, DataSource source) {
        this.tests = tests; this.cabinets = cabinets; this.testMapper = testMapper; jdbc = new JdbcTemplate(source);
    }
    public record Summary(long testCount, long batteryGroupCount, long todayCount, long monthCount) {}
    public record Trend(List<String> labels, List<Long> values) {}
    public record TestRow(long id, String name, String type, String status, String receptionStatus, String step, long warning) {}
    public record CabinetRow(long id, String name, BigDecimal capacity, int channelCount, long usedChannels, long testCount, long batteryCount, String onlineStatus) {}
    public record Warning(long id, long testId, String testName, String item, long occurredAt) {}
    public record Snapshot(Summary summary, Map<String, Trend> trends, List<TestRow> tests, List<CabinetRow> cabinets, List<Warning> warnings) {}
    /** 使用全部未删除测试计算总量及香港自然日/月新增数,统计不受页面分页限制。 */
    public Snapshot read(Instant now) {
        var allTests = tests.selectTestList(new TTest());
        var today = now.atZone(BUSINESS_ZONE).toLocalDate();
        Map<LocalDate, Long> dates = new HashMap<>();
        allTests.stream().filter(test -> test.getCreateTime() != null).forEach(test ->
                dates.merge(test.getCreateTime().toInstant().atZone(BUSINESS_ZONE).toLocalDate(), 1L, Long::sum));
        var summary = new Summary(allTests.size(), jdbc.queryForObject("select count(*) from t_battery_group where del_flag=0", Long.class),
                dates.getOrDefault(today, 0L), dates.entrySet().stream().filter(entry -> YearMonth.from(entry.getKey()).equals(YearMonth.from(today))).mapToLong(Map.Entry::getValue).sum());
        var trends = new LinkedHashMap<String, Trend>();
        trends.put("week", days(today.minusDays(6), today, dates));
        trends.put("month", days(today.withDayOfMonth(1), today, dates));
        List<String> months = new ArrayList<>(); List<Long> counts = new ArrayList<>();
        for (int month = 1; month <= today.getMonthValue(); month++) {
            final var key = YearMonth.of(today.getYear(), month); months.add(month + "月");
            counts.add(dates.entrySet().stream().filter(entry -> YearMonth.from(entry.getKey()).equals(key)).mapToLong(Map.Entry::getValue).sum());
        }
        trends.put("year", new Trend(months, counts));
        var open = testMapper.selectWarningExceptions(null, "OPEN");
        Map<Long, Long> warningCounts = new HashMap<>();
        List<Warning> warnings = open.stream().map(item -> {
            warningCounts.merge(item.getTestId(), 1L, Long::sum);
            return new Warning(item.getExceptionId(), item.getTestId(), item.getTestName(),
                    "DEVICE_OFFLINE".equals(item.getExceptionType()) ? "设备离线" : metricName(item.getMetricCode()),
                    item.getLastOccurredAtUtc() == null ? 0 : item.getLastOccurredAtUtc().getTime());
        }).sorted(Comparator.comparingLong(Warning::occurredAt).reversed()).toList();
        // 首页明确展示“最近记录”,不把持久化历史冒充设备当前工步;多电池分别保留记录身份。
        Map<Long, List<String>> latest = new HashMap<>();
        jdbc.query("select r.test_id,b.battery_no,r.step_no,r.cycle_no from t_test_realtime_record r join t_test_battery b on b.test_battery_id=r.test_battery_id and b.del_flag=0 join (select test_battery_id,max(record_id) record_id from t_test_realtime_record group by test_battery_id) last_record on last_record.record_id=r.record_id",
                (org.springframework.jdbc.core.RowCallbackHandler) rs -> {
                    if (rs.getObject(3) != null && rs.getObject(4) != null)
                        latest.computeIfAbsent(rs.getLong(1), id -> new ArrayList<>()).add(rs.getString(2) + ":工步" + rs.getInt(3) + " / 循环" + rs.getInt(4));
                });
        var rows = allTests.stream().map(test -> new TestRow(test.getTestId(), test.getTestName(), test.getTestTypeName(), test.getTestStatus(),
                test.getReceptionStatus(), String.join(";", latest.getOrDefault(test.getTestId(), List.of())), warningCounts.getOrDefault(test.getTestId(), 0L))).toList();
        Map<Long, long[]> usage = new HashMap<>();
        // 端口、电池和关联测试按未完成测试去重统计;在线事实仍由设备服务独立提供。
        jdbc.query("select c.cabinet_id,count(distinct binding.channel_id),count(distinct t.test_id),count(distinct b.test_battery_id) from t_test_channel_binding binding join t_detection_channel c on c.channel_id=binding.channel_id and c.del_flag=0 join t_test t on t.test_id=binding.test_id and t.del_flag=0 and t.test_status<>'COMPLETED' join t_test_battery b on b.test_battery_id=binding.test_battery_id and b.del_flag=0 group by c.cabinet_id",
                (org.springframework.jdbc.core.RowCallbackHandler) rs -> usage.put(rs.getLong(1), new long[] { rs.getLong(2), rs.getLong(3), rs.getLong(4) }));
        var cabinetRows = cabinets.selectDetectionCabinetList(new TDetectionCabinet()).stream().map(cabinet -> {
            var values = usage.getOrDefault(cabinet.getCabinetId(), new long[3]);
            return new CabinetRow(cabinet.getCabinetId(), cabinet.getCabinetName(), cabinet.getCapacity(),
                    cabinet.getChannelCount() == null ? 0 : cabinet.getChannelCount(), values[0], values[1], values[2],
                    "0".equals(cabinet.getOnlineStatus()) ? "在线" : "离线");
        }).toList();
        return new Snapshot(summary, trends, rows, cabinetRows, warnings);
    }
    private Trend days(LocalDate begin, LocalDate end, Map<LocalDate, Long> dates) {
        List<String> labels = new ArrayList<>(); List<Long> values = new ArrayList<>();
        for (LocalDate day = begin; !day.isAfter(end); day = day.plusDays(1)) {
            labels.add(day.getMonthValue() + "/" + day.getDayOfMonth()); values.add(dates.getOrDefault(day, 0L));
        }
        return new Trend(labels, values);
    }
    private String metricName(String code) {
        if (code == null) return "阈值异常";
        return Map.of("VOLTAGE", "电压", "CURRENT", "电流", "TEMPERATURE", "温度",
                "POSITIVE_POTENTIAL", "正电位", "NEGATIVE_POTENTIAL", "负电位").getOrDefault(code, code);
    }
}
battery-business/src/main/java/com/battery/business/service/TodoService.java
@@ -1,25 +1,74 @@
package com.battery.business.service;
import java.sql.Timestamp;
import java.sql.Statement;
import java.util.List;
import javax.sql.DataSource;
import com.ruoyi.common.exception.ServiceException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** 待办真实存储;所有查询和写操作都按当前负责人身份约束。 */
/** 独立待办持久化,负责人限制始终使用登录用户 ID,不依赖姓名或管理员身份。 */
@Service
public class TodoService {
    private final JdbcTemplate jdbc;
    public TodoService(DataSource source) { this.jdbc = new JdbcTemplate(source); }
    public record Todo(long id, String content, long ownerId, String owner, String status, String creator, long createdAt, long operatedAt, String handleRemark) {}
    public TodoService(DataSource source) { jdbc = new JdbcTemplate(source); }
    public record Todo(long id, String content, long ownerId, String owner, String status,
            String creator, long createdAt, long operatedAt, String handleRemark) {}
    public record UserOption(long id, String name) {}
    private static final String COLUMNS = "select todo_id,content,owner_id,owner_name,status,creator_name,created_at_utc,operated_at_utc,handle_remark from t_todo";
    private static final RowMapper<Todo> ROW = (rs, index) -> new Todo(rs.getLong(1), rs.getString(2), rs.getLong(3), rs.getString(4),
            rs.getString(5), rs.getString(6), rs.getTimestamp(7).getTime(), rs.getTimestamp(8).getTime(), rs.getString(9));
    /** 返回当前负责人自己的全部待办;包括已处理项,不向创建人或管理员额外开放。 */
    public List<Todo> list(long ownerId) {
        return jdbc.query("select todo_id,content,owner_id,owner_name,status,creator_name,created_at_utc,operated_at_utc,coalesce(handle_remark,'') from t_todo where owner_id=? order by operated_at_utc desc,todo_id desc", (rs, i) -> new Todo(rs.getLong(1),rs.getString(2),rs.getLong(3),rs.getString(4),rs.getString(5),rs.getString(6),rs.getTimestamp(7).getTime(),rs.getTimestamp(8).getTime(),rs.getString(9)), ownerId);
        return jdbc.query(COLUMNS + " where owner_id=? order by operated_at_utc desc,todo_id desc", ROW, ownerId);
    }
    public Todo create(String content, long ownerId, String owner, long creatorId, String creator) {
        Timestamp now = new Timestamp(System.currentTimeMillis());
        jdbc.update("insert into t_todo(content,owner_id,owner_name,creator_id,creator_name,status,created_at_utc,operated_at_utc,handle_remark) values(?,?,?,?,?,'待处理',?,?,null)", content.trim(), ownerId, owner, creatorId, creator, now, now);
        return jdbc.queryForObject("select todo_id,content,owner_id,owner_name,status,creator_name,created_at_utc,operated_at_utc,coalesce(handle_remark,'') from t_todo where todo_id=last_insert_id()", (rs,i)->new Todo(rs.getLong(1),rs.getString(2),rs.getLong(3),rs.getString(4),rs.getString(5),rs.getString(6),rs.getTimestamp(7).getTime(),rs.getTimestamp(8).getTime(),rs.getString(9)));
    /** 供新增表单选择启用用户,不要求用户管理模块权限。 */
    public List<UserOption> owners() {
        return jdbc.query("select user_id,coalesce(nullif(nick_name,''),user_name) from sys_user where status='0' and del_flag='0' order by user_id",
                (rs, index) -> new UserOption(rs.getLong(1), rs.getString(2)));
    }
    public boolean handle(long id,long ownerId,String remark) { return jdbc.update("update t_todo set status='已处理',handle_remark=?,operated_at_utc=? where todo_id=? and owner_id=? and status='待处理'",remark.trim(),new Timestamp(System.currentTimeMillis()),id,ownerId)==1; }
    public boolean remove(long id,long ownerId) { return jdbc.update("delete from t_todo where todo_id=? and owner_id=?",id,ownerId)==1; }
    /** 新增后仅返回确认主键,分配给别人时不把该待办回传给非负责人。 */
    @Transactional
    public long create(String content, long ownerId, long creatorId, String creator) {
        String value = required(content, 200, "待办内容");
        var owner = owners().stream().filter(user -> user.id() == ownerId).findFirst()
                .orElseThrow(() -> new ServiceException("请选择启用的负责人"));
        var now = new Timestamp(System.currentTimeMillis());
        var key = new GeneratedKeyHolder();
        // 主键从本次 INSERT 的 JDBC 结果取得,不跨连接使用会话相关的 last_insert_id。
        jdbc.update(connection -> {
            var statement = connection.prepareStatement("insert into t_todo(content,owner_id,owner_name,creator_id,creator_name,status,created_at_utc,operated_at_utc,handle_remark) values(?,?,?,?,?,'待处理',?,?,'')", Statement.RETURN_GENERATED_KEYS);
            statement.setString(1, value); statement.setLong(2, owner.id()); statement.setString(3, owner.name());
            statement.setLong(4, creatorId); statement.setString(5, creator); statement.setTimestamp(6, now); statement.setTimestamp(7, now);
            return statement;
        }, key);
        return key.getKey().longValue();
    }
    /** 负责人只能将自己的待处理事项完成一次;写入条件同时校验身份和当前状态。 */
    public void handle(long id, long ownerId, String remark) {
        String value = required(remark, 500, "处理说明");
        if (jdbc.update("update t_todo set status='已处理',handle_remark=?,operated_at_utc=? where todo_id=? and owner_id=? and status='待处理'",
                value, new Timestamp(System.currentTimeMillis()), id, ownerId) != 1)
            throw new ServiceException("待办不存在、已处理或无权处理");
    }
    /** 按现有删除交互物理删除自己的待办,不涉及测试或异常事实。 */
    public void remove(long id, long ownerId) {
        if (jdbc.update("delete from t_todo where todo_id=? and owner_id=?", id, ownerId) != 1)
            throw new ServiceException("待办不存在或无权删除");
    }
    private String required(String text, int max, String name) {
        if (text == null || text.trim().isEmpty() || text.trim().length() > max)
            throw new ServiceException(name + "不能为空且长度不能超过" + max);
        return text.trim();
    }
}
ruoyi-admin/src/main/java/com/battery/business/web/DashboardController.java
New file
@@ -0,0 +1,19 @@
package com.battery.business.web;
import java.time.Instant;
import com.battery.business.service.DashboardService;
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.*;
/** 首页查询使用独立查看权限,不借用用户管理和测试编辑权限。 */
@RestController
@RequestMapping("/system/dashboard")
public class DashboardController extends BaseController {
    private final DashboardService service;
    public DashboardController(DashboardService service) { this.service = service; }
    /** 汇总当前测试、检测柜和开放异常;个人待办通过自己的负责人接口读取。 */
    @GetMapping @PreAuthorize("@ss.hasAnyPermi('dashboard:workbench:list,dashboard:workbench:query')")
    public AjaxResult get() { return success(service.read(Instant.now())); }
}
ruoyi-admin/src/main/java/com/battery/business/web/TodoController.java
@@ -1,26 +1,37 @@
package com.battery.business.web;
import java.util.List;
import com.battery.business.service.TodoService;
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.*;
/** 待办列表接口;负责人身份由服务端从当前登录会话取得。 */
/** 待办动作权限与负责人身份分别校验,创建身份只取当前登录会话。 */
@RestController
@RequestMapping("/system/todo")
public class TodoController extends BaseController {
    private final TodoService service;
    public TodoController(TodoService service) { this.service = service; }
    @GetMapping("/list") @PreAuthorize("@ss.hasPermi('todo:list:query')")
    /** 仅查询自己的待办,不开放按其他负责人 ID 查询。 */
    @GetMapping("/list") @PreAuthorize("@ss.hasAnyPermi('todo:list:list,todo:list:query')")
    public AjaxResult list() { return success().put("rows", service.list(getUserId())); }
    /** 返回新增待办可选的启用用户。 */
    @GetMapping("/owners") @PreAuthorize("@ss.hasPermi('todo:list:add')")
    public AjaxResult owners() { return success(service.owners()); }
    /** 校验负责人后创建,返回主键确认,不泄露别人待办的详情。 */
    @PostMapping @PreAuthorize("@ss.hasPermi('todo:list:add')")
    public AjaxResult create(@RequestBody CreateRequest request) { if (request == null || request.content() == null || request.content().trim().isEmpty() || request.ownerId() == null || request.ownerName() == null) return error("待办内容和负责人不能为空"); return success(service.create(request.content(), request.ownerId(), request.ownerName(), getUserId(), getUsername())); }
    public AjaxResult create(@RequestBody CreateRequest request) {
        if (request == null || request.ownerId() == null) return error("负责人不能为空");
        return success(service.create(request.content(), request.ownerId(), getUserId(), getUsername()));
    }
    /** 仅负责人可完成自己的待办,已处理不能再次修改。 */
    @PutMapping("/{id}/handle") @PreAuthorize("@ss.hasPermi('todo:list:handle')")
    public AjaxResult handle(@PathVariable long id,@RequestBody HandleRequest request) { if (request == null || request.remark() == null || request.remark().trim().isEmpty()) return error("处理说明不能为空"); return service.handle(id,getUserId(),request.remark()) ? success() : error("待办不存在、已处理或无权处理"); }
    public AjaxResult handle(@PathVariable long id, @RequestBody HandleRequest request) {
        service.handle(id, getUserId(), request == null ? null : request.remark()); return success();
    }
    /** 删除只作用于当前负责人自己的记录。 */
    @DeleteMapping("/{id}") @PreAuthorize("@ss.hasPermi('todo:list:remove')")
    public AjaxResult remove(@PathVariable long id) { return service.remove(id,getUserId()) ? success() : error("待办不存在或无权删除"); }
    public record CreateRequest(String content, Long ownerId, String ownerName) {}
    public AjaxResult remove(@PathVariable long id) { service.remove(id, getUserId()); return success(); }
    public record CreateRequest(String content, Long ownerId) {}
    public record HandleRequest(String remark) {}
}
ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/SysOperlogController.java
@@ -31,7 +31,7 @@
    @Autowired
    private ISysOperLogService operLogService;
    @PreAuthorize("@ss.hasPermi('monitor:operlog:list')")
    @PreAuthorize("@ss.hasAnyPermi('monitor:operlog:list,monitor:operlog:query')")
    @GetMapping("/list")
    public TableDataInfo list(SysOperLog operLog)
    {
ruoyi-admin/src/main/resources/db/migration-final/V21__dashboard_and_todo_permissions.sql
New file
@@ -0,0 +1,35 @@
-- 最终库补齐首页及待办权限;只登记权限,不为任何角色自动增加授权。
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,menu_type,visible,status,perms,create_by,create_time)
SELECT '首页',0,0,'dashboard','M','0','0','','system',UTC_TIMESTAMP(3)
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE path='dashboard' AND menu_type='M');
SET @dashboard_module = (SELECT menu_id FROM sys_menu WHERE path='dashboard' AND menu_type='M' LIMIT 1);
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,menu_type,visible,status,perms,create_by,create_time)
SELECT '工作台',@dashboard_module,1,'workbench','C','0','0','dashboard:workbench:list','system',UTC_TIMESTAMP(3)
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='dashboard:workbench:list');
SET @dashboard_page = (SELECT menu_id FROM sys_menu WHERE perms='dashboard:workbench:list' LIMIT 1);
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,menu_type,visible,status,perms,create_by,create_time)
SELECT '查看',@dashboard_page,1,'#','F','0','0','dashboard:workbench:query','system',UTC_TIMESTAMP(3)
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='dashboard:workbench:query');
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,menu_type,visible,status,perms,create_by,create_time)
SELECT '待办管理',0,5,'todo','M','0','0','','system',UTC_TIMESTAMP(3)
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE path='todo' AND menu_type='M');
SET @todo_module = (SELECT menu_id FROM sys_menu WHERE path='todo' AND menu_type='M' LIMIT 1);
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,menu_type,visible,status,perms,create_by,create_time)
SELECT '待办列表',@todo_module,1,'todo-list','C','0','0','todo:list:list','system',UTC_TIMESTAMP(3)
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='todo:list:list');
SET @todo_menu = (SELECT menu_id FROM sys_menu WHERE perms='todo:list:list' LIMIT 1);
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,menu_type,visible,status,perms,create_by,create_time)
SELECT '查看',@todo_menu,1,'#','F','0','0','todo:list:query','system',UTC_TIMESTAMP(3)
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='todo:list:query');
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,menu_type,visible,status,perms,create_by,create_time)
SELECT '新增待办',@todo_menu,1,'#','F','0','0','todo:list:add','system',UTC_TIMESTAMP(3)
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='todo:list:add');
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,menu_type,visible,status,perms,create_by,create_time)
SELECT '处理待办',@todo_menu,2,'#','F','0','0','todo:list:handle','system',UTC_TIMESTAMP(3)
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='todo:list:handle');
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,menu_type,visible,status,perms,create_by,create_time)
SELECT '删除待办',@todo_menu,3,'#','F','0','0','todo:list:remove','system',UTC_TIMESTAMP(3)
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='todo:list:remove');
-- 权限表仅展示三级结构,将既有操作日志放到系统管理页面层,不新增日志修改能力。
UPDATE sys_menu SET parent_id=1 WHERE menu_id=500 AND parent_id=108 AND perms='monitor:operlog:list';
ruoyi-admin/src/test/java/com/battery/business/report/CompareMergedSourcesTest.java
New file
@@ -0,0 +1,33 @@
package com.battery.business.report;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.*;
import com.battery.business.domain.*;
import com.battery.business.service.*;
import org.junit.jupiter.api.Test;
class CompareMergedSourcesTest {
    @Test
    void selectingSourcesUsesSavedMergeAndPreviewIncludesItsActualSteps() {
        var tests = mock(ITTestService.class); var data = mock(ITestDataQueryService.class);
        var battery = new TTestBattery(); battery.setTestBatteryId(5L); battery.setGroupId(9L); battery.setBatteryNo("1号"); battery.setFields(List.of());
        var test = new TTest(); test.setTestId(1L); test.setTestStatus("COMPLETED"); test.setBatteries(List.of(battery)); test.setLogicStepMappings(List.of());
        when(tests.selectTestList(any())).thenReturn(List.of(test)); when(tests.selectTestById(1L)).thenReturn(test);
        var records = List.of(Map.<String,Object>of("batteryNo", "1号", "cycleNo", 3, "stepNo", 10, "deviceTime", 1000L),
                Map.<String,Object>of("batteryNo", "1号", "cycleNo", 3, "stepNo", 11, "deviceTime", 2000L));
        when(data.records(1L)).thenReturn(records);
        var service = new CompareReportService(tests, data, null);
        var actual = service.sources(); assertEquals(2, actual.size());
        var mapping = new TTestLogicStepMapping(); mapping.setLogicalNo(1); mapping.setLogicalName("合并充电"); mapping.setStartStepNo(1); mapping.setEndStepNo(2);
        test.setLogicStepMappings(List.of(mapping));
        var merged = service.sources(); assertEquals(1, merged.size());
        assertEquals(1, merged.get(0).selection().logicalNo());
        assertNull(merged.get(0).selection().stepNo());
        assertTrue(merged.get(0).process().contains("合并充电"));
        var preview = service.preview(List.of(merged.get(0).selection()));
        assertEquals(records, preview.get(0).records());
        assertEquals(1000, preview.get(0).zeroTime());
        assertEquals(1, service.preview(List.of(actual.get(0).selection())).get(0).records().size(), "旧版来源仍可明确刷新原实际工步");
    }
}
ruoyi-admin/src/test/java/com/battery/business/report/ReportTestDetailPersistenceTest.java
@@ -50,7 +50,7 @@
        }
        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 splice = new SpliceReportService(tests, data, new SpliceReportRepository(database), new SpliceFieldTemplateService(database, definitions), mock(ITBatteryGroupService.class));
        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\":{}}";
ruoyi-admin/src/test/java/com/battery/business/report/SpliceProjectCyclesTest.java
New file
@@ -0,0 +1,46 @@
package com.battery.business.report;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.math.BigDecimal;
import java.util.*;
import com.alibaba.fastjson2.JSON;
import com.battery.business.domain.*;
import com.battery.business.service.*;
import org.junit.jupiter.api.Test;
class SpliceProjectCyclesTest {
    @Test
    void actualCyclesBecomeConsecutivePositionsWithFrozenProjectStatistics() {
        var tests = mock(ITTestService.class);
        var data = mock(ITestDataQueryService.class);
        var groups = mock(ITBatteryGroupService.class);
        var group = new TBatteryGroup(); group.setF("2");
        when(groups.selectBatteryGroupById(9L)).thenReturn(group);
        for (long id : List.of(1L, 2L)) {
            var battery = new TTestBattery(); battery.setTestBatteryId(id); battery.setGroupId(9L);
            battery.setBatteryNo("电池1"); battery.setFields(List.of());
            var test = new TTest(); test.setTestId(id); test.setTestStatus("COMPLETED"); test.setBatteries(List.of(battery));
            when(tests.selectTestById(id)).thenReturn(test);
            var nos = id == 1 ? List.of(1, 3) : List.of(10, 11);
            when(data.cycles(id)).thenReturn(nos.stream().map(no -> Map.<String,Object>of("batteryNo", "电池1", "cycleNo", no, "dischargeCapacity", new BigDecimal("0.004"))).toList());
            when(data.steps(id)).thenReturn(nos.stream().flatMap(no -> List.of(
                Map.<String,Object>of("batteryNo", "电池1", "cycleNo", no, "stepNo", 8),
                Map.<String,Object>of("batteryNo", "电池1", "cycleNo", no, "stepNo", 1, "endVoltage", 4)).stream()).toList());
        }
        var service = new SpliceReportService(tests, data, null, null, groups);
        var result = service.preview(List.of(new SpliceReportRequest.Selection(1L, 1L), new SpliceReportRequest.Selection(2L, 2L)));
        assertEquals(List.of(1, 2, 3, 4), result.cycleNos());
        assertEquals(Map.of("1", 1, "3", 2), result.segments().get(0).get("cyclePositions"));
        assertEquals(Map.of("10", 3, "11", 4), result.segments().get(1).get("cyclePositions"));
        assertEquals(List.of(10, 11), result.segments().get(1).get("originalCycleNos"));
        var stat = JSON.parseObject(JSON.toJSONString(result.segments().get(0).get("statistics"))).getJSONObject("1");
        assertEquals(0, new BigDecimal("2").compareTo(stat.getBigDecimal("dischargeCapacityF")));
        assertNull(stat.get("finalVoltage1"), "末工步缺失不能回退到前一工步电压");
        assertNull(stat.get("finalVoltage2"), "未定义公式不能补值");
        verify(data, never()).records(anyLong());
        group.setF("0");
        var unavailable = service.preview(List.of(new SpliceReportRequest.Selection(1L, 1L), new SpliceReportRequest.Selection(2L, 2L)));
        assertNull(JSON.parseObject(JSON.toJSONString(unavailable.segments().get(0).get("statistics"))).getJSONObject("1").get("dischargeCapacityF"));
    }
}
ruoyi-admin/src/test/java/com/battery/business/service/DashboardServiceTest.java
New file
@@ -0,0 +1,57 @@
package com.battery.business.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.time.Instant;
import java.util.*;
import com.battery.business.domain.*;
import com.battery.business.mapper.TTestMapper;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
class DashboardServiceTest {
    @Test
    void aggregatesAllTestsAtHongKongMidnightAndKeepsDeviceFactsSeparate() {
        var database = new JdbcDataSource(); database.setURL("jdbc:h2:mem:dashboard_" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
        var jdbc = new JdbcTemplate(database);
        jdbc.execute("create table t_battery_group(group_id bigint, del_flag int)");
        jdbc.execute("insert into t_battery_group values(1,0),(2,1)");
        jdbc.execute("create table t_test_realtime_record(record_id bigint,test_id bigint,test_battery_id bigint,step_no int,cycle_no int)");
        jdbc.execute("create table t_test_battery(test_battery_id bigint,battery_no varchar(20),del_flag int)");
        jdbc.execute("create table t_test_channel_binding(channel_id bigint,test_id bigint,test_battery_id bigint)");
        jdbc.execute("create table t_detection_channel(channel_id bigint,cabinet_id bigint,del_flag int)");
        jdbc.execute("create table t_test(test_id bigint,test_status varchar(20),del_flag int)");
        jdbc.execute("insert into t_test values(1,'RUNNING',0),(2,'COMPLETED',0)");
        jdbc.execute("insert into t_test_battery values(1,'电池1',0),(2,'电池2',0)");
        jdbc.execute("insert into t_detection_channel values(1,7,0)");
        jdbc.execute("insert into t_test_channel_binding values(1,1,1),(1,2,2)");
        var tests = mock(ITTestService.class);
        var cabinets = mock(ITDetectionCabinetService.class);
        var mapper = mock(TTestMapper.class);
        var rows = new ArrayList<TTest>();
        for (int index = 0; index < 1001; index++) {
            var test = new TTest(); test.setTestId((long) index + 1); test.setTestStatus("RUNNING"); test.setReceptionStatus("PAUSED");
            test.setCreateTime(Date.from(Instant.parse(index == 0 ? "2026-08-31T15:59:59Z" : "2026-08-31T16:00:00Z"))); rows.add(test);
        }
        when(tests.selectTestList(any())).thenReturn(rows);
        var cabinet = new TDetectionCabinet(); cabinet.setCabinetId(7L); cabinet.setChannelCount(8); cabinet.setOnlineStatus("0");
        when(cabinets.selectDetectionCabinetList(any())).thenReturn(List.of(cabinet));
        var warning = new TTestWarningException(); warning.setExceptionId(3L); warning.setTestId(1L); warning.setExceptionType("DEVICE_OFFLINE");
        when(mapper.selectWarningExceptions(null, "OPEN")).thenReturn(List.of(warning));
        var result = new DashboardService(tests, cabinets, mapper, database).read(Instant.parse("2026-09-01T00:00:00Z"));
        assertEquals(1001, result.summary().testCount());
        assertEquals(1, result.summary().batteryGroupCount());
        assertEquals(1000, result.summary().todayCount());
        assertEquals(1000, result.summary().monthCount());
        assertEquals(List.of(1000L), result.trends().get("month").values());
        assertEquals(1, result.trends().get("year").values().get(7));
        assertEquals(1000, result.trends().get("year").values().get(8));
        assertEquals("在线", result.cabinets().get(0).onlineStatus());
        assertEquals(1, result.cabinets().get(0).usedChannels());
        assertEquals(1, result.cabinets().get(0).testCount());
        assertEquals("RUNNING", result.tests().get(0).status());
        assertEquals("PAUSED", result.tests().get(0).receptionStatus());
        assertEquals("设备离线", result.warnings().get(0).item());
    }
}
ruoyi-admin/src/test/java/com/battery/business/service/TodoServiceTest.java
New file
@@ -0,0 +1,39 @@
package com.battery.business.service;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.*;
import java.util.UUID;
import com.ruoyi.common.exception.ServiceException;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
class TodoServiceTest {
    @Test
    void onlyOwnerCanReadHandleAndRemoveEvenWhenNamesMatch() throws Exception {
        var database = new JdbcDataSource();
        database.setURL("jdbc:h2:mem:todo_" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
        var jdbc = new JdbcTemplate(database);
        jdbc.execute("CREATE TABLE sys_user(user_id BIGINT, user_name VARCHAR(100), nick_name VARCHAR(100), status CHAR(1), del_flag CHAR(1))");
        jdbc.update("INSERT INTO sys_user VALUES(1,'creator','同名','0','0'),(2,'owner','同名','0','0'),(3,'disabled','停用','1','0')");
        var ddl = Files.readString(Path.of("src/main/resources/db/migration-final/V20__todo_records.sql"));
        jdbc.execute(ddl.substring(0, ddl.indexOf("ENGINE=InnoDB")));
        var service = new TodoService(database);
        long id = service.create(" 工作事项 ", 2, 1, "创建人");
        assertTrue(service.list(1).isEmpty());
        assertEquals("工作事项", service.list(2).get(0).content());
        assertEquals(id, service.list(2).get(0).id());
        assertEquals(2, service.owners().size());
        assertThrows(ServiceException.class, () -> service.create("事项", 3, 1, "创建人"));
        assertThrows(ServiceException.class, () -> service.handle(id, 1, "管理员或创建人不可越权"));
        assertThrows(ServiceException.class, () -> service.remove(id, 1));
        assertThrows(ServiceException.class, () -> service.handle(id, 2, " "));
        service.handle(id, 2, "已完成");
        var reopened = new TodoService(database).list(2).get(0);
        assertEquals("已处理", reopened.status());
        assertEquals("已完成", reopened.handleRemark());
        assertThrows(ServiceException.class, () -> service.handle(id, 2, "再次处理"));
        service.remove(id, 2);
        assertTrue(service.list(2).isEmpty());
    }
}