按甲方字段补齐实时相对时长持久化
在导入、设备观测、持久化及查询中独立传递相对时长,保留历史空值和原有统计来源。补充真实表格、重复冲突及设备字段映射回归。
| | |
| | | } |
| | | if ("REALTIME".equals(type)) |
| | | { |
| | | // 相对时长是来源事实,独立于绝对设备时间;旧记录缺失不补算。 |
| | | BigDecimal relativeTime = importMetric(row, "relativeTimeSeconds"); |
| | | if (relativeTime != null && relativeTime.signum() < 0) throw new ServiceException("列「记录时间(h:min:s.ms)」:不能为负数"); |
| | | Map<String, Object> extension = new LinkedHashMap<>(); |
| | | extension.put("relativeTimeSeconds", relativeTime); |
| | | values.put("extension_measurements_json", JSON.toJSONString(extension)); |
| | | values.put("cabinet_sequence_id", importInteger(row, "sequenceId", "记录序号", 9007199254740991L)); |
| | | for (String field : List.of("devtype", "devid", "subdevid", "chlid")) values.put("cabinet_" + field, binding.get(field)); |
| | | // 导入不是轮询采样;同秒可有不同记录,设备时间不能为槽位唯一性而偏移。 |
| | |
| | | private String metricLabel(String field) |
| | | { |
| | | return switch (field) { |
| | | case "relativeTimeSeconds" -> "记录时间(h:min:s.ms)"; |
| | | case "voltage" -> "电压(V)"; case "current" -> "电流(A)"; case "capacity" -> "容量(Ah)"; case "energy" -> "能量(Wh)"; |
| | | case "temperature" -> "温度(℃)"; case "positivePotential" -> "正电位(V)"; case "negativePotential" -> "负电位(V)"; |
| | | case "endVoltage" -> "终止电压(V)"; case "startVoltage" -> "起始电压(V)"; case "stepDuration" -> "工步时间"; |
| | |
| | | @Override public List<Map<String, Object>> records(Long testId) |
| | | { |
| | | tests.selectTestById(testId); |
| | | return normalizeApiTimes(mapper.selectPersistedFinalRealtimeRecords(testId)); |
| | | List<Map<String, Object>> rows = normalizeApiTimes(mapper.selectPersistedFinalRealtimeRecords(testId)); |
| | | for (Map<String, Object> row : rows) |
| | | { |
| | | Object extension = row.get("extensionMeasurementsJson"); |
| | | if (extension != null) row.put("relativeTimeSeconds", |
| | | com.alibaba.fastjson2.JSON.parseObject(extension.toString()).get("relativeTimeSeconds")); |
| | | } |
| | | return rows; |
| | | } |
| | | |
| | | /** 查询尚存工步记录,包含设备统计与授权历史导入。 */ |
| | |
| | | if (cabinet != null) { row.setCycleNo(cabinet.cycleNo()); row.setStepNo(cabinet.stepNo()); row.setStepType(cabinet.stepType()); row.setStepName(cabinet.stepName()); row.setVoltageV(BigDecimal.valueOf(cabinet.voltageV())); row.setCurrentA(BigDecimal.valueOf(cabinet.currentA())); row.setCapacityAh(BigDecimal.valueOf(cabinet.capacityAh())); row.setEnergyWh(BigDecimal.valueOf(cabinet.energyWh())); } |
| | | row.setTemperature(metricValue(mapped, "TEMPERATURE")); row.setPositivePotential(metricValue(mapped, "POSITIVE_POTENTIAL")); row.setNegativePotential(metricValue(mapped, "NEGATIVE_POTENTIAL")); row.setCollectorChannelCode(mapped.values().stream().map(CollectorMeasurement::channelCode).findFirst().orElse(null)); |
| | | Map<String, Object> extension = new LinkedHashMap<>(); |
| | | // 工步内相对时长由成功柜端观测提供,不以轮询槽位或服务器时间推算。 |
| | | if (cabinet != null && cabinet.relativeTimeSeconds() != null) extension.put("relativeTimeSeconds", cabinet.relativeTimeSeconds()); |
| | | for (Map.Entry<String, CollectorMeasurement> e : mapped.entrySet()) if (!isFixedMetric(e.getKey())) |
| | | { |
| | | Map<String, Object> value = new LinkedHashMap<>(); value.put("value", e.getValue().value()); value.put("unit", e.getValue().unit()); value.put("channelCode", e.getValue().channelCode()); extension.put(e.getKey(), value); |
| | |
| | | record PlanRow(String rowType, int stepNo, Integer cyclePosition, String stepName, double durationSeconds, |
| | | Double voltageV, String executionStatus, Integer cycleCount, Long recordCount) { } |
| | | |
| | | /** 柜端成功观测;相对时长使用设备提供的秒数,缺失不按采样时间推算。 */ |
| | | record CabinetRealtime(long sequenceId, Instant deviceTime, double currentA, double voltageV, |
| | | double capacityAh, double energyWh, int cycleNo, int stepNo, String stepName, String stepType, |
| | | Instant serverReceivedAt, String deviceTimeRaw, String timezoneId) { } |
| | | Instant serverReceivedAt, String deviceTimeRaw, String timezoneId, Long relativeTimeSeconds) { } |
| | | |
| | | record CollectorRealtime(String collectorCode, boolean online, int sampleIntervalMs, |
| | | List<CollectorMeasurement> measurements, String timezoneId) |
| | |
| | | CabinetRealtime result = new CabinetRealtime(sequence, now, configuration.currentA().doubleValue(), |
| | | configuration.voltageV().doubleValue(), configuration.capacityAh().doubleValue(), configuration.energyWh().doubleValue(), |
| | | configuration.cycleNo(), configuration.stepNo(), step.name(), step.stepType(), |
| | | now, DEVICE_TIME.format(now.atZone(DEVICE_ZONE)), DEVICE_ZONE.getId()); |
| | | now, DEVICE_TIME.format(now.atZone(DEVICE_ZONE)), DEVICE_ZONE.getId(), configuration.relativeTimeSeconds()); |
| | | return result; |
| | | } |
| | | |
| | |
| | | assertEquals("dc", observation.stepType()); |
| | | assertEquals(120L, observation.totalTimeSeconds()); |
| | | assertEquals(30L, observation.relativeTimeSeconds()); |
| | | assertEquals(30L, simulator.latestCabinet(channel).relativeTimeSeconds()); |
| | | } |
| | | } |
| | | |
| | |
| | | assertEquals("dev-sim:22:1:1:1", row.getSourceIdentity()); |
| | | assertEquals("cabinet:7:collector:10", row.getSourceRecordId()); |
| | | assertEquals(Date.from(Instant.parse("2026-09-02T04:00:30Z")), row.getCabinetDeviceTimeUtc()); |
| | | assertEquals(3752L, com.alibaba.fastjson2.JSON.parseObject(row.getExtensionMeasurementsJson()).getLong("relativeTimeSeconds")); |
| | | assertEquals(Date.from(Instant.parse("2026-09-02T04:00:40Z")), row.getCollectorDeviceTimeUtc()); |
| | | assertEquals(new BigDecimal("25.100"), row.getTemperature()); |
| | | assertEquals(new BigDecimal("1.620"), row.getPositivePotential()); |
| | |
| | | ArgumentCaptor<TTestRealtimeRecord> saved = ArgumentCaptor.forClass(TTestRealtimeRecord.class); |
| | | verify(fixture.mapper).insertRealtimeRecord(saved.capture()); |
| | | assertNull(saved.getValue().getCabinetSequenceId()); |
| | | assertNull(com.alibaba.fastjson2.JSON.parseObject(saved.getValue().getExtensionMeasurementsJson()).get("relativeTimeSeconds")); |
| | | assertNull(saved.getValue().getCabinetDevtype()); |
| | | assertNull(saved.getValue().getCabinetAdapterTimezoneId()); |
| | | assertNull(saved.getValue().getVoltageV()); |
| | |
| | | Instant started = Instant.parse("2026-09-02T04:00:10Z"); |
| | | Instant ended = Instant.parse("2026-09-02T04:00:50Z"); |
| | | when(fixture.simulator.stepRecords(any(ChannelIdentity.class))).thenReturn(List.of(new StepRecord( |
| | | "STEP-1", 1, 2, "恒流充电", "cc", started, ended, 3.4, 2.5, 8.4, ended.toString(), "UTC", ended, java.util.Map.of()))); |
| | | "STEP-1", 1, 2, "恒流充电", "cc", started, ended, 3.4, 2.5, 8.4, ended.toString(), "UTC", ended, |
| | | java.util.Map.of("stepDuration", new BigDecimal("40.125"), "startVoltage", new BigDecimal("3.1"), |
| | | "chargeMedianVoltage", new BigDecimal("3.25"), "dischargeMedianVoltage", BigDecimal.ZERO)))); |
| | | when(fixture.simulator.cycleRecords(any(ChannelIdentity.class))).thenReturn(List.of(new CycleRecord( |
| | | "CYCLE-1-1-1", 1, started, ended, 2.5, 2.4, 8.4, 8.0, 96, ended.toString(), "UTC", ended))); |
| | | when(fixture.mapper.insertStepRecord(any())).thenReturn(1); |
| | |
| | | verify(fixture.mapper).insertStepRecord(step.capture()); |
| | | verify(fixture.mapper).insertCycleRecord(cycle.capture()); |
| | | assertEquals("record:STEP-1", step.getValue().getDedupeKey()); |
| | | var metrics = com.alibaba.fastjson2.JSON.parseObject(step.getValue().getExtensionMeasurementsJson()); |
| | | assertEquals(new BigDecimal("40.125"), metrics.getBigDecimal("stepDuration")); |
| | | assertEquals(new BigDecimal("3.1"), metrics.getBigDecimal("startVoltage")); |
| | | assertEquals(new BigDecimal("3.25"), metrics.getBigDecimal("chargeMedianVoltage")); |
| | | assertEquals(BigDecimal.ZERO, metrics.getBigDecimal("dischargeMedianVoltage")); |
| | | assertEquals("CYCLE-1-1-1", cycle.getValue().getSourceRecordId()); |
| | | assertEquals("record:CYCLE-1-1-1", cycle.getValue().getDedupeKey()); |
| | | assertEquals(Date.from(ended), step.getValue().getServerReceivedAtUtc()); |
| | |
| | | { |
| | | Instant deviceTime = Instant.parse("2026-09-02T04:00:30Z"); |
| | | return new CabinetRealtime(7L, deviceTime, 1, 3.2, 2.5, 8.4, |
| | | 1, 2, "恒流充电", "cc", Instant.parse("2026-09-02T04:00:35Z"), "2026-09-02 12:00:30.000", "Asia/Hong_Kong"); |
| | | 1, 2, "恒流充电", "cc", Instant.parse("2026-09-02T04:00:35Z"), "2026-09-02 12:00:30.000", "Asia/Hong_Kong", 3752L); |
| | | } |
| | | |
| | | private static CollectorMeasurement collector(String channel, long sequenceId, String metric, |
| | |
| | | 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)) |
| | | 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); |
| | | } |
| | |
| | | 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, |