diff --git a/docs/operations/jt808-daily-mileage-runbook.md b/docs/operations/jt808-daily-mileage-runbook.md index 5420e1ef..5e08e006 100644 --- a/docs/operations/jt808-daily-mileage-runbook.md +++ b/docs/operations/jt808-daily-mileage-runbook.md @@ -6,16 +6,16 @@ The analytics app can calculate daily mileage from JT808 telemetry only. It cons ## Storage -There is no separate JT808 daily-mileage table and no Redis mileage state. The derived value is stored as one `daily_mileage_km` metric row in the common JDBC/MySQL table `vehicle_stat_metric`; the first and latest GPS total mileage values are kept on that same row as calculation source columns. +There is no separate JT808 daily-mileage table and no Redis mileage state. The derived value is stored as one `daily_mileage_km` metric row in the common JDBC/MySQL table `vehicle_stat_metric`; the local-day minimum and maximum GPS total mileage values are kept on that same row as calculation source columns. The JT808 daily-mileage value is calculated from the GPS total mileage reported in location additional information: ```text -daily_mileage_km = last_total_mileage_km - first_total_mileage_km +daily_mileage_km = max_total_mileage_km - min_total_mileage_km calculation_method = JT808_TOTAL_MILEAGE_DIFF ``` -The first valid JT808 location point for a vehicle and local day stores the day start total on the metric row and writes `daily_mileage_km=0.0`. Later ordered points update the latest total on the same row and rewrite the derived `daily_mileage_km`. If a later total mileage is lower than the stored latest total, the previous latest total remains. +The first valid JT808 location point for a vehicle and local day stores both calculation endpoints on the metric row and writes `daily_mileage_km=0.0`. Later ordered or replayed points update the lower endpoint when the reported GPS total mileage is smaller, update the upper endpoint when it is larger, and rewrite the derived `daily_mileage_km` on the same row. ## Runtime Settings diff --git a/docs/superpowers/plans/2026-06-30-kafka-streaming-mileage.md b/docs/superpowers/plans/2026-06-30-kafka-streaming-mileage.md index 7d501471..3348e28e 100644 --- a/docs/superpowers/plans/2026-06-30-kafka-streaming-mileage.md +++ b/docs/superpowers/plans/2026-06-30-kafka-streaming-mileage.md @@ -17,17 +17,17 @@ Consume JT808 Kafka location events and write the derived daily mileage into the JT808 daily mileage is calculated only from the GPS total mileage reported by JT808 location additional information: ```text -daily_mileage_km = last_total_mileage_km - first_total_mileage_km +daily_mileage_km = max_total_mileage_km - min_total_mileage_km ``` -The first valid local-day sample writes `daily_mileage_km=0.0`. Later samples update the same metric row with: +The first valid local-day sample writes `daily_mileage_km=0.0`. Later ordered or replayed samples update the same metric row with: ```text -metric_value = latest_total_mileage_km - first_total_mileage_km +metric_value = max_total_mileage_km - min_total_mileage_km metric_key = daily_mileage_km ``` -The first and latest GPS total mileage values stay on that same metric row as calculation source columns so restarts recover from MySQL without Redis or another state table. +The local-day minimum and maximum GPS total mileage values stay on that same metric row as calculation source columns so restarts recover from MySQL without Redis or another state table. ## Runtime Properties diff --git a/docs/superpowers/specs/2026-06-23-gb32960-production-readiness-design.md b/docs/superpowers/specs/2026-06-23-gb32960-production-readiness-design.md index 8db6e2ff..23aaafbb 100644 --- a/docs/superpowers/specs/2026-06-23-gb32960-production-readiness-design.md +++ b/docs/superpowers/specs/2026-06-23-gb32960-production-readiness-design.md @@ -344,11 +344,12 @@ Validation: ### Daily Mileage JT808 daily mileage uses the GPS total mileage reported in location additional -information. The first valid sample of the local day is the baseline and the -latest valid total mileage rewrites the same metric row. +information. The local-day minimum and maximum GPS total mileage values rewrite +the same metric row, so ordered streaming and historical replay use one +calculation path. ```text -dailyMileage = today.lastTotalMileage - today.firstTotalMileage +dailyMileage = today.maxTotalMileage - today.minTotalMileage calculationMethod = JT808_TOTAL_MILEAGE_DIFF ``` diff --git a/docs/target-architecture.md b/docs/target-architecture.md index de7987db..758a6416 100644 --- a/docs/target-architecture.md +++ b/docs/target-architecture.md @@ -125,13 +125,14 @@ Responsibilities: - Calculate 808 daily mileage from the reported GPS total mileage only: ```text -daily_mileage_km = last_total_mileage_km - first_total_mileage_km +daily_mileage_km = max_total_mileage_km - min_total_mileage_km calculation_method = JT808_TOTAL_MILEAGE_DIFF ``` - Store the metric in `vehicle_stat_metric` with - `metric_key = daily_mileage_km`; the first and latest GPS total mileage used - for the subtraction stay on the same metric row as calculation source columns. + `metric_key = daily_mileage_km`; the local-day minimum and maximum GPS total + mileage used for the subtraction stay on the same metric row as calculation + source columns. There is no JT808-specific daily mileage table and no in-memory production state-store mode. Restart recovery reads the same metric row, so it does not diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/JdbcVehicleStatMetricRepository.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/JdbcVehicleStatMetricRepository.java index 27bae562..f95159a7 100644 --- a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/JdbcVehicleStatMetricRepository.java +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/JdbcVehicleStatMetricRepository.java @@ -60,7 +60,7 @@ public final class JdbcVehicleStatMetricRepository implements VehicleStatReposit DailyMileageState existingState = findDailyMileageState(normalizedVin, date).orElse(null); double firstTotalMileage = existingState == null || !Double.isFinite(existingState.firstTotalMileageKm()) ? totalMileageKm - : existingState.firstTotalMileageKm(); + : Math.min(existingState.firstTotalMileageKm(), totalMileageKm); double latestTotalMileage = existingState == null || !Double.isFinite(existingState.latestTotalMileageKm()) ? totalMileageKm : Math.max(existingState.latestTotalMileageKm(), totalMileageKm); diff --git a/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/JdbcVehicleStatMetricRepositoryTest.java b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/JdbcVehicleStatMetricRepositoryTest.java index 6c76faa9..a7480a80 100644 --- a/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/JdbcVehicleStatMetricRepositoryTest.java +++ b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/JdbcVehicleStatMetricRepositoryTest.java @@ -116,6 +116,37 @@ class JdbcVehicleStatMetricRepositoryTest { """, Double.class)).isEqualTo(9.5); } + @Test + void recalculatesJt808MileageWhenEarlierLowerMileageArrivesAfterRestart() { + JdbcTemplate jdbcTemplate = new JdbcTemplate(new DriverManagerDataSource( + "jdbc:h2:mem:vehicle_stat_metric_jt808_replay_lower;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1", + "sa", + "")); + JdbcVehicleStatMetricRepository repository = new JdbcVehicleStatMetricRepository(jdbcTemplate); + + repository.recordDailyMileageSample("VIN001", LocalDate.of(2026, 7, 1), 1010.0).orElseThrow(); + JdbcVehicleStatMetricRepository restartedRepository = new JdbcVehicleStatMetricRepository(jdbcTemplate); + VehicleDailyStatResult replayedEarlierSample = restartedRepository.recordDailyMileageSample( + "VIN001", LocalDate.of(2026, 7, 1), 1000.5).orElseThrow(); + + assertThat(replayedEarlierSample.dailyMileageKm()).hasValue(9.5); + assertThat(jdbcTemplate.queryForObject(""" + SELECT first_total_mileage_km + FROM vehicle_stat_metric + WHERE vin = 'VIN001' AND metric_key = 'daily_mileage_km' + """, Double.class)).isEqualTo(1000.5); + assertThat(jdbcTemplate.queryForObject(""" + SELECT latest_total_mileage_km + FROM vehicle_stat_metric + WHERE vin = 'VIN001' AND metric_key = 'daily_mileage_km' + """, Double.class)).isEqualTo(1010.0); + assertThat(jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM vehicle_stat_metric + WHERE vin = 'VIN001' AND stat_date = DATE '2026-07-01' + """, Integer.class)).isOne(); + } + @Test void ignoresLegacySplitMileageStateMetricsWhenRecordingNewGpsTotalMileage() { JdbcTemplate jdbcTemplate = new JdbcTemplate(new DriverManagerDataSource( @@ -145,6 +176,11 @@ class JdbcVehicleStatMetricRepositoryTest { FROM vehicle_stat_metric WHERE vin = 'VIN001' AND metric_key = 'daily_mileage_km' """, Double.class)).isEqualTo(110.0); + assertThat(jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM vehicle_stat_metric + WHERE vin = 'VIN001' AND stat_date = DATE '2026-07-01' + """, Integer.class)).isOne(); } @Test diff --git a/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/VehicleStatRepositoryContractTest.java b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/VehicleStatRepositoryContractTest.java index 623ce3c2..31ce63b4 100644 --- a/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/VehicleStatRepositoryContractTest.java +++ b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/VehicleStatRepositoryContractTest.java @@ -26,7 +26,7 @@ class VehicleStatRepositoryContractTest { assertThat(plan) .contains("Runtime state: none outside `vehicle_stat_metric`") - .contains("daily_mileage_km = last_total_mileage_km - first_total_mileage_km") + .contains("daily_mileage_km = max_total_mileage_km - min_total_mileage_km") .contains("Do not create or write a protocol-specific JT808 daily-mileage table.") .doesNotContain("Redis `Jt808MileageStateStore`") .doesNotContain("REDIS_HOST")