feat: 推广 V3.5 实时氢耗并完善导出

This commit is contained in:
lingniu
2026-09-04 15:42:02 +08:00
parent a26179c8fe
commit e402eb5e60
53 changed files with 2855 additions and 320 deletions
@@ -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)
}
@@ -82,6 +82,11 @@ func main() {
} else { } else {
registry.SetGauge("vehicle_stat_hydrogen_capacity_cache_entries", nil, float64(loaded)) 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 { if cfg.NormalizePlatformSourcesOnStart {
statDate := time.Now().In(cfg.Location).Format("2006-01-02") statDate := time.Now().In(cfg.Location).Format("2006-01-02")
normalizeCtx, cancel := context.WithTimeout(ctx, cfg.NormalizePlatformSourcesTimeout) 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.IncCounter("vehicle_stat_hydrogen_capacity_sync_total", metrics.Labels{"status": "ok"})
registry.SetGauge("vehicle_stat_hydrogen_capacity_cache_entries", nil, float64(loaded)) 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) 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) logger.Info("hydrogen tank capacities synchronized", "source_schema", cfg.HydrogenCapacitySourceSchema, "read", result.Read, "written", result.Written, "deactivated", result.Deactivated, "cache_entries", loaded)
} }
@@ -30,31 +30,32 @@ type Execer interface {
} }
type Writer struct { type Writer struct {
exec Execer exec Execer
query Queryer query Queryer
loc *time.Location loc *time.Location
sourceTouchInterval time.Duration sourceTouchInterval time.Duration
projectionInterval time.Duration projectionInterval time.Duration
cacheRetention time.Duration cacheRetention time.Duration
cacheCleanupInterval time.Duration cacheCleanupInterval time.Duration
baselineMissTTL time.Duration baselineMissTTL time.Duration
baselineHitTTL time.Duration baselineHitTTL time.Duration
maxCacheEntries int maxCacheEntries int
lastCacheCleanup time.Time lastCacheCleanup time.Time
lastCacheCleanupStats cacheCleanupStats lastCacheCleanupStats cacheCleanupStats
cacheEvictions cacheCleanupStats cacheEvictions cacheCleanupStats
mu sync.Mutex mu sync.Mutex
lastTotalMileage map[string]float64 lastTotalMileage map[string]float64
lastSourceSeen map[string]time.Time lastSourceSeen map[string]time.Time
lastProjection map[string]projectionCacheEntry lastProjection map[string]projectionCacheEntry
pendingProjection map[string]pendingProjectionEntry pendingProjection map[string]pendingProjectionEntry
baselineCache map[string]sourceBaselineCacheEntry baselineCache map[string]sourceBaselineCacheEntry
mileageKeysByPrefix map[string]map[string]struct{} mileageKeysByPrefix map[string]map[string]struct{}
projectionKeysByPrefix map[string]map[string]struct{} projectionKeysByPrefix map[string]map[string]struct{}
baselineKeysByPrefix map[string]map[string]struct{} baselineKeysByPrefix map[string]map[string]struct{}
lastGPSAccumulation map[string]time.Time lastGPSAccumulation map[string]time.Time
hydrogenTankCapacities map[string]float64 hydrogenTankCapacities map[string]float64
gpsAccumulationInterval time.Duration hydrogenEnergyParameters map[string]HydrogenRealtimeParameters
gpsAccumulationInterval time.Duration
} }
type MetricSample struct { type MetricSample struct {
@@ -151,26 +152,27 @@ func NewWriter(exec Execer, loc *time.Location) *Writer {
loc = time.FixedZone("Asia/Shanghai", 8*3600) loc = time.FixedZone("Asia/Shanghai", 8*3600)
} }
writer := &Writer{ writer := &Writer{
exec: exec, exec: exec,
loc: loc, loc: loc,
sourceTouchInterval: time.Minute, sourceTouchInterval: time.Minute,
projectionInterval: 15 * time.Second, projectionInterval: 15 * time.Second,
cacheRetention: defaultCacheRetention, cacheRetention: defaultCacheRetention,
cacheCleanupInterval: defaultCacheCleanupInterval, cacheCleanupInterval: defaultCacheCleanupInterval,
baselineMissTTL: defaultBaselineMissTTL, baselineMissTTL: defaultBaselineMissTTL,
baselineHitTTL: defaultBaselineHitTTL, baselineHitTTL: defaultBaselineHitTTL,
maxCacheEntries: defaultMaxCacheEntries, maxCacheEntries: defaultMaxCacheEntries,
lastTotalMileage: map[string]float64{}, lastTotalMileage: map[string]float64{},
lastSourceSeen: map[string]time.Time{}, lastSourceSeen: map[string]time.Time{},
lastProjection: map[string]projectionCacheEntry{}, lastProjection: map[string]projectionCacheEntry{},
pendingProjection: map[string]pendingProjectionEntry{}, pendingProjection: map[string]pendingProjectionEntry{},
baselineCache: map[string]sourceBaselineCacheEntry{}, baselineCache: map[string]sourceBaselineCacheEntry{},
mileageKeysByPrefix: map[string]map[string]struct{}{}, mileageKeysByPrefix: map[string]map[string]struct{}{},
projectionKeysByPrefix: map[string]map[string]struct{}{}, projectionKeysByPrefix: map[string]map[string]struct{}{},
baselineKeysByPrefix: map[string]map[string]struct{}{}, baselineKeysByPrefix: map[string]map[string]struct{}{},
lastGPSAccumulation: map[string]time.Time{}, lastGPSAccumulation: map[string]time.Time{},
hydrogenTankCapacities: map[string]float64{}, hydrogenTankCapacities: map[string]float64{},
gpsAccumulationInterval: defaultGPSAccumulationInterval, hydrogenEnergyParameters: map[string]HydrogenRealtimeParameters{},
gpsAccumulationInterval: defaultGPSAccumulationInterval,
} }
if query, ok := exec.(Queryer); ok { if query, ok := exec.(Queryer); ok {
writer.query = query writer.query = query
@@ -197,6 +199,25 @@ func (w *Writer) HydrogenTankCapacityLiters(vin string) (float64, bool) {
return capacity, ok 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) { func (w *Writer) SetSourceTouchInterval(interval time.Duration) {
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
@@ -315,6 +336,11 @@ func (w *Writer) EnsureSchema(ctx context.Context) error {
return err return err
} }
} }
for _, statement := range HydrogenRealtimeAlterSQL {
if _, err := w.exec.ExecContext(ctx, statement); err != nil && !isIgnorableSchemaChangeError(err) {
return err
}
}
return nil return nil
} }
@@ -333,7 +359,8 @@ func (w *Writer) AppendWithResult(ctx context.Context, env envelope.FrameEnvelop
w.maybeCleanupCaches(seenAt) w.maybeCleanupCaches(seenAt)
identity, hasSource := NewSourceIdentityFromEnvelope(env) identity, hasSource := NewSourceIdentityFromEnvelope(env)
capacityLiters, _ := w.HydrogenTankCapacityLiters(env.VIN) 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 { if err != nil {
return result, err return result, err
} }
@@ -1950,7 +1950,7 @@ func TestWriterEnsuresSchemaAndUpsertsDailyMileage(t *testing.T) {
t.Fatalf("Append() error = %v", err) t.Fatalf("Append() error = %v", err)
} }
schemaCalls := 8 + len(DailyMileageAlterSQL) schemaCalls := 8 + len(DailyMileageAlterSQL) + len(HydrogenRealtimeAlterSQL)
if len(exec.calls) != schemaCalls+5 { if len(exec.calls) != schemaCalls+5 {
t.Fatalf("exec calls = %d", len(exec.calls)) t.Fatalf("exec calls = %d", len(exec.calls))
} }
@@ -91,7 +91,7 @@ const HydrogenSegmentStreamStateTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_o
last_event_time DATETIME(3) NOT NULL, last_event_time DATETIME(3) NOT NULL,
last_event_id VARCHAR(128) NOT NULL DEFAULT '', last_event_id VARCHAR(128) NOT NULL DEFAULT '',
state_json JSON NOT NULL, 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_status VARCHAR(24) NOT NULL DEFAULT 'NO_DATA',
quality_reason VARCHAR(255) NOT NULL DEFAULT '有效工作段关键帧不足10条', quality_reason VARCHAR(255) NOT NULL DEFAULT '有效工作段关键帧不足10条',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
@@ -116,6 +116,18 @@ type HydrogenStreamSample struct {
FuelCellActive bool FuelCellActive bool
FuelCellStateKnown bool FuelCellStateKnown bool
ConsumptionEligible 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 { type HydrogenStreamResult struct {
@@ -453,6 +465,26 @@ func HydrogenStreamSampleFromEnvelope(env envelope.FrameEnvelope, loc *time.Loca
fuelCellStateKnown = true 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 { if loc == nil {
loc = time.FixedZone("Asia/Shanghai", 8*3600) loc = time.FixedZone("Asia/Shanghai", 8*3600)
} }
@@ -475,6 +507,12 @@ func HydrogenStreamSampleFromEnvelope(env envelope.FrameEnvelope, loc *time.Loca
NoiseKG: noiseKG, RefuelThresholdKG: refuelThresholdKG, NoiseKG: noiseKG, RefuelThresholdKG: refuelThresholdKG,
FuelCellActive: fuelCellActive, FuelCellStateKnown: fuelCellStateKnown, FuelCellActive: fuelCellActive, FuelCellStateKnown: fuelCellStateKnown,
ConsumptionEligible: !fuelCellStateKnown || fuelCellActive, 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 }, "", true
} }
@@ -647,13 +685,13 @@ const insertHydrogenSegmentStreamStateSQL = `INSERT INTO vehicle_open_hydrogen_s
vin,stat_date,source_endpoint,finalized_consumption_kg,projected_consumption_kg, vin,stat_date,source_endpoint,finalized_consumption_kg,projected_consumption_kg,
sample_count,refuel_count,abnormal_drop_count,eligible_interval_count,qualified_segment_count, 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 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 const updateHydrogenSegmentStreamStateSQL = `UPDATE vehicle_open_hydrogen_segment_stream_state
SET source_endpoint=?,finalized_consumption_kg=?,projected_consumption_kg=?,sample_count=?, SET source_endpoint=?,finalized_consumption_kg=?,projected_consumption_kg=?,sample_count=?,
refuel_count=?,abnormal_drop_count=?,eligible_interval_count=?,qualified_segment_count=?, refuel_count=?,abnormal_drop_count=?,eligible_interval_count=?,qualified_segment_count=?,
last_mass_kg=?,last_event_time=?,last_event_id=?,state_json=?, 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=?` WHERE vin=? AND stat_date=?`
const projectHydrogenSegmentStreamDailySQL = `INSERT INTO vehicle_open_daily_energy( const projectHydrogenSegmentStreamDailySQL = `INSERT INTO vehicle_open_daily_energy(
@@ -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) { func TestHydrogenStreamMarksInactiveFuelCellIneligible(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600) loc := time.FixedZone("Asia/Shanghai", 8*3600)
eventTime := time.Date(2026, 8, 15, 13, 31, 7, 0, loc) 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) eventTime := time.Now().In(loc).Truncate(time.Millisecond)
mock.ExpectBegin() mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta("SELECT state_json")).WillReturnError(sql.ErrNoRows) 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_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.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy")).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit() mock.ExpectCommit()
writer := NewWriter(db, loc) writer := NewWriter(db, loc)
writer.hydrogenTankCapacities["LA9GG64L0NBAF4175"] = 2050 writer.hydrogenTankCapacities["LA9GG64L0NBAF4175"] = 2050
writer.hydrogenEnergyParameters["LA9GG64L0NBAF4175"] = HydrogenRealtimeParameters{BatteryCapacityKWh: 80, HydrogenEnergyKWhKG: 16}
result, err := writer.AppendWithResult(context.Background(), pressureEnvelope(eventTime)) result, err := writer.AppendWithResult(context.Background(), pressureEnvelope(eventTime))
if err != nil { if err != nil {
t.Fatalf("AppendWithResult() error = %v", err) t.Fatalf("AppendWithResult() error = %v", err)
@@ -311,7 +334,7 @@ func TestHydrogenStreamSQLUsesVINDateStateAndSegmentMethod(t *testing.T) {
for _, want := range []string{ for _, want := range []string{
"PRIMARY KEY (vin, stat_date)", "PRIMARY KEY (vin, stat_date)",
"state_json JSON NOT NULL", "state_json JSON NOT NULL",
"PRESSURE_NIST_SEGMENT_MEDIAN_5", HydrogenV35RealtimeAlgorithmVersion,
} { } {
if !strings.Contains(HydrogenSegmentStreamStateTableSQL, want) { if !strings.Contains(HydrogenSegmentStreamStateTableSQL, want) {
t.Fatalf("segment stream schema missing %q", want) t.Fatalf("segment stream schema missing %q", want)
@@ -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)`
@@ -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)
}
}
@@ -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", "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 ( const DataSourceTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_data_source (
id BIGINT NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
protocol VARCHAR(32) NOT NULL, protocol VARCHAR(32) NOT NULL,
@@ -26,6 +26,7 @@ func main() {
noise := flag.Float64("hydrogen-noise-kg", envFloat("OPEN_STAT_HYDROGEN_NOISE_KG", 0.05), "ignored mass jitter") 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") 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") 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") 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") 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") 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") log.Fatal("-vin-workers must be between 1 and 16")
} }
normalizedVIN := strings.ToUpper(strings.TrimSpace(*vin)) normalizedVIN := strings.ToUpper(strings.TrimSpace(*vin))
normalizedBrand := strings.TrimSpace(*brand)
if normalizedVIN != "" && normalizedBrand != "" {
log.Fatal("-vin and -brand cannot be combined")
}
if *seedStream && normalizedVIN != "" { if *seedStream && normalizedVIN != "" {
log.Fatal("-seed-stream-state requires an all-vehicle rebuild") 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 { if *seedStream && *dryRun {
log.Fatal("-seed-stream-state cannot be combined with -dry-run") 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) 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)) startDate := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, location).AddDate(0, 0, -(*lookback - 1))
if *allDates { if *allDates {
var first, last time.Time var first, last time.Time
@@ -112,6 +143,8 @@ func main() {
scope := "all-vehicles" scope := "all-vehicles"
if normalizedVIN != "" { if normalizedVIN != "" {
scope = 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")) 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 !*dryRun {
if *seedStream { if *seedStream {
err = openplatform.ReplaceHydrogenDailyStatsAndSeedStream(ctx, mysqlDB, date, stats) err = openplatform.ReplaceHydrogenDailyStatsAndSeedStream(ctx, mysqlDB, date, stats)
} else if normalizedBrand != "" {
err = openplatform.ReplaceHydrogenDailyStatsForVINs(ctx, mysqlDB, date, brandVINs, stats)
} else if normalizedVIN == "" { } else if normalizedVIN == "" {
err = openplatform.ReplaceHydrogenDailyStats(ctx, mysqlDB, date, stats) err = openplatform.ReplaceHydrogenDailyStats(ctx, mysqlDB, date, stats)
} else { } else {
@@ -150,6 +150,10 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
return 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) required := requiredRole(r)
if roleRank(principal.Role) < roleRank(required) { if roleRank(principal.Role) < roleRank(required) {
httpx.WriteError(w, http.StatusForbidden, "PERMISSION_DENIED", "当前角色无权执行该操作", "需要 "+required+" 角色", requestTraceID(r)) httpx.WriteError(w, http.StatusForbidden, "PERMISSION_DENIED", "当前角色无权执行该操作", "需要 "+required+" 角色", requestTraceID(r))
@@ -243,7 +247,7 @@ func requiredMenu(r *http.Request) string {
switch { switch {
case strings.HasPrefix(path, "/api/v2/monitor"), path == "/api/v2/alerts/events": case strings.HasPrefix(path, "/api/v2/monitor"), path == "/api/v2/alerts/events":
return "monitor" 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" return "shared"
case path == "/api/v2/tracks": case path == "/api/v2/tracks":
return "tracks" return "tracks"
@@ -40,6 +40,11 @@ var customerMenuSet = map[string]bool{
var adminMenus = []string{"monitor", "vehicles", "tracks", "history", "statistics", "alerts", "access", "operations", "users"} 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 { type authUser struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
Username string `json:"username"` 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) { func (s *authStore) principalForUser(ctx context.Context, user authUser) (platform.Principal, error) {
menus := append([]string(nil), adminMenus...) menus := []string{}
vehicles := []string{} vehicles := []string{}
vehicleGrants := []platform.VehicleGrant{} vehicleGrants := []platform.VehicleGrant{}
businessScopeLevel := "" businessScopeLevel := ""
departmentIDs := []string{} departmentIDs := []string{}
responsibleUserID := "" responsibleUserID := ""
if user.UserType == "customer" { if user.UserType == "customer" || user.UserType == "admin" {
menus = []string{}
rows, err := s.db.QueryContext(ctx, `SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key`, user.ID) 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 { if err != nil {
return platform.Principal{}, err return platform.Principal{}, err
@@ -569,14 +573,19 @@ func (s *authStore) principalForUser(ctx context.Context, user authUser) (platfo
rows.Close() rows.Close()
return platform.Principal{}, err return platform.Principal{}, err
} }
if customerMenuSet[value] { if (user.UserType == "customer" && customerMenuSet[value]) || (user.UserType == "admin" && adminMenuSet[value]) {
menus = append(menus, value) menus = append(menus, value)
} }
} }
if err := rows.Close(); err != nil { if err := rows.Close(); err != nil {
return platform.Principal{}, err 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 { if err != nil {
return platform.Principal{}, err return platform.Principal{}, err
} }
@@ -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) { func TestPrincipalForOneOSOrdinaryUserUsesResponsibleVehicles(t *testing.T) {
db, mock, err := sqlmock.New() db, mock, err := sqlmock.New()
if err != nil { if err != nil {
@@ -1,6 +1,7 @@
package app package app
import ( import (
"crypto/sha256"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "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) { func TestMileagePostQueriesAllowCustomerRole(t *testing.T) {
for _, path := range []string{"/api/mileage/daily", "/api/v2/statistics/mileage"} { for _, path := range []string{"/api/mileage/daily", "/api/v2/statistics/mileage"} {
req := httptest.NewRequest(http.MethodPost, path, nil) 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) { func TestAPIAuthMisconfigurationFailsClosed(t *testing.T) {
cases := []config.Config{ cases := []config.Config{
{AuthMode: "enforce"}, {AuthMode: "enforce"},
@@ -620,6 +620,13 @@ components:
format: double format: double
nullable: true nullable: true
description: 单日用氢量,kg;无数据时为 null description: 单日用氢量,kg;无数据时为 null
calculationPhase:
type: string
enum: [PRELIMINARY, FINAL]
description: 当天流式结果为 PRELIMINARY,日终重算结果为 FINAL
algorithmVersion:
type: string
description: 氢耗计算算法版本
status: status:
$ref: '#/components/schemas/DataStatus' $ref: '#/components/schemas/DataStatus'
MileageResult: MileageResult:
@@ -49,6 +49,8 @@ type HydrogenResult struct {
PlateNumber string `json:"plateNumber"` PlateNumber string `json:"plateNumber"`
Date string `json:"date"` Date string `json:"date"`
HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg"` HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg"`
CalculationPhase string `json:"calculationPhase,omitempty"`
AlgorithmVersion string `json:"algorithmVersion,omitempty"`
Status string `json:"status"` Status string `json:"status"`
} }
@@ -281,11 +283,13 @@ type AppCredential struct {
} }
type DailyHydrogen struct { type DailyHydrogen struct {
VIN string VIN string
Date string Date string
ConsumptionKg float64 ConsumptionKg float64
SampleCount int SampleCount int
QualityStatus string QualityStatus string
CalculationPhase string
AlgorithmVersion string
} }
type DailyMileage struct { type DailyMileage struct {
@@ -312,7 +312,8 @@ func (r *MySQLRepository) DailyHydrogen(ctx context.Context, vins []string, date
return map[string]DailyHydrogen{}, nil return map[string]DailyHydrogen{}, nil
} }
query, args := inQuery(` query, args := inQuery(`
SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),consumption_kg,sample_count,quality_status SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),consumption_kg,sample_count,quality_status,
calculation_phase,algorithm_version
FROM vehicle_open_daily_energy FROM vehicle_open_daily_energy
WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins) WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins)
rows, err := r.db.QueryContext(ctx, query, args...) 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)) out := make(map[string]DailyHydrogen, len(vins))
for rows.Next() { for rows.Next() {
var value DailyHydrogen var value DailyHydrogen
if err := rows.Scan(&value.VIN, &value.Date, &value.ConsumptionKg, &value.SampleCount, &value.QualityStatus); 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 return nil, err
} }
out[value.VIN] = value out[value.VIN] = value
@@ -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 // Sampling sufficiency is decided by the producer and persisted in
// quality_status. Imported refuelling-ledger rows can be authoritative // quality_status. Imported refuelling-ledger rows can be authoritative
// with one transaction, while pressure-derived rows require two samples. // with one transaction, while pressure-derived rows require two samples.
if value, ok := values[vehicle.VIN]; ok && strings.EqualFold(value.QualityStatus, "OK") { if value, ok := values[vehicle.VIN]; ok {
consumption := round3(value.ConsumptionKg) item.CalculationPhase = value.CalculationPhase
item.HydrogenConsumptionKg = &consumption item.AlgorithmVersion = value.AlgorithmVersion
item.Status = StatusNormal if strings.EqualFold(value.QualityStatus, "OK") {
consumption := round3(value.ConsumptionKg)
item.HydrogenConsumptionKg = &consumption
item.Status = StatusNormal
}
} }
results = append(results, item) results = append(results, item)
} }
@@ -261,7 +261,7 @@ func TestExternalHydrogenAndMileageQueriesPreserveRequestedVehicles(t *testing.T
"粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"}, "粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"},
}, },
hydrogen: map[string]DailyHydrogen{ 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{ mileage: map[string]DailyMileage{
"LTEST32960VIN0001": { "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 { 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) 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) mileage, err := service.QueryMileage(context.Background(), key, "trace-m", request)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -387,6 +387,33 @@ func LoadHydrogenCapacities(ctx context.Context, db *sql.DB) (map[string]float64
return capacities, rows.Err() 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) { 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 rows, err := db.QueryContext(ctx, `SELECT UPPER(TRIM(vin)),battery_capacity_kwh,hydrogen_energy_kwh_per_kg
FROM vehicle_hydrogen_energy_parameter 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 { 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 // 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 // included in the rebuild, so Kafka backlog already covered by the rebuild is
// ignored instead of counted twice. // ignored instead of counted twice.
func ReplaceHydrogenDailyStatsAndSeedStream(ctx context.Context, db *sql.DB, date string, stats []HydrogenDailyStat) error { 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 { 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) { if !validHydrogenVIN(vin) {
return fmt.Errorf("invalid VIN %q", 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) tx, err := db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return err 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 // A pressure-based rebuild is authoritative for the whole day. Delete every
// previous hydrogen row first so legacy rate/direct-mass results cannot remain // previous hydrogen row first so legacy rate/direct-mass results cannot remain
// for vehicles without valid pressure observations in this run. // 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 { if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN'`, date); err != nil {
return err 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 { } 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 return err
} }
} }
@@ -854,8 +915,11 @@ func replaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date, vin string
} }
} }
for _, stat := range stats { for _, stat := range stats {
if vin != "" && !strings.EqualFold(strings.TrimSpace(stat.VIN), vin) { statVIN := strings.ToUpper(strings.TrimSpace(stat.VIN))
return fmt.Errorf("refusing to write VIN %q during scoped rebuild for %q", stat.VIN, 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) parameterJSON, err := json.Marshal(stat.CalculationParameters)
if err != nil { 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, raw_consumption_kg,battery_soc_delta_pct,battery_discharge_kwh,battery_equivalent_kg,
soc_balanced_consumption_kg,mixed_mileage_km,pure_electric_mileage_km, soc_balanced_consumption_kg,mixed_mileage_km,pure_electric_mileage_km,
consumption_kg_per_100km,soc_balanced_kg_per_100km,charge_count,valid_segment_count, consumption_kg_per_100km,soc_balanced_kg_per_100km,charge_count,valid_segment_count,
invalid_segment_count,algorithm_version,parameter_json,evidence_json invalid_segment_count,algorithm_version,calculation_phase,parameter_json,evidence_json
) VALUES(?,?,'HYDROGEN',?,?,'kg',?,?,?,?,?,?,NOW(3),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, ) VALUES(?,?,'HYDROGEN',?,?,'kg',?,?,?,?,?,?,NOW(3),?,?,?,?,?,?,?,?,?,?,?,?,?,'FINAL',?,?)`,
stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.FirstMassKg, stat.LastMassKg, stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.FirstMassKg, stat.LastMassKg,
stat.SampleCount, stat.RefuelCount, stat.QualityStatus, stat.QualityReason, stat.SampleCount, stat.RefuelCount, stat.QualityStatus, stat.QualityReason,
stat.ConsumptionKg, stat.BatterySOCDeltaPct, stat.BatteryDischargeKWh, stat.BatteryEquivalentKg, stat.ConsumptionKg, stat.BatterySOCDeltaPct, stat.BatteryDischargeKWh, stat.BatteryEquivalentKg,
@@ -883,7 +947,10 @@ INSERT INTO vehicle_open_daily_energy(
); err != nil { ); err != nil {
return err 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) stateJSON, err := hydrogenSegmentStreamSeedJSON(stat)
if err != nil { if err != nil {
return err 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, vin,stat_date,source_endpoint,finalized_consumption_kg,projected_consumption_kg,
sample_count,refuel_count,abnormal_drop_count,eligible_interval_count,qualified_segment_count, 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 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.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.ConsumptionKg,
stat.SampleCount, stat.RefuelCount, stat.AbnormalDropCount, stat.EligibleIntervalCount, 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, stateJSON, stat.QualityStatus, stat.QualityReason,
); err != nil { ); err != nil {
return err return err
@@ -907,54 +974,113 @@ INSERT INTO vehicle_open_hydrogen_segment_stream_state(
} }
func hydrogenSegmentStreamSeedJSON(stat HydrogenDailyStat) ([]byte, error) { func hydrogenSegmentStreamSeedJSON(stat HydrogenDailyStat) ([]byte, error) {
type segmentWindow struct { type point struct {
Count int `json:"count"` EventID string `json:"eventId"`
First []any `json:"first"` ObservedAt time.Time `json:"observedAt"`
Tail []any `json:"tail"` MassKg float64 `json:"massKg"`
CycleMinimumMassKg float64 `json:"cycleMinimumMassKg"` 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 { state := struct {
SourceEndpoint string `json:"sourceEndpoint"` AlgorithmVersion string `json:"algorithmVersion"`
FinalizedConsumptionKg float64 `json:"finalizedConsumptionKg"` SourceEndpoint string `json:"sourceEndpoint"`
SampleCount int `json:"sampleCount"` SampleCount int `json:"sampleCount"`
RefuelCount int `json:"refuelCount"` LastEventID string `json:"lastEventId"`
AbnormalDropCount int `json:"abnormalDropCount"` LastEventTime time.Time `json:"lastEventTime"`
EligibleIntervalCount int `json:"eligibleIntervalCount"` Parameters map[string]float64 `json:"parameters"`
QualifiedSegmentCount int `json:"qualifiedSegmentCount"` FirstMassKg float64 `json:"firstMassKg"`
FirstMassKg float64 `json:"firstMassKg"` LastMassKg float64 `json:"lastMassKg"`
RemainingMassKg float64 `json:"remainingMassKg"` FirstEffective point `json:"firstEffective"`
MetadataCycleMinimumKg float64 `json:"metadataCycleMinimumKg"` LastEffective point `json:"lastEffective"`
LastObservedMassKg float64 `json:"lastObservedMassKg"` HasEffective bool `json:"hasEffective"`
TankCapacityLiters float64 `json:"tankCapacityLiters"` EffectiveRunCount int `json:"effectiveRunCount"`
FirstPressureMpa float64 `json:"firstPressureMpa"` Cycle map[string]any `json:"cycle"`
LastPressureMpa float64 `json:"lastPressureMpa"` InitialStateUsed bool `json:"initialStateUsed"`
FirstTemperatureC float64 `json:"firstTemperatureC"` ChargeCount int `json:"chargeCount"`
LastTemperatureC float64 `json:"lastTemperatureC"` ChargeEnergyKWh float64 `json:"chargeEnergyKWh"`
FirstEventTime time.Time `json:"firstEventTime"` CompletedPureKm float64 `json:"completedPureKm"`
LastEventTime time.Time `json:"lastEventTime"` CompletedMixedKm float64 `json:"completedMixedKm"`
LastEventID string `json:"lastEventId"` CompletedSOCDelta float64 `json:"completedSocDelta"`
LastFuelCellActive bool `json:"lastFuelCellActive"` CompletedMixedCycles int `json:"completedMixedCycles"`
LastFuelCellStateKnown bool `json:"lastFuelCellStateKnown"` HydrogenSegmentStart point `json:"hydrogenSegmentStart"`
Segment segmentWindow `json:"segment"` 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, AlgorithmVersion: trustedHydrogenAlgorithmVersion, SourceEndpoint: stat.Source,
SampleCount: stat.SampleCount, RefuelCount: stat.RefuelCount, SampleCount: stat.SampleCount, LastEventID: stat.LastObservation.EventID, LastEventTime: stat.LastObservation.ObservedAt,
AbnormalDropCount: stat.AbnormalDropCount, EligibleIntervalCount: stat.EligibleIntervalCount, Parameters: map[string]float64{"batteryCapacityKWh": stat.CalculationParameters.BatteryCapacityKWh, "hydrogenEnergyKWhPerKg": stat.CalculationParameters.HydrogenEnergyKWhKg},
QualifiedSegmentCount: stat.QualifiedSegmentCount, FirstMassKg: stat.FirstMassKg, LastMassKg: stat.LastMassKg,
FirstMassKg: stat.FirstMassKg, RemainingMassKg: stat.LastMassKg, FirstEffective: first, LastEffective: last, HasEffective: !last.ObservedAt.IsZero(), EffectiveRunCount: stat.EligibleIntervalCount,
MetadataCycleMinimumKg: stat.CycleMinimumMassKg, Cycle: cycle, InitialStateUsed: true, ChargeCount: stat.ChargeCount, ChargeEnergyKWh: stat.ChargeEnergyKWh,
LastObservedMassKg: stat.LastObservation.MassKg, CompletedPureKm: stat.PureElectricMileageKm, CompletedMixedKm: stat.MixedMileageKm,
TankCapacityLiters: stat.LastObservation.TankCapacityLiter, CompletedSOCDelta: valueOrZero(stat.BatterySOCDeltaPct), CompletedMixedCycles: mixedCycles,
FirstPressureMpa: stat.FirstObservation.PressureMPa, LastPressureMpa: stat.LastObservation.PressureMPa, HydrogenSegmentStart: last, LastHydrogenRun: last, HasHydrogenSegment: !last.ObservedAt.IsZero(), HydrogenSegmentRuns: 1,
FirstTemperatureC: stat.FirstObservation.TemperatureC, LastTemperatureC: stat.LastObservation.TemperatureC, FinalizedHydrogenKg: stat.ConsumptionKg, HydrogenSegments: stat.QualifiedSegmentCount,
FirstEventTime: stat.FirstObservation.ObservedAt, LastEventTime: stat.LastObservation.ObservedAt, RefuelCount: stat.RefuelCount, RefuelAmountKg: stat.RefuelAmountKg,
LastFuelCellActive: stat.LastObservation.FuelCellActive, MinimumPressureMpa: last.PressureMpa, MinimumMassKg: last.MassKg, MinimumObservedAt: last.ObservedAt,
LastFuelCellStateKnown: stat.LastObservation.FuelCellStateKnown, PreviousSample: last, HasPreviousSample: !last.ObservedAt.IsZero(),
Segment: segmentWindow{First: []any{}, Tail: []any{}}, AbnormalDropCount: stat.AbnormalDropCount, InvalidSampleCount: stat.InvalidSegmentCount,
SuspectedLeakCount: stat.SuspectedLeakCount, SuspectedLeakMaxMpa: stat.SuspectedLeakMaxPressureDropMPa,
} }
return json.Marshal(state) return json.Marshal(state)
} }
func valueOrZero(value *float64) float64 {
if value == nil {
return 0
}
return *value
}
func numericValue(value any) (float64, bool) { func numericValue(value any) (float64, bool) {
switch typed := value.(type) { switch typed := value.(type) {
case float64: case float64:
@@ -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) { func TestHydrogenObservationDateRangeForAllUsesBoundedQueries(t *testing.T) {
db, mock, err := sqlmock.New() db, mock, err := sqlmock.New()
if err != nil { 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) { func TestReplaceHydrogenDailyStatsAndSeedStreamIsAtomic(t *testing.T) {
db, mock, err := sqlmock.New() db, mock, err := sqlmock.New()
if err != nil { if err != nil {
@@ -396,7 +447,37 @@ func TestReplaceHydrogenDailyStatsAndSeedStreamIsAtomic(t *testing.T) {
if err := json.Unmarshal(encoded, &state); err != nil { if err := json.Unmarshal(encoded, &state); err != nil {
t.Fatal(err) 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) 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)
}
}
@@ -441,7 +441,7 @@ func TestHandlerVehicleCoverage(t *testing.T) {
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) 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) { if !strings.Contains(rec.Body.String(), want) {
t.Fatalf("response missing %q: %s", want, rec.Body.String()) t.Fatalf("response missing %q: %s", want, rec.Body.String())
} }
@@ -776,6 +776,8 @@ func (m *MockStore) vehicleRowServiceStatus(row VehicleRow) *VehicleServiceStatu
} }
func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[VehicleCoverageRow], error) { func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[VehicleCoverageRow], error) {
m.profileMu.RLock()
defer m.profileMu.RUnlock()
vehicles := filterVehicles(m.vehicles, query) vehicles := filterVehicles(m.vehicles, query)
byVIN := map[string]*VehicleCoverageRow{} byVIN := map[string]*VehicleCoverageRow{}
for _, vehicle := range vehicles { 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)) items := make([]VehicleCoverageRow, 0, len(byVIN))
for _, row := range 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) row.MissingProtocols = missingCanonicalProtocols(row.Protocols)
onlineProtocols := make([]string, 0, row.OnlineSourceCount) onlineProtocols := make([]string, 0, row.OnlineSourceCount)
for _, vehicle := range vehicles { 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) { func (m *MockStore) VehicleServiceOverviews(_ context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
items := make([]VehicleServiceOverview, 0, len(query.Keywords)) 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) { for _, keyword := range normalizedKeywords(query.Keywords) {
vehicles := m.vehiclesForKeyword(keyword, query.Protocol) 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) resolution := buildVehicleIdentityResolution(keyword, vehicles)
identity := resolveVehicleIdentity(keyword, vehicles) identity := resolveVehicleIdentity(keyword, vehicles)
resolvedVIN := "" resolvedVIN := ""
@@ -1241,7 +1265,11 @@ func (m *MockStore) VehicleServiceOverviews(_ context.Context, query VehicleOver
resolvedVIN = resolution.VIN resolvedVIN = resolution.VIN
} }
if resolvedVIN == "" { 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 continue
} }
summary := m.realtimeSummaryForVIN(resolvedVIN, query.Protocol) summary := m.realtimeSummaryForVIN(resolvedVIN, query.Protocol)
@@ -1351,6 +1351,8 @@ type VehicleCoverageRow struct {
Plate string `json:"plate"` Plate string `json:"plate"`
Phone string `json:"phone"` Phone string `json:"phone"`
OEM string `json:"oem"` OEM string `json:"oem"`
BrandName string `json:"brandName"`
ModelName string `json:"modelName"`
Protocols []string `json:"protocols"` Protocols []string `json:"protocols"`
MissingProtocols []string `json:"missingProtocols"` MissingProtocols []string `json:"missingProtocols"`
SourceStatus []VehicleSourceStatus `json:"sourceStatus"` SourceStatus []VehicleSourceStatus `json:"sourceStatus"`
@@ -159,6 +159,7 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
} }
groupSQL := `FROM (` + vehicleSetSQL + `) v ` + groupSQL := `FROM (` + vehicleSetSQL + `) v ` +
`LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin ` + `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 vehicle_realtime_snapshot s ON s.vin = v.vin ` +
`LEFT JOIN business_scope_state bst ON bst.id = 1 ` + `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 ` + `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, ` + Text: `SELECT v.vin, ` +
`COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), b.plate, '') AS plate, ` + `COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), b.plate, '') AS plate, ` +
`COALESCE(b.phone, '') AS phone, COALESCE(b.oem, '') AS oem, ` + `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 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, ` + `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, ` + `COUNT(DISTINCT s.protocol) AS source_count, ` +
@@ -41,14 +41,17 @@ func (p Principal) Clone() Principal {
} }
func (p Principal) CanMenu(key string) bool { func (p Principal) CanMenu(key string) bool {
if p.UserType == "admin" || p.Role == "admin" {
return true
}
for _, value := range p.MenuKeys { for _, value := range p.MenuKeys {
if value == key { if value == key {
return true 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 return false
} }
@@ -321,7 +321,7 @@ func (s *ProductionStore) VehicleCoverage(ctx context.Context, query url.Values)
var protocols string var protocols string
var onlineProtocols string var onlineProtocols string
var online int 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 return Page[VehicleCoverageRow]{}, err
} }
row.Protocols = splitCSV(protocols) row.Protocols = splitCSV(protocols)
@@ -503,7 +503,11 @@ func (s *ProductionStore) VehicleServiceOverviews(ctx context.Context, query Veh
continue continue
} }
resolution := VehicleIdentityResolution{LookupKey: keyword, Protocols: []string{}} 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 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 = ? " protocolJoin = " AND s.protocol = ? "
args = append(args, 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{ return SQLQuery{
Text: `SELECT i.vin, COALESCE(MAX(NULLIF(i.plate, '')), '') AS plate, ` + 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, ` + `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 + ` ` + `WHERE ` + realtimeWhere + ` ` +
`GROUP BY s.vin, b.plate, b.phone, b.oem` + `GROUP BY s.vin, b.plate, b.phone, b.oem` +
`) i LEFT JOIN vehicle_realtime_snapshot s ON s.vin = i.vin ` + protocolJoin + `) 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, Args: args,
} }
} }
@@ -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) { func TestHydrogenRatePer100KmUsesMatchedPureHydrogenMileage(t *testing.T) {
rate := hydrogenRatePer100Km(7.3, 193.3, 2) rate := hydrogenRatePer100Km(7.3, 193.3, 2)
if rate == nil || *rate < 3.77 || *rate > 3.78 { if rate == nil || *rate < 3.77 || *rate > 3.78 {
@@ -77,7 +77,7 @@ func TestBuildVehicleListSQLFiltersServiceStatus(t *testing.T) {
func TestBuildVehicleCoverageSQL(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"}} query := url.Values{"keyword": {"粤A"}, "protocol": {"GB32960"}, "coverage": {"multi"}, "online": {"online"}, "bindingStatus": {"bound"}, "limit": {"8"}, "offset": {"16"}}
built := buildVehicleCoverageSQL(query) 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) { if !strings.Contains(built.Text, want) {
t.Fatalf("SQL missing %q: %s", want, built.Text) t.Fatalf("SQL missing %q: %s", want, built.Text)
} }
@@ -91,10 +91,11 @@ type RawFrameQuery struct {
} }
type VehicleOverviewBatchQuery struct { type VehicleOverviewBatchQuery struct {
Keywords []string `json:"keywords"` Keywords []string `json:"keywords"`
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
Limit int `json:"limit"` Limit int `json:"limit"`
Offset int `json:"offset"` Offset int `json:"offset"`
ScopeVINs []string `json:"-"`
} }
type Service struct { type Service struct {
@@ -869,10 +870,12 @@ func (s *Service) VehicleServiceOverview(ctx context.Context, keyword string, pr
} }
} }
if batchStore, ok := s.store.(VehicleOverviewBatchStore); ok { if batchStore, ok := s.store.(VehicleOverviewBatchStore); ok {
scopeVINs := principalVehicleVINScope(ctx)
page, err := batchStore.VehicleServiceOverviews(ctx, VehicleOverviewBatchQuery{ page, err := batchStore.VehicleServiceOverviews(ctx, VehicleOverviewBatchQuery{
Keywords: []string{keyword}, Keywords: []string{keyword},
Protocol: protocol, Protocol: protocol,
Limit: 1, Limit: 1,
ScopeVINs: scopeVINs,
}) })
if err != nil { if err != nil {
return VehicleServiceOverview{}, err return VehicleServiceOverview{}, err
@@ -927,11 +930,13 @@ func (s *Service) VehicleServiceOverviews(ctx context.Context, query VehicleOver
query.Keywords = keywords query.Keywords = keywords
query.Limit = limit query.Limit = limit
query.Offset = offset query.Offset = offset
query.ScopeVINs = principalVehicleVINScope(ctx)
if batchStore, ok := s.store.(VehicleOverviewBatchStore); ok { if batchStore, ok := s.store.(VehicleOverviewBatchStore); ok {
page, err := batchStore.VehicleServiceOverviews(ctx, query) page, err := batchStore.VehicleServiceOverviews(ctx, query)
if err != nil { if err != nil {
return Page[VehicleServiceOverview]{}, err return Page[VehicleServiceOverview]{}, err
} }
page = restrictVehicleOverviewPage(ctx, page)
page.Total = total page.Total = total
page.Limit = limit page.Limit = limit
page.Offset = offset 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 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) { func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string) (VehicleDetail, error) {
keyword := strings.TrimSpace(vin) keyword := strings.TrimSpace(vin)
protocol = strings.TrimSpace(protocol) protocol = strings.TrimSpace(protocol)
@@ -547,6 +547,7 @@ type countingStore struct {
vehiclesCalls int vehiclesCalls int
vehicleRealtimeCalls int vehicleRealtimeCalls int
overviewBatchCalls int overviewBatchCalls int
lastOverviewBatchQuery VehicleOverviewBatchQuery
lastVehicleQuery url.Values lastVehicleQuery url.Values
lastRealtimeQuery url.Values lastRealtimeQuery url.Values
lastHistoryQuery 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) { func (s *countingStore) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
s.overviewBatchCalls++ s.overviewBatchCalls++
s.lastOverviewBatchQuery = query
return s.MockStore.VehicleServiceOverviews(ctx, 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) { func TestVehicleServiceSummaryCountsProtocolOnlineByProtocolSlot(t *testing.T) {
service := NewService(NewMockStore()) service := NewService(NewMockStore())
summary, err := service.VehicleServiceSummary(context.Background()) summary, err := service.VehicleServiceSummary(context.Background())
@@ -19,9 +19,21 @@ func Handler(dir string, fallback http.Handler) http.Handler {
} }
path := filepath.Join(dir, filepath.Clean(r.URL.Path)) path := filepath.Join(dir, filepath.Clean(r.URL.Path))
if info, err := os.Stat(path); err == nil && !info.IsDir() { if info, err := os.Stat(path); err == nil && !info.IsDir() {
setCachePolicy(w, r.URL.Path)
fs.ServeHTTP(w, r) fs.ServeHTTP(w, r)
return return
} }
w.Header().Set("Cache-Control", "no-store")
http.ServeFile(w, r, filepath.Join(dir, "index.html")) 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")
}
}
@@ -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)
}
}
@@ -136,7 +136,7 @@ function LimitsGuide() {
function APIReference({ kind }: { kind: "hydrogen" | "mileage" }) { function APIReference({ kind }: { kind: "hydrogen" | "mileage" }) {
const product = products[kind === "hydrogen" ? 0 : 1]; const product = products[kind === "hydrogen" ? 0 : 1];
const fields = kind === "hydrogen" ? `"hydrogenConsumptionKg": 12.315` : `"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 <> return <>
<h1>{product.name}</h1><p className="docs-lead">{product.description} AppKey</p> <h1>{product.name}</h1><p className="docs-lead">{product.description} AppKey</p>
<Endpoint method={product.method} path={product.path} /> <Endpoint method={product.method} path={product.path} />
@@ -150,7 +150,7 @@ function APIReference({ kind }: { kind: "hydrogen" | "mileage" }) {
"protocolPriority": ["JT808", "GB32960", "MQTT"]` : ""} "protocolPriority": ["JT808", "GB32960", "MQTT"]` : ""}
}`} /> }`} />
{kind === "mileage" && <><h2 id="协议优先级"></h2><p><code>protocolPriority</code> <code>GB32960</code><code>MQTT</code><code>JT808</code></p><h2 id="缺日补齐"></h2><p><code>dailyMileageKm</code> 0沿<code>updatedAt</code> NO_DATA</p></>} {kind === "mileage" && <><h2 id="协议优先级"></h2><p><code>protocolPriority</code> <code>GB32960</code><code>MQTT</code><code>JT808</code></p><h2 id="缺日补齐"></h2><p><code>dailyMileageKm</code> 0沿<code>updatedAt</code> NO_DATA</p></>}
<h2 id="响应字段"></h2><table className="docs-table"><thead><tr><th></th><th></th><th></th></tr></thead><tbody>{kind === "mileage" && <tr><td><code>vin</code></td><td>string</td><td> VIN</td></tr>}<tr><td><code>plateNumber</code></td><td>string</td><td></td></tr><tr><td><code>date</code></td><td>string</td><td></td></tr><tr><td><code>{kind === "hydrogen" ? "hydrogenConsumptionKg" : "dailyMileageKm"}</code></td><td>number | null</td><td>{kind === "hydrogen" ? "用氢量,单位 kg" : "当日行驶里程,单位 km"}</td></tr>{kind === "mileage" && <><tr><td><code>totalMileageKm</code></td><td>number | null</td><td></td></tr><tr><td><code>dataTime</code></td><td>date-time | null</td><td></td></tr><tr><td><code>updatedAt</code></td><td>date-time | null</td><td></td></tr><tr><td><code>sourceProtocol</code></td><td>string | null</td><td> GB32960MQTT JT808NO_DATA null</td></tr></>}<tr><td><code>status</code></td><td>string</td><td>NORMAL NO_DATA</td></tr></tbody></table> <h2 id="响应字段"></h2><table className="docs-table"><thead><tr><th></th><th></th><th></th></tr></thead><tbody>{kind === "mileage" && <tr><td><code>vin</code></td><td>string</td><td> VIN</td></tr>}<tr><td><code>plateNumber</code></td><td>string</td><td></td></tr><tr><td><code>date</code></td><td>string</td><td></td></tr><tr><td><code>{kind === "hydrogen" ? "hydrogenConsumptionKg" : "dailyMileageKm"}</code></td><td>number | null</td><td>{kind === "hydrogen" ? "用氢量,单位 kg" : "当日行驶里程,单位 km"}</td></tr>{kind === "hydrogen" && <><tr><td><code>calculationPhase</code></td><td>string</td><td> PRELIMINARY FINAL</td></tr><tr><td><code>algorithmVersion</code></td><td>string</td><td></td></tr></>}{kind === "mileage" && <><tr><td><code>totalMileageKm</code></td><td>number | null</td><td></td></tr><tr><td><code>dataTime</code></td><td>date-time | null</td><td></td></tr><tr><td><code>updatedAt</code></td><td>date-time | null</td><td></td></tr><tr><td><code>sourceProtocol</code></td><td>string | null</td><td> GB32960MQTT JT808NO_DATA null</td></tr></>}<tr><td><code>status</code></td><td>string</td><td>NORMAL NO_DATA</td></tr></tbody></table>
<h2 id="响应示例"></h2><CodeBlock language="json" value={`{ <h2 id="响应示例"></h2><CodeBlock language="json" value={`{
"code": "SUCCESS", "code": "SUCCESS",
"message": "success", "message": "success",
@@ -24,6 +24,7 @@ import type {
AlertRuleRevision, AlertRuleRevision,
AlertSummary, AlertSummary,
DailyMileageRow, DailyMileageRow,
HydrogenDailyEvidence,
DashboardSummary, DashboardSummary,
HistoryLocationRow, HistoryLocationRow,
HistoryDataResponse, HistoryDataResponse,
@@ -487,6 +488,10 @@ export const api = {
dailyMileage: (query: MileageQuery, signal?: AbortSignal) => request<Page<DailyMileageRow>>('/api/mileage/daily', withSignal({ dailyMileage: (query: MileageQuery, signal?: AbortSignal) => request<Page<DailyMileageRow>>('/api/mileage/daily', withSignal({
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query) method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
}, signal)), }, signal)),
hydrogenDailyEvidence: (vin: string, date: string, signal?: AbortSignal) => request<HydrogenDailyEvidence>(
`/api/v2/vehicles/${encodeURIComponent(vin)}/hydrogen-evidence?date=${encodeURIComponent(date)}`,
withSignal(undefined, signal)
),
mileageStatistics: (query: MileageQuery, signal?: AbortSignal) => request<MileageStatistics>('/api/v2/statistics/mileage', withSignal({ mileageStatistics: (query: MileageQuery, signal?: AbortSignal) => request<MileageStatistics>('/api/v2/statistics/mileage', withSignal({
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query) method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
}, signal)), }, signal)),
@@ -470,6 +470,8 @@ export interface VehicleCoverageRow {
plate: string; plate: string;
phone: string; phone: string;
oem: string; oem: string;
brandName?: string;
modelName?: string;
protocols: string[]; protocols: string[];
missingProtocols: string[]; missingProtocols: string[];
sourceStatus: VehicleSourceStatus[]; sourceStatus: VehicleSourceStatus[];
@@ -908,10 +910,40 @@ export interface DailyMileageRow {
pureHydrogenMileageKm?: number; pureHydrogenMileageKm?: number;
hydrogenConsumptionKg?: number | null; hydrogenConsumptionKg?: number | null;
hydrogenConsumptionKgPer100Km?: number | null; hydrogenConsumptionKgPer100Km?: number | null;
hydrogenSocBalancedKg?: number | null;
hydrogenSocBalancedKgPer100Km?: number | null;
hydrogenEvidenceAvailable?: boolean;
hydrogenQualityStatus?: string;
hydrogenAlgorithmVersion?: string;
source: string; source: string;
anomalySeverity?: 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 { export interface MileageSummary {
vehicleCount: number; vehicleCount: number;
recordCount: number; recordCount: number;
@@ -261,6 +261,7 @@ export function VehicleDetail({
const displayLookupKey = resolution?.lookupKey || detail?.lookupKey || query.keyword; const displayLookupKey = resolution?.lookupKey || detail?.lookupKey || query.keyword;
const protocols = useMemo(() => resolution?.protocols?.length ? resolution.protocols : detail?.sources ?? [], [detail?.sources, resolution?.protocols]); const protocols = useMemo(() => resolution?.protocols?.length ? resolution.protocols : detail?.sources ?? [], [detail?.sources, resolution?.protocols]);
const latestRaw = detail?.raw?.items?.[0]; const latestRaw = detail?.raw?.items?.[0];
const latestRawType = latestRaw?.frameType === 'realtime_projection' ? '最新状态投影' : latestRaw?.frameType;
const qualityCount = detail?.quality?.total ?? 0; const qualityCount = detail?.quality?.total ?? 0;
const priorityQualityIssue = useMemo(() => { const priorityQualityIssue = useMemo(() => {
const items = detail?.quality?.items ?? []; const items = detail?.quality?.items ?? [];
@@ -617,7 +618,7 @@ export function VehicleDetail({
{ {
label: '历史数据', label: '历史数据',
value: formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0), 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 color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const
}, },
{ {
@@ -766,7 +767,7 @@ export function VehicleDetail({
{ {
title: '历史数据', title: '历史数据',
value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)}`, 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, color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
detail: '查看历史明细和字段明细,用于复核定位、里程和告警依据。', detail: '查看历史明细和字段明细,用于复核定位、里程和告警依据。',
disabled: !hasResolvedVIN, disabled: !hasResolvedVIN,
@@ -863,7 +864,7 @@ export function VehicleDetail({
}, },
{ {
title: '查询历史数据', title: '查询历史数据',
evidence: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 条历史数据,最新类型 ${latestRaw?.frameType || '-'}`, evidence: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 条历史数据,最新类型 ${latestRawType || '-'}`,
acceptance: '关键字段来自解析字段而不是页面二次推断。', acceptance: '关键字段来自解析字段而不是页面二次推断。',
action: '历史数据', action: '历史数据',
disabled: !hasResolvedVIN, disabled: !hasResolvedVIN,
@@ -938,7 +939,7 @@ export function VehicleDetail({
{ {
title: '查询历史数据', title: '查询历史数据',
value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)}`, 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, color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
primaryAction: '历史数据', primaryAction: '历史数据',
secondaryAction: '导出档案', secondaryAction: '导出档案',
@@ -991,7 +992,7 @@ export function VehicleDetail({
{ {
title: '历史数据查询', title: '历史数据查询',
value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)}`, 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, color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
action: '查询历史', action: '查询历史',
disabled: !hasResolvedVIN, disabled: !hasResolvedVIN,
@@ -1047,7 +1048,7 @@ export function VehicleDetail({
{ {
title: '数据导出', title: '数据导出',
value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)}`, value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)}`,
detail: latestRaw?.frameType ? `最新 ${latestRaw.frameType}` : '历史明细与字段', detail: latestRawType ? `最新 ${latestRawType}` : '历史明细与字段',
action: '历史导出', action: '历史导出',
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const, color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
disabled: !hasResolvedVIN, disabled: !hasResolvedVIN,
@@ -1085,7 +1086,7 @@ export function VehicleDetail({
{ {
title: '历史证据', title: '历史证据',
value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)}`, value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)}`,
detail: latestRaw?.frameType ? `最新 ${latestRaw.frameType}` : '历史明细与解析字段', detail: latestRawType ? `最新 ${latestRawType}` : '历史明细与解析字段',
action: '历史查询', action: '历史查询',
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const, color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
disabled: !hasResolvedVIN, disabled: !hasResolvedVIN,
@@ -2175,7 +2176,7 @@ export function VehicleDetail({
dataSource={detail?.raw?.items ?? []} dataSource={detail?.raw?.items ?? []}
columns={[ columns={[
{ title: '来源', dataIndex: 'protocol', width: 130 }, { 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: '大小 B', dataIndex: 'rawSizeBytes', width: 100 },
{ title: '设备时间', dataIndex: 'deviceTime', width: 190 }, { title: '设备时间', dataIndex: 'deviceTime', width: 190 },
{ title: '入库时间', dataIndex: 'serverTime', width: 190 } { title: '入库时间', dataIndex: 'serverTime', width: 190 }
@@ -75,6 +75,28 @@ test('presents failed credentials as a clear Semi UI alert without losing the lo
expect(screen.getByPlaceholderText('请输入用户名')).toBeInTheDocument(); 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(<QueryClientProvider client={client}><AuthGate><div></div></AuthGate></QueryClientProvider>);
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 () => { test('treats login and logout as complete query and mutation cache boundaries', async () => {
mocks.logout.mockResolvedValue({ loggedOut: true }); mocks.logout.mockResolvedValue({ loggedOut: true });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -192,7 +192,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
return <AuthLoadingState />; return <AuthLoadingState />;
} }
if (!session.data) { 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 <div className="v2-auth-screen"> return <div className="v2-auth-screen">
<section className="v2-auth-intro" aria-labelledby="v2-auth-intro-title"> <section className="v2-auth-intro" aria-labelledby="v2-auth-intro-title">
<header> <header>
@@ -1,5 +1,15 @@
import { afterEach, expect, test, vi } from 'vitest'; 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(() => { afterEach(() => {
window.sessionStorage.clear(); 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); 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', () => { test('invalidates only the currently active rejected token', () => {
const unauthorized = vi.fn(); const unauthorized = vi.fn();
window.addEventListener(PLATFORM_UNAUTHORIZED_EVENT, unauthorized); window.addEventListener(PLATFORM_UNAUTHORIZED_EVENT, unauthorized);
@@ -29,9 +29,8 @@ export function canOperate(session: PlatformSession) {
} }
export function hasMenu(session: PlatformSession, menu: string) { export function hasMenu(session: PlatformSession, menu: string) {
if (session.role === 'admin') return true; if (session.menuKeys) return session.menuKeys.includes(menu);
if (!session.menuKeys) return session.role !== 'customer'; return session.role !== 'customer';
return session.menuKeys.includes(menu);
} }
export function canAdminister(session: PlatformSession) { export function canAdminister(session: PlatformSession) {
@@ -10,8 +10,8 @@ test('creates a styled numeric mileage workbook with formulas and frozen panes',
dateTo: '2026-07-14', dateTo: '2026-07-14',
dates: ['2026-07-13', '2026-07-14'], dates: ['2026-07-13', '2026-07-14'],
vehicles: [ vehicles: [
{ vin: 'LTEST000000000001', plate: '粤A12345' }, { vin: 'LTEST000000000001', plate: '粤A12345', brandName: '现代', modelName: 'XCIENT' },
{ vin: 'LTEST000000000002', plate: '粤A54321' } { vin: 'LTEST000000000002', plate: '粤A54321', brandName: '飞驰', modelName: 'FSQ' }
], ],
mileageRows: [ mileageRows: [
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 100, endMileageKm: 188.7, dailyMileageKm: 88.7, pureHydrogenMileageKm: 56.2, hydrogenConsumptionKg: 3.1, hydrogenConsumptionKgPer100Km: 5.5, source: 'GB32960' }, { vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 100, endMileageKm: 188.7, dailyMileageKm: 88.7, pureHydrogenMileageKm: 56.2, 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('里程查询')!; const sheet = workbook.getWorksheet('里程查询')!;
expect(sheet.getCell('A1').value).toBe('车辆里程查询明细'); expect(sheet.getCell('A1').value).toBe('车辆里程查询明细');
expect(sheet.getCell('C6').value).toBeInstanceOf(Date); expect(sheet.getCell('C6').value).toBe('品牌');
expect(sheet.getCell('C6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'center' }); expect(sheet.getCell('D6').value).toBe('车型');
expect(sheet.getCell('D6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'center' }); 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('A6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'left' });
expect(sheet.getCell('E6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'right' }); expect(sheet.getCell('G6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'right' });
expect(sheet.getCell('C7').value).toBe(88.7); expect(sheet.getCell('C7').value).toBe('现代');
expect(sheet.getCell('E7').value).toMatchObject({ formula: 'SUM(C7:D7)', result: 193.3 }); expect(sheet.getCell('D7').value).toBe('XCIENT');
expect(sheet.getCell('E7').numFmt).toBe('#,##0.0" km"'); expect(sheet.getCell('E7').value).toBe(88.7);
expect(sheet.views[0]).toMatchObject({ state: 'frozen', xSplit: 2, ySplit: 6, showGridLines: false }); expect(sheet.getCell('G7').value).toMatchObject({ formula: 'SUM(E7:F7)', result: 193.3 });
expect(sheet.autoFilter).toEqual({ from: { row: 6, column: 1 }, to: { row: 8, column: 5 } }); 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); expect((sheet as unknown as { conditionalFormattings: unknown[] }).conditionalFormattings).toHaveLength(1);
const hydrogenSheet = workbook.getWorksheet('氢能明细')!; const hydrogenSheet = workbook.getWorksheet('氢能明细')!;
expect(hydrogenSheet.getRow(1).values).toEqual([ expect(hydrogenSheet.getRow(1).values).toEqual([
undefined, '日期', '车牌', 'VIN', '总里程 (km)', '纯氢里程 (km)', '耗氢 (kg)', '百公里纯氢耗 (kg/100km)', '来源' undefined, '日期', '车牌', 'VIN', '品牌', '车型', '总里程 (km)', '纯氢里程 (km)', '耗氢 (kg)', '百公里纯氢耗 (kg/100km)', '来源'
]); ]);
expect(hydrogenSheet.getRow(2).values).toEqual([ 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); expect((await workbook.xlsx.writeBuffer()).byteLength).toBeGreaterThan(5_000);
}); });
@@ -1,7 +1,7 @@
import type { DailyMileageRow } from '../../api/types'; import type { DailyMileageRow } from '../../api/types';
import { downloadBlob } from './download'; 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 MileageExportSource = { protocol: string; label: string; mileageType: string };
export type MileageWorkbookRow = Pick<DailyMileageRow, 'vin' | 'date' | 'dailyMileageKm'> & Partial<Omit<DailyMileageRow, 'vin' | 'date' | 'dailyMileageKm'>>; export type MileageWorkbookRow = Pick<DailyMileageRow, 'vin' | 'date' | 'dailyMileageKm'> & Partial<Omit<DailyMileageRow, 'vin' | 'date' | 'dailyMileageKm'>>;
@@ -27,11 +27,11 @@ export async function createMileageWorkbook(input: MileageExportInput) {
workbook.calcProperties.fullCalcOnLoad = true; workbook.calcProperties.fullCalcOnLoad = true;
const sheet = workbook.addWorksheet('里程查询', { 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 } }, 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 } properties: { defaultRowHeight: 21 }
}); });
const firstDateColumn = 3; const firstDateColumn = 5;
const lastDateColumn = firstDateColumn + input.dates.length - 1; const lastDateColumn = firstDateColumn + input.dates.length - 1;
const totalColumn = lastDateColumn + 1; const totalColumn = lastDateColumn + 1;
const lastColumnLetter = excelColumn(totalColumn); const lastColumnLetter = excelColumn(totalColumn);
@@ -72,14 +72,14 @@ export async function createMileageWorkbook(input: MileageExportInput) {
sheet.getRow(5).height = 8; sheet.getRow(5).height = 8;
const header = sheet.getRow(headerRowNumber); 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.height = 30;
header.eachCell((cell, columnNumber) => { header.eachCell((cell, columnNumber) => {
const isDateHeader = columnNumber >= firstDateColumn && columnNumber <= lastDateColumn; const isDateHeader = columnNumber >= firstDateColumn && columnNumber <= lastDateColumn;
cell.font = { name: 'Microsoft YaHei', size: 10, bold: true, color: { argb: columnNumber === totalColumn ? 'FF1C4F91' : 'FF304158' } }; cell.font = { name: 'Microsoft YaHei', size: 10, bold: true, color: { argb: columnNumber === totalColumn ? 'FF1C4F91' : 'FF304158' } };
cell.alignment = { cell.alignment = {
vertical: 'middle', 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.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: columnNumber === totalColumn ? 'FFDCEBFF' : 'FFEAF0F7' } };
cell.border = { bottom: { style: 'medium', color: { argb: 'FFC3D0DF' } } }; 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 dailyValues = input.dates.map((date) => mileageByDate.get(date)?.dailyMileageKm ?? null);
const total = dailyValues.reduce<number>((sum, value) => sum + (value ?? 0), 0); const total = dailyValues.reduce<number>((sum, value) => sum + (value ?? 0), 0);
const row = sheet.getRow(rowNumber); 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.height = 25;
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => { 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.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.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFF9FBFD' : 'FFFFFFFF' } };
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } }; cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } };
if (columnNumber >= firstDateColumn) cell.numFmt = '#,##0.0" km"'; 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(1).width = 15;
sheet.getColumn(2).width = 24; 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; for (let column = firstDateColumn; column <= lastDateColumn; column += 1) sheet.getColumn(column).width = 12;
sheet.getColumn(totalColumn).width = 17; sheet.getColumn(totalColumn).width = 17;
if (input.vehicles.length) { if (input.vehicles.length) {
@@ -135,7 +137,7 @@ export async function createMileageWorkbook(input: MileageExportInput) {
views: [{ state: 'frozen', ySplit: 1, activeCell: 'A2', showGridLines: false }], views: [{ state: 'frozen', ySplit: 1, activeCell: 'A2', showGridLines: false }],
properties: { defaultRowHeight: 21 } properties: { defaultRowHeight: 21 }
}); });
hydrogenSheet.addRow(['日期', '车牌', 'VIN', '总里程 (km)', '纯氢里程 (km)', '耗氢 (kg)', '百公里纯氢耗 (kg/100km)', '来源']); hydrogenSheet.addRow(['日期', '车牌', 'VIN', '品牌', '车型', '总里程 (km)', '纯氢里程 (km)', '耗氢 (kg)', '百公里纯氢耗 (kg/100km)', '来源']);
const hydrogenHeader = hydrogenSheet.getRow(1); const hydrogenHeader = hydrogenSheet.getRow(1);
hydrogenHeader.height = 30; hydrogenHeader.height = 30;
hydrogenHeader.eachCell((cell) => { hydrogenHeader.eachCell((cell) => {
@@ -144,13 +146,16 @@ export async function createMileageWorkbook(input: MileageExportInput) {
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEAF0F7' } }; cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEAF0F7' } };
cell.border = { bottom: { style: 'medium', color: { argb: 'FFC3D0DF' } } }; 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)); const energyRows = [...input.mileageRows].sort((left, right) => left.date.localeCompare(right.date) || left.vin.localeCompare(right.vin));
energyRows.forEach((value, index) => { energyRows.forEach((value, index) => {
const vehicle = vehicleByVIN.get(value.vin);
const row = hydrogenSheet.addRow([ const row = hydrogenSheet.addRow([
value.date, value.date,
plateByVIN.get(value.vin) ?? value.plate ?? '未绑定', vehicle?.plate || value.plate || '未绑定',
value.vin, value.vin,
vehicle?.brandName || '待维护',
vehicle?.modelName || '待维护',
value.dailyMileageKm, value.dailyMileageKm,
value.pureHydrogenMileageKm ?? null, value.pureHydrogenMileageKm ?? null,
value.hydrogenConsumptionKg ?? null, value.hydrogenConsumptionKg ?? null,
@@ -160,17 +165,17 @@ export async function createMileageWorkbook(input: MileageExportInput) {
row.height = 24; row.height = 24;
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => { row.eachCell({ includeEmpty: true }, (cell, columnNumber) => {
cell.font = { name: columnNumber === 3 ? 'Consolas' : 'Microsoft YaHei', size: columnNumber === 3 ? 9 : 10, color: { argb: 'FF34445A' } }; cell.font = { name: columnNumber === 3 ? 'Consolas' : 'Microsoft YaHei', size: columnNumber === 3 ? 9 : 10, color: { argb: 'FF34445A' } };
cell.alignment = { vertical: 'middle', horizontal: columnNumber >= 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.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFF9FBFD' : 'FFFFFFFF' } };
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } }; cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } };
if (columnNumber >= 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; hydrogenSheet.getColumn(index + 1).width = width;
}); });
if (energyRows.length) { if (energyRows.length) {
hydrogenSheet.autoFilter = { from: { row: 1, column: 1 }, to: { row: energyRows.length + 1, column: 8 } }; hydrogenSheet.autoFilter = { from: { row: 1, column: 1 }, to: { row: energyRows.length + 1, column: 10 } };
} }
hydrogenSheet.headerFooter.oddFooter = sheet.headerFooter.oddFooter; hydrogenSheet.headerFooter.oddFooter = sheet.headerFooter.oddFooter;
return workbook; return workbook;
@@ -136,6 +136,23 @@ test('summarizes only material automation changes before release', () => {
{ key: 'status', label: '发布状态', before: '启用', after: '停用' } { key: 'status', label: '发布状态', before: '启用', after: '停用' }
]); ]);
expect(automationReleaseChanges(baseline, baseline)).toEqual([]); 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 () => { 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: '事件自动化编辑' }); const editor = await screen.findByRole('dialog', { name: '事件自动化编辑' });
expect(editor.closest('.v2-alert-rule-editor-dialog')).toBeInTheDocument(); expect(editor.closest('.v2-alert-rule-editor-dialog')).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /自动化/ })).toHaveAttribute('aria-selected', 'true'); 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(); expect(screen.getByLabelText('自动化实时摘要')).toBeInTheDocument();
const nameInput = screen.getByLabelText('规则名称'); const nameInput = screen.getByLabelText('规则名称');
const thresholdInput = 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')).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(document.querySelector('.v2-alert-rule-editor-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(96dvh, 920px)' });
expect(within(editor).getByDisplayValue('测试超速规则')).toBeInTheDocument(); 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).getByRole('heading', { name: '什么时候触发' })).toBeInTheDocument();
expect(within(editor).queryByRole('heading', { name: '哪些车辆生效' })).not.toBeInTheDocument(); expect(within(editor).queryByRole('heading', { name: '哪些车辆生效' })).not.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(); expect(within(editor).getByRole('heading', { name: '哪些车辆生效' })).toBeInTheDocument();
fireEvent.click(within(editor).getByRole('button', { name: '下一步:执行动作' })); fireEvent.click(within(editor).getByRole('button', { name: '下一步:执行动作' }));
expect(within(editor).getByRole('heading', { name: '系统自动做什么' })).toBeInTheDocument(); expect(within(editor).getByRole('heading', { name: '系统自动做什么' })).toBeInTheDocument();
fireEvent.click(within(editor).getByRole('button', { name: '下一步:测试规则' })); fireEvent.click(within(editor).getByRole('button', { name: '下一步:检查发布' }));
expect(within(editor).getByRole('region', { name: '样例事件测试' })).toBeInTheDocument(); expect(within(editor).getByRole('region', { name: /样例事件测试/ })).toBeInTheDocument();
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled(); expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled();
fireEvent.click(within(editor).getByRole('button', { name: '运行测试' })); fireEvent.click(within(editor).getByRole('button', { name: '运行测试' }));
expect(await within(editor).findByText(/测试成功/)).toBeInTheDocument(); expect(await within(editor).findByText(/测试成功/)).toBeInTheDocument();
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled(); expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled();
fireEvent.click(within(editor).getByRole('button', { name: '编辑事件来源' })); fireEvent.click(within(editor).getByRole('button', { name: '编辑事件来源' }));
fireEvent.change(within(editor).getByDisplayValue('测试超速规则'), { target: { value: '测试超速规则 v4' } }); 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: '运行测试' })); fireEvent.click(within(editor).getByRole('button', { name: '运行测试' }));
expect(await within(editor).findByText(/测试成功/)).toBeInTheDocument(); expect(await within(editor).findByText(/测试成功/)).toBeInTheDocument();
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeEnabled(); 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()); 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.alertRulesV2.mockResolvedValue([alertRule()]);
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 3, offset: 0 }); mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 3, offset: 0 });
mocks.metricCatalog.mockResolvedValue({ 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(editor.closest('.v2-alert-rule-editor-dialog')).toBeInTheDocument();
expect(document.querySelector('.v2-alert-rule-editor-sidesheet')).not.toBeInTheDocument(); expect(document.querySelector('.v2-alert-rule-editor-sidesheet')).not.toBeInTheDocument();
expect(within(editor).getByLabelText('自动化配置摘要')).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).getByRole('region', { name: '执行跟踪' })).toBeInTheDocument();
expect(within(editor).getByText('0 项变更')).toBeInTheDocument(); expect(within(editor).getByText('0 项变更')).toBeInTheDocument();
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled(); 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(); expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled();
fireEvent.click(within(editor).getByRole('button', { name: '编辑事件来源' })); fireEvent.click(within(editor).getByRole('button', { name: '编辑事件来源' }));
fireEvent.change(within(editor).getByDisplayValue('测试超速规则'), { target: { value: '测试超速规则 v4' } }); 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(); 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('button', { name: '发布更新' })).toBeEnabled();
expect(within(editor).getByRole('status')).toHaveTextContent('配置已就绪,可直接发布更新');
fireEvent.click(within(editor).getByRole('button', { name: '上一步' })); fireEvent.click(within(editor).getByRole('button', { name: '上一步' }));
expect(within(editor).getByRole('heading', { name: '系统自动做什么' })).toBeInTheDocument(); expect(within(editor).getByRole('heading', { name: '系统自动做什么' })).toBeInTheDocument();
expect(within(editor).queryByRole('heading', { name: '什么时候触发' })).not.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: /事件与条件/ })); fireEvent.click(within(editor).getByRole('button', { name: /事件与条件/ }));
expect(within(editor).getByRole('heading', { name: '什么时候触发' })).toBeInTheDocument(); 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(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=rules&automationId=hydrogen-pressure-rule']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
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 () => { test('copies an automation into an audited disabled draft without changing the source rule', async () => {
const source = alertRule(); const source = alertRule();
const created = { ...source, id: 'speed-rule-copy', name: '测试超速规则(副本)', enabled: false, version: 1 }; 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).getByText('2 项变更')).toBeInTheDocument();
expect(within(editor).getByRole('list', { name: '自动化待发布变更' })).toHaveTextContent('测试超速规则(副本)'); expect(within(editor).getByRole('list', { name: '自动化待发布变更' })).toHaveTextContent('测试超速规则(副本)');
expect(within(editor).getByText('副本将保持停用')).toBeInTheDocument(); 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(); expect(within(editor).getByRole('button', { name: '创建副本' })).toBeEnabled();
fireEvent.click(within(editor).getByRole('button', { name: '创建副本' })); 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(); expect(within(fenceEditor).getByDisplayValue('VIN-QUICK-001')).toBeInTheDocument();
fireEvent.click(within(fenceEditor).getByRole('button', { name: '下一步:执行动作' })); fireEvent.click(within(fenceEditor).getByRole('button', { name: '下一步:执行动作' }));
expect(within(fenceEditor).getAllByText('发送高优先级站内通知').length).toBeGreaterThan(0); expect(within(fenceEditor).getAllByText('发送高优先级站内通知').length).toBeGreaterThan(0);
fireEvent.click(within(fenceEditor).getByRole('button', { name: '下一步:测试规则' })); fireEvent.click(within(fenceEditor).getByRole('button', { name: '下一步:检查发布' }));
expect(within(fenceEditor).getByRole('button', { name: '发布自动化' })).toBeDisabled(); expect(within(fenceEditor).getByRole('button', { name: '发布自动化' })).toBeEnabled();
fireEvent.click(within(fenceEditor).getByRole('button', { name: '运行测试' })); fireEvent.click(within(fenceEditor).getByRole('button', { name: '运行测试' }));
expect(await within(fenceEditor).findByText(/测试成功/)).toBeInTheDocument(); expect(await within(fenceEditor).findByText(/测试成功/)).toBeInTheDocument();
expect(within(fenceEditor).getByRole('button', { name: '发布自动化' })).toBeEnabled(); expect(within(fenceEditor).getByRole('button', { name: '发布自动化' })).toBeEnabled();
@@ -85,7 +85,7 @@ const AUTOMATION_EDITOR_STEPS: Array<{ step: AutomationEditorStep; label: string
{ step: 1, label: '事件与条件', description: '定义触发信号' }, { step: 1, label: '事件与条件', description: '定义触发信号' },
{ step: 2, label: '车辆范围', description: '选择生效对象' }, { step: 2, label: '车辆范围', description: '选择生效对象' },
{ step: 3, label: '执行动作', description: '通知、恢复与冷却' }, { step: 3, label: '执行动作', description: '通知、恢复与冷却' },
{ step: 4, label: '测试并发布', description: '验证后正式启用' } { step: 4, label: '检查并发布', description: '确认配置后发布' }
]; ];
export function alertFiltersFromParams(params: URLSearchParams): Filters { 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 type AutomationReleaseChange = { key: string; label: string; before: string; after: string };
export function automationReleaseChanges(baseline: AlertRuleInput, draft: AlertRuleInput, catalogLabels: Record<string, string> = {}, catalogUnits: Record<string, string> = {}): AutomationReleaseChange[] { export function automationReleaseChanges(baseline: AlertRuleInput, draft: AlertRuleInput, catalogLabels: Record<string, string> = {}, catalogUnits: Record<string, string> = {}): 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) => ({ const snapshot = (rule: AlertRuleInput) => ({
name: rule.name || '未命名自动化', 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), scope: ruleScopeSummary(rule),
action: `${ruleActionSummary(rule)} · ${rule.repeatIntervalSec ? `${formatAlertDuration(rule.repeatIntervalSec)}内不重复` : '允许每次匹配执行'}`, 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 ? '启用' : '停用' status: rule.enabled ? '启用' : '停用'
}); });
const before = snapshot(baseline); const before = snapshot(baseline);
const after = snapshot(draft); const after = snapshot(draft);
return [ return [
{ key: 'name', label: '规则名称', before: before.name, after: after.name }, changed((rule) => rule.name) ? { key: 'name', label: '规则名称', before: before.name, after: after.name } : undefined,
{ key: 'event', label: '事件与条件', before: before.event, after: after.event }, 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,
{ key: 'scope', label: '车辆范围', before: before.scope, after: after.scope }, 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,
{ key: 'action', label: '执行动作', before: before.action, after: after.action }, 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,
{ key: 'status', label: '发布状态', before: before.status, after: after.status } changed((rule) => ({ recoveryOperator: rule.recoveryOperator, recoveryThreshold: rule.recoveryThreshold })) ? { key: 'recovery', label: '恢复条件', before: before.recovery, after: after.recovery } : undefined,
].filter((item) => item.before !== item.after); 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 }) { 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); moveToStep((editorStep + 1) as AutomationEditorStep);
return; return;
} }
if (testRan) save.mutate(draft); save.mutate(draft);
}; };
const openNewRule = () => { const openNewRule = () => {
const nextDraft = emptyRule(); const nextDraft = emptyRule();
@@ -1168,7 +1177,7 @@ function RulesWorkspace({ page, rulesReady, rulesLoading, rulesError, onRetryRul
</div> </div>
<div className="v2-automation-review-test"> <div className="v2-automation-review-test">
<section className="v2-automation-sample-test" aria-labelledby="v2-automation-review-test-title"> <section className="v2-automation-sample-test" aria-labelledby="v2-automation-review-test-title">
<header><span><strong id="v2-automation-review-test-title"></strong><small>使</small></span><Button htmlType="button" theme="solid" disabled={Boolean(validationError)} onClick={() => setTestRan(true)}></Button></header> <header><span><strong id="v2-automation-review-test-title"></strong><small>使</small></span><Button htmlType="button" theme="solid" disabled={Boolean(validationError)} onClick={() => setTestRan(true)}></Button></header>
<div className="v2-automation-review-fields"><span><small>event.type</small><code>{automationEventType(draft)}</code></span><span><small>source.protocol</small><code>{draft.scopeProtocols[0] || 'JT808'}</code></span><span><small>subject.vin</small><code>{draft.scopeVins[0] || 'LFP23A98V2P012345'}</code></span><span><small>occurred_at</small><code>2026-07-22 14:30:04</code></span><span><small>{draft.triggerType === 'geofence' ? 'payload.geofence' : `payload.${draft.metric}`}</small><code>{ruleCondition(draft, catalogLabels, catalogUnits, false)}</code></span><span><small>planned.action</small><code>{ruleActionSummary(draft)}</code></span></div> <div className="v2-automation-review-fields"><span><small>event.type</small><code>{automationEventType(draft)}</code></span><span><small>source.protocol</small><code>{draft.scopeProtocols[0] || 'JT808'}</code></span><span><small>subject.vin</small><code>{draft.scopeVins[0] || 'LFP23A98V2P012345'}</code></span><span><small>occurred_at</small><code>2026-07-22 14:30:04</code></span><span><small>{draft.triggerType === 'geofence' ? 'payload.geofence' : `payload.${draft.metric}`}</small><code>{ruleCondition(draft, catalogLabels, catalogUnits, false)}</code></span><span><small>planned.action</small><code>{ruleActionSummary(draft)}</code></span></div>
</section> </section>
<section className="v2-automation-review-trace" aria-labelledby="v2-automation-review-trace-title"><header><strong id="v2-automation-review-trace-title"></strong><small></small></header>{testRan ? <ol className="v2-automation-test-trace"><li><IconTickCircle /><span><strong></strong><small></small></span></li><li><IconTickCircle /><span><strong></strong><small>{ruleCondition(draft, catalogLabels, catalogUnits, false)}</small></span></li><li><IconTickCircle /><span><strong>{ruleActionSummary(draft)}</strong><small></small></span></li></ol> : <p className="v2-automation-test-empty"></p>}</section> <section className="v2-automation-review-trace" aria-labelledby="v2-automation-review-trace-title"><header><strong id="v2-automation-review-trace-title"></strong><small></small></header>{testRan ? <ol className="v2-automation-test-trace"><li><IconTickCircle /><span><strong></strong><small></small></span></li><li><IconTickCircle /><span><strong></strong><small>{ruleCondition(draft, catalogLabels, catalogUnits, false)}</small></span></li><li><IconTickCircle /><span><strong>{ruleActionSummary(draft)}</strong><small></small></span></li></ol> : <p className="v2-automation-test-empty"></p>}</section>
@@ -1176,16 +1185,18 @@ function RulesWorkspace({ page, rulesReady, rulesLoading, rulesError, onRetryRul
</div> </div>
</div> </div>
</div>; </div>;
const editorPrimaryLabel = save.isPending ? '发布中' : editorStep === 1 ? '下一步:车辆范围' : editorStep === 2 ? '下一步:执行动作' : editorStep === 3 ? '下一步:测试规则' : cloneSource ? '创建副本' : draft.version ? '发布更新' : '发布自动化'; const editorPrimaryLabel = save.isPending ? '发布中' : editorStep === 1 ? '下一步:车辆范围' : editorStep === 2 ? '下一步:执行动作' : editorStep === 3 ? '下一步:检查发布' : cloneSource ? '创建副本' : draft.version ? '发布更新' : '发布自动化';
const editorGuidance = editorStep === 1 ? '先完成事件与条件,后续步骤只展示相关配置。' : editorStep === 2 ? '可以搜索并多选车辆;留空代表全部授权车辆。' : editorStep === 3 ? '动作配置完成后进入样例测试,不会立即发送通知。' : '样例测试只验证契约与计划动作,不会产生真实通知。'; const editorGuidance = editorStep === 1 ? '先完成事件与条件,后续步骤只展示相关配置。' : editorStep === 2 ? '可以搜索并多选车辆;留空代表全部授权车辆。' : editorStep === 3 ? '动作配置完成后进入发布检查,不会立即发送通知。' : '配置有效即可发布;样例测试为可选预演,不会产生真实通知。';
const editorFooterNote = currentStepError const editorFooterNote = currentStepError
? <em className="v2-automation-editor-footer-error" role="alert">{currentStepError}</em> ? <em className="v2-automation-editor-footer-error" role="alert">{currentStepError}</em>
: mutationError : mutationError
? <em className="v2-automation-editor-footer-error" role="alert">{mutationError.message}</em> ? <em className="v2-automation-editor-footer-error" role="alert">{mutationError.message}</em>
: reviewMode && draft.version > 0 && !draftDirty
? <span>线</span>
: draftDirty : draftDirty
? <span className="v2-automation-editor-draft-status" role="status">稿 · </span> ? <span className="v2-automation-editor-draft-status" role="status">{reviewMode ? '配置已就绪,可直接发布更新' : '草稿未保存 · 关闭前会再次确认'}</span>
: editorGuidance; : 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 = () => { const closeEditor = () => {
setDiscardConfirmOpen(false); setDiscardConfirmOpen(false);
setCloneSource(undefined); setCloneSource(undefined);
@@ -2,12 +2,12 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest'; import { afterEach, expect, test, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom'; 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 { buildMonitorPath, withMonitorReturn } from '../routing/monitorContext';
import { ROUTER_FUTURE } from '../routing/routerConfig'; import { ROUTER_FUTURE } from '../routing/routerConfig';
import { buildVehicleDetailPath, withVehicleReturn } from '../routing/vehicleContext'; 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 exportMocks = vi.hoisted(() => ({ createMileageExportStream: vi.fn(), appendRows: vi.fn(), finish: vi.fn(), dispose: vi.fn() }));
const layout = vi.hoisted(() => ({ mobile: false })); const layout = vi.hoisted(() => ({ mobile: false }));
const auth = vi.hoisted(() => ({ role: 'admin' as 'admin' | 'customer', userType: 'admin' as 'admin' | 'customer' })); const auth = vi.hoisted(() => ({ role: 'admin' as 'admin' | 'customer', userType: 'admin' as 'admin' | 'customer' }));
@@ -29,17 +29,28 @@ function prepareData() {
mocks.mileageStatistics.mockResolvedValue({ mocks.mileageStatistics.mockResolvedValue({
dateFrom: '2026-07-01', dateTo: '2026-07-14', vehicleCount: 1, recordCount: 2, sourceCount: 2, dateFrom: '2026-07-01', dateTo: '2026-07-14', vehicleCount: 1, recordCount: 2, sourceCount: 2,
periodMileageKm: 193.3, periodPureHydrogenMileageKm: 128.4, 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, fleetLatestMileageKm: 119925, averageMileagePerVin: 193.3, averageDailyMileageKm: 96.65,
trend: [], ranking: [{ vin: 'LTEST000000000001', plate: '粤A12345', mileageKm: 193.3, latestMileageKm: 119925, activeDays: 2 }], trend: [], ranking: [{ vin: 'LTEST000000000001', plate: '粤A12345', mileageKm: 193.3, latestMileageKm: 119925, activeDays: 2 }],
asOf: '2026-07-14 13:20:00', evidence: 'production mileage evidence' asOf: '2026-07-14 13:20:00', evidence: 'production mileage evidence'
}); });
mocks.dailyMileage.mockResolvedValue({ items: [ 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-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, hydrogenConsumptionKg: 4.2, hydrogenConsumptionKgPer100Km: 4, 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 }); ], 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.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 }); 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); 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 () => { test('renders only the desktop matrix with dates as columns and a period total', async () => {
prepareData(); prepareData();
const view = renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14'); const view = renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
expect(await screen.findByText('车辆每日里程')).toBeInTheDocument(); expect(await screen.findByText('车辆每日里程')).toBeInTheDocument();
expect((await screen.findAllByText('193.3 km')).length).toBeGreaterThan(0); 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('104.6 km').length).toBeGreaterThan(0);
expect(screen.getAllByText('88.7 km').length).toBeGreaterThan(0); expect(screen.getAllByText('88.7 km').length).toBeGreaterThan(0);
expect(screen.getAllByText('7/13').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' }); fireEvent.keyDown(matrixRegion, { key: 'ArrowRight' });
expect(matrixScroller!.scrollLeft).toBe(96); 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.semi-table-wrapper')).toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-table-wrap > table')).not.toBeInTheDocument(); expect(view.container.querySelector('.v2-mileage-table-wrap > table')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-mobile-list')).not.toBeInTheDocument(); expect(view.container.querySelector('.v2-mileage-mobile-list')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '氢耗' })); fireEvent.click(screen.getByRole('button', { name: '氢耗' }));
expect(await screen.findByRole('heading', { name: '车辆每日氢耗' })).toBeInTheDocument(); 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(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('氢耗匹配纯氢里程128.4 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('区间氢耗7.3 kg5.7 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('百公里氢耗5.7 kg/100km2 个有效车辆日');
expect(screen.getAllByText('3.1 kg').length).toBeGreaterThan(0); expect(screen.getAllByText('3.1 kg').length).toBeGreaterThan(0);
expect(screen.getAllByText('4.2 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('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)); await waitFor(() => expect(mocks.dailyMileage).toHaveBeenCalledTimes(1));
expect(mocks.dailyMileage.mock.calls[0][0].limit).toBe(10000); expect(mocks.dailyMileage.mock.calls[0][0].limit).toBe(10000);
expect(mocks.dailyMileage.mock.calls[0][0].protocols).toEqual(['GB32960', 'JT808', 'YUTONG_MQTT']); 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 () => { test('distinguishes missing mileage from a reported zero and explains partial coverage', async () => {
prepareData(); prepareData();
mocks.mileageStatistics.mockResolvedValue({ 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(); 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 () => { test('shows query failures inside the result panel without a misleading empty matrix', async () => {
prepareData(); prepareData();
mocks.dailyMileage.mockRejectedValue(new Error('日里程服务超时')); 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.querySelector('.v2-mileage-table.semi-table-wrapper')).toBeInTheDocument();
expect(view.container.querySelectorAll('.v2-mileage-table th.is-date')).toHaveLength(2); 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-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-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-plate')).toHaveClass('is-plate');
expect(view.container.querySelector('.v2-mileage-table th.is-total')).toHaveClass('is-total'); 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')); 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(); prepareData();
const exportRows = [ const exportRows = [
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 119731.7, endMileageKm: 119820.4, dailyMileageKm: 88.7, source: 'GB32960' }, { 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).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.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]).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.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(mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1][1]).toBeInstanceOf(AbortSignal);
expect(exportMocks.createMileageExportStream.mock.calls[0][1]).toBeInstanceOf(AbortSignal); expect(exportMocks.createMileageExportStream.mock.calls[0][1]).toBeInstanceOf(AbortSignal);
expect(screen.getByText(/已导出 1 辆车/)).toBeInTheDocument(); 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 () => { test('shows export progress and lets the user cancel the active request', async () => {
prepareData(); prepareData();
let exportSignal: AbortSignal | undefined; let exportSignal: AbortSignal | undefined;
@@ -1,10 +1,10 @@
import { IconArrowDown, IconArrowUp, IconClose, IconDownload, IconInfoCircle, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons'; 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 { Button, ButtonGroup, Card, DatePicker, Input, Progress, Spin, Switch, Table, Tag, TextArea } from '@douyinfe/semi-ui';
import { useQuery } from '@tanstack/react-query'; 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 { useSearchParams } from 'react-router-dom';
import { api, type MileageQuery } from '../../api/client'; 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 { createMileageExportStream, type MileageExportStream } from '../domain/mileageExport';
import { formatZhNumber } from '../domain/formatters'; import { formatZhNumber } from '../domain/formatters';
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState'; import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
@@ -32,15 +32,34 @@ const BATCH_RESOLVE_SIZE = 200;
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
const EXPORT_VEHICLE_PAGE_SIZE = 2_000; const EXPORT_VEHICLE_PAGE_SIZE = 2_000;
const EXPORT_VIN_BATCH_SIZE = 50; 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 MAX_MILEAGE_RANGE_DAYS = 366;
const CURRENT_DAY_REFRESH_MS = 60_000; const CURRENT_DAY_REFRESH_MS = 60_000;
type VehicleOption = Pick<VehicleRow, 'vin' | 'plate'>; type VehicleOption = Pick<VehicleRow, 'vin' | 'plate'> & { brandName?: string; modelName?: string };
type MileageProtocol = 'GB32960' | 'JT808' | 'YUTONG_MQTT'; type MileageProtocol = 'GB32960' | 'JT808' | 'YUTONG_MQTT';
type MileageSourceOption = { protocol: MileageProtocol; label: string; mileageType: string; enabled: boolean }; type MileageSourceOption = { protocol: MileageProtocol; label: string; mileageType: string; enabled: boolean };
type Criteria = { vehicles: VehicleOption[]; dateFrom: string; dateTo: string; sources: MileageSourceOption[] }; type Criteria = { vehicles: VehicleOption[]; dateFrom: string; dateTo: string; sources: MileageSourceOption[] };
type ExportProgress = { label: string; completed?: number; total?: number }; type ExportProgress = { label: string; completed?: number; total?: number };
type MetricView = 'mileage' | 'hydrogen'; 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<MatrixMetric, { label: string; unit: string; width: number }> = {
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 SOURCE_STORAGE_KEY = 'vehicle-platform:mileage-source-strategy';
const DEFAULT_SOURCES: MileageSourceOption[] = [ const DEFAULT_SOURCES: MileageSourceOption[] = [
@@ -147,6 +166,20 @@ function mileageQuery(criteria: Criteria, offset = 0): MileageQuery {
return query; 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) { function mileageRouteParams(criteria: Criteria) {
const params = new URLSearchParams({ dateFrom: criteria.dateFrom, dateTo: criteria.dateTo }); const params = new URLSearchParams({ dateFrom: criteria.dateFrom, dateTo: criteria.dateTo });
params.set('protocols', criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(',')); 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 <div className="v2-mileage-view-switch" role="group" aria-label="数据视图"> return <div className="v2-mileage-view-switch" role="group" aria-label="数据视图">
<span> <span>
<strong>{value === 'mileage' ? '里程视图' : '氢耗视图'}</strong> <strong>{value === 'mileage' ? '里程视图' : '氢耗视图'}</strong>
<small>{value === 'mileage' ? '只看车辆行驶里程' : '按当日总里程计算氢耗'}</small> <small>{value === 'mileage' ? '只看车辆行驶里程' : '按纯氢里程计算百公里氢耗'}</small>
</span> </span>
<ButtonGroup aria-label="切换里程或氢耗视图"> <ButtonGroup aria-label="切换里程或氢耗视图">
<Button theme={value === 'mileage' ? 'solid' : 'light'} aria-pressed={value === 'mileage'} onClick={() => onChange('mileage')}></Button> <Button theme={value === 'mileage' ? 'solid' : 'light'} aria-pressed={value === 'mileage'} onClick={() => onChange('mileage')}></Button>
@@ -511,6 +544,26 @@ function MetricViewSwitch({ value, onChange }: { value: MetricView; onChange: (v
</div>; </div>;
} }
function HydrogenMetricSelector({ value, onChange }: { value: HydrogenMatrixMetric[]; onChange: (value: HydrogenMatrixMetric[]) => void }) {
const selected = new Set(value);
return <div className="v2-mileage-metric-selector" role="group" aria-label="日期子列">
<span><strong></strong><small></small></span>
<Tag color="blue" type="light" size="small"></Tag>
{HYDROGEN_MATRIX_METRICS.map((metric) => {
const active = selected.has(metric.key);
return <Button
key={metric.key}
size="small"
theme={active ? 'solid' : 'light'}
aria-pressed={active}
onClick={() => onChange(active ? value.filter((item) => item !== metric.key) : [...value, metric.key])}
>
{metric.label}
</Button>;
})}
</div>;
}
function SummaryRail({ data, criteria, fleetTotal, loading, view, unavailable = false }: { data?: MileageStatistics; criteria: Criteria; fleetTotal?: number; loading: boolean; view: MetricView; unavailable?: boolean }) { 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 days = inclusiveDays(criteria.dateFrom, criteria.dateTo);
const scopeVehicleCount = criteria.vehicles.length || fleetTotal || 0; const scopeVehicleCount = criteria.vehicles.length || fleetTotal || 0;
@@ -542,9 +595,9 @@ function SummaryRail({ data, criteria, fleetTotal, loading, view, unavailable =
tone: 'success', tone: 'success',
emphasis: 'primary' emphasis: 'primary'
}] : [{ }] : [{
label: '氢耗匹配里程', label: '氢耗匹配纯氢里程',
value: pending ? '—' : withUnit(formatKm(data?.hydrogenMatchedMileageKm), 'km'), value: pending ? '—' : withUnit(formatKm(data?.hydrogenMatchedMileageKm), 'km'),
note: unavailable ? '里程汇总暂不可用' : loading ? '正在汇总车辆日里程' : '按有氢耗数据的车辆日总里程计算', note: unavailable ? '里程汇总暂不可用' : loading ? '正在汇总车辆日纯氢里程' : '纯氢里程=总里程-纯电模式里程',
tone: 'success', tone: 'success',
emphasis: 'primary' emphasis: 'primary'
}, { }, {
@@ -560,7 +613,7 @@ function SummaryRail({ data, criteria, fleetTotal, loading, view, unavailable =
tone: 'warning', tone: 'warning',
emphasis: 'primary' emphasis: 'primary'
}, { }, {
label: '百公里氢耗', label: '百公里氢耗',
value: pending || !data?.hydrogenDataDays ? '—' : withUnit(formatKm(data.hydrogenConsumptionKgPer100Km), 'kg/100km'), value: pending || !data?.hydrogenDataDays ? '—' : withUnit(formatKm(data.hydrogenConsumptionKgPer100Km), 'kg/100km'),
note: unavailable ? '氢耗效率暂不可用' : loading ? '正在计算氢耗效率' : `${data?.hydrogenDataDays ?? 0} 个有效车辆日`, note: unavailable ? '氢耗效率暂不可用' : loading ? '正在计算氢耗效率' : `${data?.hydrogenDataDays ?? 0} 个有效车辆日`,
tone: 'warning', 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 & { type VehicleMileageMatrix = VehicleOption & {
days: Map<string, number>; days: Map<string, number>;
hydrogenDays: Map<string, { consumptionKg: number; rateKgPer100Km?: number }>; pureElectricDays: Map<string, number>;
pureHydrogenDays: Map<string, number>;
hydrogenDays: Map<string, HydrogenDayMetric>;
sources: Map<string, string>; sources: Map<string, string>;
totalMileageKm?: number; totalMileageKm?: number;
totalPureElectricMileageKm?: number;
totalPureHydrogenMileageKm?: number;
totalHydrogenConsumptionKg?: number; totalHydrogenConsumptionKg?: number;
totalHydrogenRateKgPer100Km?: number; totalHydrogenRateKgPer100Km?: number;
}; };
@@ -604,14 +671,31 @@ function dateLabel(date: string) {
return `${Number(month)}/${Number(day)}`; 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; const vehiclesWithMileage = rows.filter((row) => row.days.size > 0).length;
if (view === 'hydrogen') return <aside id="v2-mileage-matrix-guide" className="v2-mileage-matrix-guide is-hydrogen" role="note" aria-label="氢耗矩阵读表说明"> if (view === 'hydrogen') return <aside id="v2-mileage-matrix-guide" className="v2-mileage-matrix-guide is-hydrogen has-metric-selector" role="note" aria-label="氢耗矩阵读表说明">
<span className="v2-mileage-matrix-guide-title"><IconInfoCircle /><strong> {vehiclesWithMileage} / {rows.length} </strong></span> <div className="v2-mileage-matrix-guide-toolbar">
<span><Tag color="orange" type="light" size="small">kg</Tag></span> <span className="v2-mileage-matrix-guide-title"><IconInfoCircle /><strong> {vehiclesWithMileage} / {rows.length} </strong></span>
<span><Tag color="green" type="light" size="small">km</Tag></span> <HydrogenMetricSelector value={hydrogenMetrics} onChange={onHydrogenMetricsChange} />
<span><Tag color="amber" type="light" size="small">kg/100km</Tag></span> </div>
<span className="is-mobile-hint"> · </span> <div className="v2-mileage-metric-logic" aria-label="指标取值逻辑">
<span><strong></strong> 线</span>
<span><strong></strong> </span>
<span><strong></strong> </span>
<span><strong></strong> 0x02 </span>
<span><strong></strong> ÷×100</span>
<span><strong></strong> </span>
</div>
</aside>; </aside>;
return <aside id="v2-mileage-matrix-guide" className="v2-mileage-matrix-guide" role="note" aria-label="里程矩阵读表说明"> return <aside id="v2-mileage-matrix-guide" className="v2-mileage-matrix-guide" role="note" aria-label="里程矩阵读表说明">
<span className="v2-mileage-matrix-guide-title is-desktop-guide"><IconInfoCircle /><strong> {vehiclesWithMileage} / {rows.length} </strong></span> <span className="v2-mileage-matrix-guide-title is-desktop-guide"><IconInfoCircle /><strong> {vehiclesWithMileage} / {rows.length} </strong></span>
@@ -638,79 +722,112 @@ function scrollMileageMatrixFromKeyboard(event: KeyboardEvent<HTMLDivElement>) {
event.preventDefault(); event.preventDefault();
} }
const MileageTable = memo(function MileageTable({ rows, dates, scrollRef, view }: { rows: VehicleMileageMatrix[]; dates: string[]; scrollRef: RefObject<HTMLDivElement>; view: MetricView }) { function matrixDailyMetricValue(row: VehicleMileageMatrix, date: string, metric: MatrixMetric) {
switch (metric) {
case 'mileage': return row.days.get(date);
case 'consumption': return row.hydrogenDays.get(date)?.consumptionKg;
case 'pureElectric': return row.pureElectricDays.get(date);
case 'pureHydrogen': return row.pureHydrogenDays.get(date);
case 'rate': return row.hydrogenDays.get(date)?.rateKgPer100Km;
}
}
function matrixTotalMetricValue(row: VehicleMileageMatrix, metric: MatrixMetric) {
switch (metric) {
case 'mileage': return row.totalMileageKm;
case 'consumption': return row.totalHydrogenConsumptionKg;
case 'pureElectric': return row.totalPureElectricMileageKm;
case 'pureHydrogen': return row.totalPureHydrogenMileageKm;
case 'rate': return row.totalHydrogenRateKgPer100Km;
}
}
function MatrixMetricHeader({ metric }: { metric: MatrixMetric }) {
const meta = MATRIX_METRIC_META[metric];
return <span className="v2-mileage-metric-header"><strong>{meta.label}</strong><small>{meta.unit}</small></span>;
}
function MatrixMetricValue({ value, metric, onEvidence }: { value?: number; metric: MatrixMetric; onEvidence?: () => void }) {
if (value == null || !Number.isFinite(value)) return <></>;
const content = <strong>{formatKm(value)} {MATRIX_METRIC_META[metric].unit}</strong>;
if (!onEvidence) return content;
return <button className="v2-hydrogen-evidence-trigger" type="button" title="查看计算公式、区间和原始报文标识" onClick={onEvidence}>{content}<small></small></button>;
}
const MileageTable = memo(function MileageTable({ rows, dates, scrollRef, view, hydrogenMetrics, onOpenEvidence }: { rows: VehicleMileageMatrix[]; dates: string[]; scrollRef: RefObject<HTMLDivElement>; view: MetricView; hydrogenMetrics: HydrogenMatrixMetric[]; onOpenEvidence: (row: VehicleMileageMatrix, date: string) => void }) {
const { columns, tableWidth } = useMemo(() => { const { columns, tableWidth } = useMemo(() => {
let maxDailyMileage = 1; let maxDailyMileage = 1;
for (const row of rows) { for (const row of rows) {
for (const mileage of row.days.values()) maxDailyMileage = Math.max(maxDailyMileage, mileage); for (const mileage of row.days.values()) maxDailyMileage = Math.max(maxDailyMileage, mileage);
} }
const dateColumnWidth = view === 'hydrogen' ? 128 : 96; const metrics: MatrixMetric[] = view === 'hydrogen' ? ['mileage', ...hydrogenMetrics] : ['mileage'];
const totalColumnWidth = view === 'hydrogen' ? 148 : 128; const metricsWidth = metrics.reduce((sum, metric) => sum + MATRIX_METRIC_META[metric].width, 0);
return { return {
columns: [ columns: [
{ title: '车牌', dataIndex: 'plate', className: 'is-plate', width: 120, render: (_value: string, row: VehicleMileageMatrix) => <strong>{row.plate || '未绑定'}</strong> }, { title: '车牌', dataIndex: 'plate', className: 'is-plate', width: 120, render: (_value: string, row: VehicleMileageMatrix) => <strong>{row.plate || '未绑定'}</strong> },
...dates.map((date) => ({ ...dates.map((date) => ({
title: dateLabel(date), dataIndex: date, className: 'is-number is-date', width: dateColumnWidth, title: dateLabel(date),
onHeaderCell: () => ({ title: date, 'aria-label': `${date} 每日${view === 'mileage' ? '里程' : '氢耗'}` }), dataIndex: `date:${date}`,
onCell: (row?: VehicleMileageMatrix) => { className: 'is-date-group',
const mileage = row?.days.get(date); onHeaderCell: () => ({ title: date, 'aria-label': `${date} 指标分组` }),
const hydrogen = row?.hydrogenDays.get(date); children: metrics.map((metric) => {
const source = row?.sources.get(date); const meta = MATRIX_METRIC_META[metric];
const intensity = mileage && mileage > 0 ? .035 + mileage / maxDailyMileage * .13 : 0;
if (view === 'hydrogen') return {
className: `is-number is-date is-hydrogen-daily${mileage != null ? ' is-daily' : ' is-empty'}`,
title: mileage != null
? `耗氢 ${formatKm(hydrogen?.consumptionKg)} kg · 当日总里程 ${formatKm(mileage)} km · 百公里氢耗 ${formatKm(hydrogen?.rateKgPer100Km)} kg/100km · 来源:${source || '—'}`
: '无可用氢耗数据',
'aria-label': mileage != null
? `${date},耗氢 ${formatKm(hydrogen?.consumptionKg)} 千克,当日总里程 ${formatKm(mileage)} 公里,百公里氢耗 ${formatKm(hydrogen?.rateKgPer100Km)} 千克,来源 ${source || '未知'}`
: `${date},无可用氢耗数据`
};
return { return {
className: `is-number is-date${mileage != null ? ' is-daily' : ' is-empty'}`, title: <MatrixMetricHeader metric={metric} />,
title: mileage != null ? `里程 ${formatKm(mileage)} km · 来源:${source || '—'}` : '无可用里程', dataIndex: `${date}:${metric}`,
'aria-label': mileage != null className: `is-number is-date is-metric-${metric}`,
? `${date},里程 ${formatKm(mileage)} 公里,来源 ${source || '未知'}` width: meta.width,
: `${date},无可用里程`, onHeaderCell: () => ({ 'aria-label': `${date} ${meta.label} ${meta.unit}` }),
style: intensity ? { backgroundColor: `rgba(37, 99, 235, ${intensity.toFixed(3)})` } : undefined onCell: (row?: VehicleMileageMatrix) => {
const value = row ? matrixDailyMetricValue(row, date, metric) : undefined;
const mileage = row?.days.get(date);
const source = row?.sources.get(date);
const intensity = metric === 'mileage' && mileage && mileage > 0 ? .035 + mileage / maxDailyMileage * .13 : 0;
return {
className: `is-number is-date is-metric-${metric}${value != null ? ' is-daily' : ' is-empty'}`,
title: value != null ? `${meta.label} ${formatKm(value)} ${meta.unit} · 来源:${source || '—'}` : `${meta.label}无可用数据`,
'aria-label': value != null
? `${date}${meta.label} ${formatKm(value)} ${meta.unit},来源 ${source || '未知'}`
: `${date}${meta.label}无可用数据`,
style: intensity ? { backgroundColor: `rgba(37, 99, 235, ${intensity.toFixed(3)})` } : undefined
};
},
render: (_value: unknown, row: VehicleMileageMatrix) => {
const traceable = (metric === 'consumption' || metric === 'rate') && row.hydrogenDays.get(date)?.evidenceAvailable;
return <MatrixMetricValue value={matrixDailyMetricValue(row, date, metric)} metric={metric} onEvidence={traceable ? () => onOpenEvidence(row, date) : undefined} />;
}
}; };
}, })
render: (_value: unknown, row: VehicleMileageMatrix) => {
const mileage = row.days.get(date);
const hydrogen = row.hydrogenDays.get(date);
if (mileage == null) return '—';
if (view === 'hydrogen') return <span className="v2-mileage-cell-value is-hydrogen">
<strong>{formatKm(hydrogen?.consumptionKg)} kg</strong>
<small> {formatKm(mileage)} km</small>
<small> {formatKm(hydrogen?.rateKgPer100Km)} kg</small>
</span>;
return <strong>{formatKm(mileage)} km</strong>;
}
})), })),
{ {
title: view === 'mileage' ? '区间总里程' : '区间氢耗', dataIndex: 'totalMileageKm', className: 'is-number is-total', width: totalColumnWidth, title: '区间合计',
onCell: (row?: VehicleMileageMatrix) => ({ dataIndex: 'period',
className: `is-number is-period is-total${view === 'hydrogen' ? ' is-hydrogen-total' : ''}${row?.totalMileageKm == null ? ' is-empty' : ''}`, className: 'is-total is-total-group',
'aria-label': row?.totalMileageKm == null children: metrics.map((metric) => {
? view === 'mileage' ? '区间总里程,无可用里程' : '区间氢耗,无可用氢耗数据' const meta = MATRIX_METRIC_META[metric];
: view === 'mileage' return {
? `区间总里程,${formatKm(row.totalMileageKm)} 公里` title: <MatrixMetricHeader metric={metric} />,
: `区间耗氢 ${formatKm(row.totalHydrogenConsumptionKg)} 千克,总里程 ${formatKm(row.totalMileageKm)} 公里,百公里氢耗 ${formatKm(row.totalHydrogenRateKgPer100Km)} 千克` dataIndex: `period:${metric}`,
}), className: `is-number is-total is-metric-${metric}`,
render: (value: number | undefined, row: VehicleMileageMatrix) => { width: meta.width,
if (value == null) return '—'; onHeaderCell: () => ({ 'aria-label': `区间${meta.label} ${meta.unit}` }),
if (view === 'hydrogen') return <span className="v2-mileage-cell-value is-hydrogen"> onCell: (row?: VehicleMileageMatrix) => {
<strong>{formatKm(row.totalHydrogenConsumptionKg)} kg</strong> const value = row ? matrixTotalMetricValue(row, metric) : undefined;
<small> {formatKm(row.totalMileageKm)} km</small> return {
<small> {formatKm(row.totalHydrogenRateKgPer100Km)} kg</small> className: `is-number is-period is-total is-metric-${metric}${value == null ? ' is-empty' : ''}`,
</span>; 'aria-label': value == null
return <strong>{formatKm(value)} km</strong>; ? metric === 'mileage' ? '区间总里程,无可用里程' : `区间${meta.label},无可用数据`
} : `区间${meta.label}${formatKm(value)} ${meta.unit}`
};
},
render: (_value: unknown, row: VehicleMileageMatrix) => <MatrixMetricValue value={matrixTotalMetricValue(row, metric)} metric={metric} />
};
})
} }
], ],
tableWidth: 120 + dates.length * dateColumnWidth + totalColumnWidth tableWidth: 120 + (dates.length + 1) * metricsWidth
}; };
}, [dates, rows, view]); }, [dates, hydrogenMetrics, onOpenEvidence, rows, view]);
return <div return <div
className={`v2-mileage-table-wrap${view === 'hydrogen' ? ' is-hydrogen-view' : ''}`} className={`v2-mileage-table-wrap${view === 'hydrogen' ? ' is-hydrogen-view' : ''}`}
ref={scrollRef} ref={scrollRef}
@@ -719,12 +836,101 @@ const MileageTable = memo(function MileageTable({ rows, dates, scrollRef, view }
aria-describedby="v2-mileage-matrix-guide" aria-describedby="v2-mileage-matrix-guide"
tabIndex={0} tabIndex={0}
onKeyDown={scrollMileageMatrixFromKeyboard} onKeyDown={scrollMileageMatrixFromKeyboard}
style={{ '--v2-mileage-mobile-table-width': `${96 + dates.length * (view === 'hydrogen' ? 112 : 78) + (view === 'hydrogen' ? 132 : 104)}px` } as CSSProperties} style={{ '--v2-mileage-mobile-table-width': `${Math.max(556, tableWidth)}px` } as CSSProperties}
> >
<Table className="v2-mileage-table" columns={columns} dataSource={rows} rowKey="vin" pagination={false} scroll={{ x: Math.max(556, tableWidth) }} /> <Table className="v2-mileage-table" columns={columns} dataSource={rows} rowKey="vin" pagination={false} scroll={{ x: Math.max(556, tableWidth) }} />
</div>; </div>;
}); });
type HydrogenEvidenceTarget = { vin: string; plate: string; date: string };
function evidenceNumber(value?: number | null, digits = 3) {
if (value == null || !Number.isFinite(value)) return '—';
return value.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
function HydrogenEvidenceSheet({ target, onClose }: { target?: HydrogenEvidenceTarget; onClose: () => void }) {
const evidence = useQuery<HydrogenDailyEvidence>({
queryKey: ['hydrogen-daily-evidence', target?.vin, target?.date],
queryFn: ({ signal }) => api.hydrogenDailyEvidence(target!.vin, target!.date, signal),
enabled: Boolean(target),
staleTime: CURRENT_DAY_REFRESH_MS,
gcTime: QUERY_MEMORY.summaryGcTime
});
const data = evidence.data;
return <WorkspaceSideSheet
className="v2-hydrogen-evidence-sheet"
variant="detail"
visible={Boolean(target)}
ariaLabel="氢耗计算溯源"
closeLabel="关闭氢耗计算溯源"
dialogId="v2-hydrogen-evidence-sheet"
title={`${target?.plate || target?.vin || '车辆'} · ${target?.date || ''}`}
description="每日结果、计算参数、有效区间及原始报文标识"
icon={<IconInfoCircle />}
badge={data?.qualityStatus || '读取中'}
badgeColor={data?.qualityStatus === 'OK' ? 'green' : data?.qualityStatus === 'SUSPECT' ? 'orange' : 'grey'}
width="min(760px, 94vw)"
summaryItems={data ? [
{ label: '物理耗氢', value: `${evidenceNumber(data.rawConsumptionKg)} kg`, detail: 'NIST压力—质量差分段累计', tone: 'primary' },
{ label: 'SOC平衡氢耗', value: data.socBalancedConsumptionKg == null ? '未计算' : `${evidenceNumber(data.socBalancedConsumptionKg)} kg`, detail: data.parameters.batteryCapacityKWh > 0 ? `电池 ${evidenceNumber(data.parameters.batteryCapacityKWh, 2)} kWh` : '缺少经确认的电池容量', tone: 'warning' },
{ label: '混动里程', value: `${evidenceNumber(data.mixedMileageKm)} km`, detail: `${data.validSegmentCount} 个有效区间`, tone: 'success' }
] : []}
footerNote="物理耗氢与SOC修正值分开保存;点击区间中的报文ID可复制后到原始报文查询中检索。"
primaryAction={{ label: '关闭', onClick: onClose }}
onCancel={onClose}
>
{evidence.isLoading ? <PanelLoading title="正在读取计算证据" description="加载每日参数与区间明细。" /> : null}
{evidence.error ? <InlineError message={evidence.error instanceof Error ? evidence.error.message : '氢耗计算证据加载失败'} onRetry={() => evidence.refetch()} /> : null}
{data ? <div className="v2-hydrogen-evidence-content">
<section>
<header><strong></strong><Tag color="blue" type="light" size="small">{data.algorithmVersion}</Tag></header>
<code>m = P × 1000 × 0.00201588 × V ÷ (8.314472 × Tₖ × Zₙᵢₛₜ)</code>
<code> = </code>
<code>SOC平衡氢耗 = + × (SOC SOC) ÷ 100 ÷ {evidenceNumber(data.parameters.hydrogenEnergyKWhPerKg, 1)}</code>
<p>{data.qualityReason || '数据通过完整性、运行状态、加氢、充电、异常跳变和端点中位数校验。'}</p>
</section>
<section>
<header><strong></strong></header>
<dl className="v2-hydrogen-evidence-parameters">
<div><dt></dt><dd>{data.parameters.powerOnDelaySeconds}s</dd></div>
<div><dt></dt><dd>{data.parameters.powerOffLeadSeconds}s</dd></div>
<div><dt></dt><dd>+{data.parameters.refuelRiseMpa}MPa / {data.parameters.refuelSustainSeconds}s</dd></div>
<div><dt></dt><dd>-{data.parameters.pureElectricDropMpa}MPa / {data.parameters.pureElectricWindowSeconds}s</dd></div>
<div><dt></dt><dd>{data.sampleCount}</dd></div>
<div><dt> / </dt><dd>{data.refuelCount} / {data.chargeCount}</dd></div>
<div><dt></dt><dd>{data.invalidSegmentCount}</dd></div>
<div><dt></dt><dd>{data.calculatedAt}</dd></div>
</dl>
</section>
<section>
<header><strong></strong><small>{data.intervals.length} </small></header>
<div className="v2-hydrogen-evidence-intervals">
{data.intervals.map((interval) => <article key={`${interval.index}-${interval.startTime}`} className={`is-${interval.qualityStatus.toLowerCase()}`}>
<header><span><Tag color={interval.type === 'MIXED' ? 'green' : 'blue'} type="light" size="small">{interval.type === 'MIXED' ? '混动' : '纯电'}</Tag><strong> {interval.index}</strong></span><Tag color={interval.qualityStatus === 'OK' ? 'green' : 'orange'} type="light" size="small">{interval.qualityStatus}</Tag></header>
<p>{interval.startTime} {interval.endTime} · {interval.sampleCount} </p>
<div className="v2-hydrogen-evidence-metrics">
<span><small></small><strong>{evidenceNumber(interval.startPressureMpa)} {evidenceNumber(interval.endPressureMpa)} MPa</strong></span>
<span><small></small><strong>{evidenceNumber(interval.startTemperatureC, 1)} {evidenceNumber(interval.endTemperatureC, 1)} </strong></span>
<span><small></small><strong>{evidenceNumber(interval.startMassKg)} {evidenceNumber(interval.endMassKg)} kg</strong></span>
<span><small></small><strong>{evidenceNumber(interval.rawHydrogenConsumptionKg)} kg</strong></span>
<span><small>SOC</small><strong>{evidenceNumber(interval.startSocPercent, 1)} {evidenceNumber(interval.endSocPercent, 1)} %</strong></span>
<span><small></small><strong>{evidenceNumber(interval.mileageKm)} km</strong></span>
<span><small></small><strong>{evidenceNumber(interval.batteryEquivalentKg)} kg</strong></span>
<span><small>SOC平衡氢耗</small><strong>{evidenceNumber(interval.socBalancedConsumptionKg)} kg</strong></span>
</div>
<div className="v2-hydrogen-evidence-events">
<button type="button" onClick={() => navigator.clipboard?.writeText(interval.startEventId)}><small></small><code>{interval.startEventId || '—'}</code></button>
<button type="button" onClick={() => navigator.clipboard?.writeText(interval.endEventId)}><small></small><code>{interval.endEventId || '—'}</code></button>
</div>
{interval.qualityReason ? <p className="is-reason">{interval.qualityReason}</p> : null}
</article>)}
</div>
</section>
</div> : null}
</WorkspaceSideSheet>;
}
export default function StatisticsPage() { export default function StatisticsPage() {
const { session } = usePlatformSession(); const { session } = usePlatformSession();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@@ -740,11 +946,16 @@ export default function StatisticsPage() {
const [validationError, setValidationError] = useState(''); const [validationError, setValidationError] = useState('');
const [filtersCollapsed, setFiltersCollapsed] = useState(true); const [filtersCollapsed, setFiltersCollapsed] = useState(true);
const [metricView, setMetricView] = useState<MetricView>('mileage'); const [metricView, setMetricView] = useState<MetricView>('mileage');
const [hydrogenMatrixMetrics, setHydrogenMatrixMetrics] = useState<HydrogenMatrixMetric[]>(DEFAULT_HYDROGEN_MATRIX_METRICS);
const [hydrogenEvidenceTarget, setHydrogenEvidenceTarget] = useState<HydrogenEvidenceTarget>();
const hydrogenViewAllowed = session.role !== 'customer' && session.userType !== 'customer'; const hydrogenViewAllowed = session.role !== 'customer' && session.userType !== 'customer';
const mobileFiltersOpen = mobileLayout && !filtersCollapsed; const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
const exportControllerRef = useRef<AbortController | null>(null); const exportControllerRef = useRef<AbortController | null>(null);
const pageRef = useRef<HTMLDivElement>(null); const pageRef = useRef<HTMLDivElement>(null);
const tableScrollRef = useRef<HTMLDivElement>(null); const tableScrollRef = useRef<HTMLDivElement>(null);
const openHydrogenEvidence = useCallback((row: VehicleMileageMatrix, date: string) => {
setHydrogenEvidenceTarget({ vin: row.vin, plate: row.plate, date });
}, []);
const mountedRef = useRef(true); const mountedRef = useRef(true);
useEffect(() => { useEffect(() => {
mountedRef.current = true; mountedRef.current = true;
@@ -816,20 +1027,30 @@ export default function StatisticsPage() {
const index = new Map<string, { const index = new Map<string, {
plate: string; plate: string;
days: Map<string, number>; days: Map<string, number>;
hydrogenDays: Map<string, { consumptionKg: number; rateKgPer100Km?: number }>; pureElectricDays: Map<string, number>;
pureHydrogenDays: Map<string, number>;
hydrogenDays: Map<string, HydrogenDayMetric>;
sources: Map<string, string>; sources: Map<string, string>;
}>(); }>();
for (const row of mileage.data?.items ?? []) { for (const row of mileage.data?.items ?? []) {
let entry = index.get(row.vin); let entry = index.get(row.vin);
if (!entry) { if (!entry) {
entry = { plate: row.plate || '', days: new Map(), hydrogenDays: new Map(), sources: new Map() }; entry = { plate: row.plate || '', days: new Map(), pureElectricDays: new Map(), pureHydrogenDays: new Map(), hydrogenDays: new Map(), sources: new Map() };
index.set(row.vin, entry); index.set(row.vin, entry);
} else if (!entry.plate && row.plate) entry.plate = row.plate; } else if (!entry.plate && row.plate) entry.plate = row.plate;
entry.days.set(row.date, row.dailyMileageKm); entry.days.set(row.date, row.dailyMileageKm);
const pureHydrogenMileage = Math.max(0, Math.min(row.dailyMileageKm, row.pureHydrogenMileageKm ?? 0));
entry.pureHydrogenDays.set(row.date, pureHydrogenMileage);
entry.pureElectricDays.set(row.date, Math.max(0, row.dailyMileageKm - pureHydrogenMileage));
if (row.hydrogenConsumptionKg != null) { if (row.hydrogenConsumptionKg != null) {
entry.hydrogenDays.set(row.date, { entry.hydrogenDays.set(row.date, {
consumptionKg: row.hydrogenConsumptionKg, consumptionKg: row.hydrogenConsumptionKg,
rateKgPer100Km: row.hydrogenConsumptionKgPer100Km ?? undefined rateKgPer100Km: row.hydrogenConsumptionKgPer100Km ?? undefined,
socBalancedKg: row.hydrogenSocBalancedKg ?? undefined,
socBalancedRateKgPer100Km: row.hydrogenSocBalancedKgPer100Km ?? undefined,
evidenceAvailable: row.hydrogenEvidenceAvailable === true,
qualityStatus: row.hydrogenQualityStatus,
algorithmVersion: row.hydrogenAlgorithmVersion
}); });
} }
entry.sources.set(row.date, row.source); entry.sources.set(row.date, row.source);
@@ -841,23 +1062,35 @@ export default function StatisticsPage() {
const daily = mileageByVin.get(vehicle.vin); const daily = mileageByVin.get(vehicle.vin);
const ranking = rankingByVin.get(vehicle.vin); const ranking = rankingByVin.get(vehicle.vin);
const days = daily?.days ?? new Map<string, number>(); const days = daily?.days ?? new Map<string, number>();
const hydrogenDays = daily?.hydrogenDays ?? new Map<string, { consumptionKg: number; rateKgPer100Km?: number }>(); const pureElectricDays = daily?.pureElectricDays ?? new Map<string, number>();
const pureHydrogenDays = daily?.pureHydrogenDays ?? new Map<string, number>();
const hydrogenDays = daily?.hydrogenDays ?? new Map<string, HydrogenDayMetric>();
const sources = daily?.sources ?? new Map<string, string>(); const sources = daily?.sources ?? new Map<string, string>();
let dailyTotal = 0; let dailyTotal = 0;
let hydrogenTotal = 0; let hydrogenTotal = 0;
let hydrogenRatedMileage = 0; let hydrogenRatedMileage = 0;
let pureElectricTotal = 0;
let pureHydrogenTotal = 0;
for (const value of days.values()) dailyTotal += value; for (const value of days.values()) dailyTotal += value;
for (const value of pureElectricDays.values()) pureElectricTotal += value;
for (const value of pureHydrogenDays.values()) pureHydrogenTotal += value;
for (const [date, value] of hydrogenDays) { for (const [date, value] of hydrogenDays) {
const matchedMileage = pureHydrogenDays.get(date) ?? 0;
if (matchedMileage <= 0) continue;
hydrogenTotal += value.consumptionKg; hydrogenTotal += value.consumptionKg;
hydrogenRatedMileage += days.get(date) ?? 0; hydrogenRatedMileage += matchedMileage;
} }
return { return {
...vehicle, ...vehicle,
plate: vehicle.plate || daily?.plate || ranking?.plate || '', plate: vehicle.plate || daily?.plate || ranking?.plate || '',
days, days,
pureElectricDays,
pureHydrogenDays,
hydrogenDays, hydrogenDays,
sources, sources,
totalMileageKm: days.size ? ranking?.mileageKm ?? dailyTotal : undefined, totalMileageKm: days.size ? ranking?.mileageKm ?? dailyTotal : undefined,
totalPureElectricMileageKm: pureElectricDays.size ? pureElectricTotal : undefined,
totalPureHydrogenMileageKm: pureHydrogenDays.size ? pureHydrogenTotal : undefined,
totalHydrogenConsumptionKg: hydrogenDays.size ? hydrogenTotal : undefined, totalHydrogenConsumptionKg: hydrogenDays.size ? hydrogenTotal : undefined,
totalHydrogenRateKgPer100Km: hydrogenDays.size && hydrogenRatedMileage > 0 ? hydrogenTotal * 100 / hydrogenRatedMileage : undefined totalHydrogenRateKgPer100Km: hydrogenDays.size && hydrogenRatedMileage > 0 ? hydrogenTotal * 100 / hydrogenRatedMileage : undefined
}; };
@@ -908,7 +1141,11 @@ export default function StatisticsPage() {
]; ];
const refreshing = statistics.isFetching || mileage.isFetching || fleetVehicles.isFetching; const refreshing = statistics.isFetching || mileage.isFetching || fleetVehicles.isFetching;
const resultsLoading = fleetVehicles.isLoading || mileage.isLoading || mileage.isPlaceholderData; const resultsLoading = fleetVehicles.isLoading || mileage.isLoading || mileage.isPlaceholderData;
const resultsError = statistics.error ?? mileage.error ?? (!hasVehicles ? fleetVehicles.error : undefined); // Fleet statistics are supplementary to the paged mileage matrix. A large
// authorized fleet may time out while the current page of vehicle rows is
// still available, so keep the summary-level unavailable state from hiding
// otherwise usable daily mileage data.
const resultsError = mileage.error ?? (!hasVehicles ? fleetVehicles.error : undefined);
const exportPercent = exportProgress?.total const exportPercent = exportProgress?.total
? Math.min(100, Math.round((exportProgress.completed ?? 0) / exportProgress.total * 100)) ? Math.min(100, Math.round((exportProgress.completed ?? 0) / exportProgress.total * 100))
: undefined; : undefined;
@@ -924,14 +1161,39 @@ export default function StatisticsPage() {
setExportFeedback(''); setExportFeedback('');
let exportStream: MileageExportStream | undefined; let exportStream: MileageExportStream | undefined;
try { try {
let vehicles: VehicleOption[] = criteria.vehicles.map((vehicle) => ({ ...vehicle })); let vehicles: VehicleOption[];
if (!vehicles.length) { if (criteria.vehicles.length) {
const archiveByVIN = new Map<string, VehicleOption>();
reportProgress({ label: '正在读取车辆档案', completed: 0, total: criteria.vehicles.length });
for (let start = 0; start < criteria.vehicles.length; start += BATCH_RESOLVE_SIZE) {
const batch = criteria.vehicles.slice(start, start + BATCH_RESOLVE_SIZE);
const result = await api.vehicleCoverage(new URLSearchParams({
keywords: batch.map((vehicle) => vehicle.vin).join(','),
limit: String(batch.length),
offset: '0',
bindingStatus: 'bound'
}), controller.signal);
for (const vehicle of result.items) archiveByVIN.set(vehicle.vin, {
vin: vehicle.vin,
plate: vehicle.plate,
brandName: vehicle.brandName,
modelName: vehicle.modelName
});
reportProgress({ label: '正在读取车辆档案', completed: Math.min(start + batch.length, criteria.vehicles.length), total: criteria.vehicles.length });
}
vehicles = criteria.vehicles.map((vehicle) => ({ ...vehicle, ...archiveByVIN.get(vehicle.vin), vin: vehicle.vin }));
} else {
vehicles = []; vehicles = [];
let offset = 0; let offset = 0;
reportProgress({ label: '正在读取车辆档案', completed: 0, total: totalVehicles }); reportProgress({ label: '正在读取车辆档案', completed: 0, total: totalVehicles });
while (offset < totalVehicles) { while (offset < totalVehicles) {
const result = await api.vehicleCoverage(new URLSearchParams({ limit: String(EXPORT_VEHICLE_PAGE_SIZE), offset: String(offset), bindingStatus: 'bound' }), controller.signal); const result = await api.vehicleCoverage(new URLSearchParams({ limit: String(EXPORT_VEHICLE_PAGE_SIZE), offset: String(offset), bindingStatus: 'bound' }), controller.signal);
vehicles.push(...result.items.map((vehicle) => ({ vin: vehicle.vin, plate: vehicle.plate }))); vehicles.push(...result.items.map((vehicle) => ({
vin: vehicle.vin,
plate: vehicle.plate,
brandName: vehicle.brandName,
modelName: vehicle.modelName
})));
reportProgress({ label: '正在读取车辆档案', completed: vehicles.length, total: result.total }); reportProgress({ label: '正在读取车辆档案', completed: vehicles.length, total: result.total });
if (!result.items.length) break; if (!result.items.length) break;
offset += result.items.length; offset += result.items.length;
@@ -949,21 +1211,27 @@ export default function StatisticsPage() {
const vehicleBatches = criteria.vehicles.length const vehicleBatches = criteria.vehicles.length
? Array.from({ length: Math.ceil(vehicles.length / EXPORT_VIN_BATCH_SIZE) }, (_, index) => vehicles.slice(index * EXPORT_VIN_BATCH_SIZE, (index + 1) * EXPORT_VIN_BATCH_SIZE)) ? Array.from({ length: Math.ceil(vehicles.length / EXPORT_VIN_BATCH_SIZE) }, (_, index) => vehicles.slice(index * EXPORT_VIN_BATCH_SIZE, (index + 1) * EXPORT_VIN_BATCH_SIZE))
: [[] as VehicleOption[]]; : [[] as VehicleOption[]];
for (const [batchIndex, vehicleBatch] of vehicleBatches.entries()) { const exportWindows = vehicleBatches.flatMap((vehicleBatch, batchIndex) => mileageExportDateWindows(
criteria.dateFrom,
criteria.dateTo,
vehicleBatch.length || vehicles.length
).map((window) => ({ vehicleBatch, batchIndex, window })));
let completedWindows = 0;
for (const { vehicleBatch, batchIndex, window } of exportWindows) {
let offset = 0; let offset = 0;
const mileageLabel = vehicleBatches.length > 1 const batchLabel = vehicleBatches.length > 1 ? ` · 车辆批次 ${batchIndex + 1}/${vehicleBatches.length}` : '';
? `正在读取里程数据(${batchIndex + 1}/${vehicleBatches.length}` const mileageLabel = `正在读取里程数据(${window.dateFrom}${window.dateTo}${batchLabel}`;
: '正在读取全部车辆里程'; reportProgress({ label: mileageLabel, completed: completedWindows, total: exportWindows.length });
reportProgress({ label: mileageLabel });
while (true) { while (true) {
const query = mileageQuery({ ...criteria, vehicles: vehicleBatch }, offset); const query = mileageQuery({ ...criteria, ...window, vehicles: vehicleBatch }, offset);
const result = await api.dailyMileage(query, controller.signal); const result = await api.dailyMileage(query, controller.signal);
for (const row of result.items) if (row.plate) plateByVin.set(row.vin, row.plate); for (const row of result.items) if (row.plate) plateByVin.set(row.vin, row.plate);
exportStream.appendRows(result.items); exportStream.appendRows(result.items);
offset += result.items.length; offset += result.items.length;
reportProgress({ label: mileageLabel, completed: offset, total: result.total });
if (!result.items.length || offset >= result.total) break; if (!result.items.length || offset >= result.total) break;
} }
completedWindows++;
reportProgress({ label: mileageLabel, completed: completedWindows, total: exportWindows.length });
} }
vehicles = vehicles.map((vehicle) => ({ ...vehicle, plate: vehicle.plate || plateByVin.get(vehicle.vin) || '' })); vehicles = vehicles.map((vehicle) => ({ ...vehicle, plate: vehicle.plate || plateByVin.get(vehicle.vin) || '' }));
reportProgress({ label: '正在后台生成 Excel 文件' }); reportProgress({ label: '正在后台生成 Excel 文件' });
@@ -1086,11 +1354,12 @@ export default function StatisticsPage() {
: <Progress className="v2-mileage-export-bar" percent={exportPercent} showInfo={false} size="small" strokeLinecap="round" aria-label={`${exportProgress.label} ${exportPercent}%`} />} : <Progress className="v2-mileage-export-bar" percent={exportPercent} showInfo={false} size="small" strokeLinecap="round" aria-label={`${exportProgress.label} ${exportPercent}%`} />}
</div> : null} </div> : null}
{resultsError ? <InlineError message={resultsError instanceof Error ? resultsError.message : '里程数据加载失败'} onRetry={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} /> : null} {resultsError ? <InlineError message={resultsError instanceof Error ? resultsError.message : '里程数据加载失败'} onRetry={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} /> : null}
{!resultsError && !resultsLoading && displayVehicles.length ? <MileageMatrixGuide rows={matrixRows} view={metricView} /> : null} {!resultsError && !resultsLoading && displayVehicles.length ? <MileageMatrixGuide rows={matrixRows} view={metricView} hydrogenMetrics={hydrogenMatrixMetrics} onHydrogenMetricsChange={setHydrogenMatrixMetrics} /> : null}
{!resultsError && (resultsLoading ? <PanelLoading className="v2-mileage-loading" title={`正在查询${metricView === 'mileage' ? '里程' : '氢耗'}`} description="新筛选范围返回前不会展示上一范围的数据。" /> : displayVehicles.length ? <MileageTable rows={matrixRows} dates={dates} scrollRef={tableScrollRef} view={metricView} /> : null)} {!resultsError && (resultsLoading ? <PanelLoading className="v2-mileage-loading" title={`正在查询${metricView === 'mileage' ? '里程' : '氢耗'}`} description="新筛选范围返回前不会展示上一范围的数据。" /> : displayVehicles.length ? <MileageTable rows={matrixRows} dates={dates} scrollRef={tableScrollRef} view={metricView} hydrogenMetrics={hydrogenMatrixMetrics} onOpenEvidence={openHydrogenEvidence} /> : null)}
{!resultsError && !resultsLoading && !displayVehicles.length ? <PanelEmpty className="v2-mileage-empty" title="当前没有可展示的车辆" description="选择车牌或调整车辆授权范围后重试。" /> : null} {!resultsError && !resultsLoading && !displayVehicles.length ? <PanelEmpty className="v2-mileage-empty" title="当前没有可展示的车辆" description="选择车牌或调整车辆授权范围后重试。" /> : null}
<footer>{totalVehicles > PAGE_SIZE ? <TablePagination page={page} totalPages={totalPages} info={`${hasVehicles ? '已选择' : '共'} ${totalVehicles.toLocaleString('zh-CN')} 辆 · 每页 ${PAGE_SIZE}${exportFeedback ? ` · ${exportFeedback}` : ''}`} disabled={mileage.isFetching || (!hasVehicles && fleetVehicles.isFetching)} onPageChange={setPage} /> : <span className="v2-table-pagination-info">{hasVehicles ? '已选择' : '共'} {totalVehicles.toLocaleString('zh-CN')} {exportFeedback ? ` · ${exportFeedback}` : ''}</span>}</footer> <footer>{totalVehicles > PAGE_SIZE ? <TablePagination page={page} totalPages={totalPages} info={`${hasVehicles ? '已选择' : '共'} ${totalVehicles.toLocaleString('zh-CN')} 辆 · 每页 ${PAGE_SIZE}${exportFeedback ? ` · ${exportFeedback}` : ''}`} disabled={mileage.isFetching || (!hasVehicles && fleetVehicles.isFetching)} onPageChange={setPage} /> : <span className="v2-table-pagination-info">{hasVehicles ? '已选择' : '共'} {totalVehicles.toLocaleString('zh-CN')} {exportFeedback ? ` · ${exportFeedback}` : ''}</span>}</footer>
</Card> </Card>
<HydrogenEvidenceSheet target={hydrogenEvidenceTarget} onClose={() => setHydrogenEvidenceTarget(undefined)} />
<footer className="v2-mileage-evidence"><span>{statistics.data?.asOf || '—'}</span><span>{criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(' ')}</span><span> 1 · </span></footer> <footer className="v2-mileage-evidence"><span>{statistics.data?.asOf || '—'}</span><span>{criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(' ')}</span><span> 1 · </span></footer>
</div>; </div>;
} }
@@ -28523,6 +28523,300 @@
color: #8b6a3e; color: #8b6a3e;
} }
.v2-mileage-matrix-guide.has-metric-selector {
display: grid;
min-height: 78px;
grid-template-columns: minmax(0, 1fr);
gap: 7px;
padding-block: 8px;
white-space: normal;
}
.v2-mileage-matrix-guide-toolbar,
.v2-mileage-metric-selector,
.v2-mileage-metric-logic {
display: flex;
min-width: 0;
align-items: center;
}
.v2-mileage-matrix-guide-toolbar {
justify-content: space-between;
gap: 16px;
}
.v2-mileage-metric-selector {
gap: 5px;
}
.v2-mileage-metric-selector > span {
display: inline-flex;
margin-right: 3px;
flex-direction: column;
line-height: 1.1;
}
.v2-mileage-metric-selector > span > strong {
color: #61461f;
font-size: 10px;
}
.v2-mileage-metric-selector > span > small {
color: #9a8568;
font-size: 8px;
}
.v2-mileage-metric-selector .semi-tag,
.v2-mileage-metric-selector .semi-button {
min-width: auto;
height: 24px;
border-radius: 6px;
padding-inline: 9px;
font-size: 10px;
font-weight: 680;
}
.v2-mileage-metric-logic {
overflow-x: auto;
gap: 7px 16px;
color: #76654f;
font-size: 9px;
scrollbar-width: none;
white-space: nowrap;
}
.v2-mileage-metric-logic::-webkit-scrollbar {
display: none;
}
.v2-mileage-metric-logic > span {
display: inline-flex;
flex: 0 0 auto;
align-items: baseline;
gap: 4px;
}
.v2-mileage-metric-logic strong {
color: #8b5718;
font-weight: 750;
}
.v2-mileage-table .semi-table-row-head.is-date-group,
.v2-mileage-table .semi-table-row-head.is-total-group {
border-left: 1px solid #e2e8f0;
text-align: center;
}
.v2-mileage-table .semi-table-row-head.is-date-group {
background: #eef4fb;
color: #344f70;
font-weight: 760;
}
.v2-mileage-table .v2-mileage-metric-header {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 1px;
line-height: 1.05;
}
.v2-mileage-table .v2-mileage-metric-header > strong {
color: inherit;
font-size: 10px;
font-weight: 700;
}
.v2-mileage-table .v2-mileage-metric-header > small {
color: #8998aa;
font-size: 8px;
font-weight: 600;
}
.v2-mileage-table .semi-table-row-cell[class*="is-metric-"] > strong {
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.v2-mileage-table .semi-table-row-cell[class*="is-metric-"] > strong > small {
color: #8291a3;
font-size: 8px;
font-weight: 600;
}
.v2-mileage-table .semi-table-row-cell.is-metric-consumption > strong,
.v2-mileage-table .semi-table-row-cell.is-metric-rate > strong {
color: #a65f0b;
}
.v2-mileage-table .semi-table-row-cell.is-metric-pureElectric > strong {
color: #2767ae;
}
.v2-mileage-table .semi-table-row-cell.is-metric-pureHydrogen > strong {
color: #17815a;
}
.v2-hydrogen-evidence-trigger {
display: inline-flex;
appearance: none;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
flex-direction: column;
align-items: flex-end;
gap: 1px;
padding: 2px 3px;
}
.v2-hydrogen-evidence-trigger:hover,
.v2-hydrogen-evidence-trigger:focus-visible {
border-radius: 5px;
background: rgba(180, 106, 12, .09);
outline: 1px solid rgba(180, 106, 12, .28);
}
.v2-hydrogen-evidence-trigger > small {
color: #9a6a2d;
font-size: 8px;
font-weight: 700;
}
.v2-hydrogen-evidence-content {
display: grid;
gap: 14px;
}
.v2-hydrogen-evidence-content > section {
display: grid;
gap: 8px;
border: 1px solid #e6eaf0;
border-radius: 10px;
background: #fff;
padding: 12px;
}
.v2-hydrogen-evidence-content > section > header,
.v2-hydrogen-evidence-intervals article > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.v2-hydrogen-evidence-content > section > code {
overflow-x: auto;
border-radius: 6px;
background: #f7f8fa;
color: #374151;
padding: 7px 9px;
font-size: 11px;
white-space: nowrap;
}
.v2-hydrogen-evidence-content > section > p,
.v2-hydrogen-evidence-intervals article > p {
margin: 0;
color: #6b7280;
font-size: 11px;
}
.v2-hydrogen-evidence-parameters,
.v2-hydrogen-evidence-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin: 0;
}
.v2-hydrogen-evidence-parameters > div,
.v2-hydrogen-evidence-metrics > span {
display: grid;
gap: 2px;
border-radius: 7px;
background: #f8fafc;
padding: 8px;
}
.v2-hydrogen-evidence-parameters dt,
.v2-hydrogen-evidence-metrics small {
color: #7b8492;
font-size: 9px;
}
.v2-hydrogen-evidence-parameters dd,
.v2-hydrogen-evidence-metrics strong {
overflow-wrap: anywhere;
margin: 0;
color: #253247;
font-size: 11px;
}
.v2-hydrogen-evidence-intervals {
display: grid;
gap: 9px;
}
.v2-hydrogen-evidence-intervals article {
display: grid;
gap: 8px;
border-left: 3px solid #28a176;
border-radius: 8px;
background: #fbfcfd;
padding: 10px;
}
.v2-hydrogen-evidence-intervals article.is-suspect {
border-left-color: #d98a22;
}
.v2-hydrogen-evidence-intervals article > header > span {
display: inline-flex;
align-items: center;
gap: 7px;
}
.v2-hydrogen-evidence-events {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.v2-hydrogen-evidence-events button {
display: grid;
min-width: 0;
appearance: none;
border: 1px solid #e4e8ee;
border-radius: 7px;
background: #fff;
cursor: pointer;
gap: 2px;
padding: 7px;
text-align: left;
}
.v2-hydrogen-evidence-events button:hover {
border-color: #9db5d2;
}
.v2-hydrogen-evidence-events small {
color: #778190;
font-size: 9px;
}
.v2-hydrogen-evidence-events code {
overflow: hidden;
color: #34506f;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-hydrogen-evidence-intervals .is-reason {
color: #9a651d;
}
@media (max-width: 700px) { @media (max-width: 700px) {
.v2-mileage-page { .v2-mileage-page {
gap: 7px; gap: 7px;
@@ -28563,6 +28857,40 @@
padding: 5px 7px; padding: 5px 7px;
} }
.v2-mileage-matrix-guide.has-metric-selector {
min-height: 104px;
gap: 6px;
padding-block: 7px;
}
.v2-mileage-matrix-guide-toolbar {
display: grid;
gap: 6px;
}
.v2-mileage-metric-selector {
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: none;
}
.v2-mileage-metric-selector::-webkit-scrollbar {
display: none;
}
.v2-mileage-metric-selector > span {
display: none;
}
.v2-mileage-metric-selector .semi-button,
.v2-mileage-metric-selector .semi-tag {
flex: 0 0 auto;
}
.v2-mileage-metric-logic {
font-size: 8px;
}
.v2-mileage-matrix-guide > .v2-mileage-matrix-guide-mobile { .v2-mileage-matrix-guide > .v2-mileage-matrix-guide-mobile {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 2px 5px; gap: 2px 5px;
@@ -59,6 +59,7 @@ test -x "$root/current/deploy/import-source-providers.py"
test -f "$root/current/deploy/migrations/017_reconciliation_center.sql" test -f "$root/current/deploy/migrations/017_reconciliation_center.sql"
test -f "$root/current/deploy/migrations/036_alert_rule_archive.sql" test -f "$root/current/deploy/migrations/036_alert_rule_archive.sql"
test -f "$root/current/deploy/migrations/038_daily_pure_hydrogen_mileage.sql" test -f "$root/current/deploy/migrations/038_daily_pure_hydrogen_mileage.sql"
test -f "$root/current/deploy/migrations/044_hydrogen_v35_realtime.sql"
test -f "$root/current/deploy/systemd/lingniu-vehicle-reconciliation-evaluator.timer" test -f "$root/current/deploy/systemd/lingniu-vehicle-reconciliation-evaluator.timer"
test ! -e "$root/current/lingniu-vehicle-platform.service" test ! -e "$root/current/lingniu-vehicle-platform.service"
test "$(find "$root/releases" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" = 3 test "$(find "$root/releases" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" = 3
@@ -0,0 +1,6 @@
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;
+6 -1
View File
@@ -256,13 +256,18 @@ test -n "$MYSQL_DSN"
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/034_alert_notification_retry.sql \ /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/034_alert_notification_retry.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/035_alert_notification_dispatch.sql \ /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/035_alert_notification_dispatch.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/036_alert_rule_archive.sql \ /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/036_alert_rule_archive.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/042_hydrogen_segment_stream_state.sql /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/041_daily_mileage_day_end_total.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/042_hydrogen_segment_stream_state.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/043_hydrogen_energy_traceability.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/044_hydrogen_v35_realtime.sql
``` ```
Migration `023` creates vehicle open-platform appKey records, per-vehicle grant intervals, precomputed daily energy, and immutable API/admin audits. Apply it before serving `/api/v1/vehicles/*` or enabling `lingniu-vehicle-open-stat.timer`. Migration `023` creates vehicle open-platform appKey records, per-vehicle grant intervals, precomputed daily energy, and immutable API/admin audits. Apply it before serving `/api/v1/vehicles/*` or enabling `lingniu-vehicle-open-stat.timer`.
Migration `029` creates the local VIN-to-tank-capacity projection and pressure-calculation evidence columns. Migration `043` creates the effective-dated VIN energy-parameter table. Migration `030` adds the per-refuel-cycle low-water mark used to prevent pressure and temperature oscillation from being counted repeatedly. At startup and every six hours, the stat writer projects `vehicle_model.tank_capacity` and `vehicle_model.battery_capacity` through `vehicle_info.vehicle_model_id` to VIN-scoped calculation parameters. Rated battery energy is therefore model-specific rather than hard-coded by tonnage. Migration `029` creates the local VIN-to-tank-capacity projection and pressure-calculation evidence columns. Migration `043` creates the effective-dated VIN energy-parameter table. Migration `030` adds the per-refuel-cycle low-water mark used to prevent pressure and temperature oscillation from being counted repeatedly. At startup and every six hours, the stat writer projects `vehicle_model.tank_capacity` and `vehicle_model.battery_capacity` through `vehicle_info.vehicle_model_id` to VIN-scoped calculation parameters. Rated battery energy is therefore model-specific rather than hard-coded by tonnage.
Migration `044` enables the V3.5 streaming calculation method and adds `calculation_phase`. Current-day streaming rows are marked `PRELIMINARY`; the completed-day `open-platform-stat` rebuild writes `FINAL`. Apply `043` and `044` before restarting `vehicle-stat-writer`.
Migration `038` adds daily pure-hydrogen mileage to the elected mileage table and its per-source evidence table. Deploy the migration before the updated stat writer and API so GB32960 `engine_work_state=2` and Yutong `TRIANGLE_STATE=4/11` intervals can be accumulated and returned as `pureHydrogenMileageKm`. Migration `038` adds daily pure-hydrogen mileage to the elected mileage table and its per-source evidence table. Deploy the migration before the updated stat writer and API so GB32960 `engine_work_state=2` and Yutong `TRIANGLE_STATE=4/11` intervals can be accumulated and returned as `pureHydrogenMileageKm`.
Migration `042` creates the per-VIN/per-day bounded state used by the GB32960 hydrogen segment stream. Apply it before switching `vehicle-stat-writer`; the previous writer ignores the additive table, so rolling back the binary does not require dropping it. Migration `042` creates the per-VIN/per-day bounded state used by the GB32960 hydrogen segment stream. Apply it before switching `vehicle-stat-writer`; the previous writer ignores the additive table, so rolling back the binary does not require dropping it.
@@ -68,6 +68,8 @@ POST /api/v1/vehicles/hydrogen-consumption/query
"plateNumber": "粤A12345", "plateNumber": "粤A12345",
"date": "2026-07-01", "date": "2026-07-01",
"hydrogenConsumptionKg": 12.315, "hydrogenConsumptionKg": 12.315,
"calculationPhase": "FINAL",
"algorithmVersion": "PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5",
"status": "NORMAL" "status": "NORMAL"
}, },
{ {
@@ -81,6 +83,8 @@ POST /api/v1/vehicles/hydrogen-consumption/query
} }
``` ```
当天查询可能返回 `calculationPhase: PRELIMINARY`,表示结果由 V3.5 流式状态机生成,后续帧可能使结果回修;完成日终全量重算后变为 `FINAL`
## 单日里程 ## 单日里程
```http ```http