diff --git a/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml b/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml index 16590877..dbae4ac5 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml +++ b/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: 车辆数据开放平台 API - version: 1.6.0 + version: 1.7.0 license: name: Proprietary description: | @@ -20,7 +20,7 @@ paths: post: tags: [合作方数据接口] summary: 查询车辆单日用氢量 - description: plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。 + description: plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。SUSPECT 计算结果保留用氢量并以 DATA_ANOMALY 输出,同时返回质量原因。 operationId: queryDailyHydrogenConsumption security: - AppKeyAuth: [] @@ -619,7 +619,7 @@ components: type: number format: double nullable: true - description: 单日用氢量,kg;无数据时为 null + description: 单日用氢量,kg;OK 和 SUSPECT 计算结果均返回数值,无数据时为 null calculationPhase: type: string enum: [PRELIMINARY, FINAL] @@ -627,6 +627,13 @@ components: algorithmVersion: type: string description: 氢耗计算算法版本 + qualityStatus: + type: string + enum: [OK, SUSPECT, NO_DATA] + description: 氢耗计算质量状态 + qualityReason: + type: string + description: 质量判定原因;SUSPECT 或 NO_DATA 时供调用方解释和审计 status: $ref: '#/components/schemas/DataStatus' MileageResult: diff --git a/vehicle-data-platform/apps/api/internal/openplatform/model.go b/vehicle-data-platform/apps/api/internal/openplatform/model.go index 8e35b82b..871de8fc 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/model.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/model.go @@ -51,6 +51,8 @@ type HydrogenResult struct { HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg"` CalculationPhase string `json:"calculationPhase,omitempty"` AlgorithmVersion string `json:"algorithmVersion,omitempty"` + QualityStatus string `json:"qualityStatus,omitempty"` + QualityReason string `json:"qualityReason,omitempty"` Status string `json:"status"` } @@ -288,6 +290,7 @@ type DailyHydrogen struct { ConsumptionKg float64 SampleCount int QualityStatus string + QualityReason string CalculationPhase string AlgorithmVersion string } diff --git a/vehicle-data-platform/apps/api/internal/openplatform/mysql.go b/vehicle-data-platform/apps/api/internal/openplatform/mysql.go index 3841aee9..b8c975ac 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/mysql.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/mysql.go @@ -312,7 +312,7 @@ func (r *MySQLRepository) DailyHydrogen(ctx context.Context, vins []string, date return map[string]DailyHydrogen{}, nil } query, args := inQuery(` -SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),consumption_kg,sample_count,quality_status, +SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),consumption_kg,sample_count,quality_status,quality_reason, calculation_phase,algorithm_version FROM vehicle_open_daily_energy WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins) @@ -324,7 +324,7 @@ WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins) out := make(map[string]DailyHydrogen, len(vins)) for rows.Next() { var value DailyHydrogen - if err := rows.Scan(&value.VIN, &value.Date, &value.ConsumptionKg, &value.SampleCount, &value.QualityStatus, &value.CalculationPhase, &value.AlgorithmVersion); err != nil { + if err := rows.Scan(&value.VIN, &value.Date, &value.ConsumptionKg, &value.SampleCount, &value.QualityStatus, &value.QualityReason, &value.CalculationPhase, &value.AlgorithmVersion); err != nil { return nil, err } out[value.VIN] = value diff --git a/vehicle-data-platform/apps/api/internal/openplatform/service.go b/vehicle-data-platform/apps/api/internal/openplatform/service.go index c8dac96a..904f0562 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/service.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/service.go @@ -394,10 +394,17 @@ func (s *Service) QueryHydrogen(ctx context.Context, appKey, traceID string, req if value, ok := values[vehicle.VIN]; ok { item.CalculationPhase = value.CalculationPhase item.AlgorithmVersion = value.AlgorithmVersion - if strings.EqualFold(value.QualityStatus, "OK") { + item.QualityStatus = value.QualityStatus + item.QualityReason = value.QualityReason + switch { + case strings.EqualFold(value.QualityStatus, "OK"): consumption := round3(value.ConsumptionKg) item.HydrogenConsumptionKg = &consumption item.Status = StatusNormal + case strings.EqualFold(value.QualityStatus, "SUSPECT"): + consumption := round3(value.ConsumptionKg) + item.HydrogenConsumptionKg = &consumption + item.Status = StatusDataAnomaly } } results = append(results, item) diff --git a/vehicle-data-platform/apps/api/internal/openplatform/service_test.go b/vehicle-data-platform/apps/api/internal/openplatform/service_test.go index 04ae6d95..a77ddeb3 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/service_test.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/service_test.go @@ -294,6 +294,32 @@ func TestExternalHydrogenAndMileageQueriesPreserveRequestedVehicles(t *testing.T } } +func TestExternalHydrogenQueryPublishesSuspectCalculationAsDataAnomaly(t *testing.T) { + repository := &fakeRepository{ + app: AppCredential{ID: 7, Name: "partner"}, + vehicles: map[string]AuthorizedVehicle{ + "粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"}, + }, + hydrogen: map[string]DailyHydrogen{ + "LTEST32960VIN0001": { + VIN: "LTEST32960VIN0001", ConsumptionKg: 4.491, QualityStatus: "SUSPECT", + QualityReason: "燃料电池未工作期间持续压降", CalculationPhase: "FINAL", AlgorithmVersion: trustedHydrogenAlgorithmVersion, + }, + }, + } + service := NewService(repository) + service.now = func() time.Time { return time.Date(2026, 8, 26, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)) } + result, err := service.QueryHydrogen(context.Background(), "0123456789abcdef0123456789abcdef", "trace-suspect", QueryRequest{ + PlateNumbers: []string{"粤A12345"}, Date: "2026-08-26", + }) + if err != nil { + t.Fatal(err) + } + if len(result) != 1 || result[0].HydrogenConsumptionKg == nil || *result[0].HydrogenConsumptionKg != 4.491 || result[0].Status != StatusDataAnomaly || result[0].QualityStatus != "SUSPECT" || result[0].QualityReason == "" { + t.Fatalf("suspect hydrogen result = %#v", result) + } +} + func TestDailyQueriesWithoutPlatesReturnAllAuthorizedVehicles(t *testing.T) { repository := &fakeRepository{ app: AppCredential{ID: 7, Name: "partner"}, diff --git a/vehicle-data-platform/apps/api/internal/openplatform/statistics.go b/vehicle-data-platform/apps/api/internal/openplatform/statistics.go index 52be7966..023cd9da 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/statistics.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/statistics.go @@ -933,16 +933,18 @@ func replaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, sco INSERT INTO vehicle_open_daily_energy( vin,stat_date,energy_type,source_endpoint,consumption_kg,unit,first_mass_kg,last_mass_kg, sample_count,refuel_count,quality_status,quality_reason,calculated_at, + refuel_amount_kg, raw_consumption_kg,battery_soc_delta_pct,battery_discharge_kwh,battery_equivalent_kg, soc_balanced_consumption_kg,mixed_mileage_km,pure_electric_mileage_km, - consumption_kg_per_100km,soc_balanced_kg_per_100km,charge_count,valid_segment_count, + consumption_kg_per_100km,soc_balanced_kg_per_100km,charge_count,charge_energy_kwh,valid_segment_count, invalid_segment_count,algorithm_version,calculation_phase,parameter_json,evidence_json - ) VALUES(?,?,'HYDROGEN',?,?,'kg',?,?,?,?,?,?,NOW(3),?,?,?,?,?,?,?,?,?,?,?,?,?,'FINAL',?,?)`, + ) VALUES(?,?,'HYDROGEN',?,?,'kg',?,?,?,?,?,?,NOW(3),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'FINAL',?,?)`, stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.FirstMassKg, stat.LastMassKg, stat.SampleCount, stat.RefuelCount, stat.QualityStatus, stat.QualityReason, + stat.RefuelAmountKg, stat.ConsumptionKg, stat.BatterySOCDeltaPct, stat.BatteryDischargeKWh, stat.BatteryEquivalentKg, stat.SOCBalancedConsumptionKg, stat.MixedMileageKm, stat.PureElectricMileageKm, - stat.ConsumptionKgPer100Km, stat.SOCBalancedKgPer100Km, stat.ChargeCount, stat.QualifiedSegmentCount, + stat.ConsumptionKgPer100Km, stat.SOCBalancedKgPer100Km, stat.ChargeCount, stat.ChargeEnergyKWh, stat.QualifiedSegmentCount, stat.InvalidSegmentCount, stat.CalculationParameters.AlgorithmVersion, parameterJSON, evidenceJSON, ); err != nil { return err diff --git a/vehicle-data-platform/apps/api/internal/openplatform/statistics_test.go b/vehicle-data-platform/apps/api/internal/openplatform/statistics_test.go index c58d6cee..f12af0e0 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/statistics_test.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/statistics_test.go @@ -355,8 +355,8 @@ func TestReplaceHydrogenDailyStatsForVINDeletesOnlyScopedVehicle(t *testing.T) { WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy(")). WithArgs(vin, "2026-08-12", "source-a", 8.691, 7.64, 10.515, 2803, 2, "OK", "", - 8.691, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), 0.0, 0.0, - sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 0, 0, "", sqlmock.AnyArg(), sqlmock.AnyArg()). + 0.0, 8.691, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), 0.0, 0.0, + sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 0.0, 0, 0, "", sqlmock.AnyArg(), sqlmock.AnyArg()). WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectCommit() err = ReplaceHydrogenDailyStatsForVIN(context.Background(), db, "2026-08-12", vin, []HydrogenDailyStat{{ @@ -388,9 +388,9 @@ func TestReplaceHydrogenDailyStatsForVINsDeletesOnlyScopedBrandVehicles(t *testi WillReturnResult(sqlmock.NewResult(0, 2)) mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy(")). WithArgs(stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.FirstMassKg, stat.LastMassKg, - stat.SampleCount, 0, stat.QualityStatus, "", stat.ConsumptionKg, + stat.SampleCount, 0, stat.QualityStatus, "", 0.0, stat.ConsumptionKg, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), 0.0, 0.0, - sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 0, 0, "", sqlmock.AnyArg(), sqlmock.AnyArg()). + sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 0.0, 0, 0, "", sqlmock.AnyArg(), sqlmock.AnyArg()). WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectCommit() @@ -425,8 +425,8 @@ func TestReplaceHydrogenDailyStatsAndSeedStreamIsAtomic(t *testing.T) { WithArgs("2026-08-19").WillReturnResult(sqlmock.NewResult(0, 0)) mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy(")). WithArgs(vin, "2026-08-19", "source-a", 2.6, 12.0, 9.4, 500, 1, "OK", "", - 2.6, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), 0.0, 0.0, - sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 2, 0, "", sqlmock.AnyArg(), sqlmock.AnyArg()). + 0.0, 2.6, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), 0.0, 0.0, + sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 0.0, 2, 0, "", sqlmock.AnyArg(), sqlmock.AnyArg()). WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_hydrogen_segment_stream_state(")). WithArgs(vin, "2026-08-19", "source-a", 2.6, 2.6, 500, 1, 0, 460, 2, 9.4, last.ObservedAt, "", sqlmock.AnyArg(), "OK", ""). @@ -469,8 +469,8 @@ func TestReplaceHydrogenDailyStatsAndSeedStreamSkipsMissingWatermark(t *testing. WithArgs("2026-09-04").WillReturnResult(sqlmock.NewResult(0, 0)) mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy(")). WithArgs(stat.VIN, stat.Date, stat.Source, 0.0, 0.0, 0.0, 0, 0, stat.QualityStatus, stat.QualityReason, - 0.0, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), 0.0, 0.0, - sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 0, 0, "", sqlmock.AnyArg(), sqlmock.AnyArg()). + 0.0, 0.0, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), 0.0, 0.0, + sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 0.0, 0, 0, "", sqlmock.AnyArg(), sqlmock.AnyArg()). WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectCommit() diff --git a/vehicle-data-platform/apps/api/internal/platform/hydrogen_evidence_test.go b/vehicle-data-platform/apps/api/internal/platform/hydrogen_evidence_test.go index 73bac84b..9cae489c 100644 --- a/vehicle-data-platform/apps/api/internal/platform/hydrogen_evidence_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/hydrogen_evidence_test.go @@ -17,21 +17,21 @@ func TestProductionStoreHydrogenDailyEvidenceReturnsParametersIntervalsAndRawEve "vin", "plate", "date", "source_endpoint", "raw_consumption_kg", "battery_soc_delta_pct", "battery_discharge_kwh", "battery_equivalent_kg", "soc_balanced_consumption_kg", "mixed_mileage_km", "pure_electric_mileage_km", "consumption_kg_per_100km", "soc_balanced_kg_per_100km", - "sample_count", "refuel_count", "charge_count", "valid_segment_count", "invalid_segment_count", + "sample_count", "refuel_count", "refuel_amount_kg", "charge_count", "charge_energy_kwh", "valid_segment_count", "invalid_segment_count", "quality_status", "quality_reason", "algorithm_version", "parameter_json", "evidence_json", "calculated_at", } parameterJSON := `{"batteryCapacityKWh":21.04,"hydrogenEnergyKWhPerKg":16,"algorithmVersion":"PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2"}` evidenceJSON := `[{"index":1,"type":"MIXED","startEventId":"event-start","endEventId":"event-end","rawHydrogenConsumptionKg":3.1,"sampleCount":120,"qualityStatus":"OK","qualityReason":""}]` mock.ExpectQuery("SELECT h.vin").WithArgs("LTEST000000000001", "2026-08-26").WillReturnRows(sqlmock.NewRows(columns).AddRow( "LTEST000000000001", "粤A12345", "2026-08-26", "factory-a", 3.1, -2.0, 0.421, 0.026, 3.126, - 56.2, 32.5, 5.516, 5.562, 120, 1, 1, 1, 0, "OK", "", "PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2", + 56.2, 32.5, 5.516, 5.562, 120, 1, 8.976, 1, 12.5, 1, 0, "OK", "", "PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2", []byte(parameterJSON), []byte(evidenceJSON), "2026-08-27 00:05:00.000", )) result, found, err := (&ProductionStore{db: db}).HydrogenDailyEvidence(context.Background(), "LTEST000000000001", "2026-08-26") if err != nil || !found { t.Fatalf("found=%v err=%v", found, err) } - if result.Parameters.BatteryCapacityKWh != 21.04 || len(result.Intervals) != 1 || result.Intervals[0].StartEventID != "event-start" || result.Intervals[0].EndEventID != "event-end" { + if result.Parameters.BatteryCapacityKWh != 21.04 || result.RefuelAmountKg == nil || *result.RefuelAmountKg != 8.976 || result.ChargeEnergyKWh == nil || *result.ChargeEnergyKWh != 12.5 || len(result.Intervals) != 1 || result.Intervals[0].StartEventID != "event-start" || result.Intervals[0].EndEventID != "event-end" { t.Fatalf("result=%#v", result) } if err := mock.ExpectationsWereMet(); err != nil { diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index 23494b33..df7001be 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -1885,8 +1885,16 @@ type DailyMileageRow struct { HydrogenConsumptionKgPer100Km *float64 `json:"hydrogenConsumptionKgPer100Km,omitempty"` HydrogenSOCBalancedKg *float64 `json:"hydrogenSocBalancedKg,omitempty"` HydrogenSOCBalancedKgPer100Km *float64 `json:"hydrogenSocBalancedKgPer100Km,omitempty"` + PureElectricMileageKm *float64 `json:"pureElectricMileageKm,omitempty"` + MixedMileageKm *float64 `json:"mixedMileageKm,omitempty"` + BatterySOCDeltaPct *float64 `json:"batterySocDeltaPct,omitempty"` + ChargeCount *int `json:"chargeCount,omitempty"` + ChargeEnergyKWh *float64 `json:"chargeEnergyKWh,omitempty"` + RefuelCount *int `json:"refuelCount,omitempty"` + RefuelAmountKg *float64 `json:"refuelAmountKg,omitempty"` HydrogenEvidenceAvailable bool `json:"hydrogenEvidenceAvailable,omitempty"` HydrogenQualityStatus string `json:"hydrogenQualityStatus,omitempty"` + HydrogenQualityReason string `json:"hydrogenQualityReason,omitempty"` HydrogenAlgorithmVersion string `json:"hydrogenAlgorithmVersion,omitempty"` Source string `json:"source"` AnomalySeverity string `json:"anomalySeverity,omitempty"` @@ -1949,7 +1957,9 @@ type HydrogenDailyEvidence struct { SOCBalancedKgPer100Km *float64 `json:"socBalancedKgPer100Km,omitempty"` SampleCount int `json:"sampleCount"` RefuelCount int `json:"refuelCount"` + RefuelAmountKg *float64 `json:"refuelAmountKg,omitempty"` ChargeCount int `json:"chargeCount"` + ChargeEnergyKWh *float64 `json:"chargeEnergyKWh,omitempty"` ValidSegmentCount int `json:"validSegmentCount"` InvalidSegmentCount int `json:"invalidSegmentCount"` QualityStatus string `json:"qualityStatus"` diff --git a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go index c8ced69e..435ebaa0 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go @@ -559,7 +559,7 @@ LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin LEFT JOIN vehicle_open_daily_energy h ON h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci AND h.stat_date = m.stat_date - AND h.energy_type = 'HYDROGEN' AND h.quality_status = 'OK' + AND h.energy_type = 'HYDROGEN' AND h.quality_status IN ('OK','SUSPECT') WHERE ` + strings.Join(where, " AND ") if query.Get("deduplicate") == "1" || strings.EqualFold(query.Get("deduplicate"), "true") { selectionOrder := `m.daily_mileage_km DESC, m.protocol ASC` @@ -581,8 +581,16 @@ WHERE ` + strings.Join(where, " AND ") `MAX(h.consumption_kg_per_100km) AS hydrogen_consumption_kg_per_100km, ` + `MAX(h.soc_balanced_consumption_kg) AS hydrogen_soc_balanced_kg, ` + `MAX(h.soc_balanced_kg_per_100km) AS hydrogen_soc_balanced_kg_per_100km, ` + + `MAX(h.pure_electric_mileage_km) AS pure_electric_mileage_km, ` + + `MAX(h.mixed_mileage_km) AS mixed_mileage_km, ` + + `MAX(h.battery_soc_delta_pct) AS battery_soc_delta_pct, ` + + `MAX(h.charge_count) AS charge_count, ` + + `MAX(h.charge_energy_kwh) AS charge_energy_kwh, ` + + `MAX(h.refuel_count) AS refuel_count, ` + + `MAX(h.refuel_amount_kg) AS refuel_amount_kg, ` + `MAX(CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END) AS hydrogen_evidence_available, ` + `COALESCE(MAX(h.quality_status), '') AS hydrogen_quality_status, ` + + `COALESCE(MAX(h.quality_reason), '') AS hydrogen_quality_reason, ` + `COALESCE(MAX(h.algorithm_version), '') AS hydrogen_algorithm_version, ` + `COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(m.protocol ORDER BY ` + selectionOrder + `), ',', 1), '') AS protocol ` + groupSQL + ` ORDER BY m.stat_date DESC, m.vin ASC LIMIT ? OFFSET ?`, @@ -601,8 +609,9 @@ WHERE ` + strings.Join(where, " AND ") `COALESCE(m.latest_total_mileage_km - m.daily_mileage_km, 0) AS start_mileage_km, ` + `COALESCE(m.latest_total_mileage_km, 0) AS end_mileage_km, m.daily_mileage_km, ` + `COALESCE(m.pure_hydrogen_mileage_km, 0), h.consumption_kg, h.consumption_kg_per_100km, h.soc_balanced_consumption_kg, ` + - `h.soc_balanced_kg_per_100km, CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END, ` + - `COALESCE(h.quality_status, ''), COALESCE(h.algorithm_version, ''), m.protocol ` + + `h.soc_balanced_kg_per_100km, h.pure_electric_mileage_km, h.mixed_mileage_km, h.battery_soc_delta_pct, ` + + `h.charge_count, h.charge_energy_kwh, h.refuel_count, h.refuel_amount_kg, CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END, ` + + `COALESCE(h.quality_status, ''), COALESCE(h.quality_reason, ''), COALESCE(h.algorithm_version, ''), m.protocol ` + fromSQL + ` ORDER BY m.stat_date DESC, m.vin ASC, m.protocol ASC LIMIT ? OFFSET ?`, Args: args, CountText: `SELECT COUNT(*) ` + fromSQL, diff --git a/vehicle-data-platform/apps/api/internal/platform/production_store.go b/vehicle-data-platform/apps/api/internal/platform/production_store.go index 64d94e8d..36f2fa0c 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -1153,10 +1153,12 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P items := make([]DailyMileageRow, 0) for rows.Next() { var row DailyMileageRow - var hydrogen, physicalRate, balanced, balancedRate sql.NullFloat64 + var hydrogen, physicalRate, balanced, balancedRate, pureElectric, mixed, socDelta, chargeEnergy, refuelAmount sql.NullFloat64 + var chargeCount, refuelCount sql.NullInt64 var evidenceAvailable int if err := rows.Scan(&row.VIN, &row.Plate, &row.Date, &row.StartMileageKm, &row.EndMileageKm, &row.DailyMileageKm, &row.PureHydrogenMileageKm, - &hydrogen, &physicalRate, &balanced, &balancedRate, &evidenceAvailable, &row.HydrogenQualityStatus, &row.HydrogenAlgorithmVersion, &row.Source); err != nil { + &hydrogen, &physicalRate, &balanced, &balancedRate, &pureElectric, &mixed, &socDelta, &chargeCount, &chargeEnergy, &refuelCount, &refuelAmount, + &evidenceAvailable, &row.HydrogenQualityStatus, &row.HydrogenQualityReason, &row.HydrogenAlgorithmVersion, &row.Source); err != nil { return Page[DailyMileageRow]{}, err } if hydrogen.Valid { @@ -1177,6 +1179,19 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P value := balancedRate.Float64 row.HydrogenSOCBalancedKgPer100Km = &value } + row.PureElectricMileageKm = nullableFloatPointer(pureElectric) + row.MixedMileageKm = nullableFloatPointer(mixed) + row.BatterySOCDeltaPct = nullableFloatPointer(socDelta) + if chargeCount.Valid { + value := int(chargeCount.Int64) + row.ChargeCount = &value + } + row.ChargeEnergyKWh = nullableFloatPointer(chargeEnergy) + if refuelCount.Valid { + value := int(refuelCount.Int64) + row.RefuelCount = &value + } + row.RefuelAmountKg = nullableFloatPointer(refuelAmount) row.HydrogenEvidenceAvailable = evidenceAvailable == 1 items = append(items, row) } @@ -1195,20 +1210,21 @@ func (s *ProductionStore) HydrogenDailyEvidence(ctx context.Context, vin, date s h.source_endpoint,COALESCE(h.raw_consumption_kg,h.consumption_kg),h.battery_soc_delta_pct, h.battery_discharge_kwh,h.battery_equivalent_kg,h.soc_balanced_consumption_kg, h.mixed_mileage_km,h.pure_electric_mileage_km,h.consumption_kg_per_100km,h.soc_balanced_kg_per_100km, - h.sample_count,h.refuel_count,h.charge_count,h.valid_segment_count,h.invalid_segment_count, + h.sample_count,h.refuel_count,h.refuel_amount_kg,h.charge_count,h.charge_energy_kwh,h.valid_segment_count,h.invalid_segment_count, h.quality_status,h.quality_reason,h.algorithm_version,h.parameter_json,h.evidence_json, DATE_FORMAT(h.calculated_at,'%Y-%m-%d %H:%i:%s.%f') FROM vehicle_open_daily_energy h -LEFT JOIN vehicle_identity_binding b ON b.vin=h.vin +LEFT JOIN vehicle_identity_binding b + ON b.vin COLLATE utf8mb4_unicode_ci = h.vin COLLATE utf8mb4_unicode_ci WHERE h.vin=? AND h.stat_date=? AND h.energy_type='HYDROGEN' LIMIT 1`, vin, date) var result HydrogenDailyEvidence - var socDelta, discharge, equivalent, balanced, physicalRate, balancedRate sql.NullFloat64 + var socDelta, discharge, equivalent, balanced, physicalRate, balancedRate, refuelAmount, chargeEnergy sql.NullFloat64 var parameterJSON, evidenceJSON []byte if err := row.Scan( &result.VIN, &result.Plate, &result.Date, &result.Source, &result.RawConsumptionKg, &socDelta, &discharge, &equivalent, &balanced, &result.MixedMileageKm, &result.PureElectricMileageKm, - &physicalRate, &balancedRate, &result.SampleCount, &result.RefuelCount, &result.ChargeCount, + &physicalRate, &balancedRate, &result.SampleCount, &result.RefuelCount, &refuelAmount, &result.ChargeCount, &chargeEnergy, &result.ValidSegmentCount, &result.InvalidSegmentCount, &result.QualityStatus, &result.QualityReason, &result.AlgorithmVersion, ¶meterJSON, &evidenceJSON, &result.CalculatedAt, ); err != nil { @@ -1223,6 +1239,8 @@ LIMIT 1`, vin, date) result.SOCBalancedConsumptionKg = nullableFloatPointer(balanced) result.ConsumptionKgPer100Km = nullableFloatPointer(physicalRate) result.SOCBalancedKgPer100Km = nullableFloatPointer(balancedRate) + result.RefuelAmountKg = nullableFloatPointer(refuelAmount) + result.ChargeEnergyKWh = nullableFloatPointer(chargeEnergy) result.Intervals = []HydrogenIntervalEvidenceRow{} if len(parameterJSON) > 0 { if err := json.Unmarshal(parameterJSON, &result.Parameters); err != nil { diff --git a/vehicle-data-platform/apps/api/internal/platform/production_store_test.go b/vehicle-data-platform/apps/api/internal/platform/production_store_test.go index a149114a..94fdf3a6 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store_test.go @@ -6,12 +6,52 @@ import ( "database/sql/driver" "errors" "io" + "net/url" "strings" "testing" "github.com/DATA-DOG/go-sqlmock" ) +func TestProductionStoreDailyMileageReturnsSuspectHydrogenExportDetails(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + columns := []string{ + "vin", "plate", "date", "start_mileage_km", "end_mileage_km", "daily_mileage_km", "pure_hydrogen_mileage_km", + "hydrogen_consumption_kg", "hydrogen_consumption_kg_per_100km", "hydrogen_soc_balanced_kg", "hydrogen_soc_balanced_kg_per_100km", + "pure_electric_mileage_km", "mixed_mileage_km", "battery_soc_delta_pct", "charge_count", "charge_energy_kwh", + "refuel_count", "refuel_amount_kg", "hydrogen_evidence_available", "hydrogen_quality_status", "hydrogen_quality_reason", + "hydrogen_algorithm_version", "protocol", + } + mock.ExpectQuery("SELECT m.vin"). + WithArgs(20, 0). + WillReturnRows(sqlmock.NewRows(columns).AddRow( + "LB9A32A29R0LS1423", "粤AGR9816", "2026-08-26", 23119.8, 23333.1, 213.3, 213.3, + 4.491, 2.106, 4.544, 2.130, 0.0, 213.3, -4.0, 1, 12.5, + 1, 8.976, 1, "SUSPECT", "疑似管路泄压", "PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5", "GB32960", + )) + + page, err := (&ProductionStore{db: db}).DailyMileage(context.Background(), url.Values{"skipCount": {"1"}}) + if err != nil { + t.Fatal(err) + } + if len(page.Items) != 1 { + t.Fatalf("items=%#v", page.Items) + } + row := page.Items[0] + if row.HydrogenConsumptionKg == nil || *row.HydrogenConsumptionKg != 4.491 || row.HydrogenQualityStatus != "SUSPECT" || row.HydrogenQualityReason == "" || + row.ChargeCount == nil || *row.ChargeCount != 1 || row.ChargeEnergyKWh == nil || *row.ChargeEnergyKWh != 12.5 || + row.RefuelCount == nil || *row.RefuelCount != 1 || row.RefuelAmountKg == nil || *row.RefuelAmountKg != 8.976 { + t.Fatalf("row=%#v", row) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + func init() { sql.Register("tdengine_missing_table_test", missingTableDriver{}) sql.Register("ops_health_test", opsHealthDriver{}) diff --git a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go index bfe19add..fb00b3c6 100644 --- a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go @@ -485,9 +485,9 @@ func TestBuildDailyMileageSQL(t *testing.T) { if !strings.Contains(built.Text, "m.pure_hydrogen_mileage_km") { t.Fatalf("daily mileage must return pure hydrogen mileage: %s", built.Text) } - for _, want := range []string{"vehicle_open_daily_energy h", "h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci", "h.energy_type = 'HYDROGEN'", "h.quality_status = 'OK'", "h.consumption_kg"} { + for _, want := range []string{"vehicle_open_daily_energy h", "h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci", "h.energy_type = 'HYDROGEN'", "h.quality_status IN ('OK','SUSPECT')", "h.consumption_kg", "h.charge_energy_kwh", "h.refuel_amount_kg", "h.quality_reason"} { if !strings.Contains(built.Text, want) { - t.Fatalf("daily mileage must return quality-approved hydrogen consumption, missing %q: %s", want, built.Text) + t.Fatalf("daily mileage must return calculated hydrogen details, missing %q: %s", want, built.Text) } } if !strings.Contains(built.CountText, "COUNT(*)") || strings.Contains(built.CountText, "LIMIT") { diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index 7def51f8..cd763aab 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -5586,6 +5586,19 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail result.Items[index].PureHydrogenMileageKm = 0 result.Items[index].HydrogenConsumptionKg = nil result.Items[index].HydrogenConsumptionKgPer100Km = nil + result.Items[index].HydrogenSOCBalancedKg = nil + result.Items[index].HydrogenSOCBalancedKgPer100Km = nil + result.Items[index].PureElectricMileageKm = nil + result.Items[index].MixedMileageKm = nil + result.Items[index].BatterySOCDeltaPct = nil + result.Items[index].ChargeCount = nil + result.Items[index].ChargeEnergyKWh = nil + result.Items[index].RefuelCount = nil + result.Items[index].RefuelAmountKg = nil + result.Items[index].HydrogenEvidenceAvailable = false + result.Items[index].HydrogenQualityStatus = "" + result.Items[index].HydrogenQualityReason = "" + result.Items[index].HydrogenAlgorithmVersion = "" } } return result, nil diff --git a/vehicle-data-platform/apps/api/internal/platform/service_test.go b/vehicle-data-platform/apps/api/internal/platform/service_test.go index 8607a179..fa04c9e0 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/service_test.go @@ -571,10 +571,15 @@ func (s *projectionCountingStore) LatestRealtimeFrames(_ context.Context, query type hydrogenMetricsStore struct{ *MockStore } func (s *hydrogenMetricsStore) DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error) { - consumption, rate := 3.1, 5.5 + consumption, rate, pureElectric, mixed, socDelta, chargeEnergy, refuelAmount := 3.1, 5.5, 32.5, 56.2, -2.0, 12.5, 8.976 + chargeCount, refuelCount := 1, 1 return Page[DailyMileageRow]{Items: []DailyMileageRow{{ VIN: "LB9A32A24R0LS1426", Date: "2026-07-13", DailyMileageKm: 88.7, PureHydrogenMileageKm: 56.2, HydrogenConsumptionKg: &consumption, HydrogenConsumptionKgPer100Km: &rate, + HydrogenSOCBalancedKg: &consumption, PureElectricMileageKm: &pureElectric, MixedMileageKm: &mixed, + BatterySOCDeltaPct: &socDelta, ChargeCount: &chargeCount, ChargeEnergyKWh: &chargeEnergy, + RefuelCount: &refuelCount, RefuelAmountKg: &refuelAmount, HydrogenEvidenceAvailable: true, + HydrogenQualityStatus: "SUSPECT", HydrogenQualityReason: "疑似管路泄压", HydrogenAlgorithmVersion: "V3_5", }}}, nil } @@ -623,7 +628,8 @@ func TestHydrogenConsumptionMetricsAreNotExposedToCustomerAccounts(t *testing.T) if err != nil { t.Fatal(err) } - if len(daily.Items) != 1 || daily.Items[0].PureHydrogenMileageKm != 0 || daily.Items[0].HydrogenConsumptionKg != nil || daily.Items[0].HydrogenConsumptionKgPer100Km != nil { + if len(daily.Items) != 1 || daily.Items[0].PureHydrogenMileageKm != 0 || daily.Items[0].HydrogenConsumptionKg != nil || daily.Items[0].HydrogenConsumptionKgPer100Km != nil || + daily.Items[0].ChargeEnergyKWh != nil || daily.Items[0].RefuelAmountKg != nil || daily.Items[0].HydrogenQualityStatus != "" || daily.Items[0].HydrogenEvidenceAvailable { t.Fatalf("customer daily mileage exposed hydrogen metrics: %+v", daily.Items) } summary, err := service.MileageStatistics(customer, query) diff --git a/vehicle-data-platform/apps/open-portal/src/Documentation.tsx b/vehicle-data-platform/apps/open-portal/src/Documentation.tsx index db232010..11e8cf8f 100644 --- a/vehicle-data-platform/apps/open-portal/src/Documentation.tsx +++ b/vehicle-data-platform/apps/open-portal/src/Documentation.tsx @@ -136,7 +136,7 @@ function LimitsGuide() { function APIReference({ kind }: { kind: "hydrogen" | "mileage" }) { const product = products[kind === "hydrogen" ? 0 : 1]; - const fields = kind === "hydrogen" ? `"hydrogenConsumptionKg": 12.315,\n "calculationPhase": "FINAL",\n "algorithmVersion": "PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5"` : `"dailyMileageKm": 182.437,\n "totalMileageKm": 12345.679,\n "dataTime": "2026-07-19T23:58:45+08:00",\n "updatedAt": "2026-07-20T05:10:00+08:00",\n "sourceProtocol": "GB32960"`; + const fields = kind === "hydrogen" ? `"hydrogenConsumptionKg": 12.315,\n "calculationPhase": "FINAL",\n "algorithmVersion": "PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5",\n "qualityStatus": "OK",\n "qualityReason": ""` : `"dailyMileageKm": 182.437,\n "totalMileageKm": 12345.679,\n "dataTime": "2026-07-19T23:58:45+08:00",\n "updatedAt": "2026-07-20T05:10:00+08:00",\n "sourceProtocol": "GB32960"`; return <>
{product.description} 查询范围同时受 AppKey、车辆授权和授权有效期约束。
protocolPriority 只允许 GB32960、MQTT、JT808。数组必须非空且不能重复;逐车按顺序选择第一个有效协议,未列出的协议完全禁用。不传时保持平台默认行为。
查询日没有有效里程但此前存在有效累计里程时,dailyMileageKm 返回 0,累计总里程、来源协议和数据时间沿用最近有效统计;updatedAt 保持为上一个统计周期的计算时间。此前也无有效累计里程时才返回 NO_DATA。
| 字段 | 类型 | 说明 |
|---|---|---|
vin | string | 车辆 VIN |
plateNumber | string | 请求中的车牌号 |
date | string | 统计自然日 |
{kind === "hydrogen" ? "hydrogenConsumptionKg" : "dailyMileageKm"} | number | null | {kind === "hydrogen" ? "用氢量,单位 kg" : "当日行驶里程,单位 km"} |
calculationPhase | string | 当天实时暂估为 PRELIMINARY,日终重算为 FINAL |
algorithmVersion | string | 氢耗计算算法版本 |
totalMileageKm | number | null | 同一协议在当日最后有效时刻的累计总里程 |
dataTime | date-time | null | 统计实际采用的最后一条车辆源数据时间 |
updatedAt | date-time | null | 日统计投影最后更新时间 |
sourceProtocol | string | null | 实际选中的 GB32960、MQTT 或 JT808;NO_DATA 时为 null |
status | string | NORMAL 或 NO_DATA |
| 字段 | 类型 | 说明 |
|---|---|---|
vin | string | 车辆 VIN |
plateNumber | string | 请求中的车牌号 |
date | string | 统计自然日 |
{kind === "hydrogen" ? "hydrogenConsumptionKg" : "dailyMileageKm"} | number | null | {kind === "hydrogen" ? "用氢量,单位 kg;SUSPECT 结果也会返回数值" : "当日行驶里程,单位 km"} |
calculationPhase | string | 当天实时暂估为 PRELIMINARY,日终重算为 FINAL |
algorithmVersion | string | 氢耗计算算法版本 |
qualityStatus | string | 计算质量:OK、SUSPECT 或 NO_DATA |
qualityReason | string | 质量判定原因,便于调用方识别异常结果 |
totalMileageKm | number | null | 同一协议在当日最后有效时刻的累计总里程 |
dataTime | date-time | null | 统计实际采用的最后一条车辆源数据时间 |
updatedAt | date-time | null | 日统计投影最后更新时间 |
sourceProtocol | string | null | 实际选中的 GB32960、MQTT 或 JT808;NO_DATA 时为 null |
status | string | NORMAL、DATA_ANOMALY 或 NO_DATA |