feat: 完善每日氢耗明细导出与质量标记
This commit is contained in:
@@ -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: 单日用氢量,kg;OK 和 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, ¶meterJSON, &evidenceJSON, &result.CalculatedAt,
|
&result.AlgorithmVersion, ¶meterJSON, &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>实际选中的 GB32960、MQTT 或 JT808;NO_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" ? "用氢量,单位 kg;SUSPECT 结果也会返回数值" : "当日行驶里程,单位 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>计算质量:OK、SUSPECT 或 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>实际选中的 GB32960、MQTT 或 JT808;NO_DATA 时为 null</td></tr></>}<tr><td><code>status</code></td><td>string</td><td>NORMAL、DATA_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;
|
||||||
Reference in New Issue
Block a user