diff --git a/go/vehicle-gateway/cmd/mileage-correction/main.go b/go/vehicle-gateway/cmd/mileage-correction/main.go new file mode 100644 index 00000000..5d1b76f8 --- /dev/null +++ b/go/vehicle-gateway/cmd/mileage-correction/main.go @@ -0,0 +1,88 @@ +// mileage-correction writes a confirmed daily terminal odometer correction. +// It is intentionally explicit: callers must provide the VIN, natural day and +// confirmed cumulative mileage; it never derives values from a guess. +package main + +import ( + "context" + "database/sql" + "flag" + "fmt" + "log" + "os" + "strings" + "time" + + _ "github.com/go-sql-driver/mysql" + + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats" +) + +func main() { + vin := flag.String("vin", "", "VIN") + date := flag.String("date", "", "natural day, yyyy-MM-dd") + total := flag.Float64("total-km", 0, "confirmed cumulative mileage, km") + flag.Parse() + if strings.TrimSpace(*vin) == "" || strings.TrimSpace(*date) == "" || *total <= 0 { + log.Fatal("vin, date and positive total-km are required") + } + dsn := strings.TrimSpace(os.Getenv("MYSQL_DSN")) + if dsn == "" { + log.Fatal("MYSQL_DSN is required") + } + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + log.Fatal(err) + } + eventTime, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(*date)+" 23:59:59", loc) + if err != nil { + log.Fatal(err) + } + db, err := sql.Open("mysql", dsn) + if err != nil { + log.Fatal(err) + } + defer db.Close() + ctx := context.Background() + if err := db.PingContext(ctx); err != nil { + log.Fatal(err) + } + candidate := stats.SourceMileageSample{ + VIN: strings.TrimSpace(*vin), + StatDate: strings.TrimSpace(*date), + Protocol: envelope.ProtocolGB32960, + SourceKey: "GB32960:manual-correction@manual", + SourceIP: "manual", + SourceEndpoint: "manual-correction", + FirstTotalKM: *total, + LatestTotalKM: *total, + DailyKM: 0, + SampleCount: 1, + FirstEventTime: eventTime, + LatestEventTime: eventTime, + QualityStatus: stats.QualityOK, + QualityReason: "manual_correction_confirmed_total", + } + if err := stats.UpsertSourceMileage(ctx, db, candidate); err != nil { + log.Fatal(err) + } + if err := stats.ProjectDailyMileage(ctx, db, candidate.VIN, candidate.StatDate, candidate.Protocol); err != nil { + log.Fatal(err) + } + // Keep both cumulative fields aligned. day_end_total_mileage_km is the API + // field of record, while latest_total_mileage_km remains useful to internal + // readers and must not retain the rejected reset value. + if _, err := db.ExecContext(ctx, ` +UPDATE vehicle_daily_mileage +SET latest_total_mileage_km=?, day_end_total_mileage_km=? +WHERE vin=? AND stat_date=? AND protocol=?`, + *total, *total, candidate.VIN, candidate.StatDate, string(candidate.Protocol)); err != nil { + log.Fatal(err) + } + var daily, latest, dayEnd float64 + if err := db.QueryRowContext(ctx, `SELECT daily_mileage_km,latest_total_mileage_km,day_end_total_mileage_km FROM vehicle_daily_mileage WHERE vin=? AND stat_date=? AND protocol=?`, candidate.VIN, candidate.StatDate, string(candidate.Protocol)).Scan(&daily, &latest, &dayEnd); err != nil { + log.Fatal(err) + } + fmt.Printf("corrected vin=%s date=%s protocol=%s daily_km=%.3f latest_total_km=%.3f day_end_total_km=%.3f\n", candidate.VIN, candidate.StatDate, candidate.Protocol, daily, latest, dayEnd) +} diff --git a/go/vehicle-gateway/cmd/stat-writer/main.go b/go/vehicle-gateway/cmd/stat-writer/main.go index ff0a5a6b..73f51e88 100644 --- a/go/vehicle-gateway/cmd/stat-writer/main.go +++ b/go/vehicle-gateway/cmd/stat-writer/main.go @@ -82,6 +82,11 @@ func main() { } else { registry.SetGauge("vehicle_stat_hydrogen_capacity_cache_entries", nil, float64(loaded)) } + if loaded, err := writer.ReloadHydrogenRealtimeParameters(ctx); err != nil { + logger.Warn("hydrogen V3.5 parameter cache load failed", "error", err) + } else { + registry.SetGauge("vehicle_stat_hydrogen_v35_parameter_cache_entries", nil, float64(loaded)) + } if cfg.NormalizePlatformSourcesOnStart { statDate := time.Now().In(cfg.Location).Format("2006-01-02") normalizeCtx, cancel := context.WithTimeout(ctx, cfg.NormalizePlatformSourcesTimeout) @@ -191,6 +196,13 @@ func syncHydrogenCapacities(ctx context.Context, logger interface { } registry.IncCounter("vehicle_stat_hydrogen_capacity_sync_total", metrics.Labels{"status": "ok"}) registry.SetGauge("vehicle_stat_hydrogen_capacity_cache_entries", nil, float64(loaded)) + parameterCount, parameterErr := writer.ReloadHydrogenRealtimeParameters(syncCtx) + if parameterErr != nil { + registry.IncCounter("vehicle_stat_hydrogen_energy_parameter_sync_total", metrics.Labels{"status": "cache_error"}) + logger.Warn("hydrogen V3.5 parameter cache reload failed", "error", parameterErr) + } else { + registry.SetGauge("vehicle_stat_hydrogen_v35_parameter_cache_entries", nil, float64(parameterCount)) + } metrics.RecordLastActivity(registry, "vehicle_stat_hydrogen_capacity_last_sync_unix_seconds", nil) logger.Info("hydrogen tank capacities synchronized", "source_schema", cfg.HydrogenCapacitySourceSchema, "read", result.Read, "written", result.Written, "deactivated", result.Deactivated, "cache_entries", loaded) } diff --git a/go/vehicle-gateway/internal/stats/daily_metric.go b/go/vehicle-gateway/internal/stats/daily_metric.go index bed27055..3202ee59 100644 --- a/go/vehicle-gateway/internal/stats/daily_metric.go +++ b/go/vehicle-gateway/internal/stats/daily_metric.go @@ -30,31 +30,32 @@ type Execer interface { } type Writer struct { - exec Execer - query Queryer - loc *time.Location - sourceTouchInterval time.Duration - projectionInterval time.Duration - cacheRetention time.Duration - cacheCleanupInterval time.Duration - baselineMissTTL time.Duration - baselineHitTTL time.Duration - maxCacheEntries int - lastCacheCleanup time.Time - lastCacheCleanupStats cacheCleanupStats - cacheEvictions cacheCleanupStats - mu sync.Mutex - lastTotalMileage map[string]float64 - lastSourceSeen map[string]time.Time - lastProjection map[string]projectionCacheEntry - pendingProjection map[string]pendingProjectionEntry - baselineCache map[string]sourceBaselineCacheEntry - mileageKeysByPrefix map[string]map[string]struct{} - projectionKeysByPrefix map[string]map[string]struct{} - baselineKeysByPrefix map[string]map[string]struct{} - lastGPSAccumulation map[string]time.Time - hydrogenTankCapacities map[string]float64 - gpsAccumulationInterval time.Duration + exec Execer + query Queryer + loc *time.Location + sourceTouchInterval time.Duration + projectionInterval time.Duration + cacheRetention time.Duration + cacheCleanupInterval time.Duration + baselineMissTTL time.Duration + baselineHitTTL time.Duration + maxCacheEntries int + lastCacheCleanup time.Time + lastCacheCleanupStats cacheCleanupStats + cacheEvictions cacheCleanupStats + mu sync.Mutex + lastTotalMileage map[string]float64 + lastSourceSeen map[string]time.Time + lastProjection map[string]projectionCacheEntry + pendingProjection map[string]pendingProjectionEntry + baselineCache map[string]sourceBaselineCacheEntry + mileageKeysByPrefix map[string]map[string]struct{} + projectionKeysByPrefix map[string]map[string]struct{} + baselineKeysByPrefix map[string]map[string]struct{} + lastGPSAccumulation map[string]time.Time + hydrogenTankCapacities map[string]float64 + hydrogenEnergyParameters map[string]HydrogenRealtimeParameters + gpsAccumulationInterval time.Duration } type MetricSample struct { @@ -151,26 +152,27 @@ func NewWriter(exec Execer, loc *time.Location) *Writer { loc = time.FixedZone("Asia/Shanghai", 8*3600) } writer := &Writer{ - exec: exec, - loc: loc, - sourceTouchInterval: time.Minute, - projectionInterval: 15 * time.Second, - cacheRetention: defaultCacheRetention, - cacheCleanupInterval: defaultCacheCleanupInterval, - baselineMissTTL: defaultBaselineMissTTL, - baselineHitTTL: defaultBaselineHitTTL, - maxCacheEntries: defaultMaxCacheEntries, - lastTotalMileage: map[string]float64{}, - lastSourceSeen: map[string]time.Time{}, - lastProjection: map[string]projectionCacheEntry{}, - pendingProjection: map[string]pendingProjectionEntry{}, - baselineCache: map[string]sourceBaselineCacheEntry{}, - mileageKeysByPrefix: map[string]map[string]struct{}{}, - projectionKeysByPrefix: map[string]map[string]struct{}{}, - baselineKeysByPrefix: map[string]map[string]struct{}{}, - lastGPSAccumulation: map[string]time.Time{}, - hydrogenTankCapacities: map[string]float64{}, - gpsAccumulationInterval: defaultGPSAccumulationInterval, + exec: exec, + loc: loc, + sourceTouchInterval: time.Minute, + projectionInterval: 15 * time.Second, + cacheRetention: defaultCacheRetention, + cacheCleanupInterval: defaultCacheCleanupInterval, + baselineMissTTL: defaultBaselineMissTTL, + baselineHitTTL: defaultBaselineHitTTL, + maxCacheEntries: defaultMaxCacheEntries, + lastTotalMileage: map[string]float64{}, + lastSourceSeen: map[string]time.Time{}, + lastProjection: map[string]projectionCacheEntry{}, + pendingProjection: map[string]pendingProjectionEntry{}, + baselineCache: map[string]sourceBaselineCacheEntry{}, + mileageKeysByPrefix: map[string]map[string]struct{}{}, + projectionKeysByPrefix: map[string]map[string]struct{}{}, + baselineKeysByPrefix: map[string]map[string]struct{}{}, + lastGPSAccumulation: map[string]time.Time{}, + hydrogenTankCapacities: map[string]float64{}, + hydrogenEnergyParameters: map[string]HydrogenRealtimeParameters{}, + gpsAccumulationInterval: defaultGPSAccumulationInterval, } if query, ok := exec.(Queryer); ok { writer.query = query @@ -197,6 +199,25 @@ func (w *Writer) HydrogenTankCapacityLiters(vin string) (float64, bool) { return capacity, ok } +func (w *Writer) ReloadHydrogenRealtimeParameters(ctx context.Context) (int, error) { + date := time.Now().In(w.loc).Format("2006-01-02") + parameters, err := LoadHydrogenRealtimeParameters(ctx, w.query, date) + if err != nil { + return 0, err + } + w.mu.Lock() + w.hydrogenEnergyParameters = parameters + w.mu.Unlock() + return len(parameters), nil +} + +func (w *Writer) HydrogenRealtimeParameters(vin string) HydrogenRealtimeParameters { + vin = strings.ToUpper(strings.TrimSpace(vin)) + w.mu.Lock() + defer w.mu.Unlock() + return normalizeHydrogenRealtimeParameters(w.hydrogenEnergyParameters[vin]) +} + func (w *Writer) SetSourceTouchInterval(interval time.Duration) { w.mu.Lock() defer w.mu.Unlock() @@ -315,6 +336,11 @@ func (w *Writer) EnsureSchema(ctx context.Context) error { return err } } + for _, statement := range HydrogenRealtimeAlterSQL { + if _, err := w.exec.ExecContext(ctx, statement); err != nil && !isIgnorableSchemaChangeError(err) { + return err + } + } return nil } @@ -333,7 +359,8 @@ func (w *Writer) AppendWithResult(ctx context.Context, env envelope.FrameEnvelop w.maybeCleanupCaches(seenAt) identity, hasSource := NewSourceIdentityFromEnvelope(env) capacityLiters, _ := w.HydrogenTankCapacityLiters(env.VIN) - hydrogen, err := AppendHydrogenStream(ctx, w.exec, env, w.loc, time.Now(), capacityLiters) + hydrogenParams := w.HydrogenRealtimeParameters(env.VIN) + hydrogen, err := AppendHydrogenV35Realtime(ctx, w.exec, env, w.loc, time.Now(), capacityLiters, hydrogenParams) if err != nil { return result, err } diff --git a/go/vehicle-gateway/internal/stats/daily_metric_test.go b/go/vehicle-gateway/internal/stats/daily_metric_test.go index b4820627..9aa4557e 100644 --- a/go/vehicle-gateway/internal/stats/daily_metric_test.go +++ b/go/vehicle-gateway/internal/stats/daily_metric_test.go @@ -1950,7 +1950,7 @@ func TestWriterEnsuresSchemaAndUpsertsDailyMileage(t *testing.T) { t.Fatalf("Append() error = %v", err) } - schemaCalls := 8 + len(DailyMileageAlterSQL) + schemaCalls := 8 + len(DailyMileageAlterSQL) + len(HydrogenRealtimeAlterSQL) if len(exec.calls) != schemaCalls+5 { t.Fatalf("exec calls = %d", len(exec.calls)) } diff --git a/go/vehicle-gateway/internal/stats/hydrogen_stream.go b/go/vehicle-gateway/internal/stats/hydrogen_stream.go index aa8e2a15..275ced3c 100644 --- a/go/vehicle-gateway/internal/stats/hydrogen_stream.go +++ b/go/vehicle-gateway/internal/stats/hydrogen_stream.go @@ -91,7 +91,7 @@ const HydrogenSegmentStreamStateTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_o last_event_time DATETIME(3) NOT NULL, last_event_id VARCHAR(128) NOT NULL DEFAULT '', state_json JSON NOT NULL, - calculation_method VARCHAR(48) NOT NULL DEFAULT 'PRESSURE_NIST_SEGMENT_MEDIAN_5', + calculation_method VARCHAR(80) NOT NULL DEFAULT 'PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5', quality_status VARCHAR(24) NOT NULL DEFAULT 'NO_DATA', quality_reason VARCHAR(255) NOT NULL DEFAULT '有效工作段关键帧不足10条', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), @@ -116,6 +116,18 @@ type HydrogenStreamSample struct { FuelCellActive bool FuelCellStateKnown bool ConsumptionEligible bool + MileageKM float64 + MileageKnown bool + SOCPercent float64 + SOCKnown bool + VehicleState int + VehicleStateKnown bool + ChargeState int + ChargeStateKnown bool + RunningMode int + RunningModeKnown bool + FuelCellPowerKW float64 + FuelCellPowerKnown bool } type HydrogenStreamResult struct { @@ -453,6 +465,26 @@ func HydrogenStreamSampleFromEnvelope(env envelope.FrameEnvelope, loc *time.Loca fuelCellStateKnown = true } } + mileageKM, mileageKnown := firstHydrogenNumber(env.Fields, []string{ + envelope.FieldTotalMileageKM, "gb32960.vehicle.total_mileage_km", + }) + socPercent, socKnown := firstHydrogenNumber(env.Fields, []string{ + envelope.FieldSOCPercent, "gb32960.vehicle.soc_percent", + }) + vehicleStateValue, vehicleStateKnown := firstHydrogenNumber(env.Fields, []string{ + "vehicle_status", "gb32960.vehicle.vehicle_status", + }) + chargeStateValue, chargeStateKnown := firstHydrogenNumber(env.Fields, []string{ + "charge_status", "gb32960.vehicle.charge_status", + }) + runningModeValue, runningModeKnown := firstHydrogenNumber(env.Fields, []string{ + envelope.FieldVehicleRunningMode, "gb32960.vehicle.running_mode", + }) + fuelCellVoltage, voltageKnown := firstHydrogenNumber(env.Fields, []string{ + "fuel_cell_voltage_v", "gb32960.fuel_cell.fuel_cell_voltage_v", + }) + fuelCellCurrent, currentKnown := firstHydrogenNumber(env.Fields, hydrogenCurrentFieldKeys) + fuelCellPowerKnown := voltageKnown && currentKnown && fuelCellVoltage >= 0 && fuelCellCurrent >= 0 if loc == nil { loc = time.FixedZone("Asia/Shanghai", 8*3600) } @@ -475,6 +507,12 @@ func HydrogenStreamSampleFromEnvelope(env envelope.FrameEnvelope, loc *time.Loca NoiseKG: noiseKG, RefuelThresholdKG: refuelThresholdKG, FuelCellActive: fuelCellActive, FuelCellStateKnown: fuelCellStateKnown, ConsumptionEligible: !fuelCellStateKnown || fuelCellActive, + MileageKM: mileageKM, MileageKnown: mileageKnown, + SOCPercent: socPercent, SOCKnown: socKnown && socPercent >= 0 && socPercent <= 100, + VehicleState: int(vehicleStateValue), VehicleStateKnown: vehicleStateKnown, + ChargeState: int(chargeStateValue), ChargeStateKnown: chargeStateKnown, + RunningMode: int(runningModeValue), RunningModeKnown: runningModeKnown, + FuelCellPowerKW: fuelCellVoltage * fuelCellCurrent / 1000, FuelCellPowerKnown: fuelCellPowerKnown, }, "", true } @@ -647,13 +685,13 @@ const insertHydrogenSegmentStreamStateSQL = `INSERT INTO vehicle_open_hydrogen_s vin,stat_date,source_endpoint,finalized_consumption_kg,projected_consumption_kg, sample_count,refuel_count,abnormal_drop_count,eligible_interval_count,qualified_segment_count, last_mass_kg,last_event_time,last_event_id,state_json,calculation_method,quality_status,quality_reason -) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'PRESSURE_NIST_SEGMENT_MEDIAN_5',?,?)` +) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5',?,?)` const updateHydrogenSegmentStreamStateSQL = `UPDATE vehicle_open_hydrogen_segment_stream_state SET source_endpoint=?,finalized_consumption_kg=?,projected_consumption_kg=?,sample_count=?, refuel_count=?,abnormal_drop_count=?,eligible_interval_count=?,qualified_segment_count=?, last_mass_kg=?,last_event_time=?,last_event_id=?,state_json=?, - calculation_method='PRESSURE_NIST_SEGMENT_MEDIAN_5',quality_status=?,quality_reason=? + calculation_method='PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5',quality_status=?,quality_reason=? WHERE vin=? AND stat_date=?` const projectHydrogenSegmentStreamDailySQL = `INSERT INTO vehicle_open_daily_energy( diff --git a/go/vehicle-gateway/internal/stats/hydrogen_stream_test.go b/go/vehicle-gateway/internal/stats/hydrogen_stream_test.go index 13be4037..e1f9c60e 100644 --- a/go/vehicle-gateway/internal/stats/hydrogen_stream_test.go +++ b/go/vehicle-gateway/internal/stats/hydrogen_stream_test.go @@ -49,6 +49,29 @@ func TestHydrogenStreamSampleUsesPressureTemperatureAndCapacity(t *testing.T) { } } +func TestHydrogenStreamSampleExtractsV35Inputs(t *testing.T) { + loc := time.FixedZone("Asia/Shanghai", 8*3600) + eventTime := time.Date(2026, 9, 4, 10, 30, 0, 0, loc) + env := pressureEnvelope(eventTime) + env.Fields[envelope.FieldTotalMileageKM] = 12005.6 + env.Fields[envelope.FieldSOCPercent] = 72.5 + env.Fields[envelope.FieldVehicleRunningMode] = 2 + env.Fields[envelope.FieldFuelCellWorkMode] = 2 + env.Fields["vehicle_status"] = 1 + env.Fields["charge_status"] = 3 + env.Fields["fuel_cell_voltage_v"] = 480.0 + env.Fields["fuel_cell_current_a"] = 100.0 + sample, reason, ok := HydrogenStreamSampleFromEnvelope(env, loc, eventTime, 520) + if !ok || reason != "" { + t.Fatalf("sample rejected: reason=%q", reason) + } + if !sample.MileageKnown || sample.MileageKM != 12005.6 || !sample.SOCKnown || sample.SOCPercent != 72.5 || + !sample.VehicleStateKnown || sample.VehicleState != 1 || !sample.ChargeStateKnown || sample.ChargeState != 3 || + !sample.RunningModeKnown || sample.RunningMode != 2 || !sample.FuelCellPowerKnown || sample.FuelCellPowerKW != 48 { + t.Fatalf("V3.5 fields=%#v", sample) + } +} + func TestHydrogenStreamMarksInactiveFuelCellIneligible(t *testing.T) { loc := time.FixedZone("Asia/Shanghai", 8*3600) eventTime := time.Date(2026, 8, 15, 13, 31, 7, 0, loc) @@ -288,13 +311,13 @@ func TestWriterAppendWithResultUsesInMemoryCapacity(t *testing.T) { eventTime := time.Now().In(loc).Truncate(time.Millisecond) mock.ExpectBegin() mock.ExpectQuery(regexp.QuoteMeta("SELECT state_json")).WillReturnError(sql.ErrNoRows) - mock.ExpectQuery(regexp.QuoteMeta("SELECT source_endpoint,consumption_kg,first_mass_kg,last_mass_kg,")).WillReturnError(sql.ErrNoRows) mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_hydrogen_segment_stream_state")).WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy")).WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectCommit() writer := NewWriter(db, loc) writer.hydrogenTankCapacities["LA9GG64L0NBAF4175"] = 2050 + writer.hydrogenEnergyParameters["LA9GG64L0NBAF4175"] = HydrogenRealtimeParameters{BatteryCapacityKWh: 80, HydrogenEnergyKWhKG: 16} result, err := writer.AppendWithResult(context.Background(), pressureEnvelope(eventTime)) if err != nil { t.Fatalf("AppendWithResult() error = %v", err) @@ -311,7 +334,7 @@ func TestHydrogenStreamSQLUsesVINDateStateAndSegmentMethod(t *testing.T) { for _, want := range []string{ "PRIMARY KEY (vin, stat_date)", "state_json JSON NOT NULL", - "PRESSURE_NIST_SEGMENT_MEDIAN_5", + HydrogenV35RealtimeAlgorithmVersion, } { if !strings.Contains(HydrogenSegmentStreamStateTableSQL, want) { t.Fatalf("segment stream schema missing %q", want) diff --git a/go/vehicle-gateway/internal/stats/hydrogen_v35_realtime.go b/go/vehicle-gateway/internal/stats/hydrogen_v35_realtime.go new file mode 100644 index 00000000..d14ec1d5 --- /dev/null +++ b/go/vehicle-gateway/internal/stats/hydrogen_v35_realtime.go @@ -0,0 +1,780 @@ +package stats + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "math" + "strings" + "time" + + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" +) + +const ( + HydrogenV35RealtimeAlgorithmVersion = "PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5" + hydrogenV35EnergyKWhPerKG = 16.0 + hydrogenV35MixedMinimumKM = 10.0 + hydrogenV35MixedConfirmSamples = 3 + hydrogenV35MixedConfirmWindow = 30 * time.Second + hydrogenV35ContinuityGap = 5 * time.Minute + hydrogenV35PowerIntegrationGap = 2 * time.Minute + hydrogenV35RefuelObservationWindow = 10 * time.Minute + hydrogenV35RefuelThermalWindow = 30 * time.Minute + hydrogenV35RefuelSustain = 60 * time.Second + hydrogenV35RefuelRiseMPa = 3.0 + hydrogenV35RefuelTemperatureRiseC = 3.0 +) + +// HydrogenRealtimeParameters are the effective-dated vehicle parameters used by +// the V3.5 preview. The physical pressure/temperature result remains available +// without a battery capacity, but SOC-balanced results are then marked suspect. +type HydrogenRealtimeParameters struct { + BatteryCapacityKWh float64 `json:"batteryCapacityKWh"` + HydrogenEnergyKWhKG float64 `json:"hydrogenEnergyKWhPerKg"` +} + +func normalizeHydrogenRealtimeParameters(value HydrogenRealtimeParameters) HydrogenRealtimeParameters { + if value.HydrogenEnergyKWhKG <= 0 { + value.HydrogenEnergyKWhKG = hydrogenV35EnergyKWhPerKG + } + return value +} + +// LoadHydrogenRealtimeParameters loads the currently effective parameter row +// for every VIN. Rows are ordered newest-first so a business override wins over +// the oldest automatically synchronized model value. +func LoadHydrogenRealtimeParameters(ctx context.Context, query Queryer, date string) (map[string]HydrogenRealtimeParameters, error) { + result := map[string]HydrogenRealtimeParameters{} + if query == nil { + return result, errors.New("hydrogen parameter query is unavailable") + } + rows, err := query.QueryContext(ctx, `SELECT UPPER(TRIM(vin)),battery_capacity_kwh,hydrogen_energy_kwh_per_kg +FROM vehicle_hydrogen_energy_parameter +WHERE active=1 AND effective_from<=? AND (effective_to IS NULL OR effective_to>=?) +ORDER BY vin,effective_from DESC`, date, date) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var vin string + var value HydrogenRealtimeParameters + if err := rows.Scan(&vin, &value.BatteryCapacityKWh, &value.HydrogenEnergyKWhKG); err != nil { + return nil, err + } + vin = strings.ToUpper(strings.TrimSpace(vin)) + if len(vin) != 17 { + continue + } + if _, exists := result[vin]; !exists { + result[vin] = normalizeHydrogenRealtimeParameters(value) + } + } + return result, rows.Err() +} + +type hydrogenV35Point struct { + EventID string `json:"eventId"` + ObservedAt time.Time `json:"observedAt"` + MassKG float64 `json:"massKg"` + PressureMPa float64 `json:"pressureMpa"` + TemperatureC float64 `json:"temperatureC"` + NoiseKG float64 `json:"noiseKg"` + MileageKM float64 `json:"mileageKm"` + MileageKnown bool `json:"mileageKnown"` + SOCPercent float64 `json:"socPercent"` + SOCKnown bool `json:"socKnown"` + RunningMode int `json:"runningMode"` + FuelCellActive bool `json:"fuelCellActive"` + FuelCellStateKnown bool `json:"fuelCellStateKnown"` + FuelCellPowerKW float64 `json:"fuelCellPowerKw"` + FuelCellPowerKnown bool `json:"fuelCellPowerKnown"` +} + +func hydrogenV35PointFromSample(sample HydrogenStreamSample) hydrogenV35Point { + return hydrogenV35Point{ + EventID: sample.EventID, ObservedAt: sample.EventTime, MassKG: sample.MassKG, + PressureMPa: sample.PressureMPa, TemperatureC: sample.TemperatureC, NoiseKG: sample.NoiseKG, + MileageKM: sample.MileageKM, MileageKnown: sample.MileageKnown, + SOCPercent: sample.SOCPercent, SOCKnown: sample.SOCKnown, RunningMode: sample.RunningMode, + FuelCellActive: sample.FuelCellActive, FuelCellStateKnown: sample.FuelCellStateKnown, + FuelCellPowerKW: sample.FuelCellPowerKW, FuelCellPowerKnown: sample.FuelCellPowerKnown, + } +} + +type hydrogenV35CycleState struct { + First hydrogenV35Point `json:"first"` + Last hydrogenV35Point `json:"last"` + HasRun bool `json:"hasRun"` + StartsPure bool `json:"startsPure"` + MixedLocked bool `json:"mixedLocked"` + MixedConfirmed bool `json:"mixedConfirmed"` + MixedStart hydrogenV35Point `json:"mixedStart"` + MixedCandidate hydrogenV35Point `json:"mixedCandidate"` + MixedCandidateSet bool `json:"mixedCandidateSet"` + MixedCandidateN int `json:"mixedCandidateCount"` + MixedCandidateAt time.Time `json:"mixedCandidateLastAt"` + MixedEnergyKWh float64 `json:"mixedEnergyKWh"` + CandidateEnergyKWh float64 `json:"candidateEnergyKWh"` +} + +type hydrogenV35RefuelCandidate struct { + Set bool `json:"set"` + Boundary hydrogenV35Point `json:"boundary"` + BaselinePressureMPa float64 `json:"baselinePressureMpa"` + BaselineMassKG float64 `json:"baselineMassKg"` + HighSamples int `json:"highSamples"` + PreviousHydrogen hydrogenV35Point `json:"previousHydrogen"` + PreviousHydrogenExists bool `json:"previousHydrogenExists"` + FirstHydrogenAfter hydrogenV35Point `json:"firstHydrogenAfter"` + HydrogenRunsAfter int64 `json:"hydrogenRunsAfter"` +} + +type hydrogenV35RealtimeState struct { + AlgorithmVersion string `json:"algorithmVersion"` + SourceEndpoint string `json:"sourceEndpoint"` + SampleCount int64 `json:"sampleCount"` + LastEventID string `json:"lastEventId"` + LastEventTime time.Time `json:"lastEventTime"` + + Parameters HydrogenRealtimeParameters `json:"parameters"` + FirstMassKG float64 `json:"firstMassKg"` + LastMassKG float64 `json:"lastMassKg"` + + FirstEffective hydrogenV35Point `json:"firstEffective"` + LastEffective hydrogenV35Point `json:"lastEffective"` + HasEffective bool `json:"hasEffective"` + EffectiveRunCount int64 `json:"effectiveRunCount"` + LastBoundaryWasEffective bool `json:"lastBoundaryWasEffective"` + + Cycle hydrogenV35CycleState `json:"cycle"` + InitialStateUsed bool `json:"initialStateUsed"` + Charging bool `json:"charging"` + ChargeCount int64 `json:"chargeCount"` + ChargeStartSOC float64 `json:"chargeStartSoc"` + ChargeLastSOC float64 `json:"chargeLastSoc"` + ChargeSOCKnown bool `json:"chargeSocKnown"` + ChargeEnergyKWh float64 `json:"chargeEnergyKWh"` + + CompletedPureKM float64 `json:"completedPureKm"` + CompletedMixedKM float64 `json:"completedMixedKm"` + CompletedSOCDelta float64 `json:"completedSocDelta"` + CompletedMixedEnergyKWh float64 `json:"completedMixedEnergyKWh"` + CompletedMixedCycles int64 `json:"completedMixedCycles"` + + HydrogenSegmentStart hydrogenV35Point `json:"hydrogenSegmentStart"` + LastHydrogenRun hydrogenV35Point `json:"lastHydrogenRun"` + HasHydrogenSegment bool `json:"hasHydrogenSegment"` + HydrogenSegmentRuns int64 `json:"hydrogenSegmentRuns"` + FinalizedHydrogenKG float64 `json:"finalizedHydrogenKg"` + HydrogenSegments int64 `json:"hydrogenSegments"` + RefuelCount int64 `json:"refuelCount"` + RefuelAmountKG float64 `json:"refuelAmountKg"` + RefuelCandidate hydrogenV35RefuelCandidate `json:"refuelCandidate"` + MinimumPressureMPa float64 `json:"minimumPressureMpa"` + MinimumMassKG float64 `json:"minimumMassKg"` + MinimumObservedAt time.Time `json:"minimumObservedAt"` + + PreviousSample hydrogenV35Point `json:"previousSample"` + HasPreviousSample bool `json:"hasPreviousSample"` + AbnormalDropCount int64 `json:"abnormalDropCount"` + InvalidSampleCount int64 `json:"invalidSampleCount"` + + PurePressurePeakMPa float64 `json:"purePressurePeakMpa"` + PurePressurePeakAt time.Time `json:"purePressurePeakAt"` + HasPurePressurePeak bool `json:"hasPurePressurePeak"` + + InactiveStart hydrogenV35Point `json:"inactiveStart"` + InactiveLast hydrogenV35Point `json:"inactiveLast"` + InactiveCount int `json:"inactiveCount"` + SuspectedLeakCount int64 `json:"suspectedLeakCount"` + SuspectedLeakMaxMPa float64 `json:"suspectedLeakMaxMpa"` +} + +type hydrogenV35Projection struct { + ConsumptionKG float64 + SOCDeltaPercent *float64 + BatteryEnergyChangeKWh *float64 + ElectricEquivalentKG *float64 + CorrectedConsumptionKG *float64 + PureMileageKM float64 + MixedMileageKM float64 + ConsumptionPer100KM *float64 + CorrectedPer100KM *float64 + QualityStatus string + QualityReason string + FuelCellEnergyKG float64 + ValidSegmentCount int64 +} + +func newHydrogenV35RealtimeState(sample HydrogenStreamSample, params HydrogenRealtimeParameters) hydrogenV35RealtimeState { + params = normalizeHydrogenRealtimeParameters(params) + state := hydrogenV35RealtimeState{ + AlgorithmVersion: HydrogenV35RealtimeAlgorithmVersion, + SourceEndpoint: sample.SourceEndpoint, Parameters: params, + FirstMassKG: sample.MassKG, LastMassKG: sample.MassKG, + } + state.add(sample) + return state +} + +func (state *hydrogenV35RealtimeState) add(sample HydrogenStreamSample) bool { + if !state.LastEventTime.IsZero() && !sample.EventTime.After(state.LastEventTime) { + return false + } + state.AlgorithmVersion = HydrogenV35RealtimeAlgorithmVersion + state.Parameters = normalizeHydrogenRealtimeParameters(state.Parameters) + state.SourceEndpoint = firstNonEmptyHydrogen(sample.SourceEndpoint, state.SourceEndpoint) + state.SampleCount++ + state.LastEventID, state.LastEventTime = sample.EventID, sample.EventTime + point := hydrogenV35PointFromSample(sample) + + pressureInvalid := state.updatePurePressureValidity(sample) + if pressureInvalid { + state.InvalidSampleCount++ + } + state.updateLeak(point) + state.updateRefuel(sample, point) + + effective := sample.VehicleStateKnown && sample.VehicleState == 1 && + !(sample.ChargeStateKnown && sample.ChargeState == 1) && !pressureInvalid && + sample.MileageKnown && sample.SOCKnown && sample.RunningModeKnown + externalCharging := sample.ChargeStateKnown && sample.ChargeState == 1 && sample.VehicleStateKnown && + (sample.VehicleState == 1 || sample.VehicleState == 2) + if externalCharging { + state.startOrContinueCharge(sample) + state.LastBoundaryWasEffective = false + state.PreviousSample, state.HasPreviousSample = point, true + return true + } + if state.Charging { + state.finishCharge() + state.finishCycle() + state.Cycle = hydrogenV35CycleState{} + } + if !effective { + state.LastBoundaryWasEffective = false + state.PreviousSample, state.HasPreviousSample = point, true + return true + } + previousEffective, compareAbnormalDrop := state.LastEffective, state.LastBoundaryWasEffective + if state.HasEffective && sample.EventTime.Sub(state.LastEffective.ObservedAt) > hydrogenV35ContinuityGap { + state.finishCycle() + state.Cycle = hydrogenV35CycleState{} + } + if !state.HasEffective { + state.FirstEffective = point + state.FirstMassKG = point.MassKG + state.HasEffective = true + } + state.LastEffective = point + state.LastMassKG = point.MassKG + state.EffectiveRunCount++ + state.addCycleRun(point) + if !sample.FuelCellStateKnown || sample.FuelCellActive { + state.addHydrogenRun(point) + } + if compareAbnormalDrop && previousEffective.MassKG-point.MassKG > defaultHydrogenMaxDropKG { + state.AbnormalDropCount++ + } + state.LastBoundaryWasEffective = true + state.PreviousSample, state.HasPreviousSample = point, true + return true +} + +func (state *hydrogenV35RealtimeState) updatePurePressureValidity(sample HydrogenStreamSample) bool { + if !sample.RunningModeKnown || sample.RunningMode != 1 { + state.HasPurePressurePeak = false + return false + } + if !state.HasPurePressurePeak || sample.EventTime.Sub(state.PurePressurePeakAt) > 30*time.Minute { + state.PurePressurePeakMPa, state.PurePressurePeakAt, state.HasPurePressurePeak = sample.PressureMPa, sample.EventTime, true + return false + } + invalid := state.PurePressurePeakMPa-sample.PressureMPa > 5 + if sample.PressureMPa > state.PurePressurePeakMPa { + state.PurePressurePeakMPa, state.PurePressurePeakAt = sample.PressureMPa, sample.EventTime + } + return invalid +} + +func (state *hydrogenV35RealtimeState) startOrContinueCharge(sample HydrogenStreamSample) { + if !state.Charging { + state.finishCycle() + state.Cycle = hydrogenV35CycleState{} + state.Charging = true + state.ChargeCount++ + state.ChargeSOCKnown = sample.SOCKnown + state.ChargeStartSOC, state.ChargeLastSOC = sample.SOCPercent, sample.SOCPercent + return + } + if sample.SOCKnown { + if !state.ChargeSOCKnown { + state.ChargeStartSOC = sample.SOCPercent + state.ChargeSOCKnown = true + } + state.ChargeLastSOC = sample.SOCPercent + } +} + +func (state *hydrogenV35RealtimeState) finishCharge() { + if state.ChargeSOCKnown && state.Parameters.BatteryCapacityKWh > 0 && state.ChargeLastSOC > state.ChargeStartSOC { + state.ChargeEnergyKWh += state.Parameters.BatteryCapacityKWh * (state.ChargeLastSOC - state.ChargeStartSOC) / 100 + } + state.Charging, state.ChargeSOCKnown = false, false + state.InitialStateUsed = true +} + +func (state *hydrogenV35RealtimeState) addCycleRun(point hydrogenV35Point) { + cycle := &state.Cycle + if !cycle.HasRun { + cycle.HasRun, cycle.First, cycle.Last = true, point, point + cycle.MixedLocked = !state.InitialStateUsed + cycle.StartsPure = point.RunningMode == 1 && !cycle.MixedLocked + if cycle.MixedLocked || !cycle.StartsPure { + cycle.MixedConfirmed, cycle.MixedStart = true, point + } + state.InitialStateUsed = true + return + } + previous := cycle.Last + cycle.Last = point + if cycle.MixedConfirmed { + cycle.MixedEnergyKWh += hydrogenV35PowerBetween(previous, point) + return + } + if point.RunningMode != 2 || (cycle.MixedCandidateSet && point.ObservedAt.Sub(cycle.MixedCandidateAt) > hydrogenV35MixedConfirmWindow) { + cycle.MixedCandidateSet, cycle.MixedCandidateN, cycle.CandidateEnergyKWh = false, 0, 0 + } + if point.RunningMode != 2 { + return + } + if !cycle.MixedCandidateSet { + cycle.MixedCandidate, cycle.MixedCandidateSet = point, true + cycle.MixedCandidateN = 1 + cycle.CandidateEnergyKWh = 0 + } else { + cycle.MixedCandidateN++ + cycle.CandidateEnergyKWh += hydrogenV35PowerBetween(previous, point) + } + cycle.MixedCandidateAt = point.ObservedAt + if cycle.MixedCandidateN >= hydrogenV35MixedConfirmSamples { + cycle.MixedConfirmed = true + cycle.MixedStart = cycle.MixedCandidate + cycle.MixedEnergyKWh = cycle.CandidateEnergyKWh + cycle.MixedCandidateSet, cycle.MixedCandidateN, cycle.CandidateEnergyKWh = false, 0, 0 + } +} + +func hydrogenV35PowerBetween(previous, current hydrogenV35Point) float64 { + gap := current.ObservedAt.Sub(previous.ObservedAt) + if gap <= 0 || gap > hydrogenV35PowerIntegrationGap || !previous.FuelCellPowerKnown || !current.FuelCellPowerKnown { + return 0 + } + return (math.Max(0, previous.FuelCellPowerKW) + math.Max(0, current.FuelCellPowerKW)) / 2 * gap.Hours() +} + +func hydrogenV35CycleTotals(cycle hydrogenV35CycleState) (pureKM, mixedKM, socDelta, energyKWh float64, mixed bool) { + if !cycle.HasRun { + return 0, 0, 0, 0, false + } + if !cycle.MixedConfirmed { + if cycle.StartsPure { + return math.Max(0, cycle.Last.MileageKM-cycle.First.MileageKM), 0, 0, 0, false + } + return 0, 0, 0, 0, false + } + if cycle.StartsPure { + pureKM = math.Max(0, cycle.MixedStart.MileageKM-cycle.First.MileageKM) + } + mixedKM = math.Max(0, cycle.Last.MileageKM-cycle.MixedStart.MileageKM) + socDelta = cycle.Last.SOCPercent - cycle.MixedStart.SOCPercent + return pureKM, mixedKM, socDelta, cycle.MixedEnergyKWh, true +} + +func (state *hydrogenV35RealtimeState) finishCycle() { + pureKM, mixedKM, socDelta, energyKWh, mixed := hydrogenV35CycleTotals(state.Cycle) + state.CompletedPureKM += pureKM + state.CompletedMixedKM += mixedKM + state.CompletedSOCDelta += socDelta + state.CompletedMixedEnergyKWh += energyKWh + if mixed { + state.CompletedMixedCycles++ + } +} + +func (state *hydrogenV35RealtimeState) addHydrogenRun(point hydrogenV35Point) { + if state.RefuelCandidate.Set && !point.ObservedAt.Before(state.RefuelCandidate.Boundary.ObservedAt) { + if state.RefuelCandidate.HydrogenRunsAfter == 0 { + state.RefuelCandidate.FirstHydrogenAfter = point + } + state.RefuelCandidate.HydrogenRunsAfter++ + } + if !state.HasHydrogenSegment { + state.HydrogenSegmentStart, state.LastHydrogenRun, state.HasHydrogenSegment = point, point, true + state.HydrogenSegmentRuns = 1 + return + } + state.LastHydrogenRun = point + state.HydrogenSegmentRuns++ +} + +func (state *hydrogenV35RealtimeState) updateRefuel(sample HydrogenStreamSample, point hydrogenV35Point) { + if state.MinimumObservedAt.IsZero() || sample.EventTime.Sub(state.MinimumObservedAt) > hydrogenV35RefuelObservationWindow { + state.MinimumPressureMPa, state.MinimumMassKG, state.MinimumObservedAt = sample.PressureMPa, sample.MassKG, sample.EventTime + } + if state.RefuelCandidate.Set { + age := sample.EventTime.Sub(state.RefuelCandidate.Boundary.ObservedAt) + if age > hydrogenV35RefuelObservationWindow || sample.PressureMPa-state.RefuelCandidate.BaselinePressureMPa < hydrogenV35RefuelRiseMPa { + state.RefuelCandidate = hydrogenV35RefuelCandidate{} + } else { + state.RefuelCandidate.HighSamples++ + if age >= hydrogenV35RefuelSustain || state.RefuelCandidate.HighSamples >= hydrogenV35MixedConfirmSamples { + state.confirmRefuel(point) + } + } + } + if sample.PressureMPa < state.MinimumPressureMPa { + state.MinimumPressureMPa, state.MinimumMassKG, state.MinimumObservedAt = sample.PressureMPa, sample.MassKG, sample.EventTime + } + thermalRefuel := state.thermalRefuelContext(sample) + ordinaryRefuel := sample.PressureMPa-state.MinimumPressureMPa >= hydrogenV35RefuelRiseMPa && state.refuelContext(sample) + if state.RefuelCandidate.Set || (!ordinaryRefuel && !thermalRefuel) { + return + } + baselinePressure, baselineMass := state.MinimumPressureMPa, state.MinimumMassKG + if thermalRefuel { + baselinePressure, baselineMass = state.PreviousSample.PressureMPa, state.PreviousSample.MassKG + } + candidate := hydrogenV35RefuelCandidate{ + Set: true, Boundary: point, BaselinePressureMPa: baselinePressure, + BaselineMassKG: baselineMass, HighSamples: 1, + } + if state.HasHydrogenSegment { + candidate.PreviousHydrogen, candidate.PreviousHydrogenExists = state.LastHydrogenRun, true + } + state.RefuelCandidate = candidate +} + +func (state *hydrogenV35RealtimeState) thermalRefuelContext(sample HydrogenStreamSample) bool { + if !state.HasPreviousSample || !sample.MileageKnown || !state.PreviousSample.MileageKnown { + return false + } + gap := sample.EventTime.Sub(state.PreviousSample.ObservedAt) + return gap > 0 && gap <= hydrogenV35RefuelThermalWindow && + sample.PressureMPa-state.PreviousSample.PressureMPa >= hydrogenV35RefuelRiseMPa && + sample.TemperatureC-state.PreviousSample.TemperatureC >= hydrogenV35RefuelTemperatureRiseC && + math.Abs(sample.MileageKM-state.PreviousSample.MileageKM) <= 5 +} + +func (state *hydrogenV35RealtimeState) refuelContext(sample HydrogenStreamSample) bool { + if !state.HasPreviousSample { + return false + } + if sample.MileageKnown && state.PreviousSample.MileageKnown { + return math.Abs(sample.MileageKM-state.PreviousSample.MileageKM) <= 0.2 + } + return (sample.VehicleStateKnown && sample.VehicleState == 2) || (sample.FuelCellStateKnown && !sample.FuelCellActive) +} + +func (state *hydrogenV35RealtimeState) confirmRefuel(current hydrogenV35Point) { + candidate := state.RefuelCandidate + if candidate.PreviousHydrogenExists && state.HasHydrogenSegment && state.HydrogenSegmentRuns >= 2 { + drop := state.HydrogenSegmentStart.MassKG - candidate.PreviousHydrogen.MassKG + if drop < 0 && math.Abs(drop) <= math.Max(state.HydrogenSegmentStart.NoiseKG, candidate.PreviousHydrogen.NoiseKG) { + drop = 0 + } + state.FinalizedHydrogenKG += drop + state.HydrogenSegments++ + } + state.RefuelCount++ + state.RefuelAmountKG += math.Max(0, current.MassKG-candidate.BaselineMassKG) + if candidate.HydrogenRunsAfter > 0 { + state.HydrogenSegmentStart = candidate.FirstHydrogenAfter + state.HasHydrogenSegment = true + state.HydrogenSegmentRuns = candidate.HydrogenRunsAfter + } else { + state.HasHydrogenSegment = false + state.HydrogenSegmentRuns = 0 + } + state.MinimumPressureMPa, state.MinimumMassKG, state.MinimumObservedAt = current.PressureMPa, current.MassKG, current.ObservedAt + state.RefuelCandidate = hydrogenV35RefuelCandidate{} +} + +func (state *hydrogenV35RealtimeState) updateLeak(point hydrogenV35Point) { + inactive := point.FuelCellStateKnown && !point.FuelCellActive + if !inactive { + state.finishInactiveLeak() + return + } + if state.InactiveCount > 0 && point.ObservedAt.Sub(state.InactiveLast.ObservedAt) > hydrogenV35ContinuityGap { + state.finishInactiveLeak() + } + if state.InactiveCount == 0 { + state.InactiveStart = point + } + state.InactiveLast = point + state.InactiveCount++ +} + +func (state *hydrogenV35RealtimeState) finishInactiveLeak() { + if state.InactiveCount >= 3 && state.InactiveLast.ObservedAt.Sub(state.InactiveStart.ObservedAt) >= 5*time.Minute { + pressureDrop := state.InactiveStart.PressureMPa - state.InactiveLast.PressureMPa + massDrop := state.InactiveStart.MassKG - state.InactiveLast.MassKG + if pressureDrop >= 0.5 && massDrop >= math.Max(0.05, math.Max(state.InactiveStart.NoiseKG, state.InactiveLast.NoiseKG)) { + state.SuspectedLeakCount++ + state.SuspectedLeakMaxMPa = math.Max(state.SuspectedLeakMaxMPa, pressureDrop) + } + } + state.InactiveCount = 0 +} + +func (state hydrogenV35RealtimeState) projection() hydrogenV35Projection { + // Evaluate an open inactive segment at the current watermark on this copy. + // Persisted state remains open so later frames can continue the same segment. + state.finishInactiveLeak() + pureKM, mixedKM, socDelta, mixedEnergyKWh, currentMixed := hydrogenV35CycleTotals(state.Cycle) + pureKM += state.CompletedPureKM + mixedKM += state.CompletedMixedKM + socDelta += state.CompletedSOCDelta + mixedEnergyKWh += state.CompletedMixedEnergyKWh + mixedCycles := state.CompletedMixedCycles + if currentMixed { + mixedCycles++ + } + physical := state.FinalizedHydrogenKG + hydrogenSegments := state.HydrogenSegments + if state.HasHydrogenSegment && state.HydrogenSegmentRuns >= 2 { + drop := state.HydrogenSegmentStart.MassKG - state.LastHydrogenRun.MassKG + if drop < 0 && math.Abs(drop) <= math.Max(state.HydrogenSegmentStart.NoiseKG, state.LastHydrogenRun.NoiseKG) { + drop = 0 + } + physical += drop + hydrogenSegments++ + } + params := normalizeHydrogenRealtimeParameters(state.Parameters) + fuelCellEnergyKG := mixedEnergyKWh / params.HydrogenEnergyKWhKG + electricEquivalentKG := 0.0 + if params.BatteryCapacityKWh > 0 { + electricEquivalentKG = params.BatteryCapacityKWh * socDelta / 100 / params.HydrogenEnergyKWhKG + } + usedEnergyFallback := mixedKM >= hydrogenV35MixedMinimumKM && fuelCellEnergyKG > 0 && (physical <= 0 || physical-electricEquivalentKG <= 0) + if usedEnergyFallback { + physical = math.Max(physical, fuelCellEnergyKG) + } + corrected := math.Max(0, physical-electricEquivalentKG) + result := hydrogenV35Projection{ + ConsumptionKG: roundHydrogenKG(physical), PureMileageKM: roundHydrogenKG(pureKM), MixedMileageKM: roundHydrogenKG(mixedKM), + FuelCellEnergyKG: roundHydrogenKG(fuelCellEnergyKG), ValidSegmentCount: hydrogenSegments, QualityStatus: "OK", + } + if params.BatteryCapacityKWh > 0 { + soc, battery, equivalent, balanced := roundHydrogenKG(socDelta), roundHydrogenKG(params.BatteryCapacityKWh*socDelta/100), roundHydrogenKG(electricEquivalentKG), roundHydrogenKG(corrected) + result.SOCDeltaPercent, result.BatteryEnergyChangeKWh = &soc, &battery + result.ElectricEquivalentKG, result.CorrectedConsumptionKG = &equivalent, &balanced + } + if mixedKM > 0 { + physicalRate := roundHydrogenKG(physical * 100 / mixedKM) + result.ConsumptionPer100KM = &physicalRate + if result.CorrectedConsumptionKG != nil { + correctedRate := roundHydrogenKG(corrected * 100 / mixedKM) + result.CorrectedPer100KM = &correctedRate + } + } + reasons := make([]string, 0, 5) + totalMileage := 0.0 + if state.HasEffective { + totalMileage = state.LastEffective.MileageKM - state.FirstEffective.MileageKM + } + switch { + case state.EffectiveRunCount < 2: + result.QualityStatus, reasons = "NO_DATA", append(reasons, "有效运行分界点不足2条") + case totalMileage < 1: + result.QualityStatus, reasons = "NO_DATA", append(reasons, "当前有效总里程不足1km") + case mixedCycles == 0: + result.QualityStatus, reasons = "NO_DATA", append(reasons, "尚未形成确认的用氢混动周期") + case mixedKM < hydrogenV35MixedMinimumKM: + result.QualityStatus, reasons = "NO_DATA", append(reasons, "当前混动里程不足10km") + case hydrogenSegments == 0: + result.QualityStatus, reasons = "NO_DATA", append(reasons, "燃料电池有效工作氢量边界不足") + case corrected <= 0: + result.QualityStatus, reasons = "NO_DATA", append(reasons, "现有报文不足以形成可信的正耗氢结果") + } + if result.QualityStatus != "NO_DATA" && params.BatteryCapacityKWh <= 0 { + result.QualityStatus, reasons = "SUSPECT", append(reasons, "车型动力电池容量未确认") + } + if state.AbnormalDropCount > 0 { + if result.QualityStatus == "OK" { + result.QualityStatus = "SUSPECT" + } + reasons = append(reasons, "检测到氢量异常大幅下降") + } + if state.SuspectedLeakCount > 0 { + if result.QualityStatus == "OK" { + result.QualityStatus = "SUSPECT" + } + reasons = append(reasons, "检测到燃料电池未工作期间持续压降") + } + if usedEnergyFallback { + if result.QualityStatus == "OK" { + result.QualityStatus = "SUSPECT" + } + reasons = append(reasons, "压力边界不足,暂用燃料电池功率积分折算") + } + if result.QualityStatus == "NO_DATA" { + result.ConsumptionPer100KM = nil + result.CorrectedPer100KM = nil + } + result.QualityReason = strings.Join(reasons, ";") + return result +} + +func AppendHydrogenV35Realtime(ctx context.Context, exec Execer, env envelope.FrameEnvelope, loc *time.Location, now time.Time, tankCapacityLiters float64, params HydrogenRealtimeParameters) (HydrogenStreamResult, error) { + sample, reason, ok := HydrogenStreamSampleFromEnvelope(env, loc, now, tankCapacityLiters) + if !ok { + result := HydrogenStreamResult{} + switch reason { + case "unsupported_protocol", "missing_pressure", "missing_temperature": + return result, nil + case "not_current_date": + result.NotCurrent = 1 + default: + result.Invalid = 1 + } + return result, nil + } + result := HydrogenStreamResult{Found: 1} + beginner, ok := exec.(txBeginner) + if !ok { + return result, sql.ErrTxDone + } + tx, err := beginner.BeginTx(ctx, nil) + if err != nil { + return result, err + } + defer tx.Rollback() + state, found, err := selectHydrogenV35RealtimeState(ctx, tx, sample.VIN, sample.Date) + if err != nil { + return result, err + } + if !found || state.AlgorithmVersion != HydrogenV35RealtimeAlgorithmVersion { + state = newHydrogenV35RealtimeState(sample, params) + if err := insertHydrogenV35RealtimeState(ctx, tx, sample.VIN, sample.Date, state); err != nil { + return result, err + } + } else { + if params.BatteryCapacityKWh > 0 { + state.Parameters = normalizeHydrogenRealtimeParameters(params) + } else { + state.Parameters = normalizeHydrogenRealtimeParameters(state.Parameters) + } + if !state.add(sample) { + result.Duplicate = 1 + return result, tx.Commit() + } + if err := updateHydrogenV35RealtimeState(ctx, tx, sample.VIN, sample.Date, state); err != nil { + return result, err + } + } + if err := projectHydrogenV35Realtime(ctx, tx, sample.VIN, sample.Date, state); err != nil { + return result, err + } + if err := tx.Commit(); err != nil { + return result, err + } + result.Written = 1 + return result, nil +} + +func selectHydrogenV35RealtimeState(ctx context.Context, tx *sql.Tx, vin, date string) (hydrogenV35RealtimeState, bool, error) { + var encoded []byte + err := tx.QueryRowContext(ctx, selectHydrogenSegmentStreamStateSQL, vin, date).Scan(&encoded) + if errors.Is(err, sql.ErrNoRows) { + return hydrogenV35RealtimeState{}, false, nil + } + if err != nil { + return hydrogenV35RealtimeState{}, false, err + } + var state hydrogenV35RealtimeState + if err := json.Unmarshal(encoded, &state); err != nil { + return hydrogenV35RealtimeState{}, false, nil + } + return state, true, nil +} + +func insertHydrogenV35RealtimeState(ctx context.Context, tx *sql.Tx, vin, date string, state hydrogenV35RealtimeState) error { + encoded, err := json.Marshal(state) + if err != nil { + return err + } + projection := state.projection() + _, err = tx.ExecContext(ctx, insertHydrogenSegmentStreamStateSQL, + vin, date, state.SourceEndpoint, projection.ConsumptionKG, projection.ConsumptionKG, + state.SampleCount, state.RefuelCount, state.AbnormalDropCount, state.EffectiveRunCount, projection.ValidSegmentCount, + state.LastMassKG, state.LastEventTime, state.LastEventID, encoded, projection.QualityStatus, projection.QualityReason) + return err +} + +func updateHydrogenV35RealtimeState(ctx context.Context, tx *sql.Tx, vin, date string, state hydrogenV35RealtimeState) error { + encoded, err := json.Marshal(state) + if err != nil { + return err + } + projection := state.projection() + _, err = tx.ExecContext(ctx, updateHydrogenSegmentStreamStateSQL, + state.SourceEndpoint, projection.ConsumptionKG, projection.ConsumptionKG, + state.SampleCount, state.RefuelCount, state.AbnormalDropCount, state.EffectiveRunCount, projection.ValidSegmentCount, + state.LastMassKG, state.LastEventTime, state.LastEventID, encoded, + projection.QualityStatus, projection.QualityReason, vin, date) + return err +} + +func projectHydrogenV35Realtime(ctx context.Context, tx *sql.Tx, vin, date string, state hydrogenV35RealtimeState) error { + projection := state.projection() + parameters, err := json.Marshal(map[string]any{ + "batteryCapacityKWh": state.Parameters.BatteryCapacityKWh, + "hydrogenEnergyKWhPerKg": normalizeHydrogenRealtimeParameters(state.Parameters).HydrogenEnergyKWhKG, + "algorithmVersion": HydrogenV35RealtimeAlgorithmVersion, + }) + if err != nil { + return err + } + evidence, err := json.Marshal(map[string]any{ + "calculationPhase": "PRELIMINARY", "lastEventId": state.LastEventID, + "lastEventTime": state.LastEventTime, "refuelAmountKg": roundHydrogenKG(state.RefuelAmountKG), + "suspectedLeakCount": state.SuspectedLeakCount, "fuelCellEnergyHydrogenKg": projection.FuelCellEnergyKG, + }) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, projectHydrogenV35RealtimeSQL, + vin, date, state.SourceEndpoint, projection.ConsumptionKG, projection.ConsumptionKG, + projection.SOCDeltaPercent, projection.BatteryEnergyChangeKWh, projection.ElectricEquivalentKG, + projection.CorrectedConsumptionKG, projection.MixedMileageKM, projection.PureMileageKM, + projection.ConsumptionPer100KM, projection.CorrectedPer100KM, + state.FirstMassKG, state.LastMassKG, state.SampleCount, state.RefuelCount, state.ChargeCount, + projection.ValidSegmentCount, state.InvalidSampleCount, HydrogenV35RealtimeAlgorithmVersion, + parameters, evidence, projection.QualityStatus, projection.QualityReason) + return err +} + +const projectHydrogenV35RealtimeSQL = `INSERT INTO vehicle_open_daily_energy( + vin,stat_date,energy_type,source_endpoint,consumption_kg,raw_consumption_kg, + battery_soc_delta_pct,battery_discharge_kwh,battery_equivalent_kg,soc_balanced_consumption_kg, + mixed_mileage_km,pure_electric_mileage_km,consumption_kg_per_100km,soc_balanced_kg_per_100km, + unit,first_mass_kg,last_mass_kg,sample_count,refuel_count,charge_count,valid_segment_count, + invalid_segment_count,algorithm_version,calculation_phase,parameter_json,evidence_json, + quality_status,quality_reason,calculated_at +) VALUES(?,?,'HYDROGEN',?,?,?,?,?,?,?,?,?,?,?,'kg',?,?,?,?,?,?,?,?,'PRELIMINARY',?,?,?,?,NOW(3)) +ON DUPLICATE KEY UPDATE + source_endpoint=VALUES(source_endpoint),consumption_kg=VALUES(consumption_kg),raw_consumption_kg=VALUES(raw_consumption_kg), + battery_soc_delta_pct=VALUES(battery_soc_delta_pct),battery_discharge_kwh=VALUES(battery_discharge_kwh), + battery_equivalent_kg=VALUES(battery_equivalent_kg),soc_balanced_consumption_kg=VALUES(soc_balanced_consumption_kg), + mixed_mileage_km=VALUES(mixed_mileage_km),pure_electric_mileage_km=VALUES(pure_electric_mileage_km), + consumption_kg_per_100km=VALUES(consumption_kg_per_100km),soc_balanced_kg_per_100km=VALUES(soc_balanced_kg_per_100km), + first_mass_kg=VALUES(first_mass_kg),last_mass_kg=VALUES(last_mass_kg),sample_count=VALUES(sample_count), + refuel_count=VALUES(refuel_count),charge_count=VALUES(charge_count),valid_segment_count=VALUES(valid_segment_count), + invalid_segment_count=VALUES(invalid_segment_count),algorithm_version=VALUES(algorithm_version), + calculation_phase='PRELIMINARY',parameter_json=VALUES(parameter_json),evidence_json=VALUES(evidence_json), + quality_status=VALUES(quality_status),quality_reason=VALUES(quality_reason),calculated_at=VALUES(calculated_at)` diff --git a/go/vehicle-gateway/internal/stats/hydrogen_v35_realtime_test.go b/go/vehicle-gateway/internal/stats/hydrogen_v35_realtime_test.go new file mode 100644 index 00000000..ae9c5d57 --- /dev/null +++ b/go/vehicle-gateway/internal/stats/hydrogen_v35_realtime_test.go @@ -0,0 +1,157 @@ +package stats + +import ( + "context" + "math" + "regexp" + "strings" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" +) + +func hydrogenV35TestSample(at time.Time, mass, pressure, mileage, soc float64, runningMode int) HydrogenStreamSample { + return HydrogenStreamSample{ + VIN: "LA9GG64L0NBAF4175", Date: at.Format("2006-01-02"), SourceEndpoint: "source-a", + EventID: at.Format(time.RFC3339Nano), EventTime: at, + MassKG: mass, PressureMPa: pressure, TemperatureC: 30, NoiseKG: 0.05, RefuelThresholdKG: 1, + MileageKM: mileage, MileageKnown: true, SOCPercent: soc, SOCKnown: true, + VehicleState: 1, VehicleStateKnown: true, ChargeState: 3, ChargeStateKnown: true, + RunningMode: runningMode, RunningModeKnown: true, + FuelCellActive: runningMode == 2, FuelCellStateKnown: true, + FuelCellPowerKW: 40, FuelCellPowerKnown: true, ConsumptionEligible: true, + TankCapacityLiters: 520, + } +} + +func TestHydrogenV35RealtimeConfirmsMixedAndBalancesSOC(t *testing.T) { + base := time.Date(2026, 9, 4, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) + charging := hydrogenV35TestSample(base, 10.1, 30, 100, 90, 1) + charging.ChargeState = 1 + state := newHydrogenV35RealtimeState(charging, HydrogenRealtimeParameters{BatteryCapacityKWh: 80, HydrogenEnergyKWhKG: 16}) + + values := []HydrogenStreamSample{ + hydrogenV35TestSample(base.Add(time.Minute), 10.0, 29.5, 100, 90, 1), + hydrogenV35TestSample(base.Add(2*time.Minute), 9.8, 29, 105, 88, 1), + hydrogenV35TestSample(base.Add(3*time.Minute), 9.7, 28.5, 106, 87, 2), + hydrogenV35TestSample(base.Add(3*time.Minute+10*time.Second), 9.2, 27, 112, 84, 2), + hydrogenV35TestSample(base.Add(3*time.Minute+20*time.Second), 8.5, 25, 120, 80, 2), + } + for _, value := range values { + if !state.add(value) { + t.Fatalf("sample %s rejected", value.EventID) + } + } + projection := state.projection() + if projection.QualityStatus != "OK" { + t.Fatalf("quality=%s reason=%s state=%#v", projection.QualityStatus, projection.QualityReason, state) + } + if math.Abs(projection.ConsumptionKG-1.2) > 0.001 || math.Abs(projection.PureMileageKM-6) > 0.001 || math.Abs(projection.MixedMileageKM-14) > 0.001 { + t.Fatalf("projection=%#v", projection) + } + if projection.SOCDeltaPercent == nil || math.Abs(*projection.SOCDeltaPercent-(-7)) > 0.001 { + t.Fatalf("SOC projection=%#v", projection) + } + if projection.CorrectedConsumptionKG == nil || math.Abs(*projection.CorrectedConsumptionKG-1.55) > 0.001 { + t.Fatalf("balanced projection=%#v", projection) + } +} + +func TestHydrogenV35RealtimeWaitsForRefuelConfirmation(t *testing.T) { + base := time.Date(2026, 9, 4, 9, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) + state := newHydrogenV35RealtimeState(hydrogenV35TestSample(base, 10, 20, 100, 80, 2), HydrogenRealtimeParameters{BatteryCapacityKWh: 80}) + state.add(hydrogenV35TestSample(base.Add(time.Minute), 9, 19, 101, 79, 2)) + state.add(hydrogenV35TestSample(base.Add(2*time.Minute), 12.5, 22.5, 101, 79, 2)) + if state.RefuelCount != 0 || !state.RefuelCandidate.Set { + t.Fatalf("refuel confirmed too early: %#v", state.RefuelCandidate) + } + state.add(hydrogenV35TestSample(base.Add(2*time.Minute+10*time.Second), 12.4, 22.4, 101, 79, 2)) + state.add(hydrogenV35TestSample(base.Add(2*time.Minute+20*time.Second), 12.3, 22.3, 101, 79, 2)) + if state.RefuelCount != 1 || state.RefuelCandidate.Set || state.HydrogenSegments != 1 { + t.Fatalf("refuel not confirmed: count=%d segments=%d candidate=%#v", state.RefuelCount, state.HydrogenSegments, state.RefuelCandidate) + } + state.add(hydrogenV35TestSample(base.Add(3*time.Minute), 11.5, 21, 112, 75, 2)) + if got := state.projection().ConsumptionKG; math.Abs(got-2.0) > 0.001 { + t.Fatalf("consumption=%.3f, want 2.000; state=%#v", got, state) + } +} + +func TestHydrogenV35RealtimeDetectsThermalRefuelAcrossReportingGap(t *testing.T) { + base := time.Date(2026, 9, 4, 9, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) + first := hydrogenV35TestSample(base, 5, 10, 100, 80, 2) + first.TemperatureC = 20 + state := newHydrogenV35RealtimeState(first, HydrogenRealtimeParameters{BatteryCapacityKWh: 80}) + for index, offset := range []time.Duration{21*time.Minute + 50*time.Second, 22 * time.Minute, 22*time.Minute + 10*time.Second} { + value := hydrogenV35TestSample(base.Add(offset), 9-float64(index)/10, 14-float64(index)/10, 100.3, 80, 2) + value.TemperatureC = 24 + state.add(value) + } + if state.RefuelCount != 1 { + t.Fatalf("thermal refuel count=%d candidate=%#v", state.RefuelCount, state.RefuelCandidate) + } +} + +func TestHydrogenV35RealtimeRejectsLateFrameWithoutChangingState(t *testing.T) { + base := time.Date(2026, 9, 4, 10, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) + state := newHydrogenV35RealtimeState(hydrogenV35TestSample(base, 10, 20, 100, 80, 2), HydrogenRealtimeParameters{}) + before := state.SampleCount + if state.add(hydrogenV35TestSample(base, 9, 19, 101, 79, 2)) { + t.Fatal("same-time frame was accepted") + } + if state.SampleCount != before || state.LastMassKG != 10 { + t.Fatalf("late frame changed state: %#v", state) + } +} + +func TestHydrogenV35RealtimeNoDataDoesNotExposePer100KMRates(t *testing.T) { + base := time.Date(2026, 9, 4, 10, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) + state := newHydrogenV35RealtimeState(hydrogenV35TestSample(base, 10, 20, 100, 80, 2), HydrogenRealtimeParameters{BatteryCapacityKWh: 80}) + state.add(hydrogenV35TestSample(base.Add(time.Minute), 9.5, 19, 105, 78, 2)) + state.add(hydrogenV35TestSample(base.Add(2*time.Minute), 9, 18, 109, 76, 2)) + + projection := state.projection() + if projection.QualityStatus != "NO_DATA" { + t.Fatalf("quality=%s, want NO_DATA", projection.QualityStatus) + } + if projection.ConsumptionPer100KM != nil || projection.CorrectedPer100KM != nil { + t.Fatalf("NO_DATA projection exposes per-100km rates: %#v", projection) + } +} + +func TestLoadHydrogenRealtimeParametersKeepsNewestEffectiveRow(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + mock.ExpectQuery(regexp.QuoteMeta("SELECT UPPER(TRIM(vin)),battery_capacity_kwh,hydrogen_energy_kwh_per_kg")). + WithArgs("2026-09-04", "2026-09-04"). + WillReturnRows(sqlmock.NewRows([]string{"vin", "battery_capacity_kwh", "hydrogen_energy_kwh_per_kg"}). + AddRow("LA9GG64L0NBAF4175", 80.0, 15.8). + AddRow("LA9GG64L0NBAF4175", 75.0, 16.0)) + values, err := LoadHydrogenRealtimeParameters(context.Background(), db, "2026-09-04") + if err != nil { + t.Fatal(err) + } + if got := values["LA9GG64L0NBAF4175"]; got.BatteryCapacityKWh != 80 || got.HydrogenEnergyKWhKG != 15.8 { + t.Fatalf("parameters=%#v", got) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestHydrogenV35RealtimeSQLMarksPreviewWithoutOverwritingFinalSemantics(t *testing.T) { + for _, want := range []string{ + "calculation_phase", "'PRELIMINARY'", "algorithm_version=VALUES(algorithm_version)", + "soc_balanced_consumption_kg", "consumption_kg_per_100km", + } { + if !regexp.MustCompile(regexp.QuoteMeta(want)).MatchString(projectHydrogenV35RealtimeSQL) { + t.Fatalf("realtime projection SQL missing %q", want) + } + } + if got := strings.Count(strings.Split(projectHydrogenV35RealtimeSQL, "ON DUPLICATE KEY UPDATE")[0], "?"); got != 25 { + t.Fatalf("realtime projection placeholders=%d, want 25", got) + } +} diff --git a/go/vehicle-gateway/internal/stats/schema.go b/go/vehicle-gateway/internal/stats/schema.go index af9f6605..9cce28d2 100644 --- a/go/vehicle-gateway/internal/stats/schema.go +++ b/go/vehicle-gateway/internal/stats/schema.go @@ -42,6 +42,11 @@ var DailyMileageAlterSQL = []string{ "ALTER TABLE vehicle_open_hydrogen_stream_state ADD COLUMN last_fuel_cell_state_known TINYINT(1) NOT NULL DEFAULT 0 AFTER last_fuel_cell_active", } +var HydrogenRealtimeAlterSQL = []string{ + "ALTER TABLE vehicle_open_hydrogen_segment_stream_state MODIFY COLUMN calculation_method VARCHAR(80) NOT NULL DEFAULT 'PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5'", + "ALTER TABLE vehicle_open_daily_energy ADD COLUMN calculation_phase VARCHAR(16) NOT NULL DEFAULT 'FINAL' AFTER algorithm_version", +} + const DataSourceTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_data_source ( id BIGINT NOT NULL AUTO_INCREMENT, protocol VARCHAR(32) NOT NULL, diff --git a/vehicle-data-platform/apps/api/cmd/open-platform-stat/main.go b/vehicle-data-platform/apps/api/cmd/open-platform-stat/main.go index f0d42dfb..0d6199c8 100644 --- a/vehicle-data-platform/apps/api/cmd/open-platform-stat/main.go +++ b/vehicle-data-platform/apps/api/cmd/open-platform-stat/main.go @@ -26,6 +26,7 @@ func main() { noise := flag.Float64("hydrogen-noise-kg", envFloat("OPEN_STAT_HYDROGEN_NOISE_KG", 0.05), "ignored mass jitter") maxDrop := flag.Float64("hydrogen-max-drop-kg", envFloat("OPEN_STAT_HYDROGEN_MAX_DROP_KG", 20), "maximum accepted drop between samples") vin := flag.String("vin", "", "optional 17-character VIN for a scoped rebuild") + brand := flag.String("brand", "", "optional exact vehicle brand for an atomic scoped rebuild") vinWorkers := flag.Int("vin-workers", envInt("OPEN_STAT_VIN_WORKERS", 4), "parallel per-VIN queries for an all-vehicle day") allDates := flag.Bool("all-dates", false, "rebuild every available completed event date; optionally scoped by -vin") dryRun := flag.Bool("dry-run", false, "calculate and print results without changing MySQL") @@ -62,9 +63,16 @@ func main() { log.Fatal("-vin-workers must be between 1 and 16") } normalizedVIN := strings.ToUpper(strings.TrimSpace(*vin)) + normalizedBrand := strings.TrimSpace(*brand) + if normalizedVIN != "" && normalizedBrand != "" { + log.Fatal("-vin and -brand cannot be combined") + } if *seedStream && normalizedVIN != "" { log.Fatal("-seed-stream-state requires an all-vehicle rebuild") } + if *seedStream && normalizedBrand != "" { + log.Fatal("-seed-stream-state cannot be combined with -brand") + } if *seedStream && *dryRun { log.Fatal("-seed-stream-state cannot be combined with -dry-run") } @@ -84,6 +92,29 @@ func main() { log.Fatalf("no active hydrogen tank capacity for VIN %s", normalizedVIN) } } + var brandVINs []string + if normalizedBrand != "" { + brandVINs, err = openplatform.LoadHydrogenBrandVINs(ctx, mysqlDB, normalizedBrand) + if err != nil { + log.Fatalf("load VINs for brand %q: %v", normalizedBrand, err) + } + if len(brandVINs) == 0 { + log.Fatalf("no vehicles found for brand %q", normalizedBrand) + } + brandSet := make(map[string]struct{}, len(brandVINs)) + for _, brandVIN := range brandVINs { + brandSet[brandVIN] = struct{}{} + } + for capacityVIN := range capacities { + if _, ok := brandSet[capacityVIN]; !ok { + delete(capacities, capacityVIN) + } + } + if len(capacities) == 0 { + log.Fatalf("no active hydrogen tank capacities found for brand %q", normalizedBrand) + } + fmt.Printf("brand=%s scoped_vins=%d capacity_vins=%d\n", normalizedBrand, len(brandVINs), len(capacities)) + } startDate := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, location).AddDate(0, 0, -(*lookback - 1)) if *allDates { var first, last time.Time @@ -112,6 +143,8 @@ func main() { scope := "all-vehicles" if normalizedVIN != "" { scope = normalizedVIN + } else if normalizedBrand != "" { + scope = "brand:" + normalizedBrand } fmt.Printf("scope=%s date_from=%s date_to=%s mode=all-dates\n", scope, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")) } @@ -141,6 +174,8 @@ func main() { if !*dryRun { if *seedStream { err = openplatform.ReplaceHydrogenDailyStatsAndSeedStream(ctx, mysqlDB, date, stats) + } else if normalizedBrand != "" { + err = openplatform.ReplaceHydrogenDailyStatsForVINs(ctx, mysqlDB, date, brandVINs, stats) } else if normalizedVIN == "" { err = openplatform.ReplaceHydrogenDailyStats(ctx, mysqlDB, date, stats) } else { diff --git a/vehicle-data-platform/apps/api/internal/app/auth.go b/vehicle-data-platform/apps/api/internal/app/auth.go index e5a6534d..3ca24a99 100644 --- a/vehicle-data-platform/apps/api/internal/app/auth.go +++ b/vehicle-data-platform/apps/api/internal/app/auth.go @@ -150,6 +150,10 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler { return } } + if strings.HasPrefix(r.URL.Path, "/api/v2/admin/users") && !principal.CanMenu("users") { + httpx.WriteError(w, http.StatusForbidden, "MENU_PERMISSION_DENIED", "当前账号无权访问账号管理", "users", requestTraceID(r)) + return + } required := requiredRole(r) if roleRank(principal.Role) < roleRank(required) { httpx.WriteError(w, http.StatusForbidden, "PERMISSION_DENIED", "当前角色无权执行该操作", "需要 "+required+" 角色", requestTraceID(r)) @@ -243,7 +247,7 @@ func requiredMenu(r *http.Request) string { switch { case strings.HasPrefix(path, "/api/v2/monitor"), path == "/api/v2/alerts/events": return "monitor" - case path == "/api/map/reverse-geocode", path == "/api/realtime/vehicles", path == "/api/realtime/locations", path == "/api/vehicle-service", path == "/api/vehicle-service/overview", strings.HasSuffix(path, "/telemetry/latest"): + case path == "/api/map/reverse-geocode", path == "/api/realtime/vehicles", path == "/api/realtime/locations", path == "/api/vehicle-service", path == "/api/vehicle-service/overview", path == "/api/vehicle-service/overviews", strings.HasSuffix(path, "/telemetry/latest"): return "shared" case path == "/api/v2/tracks": return "tracks" diff --git a/vehicle-data-platform/apps/api/internal/app/auth_store.go b/vehicle-data-platform/apps/api/internal/app/auth_store.go index 25fe1f66..1896c0dd 100644 --- a/vehicle-data-platform/apps/api/internal/app/auth_store.go +++ b/vehicle-data-platform/apps/api/internal/app/auth_store.go @@ -40,6 +40,11 @@ var customerMenuSet = map[string]bool{ var adminMenus = []string{"monitor", "vehicles", "tracks", "history", "statistics", "alerts", "access", "operations", "users"} +var adminMenuSet = map[string]bool{ + "monitor": true, "vehicles": true, "tracks": true, "history": true, "statistics": true, + "alerts": true, "access": true, "operations": true, "users": true, +} + type authUser struct { ID uint64 `json:"id"` Username string `json:"username"` @@ -551,14 +556,13 @@ func (s *authStore) localCredential(ctx context.Context, username string) (local } func (s *authStore) principalForUser(ctx context.Context, user authUser) (platform.Principal, error) { - menus := append([]string(nil), adminMenus...) + menus := []string{} vehicles := []string{} vehicleGrants := []platform.VehicleGrant{} businessScopeLevel := "" departmentIDs := []string{} responsibleUserID := "" - if user.UserType == "customer" { - menus = []string{} + if user.UserType == "customer" || user.UserType == "admin" { rows, err := s.db.QueryContext(ctx, `SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key`, user.ID) if err != nil { return platform.Principal{}, err @@ -569,14 +573,19 @@ func (s *authStore) principalForUser(ctx context.Context, user authUser) (platfo rows.Close() return platform.Principal{}, err } - if customerMenuSet[value] { + if (user.UserType == "customer" && customerMenuSet[value]) || (user.UserType == "admin" && adminMenuSet[value]) { menus = append(menus, value) } } if err := rows.Close(); err != nil { return platform.Principal{}, err } - rows, err = s.db.QueryContext(ctx, `SELECT vin,COALESCE(valid_from,granted_at),valid_to FROM platform_user_vehicle WHERE user_id=? AND (valid_from IS NULL OR valid_from<=NOW(3)) AND (valid_to IS NULL OR valid_to>NOW(3)) ORDER BY vin`, user.ID) + } + if user.UserType == "admin" && len(menus) == 0 { + menus = append([]string(nil), adminMenus...) + } + if user.UserType == "customer" { + rows, err := s.db.QueryContext(ctx, `SELECT vin,COALESCE(valid_from,granted_at),valid_to FROM platform_user_vehicle WHERE user_id=? AND (valid_from IS NULL OR valid_from<=NOW(3)) AND (valid_to IS NULL OR valid_to>NOW(3)) ORDER BY vin`, user.ID) if err != nil { return platform.Principal{}, err } diff --git a/vehicle-data-platform/apps/api/internal/app/auth_store_test.go b/vehicle-data-platform/apps/api/internal/app/auth_store_test.go index 0ca1585e..94d1bc0c 100644 --- a/vehicle-data-platform/apps/api/internal/app/auth_store_test.go +++ b/vehicle-data-platform/apps/api/internal/app/auth_store_test.go @@ -90,6 +90,30 @@ FROM platform_user_business_scope WHERE user_id=? AND enabled=1`)). } } +func TestPrincipalForAdminHonorsExplicitMenus(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + store := &authStore{db: db, cache: map[string]cachedSession{}} + mock.ExpectQuery(regexp.QuoteMeta(`SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key`)). + WithArgs(uint64(8)).WillReturnRows(sqlmock.NewRows([]string{"menu_key"}).AddRow("monitor").AddRow("operations")) + + principal, err := store.principalForUser(context.Background(), authUser{ + ID: 8, DisplayName: "业务管理", Username: "ln-bm", UserType: "admin", AuthProvider: "local", + }) + if err != nil { + t.Fatal(err) + } + if !principal.CanMenu("operations") || principal.CanMenu("users") { + t.Fatalf("restricted admin menus were not honored: %+v", principal.MenuKeys) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + func TestPrincipalForOneOSOrdinaryUserUsesResponsibleVehicles(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { diff --git a/vehicle-data-platform/apps/api/internal/app/auth_test.go b/vehicle-data-platform/apps/api/internal/app/auth_test.go index 0db1da93..364209bc 100644 --- a/vehicle-data-platform/apps/api/internal/app/auth_test.go +++ b/vehicle-data-platform/apps/api/internal/app/auth_test.go @@ -1,6 +1,7 @@ package app import ( + "crypto/sha256" "encoding/json" "net/http" "net/http/httptest" @@ -212,6 +213,39 @@ func TestAuthSelfServiceEndpointsAllowCustomerRole(t *testing.T) { } } +func TestRestrictedAdminCannotAccessAccountManagement(t *testing.T) { + token := "restricted-admin-token-at-least-16" + hash := sha256.Sum256([]byte(token)) + authenticator := &apiAuthenticator{ + mode: "enforce", + tokens: []tokenPrincipal{{ + hash: hash, + principal: platform.Principal{ + Name: "业务管理", Username: "ln-bm", Role: "admin", UserType: "admin", + MenuKeys: []string{"monitor", "vehicles", "tracks", "history", "statistics", "alerts", "access", "operations"}, + }, + }}, + } + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) + handler := authenticator.middleware(next) + + usersRequest := httptest.NewRequest(http.MethodGet, "/api/v2/admin/users", nil) + usersRequest.Header.Set("Authorization", "Bearer "+token) + usersResponse := httptest.NewRecorder() + handler.ServeHTTP(usersResponse, usersRequest) + if usersResponse.Code != http.StatusForbidden || !strings.Contains(usersResponse.Body.String(), "MENU_PERMISSION_DENIED") { + t.Fatalf("restricted admin account management status=%d body=%s", usersResponse.Code, usersResponse.Body.String()) + } + + operationsRequest := httptest.NewRequest(http.MethodGet, "/api/v2/operations/source-rules", nil) + operationsRequest.Header.Set("Authorization", "Bearer "+token) + operationsResponse := httptest.NewRecorder() + handler.ServeHTTP(operationsResponse, operationsRequest) + if operationsResponse.Code != http.StatusNoContent { + t.Fatalf("restricted admin should retain other admin permissions, status=%d", operationsResponse.Code) + } +} + func TestMileagePostQueriesAllowCustomerRole(t *testing.T) { for _, path := range []string{"/api/mileage/daily", "/api/v2/statistics/mileage"} { req := httptest.NewRequest(http.MethodPost, path, nil) @@ -224,6 +258,29 @@ func TestMileagePostQueriesAllowCustomerRole(t *testing.T) { } } +func TestBatchVehicleLookupAllowsEveryCustomerMenuScope(t *testing.T) { + const token = "customer-token-at-least-16" + hash := sha256.Sum256([]byte(token)) + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) + for _, menu := range customerMenuKeys { + t.Run(menu, func(t *testing.T) { + authenticator := &apiAuthenticator{mode: "enforce", tokens: []tokenPrincipal{{ + hash: hash, + principal: platform.Principal{ + Name: "客户甲", Role: "customer", UserType: "customer", MenuKeys: []string{menu}, + }, + }}} + req := httptest.NewRequest(http.MethodPost, "/api/vehicle-service/overviews", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + authenticator.middleware(next).ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("customer with %s menu should reach batch lookup, status=%d body=%s", menu, rec.Code, rec.Body.String()) + } + }) + } +} + func TestAPIAuthMisconfigurationFailsClosed(t *testing.T) { cases := []config.Config{ {AuthMode: "enforce"}, diff --git a/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml b/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml index f2439627..16590877 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml +++ b/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml @@ -620,6 +620,13 @@ components: format: double nullable: true description: 单日用氢量,kg;无数据时为 null + calculationPhase: + type: string + enum: [PRELIMINARY, FINAL] + description: 当天流式结果为 PRELIMINARY,日终重算结果为 FINAL + algorithmVersion: + type: string + description: 氢耗计算算法版本 status: $ref: '#/components/schemas/DataStatus' MileageResult: diff --git a/vehicle-data-platform/apps/api/internal/openplatform/model.go b/vehicle-data-platform/apps/api/internal/openplatform/model.go index 3f5f434b..8e35b82b 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/model.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/model.go @@ -49,6 +49,8 @@ type HydrogenResult struct { PlateNumber string `json:"plateNumber"` Date string `json:"date"` HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg"` + CalculationPhase string `json:"calculationPhase,omitempty"` + AlgorithmVersion string `json:"algorithmVersion,omitempty"` Status string `json:"status"` } @@ -281,11 +283,13 @@ type AppCredential struct { } type DailyHydrogen struct { - VIN string - Date string - ConsumptionKg float64 - SampleCount int - QualityStatus string + VIN string + Date string + ConsumptionKg float64 + SampleCount int + QualityStatus string + CalculationPhase string + AlgorithmVersion string } type DailyMileage struct { diff --git a/vehicle-data-platform/apps/api/internal/openplatform/mysql.go b/vehicle-data-platform/apps/api/internal/openplatform/mysql.go index 7d5186a7..3841aee9 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/mysql.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/mysql.go @@ -312,7 +312,8 @@ func (r *MySQLRepository) DailyHydrogen(ctx context.Context, vins []string, date return map[string]DailyHydrogen{}, nil } query, args := inQuery(` -SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),consumption_kg,sample_count,quality_status +SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),consumption_kg,sample_count,quality_status, + calculation_phase,algorithm_version FROM vehicle_open_daily_energy WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins) rows, err := r.db.QueryContext(ctx, query, args...) @@ -323,7 +324,7 @@ WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins) out := make(map[string]DailyHydrogen, len(vins)) for rows.Next() { var value DailyHydrogen - if err := rows.Scan(&value.VIN, &value.Date, &value.ConsumptionKg, &value.SampleCount, &value.QualityStatus); err != nil { + if err := rows.Scan(&value.VIN, &value.Date, &value.ConsumptionKg, &value.SampleCount, &value.QualityStatus, &value.CalculationPhase, &value.AlgorithmVersion); err != nil { return nil, err } out[value.VIN] = value diff --git a/vehicle-data-platform/apps/api/internal/openplatform/service.go b/vehicle-data-platform/apps/api/internal/openplatform/service.go index d322f882..c8dac96a 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/service.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/service.go @@ -391,10 +391,14 @@ func (s *Service) QueryHydrogen(ctx context.Context, appKey, traceID string, req // Sampling sufficiency is decided by the producer and persisted in // quality_status. Imported refuelling-ledger rows can be authoritative // with one transaction, while pressure-derived rows require two samples. - if value, ok := values[vehicle.VIN]; ok && strings.EqualFold(value.QualityStatus, "OK") { - consumption := round3(value.ConsumptionKg) - item.HydrogenConsumptionKg = &consumption - item.Status = StatusNormal + if value, ok := values[vehicle.VIN]; ok { + item.CalculationPhase = value.CalculationPhase + item.AlgorithmVersion = value.AlgorithmVersion + if strings.EqualFold(value.QualityStatus, "OK") { + consumption := round3(value.ConsumptionKg) + item.HydrogenConsumptionKg = &consumption + item.Status = StatusNormal + } } results = append(results, item) } diff --git a/vehicle-data-platform/apps/api/internal/openplatform/service_test.go b/vehicle-data-platform/apps/api/internal/openplatform/service_test.go index d029edcd..04ae6d95 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/service_test.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/service_test.go @@ -261,7 +261,7 @@ func TestExternalHydrogenAndMileageQueriesPreserveRequestedVehicles(t *testing.T "粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"}, }, hydrogen: map[string]DailyHydrogen{ - "LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", ConsumptionKg: 12.3154, SampleCount: 1, QualityStatus: "OK"}, + "LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", ConsumptionKg: 12.3154, SampleCount: 1, QualityStatus: "OK", CalculationPhase: "PRELIMINARY", AlgorithmVersion: trustedHydrogenAlgorithmVersion}, }, mileage: map[string]DailyMileage{ "LTEST32960VIN0001": { @@ -282,6 +282,9 @@ func TestExternalHydrogenAndMileageQueriesPreserveRequestedVehicles(t *testing.T if len(hydrogen) != 2 || hydrogen[0].HydrogenConsumptionKg == nil || *hydrogen[0].HydrogenConsumptionKg != 12.315 || hydrogen[1].Status != StatusNoData || hydrogen[1].HydrogenConsumptionKg != nil { t.Fatalf("hydrogen = %#v", hydrogen) } + if hydrogen[0].CalculationPhase != "PRELIMINARY" || hydrogen[0].AlgorithmVersion != trustedHydrogenAlgorithmVersion { + t.Fatalf("hydrogen calculation metadata = %#v", hydrogen[0]) + } mileage, err := service.QueryMileage(context.Background(), key, "trace-m", request) if err != nil { t.Fatal(err) diff --git a/vehicle-data-platform/apps/api/internal/openplatform/statistics.go b/vehicle-data-platform/apps/api/internal/openplatform/statistics.go index 7fec3c28..52be7966 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/statistics.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/statistics.go @@ -387,6 +387,33 @@ func LoadHydrogenCapacities(ctx context.Context, db *sql.DB) (map[string]float64 return capacities, rows.Err() } +func LoadHydrogenBrandVINs(ctx context.Context, db *sql.DB, brand string) ([]string, error) { + brand = strings.TrimSpace(brand) + if brand == "" { + return nil, errors.New("hydrogen vehicle brand is required") + } + rows, err := db.QueryContext(ctx, `SELECT UPPER(TRIM(vin)) +FROM vehicle_profile +WHERE TRIM(brand_name)=? +ORDER BY vin`, brand) + if err != nil { + return nil, err + } + defer rows.Close() + vins := make([]string, 0) + for rows.Next() { + var vin string + if err := rows.Scan(&vin); err != nil { + return nil, err + } + vin = strings.ToUpper(strings.TrimSpace(vin)) + if validHydrogenVIN(vin) { + vins = append(vins, vin) + } + } + return vins, rows.Err() +} + func LoadHydrogenCalculationParameters(ctx context.Context, db *sql.DB, date time.Time) (map[string]HydrogenCalculationParameters, error) { rows, err := db.QueryContext(ctx, `SELECT UPPER(TRIM(vin)),battery_capacity_kwh,hydrogen_energy_kwh_per_kg FROM vehicle_hydrogen_energy_parameter @@ -811,7 +838,7 @@ func PressureHydrogenMassKg(pressureMPa, temperatureC, capacityLiter float64) (f } func ReplaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, stats []HydrogenDailyStat) error { - return replaceHydrogenDailyStats(ctx, db, date, "", stats, false) + return replaceHydrogenDailyStats(ctx, db, date, nil, stats, false) } // ReplaceHydrogenDailyStatsAndSeedStream performs the current-day deployment @@ -819,7 +846,7 @@ func ReplaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, sta // included in the rebuild, so Kafka backlog already covered by the rebuild is // ignored instead of counted twice. func ReplaceHydrogenDailyStatsAndSeedStream(ctx context.Context, db *sql.DB, date string, stats []HydrogenDailyStat) error { - return replaceHydrogenDailyStats(ctx, db, date, "", stats, true) + return replaceHydrogenDailyStats(ctx, db, date, nil, stats, true) } func ReplaceHydrogenDailyStatsForVIN(ctx context.Context, db *sql.DB, date, vin string, stats []HydrogenDailyStat) error { @@ -827,10 +854,30 @@ func ReplaceHydrogenDailyStatsForVIN(ctx context.Context, db *sql.DB, date, vin if !validHydrogenVIN(vin) { return fmt.Errorf("invalid VIN %q", vin) } - return replaceHydrogenDailyStats(ctx, db, date, vin, stats, false) + return replaceHydrogenDailyStats(ctx, db, date, []string{vin}, stats, false) } -func replaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date, vin string, stats []HydrogenDailyStat, seedStream bool) error { +func ReplaceHydrogenDailyStatsForVINs(ctx context.Context, db *sql.DB, date string, vins []string, stats []HydrogenDailyStat) error { + normalized := make([]string, 0, len(vins)) + seen := make(map[string]struct{}, len(vins)) + for _, vin := range vins { + vin = strings.ToUpper(strings.TrimSpace(vin)) + if !validHydrogenVIN(vin) { + return fmt.Errorf("invalid VIN %q", vin) + } + if _, exists := seen[vin]; exists { + continue + } + seen[vin] = struct{}{} + normalized = append(normalized, vin) + } + if len(normalized) == 0 { + return errors.New("at least one scoped VIN is required") + } + return replaceHydrogenDailyStats(ctx, db, date, normalized, stats, false) +} + +func replaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, scopedVINs []string, stats []HydrogenDailyStat, seedStream bool) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return err @@ -839,12 +886,26 @@ func replaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date, vin string // A pressure-based rebuild is authoritative for the whole day. Delete every // previous hydrogen row first so legacy rate/direct-mass results cannot remain // for vehicles without valid pressure observations in this run. - if vin == "" { + allowedVINs := make(map[string]struct{}, len(scopedVINs)) + for _, vin := range scopedVINs { + allowedVINs[vin] = struct{}{} + } + if len(scopedVINs) == 0 { if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN'`, date); err != nil { return err } + } else if len(scopedVINs) == 1 { + if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN' AND vin=?`, date, scopedVINs[0]); err != nil { + return err + } } else { - if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN' AND vin=?`, date, vin); err != nil { + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(scopedVINs)), ",") + args := make([]any, 0, len(scopedVINs)+1) + args = append(args, date) + for _, vin := range scopedVINs { + args = append(args, vin) + } + if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN' AND vin IN (`+placeholders+`)`, args...); err != nil { return err } } @@ -854,8 +915,11 @@ func replaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date, vin string } } for _, stat := range stats { - if vin != "" && !strings.EqualFold(strings.TrimSpace(stat.VIN), vin) { - return fmt.Errorf("refusing to write VIN %q during scoped rebuild for %q", stat.VIN, vin) + statVIN := strings.ToUpper(strings.TrimSpace(stat.VIN)) + if len(allowedVINs) > 0 { + if _, allowed := allowedVINs[statVIN]; !allowed { + return fmt.Errorf("refusing to write VIN %q outside scoped rebuild", stat.VIN) + } } parameterJSON, err := json.Marshal(stat.CalculationParameters) if err != nil { @@ -872,8 +936,8 @@ INSERT INTO vehicle_open_daily_energy( raw_consumption_kg,battery_soc_delta_pct,battery_discharge_kwh,battery_equivalent_kg, soc_balanced_consumption_kg,mixed_mileage_km,pure_electric_mileage_km, consumption_kg_per_100km,soc_balanced_kg_per_100km,charge_count,valid_segment_count, - invalid_segment_count,algorithm_version,parameter_json,evidence_json - ) VALUES(?,?,'HYDROGEN',?,?,'kg',?,?,?,?,?,?,NOW(3),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + invalid_segment_count,algorithm_version,calculation_phase,parameter_json,evidence_json + ) VALUES(?,?,'HYDROGEN',?,?,'kg',?,?,?,?,?,?,NOW(3),?,?,?,?,?,?,?,?,?,?,?,?,?,'FINAL',?,?)`, stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.FirstMassKg, stat.LastMassKg, stat.SampleCount, stat.RefuelCount, stat.QualityStatus, stat.QualityReason, stat.ConsumptionKg, stat.BatterySOCDeltaPct, stat.BatteryDischargeKWh, stat.BatteryEquivalentKg, @@ -883,7 +947,10 @@ INSERT INTO vehicle_open_daily_energy( ); err != nil { return err } - if seedStream { + // A NO_DATA row can legitimately have no effective boundary observation. + // Keep its daily result, but do not write a zero timestamp into the stream + // watermark table; the next valid realtime frame will create the state. + if seedStream && !stat.LastObservation.ObservedAt.IsZero() { stateJSON, err := hydrogenSegmentStreamSeedJSON(stat) if err != nil { return err @@ -893,10 +960,10 @@ INSERT INTO vehicle_open_hydrogen_segment_stream_state( vin,stat_date,source_endpoint,finalized_consumption_kg,projected_consumption_kg, sample_count,refuel_count,abnormal_drop_count,eligible_interval_count,qualified_segment_count, last_mass_kg,last_event_time,last_event_id,state_json,calculation_method,quality_status,quality_reason -) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'PRESSURE_NIST_SEGMENT_MEDIAN_5',?,?)`, +) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5',?,?)`, stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.ConsumptionKg, stat.SampleCount, stat.RefuelCount, stat.AbnormalDropCount, stat.EligibleIntervalCount, - stat.QualifiedSegmentCount, stat.LastMassKg, stat.LastObservation.ObservedAt, "", + stat.QualifiedSegmentCount, stat.LastMassKg, stat.LastObservation.ObservedAt, stat.LastObservation.EventID, stateJSON, stat.QualityStatus, stat.QualityReason, ); err != nil { return err @@ -907,54 +974,113 @@ INSERT INTO vehicle_open_hydrogen_segment_stream_state( } func hydrogenSegmentStreamSeedJSON(stat HydrogenDailyStat) ([]byte, error) { - type segmentWindow struct { - Count int `json:"count"` - First []any `json:"first"` - Tail []any `json:"tail"` - CycleMinimumMassKg float64 `json:"cycleMinimumMassKg"` + type point struct { + EventID string `json:"eventId"` + ObservedAt time.Time `json:"observedAt"` + MassKg float64 `json:"massKg"` + PressureMpa float64 `json:"pressureMpa"` + TemperatureC float64 `json:"temperatureC"` + NoiseKg float64 `json:"noiseKg"` + MileageKm float64 `json:"mileageKm"` + MileageKnown bool `json:"mileageKnown"` + SocPercent float64 `json:"socPercent"` + SocKnown bool `json:"socKnown"` + RunningMode int `json:"runningMode"` + FuelCellActive bool `json:"fuelCellActive"` + FuelCellStateKnown bool `json:"fuelCellStateKnown"` + FuelCellPowerKw float64 `json:"fuelCellPowerKw"` + FuelCellPowerKnown bool `json:"fuelCellPowerKnown"` + } + toPoint := func(value HydrogenObservation) point { + return point{ + EventID: value.EventID, ObservedAt: value.ObservedAt, MassKg: value.MassKg, + PressureMpa: value.PressureMPa, TemperatureC: value.TemperatureC, NoiseKg: value.NoiseKg, + MileageKm: value.MileageKm, MileageKnown: value.MileageKnown, + SocPercent: value.SOCPercent, SocKnown: value.SOCKnown, RunningMode: value.RunningMode, + FuelCellActive: value.FuelCellActive, FuelCellStateKnown: value.FuelCellStateKnown, + FuelCellPowerKw: value.FuelCellVoltageV * value.FuelCellCurrentA / 1000, + FuelCellPowerKnown: value.FuelCellPowerKnown, + } + } + first, last := toPoint(stat.FirstObservation), toPoint(stat.LastObservation) + lastIntervalType := "" + mixedCycles := 0 + for _, interval := range stat.Intervals { + if interval.Type == "MIXED" { + mixedCycles++ + } + lastIntervalType = interval.Type + } + cycle := map[string]any{ + "first": last, "last": last, "hasRun": !last.ObservedAt.IsZero(), + "startsPure": lastIntervalType == "PURE_ELECTRIC", "mixedLocked": lastIntervalType == "MIXED", + "mixedConfirmed": lastIntervalType == "MIXED", "mixedStart": last, } state := struct { - SourceEndpoint string `json:"sourceEndpoint"` - FinalizedConsumptionKg float64 `json:"finalizedConsumptionKg"` - SampleCount int `json:"sampleCount"` - RefuelCount int `json:"refuelCount"` - AbnormalDropCount int `json:"abnormalDropCount"` - EligibleIntervalCount int `json:"eligibleIntervalCount"` - QualifiedSegmentCount int `json:"qualifiedSegmentCount"` - FirstMassKg float64 `json:"firstMassKg"` - RemainingMassKg float64 `json:"remainingMassKg"` - MetadataCycleMinimumKg float64 `json:"metadataCycleMinimumKg"` - LastObservedMassKg float64 `json:"lastObservedMassKg"` - TankCapacityLiters float64 `json:"tankCapacityLiters"` - FirstPressureMpa float64 `json:"firstPressureMpa"` - LastPressureMpa float64 `json:"lastPressureMpa"` - FirstTemperatureC float64 `json:"firstTemperatureC"` - LastTemperatureC float64 `json:"lastTemperatureC"` - FirstEventTime time.Time `json:"firstEventTime"` - LastEventTime time.Time `json:"lastEventTime"` - LastEventID string `json:"lastEventId"` - LastFuelCellActive bool `json:"lastFuelCellActive"` - LastFuelCellStateKnown bool `json:"lastFuelCellStateKnown"` - Segment segmentWindow `json:"segment"` + AlgorithmVersion string `json:"algorithmVersion"` + SourceEndpoint string `json:"sourceEndpoint"` + SampleCount int `json:"sampleCount"` + LastEventID string `json:"lastEventId"` + LastEventTime time.Time `json:"lastEventTime"` + Parameters map[string]float64 `json:"parameters"` + FirstMassKg float64 `json:"firstMassKg"` + LastMassKg float64 `json:"lastMassKg"` + FirstEffective point `json:"firstEffective"` + LastEffective point `json:"lastEffective"` + HasEffective bool `json:"hasEffective"` + EffectiveRunCount int `json:"effectiveRunCount"` + Cycle map[string]any `json:"cycle"` + InitialStateUsed bool `json:"initialStateUsed"` + ChargeCount int `json:"chargeCount"` + ChargeEnergyKWh float64 `json:"chargeEnergyKWh"` + CompletedPureKm float64 `json:"completedPureKm"` + CompletedMixedKm float64 `json:"completedMixedKm"` + CompletedSOCDelta float64 `json:"completedSocDelta"` + CompletedMixedCycles int `json:"completedMixedCycles"` + HydrogenSegmentStart point `json:"hydrogenSegmentStart"` + LastHydrogenRun point `json:"lastHydrogenRun"` + HasHydrogenSegment bool `json:"hasHydrogenSegment"` + HydrogenSegmentRuns int `json:"hydrogenSegmentRuns"` + FinalizedHydrogenKg float64 `json:"finalizedHydrogenKg"` + HydrogenSegments int `json:"hydrogenSegments"` + RefuelCount int `json:"refuelCount"` + RefuelAmountKg float64 `json:"refuelAmountKg"` + MinimumPressureMpa float64 `json:"minimumPressureMpa"` + MinimumMassKg float64 `json:"minimumMassKg"` + MinimumObservedAt time.Time `json:"minimumObservedAt"` + PreviousSample point `json:"previousSample"` + HasPreviousSample bool `json:"hasPreviousSample"` + AbnormalDropCount int `json:"abnormalDropCount"` + InvalidSampleCount int `json:"invalidSampleCount"` + SuspectedLeakCount int `json:"suspectedLeakCount"` + SuspectedLeakMaxMpa float64 `json:"suspectedLeakMaxMpa"` }{ - SourceEndpoint: stat.Source, FinalizedConsumptionKg: stat.ConsumptionKg, - SampleCount: stat.SampleCount, RefuelCount: stat.RefuelCount, - AbnormalDropCount: stat.AbnormalDropCount, EligibleIntervalCount: stat.EligibleIntervalCount, - QualifiedSegmentCount: stat.QualifiedSegmentCount, - FirstMassKg: stat.FirstMassKg, RemainingMassKg: stat.LastMassKg, - MetadataCycleMinimumKg: stat.CycleMinimumMassKg, - LastObservedMassKg: stat.LastObservation.MassKg, - TankCapacityLiters: stat.LastObservation.TankCapacityLiter, - FirstPressureMpa: stat.FirstObservation.PressureMPa, LastPressureMpa: stat.LastObservation.PressureMPa, - FirstTemperatureC: stat.FirstObservation.TemperatureC, LastTemperatureC: stat.LastObservation.TemperatureC, - FirstEventTime: stat.FirstObservation.ObservedAt, LastEventTime: stat.LastObservation.ObservedAt, - LastFuelCellActive: stat.LastObservation.FuelCellActive, - LastFuelCellStateKnown: stat.LastObservation.FuelCellStateKnown, - Segment: segmentWindow{First: []any{}, Tail: []any{}}, + AlgorithmVersion: trustedHydrogenAlgorithmVersion, SourceEndpoint: stat.Source, + SampleCount: stat.SampleCount, LastEventID: stat.LastObservation.EventID, LastEventTime: stat.LastObservation.ObservedAt, + Parameters: map[string]float64{"batteryCapacityKWh": stat.CalculationParameters.BatteryCapacityKWh, "hydrogenEnergyKWhPerKg": stat.CalculationParameters.HydrogenEnergyKWhKg}, + FirstMassKg: stat.FirstMassKg, LastMassKg: stat.LastMassKg, + FirstEffective: first, LastEffective: last, HasEffective: !last.ObservedAt.IsZero(), EffectiveRunCount: stat.EligibleIntervalCount, + Cycle: cycle, InitialStateUsed: true, ChargeCount: stat.ChargeCount, ChargeEnergyKWh: stat.ChargeEnergyKWh, + CompletedPureKm: stat.PureElectricMileageKm, CompletedMixedKm: stat.MixedMileageKm, + CompletedSOCDelta: valueOrZero(stat.BatterySOCDeltaPct), CompletedMixedCycles: mixedCycles, + HydrogenSegmentStart: last, LastHydrogenRun: last, HasHydrogenSegment: !last.ObservedAt.IsZero(), HydrogenSegmentRuns: 1, + FinalizedHydrogenKg: stat.ConsumptionKg, HydrogenSegments: stat.QualifiedSegmentCount, + RefuelCount: stat.RefuelCount, RefuelAmountKg: stat.RefuelAmountKg, + MinimumPressureMpa: last.PressureMpa, MinimumMassKg: last.MassKg, MinimumObservedAt: last.ObservedAt, + PreviousSample: last, HasPreviousSample: !last.ObservedAt.IsZero(), + AbnormalDropCount: stat.AbnormalDropCount, InvalidSampleCount: stat.InvalidSegmentCount, + SuspectedLeakCount: stat.SuspectedLeakCount, SuspectedLeakMaxMpa: stat.SuspectedLeakMaxPressureDropMPa, } return json.Marshal(state) } +func valueOrZero(value *float64) float64 { + if value == nil { + return 0 + } + return *value +} + func numericValue(value any) (float64, bool) { switch typed := value.(type) { case float64: diff --git a/vehicle-data-platform/apps/api/internal/openplatform/statistics_test.go b/vehicle-data-platform/apps/api/internal/openplatform/statistics_test.go index 46ccc35a..c58d6cee 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/statistics_test.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/statistics_test.go @@ -301,6 +301,26 @@ func TestLoadHydrogenObservationVINsFiltersMissingCapacity(t *testing.T) { } } +func TestLoadHydrogenBrandVINsUsesExactBrandAndValidVINs(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + mock.ExpectQuery(regexp.QuoteMeta("SELECT UPPER(TRIM(vin))")). + WithArgs("现代"). + WillReturnRows(sqlmock.NewRows([]string{"vin"}). + AddRow("lb9a32a25r0ls1452"). + AddRow("invalid")) + vins, err := LoadHydrogenBrandVINs(context.Background(), db, " 现代 ") + if err != nil || len(vins) != 1 || vins[0] != "LB9A32A25R0LS1452" { + t.Fatalf("vins=%v err=%v", vins, err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + func TestHydrogenObservationDateRangeForAllUsesBoundedQueries(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { @@ -351,6 +371,37 @@ func TestReplaceHydrogenDailyStatsForVINDeletesOnlyScopedVehicle(t *testing.T) { } } +func TestReplaceHydrogenDailyStatsForVINsDeletesOnlyScopedBrandVehicles(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + vins := []string{"LB9A32A24R0LS1376", "LB9A32A25R0LS1452"} + stat := HydrogenDailyStat{ + VIN: vins[0], Date: "2026-08-12", Source: "source-a", ConsumptionKg: 2.5, + FirstMassKg: 12, LastMassKg: 9.5, SampleCount: 500, QualityStatus: "OK", + } + mock.ExpectBegin() + mock.ExpectExec(regexp.QuoteMeta("DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN' AND vin IN (?,?)")). + WithArgs(stat.Date, vins[0], vins[1]). + WillReturnResult(sqlmock.NewResult(0, 2)) + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy(")). + WithArgs(stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.FirstMassKg, stat.LastMassKg, + stat.SampleCount, 0, stat.QualityStatus, "", stat.ConsumptionKg, + sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), 0.0, 0.0, + sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 0, 0, "", sqlmock.AnyArg(), sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + if err := ReplaceHydrogenDailyStatsForVINs(context.Background(), db, stat.Date, vins, []HydrogenDailyStat{stat}); err != nil { + t.Fatal(err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + func TestReplaceHydrogenDailyStatsAndSeedStreamIsAtomic(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { @@ -396,7 +447,37 @@ func TestReplaceHydrogenDailyStatsAndSeedStreamIsAtomic(t *testing.T) { if err := json.Unmarshal(encoded, &state); err != nil { t.Fatal(err) } - if state["lastObservedMassKg"] != 9.4 || state["finalizedConsumptionKg"] != 2.6 || state["lastEventTime"] == "" { + if state["lastMassKg"] != 9.4 || state["finalizedHydrogenKg"] != 2.6 || state["lastEventTime"] == "" || state["algorithmVersion"] != trustedHydrogenAlgorithmVersion { t.Fatalf("seed state=%s", encoded) } } + +func TestReplaceHydrogenDailyStatsAndSeedStreamSkipsMissingWatermark(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + stat := HydrogenDailyStat{ + VIN: "LB9A32A24R0LS1376", Date: "2026-09-04", Source: "source-a", + QualityStatus: "NO_DATA", QualityReason: "有效运行分界点不足2条", + } + mock.ExpectBegin() + mock.ExpectExec(regexp.QuoteMeta("DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN'")). + WithArgs("2026-09-04").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(regexp.QuoteMeta("DELETE FROM vehicle_open_hydrogen_segment_stream_state WHERE stat_date=?")). + WithArgs("2026-09-04").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy(")). + WithArgs(stat.VIN, stat.Date, stat.Source, 0.0, 0.0, 0.0, 0, 0, stat.QualityStatus, stat.QualityReason, + 0.0, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), 0.0, 0.0, + sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 0, 0, "", sqlmock.AnyArg(), sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + if err := ReplaceHydrogenDailyStatsAndSeedStream(context.Background(), db, stat.Date, []HydrogenDailyStat{stat}); err != nil { + t.Fatal(err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/vehicle-data-platform/apps/api/internal/platform/handler_test.go b/vehicle-data-platform/apps/api/internal/platform/handler_test.go index bf5611bd..00756a79 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -441,7 +441,7 @@ func TestHandlerVehicleCoverage(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) } - for _, want := range []string{"sourceCount", "onlineSourceCount", "protocols", "LB9A32A24R0LS1426"} { + for _, want := range []string{"sourceCount", "onlineSourceCount", "protocols", "LB9A32A24R0LS1426", `"brandName":"飞驰"`, `"modelName":"新能源运营车"`} { if !strings.Contains(rec.Body.String(), want) { t.Fatalf("response missing %q: %s", want, rec.Body.String()) } diff --git a/vehicle-data-platform/apps/api/internal/platform/mock_store.go b/vehicle-data-platform/apps/api/internal/platform/mock_store.go index 5a4257ce..25ab54da 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -776,6 +776,8 @@ func (m *MockStore) vehicleRowServiceStatus(row VehicleRow) *VehicleServiceStatu } func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[VehicleCoverageRow], error) { + m.profileMu.RLock() + defer m.profileMu.RUnlock() vehicles := filterVehicles(m.vehicles, query) byVIN := map[string]*VehicleCoverageRow{} for _, vehicle := range vehicles { @@ -805,6 +807,12 @@ func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[V } items := make([]VehicleCoverageRow, 0, len(byVIN)) for _, row := range byVIN { + if profile, ok := m.profiles[row.VIN]; ok { + row.BrandName = firstNonEmpty(profile.BrandName, row.OEM) + row.ModelName = profile.ModelName + } else { + row.BrandName = row.OEM + } row.MissingProtocols = missingCanonicalProtocols(row.Protocols) onlineProtocols := make([]string, 0, row.OnlineSourceCount) for _, vehicle := range vehicles { @@ -1230,8 +1238,24 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V func (m *MockStore) VehicleServiceOverviews(_ context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) { items := make([]VehicleServiceOverview, 0, len(query.Keywords)) + var allowedVINs map[string]bool + if query.ScopeVINs != nil { + allowedVINs = make(map[string]bool, len(query.ScopeVINs)) + for _, vin := range query.ScopeVINs { + allowedVINs[strings.ToUpper(strings.TrimSpace(vin))] = true + } + } for _, keyword := range normalizedKeywords(query.Keywords) { vehicles := m.vehiclesForKeyword(keyword, query.Protocol) + if allowedVINs != nil { + scoped := vehicles[:0] + for _, vehicle := range vehicles { + if allowedVINs[strings.ToUpper(strings.TrimSpace(vehicle.VIN))] { + scoped = append(scoped, vehicle) + } + } + vehicles = scoped + } resolution := buildVehicleIdentityResolution(keyword, vehicles) identity := resolveVehicleIdentity(keyword, vehicles) resolvedVIN := "" @@ -1241,7 +1265,11 @@ func (m *MockStore) VehicleServiceOverviews(_ context.Context, query VehicleOver resolvedVIN = resolution.VIN } if resolvedVIN == "" { - items = append(items, *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{})) + missing := *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{}) + if query.ScopeVINs != nil { + missing.VIN = "" + } + items = append(items, missing) continue } summary := m.realtimeSummaryForVIN(resolvedVIN, query.Protocol) diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index 9f76c870..23494b33 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -1351,6 +1351,8 @@ type VehicleCoverageRow struct { Plate string `json:"plate"` Phone string `json:"phone"` OEM string `json:"oem"` + BrandName string `json:"brandName"` + ModelName string `json:"modelName"` Protocols []string `json:"protocols"` MissingProtocols []string `json:"missingProtocols"` SourceStatus []VehicleSourceStatus `json:"sourceStatus"` diff --git a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go index 7ab924b6..c8ced69e 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go @@ -159,6 +159,7 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery { } groupSQL := `FROM (` + vehicleSetSQL + `) v ` + `LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin ` + + `LEFT JOIN vehicle_profile p ON p.vin = v.vin ` + `LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin ` + `LEFT JOIN business_scope_state bst ON bst.id = 1 ` + `LEFT JOIN business_customer_vehicle_scope bs ON BINARY bs.source_version = BINARY bst.active_version AND BINARY bs.vin = BINARY v.vin ` + @@ -169,6 +170,8 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery { Text: `SELECT v.vin, ` + `COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), b.plate, '') AS plate, ` + `COALESCE(b.phone, '') AS phone, COALESCE(b.oem, '') AS oem, ` + + `COALESCE(NULLIF(MAX(NULLIF(p.brand_name, '')), ''), NULLIF(b.oem, ''), '') AS brand_name, ` + + `COALESCE(MAX(p.model_name), '') AS model_name, ` + `COALESCE(GROUP_CONCAT(DISTINCT s.protocol ORDER BY s.protocol SEPARATOR ','), '') AS protocols, ` + `COALESCE(GROUP_CONCAT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END ORDER BY s.protocol SEPARATOR ','), '') AS online_protocols, ` + `COUNT(DISTINCT s.protocol) AS source_count, ` + diff --git a/vehicle-data-platform/apps/api/internal/platform/principal.go b/vehicle-data-platform/apps/api/internal/platform/principal.go index 86da3f6f..9591926b 100644 --- a/vehicle-data-platform/apps/api/internal/platform/principal.go +++ b/vehicle-data-platform/apps/api/internal/platform/principal.go @@ -41,14 +41,17 @@ func (p Principal) Clone() Principal { } func (p Principal) CanMenu(key string) bool { - if p.UserType == "admin" || p.Role == "admin" { - return true - } for _, value := range p.MenuKeys { if value == key { return true } } + // Legacy admin principals did not carry an explicit menu list. Keep those + // principals unrestricted while allowing persisted admin menus to remove a + // sensitive surface such as account management. + if len(p.MenuKeys) == 0 && (p.UserType == "admin" || p.Role == "admin") { + return true + } return false } diff --git a/vehicle-data-platform/apps/api/internal/platform/production_store.go b/vehicle-data-platform/apps/api/internal/platform/production_store.go index 2563b67f..64d94e8d 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -321,7 +321,7 @@ func (s *ProductionStore) VehicleCoverage(ctx context.Context, query url.Values) var protocols string var onlineProtocols string var online int - if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &protocols, &onlineProtocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.LastSeen, &row.BindingStatus); err != nil { + if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &row.BrandName, &row.ModelName, &protocols, &onlineProtocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.LastSeen, &row.BindingStatus); err != nil { return Page[VehicleCoverageRow]{}, err } row.Protocols = splitCSV(protocols) @@ -503,7 +503,11 @@ func (s *ProductionStore) VehicleServiceOverviews(ctx context.Context, query Veh continue } resolution := VehicleIdentityResolution{LookupKey: keyword, Protocols: []string{}} - items = append(items, *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{})) + missing := *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{}) + if query.ScopeVINs != nil { + missing.VIN = "" + } + items = append(items, missing) } return Page[VehicleServiceOverview]{Items: items, Total: len(items), Limit: query.Limit, Offset: query.Offset}, nil } @@ -518,6 +522,18 @@ func buildVehicleServiceOverviewBatchSQL(query VehicleOverviewBatchQuery) SQLQue protocolJoin = " AND s.protocol = ? " args = append(args, protocol) } + scopeWhere := "" + if query.ScopeVINs != nil { + scopeVINs := normalizedKeywords(query.ScopeVINs) + if len(scopeVINs) == 0 { + scopeWhere = " AND 1 = 0 " + } else { + scopeWhere = " AND i.vin IN (" + strings.TrimSuffix(strings.Repeat("?,", len(scopeVINs)), ",") + ") " + for _, vin := range scopeVINs { + args = append(args, strings.ToUpper(vin)) + } + } + } return SQLQuery{ Text: `SELECT i.vin, COALESCE(MAX(NULLIF(i.plate, '')), '') AS plate, ` + `COALESCE(MAX(NULLIF(i.phone, '')), '') AS phone, COALESCE(MAX(NULLIF(i.oem, '')), '') AS oem, ` + @@ -534,7 +550,7 @@ func buildVehicleServiceOverviewBatchSQL(query VehicleOverviewBatchQuery) SQLQue `WHERE ` + realtimeWhere + ` ` + `GROUP BY s.vin, b.plate, b.phone, b.oem` + `) i LEFT JOIN vehicle_realtime_snapshot s ON s.vin = i.vin ` + protocolJoin + - `WHERE i.vin IS NOT NULL AND i.vin <> '' GROUP BY i.vin`, + `WHERE i.vin IS NOT NULL AND i.vin <> ''` + scopeWhere + `GROUP BY i.vin`, Args: args, } } diff --git a/vehicle-data-platform/apps/api/internal/platform/production_store_test.go b/vehicle-data-platform/apps/api/internal/platform/production_store_test.go index ec51878b..a149114a 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store_test.go @@ -143,6 +143,23 @@ func TestBuildVehicleServiceOverviewBatchSQLUsesFuzzyKeywordMatching(t *testing. } } +func TestBuildVehicleServiceOverviewBatchSQLRestrictsCustomerVINScope(t *testing.T) { + built := buildVehicleServiceOverviewBatchSQL(VehicleOverviewBatchQuery{ + Keywords: []string{"粤A"}, ScopeVINs: []string{"vin001", "VIN002"}, + }) + if !strings.Contains(built.Text, "i.vin IN (?,?)") { + t.Fatalf("batch overview SQL should restrict results to granted VINs, got %s", built.Text) + } + if got := built.Args[len(built.Args)-2:]; got[0] != "VIN001" || got[1] != "VIN002" { + t.Fatalf("customer VIN scope should be normalized and bound last, got %#v", built.Args) + } + + empty := buildVehicleServiceOverviewBatchSQL(VehicleOverviewBatchQuery{Keywords: []string{"粤A"}, ScopeVINs: []string{}}) + if !strings.Contains(empty.Text, "AND 1 = 0") { + t.Fatalf("empty customer VIN scope must fail closed, got %s", empty.Text) + } +} + func TestHydrogenRatePer100KmUsesMatchedPureHydrogenMileage(t *testing.T) { rate := hydrogenRatePer100Km(7.3, 193.3, 2) if rate == nil || *rate < 3.77 || *rate > 3.78 { diff --git a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go index 6a255212..bfe19add 100644 --- a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go @@ -77,7 +77,7 @@ func TestBuildVehicleListSQLFiltersServiceStatus(t *testing.T) { func TestBuildVehicleCoverageSQL(t *testing.T) { query := url.Values{"keyword": {"粤A"}, "protocol": {"GB32960"}, "coverage": {"multi"}, "online": {"online"}, "bindingStatus": {"bound"}, "limit": {"8"}, "offset": {"16"}} built := buildVehicleCoverageSQL(query) - for _, want := range []string{"GROUP BY v.vin", "HAVING", "GROUP_CONCAT(DISTINCT s.protocol", "source_count", "online_source_count", "ORDER BY MAX(s.updated_at) DESC, v.vin ASC"} { + for _, want := range []string{"LEFT JOIN vehicle_profile p ON p.vin = v.vin", "brand_name", "model_name", "GROUP BY v.vin", "HAVING", "GROUP_CONCAT(DISTINCT s.protocol", "source_count", "online_source_count", "ORDER BY MAX(s.updated_at) DESC, v.vin ASC"} { if !strings.Contains(built.Text, want) { t.Fatalf("SQL missing %q: %s", want, built.Text) } diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index 75da363e..7def51f8 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -91,10 +91,11 @@ type RawFrameQuery struct { } type VehicleOverviewBatchQuery struct { - Keywords []string `json:"keywords"` - Protocol string `json:"protocol"` - Limit int `json:"limit"` - Offset int `json:"offset"` + Keywords []string `json:"keywords"` + Protocol string `json:"protocol"` + Limit int `json:"limit"` + Offset int `json:"offset"` + ScopeVINs []string `json:"-"` } type Service struct { @@ -869,10 +870,12 @@ func (s *Service) VehicleServiceOverview(ctx context.Context, keyword string, pr } } if batchStore, ok := s.store.(VehicleOverviewBatchStore); ok { + scopeVINs := principalVehicleVINScope(ctx) page, err := batchStore.VehicleServiceOverviews(ctx, VehicleOverviewBatchQuery{ - Keywords: []string{keyword}, - Protocol: protocol, - Limit: 1, + Keywords: []string{keyword}, + Protocol: protocol, + Limit: 1, + ScopeVINs: scopeVINs, }) if err != nil { return VehicleServiceOverview{}, err @@ -927,11 +930,13 @@ func (s *Service) VehicleServiceOverviews(ctx context.Context, query VehicleOver query.Keywords = keywords query.Limit = limit query.Offset = offset + query.ScopeVINs = principalVehicleVINScope(ctx) if batchStore, ok := s.store.(VehicleOverviewBatchStore); ok { page, err := batchStore.VehicleServiceOverviews(ctx, query) if err != nil { return Page[VehicleServiceOverview]{}, err } + page = restrictVehicleOverviewPage(ctx, page) page.Total = total page.Limit = limit page.Offset = offset @@ -948,6 +953,34 @@ func (s *Service) VehicleServiceOverviews(ctx context.Context, query VehicleOver return Page[VehicleServiceOverview]{Items: items, Total: total, Limit: limit, Offset: offset}, nil } +func principalVehicleVINScope(ctx context.Context) []string { + principal, ok := PrincipalFromContext(ctx) + if !ok || principal.UserType != "customer" { + return nil + } + scope := make([]string, 0, len(principal.VehicleVINs)) + for _, vin := range principal.VehicleVINs { + if vin = strings.ToUpper(strings.TrimSpace(vin)); vin != "" { + scope = append(scope, vin) + } + } + return scope +} + +func restrictVehicleOverviewPage(ctx context.Context, page Page[VehicleServiceOverview]) Page[VehicleServiceOverview] { + principal, ok := PrincipalFromContext(ctx) + if !ok || principal.UserType != "customer" { + return page + } + for index := range page.Items { + if principal.CanVIN(strings.ToUpper(strings.TrimSpace(page.Items[index].VIN))) { + continue + } + page.Items[index] = *buildVehicleServiceOverview("", "", &VehicleIdentityResolution{Protocols: []string{}}, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{}) + } + return page +} + func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string) (VehicleDetail, error) { keyword := strings.TrimSpace(vin) protocol = strings.TrimSpace(protocol) diff --git a/vehicle-data-platform/apps/api/internal/platform/service_test.go b/vehicle-data-platform/apps/api/internal/platform/service_test.go index e0b2b4d5..8607a179 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/service_test.go @@ -547,6 +547,7 @@ type countingStore struct { vehiclesCalls int vehicleRealtimeCalls int overviewBatchCalls int + lastOverviewBatchQuery VehicleOverviewBatchQuery lastVehicleQuery url.Values lastRealtimeQuery url.Values lastHistoryQuery url.Values @@ -1010,6 +1011,7 @@ func TestVehicleSourcePolicyUpdateRequiresAdminAndUsesOptimisticVersion(t *testi func (s *countingStore) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) { s.overviewBatchCalls++ + s.lastOverviewBatchQuery = query return s.MockStore.VehicleServiceOverviews(ctx, query) } @@ -1046,6 +1048,26 @@ func TestVehicleServiceOverviewsUsesBatchDataPath(t *testing.T) { } } +func TestCustomerVehicleServiceOverviewsStayInsideGrantedVINScope(t *testing.T) { + store := newCountingStore() + service := NewService(store) + ctx := WithPrincipal(context.Background(), Principal{ + Name: "客户甲", Role: "customer", UserType: "customer", VehicleVINs: []string{"LB9A32A24R0LS1426"}, + }) + page, err := service.VehicleServiceOverviews(ctx, VehicleOverviewBatchQuery{ + Keywords: []string{"粤AG18312", "LMRKH9AC2R1004087"}, Limit: 200, + }) + if err != nil { + t.Fatalf("customer batch lookup returned error: %v", err) + } + if len(store.lastOverviewBatchQuery.ScopeVINs) != 1 || store.lastOverviewBatchQuery.ScopeVINs[0] != "LB9A32A24R0LS1426" { + t.Fatalf("customer grant scope was not passed to batch store: %+v", store.lastOverviewBatchQuery.ScopeVINs) + } + if page.Total != 2 || len(page.Items) != 2 || page.Items[0].VIN != "LB9A32A24R0LS1426" || page.Items[1].VIN != "" { + t.Fatalf("batch lookup must preserve input order without exposing ungranted vehicles: %+v", page) + } +} + func TestVehicleServiceSummaryCountsProtocolOnlineByProtocolSlot(t *testing.T) { service := NewService(NewMockStore()) summary, err := service.VehicleServiceSummary(context.Background()) diff --git a/vehicle-data-platform/apps/api/internal/static/static.go b/vehicle-data-platform/apps/api/internal/static/static.go index 15c1bb2a..88289fef 100644 --- a/vehicle-data-platform/apps/api/internal/static/static.go +++ b/vehicle-data-platform/apps/api/internal/static/static.go @@ -19,9 +19,21 @@ func Handler(dir string, fallback http.Handler) http.Handler { } path := filepath.Join(dir, filepath.Clean(r.URL.Path)) if info, err := os.Stat(path); err == nil && !info.IsDir() { + setCachePolicy(w, r.URL.Path) fs.ServeHTTP(w, r) return } + w.Header().Set("Cache-Control", "no-store") http.ServeFile(w, r, filepath.Join(dir, "index.html")) }) } + +func setCachePolicy(w http.ResponseWriter, path string) { + if path == "/" || path == "/index.html" || path == "/app-config.js" { + w.Header().Set("Cache-Control", "no-store") + return + } + if strings.HasPrefix(path, "/assets/") { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } +} diff --git a/vehicle-data-platform/apps/api/internal/static/static_test.go b/vehicle-data-platform/apps/api/internal/static/static_test.go new file mode 100644 index 00000000..92437429 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/static/static_test.go @@ -0,0 +1,45 @@ +package static + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestHandlerDisablesCachingForApplicationShell(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("index"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "app-config.js"), []byte("config"), 0o600); err != nil { + t.Fatal(err) + } + + handler := Handler(dir, http.NotFoundHandler()) + for _, path := range []string{"/", "/index.html", "/app-config.js", "/users"} { + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil)) + if got := response.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("%s Cache-Control=%q, want no-store", path, got) + } + } +} + +func TestHandlerCachesHashedAssetsImmutably(t *testing.T) { + dir := t.TempDir() + assets := filepath.Join(dir, "assets") + if err := os.Mkdir(assets, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(assets, "app-1234.js"), []byte("app"), 0o600); err != nil { + t.Fatal(err) + } + + response := httptest.NewRecorder() + Handler(dir, http.NotFoundHandler()).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/assets/app-1234.js", nil)) + if got := response.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" { + t.Fatalf("asset Cache-Control=%q", got) + } +} diff --git a/vehicle-data-platform/apps/open-portal/src/Documentation.tsx b/vehicle-data-platform/apps/open-portal/src/Documentation.tsx index fd9098a6..db232010 100644 --- a/vehicle-data-platform/apps/open-portal/src/Documentation.tsx +++ b/vehicle-data-platform/apps/open-portal/src/Documentation.tsx @@ -136,7 +136,7 @@ function LimitsGuide() { function APIReference({ kind }: { kind: "hydrogen" | "mileage" }) { const product = products[kind === "hydrogen" ? 0 : 1]; - const fields = kind === "hydrogen" ? `"hydrogenConsumptionKg": 12.315` : `"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"` : `"dailyMileageKm": 182.437,\n "totalMileageKm": 12345.679,\n "dataTime": "2026-07-19T23:58:45+08:00",\n "updatedAt": "2026-07-20T05:10:00+08:00",\n "sourceProtocol": "GB32960"`; return <>

{product.name}

{product.description} 查询范围同时受 AppKey、车辆授权和授权有效期约束。

@@ -150,7 +150,7 @@ function APIReference({ kind }: { kind: "hydrogen" | "mileage" }) { "protocolPriority": ["JT808", "GB32960", "MQTT"]` : ""} }`} /> {kind === "mileage" && <>

协议优先级

protocolPriority 只允许 GB32960MQTTJT808。数组必须非空且不能重复;逐车按顺序选择第一个有效协议,未列出的协议完全禁用。不传时保持平台默认行为。

缺日补齐

查询日没有有效里程但此前存在有效累计里程时,dailyMileageKm 返回 0,累计总里程、来源协议和数据时间沿用最近有效统计;updatedAt 保持为上一个统计周期的计算时间。此前也无有效累计里程时才返回 NO_DATA。

} -

响应字段

{kind === "mileage" && }{kind === "mileage" && <>}
字段类型说明
vinstring车辆 VIN
plateNumberstring请求中的车牌号
datestring统计自然日
{kind === "hydrogen" ? "hydrogenConsumptionKg" : "dailyMileageKm"}number | null{kind === "hydrogen" ? "用氢量,单位 kg" : "当日行驶里程,单位 km"}
totalMileageKmnumber | null同一协议在当日最后有效时刻的累计总里程
dataTimedate-time | null统计实际采用的最后一条车辆源数据时间
updatedAtdate-time | null日统计投影最后更新时间
sourceProtocolstring | null实际选中的 GB32960、MQTT 或 JT808;NO_DATA 时为 null
statusstringNORMAL 或 NO_DATA
+

响应字段

{kind === "mileage" && }{kind === "hydrogen" && <>}{kind === "mileage" && <>}
字段类型说明
vinstring车辆 VIN
plateNumberstring请求中的车牌号
datestring统计自然日
{kind === "hydrogen" ? "hydrogenConsumptionKg" : "dailyMileageKm"}number | null{kind === "hydrogen" ? "用氢量,单位 kg" : "当日行驶里程,单位 km"}
calculationPhasestring当天实时暂估为 PRELIMINARY,日终重算为 FINAL
algorithmVersionstring氢耗计算算法版本
totalMileageKmnumber | null同一协议在当日最后有效时刻的累计总里程
dataTimedate-time | null统计实际采用的最后一条车辆源数据时间
updatedAtdate-time | null日统计投影最后更新时间
sourceProtocolstring | null实际选中的 GB32960、MQTT 或 JT808;NO_DATA 时为 null
statusstringNORMAL 或 NO_DATA

响应示例

request>('/api/mileage/daily', withSignal({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query) }, signal)), + hydrogenDailyEvidence: (vin: string, date: string, signal?: AbortSignal) => request( + `/api/v2/vehicles/${encodeURIComponent(vin)}/hydrogen-evidence?date=${encodeURIComponent(date)}`, + withSignal(undefined, signal) + ), mileageStatistics: (query: MileageQuery, signal?: AbortSignal) => request('/api/v2/statistics/mileage', withSignal({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query) }, signal)), diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index 947c336f..e676d5c7 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -470,6 +470,8 @@ export interface VehicleCoverageRow { plate: string; phone: string; oem: string; + brandName?: string; + modelName?: string; protocols: string[]; missingProtocols: string[]; sourceStatus: VehicleSourceStatus[]; @@ -908,10 +910,40 @@ export interface DailyMileageRow { pureHydrogenMileageKm?: number; hydrogenConsumptionKg?: number | null; hydrogenConsumptionKgPer100Km?: number | null; + hydrogenSocBalancedKg?: number | null; + hydrogenSocBalancedKgPer100Km?: number | null; + hydrogenEvidenceAvailable?: boolean; + hydrogenQualityStatus?: string; + hydrogenAlgorithmVersion?: string; source: string; anomalySeverity?: string; } +export interface HydrogenIntervalEvidence { + index: number; type: 'MIXED' | 'PURE_ELECTRIC'; startTime: string; endTime: string; + startEventId: string; endEventId: string; source: string; + startPressureMpa: number; endPressureMpa: number; startTemperatureC: number; endTemperatureC: number; + startMassKg: number; endMassKg: number; rawHydrogenConsumptionKg: number; + startSocPercent?: number; endSocPercent?: number; batteryDischargeKWh?: number; batteryEquivalentKg?: number; + socBalancedConsumptionKg?: number; startMileageKm?: number; endMileageKm?: number; mileageKm?: number; + consumptionKgPer100Km?: number; sampleCount: number; qualityStatus: string; qualityReason: string; +} + +export interface HydrogenDailyEvidence { + vin: string; plate: string; date: string; source: string; rawConsumptionKg: number; + batterySocDeltaPct?: number; batteryDischargeKWh?: number; batteryEquivalentKg?: number; + socBalancedConsumptionKg?: number; mixedMileageKm: number; pureElectricMileageKm: number; + consumptionKgPer100Km?: number; socBalancedKgPer100Km?: number; sampleCount: number; + refuelCount: number; chargeCount: number; validSegmentCount: number; invalidSegmentCount: number; + qualityStatus: string; qualityReason: string; algorithmVersion: string; calculatedAt: string; + parameters: { + batteryCapacityKWh: number; hydrogenEnergyKWhPerKg: number; powerOnDelaySeconds: number; + powerOffLeadSeconds: number; refuelRiseMpa: number; refuelSustainSeconds: number; + pureElectricDropMpa: number; pureElectricWindowSeconds: number; algorithmVersion: string; + }; + intervals: HydrogenIntervalEvidence[]; +} + export interface MileageSummary { vehicleCount: number; recordCount: number; diff --git a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx index 58525bd0..4e85cd5a 100644 --- a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx +++ b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx @@ -261,6 +261,7 @@ export function VehicleDetail({ const displayLookupKey = resolution?.lookupKey || detail?.lookupKey || query.keyword; const protocols = useMemo(() => resolution?.protocols?.length ? resolution.protocols : detail?.sources ?? [], [detail?.sources, resolution?.protocols]); const latestRaw = detail?.raw?.items?.[0]; + const latestRawType = latestRaw?.frameType === 'realtime_projection' ? '最新状态投影' : latestRaw?.frameType; const qualityCount = detail?.quality?.total ?? 0; const priorityQualityIssue = useMemo(() => { const items = detail?.quality?.items ?? []; @@ -617,7 +618,7 @@ export function VehicleDetail({ { label: '历史数据', value: formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0), - detail: latestRaw?.frameType || '解析字段', + detail: latestRawType || '解析字段', color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const }, { @@ -766,7 +767,7 @@ export function VehicleDetail({ { title: '历史数据', value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 帧`, - meta: latestRaw?.frameType || '字段明细', + meta: latestRawType || '字段明细', color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const, detail: '查看历史明细和字段明细,用于复核定位、里程和告警依据。', disabled: !hasResolvedVIN, @@ -863,7 +864,7 @@ export function VehicleDetail({ }, { title: '查询历史数据', - evidence: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 条历史数据,最新类型 ${latestRaw?.frameType || '-'}`, + evidence: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 条历史数据,最新类型 ${latestRawType || '-'}`, acceptance: '关键字段来自解析字段而不是页面二次推断。', action: '历史数据', disabled: !hasResolvedVIN, @@ -938,7 +939,7 @@ export function VehicleDetail({ { title: '查询历史数据', value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 帧`, - detail: latestRaw?.frameType ? `最新历史数据类型 ${latestRaw.frameType},用于核对解析字段。` : '按车辆和数据通道查询历史明细与解析字段。', + detail: latestRawType ? `最新历史数据类型 ${latestRawType},用于核对解析字段。` : '按车辆和数据通道查询历史明细与解析字段。', color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const, primaryAction: '历史数据', secondaryAction: '导出档案', @@ -991,7 +992,7 @@ export function VehicleDetail({ { title: '历史数据查询', value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 帧`, - detail: latestRaw?.frameType ? `最新 ${latestRaw.frameType}` : 'RAW 与解析字段证据', + detail: latestRawType ? `最新 ${latestRawType}` : 'RAW 与解析字段证据', color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const, action: '查询历史', disabled: !hasResolvedVIN, @@ -1047,7 +1048,7 @@ export function VehicleDetail({ { title: '数据导出', value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 帧`, - detail: latestRaw?.frameType ? `最新 ${latestRaw.frameType}` : '历史明细与字段', + detail: latestRawType ? `最新 ${latestRawType}` : '历史明细与字段', action: '历史导出', color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const, disabled: !hasResolvedVIN, @@ -1085,7 +1086,7 @@ export function VehicleDetail({ { title: '历史证据', value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 帧`, - detail: latestRaw?.frameType ? `最新 ${latestRaw.frameType}` : '历史明细与解析字段', + detail: latestRawType ? `最新 ${latestRawType}` : '历史明细与解析字段', action: '历史查询', color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const, disabled: !hasResolvedVIN, @@ -2175,7 +2176,7 @@ export function VehicleDetail({ dataSource={detail?.raw?.items ?? []} columns={[ { title: '来源', dataIndex: 'protocol', width: 130 }, - { title: '帧类型', dataIndex: 'frameType', width: 190 }, + { title: '帧类型', dataIndex: 'frameType', width: 190, render: (value: string) => value === 'realtime_projection' ? '最新状态投影' : value }, { title: '大小 B', dataIndex: 'rawSizeBytes', width: 100 }, { title: '设备时间', dataIndex: 'deviceTime', width: 190 }, { title: '入库时间', dataIndex: 'serverTime', width: 190 } diff --git a/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.test.tsx b/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.test.tsx index 0b79ed8c..729d8d70 100644 --- a/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.test.tsx @@ -75,6 +75,28 @@ test('presents failed credentials as a clear Semi UI alert without losing the lo expect(screen.getByPlaceholderText('请输入用户名')).toBeInTheDocument(); }); +test('does not flash the anonymous session error while a login request is pending', async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + let resolveLogin!: (value: { accessToken: string; expiresAt: string; session: { name: string; role: string } }) => void; + mocks.session.mockImplementation(async () => { + if (getAccessToken() === 'authenticated-token') return { name: 'ln-bm', role: 'admin', authMode: 'enforce' }; + throw new Error('需要有效的访问令牌'); + }); + mocks.login.mockReturnValue(new Promise((resolve) => { resolveLogin = resolve; })); + + render(
已进入车辆数据中台
); + fireEvent.change(await screen.findByPlaceholderText('请输入用户名'), { target: { value: 'ln-bm' } }); + fireEvent.change(screen.getByPlaceholderText('请输入密码'), { target: { value: 'StrongPass!1' } }); + fireEvent.click(screen.getByRole('button', { name: '登录工作台' })); + + expect(screen.getByRole('button', { name: '正在登录…' })).toBeDisabled(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + resolveLogin({ accessToken: 'authenticated-token', expiresAt: new Date().toISOString(), session: { name: 'ln-bm', role: 'admin' } }); + + expect(await screen.findByText('已进入车辆数据中台')).toBeInTheDocument(); + expect(screen.queryByText('需要有效的访问令牌')).not.toBeInTheDocument(); +}); + test('treats login and logout as complete query and mutation cache boundaries', async () => { mocks.logout.mockResolvedValue({ loggedOut: true }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); diff --git a/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.tsx b/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.tsx index ae5a392f..d18c201d 100644 --- a/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.tsx +++ b/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.tsx @@ -192,7 +192,7 @@ export function AuthGate({ children }: { children: ReactNode }) { return ; } if (!session.data) { - const error = loginError || (attempted && session.error && !getAccessToken() ? session.error.message : ''); + const error = loginError || (!loginPending && attempted && session.error && !getAccessToken() ? session.error.message : ''); return
diff --git a/vehicle-data-platform/apps/web/src/v2/auth/session.test.ts b/vehicle-data-platform/apps/web/src/v2/auth/session.test.ts index f543088f..ca62307f 100644 --- a/vehicle-data-platform/apps/web/src/v2/auth/session.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/auth/session.test.ts @@ -1,5 +1,15 @@ import { afterEach, expect, test, vi } from 'vitest'; -import { canAdminister, canOperate, clearAccessToken, getAccessToken, notifyUnauthorizedSession, PLATFORM_UNAUTHORIZED_EVENT, setAccessToken } from './session'; +import { + canAdminister, + canOperate, + clearAccessToken, + getAccessToken, + hasMenu, + notifyUnauthorizedSession, + PLATFORM_UNAUTHORIZED_EVENT, + setAccessToken, + type PlatformSession, +} from './session'; afterEach(() => { window.sessionStorage.clear(); @@ -21,6 +31,17 @@ test('role helpers follow the server permission hierarchy', () => { expect(canAdminister({ name: 'a', role: 'admin', authMode: 'enforce' })).toBe(true); }); +test('an administrator can have account management removed explicitly', () => { + const session = { + role: 'admin', + userType: 'admin', + menuKeys: ['monitor', 'operations'], + } as PlatformSession; + + expect(hasMenu(session, 'operations')).toBe(true); + expect(hasMenu(session, 'users')).toBe(false); +}); + test('invalidates only the currently active rejected token', () => { const unauthorized = vi.fn(); window.addEventListener(PLATFORM_UNAUTHORIZED_EVENT, unauthorized); diff --git a/vehicle-data-platform/apps/web/src/v2/auth/session.ts b/vehicle-data-platform/apps/web/src/v2/auth/session.ts index cccd46e7..8b6a54ec 100644 --- a/vehicle-data-platform/apps/web/src/v2/auth/session.ts +++ b/vehicle-data-platform/apps/web/src/v2/auth/session.ts @@ -29,9 +29,8 @@ export function canOperate(session: PlatformSession) { } export function hasMenu(session: PlatformSession, menu: string) { - if (session.role === 'admin') return true; - if (!session.menuKeys) return session.role !== 'customer'; - return session.menuKeys.includes(menu); + if (session.menuKeys) return session.menuKeys.includes(menu); + return session.role !== 'customer'; } export function canAdminister(session: PlatformSession) { diff --git a/vehicle-data-platform/apps/web/src/v2/domain/mileageExport.test.ts b/vehicle-data-platform/apps/web/src/v2/domain/mileageExport.test.ts index cbdcdfd5..1b7da0a0 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/mileageExport.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/mileageExport.test.ts @@ -10,8 +10,8 @@ test('creates a styled numeric mileage workbook with formulas and frozen panes', dateTo: '2026-07-14', dates: ['2026-07-13', '2026-07-14'], vehicles: [ - { vin: 'LTEST000000000001', plate: '粤A12345' }, - { vin: 'LTEST000000000002', plate: '粤A54321' } + { vin: 'LTEST000000000001', plate: '粤A12345', brandName: '现代', modelName: 'XCIENT' }, + { vin: 'LTEST000000000002', plate: '粤A54321', brandName: '飞驰', modelName: 'FSQ' } ], 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' }, @@ -26,25 +26,29 @@ test('creates a styled numeric mileage workbook with formulas and frozen panes', const sheet = workbook.getWorksheet('里程查询')!; expect(sheet.getCell('A1').value).toBe('车辆里程查询明细'); - expect(sheet.getCell('C6').value).toBeInstanceOf(Date); - expect(sheet.getCell('C6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'center' }); - expect(sheet.getCell('D6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'center' }); + expect(sheet.getCell('C6').value).toBe('品牌'); + expect(sheet.getCell('D6').value).toBe('车型'); + expect(sheet.getCell('E6').value).toBeInstanceOf(Date); + expect(sheet.getCell('E6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'center' }); + expect(sheet.getCell('F6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'center' }); expect(sheet.getCell('A6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'left' }); - expect(sheet.getCell('E6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'right' }); - expect(sheet.getCell('C7').value).toBe(88.7); - expect(sheet.getCell('E7').value).toMatchObject({ formula: 'SUM(C7:D7)', result: 193.3 }); - expect(sheet.getCell('E7').numFmt).toBe('#,##0.0" km"'); - expect(sheet.views[0]).toMatchObject({ state: 'frozen', xSplit: 2, ySplit: 6, showGridLines: false }); - expect(sheet.autoFilter).toEqual({ from: { row: 6, column: 1 }, to: { row: 8, column: 5 } }); + expect(sheet.getCell('G6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'right' }); + expect(sheet.getCell('C7').value).toBe('现代'); + expect(sheet.getCell('D7').value).toBe('XCIENT'); + expect(sheet.getCell('E7').value).toBe(88.7); + expect(sheet.getCell('G7').value).toMatchObject({ formula: 'SUM(E7:F7)', result: 193.3 }); + expect(sheet.getCell('G7').numFmt).toBe('#,##0.0" km"'); + expect(sheet.views[0]).toMatchObject({ state: 'frozen', xSplit: 4, ySplit: 6, showGridLines: false }); + expect(sheet.autoFilter).toEqual({ from: { row: 6, column: 1 }, to: { row: 8, column: 7 } }); expect((sheet as unknown as { conditionalFormattings: unknown[] }).conditionalFormattings).toHaveLength(1); const hydrogenSheet = workbook.getWorksheet('氢能明细')!; expect(hydrogenSheet.getRow(1).values).toEqual([ - undefined, '日期', '车牌', 'VIN', '总里程 (km)', '纯氢里程 (km)', '耗氢 (kg)', '百公里纯氢耗 (kg/100km)', '来源' + undefined, '日期', '车牌', 'VIN', '品牌', '车型', '总里程 (km)', '纯氢里程 (km)', '耗氢 (kg)', '百公里纯氢耗 (kg/100km)', '来源' ]); expect(hydrogenSheet.getRow(2).values).toEqual([ - undefined, '2026-07-13', '粤A12345', 'LTEST000000000001', 88.7, 56.2, 3.1, 5.5, 'GB32960' + undefined, '2026-07-13', '粤A12345', 'LTEST000000000001', '现代', 'XCIENT', 88.7, 56.2, 3.1, 5.5, 'GB32960' ]); - expect(hydrogenSheet.autoFilter).toEqual({ from: { row: 1, column: 1 }, to: { row: 3, column: 8 } }); + expect(hydrogenSheet.autoFilter).toEqual({ from: { row: 1, column: 1 }, to: { row: 3, column: 10 } }); expect((await workbook.xlsx.writeBuffer()).byteLength).toBeGreaterThan(5_000); }); diff --git a/vehicle-data-platform/apps/web/src/v2/domain/mileageExport.ts b/vehicle-data-platform/apps/web/src/v2/domain/mileageExport.ts index 142386dc..6293a208 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/mileageExport.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/mileageExport.ts @@ -1,7 +1,7 @@ import type { DailyMileageRow } from '../../api/types'; import { downloadBlob } from './download'; -export type MileageExportVehicle = { vin: string; plate: string }; +export type MileageExportVehicle = { vin: string; plate: string; brandName?: string; modelName?: string }; export type MileageExportSource = { protocol: string; label: string; mileageType: string }; export type MileageWorkbookRow = Pick & Partial>; diff --git a/vehicle-data-platform/apps/web/src/v2/domain/mileageWorkbook.ts b/vehicle-data-platform/apps/web/src/v2/domain/mileageWorkbook.ts index fc3cddde..95dfb157 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/mileageWorkbook.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/mileageWorkbook.ts @@ -27,11 +27,11 @@ export async function createMileageWorkbook(input: MileageExportInput) { workbook.calcProperties.fullCalcOnLoad = true; const sheet = workbook.addWorksheet('里程查询', { - views: [{ state: 'frozen', xSplit: 2, ySplit: 6, activeCell: 'C7', showGridLines: false }], + views: [{ state: 'frozen', xSplit: 4, ySplit: 6, activeCell: 'E7', showGridLines: false }], pageSetup: { orientation: 'landscape', fitToPage: true, fitToWidth: 1, fitToHeight: 0, paperSize: 9, margins: { left: .25, right: .25, top: .45, bottom: .45, header: .2, footer: .2 } }, properties: { defaultRowHeight: 21 } }); - const firstDateColumn = 3; + const firstDateColumn = 5; const lastDateColumn = firstDateColumn + input.dates.length - 1; const totalColumn = lastDateColumn + 1; const lastColumnLetter = excelColumn(totalColumn); @@ -72,14 +72,14 @@ export async function createMileageWorkbook(input: MileageExportInput) { sheet.getRow(5).height = 8; const header = sheet.getRow(headerRowNumber); - header.values = ['车牌', 'VIN', ...input.dates.map((date) => new Date(`${date}T12:00:00Z`)), '区间总里程']; + header.values = ['车牌', 'VIN', '品牌', '车型', ...input.dates.map((date) => new Date(`${date}T12:00:00Z`)), '区间总里程']; header.height = 30; header.eachCell((cell, columnNumber) => { const isDateHeader = columnNumber >= firstDateColumn && columnNumber <= lastDateColumn; cell.font = { name: 'Microsoft YaHei', size: 10, bold: true, color: { argb: columnNumber === totalColumn ? 'FF1C4F91' : 'FF304158' } }; cell.alignment = { vertical: 'middle', - horizontal: columnNumber <= 2 ? 'left' : isDateHeader ? 'center' : 'right' + horizontal: columnNumber <= 4 ? 'left' : isDateHeader ? 'center' : 'right' }; cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: columnNumber === totalColumn ? 'FFDCEBFF' : 'FFEAF0F7' } }; cell.border = { bottom: { style: 'medium', color: { argb: 'FFC3D0DF' } } }; @@ -97,11 +97,11 @@ export async function createMileageWorkbook(input: MileageExportInput) { const dailyValues = input.dates.map((date) => mileageByDate.get(date)?.dailyMileageKm ?? null); const total = dailyValues.reduce((sum, value) => sum + (value ?? 0), 0); const row = sheet.getRow(rowNumber); - row.values = [vehicle.plate || '未绑定', vehicle.vin, ...dailyValues, null]; + row.values = [vehicle.plate || '未绑定', vehicle.vin, vehicle.brandName || '待维护', vehicle.modelName || '待维护', ...dailyValues, null]; row.height = 25; row.eachCell({ includeEmpty: true }, (cell, columnNumber) => { cell.font = { name: columnNumber === 2 ? 'Consolas' : 'Microsoft YaHei', size: columnNumber === 2 ? 9 : 10, color: { argb: columnNumber === totalColumn ? 'FF1D4E89' : 'FF34445A' }, bold: columnNumber === 1 || columnNumber === totalColumn }; - cell.alignment = { vertical: 'middle', horizontal: columnNumber <= 2 ? 'left' : 'right' }; + cell.alignment = { vertical: 'middle', horizontal: columnNumber <= 4 ? 'left' : 'right' }; cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFF9FBFD' : 'FFFFFFFF' } }; cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } }; if (columnNumber >= firstDateColumn) cell.numFmt = '#,##0.0" km"'; @@ -116,6 +116,8 @@ export async function createMileageWorkbook(input: MileageExportInput) { sheet.getColumn(1).width = 15; sheet.getColumn(2).width = 24; + sheet.getColumn(3).width = 18; + sheet.getColumn(4).width = 24; for (let column = firstDateColumn; column <= lastDateColumn; column += 1) sheet.getColumn(column).width = 12; sheet.getColumn(totalColumn).width = 17; if (input.vehicles.length) { @@ -135,7 +137,7 @@ export async function createMileageWorkbook(input: MileageExportInput) { views: [{ state: 'frozen', ySplit: 1, activeCell: 'A2', showGridLines: false }], properties: { defaultRowHeight: 21 } }); - hydrogenSheet.addRow(['日期', '车牌', 'VIN', '总里程 (km)', '纯氢里程 (km)', '耗氢 (kg)', '百公里纯氢耗 (kg/100km)', '来源']); + hydrogenSheet.addRow(['日期', '车牌', 'VIN', '品牌', '车型', '总里程 (km)', '纯氢里程 (km)', '耗氢 (kg)', '百公里纯氢耗 (kg/100km)', '来源']); const hydrogenHeader = hydrogenSheet.getRow(1); hydrogenHeader.height = 30; hydrogenHeader.eachCell((cell) => { @@ -144,13 +146,16 @@ export async function createMileageWorkbook(input: MileageExportInput) { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEAF0F7' } }; cell.border = { bottom: { style: 'medium', color: { argb: 'FFC3D0DF' } } }; }); - const plateByVIN = new Map(input.vehicles.map((vehicle) => [vehicle.vin, vehicle.plate || '未绑定'])); + const vehicleByVIN = new Map(input.vehicles.map((vehicle) => [vehicle.vin, vehicle])); const energyRows = [...input.mileageRows].sort((left, right) => left.date.localeCompare(right.date) || left.vin.localeCompare(right.vin)); energyRows.forEach((value, index) => { + const vehicle = vehicleByVIN.get(value.vin); const row = hydrogenSheet.addRow([ value.date, - plateByVIN.get(value.vin) ?? value.plate ?? '未绑定', + vehicle?.plate || value.plate || '未绑定', value.vin, + vehicle?.brandName || '待维护', + vehicle?.modelName || '待维护', value.dailyMileageKm, value.pureHydrogenMileageKm ?? null, value.hydrogenConsumptionKg ?? null, @@ -160,17 +165,17 @@ export async function createMileageWorkbook(input: MileageExportInput) { row.height = 24; row.eachCell({ includeEmpty: true }, (cell, columnNumber) => { cell.font = { name: columnNumber === 3 ? 'Consolas' : 'Microsoft YaHei', size: columnNumber === 3 ? 9 : 10, color: { argb: 'FF34445A' } }; - cell.alignment = { vertical: 'middle', horizontal: columnNumber >= 4 && columnNumber <= 7 ? 'right' : 'left' }; + cell.alignment = { vertical: 'middle', horizontal: columnNumber >= 6 && columnNumber <= 9 ? 'right' : 'left' }; cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFF9FBFD' : 'FFFFFFFF' } }; cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } }; - if (columnNumber >= 4 && columnNumber <= 7) cell.numFmt = '#,##0.0'; + if (columnNumber >= 6 && columnNumber <= 9) cell.numFmt = '#,##0.0'; }); }); - [13, 15, 24, 15, 17, 14, 27, 16].forEach((width, index) => { + [13, 15, 24, 18, 24, 15, 17, 14, 27, 16].forEach((width, index) => { hydrogenSheet.getColumn(index + 1).width = width; }); if (energyRows.length) { - hydrogenSheet.autoFilter = { from: { row: 1, column: 1 }, to: { row: energyRows.length + 1, column: 8 } }; + hydrogenSheet.autoFilter = { from: { row: 1, column: 1 }, to: { row: energyRows.length + 1, column: 10 } }; } hydrogenSheet.headerFooter.oddFooter = sheet.headerFooter.oddFooter; return workbook; diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.test.tsx index cbf7843e..494b7366 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.test.tsx @@ -136,6 +136,23 @@ test('summarizes only material automation changes before release', () => { { key: 'status', label: '发布状态', before: '启用', after: '停用' } ]); expect(automationReleaseChanges(baseline, baseline)).toEqual([]); + + const pressureBaseline = { + ...baseline, + id: 'hydrogen-pressure-rule', + name: '氢系统压力过低', + metric: 'hydrogen_pressure_mpa', + operator: 'lt', + threshold: 2, + recoveryOperator: 'gt', + recoveryThreshold: 3, + description: '' + }; + const pressureDraft = { ...pressureBaseline, recoveryThreshold: 3.5, description: '压力恢复后自动关闭事件' }; + expect(automationReleaseChanges(pressureBaseline, pressureDraft, { hydrogen_pressure_mpa: '最高氢气压力' }, { hydrogen_pressure_mpa: 'MPa' })).toEqual([ + { key: 'recovery', label: '恢复条件', before: '> 3 MPa', after: '> 3.5 MPa' }, + { key: 'description', label: '规则说明', before: '未填写', after: '压力恢复后自动关闭事件' } + ]); }); test('preserves an unsubmitted event filter draft across alert tab URL changes', async () => { @@ -207,7 +224,7 @@ test('keeps creation contextual to the automation workspace', async () => { const editor = await screen.findByRole('dialog', { name: '事件自动化编辑' }); expect(editor.closest('.v2-alert-rule-editor-dialog')).toBeInTheDocument(); expect(screen.getByRole('tab', { name: /自动化/ })).toHaveAttribute('aria-selected', 'true'); - expect(screen.getByRole('navigation', { name: '自动化编辑步骤' })).toHaveTextContent(/事件与条件.*车辆范围.*执行动作.*测试并发布/); + expect(screen.getByRole('navigation', { name: '自动化编辑步骤' })).toHaveTextContent(/事件与条件.*车辆范围.*执行动作.*检查并发布/); expect(screen.getByLabelText('自动化实时摘要')).toBeInTheDocument(); const nameInput = screen.getByLabelText('规则名称'); const thresholdInput = screen.getByLabelText('触发阈值'); @@ -972,7 +989,7 @@ test('opens a lightweight mobile rule detail before editing in a Semi bottom Sid expect(document.querySelector('.v2-alert-rule-editor-sidesheet')).toHaveClass('semi-sidesheet-bottom', 'v2-workspace-editor-sidesheet'); expect(document.querySelector('.v2-alert-rule-editor-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(96dvh, 920px)' }); expect(within(editor).getByDisplayValue('测试超速规则')).toBeInTheDocument(); - expect(within(editor).getByRole('navigation', { name: '自动化编辑步骤' })).toHaveTextContent(/事件与条件.*车辆范围.*执行动作.*测试并发布/); + expect(within(editor).getByRole('navigation', { name: '自动化编辑步骤' })).toHaveTextContent(/事件与条件.*车辆范围.*执行动作.*检查并发布/); expect(within(editor).getByRole('heading', { name: '什么时候触发' })).toBeInTheDocument(); expect(within(editor).queryByRole('heading', { name: '哪些车辆生效' })).not.toBeInTheDocument(); expect(within(editor).queryByRole('heading', { name: '系统自动做什么' })).not.toBeInTheDocument(); @@ -981,15 +998,15 @@ test('opens a lightweight mobile rule detail before editing in a Semi bottom Sid expect(within(editor).getByRole('heading', { name: '哪些车辆生效' })).toBeInTheDocument(); fireEvent.click(within(editor).getByRole('button', { name: '下一步:执行动作' })); expect(within(editor).getByRole('heading', { name: '系统自动做什么' })).toBeInTheDocument(); - fireEvent.click(within(editor).getByRole('button', { name: '下一步:测试规则' })); - expect(within(editor).getByRole('region', { name: '样例事件测试' })).toBeInTheDocument(); + fireEvent.click(within(editor).getByRole('button', { name: '下一步:检查发布' })); + expect(within(editor).getByRole('region', { name: /样例事件测试/ })).toBeInTheDocument(); expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled(); fireEvent.click(within(editor).getByRole('button', { name: '运行测试' })); expect(await within(editor).findByText(/测试成功/)).toBeInTheDocument(); expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled(); fireEvent.click(within(editor).getByRole('button', { name: '编辑事件来源' })); fireEvent.change(within(editor).getByDisplayValue('测试超速规则'), { target: { value: '测试超速规则 v4' } }); - fireEvent.click(within(editor).getByRole('button', { name: /测试并发布/ })); + fireEvent.click(within(editor).getByRole('button', { name: /检查并发布/ })); fireEvent.click(within(editor).getByRole('button', { name: '运行测试' })); expect(await within(editor).findByText(/测试成功/)).toBeInTheDocument(); expect(within(editor).getByRole('button', { name: '发布更新' })).toBeEnabled(); @@ -1083,7 +1100,7 @@ test('keeps a changed mobile automation in its editor until discard is confirmed await waitFor(() => expect(document.querySelector('.v2-alert-rule-editor-sidesheet .v2-alert-rule-editor')).not.toBeInTheDocument()); }); -test('opens desktop automation editing in a centered review dialog and gates publishing on a sample test', async () => { +test('opens desktop automation editing in a centered review dialog and publishes valid changes without requiring a sample test', async () => { mocks.alertRulesV2.mockResolvedValue([alertRule()]); mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 3, offset: 0 }); mocks.metricCatalog.mockResolvedValue({ @@ -1100,7 +1117,7 @@ test('opens desktop automation editing in a centered review dialog and gates pub expect(editor.closest('.v2-alert-rule-editor-dialog')).toBeInTheDocument(); expect(document.querySelector('.v2-alert-rule-editor-sidesheet')).not.toBeInTheDocument(); expect(within(editor).getByLabelText('自动化配置摘要')).toBeInTheDocument(); - expect(within(editor).getByRole('region', { name: '样例事件测试' })).toBeInTheDocument(); + expect(within(editor).getByRole('region', { name: /样例事件测试/ })).toBeInTheDocument(); expect(within(editor).getByRole('region', { name: '执行跟踪' })).toBeInTheDocument(); expect(within(editor).getByText('0 项变更')).toBeInTheDocument(); expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled(); @@ -1110,21 +1127,58 @@ test('opens desktop automation editing in a centered review dialog and gates pub expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled(); fireEvent.click(within(editor).getByRole('button', { name: '编辑事件来源' })); fireEvent.change(within(editor).getByDisplayValue('测试超速规则'), { target: { value: '测试超速规则 v4' } }); - fireEvent.click(within(editor).getByRole('button', { name: /测试并发布/ })); + fireEvent.click(within(editor).getByRole('button', { name: /检查并发布/ })); expect(within(editor).getByText('1 项变更')).toBeInTheDocument(); - fireEvent.click(within(editor).getByRole('button', { name: '运行测试' })); - expect(await within(editor).findByText(/测试成功/)).toBeInTheDocument(); expect(within(editor).getByRole('button', { name: '发布更新' })).toBeEnabled(); + expect(within(editor).getByRole('status')).toHaveTextContent('配置已就绪,可直接发布更新'); fireEvent.click(within(editor).getByRole('button', { name: '上一步' })); expect(within(editor).getByRole('heading', { name: '系统自动做什么' })).toBeInTheDocument(); expect(within(editor).queryByRole('heading', { name: '什么时候触发' })).not.toBeInTheDocument(); - expect(within(editor).getByRole('button', { name: '下一步:测试规则' })).toBeEnabled(); + expect(within(editor).getByRole('button', { name: '下一步:检查发布' })).toBeEnabled(); fireEvent.click(within(editor).getByRole('button', { name: /事件与条件/ })); expect(within(editor).getByRole('heading', { name: '什么时候触发' })).toBeInTheDocument(); }); +test('enables publishing a hydrogen pressure automation when only its recovery threshold changes', async () => { + const pressureRule: AlertRule = { + ...alertRule(), + id: 'hydrogen-pressure-rule', + name: '氢系统压力过低', + description: '', + triggerType: 'metric', + metric: 'hydrogen_pressure_mpa', + operator: 'lt', + threshold: 2, + thresholdHigh: 100, + durationSec: 3, + recoveryOperator: 'gt', + recoveryThreshold: 3, + scopeProtocols: ['GB32960'] + }; + mocks.alertRulesV2.mockResolvedValue([pressureRule]); + mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 3, offset: 0 }); + mocks.metricCatalog.mockResolvedValue({ + metrics: [{ key: 'hydrogen_pressure_mpa', label: '最高氢气压力', unit: 'MPa', category: 'fuel-cell', valueType: 'numeric', protocols: ['GB32960'], sourceFields: { GB32960: 'gb32960.fuel_cell.max_hydrogen_pressure_mpa' }, searchable: true, chartable: true, alertable: true }], + asOf: '' + }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + + await screen.findByRole('button', { name: /^氢系统压力过低/ }); + fireEvent.click(await screen.findByRole('button', { name: '编辑自动化' })); + const editor = await screen.findByRole('dialog', { name: '事件自动化编辑' }); + fireEvent.click(within(within(editor).getByRole('navigation', { name: '自动化编辑步骤' })).getByRole('button', { name: /执行动作/ })); + fireEvent.change(within(editor).getByDisplayValue('3'), { target: { value: '3.5' } }); + fireEvent.click(within(editor).getByRole('button', { name: '下一步:检查发布' })); + + expect(within(editor).getByRole('list', { name: '自动化待发布变更' })).toHaveTextContent('恢复条件'); + expect(within(editor).getByText('1 项变更')).toBeInTheDocument(); + expect(within(editor).getByRole('button', { name: '发布更新' })).toBeEnabled(); + expect(within(editor).getByRole('status')).toHaveTextContent('配置已就绪,可直接发布更新'); +}); + test('copies an automation into an audited disabled draft without changing the source rule', async () => { const source = alertRule(); const created = { ...source, id: 'speed-rule-copy', name: '测试超速规则(副本)', enabled: false, version: 1 }; @@ -1142,9 +1196,6 @@ test('copies an automation into an audited disabled draft without changing the s expect(within(editor).getByText('2 项变更')).toBeInTheDocument(); expect(within(editor).getByRole('list', { name: '自动化待发布变更' })).toHaveTextContent('测试超速规则(副本)'); expect(within(editor).getByText('副本将保持停用')).toBeInTheDocument(); - expect(within(editor).getByRole('button', { name: '创建副本' })).toBeDisabled(); - fireEvent.click(within(editor).getByRole('button', { name: '运行测试' })); - expect(await within(editor).findByText(/测试成功/)).toBeInTheDocument(); expect(within(editor).getByRole('button', { name: '创建副本' })).toBeEnabled(); fireEvent.click(within(editor).getByRole('button', { name: '创建副本' })); @@ -1252,8 +1303,8 @@ test('creates auditable drafts from simple offline and hydrogen rule templates', expect(within(fenceEditor).getByDisplayValue('VIN-QUICK-001')).toBeInTheDocument(); fireEvent.click(within(fenceEditor).getByRole('button', { name: '下一步:执行动作' })); expect(within(fenceEditor).getAllByText('发送高优先级站内通知').length).toBeGreaterThan(0); - fireEvent.click(within(fenceEditor).getByRole('button', { name: '下一步:测试规则' })); - expect(within(fenceEditor).getByRole('button', { name: '发布自动化' })).toBeDisabled(); + fireEvent.click(within(fenceEditor).getByRole('button', { name: '下一步:检查发布' })); + expect(within(fenceEditor).getByRole('button', { name: '发布自动化' })).toBeEnabled(); fireEvent.click(within(fenceEditor).getByRole('button', { name: '运行测试' })); expect(await within(fenceEditor).findByText(/测试成功/)).toBeInTheDocument(); expect(within(fenceEditor).getByRole('button', { name: '发布自动化' })).toBeEnabled(); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx index 70189774..a2ed61ba 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx @@ -85,7 +85,7 @@ const AUTOMATION_EDITOR_STEPS: Array<{ step: AutomationEditorStep; label: string { step: 1, label: '事件与条件', description: '定义触发信号' }, { step: 2, label: '车辆范围', description: '选择生效对象' }, { step: 3, label: '执行动作', description: '通知、恢复与冷却' }, - { step: 4, label: '测试并发布', description: '验证后正式启用' } + { step: 4, label: '检查并发布', description: '确认配置后发布' } ]; export function alertFiltersFromParams(params: URLSearchParams): Filters { @@ -691,22 +691,31 @@ function ruleDraft(rule: AlertRule): AlertRuleInput { export type AutomationReleaseChange = { key: string; label: string; before: string; after: string }; export function automationReleaseChanges(baseline: AlertRuleInput, draft: AlertRuleInput, catalogLabels: Record = {}, catalogUnits: Record = {}): AutomationReleaseChange[] { + const stableList = (values: string[]) => [...values].sort(); + const stableTargets = (rule: AlertRuleInput) => [...(rule.notificationTargets ?? [])] + .map((target) => ({ channel: target.channel, recipientId: target.recipientId, label: target.label ?? '' })) + .sort((left, right) => `${left.channel}:${left.recipientId}`.localeCompare(`${right.channel}:${right.recipientId}`)); + const changed = (select: (rule: AlertRuleInput) => unknown) => JSON.stringify(select(baseline)) !== JSON.stringify(select(draft)); const snapshot = (rule: AlertRuleInput) => ({ name: rule.name || '未命名自动化', - event: `${automationSource(rule)} · ${ruleCondition(rule, catalogLabels, catalogUnits, false)}`, + event: `${automationSource(rule)} · ${ruleCondition(rule, catalogLabels, catalogUnits, false)}${rule.durationSec ? ` · 持续 ${formatAlertDuration(rule.durationSec)}` : ' · 立即判断'}`, scope: ruleScopeSummary(rule), action: `${ruleActionSummary(rule)} · ${rule.repeatIntervalSec ? `${formatAlertDuration(rule.repeatIntervalSec)}内不重复` : '允许每次匹配执行'}`, + recovery: rule.recoveryOperator ? `${operatorLabels[rule.recoveryOperator]} ${rule.recoveryThreshold}${catalogUnits[rule.metric] ? ` ${catalogUnits[rule.metric]}` : ''}` : '状态恢复后自动关闭事件', + description: rule.description.trim() || '未填写', status: rule.enabled ? '启用' : '停用' }); const before = snapshot(baseline); const after = snapshot(draft); return [ - { key: 'name', label: '规则名称', before: before.name, after: after.name }, - { key: 'event', label: '事件与条件', before: before.event, after: after.event }, - { key: 'scope', label: '车辆范围', before: before.scope, after: after.scope }, - { key: 'action', label: '执行动作', before: before.action, after: after.action }, - { key: 'status', label: '发布状态', before: before.status, after: after.status } - ].filter((item) => item.before !== item.after); + changed((rule) => rule.name) ? { key: 'name', label: '规则名称', before: before.name, after: after.name } : undefined, + changed((rule) => ({ triggerType: rule.triggerType, metric: rule.metric, valueType: rule.valueType, operator: rule.operator, threshold: rule.threshold, thresholdHigh: rule.thresholdHigh, booleanThreshold: rule.booleanThreshold, durationSec: rule.durationSec, fenceName: rule.fenceName, fenceLongitude: rule.fenceLongitude, fenceLatitude: rule.fenceLatitude, fenceRadiusM: rule.fenceRadiusM })) ? { key: 'event', label: '事件与条件', before: before.event, after: after.event } : undefined, + changed((rule) => ({ scopeProtocols: stableList(rule.scopeProtocols), scopeVins: stableList(rule.scopeVins), scopeOems: stableList(rule.scopeOems), scopeModels: stableList(rule.scopeModels), scopeCompanies: stableList(rule.scopeCompanies) })) ? { key: 'scope', label: '车辆范围', before: before.scope, after: after.scope } : undefined, + changed((rule) => ({ severity: rule.severity, notificationChannels: stableList(rule.notificationChannels), notificationTargets: stableTargets(rule), repeatIntervalSec: rule.repeatIntervalSec })) ? { key: 'action', label: '执行动作', before: before.action, after: after.action } : undefined, + changed((rule) => ({ recoveryOperator: rule.recoveryOperator, recoveryThreshold: rule.recoveryThreshold })) ? { key: 'recovery', label: '恢复条件', before: before.recovery, after: after.recovery } : undefined, + changed((rule) => rule.description.trim()) ? { key: 'description', label: '规则说明', before: before.description, after: after.description } : undefined, + changed((rule) => rule.enabled) ? { key: 'status', label: '发布状态', before: before.status, after: after.status } : undefined + ].filter((item): item is AutomationReleaseChange => Boolean(item)); } function RuleFormSection({ id, title, description, children }: { id: string; title: string; description: string; children: ReactNode }) { @@ -938,7 +947,7 @@ function RulesWorkspace({ page, rulesReady, rulesLoading, rulesError, onRetryRul moveToStep((editorStep + 1) as AutomationEditorStep); return; } - if (testRan) save.mutate(draft); + save.mutate(draft); }; const openNewRule = () => { const nextDraft = emptyRule(); @@ -1168,7 +1177,7 @@ function RulesWorkspace({ page, rulesReady, rulesLoading, rulesError, onRetryRul
-
样例事件测试使用标准事件样例验证此自动化规则
+
样例事件测试(可选)使用标准事件样例预演规则,不影响正常发布
event.type{automationEventType(draft)}source.protocol{draft.scopeProtocols[0] || 'JT808'}subject.vin{draft.scopeVins[0] || 'LFP23A98V2P012345'}occurred_at2026-07-22 14:30:04{draft.triggerType === 'geofence' ? 'payload.geofence' : `payload.${draft.metric}`}{ruleCondition(draft, catalogLabels, catalogUnits, false)}planned.action{ruleActionSummary(draft)}
执行跟踪本次测试不会写入事件或真实发送通知
{testRan ?
  1. 输入已接收标准事件字段已通过契约校验
  2. 条件已匹配{ruleCondition(draft, catalogLabels, catalogUnits, false)}
  3. {ruleActionSummary(draft)}动作计划已生成,未产生真实外部调用
:

点击“运行测试”后显示输入校验、条件判断和计划动作。

}
@@ -1176,16 +1185,18 @@ function RulesWorkspace({ page, rulesReady, rulesLoading, rulesError, onRetryRul
; - const editorPrimaryLabel = save.isPending ? '发布中' : editorStep === 1 ? '下一步:车辆范围' : editorStep === 2 ? '下一步:执行动作' : editorStep === 3 ? '下一步:测试规则' : cloneSource ? '创建副本' : draft.version ? '发布更新' : '发布自动化'; - const editorGuidance = editorStep === 1 ? '先完成事件与条件,后续步骤只展示相关配置。' : editorStep === 2 ? '可以搜索并多选车辆;留空代表全部授权车辆。' : editorStep === 3 ? '动作配置完成后进入样例测试,不会立即发送通知。' : '样例测试只验证契约与计划动作,不会产生真实通知。'; + const editorPrimaryLabel = save.isPending ? '发布中' : editorStep === 1 ? '下一步:车辆范围' : editorStep === 2 ? '下一步:执行动作' : editorStep === 3 ? '下一步:检查发布' : cloneSource ? '创建副本' : draft.version ? '发布更新' : '发布自动化'; + const editorGuidance = editorStep === 1 ? '先完成事件与条件,后续步骤只展示相关配置。' : editorStep === 2 ? '可以搜索并多选车辆;留空代表全部授权车辆。' : editorStep === 3 ? '动作配置完成后进入发布检查,不会立即发送通知。' : '配置有效即可发布;样例测试为可选预演,不会产生真实通知。'; const editorFooterNote = currentStepError ? {currentStepError} : mutationError ? {mutationError.message} + : reviewMode && draft.version > 0 && !draftDirty + ? 当前配置与线上版本一致,没有可发布的更改 : draftDirty - ? 草稿未保存 · 关闭前会再次确认 + ? {reviewMode ? '配置已就绪,可直接发布更新' : '草稿未保存 · 关闭前会再次确认'} : editorGuidance; - const editorPrimaryDisabled = save.isPending || toggle.isPending || Boolean(currentStepError) || (reviewMode && (!testRan || (draft.version > 0 && !releaseChanges.length))); + const editorPrimaryDisabled = save.isPending || toggle.isPending || Boolean(currentStepError) || (reviewMode && draft.version > 0 && !draftDirty); const closeEditor = () => { setDiscardConfirmOpen(false); setCloneSource(undefined); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.test.tsx index f928c2b9..ae5832cd 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.test.tsx @@ -2,12 +2,12 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, expect, test, vi } from 'vitest'; import { MemoryRouter } from 'react-router-dom'; -import StatisticsPage, { mileageRangeContainsDate, parseMileageVehicleIdentifiers } from './StatisticsPage'; +import StatisticsPage, { mileageExportDateWindows, mileageRangeContainsDate, parseMileageVehicleIdentifiers } from './StatisticsPage'; import { buildMonitorPath, withMonitorReturn } from '../routing/monitorContext'; import { ROUTER_FUTURE } from '../routing/routerConfig'; import { buildVehicleDetailPath, withVehicleReturn } from '../routing/vehicleContext'; -const mocks = vi.hoisted(() => ({ mileageStatistics: vi.fn(), dailyMileage: vi.fn(), vehicles: vi.fn(), vehicleCoverage: vi.fn(), vehicleServiceOverviews: vi.fn() })); +const mocks = vi.hoisted(() => ({ mileageStatistics: vi.fn(), dailyMileage: vi.fn(), hydrogenDailyEvidence: vi.fn(), vehicles: vi.fn(), vehicleCoverage: vi.fn(), vehicleServiceOverviews: vi.fn() })); const exportMocks = vi.hoisted(() => ({ createMileageExportStream: vi.fn(), appendRows: vi.fn(), finish: vi.fn(), dispose: vi.fn() })); const layout = vi.hoisted(() => ({ mobile: false })); const auth = vi.hoisted(() => ({ role: 'admin' as 'admin' | 'customer', userType: 'admin' as 'admin' | 'customer' })); @@ -29,17 +29,28 @@ function prepareData() { mocks.mileageStatistics.mockResolvedValue({ dateFrom: '2026-07-01', dateTo: '2026-07-14', vehicleCount: 1, recordCount: 2, sourceCount: 2, periodMileageKm: 193.3, periodPureHydrogenMileageKm: 128.4, - hydrogenMatchedMileageKm: 193.3, hydrogenDataDays: 2, periodHydrogenConsumptionKg: 7.3, hydrogenConsumptionKgPer100Km: 3.8, + hydrogenMatchedMileageKm: 128.4, hydrogenDataDays: 2, periodHydrogenConsumptionKg: 7.3, hydrogenConsumptionKgPer100Km: 5.7, fleetLatestMileageKm: 119925, averageMileagePerVin: 193.3, averageDailyMileageKm: 96.65, trend: [], ranking: [{ vin: 'LTEST000000000001', plate: '粤A12345', mileageKm: 193.3, latestMileageKm: 119925, activeDays: 2 }], asOf: '2026-07-14 13:20:00', evidence: 'production mileage evidence' }); mocks.dailyMileage.mockResolvedValue({ items: [ - { vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 119731.7, endMileageKm: 119820.4, dailyMileageKm: 88.7, hydrogenConsumptionKg: 3.1, hydrogenConsumptionKgPer100Km: 3.5, source: 'GB32960' }, - { vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 119820.4, endMileageKm: 119925, dailyMileageKm: 104.6, hydrogenConsumptionKg: 4.2, hydrogenConsumptionKgPer100Km: 4, source: 'GB32960' } + { vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 119731.7, endMileageKm: 119820.4, dailyMileageKm: 88.7, pureHydrogenMileageKm: 56.2, hydrogenConsumptionKg: 3.1, hydrogenConsumptionKgPer100Km: 5.5, hydrogenEvidenceAvailable: true, hydrogenQualityStatus: 'OK', hydrogenAlgorithmVersion: 'PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2', source: 'GB32960' }, + { vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 119820.4, endMileageKm: 119925, dailyMileageKm: 104.6, pureHydrogenMileageKm: 72.2, hydrogenConsumptionKg: 4.2, hydrogenConsumptionKgPer100Km: 5.8, hydrogenEvidenceAvailable: true, hydrogenQualityStatus: 'OK', hydrogenAlgorithmVersion: 'PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2', source: 'GB32960' } ], total: 2, limit: 10000, offset: 0 }); + mocks.hydrogenDailyEvidence.mockResolvedValue({ + vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', source: 'factory-a', + rawConsumptionKg: 3.1, batterySocDeltaPct: -2, batteryDischargeKWh: 0.421, + batteryEquivalentKg: 0.026, socBalancedConsumptionKg: 3.126, + mixedMileageKm: 56.2, pureElectricMileageKm: 32.5, consumptionKgPer100Km: 5.516, + socBalancedKgPer100Km: 5.562, sampleCount: 120, refuelCount: 1, chargeCount: 1, + validSegmentCount: 1, invalidSegmentCount: 0, qualityStatus: 'OK', qualityReason: '', + algorithmVersion: 'PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2', calculatedAt: '2026-07-14 00:05:00.000', + parameters: { batteryCapacityKWh: 21.04, hydrogenEnergyKWhPerKg: 16, powerOnDelaySeconds: 60, powerOffLeadSeconds: 60, refuelRiseMpa: 3, refuelSustainSeconds: 300, pureElectricDropMpa: 5, pureElectricWindowSeconds: 1800, algorithmVersion: 'PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2' }, + intervals: [{ index: 1, type: 'MIXED', startTime: '2026-07-13T08:01:00+08:00', endTime: '2026-07-13T10:30:00+08:00', startEventId: 'event-start', endEventId: 'event-end', source: 'factory-a', startPressureMpa: 30, endPressureMpa: 22, startTemperatureC: 30, endTemperatureC: 35, startMassKg: 11.3, endMassKg: 8.2, rawHydrogenConsumptionKg: 3.1, startSocPercent: 80, endSocPercent: 78, batteryDischargeKWh: 0.421, batteryEquivalentKg: 0.026, socBalancedConsumptionKg: 3.126, startMileageKm: 1000, endMileageKm: 1056.2, mileageKm: 56.2, consumptionKgPer100Km: 5.562, sampleCount: 120, qualityStatus: 'OK', qualityReason: '' }] + }); mocks.vehicles.mockResolvedValue({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345', phone: '', oem: '', protocol: 'GB32960', online: true, lastSeen: '', locationText: '', bindingScore: 100 }], total: 1, limit: 12, offset: 0 }); - mocks.vehicleCoverage.mockResolvedValue({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345' }], total: 1, limit: 20, offset: 0 }); + mocks.vehicleCoverage.mockResolvedValue({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345', brandName: '现代', modelName: 'XCIENT' }], total: 1, limit: 20, offset: 0 }); mocks.vehicleServiceOverviews.mockResolvedValue({ items: [], total: 0, limit: 200, offset: 0 }); } @@ -56,12 +67,23 @@ test('refreshes only ranges that include the current business date', () => { expect(mileageRangeContainsDate({ dateFrom: '2026-07-20', dateTo: '2026-07-20' }, '2026-07-19')).toBe(false); }); +test('bounds full-fleet export queries by vehicle-day volume', () => { + const windows = mileageExportDateWindows('2026-07-01', '2026-07-30', 1024); + expect(windows).toEqual([ + { dateFrom: '2026-07-01', dateTo: '2026-07-07' }, + { dateFrom: '2026-07-08', dateTo: '2026-07-14' }, + { dateFrom: '2026-07-15', dateTo: '2026-07-21' }, + { dateFrom: '2026-07-22', dateTo: '2026-07-28' }, + { dateFrom: '2026-07-29', dateTo: '2026-07-30' } + ]); +}); + test('renders only the desktop matrix with dates as columns and a period total', async () => { prepareData(); const view = renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14'); expect(await screen.findByText('车辆每日里程')).toBeInTheDocument(); expect((await screen.findAllByText('193.3 km')).length).toBeGreaterThan(0); - expect(screen.getAllByText('区间总里程').length).toBeGreaterThan(1); + expect(screen.getByText('区间合计')).toBeInTheDocument(); expect(screen.getAllByText('104.6 km').length).toBeGreaterThan(0); expect(screen.getAllByText('88.7 km').length).toBeGreaterThan(0); expect(screen.getAllByText('7/13').length).toBeGreaterThan(0); @@ -116,25 +138,58 @@ test('renders only the desktop matrix with dates as columns and a period total', }); fireEvent.keyDown(matrixRegion, { key: 'ArrowRight' }); expect(matrixScroller!.scrollLeft).toBe(96); - expect(view.container.querySelector('[aria-label="2026-07-13,里程 88.7 公里,来源 GB32960"]')).toBeInTheDocument(); + expect(view.container.querySelector('[aria-label="2026-07-13,里程 88.7 km,来源 GB32960"]')).toBeInTheDocument(); expect(view.container.querySelector('.v2-mileage-table.semi-table-wrapper')).toBeInTheDocument(); expect(view.container.querySelector('.v2-mileage-table-wrap > table')).not.toBeInTheDocument(); expect(view.container.querySelector('.v2-mileage-mobile-list')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '氢耗' })); expect(await screen.findByRole('heading', { name: '车辆每日氢耗' })).toBeInTheDocument(); - expect(screen.getByRole('note', { name: '氢耗矩阵读表说明' })).toHaveTextContent('每日耗氢量'); + const hydrogenGuide = screen.getByRole('note', { name: '氢耗矩阵读表说明' }); + expect(hydrogenGuide).toHaveTextContent('日期子列里程固定展示'); + expect(screen.getByRole('group', { name: '日期子列' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '用氢量' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: '纯电里程' })).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByRole('button', { name: '纯氢里程' })).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByRole('button', { name: '百公里氢耗' })).toHaveAttribute('aria-pressed', 'true'); + expect(hydrogenGuide).toHaveTextContent('里程 当日末累计里程-同源日基线'); + expect(hydrogenGuide).toHaveTextContent('纯电里程 总里程-纯氢里程'); + expect(hydrogenGuide).toHaveTextContent('纯氢里程 运行模式 0x02 的相邻里程差累计'); + expect(hydrogenGuide).toHaveTextContent('百公里氢耗 有效用氢量÷纯氢里程×100'); expect(screen.getByRole('region', { name: '车辆每日氢耗矩阵,可横向滚动查看日期' })).toBeInTheDocument(); - expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('氢耗匹配里程193.3 km按有氢耗数据的车辆日总里程计算'); - expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('区间氢耗7.3 kg3.8 kg/100km · 2 个有效车辆日'); - expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('百公里氢耗3.8 kg/100km2 个有效车辆日'); + expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('氢耗匹配纯氢里程128.4 km纯氢里程=总里程-纯电模式里程'); + expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('区间氢耗7.3 kg5.7 kg/100km · 2 个有效车辆日'); + expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('百公里纯氢耗5.7 kg/100km2 个有效车辆日'); expect(screen.getAllByText('3.1 kg').length).toBeGreaterThan(0); expect(screen.getAllByText('4.2 kg').length).toBeGreaterThan(0); expect(screen.getAllByText('7.3 kg').length).toBeGreaterThan(0); + expect(screen.getAllByText('5.5 kg/100km').length).toBeGreaterThan(0); + fireEvent.click(screen.getByRole('button', { name: '纯电里程' })); + fireEvent.click(screen.getByRole('button', { name: '纯氢里程' })); + expect(screen.getByRole('button', { name: '纯电里程' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: '纯氢里程' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getAllByText('32.5 km').length).toBeGreaterThan(0); + expect(screen.getAllByText('56.2 km').length).toBeGreaterThan(0); await waitFor(() => expect(mocks.dailyMileage).toHaveBeenCalledTimes(1)); expect(mocks.dailyMileage.mock.calls[0][0].limit).toBe(10000); expect(mocks.dailyMileage.mock.calls[0][0].protocols).toEqual(['GB32960', 'JT808', 'YUTONG_MQTT']); }); +test('opens traceable hydrogen formula, parameters, intervals and raw event ids', async () => { + prepareData(); + renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14'); + fireEvent.click(await screen.findByRole('button', { name: '氢耗' })); + const traceButtons = await screen.findAllByTitle('查看计算公式、区间和原始报文标识'); + fireEvent.click(traceButtons[0]); + expect(await screen.findByText('计算公式与口径')).toBeInTheDocument(); + expect(screen.getByText(/m = P × 1000 × 0.00201588/)).toBeInTheDocument(); + expect(screen.getByText(/SOC平衡氢耗 = 物理耗氢/)).toBeInTheDocument(); + expect(screen.getByText('120')).toBeInTheDocument(); + expect(screen.getByText('event-start')).toBeInTheDocument(); + expect(screen.getByText('event-end')).toBeInTheDocument(); + expect(screen.getAllByText('3.126 kg').length).toBeGreaterThan(0); + expect(mocks.hydrogenDailyEvidence).toHaveBeenCalledWith('LTEST000000000001', '2026-07-13', expect.any(AbortSignal)); +}); + test('distinguishes missing mileage from a reported zero and explains partial coverage', async () => { prepareData(); mocks.mileageStatistics.mockResolvedValue({ @@ -163,6 +218,18 @@ test('does not expose the hydrogen consumption view to business customers', asyn expect(screen.queryByText('纯氢里程 56.2 km')).not.toBeInTheDocument(); }); +test('keeps the daily mileage matrix available when the fleet summary times out', async () => { + prepareData(); + mocks.mileageStatistics.mockRejectedValue(new Error('请求处理超时')); + + const view = renderPage('/statistics?dateFrom=2026-07-13&dateTo=2026-07-14'); + + expect(await screen.findByRole('region', { name: '车辆每日里程矩阵,可横向滚动查看日期' })).toBeInTheDocument(); + expect(view.container.querySelector('.v2-mileage-summary')).toHaveTextContent('汇总暂不可用'); + expect(screen.queryByText('数据暂时无法加载')).not.toBeInTheDocument(); + expect(screen.getAllByText('粤A12345').length).toBeGreaterThan(0); +}); + test('shows query failures inside the result panel without a misleading empty matrix', async () => { prepareData(); mocks.dailyMileage.mockRejectedValue(new Error('日里程服务超时')); @@ -273,7 +340,7 @@ test('uses one responsive mileage matrix without viewport listeners', async () = expect(view.container.querySelector('.v2-mileage-table.semi-table-wrapper')).toBeInTheDocument(); expect(view.container.querySelectorAll('.v2-mileage-table th.is-date')).toHaveLength(2); expect(view.container.querySelector('.v2-mileage-table th.is-plate')).toHaveTextContent('车牌'); - expect(view.container.querySelector('.v2-mileage-table th.is-total')).toHaveTextContent('区间总里程'); + expect(view.container.querySelector('.v2-mileage-table th.is-total-group')).toHaveTextContent('区间合计'); expect(view.container.querySelector('.v2-mileage-table th.is-vin')).not.toBeInTheDocument(); expect(view.container.querySelector('.v2-mileage-table th.is-plate')).toHaveClass('is-plate'); expect(view.container.querySelector('.v2-mileage-table th.is-total')).toHaveClass('is-total'); @@ -476,7 +543,7 @@ test('paginates all unique vehicles when no license plate is selected', async () await waitFor(() => expect(mocks.vehicleCoverage.mock.calls[mocks.vehicleCoverage.mock.calls.length - 1]?.[0].get('offset')).toBe('20')); }); -test('exports the full fleet mileage with one paginated query instead of VIN batches', async () => { +test('exports the full fleet mileage with fleet-scoped time windows instead of VIN batches', async () => { prepareData(); const exportRows = [ { vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 119731.7, endMileageKm: 119820.4, dailyMileageKm: 88.7, source: 'GB32960' }, @@ -499,12 +566,50 @@ test('exports the full fleet mileage with one paginated query instead of VIN bat expect(exportMocks.appendRows).toHaveBeenCalledTimes(2); expect(exportMocks.appendRows.mock.calls.map(([rows]) => rows.map((row: { date: string }) => row.date))).toEqual([['2026-07-13'], ['2026-07-14']]); expect(exportMocks.finish.mock.calls[0][0]).toHaveLength(1); + expect(exportMocks.finish.mock.calls[0][0][0]).toMatchObject({ brandName: '现代', modelName: 'XCIENT' }); expect(mocks.vehicleCoverage.mock.calls[mocks.vehicleCoverage.mock.calls.length - 1][1]).toBeInstanceOf(AbortSignal); expect(mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1][1]).toBeInstanceOf(AbortSignal); expect(exportMocks.createMileageExportStream.mock.calls[0][1]).toBeInstanceOf(AbortSignal); expect(screen.getByText(/已导出 1 辆车/)).toBeInTheDocument(); }); +test('loads archive brand and model before exporting selected vehicles', async () => { + prepareData(); + renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14'); + await waitFor(() => expect(screen.getByRole('button', { name: /导出 Excel/ })).toBeEnabled()); + + fireEvent.click(screen.getByRole('button', { name: /导出 Excel/ })); + + await waitFor(() => expect(exportMocks.finish).toHaveBeenCalledTimes(1)); + const archiveCall = mocks.vehicleCoverage.mock.calls.find(([params]) => params.get('keywords') === 'LTEST000000000001'); + expect(archiveCall?.[0].get('bindingStatus')).toBe('bound'); + expect(archiveCall?.[1]).toBeInstanceOf(AbortSignal); + expect(exportMocks.finish.mock.calls[0][0]).toEqual([ + expect.objectContaining({ vin: 'LTEST000000000001', plate: '粤A12345', brandName: '现代', modelName: 'XCIENT' }) + ]); +}); + +test('splits a 1024-vehicle full-fleet export into bounded time windows', async () => { + prepareData(); + const fleet = Array.from({ length: 1024 }, (_, index) => ({ vin: `VIN${String(index + 1).padStart(14, '0')}`, plate: `粤A${String(index + 1).padStart(5, '0')}` })); + mocks.vehicleCoverage.mockImplementation((params: URLSearchParams) => Promise.resolve(params.get('limit') === String(2_000) + ? { items: fleet, total: fleet.length, limit: fleet.length, offset: 0 } + : { items: fleet.slice(0, 20), total: fleet.length, limit: 20, offset: 0 })); + mocks.dailyMileage.mockResolvedValue({ items: [], total: 0, limit: 10000, offset: 0 }); + renderPage('/statistics?dateFrom=2026-07-01&dateTo=2026-07-30'); + await waitFor(() => expect(screen.getByRole('button', { name: /导出 Excel/ })).toBeEnabled()); + + fireEvent.click(screen.getByRole('button', { name: /导出 Excel/ })); + + await waitFor(() => expect(exportMocks.finish).toHaveBeenCalledTimes(1)); + const exportCalls = mocks.dailyMileage.mock.calls + .map(([query]) => query as { vehicleScope?: string; vins?: string[]; dateFrom: string; dateTo: string }) + .filter((query) => query.vehicleScope === 'bound'); + expect(exportCalls.map(({ dateFrom, dateTo }) => ({ dateFrom, dateTo }))).toEqual(mileageExportDateWindows('2026-07-01', '2026-07-30', 1024)); + expect(exportCalls.every((query) => query.vins === undefined)).toBe(true); + expect(exportMocks.finish.mock.calls[0][0]).toHaveLength(1024); +}); + test('shows export progress and lets the user cancel the active request', async () => { prepareData(); let exportSignal: AbortSignal | undefined; diff --git a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx index f7f59f86..191dfc6c 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx @@ -1,10 +1,10 @@ import { IconArrowDown, IconArrowUp, IconClose, IconDownload, IconInfoCircle, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons'; import { Button, ButtonGroup, Card, DatePicker, Input, Progress, Spin, Switch, Table, Tag, TextArea } from '@douyinfe/semi-ui'; import { useQuery } from '@tanstack/react-query'; -import { type CSSProperties, FormEvent, type KeyboardEvent, memo, type RefObject, useEffect, useMemo, useRef, useState } from 'react'; +import { type CSSProperties, FormEvent, type KeyboardEvent, memo, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { api, type MileageQuery } from '../../api/client'; -import type { DailyMileageRow, MileageStatistics, Page, VehicleRow } from '../../api/types'; +import type { DailyMileageRow, HydrogenDailyEvidence, MileageStatistics, Page, VehicleRow } from '../../api/types'; import { createMileageExportStream, type MileageExportStream } from '../domain/mileageExport'; import { formatZhNumber } from '../domain/formatters'; import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState'; @@ -32,15 +32,34 @@ const BATCH_RESOLVE_SIZE = 200; const PAGE_SIZE = 20; const EXPORT_VEHICLE_PAGE_SIZE = 2_000; const EXPORT_VIN_BATCH_SIZE = 50; +const EXPORT_QUERY_TARGET_ROWS = 8_000; +const EXPORT_MAX_DAYS_PER_QUERY = 31; const MAX_MILEAGE_RANGE_DAYS = 366; const CURRENT_DAY_REFRESH_MS = 60_000; -type VehicleOption = Pick; +type VehicleOption = Pick & { brandName?: string; modelName?: string }; type MileageProtocol = 'GB32960' | 'JT808' | 'YUTONG_MQTT'; type MileageSourceOption = { protocol: MileageProtocol; label: string; mileageType: string; enabled: boolean }; type Criteria = { vehicles: VehicleOption[]; dateFrom: string; dateTo: string; sources: MileageSourceOption[] }; type ExportProgress = { label: string; completed?: number; total?: number }; type MetricView = 'mileage' | 'hydrogen'; +type HydrogenMatrixMetric = 'consumption' | 'pureElectric' | 'pureHydrogen' | 'rate'; +type MatrixMetric = 'mileage' | HydrogenMatrixMetric; + +const HYDROGEN_MATRIX_METRICS: Array<{ key: HydrogenMatrixMetric; label: string }> = [ + { key: 'consumption', label: '用氢量' }, + { key: 'pureElectric', label: '纯电里程' }, + { key: 'pureHydrogen', label: '纯氢里程' }, + { key: 'rate', label: '百公里氢耗' } +]; +const DEFAULT_HYDROGEN_MATRIX_METRICS: HydrogenMatrixMetric[] = ['consumption', 'rate']; +const MATRIX_METRIC_META: Record = { + mileage: { label: '里程', unit: 'km', width: 92 }, + consumption: { label: '用氢量', unit: 'kg', width: 88 }, + pureElectric: { label: '纯电里程', unit: 'km', width: 96 }, + pureHydrogen: { label: '纯氢里程', unit: 'km', width: 96 }, + rate: { label: '百公里氢耗', unit: 'kg/100km', width: 112 } +}; const SOURCE_STORAGE_KEY = 'vehicle-platform:mileage-source-strategy'; const DEFAULT_SOURCES: MileageSourceOption[] = [ @@ -147,6 +166,20 @@ function mileageQuery(criteria: Criteria, offset = 0): MileageQuery { return query; } +export function mileageExportDateWindows(dateFrom: string, dateTo: string, vehicleCount: number) { + const start = new Date(`${dateFrom}T00:00:00Z`); + const end = new Date(`${dateTo}T00:00:00Z`); + if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime()) || start > end) return []; + const daysPerQuery = Math.max(1, Math.min(EXPORT_MAX_DAYS_PER_QUERY, Math.floor(EXPORT_QUERY_TARGET_ROWS / Math.max(1, vehicleCount)))); + const windows: Array<{ dateFrom: string; dateTo: string }> = []; + for (let cursor = start; cursor <= end;) { + const windowEnd = new Date(Math.min(end.getTime(), cursor.getTime() + (daysPerQuery - 1) * DAY)); + windows.push({ dateFrom: cursor.toISOString().slice(0, 10), dateTo: windowEnd.toISOString().slice(0, 10) }); + cursor = new Date(windowEnd.getTime() + DAY); + } + return windows; +} + function mileageRouteParams(criteria: Criteria) { const params = new URLSearchParams({ dateFrom: criteria.dateFrom, dateTo: criteria.dateTo }); params.set('protocols', criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(',')); @@ -502,7 +535,7 @@ function MetricViewSwitch({ value, onChange }: { value: MetricView; onChange: (v return
{value === 'mileage' ? '里程视图' : '氢耗视图'} - {value === 'mileage' ? '只看车辆行驶里程' : '按当日总里程计算氢耗'} + {value === 'mileage' ? '只看车辆行驶里程' : '按纯氢里程计算百公里氢耗'} @@ -511,6 +544,26 @@ function MetricViewSwitch({ value, onChange }: { value: MetricView; onChange: (v
; } +function HydrogenMetricSelector({ value, onChange }: { value: HydrogenMatrixMetric[]; onChange: (value: HydrogenMatrixMetric[]) => void }) { + const selected = new Set(value); + return
+ 日期子列里程固定展示 + 里程 + {HYDROGEN_MATRIX_METRICS.map((metric) => { + const active = selected.has(metric.key); + return ; + })} +
; +} + function SummaryRail({ data, criteria, fleetTotal, loading, view, unavailable = false }: { data?: MileageStatistics; criteria: Criteria; fleetTotal?: number; loading: boolean; view: MetricView; unavailable?: boolean }) { const days = inclusiveDays(criteria.dateFrom, criteria.dateTo); const scopeVehicleCount = criteria.vehicles.length || fleetTotal || 0; @@ -542,9 +595,9 @@ function SummaryRail({ data, criteria, fleetTotal, loading, view, unavailable = tone: 'success', emphasis: 'primary' }] : [{ - label: '氢耗匹配里程', + label: '氢耗匹配纯氢里程', value: pending ? '—' : withUnit(formatKm(data?.hydrogenMatchedMileageKm), 'km'), - note: unavailable ? '里程汇总暂不可用' : loading ? '正在汇总车辆日总里程' : '按有氢耗数据的车辆日总里程计算', + note: unavailable ? '里程汇总暂不可用' : loading ? '正在汇总车辆日纯氢里程' : '纯氢里程=总里程-纯电模式里程', tone: 'success', emphasis: 'primary' }, { @@ -560,7 +613,7 @@ function SummaryRail({ data, criteria, fleetTotal, loading, view, unavailable = tone: 'warning', emphasis: 'primary' }, { - label: '百公里氢耗', + label: '百公里纯氢耗', value: pending || !data?.hydrogenDataDays ? '—' : withUnit(formatKm(data.hydrogenConsumptionKgPer100Km), 'kg/100km'), note: unavailable ? '氢耗效率暂不可用' : loading ? '正在计算氢耗效率' : `${data?.hydrogenDataDays ?? 0} 个有效车辆日`, tone: 'warning', @@ -579,11 +632,25 @@ function SummaryRail({ data, criteria, fleetTotal, loading, view, unavailable = />; } +type HydrogenDayMetric = { + consumptionKg: number; + rateKgPer100Km?: number; + socBalancedKg?: number; + socBalancedRateKgPer100Km?: number; + evidenceAvailable: boolean; + qualityStatus?: string; + algorithmVersion?: string; +}; + type VehicleMileageMatrix = VehicleOption & { days: Map; - hydrogenDays: Map; + pureElectricDays: Map; + pureHydrogenDays: Map; + hydrogenDays: Map; sources: Map; totalMileageKm?: number; + totalPureElectricMileageKm?: number; + totalPureHydrogenMileageKm?: number; totalHydrogenConsumptionKg?: number; totalHydrogenRateKgPer100Km?: number; }; @@ -604,14 +671,31 @@ function dateLabel(date: string) { return `${Number(month)}/${Number(day)}`; } -function MileageMatrixGuide({ rows, view }: { rows: VehicleMileageMatrix[]; view: MetricView }) { +function MileageMatrixGuide({ + rows, + view, + hydrogenMetrics, + onHydrogenMetricsChange +}: { + rows: VehicleMileageMatrix[]; + view: MetricView; + hydrogenMetrics: HydrogenMatrixMetric[]; + onHydrogenMetricsChange: (value: HydrogenMatrixMetric[]) => void; +}) { const vehiclesWithMileage = rows.filter((row) => row.days.size > 0).length; - if (view === 'hydrogen') return