feat: 完善每日氢耗明细导出与质量标记

This commit is contained in:
lingniu
2026-09-06 02:35:36 +08:00
parent e402eb5e60
commit 9fa3d81777
22 changed files with 237 additions and 48 deletions
@@ -1,7 +1,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: 车辆数据开放平台 API title: 车辆数据开放平台 API
version: 1.6.0 version: 1.7.0
license: license:
name: Proprietary name: Proprietary
description: | description: |
@@ -20,7 +20,7 @@ paths:
post: post:
tags: [合作方数据接口] tags: [合作方数据接口]
summary: 查询车辆单日用氢量 summary: 查询车辆单日用氢量
description: plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。 description: plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。SUSPECT 计算结果保留用氢量并以 DATA_ANOMALY 输出,同时返回质量原因。
operationId: queryDailyHydrogenConsumption operationId: queryDailyHydrogenConsumption
security: security:
- AppKeyAuth: [] - AppKeyAuth: []
@@ -619,7 +619,7 @@ components:
type: number type: number
format: double format: double
nullable: true nullable: true
description: 单日用氢量,kg;无数据时为 null description: 单日用氢量,kgOK 和 SUSPECT 计算结果均返回数值,无数据时为 null
calculationPhase: calculationPhase:
type: string type: string
enum: [PRELIMINARY, FINAL] enum: [PRELIMINARY, FINAL]
@@ -627,6 +627,13 @@ components:
algorithmVersion: algorithmVersion:
type: string type: string
description: 氢耗计算算法版本 description: 氢耗计算算法版本
qualityStatus:
type: string
enum: [OK, SUSPECT, NO_DATA]
description: 氢耗计算质量状态
qualityReason:
type: string
description: 质量判定原因;SUSPECT 或 NO_DATA 时供调用方解释和审计
status: status:
$ref: '#/components/schemas/DataStatus' $ref: '#/components/schemas/DataStatus'
MileageResult: MileageResult:
@@ -51,6 +51,8 @@ type HydrogenResult struct {
HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg"` HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg"`
CalculationPhase string `json:"calculationPhase,omitempty"` CalculationPhase string `json:"calculationPhase,omitempty"`
AlgorithmVersion string `json:"algorithmVersion,omitempty"` AlgorithmVersion string `json:"algorithmVersion,omitempty"`
QualityStatus string `json:"qualityStatus,omitempty"`
QualityReason string `json:"qualityReason,omitempty"`
Status string `json:"status"` Status string `json:"status"`
} }
@@ -288,6 +290,7 @@ type DailyHydrogen struct {
ConsumptionKg float64 ConsumptionKg float64
SampleCount int SampleCount int
QualityStatus string QualityStatus string
QualityReason string
CalculationPhase string CalculationPhase string
AlgorithmVersion string AlgorithmVersion string
} }
@@ -312,7 +312,7 @@ func (r *MySQLRepository) DailyHydrogen(ctx context.Context, vins []string, date
return map[string]DailyHydrogen{}, nil return map[string]DailyHydrogen{}, nil
} }
query, args := inQuery(` 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 calculation_phase,algorithm_version
FROM vehicle_open_daily_energy FROM vehicle_open_daily_energy
WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins) 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)) out := make(map[string]DailyHydrogen, len(vins))
for rows.Next() { for rows.Next() {
var value DailyHydrogen 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 return nil, err
} }
out[value.VIN] = value out[value.VIN] = value
@@ -394,10 +394,17 @@ func (s *Service) QueryHydrogen(ctx context.Context, appKey, traceID string, req
if value, ok := values[vehicle.VIN]; ok { if value, ok := values[vehicle.VIN]; ok {
item.CalculationPhase = value.CalculationPhase item.CalculationPhase = value.CalculationPhase
item.AlgorithmVersion = value.AlgorithmVersion 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) consumption := round3(value.ConsumptionKg)
item.HydrogenConsumptionKg = &consumption item.HydrogenConsumptionKg = &consumption
item.Status = StatusNormal item.Status = StatusNormal
case strings.EqualFold(value.QualityStatus, "SUSPECT"):
consumption := round3(value.ConsumptionKg)
item.HydrogenConsumptionKg = &consumption
item.Status = StatusDataAnomaly
} }
} }
results = append(results, item) results = append(results, item)
@@ -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) { func TestDailyQueriesWithoutPlatesReturnAllAuthorizedVehicles(t *testing.T) {
repository := &fakeRepository{ repository := &fakeRepository{
app: AppCredential{ID: 7, Name: "partner"}, app: AppCredential{ID: 7, Name: "partner"},
@@ -933,16 +933,18 @@ func replaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, sco
INSERT INTO vehicle_open_daily_energy( INSERT INTO vehicle_open_daily_energy(
vin,stat_date,energy_type,source_endpoint,consumption_kg,unit,first_mass_kg,last_mass_kg, 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, 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, raw_consumption_kg,battery_soc_delta_pct,battery_discharge_kwh,battery_equivalent_kg,
soc_balanced_consumption_kg,mixed_mileage_km,pure_electric_mileage_km, 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 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.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.FirstMassKg, stat.LastMassKg,
stat.SampleCount, stat.RefuelCount, stat.QualityStatus, stat.QualityReason, stat.SampleCount, stat.RefuelCount, stat.QualityStatus, stat.QualityReason,
stat.RefuelAmountKg,
stat.ConsumptionKg, stat.BatterySOCDeltaPct, stat.BatteryDischargeKWh, stat.BatteryEquivalentKg, stat.ConsumptionKg, stat.BatterySOCDeltaPct, stat.BatteryDischargeKWh, stat.BatteryEquivalentKg,
stat.SOCBalancedConsumptionKg, stat.MixedMileageKm, stat.PureElectricMileageKm, 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, stat.InvalidSegmentCount, stat.CalculationParameters.AlgorithmVersion, parameterJSON, evidenceJSON,
); err != nil { ); err != nil {
return err return err
@@ -355,8 +355,8 @@ func TestReplaceHydrogenDailyStatsForVINDeletesOnlyScopedVehicle(t *testing.T) {
WillReturnResult(sqlmock.NewResult(0, 1)) WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy(")). 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", "", 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, 0.0, 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()). sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 0.0, 0, 0, "", sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1)) WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit() mock.ExpectCommit()
err = ReplaceHydrogenDailyStatsForVIN(context.Background(), db, "2026-08-12", vin, []HydrogenDailyStat{{ err = ReplaceHydrogenDailyStatsForVIN(context.Background(), db, "2026-08-12", vin, []HydrogenDailyStat{{
@@ -388,9 +388,9 @@ func TestReplaceHydrogenDailyStatsForVINsDeletesOnlyScopedBrandVehicles(t *testi
WillReturnResult(sqlmock.NewResult(0, 2)) WillReturnResult(sqlmock.NewResult(0, 2))
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy(")). mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy(")).
WithArgs(stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.FirstMassKg, stat.LastMassKg, 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(), 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)) WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit() mock.ExpectCommit()
@@ -425,8 +425,8 @@ func TestReplaceHydrogenDailyStatsAndSeedStreamIsAtomic(t *testing.T) {
WithArgs("2026-08-19").WillReturnResult(sqlmock.NewResult(0, 0)) WithArgs("2026-08-19").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy(")). 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", "", 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, 0.0, 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()). sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 0.0, 2, 0, "", sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1)) WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_hydrogen_segment_stream_state(")). 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", ""). 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)) WithArgs("2026-09-04").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy(")). 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, 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, 0.0, 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()). sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 0.0, 0, 0, "", sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1)) WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit() mock.ExpectCommit()
@@ -17,21 +17,21 @@ func TestProductionStoreHydrogenDailyEvidenceReturnsParametersIntervalsAndRawEve
"vin", "plate", "date", "source_endpoint", "raw_consumption_kg", "battery_soc_delta_pct", "vin", "plate", "date", "source_endpoint", "raw_consumption_kg", "battery_soc_delta_pct",
"battery_discharge_kwh", "battery_equivalent_kg", "soc_balanced_consumption_kg", "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", "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", "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"}` 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":""}]` 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( 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, "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", []byte(parameterJSON), []byte(evidenceJSON), "2026-08-27 00:05:00.000",
)) ))
result, found, err := (&ProductionStore{db: db}).HydrogenDailyEvidence(context.Background(), "LTEST000000000001", "2026-08-26") result, found, err := (&ProductionStore{db: db}).HydrogenDailyEvidence(context.Background(), "LTEST000000000001", "2026-08-26")
if err != nil || !found { if err != nil || !found {
t.Fatalf("found=%v err=%v", found, err) 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) t.Fatalf("result=%#v", result)
} }
if err := mock.ExpectationsWereMet(); err != nil { if err := mock.ExpectationsWereMet(); err != nil {
@@ -1885,8 +1885,16 @@ type DailyMileageRow struct {
HydrogenConsumptionKgPer100Km *float64 `json:"hydrogenConsumptionKgPer100Km,omitempty"` HydrogenConsumptionKgPer100Km *float64 `json:"hydrogenConsumptionKgPer100Km,omitempty"`
HydrogenSOCBalancedKg *float64 `json:"hydrogenSocBalancedKg,omitempty"` HydrogenSOCBalancedKg *float64 `json:"hydrogenSocBalancedKg,omitempty"`
HydrogenSOCBalancedKgPer100Km *float64 `json:"hydrogenSocBalancedKgPer100Km,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"` HydrogenEvidenceAvailable bool `json:"hydrogenEvidenceAvailable,omitempty"`
HydrogenQualityStatus string `json:"hydrogenQualityStatus,omitempty"` HydrogenQualityStatus string `json:"hydrogenQualityStatus,omitempty"`
HydrogenQualityReason string `json:"hydrogenQualityReason,omitempty"`
HydrogenAlgorithmVersion string `json:"hydrogenAlgorithmVersion,omitempty"` HydrogenAlgorithmVersion string `json:"hydrogenAlgorithmVersion,omitempty"`
Source string `json:"source"` Source string `json:"source"`
AnomalySeverity string `json:"anomalySeverity,omitempty"` AnomalySeverity string `json:"anomalySeverity,omitempty"`
@@ -1949,7 +1957,9 @@ type HydrogenDailyEvidence struct {
SOCBalancedKgPer100Km *float64 `json:"socBalancedKgPer100Km,omitempty"` SOCBalancedKgPer100Km *float64 `json:"socBalancedKgPer100Km,omitempty"`
SampleCount int `json:"sampleCount"` SampleCount int `json:"sampleCount"`
RefuelCount int `json:"refuelCount"` RefuelCount int `json:"refuelCount"`
RefuelAmountKg *float64 `json:"refuelAmountKg,omitempty"`
ChargeCount int `json:"chargeCount"` ChargeCount int `json:"chargeCount"`
ChargeEnergyKWh *float64 `json:"chargeEnergyKWh,omitempty"`
ValidSegmentCount int `json:"validSegmentCount"` ValidSegmentCount int `json:"validSegmentCount"`
InvalidSegmentCount int `json:"invalidSegmentCount"` InvalidSegmentCount int `json:"invalidSegmentCount"`
QualityStatus string `json:"qualityStatus"` QualityStatus string `json:"qualityStatus"`
@@ -559,7 +559,7 @@ LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin
LEFT JOIN vehicle_open_daily_energy h LEFT JOIN vehicle_open_daily_energy h
ON h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci ON h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci
AND h.stat_date = m.stat_date 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 ") WHERE ` + strings.Join(where, " AND ")
if query.Get("deduplicate") == "1" || strings.EqualFold(query.Get("deduplicate"), "true") { if query.Get("deduplicate") == "1" || strings.EqualFold(query.Get("deduplicate"), "true") {
selectionOrder := `m.daily_mileage_km DESC, m.protocol ASC` 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.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_consumption_kg) AS hydrogen_soc_balanced_kg, ` +
`MAX(h.soc_balanced_kg_per_100km) AS hydrogen_soc_balanced_kg_per_100km, ` + `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, ` + `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_status), '') AS hydrogen_quality_status, ` +
`COALESCE(MAX(h.quality_reason), '') AS hydrogen_quality_reason, ` +
`COALESCE(MAX(h.algorithm_version), '') AS hydrogen_algorithm_version, ` + `COALESCE(MAX(h.algorithm_version), '') AS hydrogen_algorithm_version, ` +
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(m.protocol ORDER BY ` + selectionOrder + `), ',', 1), '') AS protocol ` + `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 ?`, 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 - 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.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, ` + `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, ` + `h.soc_balanced_kg_per_100km, h.pure_electric_mileage_km, h.mixed_mileage_km, h.battery_soc_delta_pct, ` +
`COALESCE(h.quality_status, ''), COALESCE(h.algorithm_version, ''), m.protocol ` + `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 ?`, fromSQL + ` ORDER BY m.stat_date DESC, m.vin ASC, m.protocol ASC LIMIT ? OFFSET ?`,
Args: args, Args: args,
CountText: `SELECT COUNT(*) ` + fromSQL, CountText: `SELECT COUNT(*) ` + fromSQL,
@@ -1153,10 +1153,12 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P
items := make([]DailyMileageRow, 0) items := make([]DailyMileageRow, 0)
for rows.Next() { for rows.Next() {
var row DailyMileageRow 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 var evidenceAvailable int
if err := rows.Scan(&row.VIN, &row.Plate, &row.Date, &row.StartMileageKm, &row.EndMileageKm, &row.DailyMileageKm, &row.PureHydrogenMileageKm, 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 return Page[DailyMileageRow]{}, err
} }
if hydrogen.Valid { if hydrogen.Valid {
@@ -1177,6 +1179,19 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P
value := balancedRate.Float64 value := balancedRate.Float64
row.HydrogenSOCBalancedKgPer100Km = &value 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 row.HydrogenEvidenceAvailable = evidenceAvailable == 1
items = append(items, row) 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.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.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.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, 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') DATE_FORMAT(h.calculated_at,'%Y-%m-%d %H:%i:%s.%f')
FROM vehicle_open_daily_energy h 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' WHERE h.vin=? AND h.stat_date=? AND h.energy_type='HYDROGEN'
LIMIT 1`, vin, date) LIMIT 1`, vin, date)
var result HydrogenDailyEvidence 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 var parameterJSON, evidenceJSON []byte
if err := row.Scan( if err := row.Scan(
&result.VIN, &result.Plate, &result.Date, &result.Source, &result.RawConsumptionKg, &result.VIN, &result.Plate, &result.Date, &result.Source, &result.RawConsumptionKg,
&socDelta, &discharge, &equivalent, &balanced, &result.MixedMileageKm, &result.PureElectricMileageKm, &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.ValidSegmentCount, &result.InvalidSegmentCount, &result.QualityStatus, &result.QualityReason,
&result.AlgorithmVersion, &parameterJSON, &evidenceJSON, &result.CalculatedAt, &result.AlgorithmVersion, &parameterJSON, &evidenceJSON, &result.CalculatedAt,
); err != nil { ); err != nil {
@@ -1223,6 +1239,8 @@ LIMIT 1`, vin, date)
result.SOCBalancedConsumptionKg = nullableFloatPointer(balanced) result.SOCBalancedConsumptionKg = nullableFloatPointer(balanced)
result.ConsumptionKgPer100Km = nullableFloatPointer(physicalRate) result.ConsumptionKgPer100Km = nullableFloatPointer(physicalRate)
result.SOCBalancedKgPer100Km = nullableFloatPointer(balancedRate) result.SOCBalancedKgPer100Km = nullableFloatPointer(balancedRate)
result.RefuelAmountKg = nullableFloatPointer(refuelAmount)
result.ChargeEnergyKWh = nullableFloatPointer(chargeEnergy)
result.Intervals = []HydrogenIntervalEvidenceRow{} result.Intervals = []HydrogenIntervalEvidenceRow{}
if len(parameterJSON) > 0 { if len(parameterJSON) > 0 {
if err := json.Unmarshal(parameterJSON, &result.Parameters); err != nil { if err := json.Unmarshal(parameterJSON, &result.Parameters); err != nil {
@@ -6,12 +6,52 @@ import (
"database/sql/driver" "database/sql/driver"
"errors" "errors"
"io" "io"
"net/url"
"strings" "strings"
"testing" "testing"
"github.com/DATA-DOG/go-sqlmock" "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() { func init() {
sql.Register("tdengine_missing_table_test", missingTableDriver{}) sql.Register("tdengine_missing_table_test", missingTableDriver{})
sql.Register("ops_health_test", opsHealthDriver{}) sql.Register("ops_health_test", opsHealthDriver{})
@@ -485,9 +485,9 @@ func TestBuildDailyMileageSQL(t *testing.T) {
if !strings.Contains(built.Text, "m.pure_hydrogen_mileage_km") { if !strings.Contains(built.Text, "m.pure_hydrogen_mileage_km") {
t.Fatalf("daily mileage must return pure hydrogen mileage: %s", built.Text) 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) { 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") { if !strings.Contains(built.CountText, "COUNT(*)") || strings.Contains(built.CountText, "LIMIT") {
@@ -5586,6 +5586,19 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail
result.Items[index].PureHydrogenMileageKm = 0 result.Items[index].PureHydrogenMileageKm = 0
result.Items[index].HydrogenConsumptionKg = nil result.Items[index].HydrogenConsumptionKg = nil
result.Items[index].HydrogenConsumptionKgPer100Km = 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 return result, nil
@@ -571,10 +571,15 @@ func (s *projectionCountingStore) LatestRealtimeFrames(_ context.Context, query
type hydrogenMetricsStore struct{ *MockStore } type hydrogenMetricsStore struct{ *MockStore }
func (s *hydrogenMetricsStore) DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error) { 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{{ return Page[DailyMileageRow]{Items: []DailyMileageRow{{
VIN: "LB9A32A24R0LS1426", Date: "2026-07-13", DailyMileageKm: 88.7, VIN: "LB9A32A24R0LS1426", Date: "2026-07-13", DailyMileageKm: 88.7,
PureHydrogenMileageKm: 56.2, HydrogenConsumptionKg: &consumption, HydrogenConsumptionKgPer100Km: &rate, 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 }}}, nil
} }
@@ -623,7 +628,8 @@ func TestHydrogenConsumptionMetricsAreNotExposedToCustomerAccounts(t *testing.T)
if err != nil { if err != nil {
t.Fatal(err) 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) t.Fatalf("customer daily mileage exposed hydrogen metrics: %+v", daily.Items)
} }
summary, err := service.MileageStatistics(customer, query) summary, err := service.MileageStatistics(customer, query)
@@ -136,7 +136,7 @@ function LimitsGuide() {
function APIReference({ kind }: { kind: "hydrogen" | "mileage" }) { function APIReference({ kind }: { kind: "hydrogen" | "mileage" }) {
const product = products[kind === "hydrogen" ? 0 : 1]; 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 <> return <>
<h1>{product.name}</h1><p className="docs-lead">{product.description} AppKey</p> <h1>{product.name}</h1><p className="docs-lead">{product.description} AppKey</p>
<Endpoint method={product.method} path={product.path} /> <Endpoint method={product.method} path={product.path} />
@@ -150,7 +150,7 @@ function APIReference({ kind }: { kind: "hydrogen" | "mileage" }) {
"protocolPriority": ["JT808", "GB32960", "MQTT"]` : ""} "protocolPriority": ["JT808", "GB32960", "MQTT"]` : ""}
}`} /> }`} />
{kind === "mileage" && <><h2 id="协议优先级"></h2><p><code>protocolPriority</code> <code>GB32960</code><code>MQTT</code><code>JT808</code></p><h2 id="缺日补齐"></h2><p><code>dailyMileageKm</code> 0沿<code>updatedAt</code> NO_DATA</p></>} {kind === "mileage" && <><h2 id="协议优先级"></h2><p><code>protocolPriority</code> <code>GB32960</code><code>MQTT</code><code>JT808</code></p><h2 id="缺日补齐"></h2><p><code>dailyMileageKm</code> 0沿<code>updatedAt</code> NO_DATA</p></>}
<h2 id="响应字段"></h2><table className="docs-table"><thead><tr><th></th><th></th><th></th></tr></thead><tbody>{kind === "mileage" && <tr><td><code>vin</code></td><td>string</td><td> VIN</td></tr>}<tr><td><code>plateNumber</code></td><td>string</td><td></td></tr><tr><td><code>date</code></td><td>string</td><td></td></tr><tr><td><code>{kind === "hydrogen" ? "hydrogenConsumptionKg" : "dailyMileageKm"}</code></td><td>number | null</td><td>{kind === "hydrogen" ? "用氢量,单位 kg" : "当日行驶里程,单位 km"}</td></tr>{kind === "hydrogen" && <><tr><td><code>calculationPhase</code></td><td>string</td><td> PRELIMINARY FINAL</td></tr><tr><td><code>algorithmVersion</code></td><td>string</td><td></td></tr></>}{kind === "mileage" && <><tr><td><code>totalMileageKm</code></td><td>number | null</td><td></td></tr><tr><td><code>dataTime</code></td><td>date-time | null</td><td></td></tr><tr><td><code>updatedAt</code></td><td>date-time | null</td><td></td></tr><tr><td><code>sourceProtocol</code></td><td>string | null</td><td> GB32960MQTT JT808NO_DATA null</td></tr></>}<tr><td><code>status</code></td><td>string</td><td>NORMAL NO_DATA</td></tr></tbody></table> <h2 id="响应字段"></h2><table className="docs-table"><thead><tr><th></th><th></th><th></th></tr></thead><tbody>{kind === "mileage" && <tr><td><code>vin</code></td><td>string</td><td> VIN</td></tr>}<tr><td><code>plateNumber</code></td><td>string</td><td></td></tr><tr><td><code>date</code></td><td>string</td><td></td></tr><tr><td><code>{kind === "hydrogen" ? "hydrogenConsumptionKg" : "dailyMileageKm"}</code></td><td>number | null</td><td>{kind === "hydrogen" ? "用氢量,单位 kgSUSPECT 结果也会返回数值" : "当日行驶里程,单位 km"}</td></tr>{kind === "hydrogen" && <><tr><td><code>calculationPhase</code></td><td>string</td><td> PRELIMINARY FINAL</td></tr><tr><td><code>algorithmVersion</code></td><td>string</td><td></td></tr><tr><td><code>qualityStatus</code></td><td>string</td><td>OKSUSPECT NO_DATA</td></tr><tr><td><code>qualityReason</code></td><td>string</td><td>便</td></tr></>}{kind === "mileage" && <><tr><td><code>totalMileageKm</code></td><td>number | null</td><td></td></tr><tr><td><code>dataTime</code></td><td>date-time | null</td><td></td></tr><tr><td><code>updatedAt</code></td><td>date-time | null</td><td></td></tr><tr><td><code>sourceProtocol</code></td><td>string | null</td><td> GB32960MQTT JT808NO_DATA null</td></tr></>}<tr><td><code>status</code></td><td>string</td><td>NORMALDATA_ANOMALY NO_DATA</td></tr></tbody></table>
<h2 id="响应示例"></h2><CodeBlock language="json" value={`{ <h2 id="响应示例"></h2><CodeBlock language="json" value={`{
"code": "SUCCESS", "code": "SUCCESS",
"message": "success", "message": "success",
@@ -912,8 +912,16 @@ export interface DailyMileageRow {
hydrogenConsumptionKgPer100Km?: number | null; hydrogenConsumptionKgPer100Km?: number | null;
hydrogenSocBalancedKg?: number | null; hydrogenSocBalancedKg?: number | null;
hydrogenSocBalancedKgPer100Km?: number | null; hydrogenSocBalancedKgPer100Km?: number | null;
pureElectricMileageKm?: number | null;
mixedMileageKm?: number | null;
batterySocDeltaPct?: number | null;
chargeCount?: number | null;
chargeEnergyKWh?: number | null;
refuelCount?: number | null;
refuelAmountKg?: number | null;
hydrogenEvidenceAvailable?: boolean; hydrogenEvidenceAvailable?: boolean;
hydrogenQualityStatus?: string; hydrogenQualityStatus?: string;
hydrogenQualityReason?: string;
hydrogenAlgorithmVersion?: string; hydrogenAlgorithmVersion?: string;
source: string; source: string;
anomalySeverity?: string; anomalySeverity?: string;
@@ -934,7 +942,7 @@ export interface HydrogenDailyEvidence {
batterySocDeltaPct?: number; batteryDischargeKWh?: number; batteryEquivalentKg?: number; batterySocDeltaPct?: number; batteryDischargeKWh?: number; batteryEquivalentKg?: number;
socBalancedConsumptionKg?: number; mixedMileageKm: number; pureElectricMileageKm: number; socBalancedConsumptionKg?: number; mixedMileageKm: number; pureElectricMileageKm: number;
consumptionKgPer100Km?: number; socBalancedKgPer100Km?: number; sampleCount: number; consumptionKgPer100Km?: number; socBalancedKgPer100Km?: number; sampleCount: number;
refuelCount: number; chargeCount: number; validSegmentCount: number; invalidSegmentCount: number; refuelCount: number; refuelAmountKg?: number; chargeCount: number; chargeEnergyKWh?: number; validSegmentCount: number; invalidSegmentCount: number;
qualityStatus: string; qualityReason: string; algorithmVersion: string; calculatedAt: string; qualityStatus: string; qualityReason: string; algorithmVersion: string; calculatedAt: string;
parameters: { parameters: {
batteryCapacityKWh: number; hydrogenEnergyKWhPerKg: number; powerOnDelaySeconds: number; batteryCapacityKWh: number; hydrogenEnergyKWhPerKg: number; powerOnDelaySeconds: number;
@@ -14,7 +14,7 @@ test('creates a styled numeric mileage workbook with formulas and frozen panes',
{ vin: 'LTEST000000000002', plate: '粤A54321', brandName: '飞驰', modelName: 'FSQ' } { vin: 'LTEST000000000002', plate: '粤A54321', brandName: '飞驰', modelName: 'FSQ' }
], ],
mileageRows: [ mileageRows: [
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 100, endMileageKm: 188.7, dailyMileageKm: 88.7, pureHydrogenMileageKm: 56.2, hydrogenConsumptionKg: 3.1, hydrogenConsumptionKgPer100Km: 5.5, source: 'GB32960' }, { vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 100, endMileageKm: 188.7, dailyMileageKm: 88.7, pureHydrogenMileageKm: 56.2, pureElectricMileageKm: 32.5, mixedMileageKm: 56.2, hydrogenConsumptionKg: 3.1, hydrogenConsumptionKgPer100Km: 5.5, hydrogenSocBalancedKg: 3.126, batterySocDeltaPct: -2, chargeCount: 1, chargeEnergyKWh: 12.5, refuelCount: 1, refuelAmountKg: 8.976, hydrogenQualityStatus: 'SUSPECT', hydrogenQualityReason: '疑似管路泄压', source: 'GB32960' },
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 188.7, endMileageKm: 293.3, dailyMileageKm: 104.6, pureHydrogenMileageKm: 72.2, hydrogenConsumptionKg: 4.2, hydrogenConsumptionKgPer100Km: 5.8, source: 'GB32960' } { vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 188.7, endMileageKm: 293.3, dailyMileageKm: 104.6, pureHydrogenMileageKm: 72.2, hydrogenConsumptionKg: 4.2, hydrogenConsumptionKgPer100Km: 5.8, source: 'GB32960' }
], ],
sources: [ sources: [
@@ -43,12 +43,12 @@ test('creates a styled numeric mileage workbook with formulas and frozen panes',
expect((sheet as unknown as { conditionalFormattings: unknown[] }).conditionalFormattings).toHaveLength(1); expect((sheet as unknown as { conditionalFormattings: unknown[] }).conditionalFormattings).toHaveLength(1);
const hydrogenSheet = workbook.getWorksheet('氢能明细')!; const hydrogenSheet = workbook.getWorksheet('氢能明细')!;
expect(hydrogenSheet.getRow(1).values).toEqual([ expect(hydrogenSheet.getRow(1).values).toEqual([
undefined, '日期', '车牌', 'VIN', '品牌', '车型', '总里程 (km)', '纯里程 (km)', '耗氢 (kg)', '百公里纯氢耗 (kg/100km)', '来源' undefined, '日期', '车牌', 'VIN', '品牌', '车型', '每日氢耗 (kg/100km)', '结果状态', '总里程 (km)', '纯里程 (km)', '混动里程 (km)', '电SOC差值 (百分点)', '充电次数', '充电量 (kWh)', '加氢次数', '加氢量 (kg)', '修正用氢量 (kg)', '原因', '物理用氢量 (kg)', '来源'
]); ]);
expect(hydrogenSheet.getRow(2).values).toEqual([ expect(hydrogenSheet.getRow(2).values).toEqual([
undefined, '2026-07-13', '粤A12345', 'LTEST000000000001', '现代', 'XCIENT', 88.7, 56.2, 3.1, 5.5, 'GB32960' undefined, '2026-07-13', '粤A12345', 'LTEST000000000001', '现代', 'XCIENT', 5.5, 'SUSPECT', 88.7, 32.5, 56.2, -2, 1, 12.5, 1, 8.976, 3.126, '疑似管路泄压', 3.1, 'GB32960'
]); ]);
expect(hydrogenSheet.autoFilter).toEqual({ from: { row: 1, column: 1 }, to: { row: 3, column: 10 } }); expect(hydrogenSheet.autoFilter).toEqual({ from: { row: 1, column: 1 }, to: { row: 3, column: 19 } });
expect((await workbook.xlsx.writeBuffer()).byteLength).toBeGreaterThan(5_000); expect((await workbook.xlsx.writeBuffer()).byteLength).toBeGreaterThan(5_000);
}); });
@@ -106,6 +106,16 @@ test('terminates the workbook worker when an active export is cancelled', async
pureHydrogenMileageKm: undefined, pureHydrogenMileageKm: undefined,
hydrogenConsumptionKg: undefined, hydrogenConsumptionKg: undefined,
hydrogenConsumptionKgPer100Km: undefined, hydrogenConsumptionKgPer100Km: undefined,
hydrogenSocBalancedKg: undefined,
pureElectricMileageKm: undefined,
mixedMileageKm: undefined,
batterySocDeltaPct: undefined,
chargeCount: undefined,
chargeEnergyKWh: undefined,
refuelCount: undefined,
refuelAmountKg: undefined,
hydrogenQualityStatus: undefined,
hydrogenQualityReason: undefined,
source: 'GB32960' source: 'GB32960'
}]); }]);
controller.abort(); controller.abort();
@@ -90,6 +90,16 @@ export function createMileageExportStream(input: Omit<MileageExportInput, 'vehic
pureHydrogenMileageKm: row.pureHydrogenMileageKm, pureHydrogenMileageKm: row.pureHydrogenMileageKm,
hydrogenConsumptionKg: row.hydrogenConsumptionKg, hydrogenConsumptionKg: row.hydrogenConsumptionKg,
hydrogenConsumptionKgPer100Km: row.hydrogenConsumptionKgPer100Km, hydrogenConsumptionKgPer100Km: row.hydrogenConsumptionKgPer100Km,
hydrogenSocBalancedKg: row.hydrogenSocBalancedKg,
pureElectricMileageKm: row.pureElectricMileageKm,
mixedMileageKm: row.mixedMileageKm,
batterySocDeltaPct: row.batterySocDeltaPct,
chargeCount: row.chargeCount,
chargeEnergyKWh: row.chargeEnergyKWh,
refuelCount: row.refuelCount,
refuelAmountKg: row.refuelAmountKg,
hydrogenQualityStatus: row.hydrogenQualityStatus,
hydrogenQualityReason: row.hydrogenQualityReason,
source: row.source source: row.source
})) }))
}); });
@@ -137,7 +137,12 @@ export async function createMileageWorkbook(input: MileageExportInput) {
views: [{ state: 'frozen', ySplit: 1, activeCell: 'A2', showGridLines: false }], views: [{ state: 'frozen', ySplit: 1, activeCell: 'A2', showGridLines: false }],
properties: { defaultRowHeight: 21 } properties: { defaultRowHeight: 21 }
}); });
hydrogenSheet.addRow(['日期', '车牌', 'VIN', '品牌', '车型', '总里程 (km)', '纯氢里程 (km)', '耗氢 (kg)', '百公里纯氢耗 (kg/100km)', '来源']); const hydrogenHeaders = [
'日期', '车牌', 'VIN', '品牌', '车型', '每日氢耗 (kg/100km)', '结果状态', '总里程 (km)',
'纯电里程 (km)', '混动里程 (km)', '电SOC差值 (百分点)', '充电次数', '充电量 (kWh)', '加氢次数', '加氢量 (kg)',
'修正用氢量 (kg)', '原因', '物理用氢量 (kg)', '来源'
];
hydrogenSheet.addRow(hydrogenHeaders);
const hydrogenHeader = hydrogenSheet.getRow(1); const hydrogenHeader = hydrogenSheet.getRow(1);
hydrogenHeader.height = 30; hydrogenHeader.height = 30;
hydrogenHeader.eachCell((cell) => { hydrogenHeader.eachCell((cell) => {
@@ -156,26 +161,36 @@ export async function createMileageWorkbook(input: MileageExportInput) {
value.vin, value.vin,
vehicle?.brandName || '待维护', vehicle?.brandName || '待维护',
vehicle?.modelName || '待维护', vehicle?.modelName || '待维护',
value.dailyMileageKm,
value.pureHydrogenMileageKm ?? null,
value.hydrogenConsumptionKg ?? null,
value.hydrogenConsumptionKgPer100Km ?? null, value.hydrogenConsumptionKgPer100Km ?? null,
value.hydrogenQualityStatus || 'NO_DATA',
value.dailyMileageKm,
value.pureElectricMileageKm ?? null,
value.mixedMileageKm ?? null,
value.batterySocDeltaPct ?? null,
value.chargeCount ?? null,
value.chargeEnergyKWh ?? null,
value.refuelCount ?? null,
value.refuelAmountKg ?? null,
value.hydrogenSocBalancedKg ?? null,
value.hydrogenQualityReason || '',
value.hydrogenConsumptionKg ?? null,
value.source ?? '' value.source ?? ''
]); ]);
row.height = 24; row.height = 24;
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => { row.eachCell({ includeEmpty: true }, (cell, columnNumber) => {
cell.font = { name: columnNumber === 3 ? 'Consolas' : 'Microsoft YaHei', size: columnNumber === 3 ? 9 : 10, color: { argb: 'FF34445A' } }; cell.font = { name: columnNumber === 3 ? 'Consolas' : 'Microsoft YaHei', size: columnNumber === 3 ? 9 : 10, color: { argb: 'FF34445A' } };
cell.alignment = { vertical: 'middle', horizontal: columnNumber >= 6 && columnNumber <= 9 ? 'right' : 'left' }; cell.alignment = { vertical: 'middle', horizontal: columnNumber >= 6 && columnNumber <= 16 ? 'right' : 'left' };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFF9FBFD' : 'FFFFFFFF' } }; cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFF9FBFD' : 'FFFFFFFF' } };
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } }; cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } };
if (columnNumber >= 6 && columnNumber <= 9) cell.numFmt = '#,##0.0'; if ([6, 8, 9, 10, 11, 13, 15, 16, 18].includes(columnNumber)) cell.numFmt = '#,##0.000';
if ([12, 14].includes(columnNumber)) cell.numFmt = '0';
}); });
}); });
[13, 15, 24, 18, 24, 15, 17, 14, 27, 16].forEach((width, index) => { [13, 15, 24, 18, 24, 22, 14, 15, 17, 17, 22, 12, 17, 12, 16, 19, 52, 19, 16].forEach((width, index) => {
hydrogenSheet.getColumn(index + 1).width = width; hydrogenSheet.getColumn(index + 1).width = width;
}); });
if (energyRows.length) { if (energyRows.length) {
hydrogenSheet.autoFilter = { from: { row: 1, column: 1 }, to: { row: energyRows.length + 1, column: 10 } }; hydrogenSheet.autoFilter = { from: { row: 1, column: 1 }, to: { row: energyRows.length + 1, column: hydrogenHeaders.length } };
} }
hydrogenSheet.headerFooter.oddFooter = sheet.headerFooter.oddFooter; hydrogenSheet.headerFooter.oddFooter = sheet.headerFooter.oddFooter;
return workbook; return workbook;
@@ -899,6 +899,8 @@ function HydrogenEvidenceSheet({ target, onClose }: { target?: HydrogenEvidenceT
<div><dt></dt><dd>-{data.parameters.pureElectricDropMpa}MPa / {data.parameters.pureElectricWindowSeconds}s</dd></div> <div><dt></dt><dd>-{data.parameters.pureElectricDropMpa}MPa / {data.parameters.pureElectricWindowSeconds}s</dd></div>
<div><dt></dt><dd>{data.sampleCount}</dd></div> <div><dt></dt><dd>{data.sampleCount}</dd></div>
<div><dt> / </dt><dd>{data.refuelCount} / {data.chargeCount}</dd></div> <div><dt> / </dt><dd>{data.refuelCount} / {data.chargeCount}</dd></div>
<div><dt></dt><dd>{data.refuelAmountKg == null ? '—' : `${evidenceNumber(data.refuelAmountKg)} kg`}</dd></div>
<div><dt></dt><dd>{data.chargeEnergyKWh == null ? '—' : `${evidenceNumber(data.chargeEnergyKWh)} kWh`}</dd></div>
<div><dt></dt><dd>{data.invalidSegmentCount}</dd></div> <div><dt></dt><dd>{data.invalidSegmentCount}</dd></div>
<div><dt></dt><dd>{data.calculatedAt}</dd></div> <div><dt></dt><dd>{data.calculatedAt}</dd></div>
</dl> </dl>
@@ -0,0 +1,3 @@
ALTER TABLE vehicle_open_daily_energy
ADD COLUMN refuel_amount_kg DECIMAL(18,3) NULL AFTER refuel_count,
ADD COLUMN charge_energy_kwh DECIMAL(18,3) NULL AFTER charge_count;