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
@@ -17,21 +17,21 @@ func TestProductionStoreHydrogenDailyEvidenceReturnsParametersIntervalsAndRawEve
"vin", "plate", "date", "source_endpoint", "raw_consumption_kg", "battery_soc_delta_pct",
"battery_discharge_kwh", "battery_equivalent_kg", "soc_balanced_consumption_kg",
"mixed_mileage_km", "pure_electric_mileage_km", "consumption_kg_per_100km", "soc_balanced_kg_per_100km",
"sample_count", "refuel_count", "charge_count", "valid_segment_count", "invalid_segment_count",
"sample_count", "refuel_count", "refuel_amount_kg", "charge_count", "charge_energy_kwh", "valid_segment_count", "invalid_segment_count",
"quality_status", "quality_reason", "algorithm_version", "parameter_json", "evidence_json", "calculated_at",
}
parameterJSON := `{"batteryCapacityKWh":21.04,"hydrogenEnergyKWhPerKg":16,"algorithmVersion":"PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2"}`
evidenceJSON := `[{"index":1,"type":"MIXED","startEventId":"event-start","endEventId":"event-end","rawHydrogenConsumptionKg":3.1,"sampleCount":120,"qualityStatus":"OK","qualityReason":""}]`
mock.ExpectQuery("SELECT h.vin").WithArgs("LTEST000000000001", "2026-08-26").WillReturnRows(sqlmock.NewRows(columns).AddRow(
"LTEST000000000001", "粤A12345", "2026-08-26", "factory-a", 3.1, -2.0, 0.421, 0.026, 3.126,
56.2, 32.5, 5.516, 5.562, 120, 1, 1, 1, 0, "OK", "", "PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2",
56.2, 32.5, 5.516, 5.562, 120, 1, 8.976, 1, 12.5, 1, 0, "OK", "", "PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2",
[]byte(parameterJSON), []byte(evidenceJSON), "2026-08-27 00:05:00.000",
))
result, found, err := (&ProductionStore{db: db}).HydrogenDailyEvidence(context.Background(), "LTEST000000000001", "2026-08-26")
if err != nil || !found {
t.Fatalf("found=%v err=%v", found, err)
}
if result.Parameters.BatteryCapacityKWh != 21.04 || len(result.Intervals) != 1 || result.Intervals[0].StartEventID != "event-start" || result.Intervals[0].EndEventID != "event-end" {
if result.Parameters.BatteryCapacityKWh != 21.04 || result.RefuelAmountKg == nil || *result.RefuelAmountKg != 8.976 || result.ChargeEnergyKWh == nil || *result.ChargeEnergyKWh != 12.5 || len(result.Intervals) != 1 || result.Intervals[0].StartEventID != "event-start" || result.Intervals[0].EndEventID != "event-end" {
t.Fatalf("result=%#v", result)
}
if err := mock.ExpectationsWereMet(); err != nil {
@@ -1885,8 +1885,16 @@ type DailyMileageRow struct {
HydrogenConsumptionKgPer100Km *float64 `json:"hydrogenConsumptionKgPer100Km,omitempty"`
HydrogenSOCBalancedKg *float64 `json:"hydrogenSocBalancedKg,omitempty"`
HydrogenSOCBalancedKgPer100Km *float64 `json:"hydrogenSocBalancedKgPer100Km,omitempty"`
PureElectricMileageKm *float64 `json:"pureElectricMileageKm,omitempty"`
MixedMileageKm *float64 `json:"mixedMileageKm,omitempty"`
BatterySOCDeltaPct *float64 `json:"batterySocDeltaPct,omitempty"`
ChargeCount *int `json:"chargeCount,omitempty"`
ChargeEnergyKWh *float64 `json:"chargeEnergyKWh,omitempty"`
RefuelCount *int `json:"refuelCount,omitempty"`
RefuelAmountKg *float64 `json:"refuelAmountKg,omitempty"`
HydrogenEvidenceAvailable bool `json:"hydrogenEvidenceAvailable,omitempty"`
HydrogenQualityStatus string `json:"hydrogenQualityStatus,omitempty"`
HydrogenQualityReason string `json:"hydrogenQualityReason,omitempty"`
HydrogenAlgorithmVersion string `json:"hydrogenAlgorithmVersion,omitempty"`
Source string `json:"source"`
AnomalySeverity string `json:"anomalySeverity,omitempty"`
@@ -1949,7 +1957,9 @@ type HydrogenDailyEvidence struct {
SOCBalancedKgPer100Km *float64 `json:"socBalancedKgPer100Km,omitempty"`
SampleCount int `json:"sampleCount"`
RefuelCount int `json:"refuelCount"`
RefuelAmountKg *float64 `json:"refuelAmountKg,omitempty"`
ChargeCount int `json:"chargeCount"`
ChargeEnergyKWh *float64 `json:"chargeEnergyKWh,omitempty"`
ValidSegmentCount int `json:"validSegmentCount"`
InvalidSegmentCount int `json:"invalidSegmentCount"`
QualityStatus string `json:"qualityStatus"`
@@ -559,7 +559,7 @@ LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin
LEFT JOIN vehicle_open_daily_energy h
ON h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci
AND h.stat_date = m.stat_date
AND h.energy_type = 'HYDROGEN' AND h.quality_status = 'OK'
AND h.energy_type = 'HYDROGEN' AND h.quality_status IN ('OK','SUSPECT')
WHERE ` + strings.Join(where, " AND ")
if query.Get("deduplicate") == "1" || strings.EqualFold(query.Get("deduplicate"), "true") {
selectionOrder := `m.daily_mileage_km DESC, m.protocol ASC`
@@ -581,8 +581,16 @@ WHERE ` + strings.Join(where, " AND ")
`MAX(h.consumption_kg_per_100km) AS hydrogen_consumption_kg_per_100km, ` +
`MAX(h.soc_balanced_consumption_kg) AS hydrogen_soc_balanced_kg, ` +
`MAX(h.soc_balanced_kg_per_100km) AS hydrogen_soc_balanced_kg_per_100km, ` +
`MAX(h.pure_electric_mileage_km) AS pure_electric_mileage_km, ` +
`MAX(h.mixed_mileage_km) AS mixed_mileage_km, ` +
`MAX(h.battery_soc_delta_pct) AS battery_soc_delta_pct, ` +
`MAX(h.charge_count) AS charge_count, ` +
`MAX(h.charge_energy_kwh) AS charge_energy_kwh, ` +
`MAX(h.refuel_count) AS refuel_count, ` +
`MAX(h.refuel_amount_kg) AS refuel_amount_kg, ` +
`MAX(CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END) AS hydrogen_evidence_available, ` +
`COALESCE(MAX(h.quality_status), '') AS hydrogen_quality_status, ` +
`COALESCE(MAX(h.quality_reason), '') AS hydrogen_quality_reason, ` +
`COALESCE(MAX(h.algorithm_version), '') AS hydrogen_algorithm_version, ` +
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(m.protocol ORDER BY ` + selectionOrder + `), ',', 1), '') AS protocol ` +
groupSQL + ` ORDER BY m.stat_date DESC, m.vin ASC LIMIT ? OFFSET ?`,
@@ -601,8 +609,9 @@ WHERE ` + strings.Join(where, " AND ")
`COALESCE(m.latest_total_mileage_km - m.daily_mileage_km, 0) AS start_mileage_km, ` +
`COALESCE(m.latest_total_mileage_km, 0) AS end_mileage_km, m.daily_mileage_km, ` +
`COALESCE(m.pure_hydrogen_mileage_km, 0), h.consumption_kg, h.consumption_kg_per_100km, h.soc_balanced_consumption_kg, ` +
`h.soc_balanced_kg_per_100km, CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END, ` +
`COALESCE(h.quality_status, ''), COALESCE(h.algorithm_version, ''), m.protocol ` +
`h.soc_balanced_kg_per_100km, h.pure_electric_mileage_km, h.mixed_mileage_km, h.battery_soc_delta_pct, ` +
`h.charge_count, h.charge_energy_kwh, h.refuel_count, h.refuel_amount_kg, CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END, ` +
`COALESCE(h.quality_status, ''), COALESCE(h.quality_reason, ''), COALESCE(h.algorithm_version, ''), m.protocol ` +
fromSQL + ` ORDER BY m.stat_date DESC, m.vin ASC, m.protocol ASC LIMIT ? OFFSET ?`,
Args: args,
CountText: `SELECT COUNT(*) ` + fromSQL,
@@ -1153,10 +1153,12 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P
items := make([]DailyMileageRow, 0)
for rows.Next() {
var row DailyMileageRow
var hydrogen, physicalRate, balanced, balancedRate sql.NullFloat64
var hydrogen, physicalRate, balanced, balancedRate, pureElectric, mixed, socDelta, chargeEnergy, refuelAmount sql.NullFloat64
var chargeCount, refuelCount sql.NullInt64
var evidenceAvailable int
if err := rows.Scan(&row.VIN, &row.Plate, &row.Date, &row.StartMileageKm, &row.EndMileageKm, &row.DailyMileageKm, &row.PureHydrogenMileageKm,
&hydrogen, &physicalRate, &balanced, &balancedRate, &evidenceAvailable, &row.HydrogenQualityStatus, &row.HydrogenAlgorithmVersion, &row.Source); err != nil {
&hydrogen, &physicalRate, &balanced, &balancedRate, &pureElectric, &mixed, &socDelta, &chargeCount, &chargeEnergy, &refuelCount, &refuelAmount,
&evidenceAvailable, &row.HydrogenQualityStatus, &row.HydrogenQualityReason, &row.HydrogenAlgorithmVersion, &row.Source); err != nil {
return Page[DailyMileageRow]{}, err
}
if hydrogen.Valid {
@@ -1177,6 +1179,19 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P
value := balancedRate.Float64
row.HydrogenSOCBalancedKgPer100Km = &value
}
row.PureElectricMileageKm = nullableFloatPointer(pureElectric)
row.MixedMileageKm = nullableFloatPointer(mixed)
row.BatterySOCDeltaPct = nullableFloatPointer(socDelta)
if chargeCount.Valid {
value := int(chargeCount.Int64)
row.ChargeCount = &value
}
row.ChargeEnergyKWh = nullableFloatPointer(chargeEnergy)
if refuelCount.Valid {
value := int(refuelCount.Int64)
row.RefuelCount = &value
}
row.RefuelAmountKg = nullableFloatPointer(refuelAmount)
row.HydrogenEvidenceAvailable = evidenceAvailable == 1
items = append(items, row)
}
@@ -1195,20 +1210,21 @@ func (s *ProductionStore) HydrogenDailyEvidence(ctx context.Context, vin, date s
h.source_endpoint,COALESCE(h.raw_consumption_kg,h.consumption_kg),h.battery_soc_delta_pct,
h.battery_discharge_kwh,h.battery_equivalent_kg,h.soc_balanced_consumption_kg,
h.mixed_mileage_km,h.pure_electric_mileage_km,h.consumption_kg_per_100km,h.soc_balanced_kg_per_100km,
h.sample_count,h.refuel_count,h.charge_count,h.valid_segment_count,h.invalid_segment_count,
h.sample_count,h.refuel_count,h.refuel_amount_kg,h.charge_count,h.charge_energy_kwh,h.valid_segment_count,h.invalid_segment_count,
h.quality_status,h.quality_reason,h.algorithm_version,h.parameter_json,h.evidence_json,
DATE_FORMAT(h.calculated_at,'%Y-%m-%d %H:%i:%s.%f')
FROM vehicle_open_daily_energy h
LEFT JOIN vehicle_identity_binding b ON b.vin=h.vin
LEFT JOIN vehicle_identity_binding b
ON b.vin COLLATE utf8mb4_unicode_ci = h.vin COLLATE utf8mb4_unicode_ci
WHERE h.vin=? AND h.stat_date=? AND h.energy_type='HYDROGEN'
LIMIT 1`, vin, date)
var result HydrogenDailyEvidence
var socDelta, discharge, equivalent, balanced, physicalRate, balancedRate sql.NullFloat64
var socDelta, discharge, equivalent, balanced, physicalRate, balancedRate, refuelAmount, chargeEnergy sql.NullFloat64
var parameterJSON, evidenceJSON []byte
if err := row.Scan(
&result.VIN, &result.Plate, &result.Date, &result.Source, &result.RawConsumptionKg,
&socDelta, &discharge, &equivalent, &balanced, &result.MixedMileageKm, &result.PureElectricMileageKm,
&physicalRate, &balancedRate, &result.SampleCount, &result.RefuelCount, &result.ChargeCount,
&physicalRate, &balancedRate, &result.SampleCount, &result.RefuelCount, &refuelAmount, &result.ChargeCount, &chargeEnergy,
&result.ValidSegmentCount, &result.InvalidSegmentCount, &result.QualityStatus, &result.QualityReason,
&result.AlgorithmVersion, &parameterJSON, &evidenceJSON, &result.CalculatedAt,
); err != nil {
@@ -1223,6 +1239,8 @@ LIMIT 1`, vin, date)
result.SOCBalancedConsumptionKg = nullableFloatPointer(balanced)
result.ConsumptionKgPer100Km = nullableFloatPointer(physicalRate)
result.SOCBalancedKgPer100Km = nullableFloatPointer(balancedRate)
result.RefuelAmountKg = nullableFloatPointer(refuelAmount)
result.ChargeEnergyKWh = nullableFloatPointer(chargeEnergy)
result.Intervals = []HydrogenIntervalEvidenceRow{}
if len(parameterJSON) > 0 {
if err := json.Unmarshal(parameterJSON, &result.Parameters); err != nil {
@@ -6,12 +6,52 @@ import (
"database/sql/driver"
"errors"
"io"
"net/url"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
)
func TestProductionStoreDailyMileageReturnsSuspectHydrogenExportDetails(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
columns := []string{
"vin", "plate", "date", "start_mileage_km", "end_mileage_km", "daily_mileage_km", "pure_hydrogen_mileage_km",
"hydrogen_consumption_kg", "hydrogen_consumption_kg_per_100km", "hydrogen_soc_balanced_kg", "hydrogen_soc_balanced_kg_per_100km",
"pure_electric_mileage_km", "mixed_mileage_km", "battery_soc_delta_pct", "charge_count", "charge_energy_kwh",
"refuel_count", "refuel_amount_kg", "hydrogen_evidence_available", "hydrogen_quality_status", "hydrogen_quality_reason",
"hydrogen_algorithm_version", "protocol",
}
mock.ExpectQuery("SELECT m.vin").
WithArgs(20, 0).
WillReturnRows(sqlmock.NewRows(columns).AddRow(
"LB9A32A29R0LS1423", "粤AGR9816", "2026-08-26", 23119.8, 23333.1, 213.3, 213.3,
4.491, 2.106, 4.544, 2.130, 0.0, 213.3, -4.0, 1, 12.5,
1, 8.976, 1, "SUSPECT", "疑似管路泄压", "PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5", "GB32960",
))
page, err := (&ProductionStore{db: db}).DailyMileage(context.Background(), url.Values{"skipCount": {"1"}})
if err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 {
t.Fatalf("items=%#v", page.Items)
}
row := page.Items[0]
if row.HydrogenConsumptionKg == nil || *row.HydrogenConsumptionKg != 4.491 || row.HydrogenQualityStatus != "SUSPECT" || row.HydrogenQualityReason == "" ||
row.ChargeCount == nil || *row.ChargeCount != 1 || row.ChargeEnergyKWh == nil || *row.ChargeEnergyKWh != 12.5 ||
row.RefuelCount == nil || *row.RefuelCount != 1 || row.RefuelAmountKg == nil || *row.RefuelAmountKg != 8.976 {
t.Fatalf("row=%#v", row)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func init() {
sql.Register("tdengine_missing_table_test", missingTableDriver{})
sql.Register("ops_health_test", opsHealthDriver{})
@@ -485,9 +485,9 @@ func TestBuildDailyMileageSQL(t *testing.T) {
if !strings.Contains(built.Text, "m.pure_hydrogen_mileage_km") {
t.Fatalf("daily mileage must return pure hydrogen mileage: %s", built.Text)
}
for _, want := range []string{"vehicle_open_daily_energy h", "h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci", "h.energy_type = 'HYDROGEN'", "h.quality_status = 'OK'", "h.consumption_kg"} {
for _, want := range []string{"vehicle_open_daily_energy h", "h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci", "h.energy_type = 'HYDROGEN'", "h.quality_status IN ('OK','SUSPECT')", "h.consumption_kg", "h.charge_energy_kwh", "h.refuel_amount_kg", "h.quality_reason"} {
if !strings.Contains(built.Text, want) {
t.Fatalf("daily mileage must return quality-approved hydrogen consumption, missing %q: %s", want, built.Text)
t.Fatalf("daily mileage must return calculated hydrogen details, missing %q: %s", want, built.Text)
}
}
if !strings.Contains(built.CountText, "COUNT(*)") || strings.Contains(built.CountText, "LIMIT") {
@@ -5586,6 +5586,19 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail
result.Items[index].PureHydrogenMileageKm = 0
result.Items[index].HydrogenConsumptionKg = nil
result.Items[index].HydrogenConsumptionKgPer100Km = nil
result.Items[index].HydrogenSOCBalancedKg = nil
result.Items[index].HydrogenSOCBalancedKgPer100Km = nil
result.Items[index].PureElectricMileageKm = nil
result.Items[index].MixedMileageKm = nil
result.Items[index].BatterySOCDeltaPct = nil
result.Items[index].ChargeCount = nil
result.Items[index].ChargeEnergyKWh = nil
result.Items[index].RefuelCount = nil
result.Items[index].RefuelAmountKg = nil
result.Items[index].HydrogenEvidenceAvailable = false
result.Items[index].HydrogenQualityStatus = ""
result.Items[index].HydrogenQualityReason = ""
result.Items[index].HydrogenAlgorithmVersion = ""
}
}
return result, nil
@@ -571,10 +571,15 @@ func (s *projectionCountingStore) LatestRealtimeFrames(_ context.Context, query
type hydrogenMetricsStore struct{ *MockStore }
func (s *hydrogenMetricsStore) DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error) {
consumption, rate := 3.1, 5.5
consumption, rate, pureElectric, mixed, socDelta, chargeEnergy, refuelAmount := 3.1, 5.5, 32.5, 56.2, -2.0, 12.5, 8.976
chargeCount, refuelCount := 1, 1
return Page[DailyMileageRow]{Items: []DailyMileageRow{{
VIN: "LB9A32A24R0LS1426", Date: "2026-07-13", DailyMileageKm: 88.7,
PureHydrogenMileageKm: 56.2, HydrogenConsumptionKg: &consumption, HydrogenConsumptionKgPer100Km: &rate,
HydrogenSOCBalancedKg: &consumption, PureElectricMileageKm: &pureElectric, MixedMileageKm: &mixed,
BatterySOCDeltaPct: &socDelta, ChargeCount: &chargeCount, ChargeEnergyKWh: &chargeEnergy,
RefuelCount: &refuelCount, RefuelAmountKg: &refuelAmount, HydrogenEvidenceAvailable: true,
HydrogenQualityStatus: "SUSPECT", HydrogenQualityReason: "疑似管路泄压", HydrogenAlgorithmVersion: "V3_5",
}}}, nil
}
@@ -623,7 +628,8 @@ func TestHydrogenConsumptionMetricsAreNotExposedToCustomerAccounts(t *testing.T)
if err != nil {
t.Fatal(err)
}
if len(daily.Items) != 1 || daily.Items[0].PureHydrogenMileageKm != 0 || daily.Items[0].HydrogenConsumptionKg != nil || daily.Items[0].HydrogenConsumptionKgPer100Km != nil {
if len(daily.Items) != 1 || daily.Items[0].PureHydrogenMileageKm != 0 || daily.Items[0].HydrogenConsumptionKg != nil || daily.Items[0].HydrogenConsumptionKgPer100Km != nil ||
daily.Items[0].ChargeEnergyKWh != nil || daily.Items[0].RefuelAmountKg != nil || daily.Items[0].HydrogenQualityStatus != "" || daily.Items[0].HydrogenEvidenceAvailable {
t.Fatalf("customer daily mileage exposed hydrogen metrics: %+v", daily.Items)
}
summary, err := service.MileageStatistics(customer, query)