yeg
2026-09-29 b78295e8da96c3e5e76befeef9582ce5273dbb74
ruoyi-admin/src/test/java/com/battery/business/web/ConsoleSingleSheetImportDatabaseTest.java
@@ -64,7 +64,7 @@
                new ClassPathResource("db/migration-final/V8__import_records_without_polling_slots.sql"));
        String deviceTime = "dedupe_key varchar(512) not null, device_time_utc timestamp(3) not null, device_time_raw varchar(64) not null, adapter_timezone_id varchar(64) not null, unique(test_battery_id,source_identity,dedupe_key)";
        jdbc.execute("create table t_test_step_record(step_record_id bigint auto_increment primary key," + common + "," + metrics + "," + deviceTime + ")");
        jdbc.execute("create table t_test_cycle_record(cycle_record_id bigint auto_increment primary key," + common + ",charge_capacity_ah decimal(30,12),discharge_capacity_ah decimal(30,12),charge_energy_wh decimal(30,12),discharge_energy_wh decimal(30,12),efficiency decimal(30,12)," + deviceTime + ")");
        jdbc.execute("create table t_test_cycle_record(cycle_record_id bigint auto_increment primary key," + common + ",source_kind varchar(16) default 'DEVICE',charge_capacity_ah decimal(30,12),discharge_capacity_ah decimal(30,12),charge_energy_wh decimal(30,12),discharge_energy_wh decimal(30,12),efficiency decimal(30,12)," + deviceTime + ")");
        var factory = new SqlSessionFactoryBean(); factory.setDataSource(source);
        factory.setTypeAliasesPackage("com.battery.business.domain");
        factory.setMapperLocations(new ClassPathResource("mapper/battery/TTestMapper.xml"));
@@ -107,6 +107,40 @@
        }
        assertEquals("17", mapper.selectPersistedFinalRealtimeRecords(1L).get(0).get("recordno"));
        assertEquals(3, service.selectActions(new TTestDataRevisionAction()).size());
    }
    /**
     * 循环记录的来源类别随记录落库并经查询交回:导入的循环来自授权文件,
     * 由工步层聚合的循环标注为聚合,使界面与导出能区分"推导值"与"设备或文件给的值"。
     */
    @Test void cycleSourceKindDistinguishesImportedAndAggregatedRecords()
    {
        assertEquals(1, submit("CYCLE", List.of(row())).get("importedCount"));
        assertEquals("IMPORTED", mapper.selectPersistedFinalCycleRecords(1L).get(0).get("sourcekind"));
        var aggregated = new com.battery.business.domain.TTestCycleRecord();
        aggregated.setTestId(1L); aggregated.setTestBatteryId(4L); aggregated.setBindingId(5L);
        aggregated.setAdapterCode("adapter-a"); aggregated.setSourceIdentity("adapter-a:27:1:1:2");
        aggregated.setSourceRecordId("neware:442:cycle:3");
        aggregated.setSourceKind(com.battery.device.api.TestDataObservationAdapter.CycleRecord.SOURCE_AGGREGATED);
        aggregated.setDedupeKey("record:neware:442:cycle:3");
        aggregated.setAdapterTimezoneId("Asia/Hong_Kong");
        aggregated.setDeviceTimeUtc(new java.util.Date(1683517331000L));
        aggregated.setDeviceTimeRaw("2023-05-08 11:42:11");
        aggregated.setServerReceivedAtUtc(new java.util.Date(1683517331000L));
        aggregated.setCycleNo(3);
        aggregated.setChargeCapacityAh(new java.math.BigDecimal("2.375"));
        aggregated.setDischargeCapacityAh(new java.math.BigDecimal("1.383"));
        aggregated.setEfficiency(null);
        aggregated.setContentSha256(new byte[32]);
        assertEquals(1, mapper.insertCycleRecord(aggregated));
        var rows = mapper.selectPersistedFinalCycleRecords(1L);
        assertEquals(2, rows.size());
        var stored = rows.stream().filter(item -> "3".equals(String.valueOf(item.get("cycleno")))).findFirst().orElseThrow();
        assertEquals("AGGREGATED", stored.get("sourcekind"));
        // 没有可用充电容量就没有效率:空值不是零。
        assertNull(stored.get("efficiency"));
    }
    @Test void databaseFailureRollsBackEarlierRowsAndTheRevision()
@@ -165,19 +199,31 @@
                org.mockito.Mockito.mock(com.battery.business.service.ITTestService.class));
        var current = new LinkedHashMap<String, Object>(Map.of("extensionMeasurementsJson", extension.toJSONString()));
        var legacy = new LinkedHashMap<String, Object>(Map.of("extensionMeasurementsJson", "{\"chargePeakVoltage\":1.8,\"dischargeValleyVoltage\":0.8}"));
        org.mockito.Mockito.when(queryMapper.selectPersistedFinalStepRecords(1L)).thenReturn(List.of(current, legacy));
        // 设备工步层的四组容量能量是独立事实:查询要交出它们,供方向未确认的工步核对。
        var deviceStep = new LinkedHashMap<String, Object>(Map.of("extensionMeasurementsJson",
                "{\"chargeCapacity\":13.888888888889,\"dischargeCapacity\":0,\"chargeEnergy\":25,\"dischargeEnergy\":0}"));
        org.mockito.Mockito.when(queryMapper.selectPersistedFinalStepRecords(1L)).thenReturn(List.of(current, legacy, deviceStep));
        var result = query.steps(1L);
        assertEquals(new java.math.BigDecimal("1.1709"), result.get(0).get("dischargeMedianVoltage"));
        assertEquals(0.0, ((Number) result.get(0).get("chargeMedianVoltage")).doubleValue());
        assertNull(result.get(1).get("chargeMedianVoltage"));
        assertNull(result.get(1).get("dischargeMedianVoltage"));
        assertEquals(0, new java.math.BigDecimal("13.888888888889").compareTo(
                new java.math.BigDecimal(String.valueOf(result.get(2).get("chargeCapacity")))));
        assertEquals(0, java.math.BigDecimal.ZERO.compareTo(
                new java.math.BigDecimal(String.valueOf(result.get(2).get("dischargeCapacity")))));
        assertEquals(0, new java.math.BigDecimal("25.0").compareTo(
                new java.math.BigDecimal(String.valueOf(result.get(2).get("chargeEnergy")))));
        assertEquals(0, java.math.BigDecimal.ZERO.compareTo(
                new java.math.BigDecimal(String.valueOf(result.get(2).get("dischargeEnergy")))));
        assertNull(result.get(1).get("chargeCapacity"));
        imported.put("dischargeMedianVoltage", 1.2);
        assertTrue(assertThrows(RuntimeException.class, () -> submit("STEP", List.of(imported))).getMessage().contains("冲突拒绝"));
        assertEquals(1, service.selectActions(new TTestDataRevisionAction()).size());
    }
    @Test void workbookImportsThreeSheetsWithOneRevisionAndSkipsRetry()
    @Test void workbookImportsThreeSheetsWithSeparateRevisionsAndSkipsRetry()
    {
        var sheets = Map.of("REALTIME", List.of(row(), row()), "STEP", List.of(row()), "CYCLE", List.of(row()));
        var result = submitWorkbook(sheets);
@@ -191,14 +237,22 @@
            assertEquals(1, service.selectActions(query).size());
        }
        var actions = service.selectActions(new TTestDataRevisionAction());
        assertEquals(1, actions.size());
        assertEquals("ALL", actions.get(0).getDataType());
        assertEquals(3, actions.size());
        assertEquals(3, ((List<?>) result.get("actionIds")).size());
        assertEquals(java.util.Set.of("REALTIME", "STEP", "CYCLE"), actions.stream().map(TTestDataRevisionAction::getDataType).collect(java.util.stream.Collectors.toSet()));
        for (var revision : actions)
        {
            String label = switch (revision.getDataType()) { case "REALTIME" -> "实时"; case "STEP" -> "工步"; default -> "循环"; };
            assertEquals("导入1条" + label + "数据", revision.getScopeSummary());
            assertEquals(1L, service.selectActionById(revision.getActionId()).getAffectedRecordCount());
        }
        assertEquals("三页验收", actions.get(0).getReason());
        var retry = submitWorkbook(sheets);
        assertEquals(0, retry.get("importedCount"));
        assertEquals(4, retry.get("skippedCount"));
        assertNull(retry.get("actionId"));
        assertEquals(1, service.selectActions(new TTestDataRevisionAction()).size());
        assertEquals(List.of(), retry.get("actionIds"));
        assertEquals(3, service.selectActions(new TTestDataRevisionAction()).size());
    }
    private com.ruoyi.common.core.domain.AjaxResult submitWorkbook(Map<String, List<Map<String, Object>>> sheets)
@@ -213,6 +267,29 @@
        jdbc.execute("alter table t_test_cycle_record add check (discharge_energy_wh <> 999)");
        assertThrows(RuntimeException.class, () -> submitWorkbook(Map.of("REALTIME", List.of(row()), "STEP", List.of(row()), "CYCLE", List.of(cycle))));
        assertWorkbookEmpty();
    }
    @Test void workbookRevisionFailureRollsBackEarlierRevisionsAndAllData()
    {
        jdbc.execute("alter table t_test_data_revision_action add check (data_type <> 'CYCLE')");
        assertThrows(RuntimeException.class, () -> submitWorkbook(Map.of("REALTIME", List.of(row()), "STEP", List.of(row()), "CYCLE", List.of(row()))));
        assertWorkbookEmpty();
    }
    @Test void partiallyRepeatedWorkbookRecordsActualPerTypeCounts()
    {
        submit("REALTIME", List.of(row()));
        var result = submitWorkbook(Map.of("REALTIME", List.of(row()), "STEP", List.of(row()), "CYCLE", List.of(row())));
        assertEquals(2, result.get("importedCount"));
        assertEquals(1, result.get("skippedCount"));
        assertEquals(3, ((List<?>) result.get("actionIds")).size());
        var realtime = new TTestDataRevisionAction(); realtime.setDataType("REALTIME");
        var revisions = service.selectActions(realtime);
        assertEquals(2, revisions.size());
        assertEquals("导入0条实时数据", revisions.get(0).getScopeSummary());
        assertEquals(0L, service.selectActionById(revisions.get(0).getActionId()).getAffectedRecordCount());
        var invalid = new TTestDataRevisionAction(); invalid.setDataType("ALL");
        assertThrows(RuntimeException.class, () -> service.selectActions(invalid));
    }
    @Test void workbookMissingEmptyInvalidOrConflictingSheetNeverWritesAnySheet()
@@ -302,21 +379,29 @@
    void suppliedPartyWorkbookImportsAllRowsAndSkipsEveryRetry() throws Exception
    {
        var sample = JSON.parseObject(java.nio.file.Files.readString(java.nio.file.Path.of(System.getenv("TICKET03_SAMPLE_JSON"))));
        Map<String, List<Map<String, Object>>> sheets = new LinkedHashMap<>();
        for (String type : List.of("REALTIME", "STEP", "CYCLE")) sheets.put(type, (List) sample.getJSONArray(type).toJavaList(Map.class));
        assertEquals(7767, submitWorkbook(sheets).get("importedCount"));
        assertEquals(7767, submitWorkbook(sheets).get("skippedCount"));
        for (String type : List.of("REALTIME", "STEP", "CYCLE"))
        {
            List<Map<String, Object>> rows = (List) sample.getJSONArray(type).toJavaList(Map.class);
            int count = switch (type) { case "REALTIME" -> 7605; case "STEP" -> 129; default -> 33; };
            assertEquals(count, submit(type, rows).get("importedCount"));
            assertEquals(count, mapper.selectImportDataRecords(1L, type).size());
            assertEquals(count, submit(type, rows).get("skippedCount"));
            if ("STEP".equals(type))
            var query = new TTestDataRevisionAction(); query.setDataType(type);
            var actions = service.selectActions(query);
            assertEquals(1, actions.size());
            assertEquals((long) count, service.selectActionById(actions.get(0).getActionId()).getAffectedRecordCount());
            String label = switch (type) { case "REALTIME" -> "实时"; case "STEP" -> "工步"; default -> "循环"; };
            assertEquals("导入" + count + "条" + label + "数据", actions.get(0).getScopeSummary());
            if ("STEP".equals(type) || "REALTIME".equals(type))
            {
                var persisted = mapper.selectImportDataRecords(1L, type).stream().collect(java.util.stream.Collectors.toMap(
                        record -> record.get("source_record_id").toString(), record -> record));
                for (var input : rows)
                {
                    var stored = JSON.parseObject(persisted.get(input.get("recordNo").toString()).get("extension_measurements_json").toString());
                    for (String field : List.of("chargeMedianVoltage", "dischargeMedianVoltage"))
                    for (String field : "STEP".equals(type) ? List.of("stepDuration", "chargeMedianVoltage", "dischargeMedianVoltage") : List.of("relativeTimeSeconds"))
                        if (input.get(field) == null) assertNull(stored.get(field));
                        else assertEquals(0, new java.math.BigDecimal(input.get(field).toString()).compareTo(stored.getBigDecimal(field)), field);
                }
@@ -325,6 +410,27 @@
        assertEquals(3, service.selectActions(new TTestDataRevisionAction()).size());
    }
    @Test void realtimeRelativeTimeSurvivesImportQueryAndDuplicateValidation()
    {
        var input = row(); input.put("relativeTimeSeconds", 3752.125);
        submit("REALTIME", List.of(input));
        var stored = mapper.selectImportDataRecords(1L, "REALTIME").get(0);
        assertNotNull(stored.get("extension_measurements_json"));
        var extension = JSON.parseObject(stored.get("extension_measurements_json").toString());
        assertEquals(0, new java.math.BigDecimal("3752.125").compareTo(extension.getBigDecimal("relativeTimeSeconds")));
        var queryMapper = org.mockito.Mockito.mock(TTestMapper.class);
        var query = new com.battery.business.service.impl.TestDataQueryServiceImpl(queryMapper,
                org.mockito.Mockito.mock(com.battery.business.service.ITTestService.class));
        var current = new LinkedHashMap<String, Object>(); current.put("extensionMeasurementsJson", extension.toJSONString());
        var legacy = new LinkedHashMap<String, Object>();
        org.mockito.Mockito.when(queryMapper.selectPersistedFinalRealtimeRecords(1L)).thenReturn(List.of(current, legacy));
        assertEquals(new java.math.BigDecimal("3752.125"), query.records(1L).get(0).get("relativeTimeSeconds"));
        assertNull(query.records(1L).get(1).get("relativeTimeSeconds"));
        assertEquals(1, submit("REALTIME", List.of(input)).get("skippedCount"));
        input.put("relativeTimeSeconds", 3752.126);
        assertThrows(RuntimeException.class, () -> submit("REALTIME", List.of(input)));
    }
    private Map<String, Object> row()
    {
        var row = new LinkedHashMap<String, Object>(Map.of("recordNo", "17", "batteryNo", "BAT-1", "recordedAt", 1683517331000L,