feat: 推广 V3.5 实时氢耗并完善导出
This commit is contained in:
@@ -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 {
|
||||
registry.SetGauge("vehicle_stat_hydrogen_capacity_cache_entries", nil, float64(loaded))
|
||||
}
|
||||
if loaded, err := writer.ReloadHydrogenRealtimeParameters(ctx); err != nil {
|
||||
logger.Warn("hydrogen V3.5 parameter cache load failed", "error", err)
|
||||
} else {
|
||||
registry.SetGauge("vehicle_stat_hydrogen_v35_parameter_cache_entries", nil, float64(loaded))
|
||||
}
|
||||
if cfg.NormalizePlatformSourcesOnStart {
|
||||
statDate := time.Now().In(cfg.Location).Format("2006-01-02")
|
||||
normalizeCtx, cancel := context.WithTimeout(ctx, cfg.NormalizePlatformSourcesTimeout)
|
||||
@@ -191,6 +196,13 @@ func syncHydrogenCapacities(ctx context.Context, logger interface {
|
||||
}
|
||||
registry.IncCounter("vehicle_stat_hydrogen_capacity_sync_total", metrics.Labels{"status": "ok"})
|
||||
registry.SetGauge("vehicle_stat_hydrogen_capacity_cache_entries", nil, float64(loaded))
|
||||
parameterCount, parameterErr := writer.ReloadHydrogenRealtimeParameters(syncCtx)
|
||||
if parameterErr != nil {
|
||||
registry.IncCounter("vehicle_stat_hydrogen_energy_parameter_sync_total", metrics.Labels{"status": "cache_error"})
|
||||
logger.Warn("hydrogen V3.5 parameter cache reload failed", "error", parameterErr)
|
||||
} else {
|
||||
registry.SetGauge("vehicle_stat_hydrogen_v35_parameter_cache_entries", nil, float64(parameterCount))
|
||||
}
|
||||
metrics.RecordLastActivity(registry, "vehicle_stat_hydrogen_capacity_last_sync_unix_seconds", nil)
|
||||
logger.Info("hydrogen tank capacities synchronized", "source_schema", cfg.HydrogenCapacitySourceSchema, "read", result.Read, "written", result.Written, "deactivated", result.Deactivated, "cache_entries", loaded)
|
||||
}
|
||||
|
||||
@@ -30,31 +30,32 @@ type Execer interface {
|
||||
}
|
||||
|
||||
type Writer struct {
|
||||
exec Execer
|
||||
query Queryer
|
||||
loc *time.Location
|
||||
sourceTouchInterval time.Duration
|
||||
projectionInterval time.Duration
|
||||
cacheRetention time.Duration
|
||||
cacheCleanupInterval time.Duration
|
||||
baselineMissTTL time.Duration
|
||||
baselineHitTTL time.Duration
|
||||
maxCacheEntries int
|
||||
lastCacheCleanup time.Time
|
||||
lastCacheCleanupStats cacheCleanupStats
|
||||
cacheEvictions cacheCleanupStats
|
||||
mu sync.Mutex
|
||||
lastTotalMileage map[string]float64
|
||||
lastSourceSeen map[string]time.Time
|
||||
lastProjection map[string]projectionCacheEntry
|
||||
pendingProjection map[string]pendingProjectionEntry
|
||||
baselineCache map[string]sourceBaselineCacheEntry
|
||||
mileageKeysByPrefix map[string]map[string]struct{}
|
||||
projectionKeysByPrefix map[string]map[string]struct{}
|
||||
baselineKeysByPrefix map[string]map[string]struct{}
|
||||
lastGPSAccumulation map[string]time.Time
|
||||
hydrogenTankCapacities map[string]float64
|
||||
gpsAccumulationInterval time.Duration
|
||||
exec Execer
|
||||
query Queryer
|
||||
loc *time.Location
|
||||
sourceTouchInterval time.Duration
|
||||
projectionInterval time.Duration
|
||||
cacheRetention time.Duration
|
||||
cacheCleanupInterval time.Duration
|
||||
baselineMissTTL time.Duration
|
||||
baselineHitTTL time.Duration
|
||||
maxCacheEntries int
|
||||
lastCacheCleanup time.Time
|
||||
lastCacheCleanupStats cacheCleanupStats
|
||||
cacheEvictions cacheCleanupStats
|
||||
mu sync.Mutex
|
||||
lastTotalMileage map[string]float64
|
||||
lastSourceSeen map[string]time.Time
|
||||
lastProjection map[string]projectionCacheEntry
|
||||
pendingProjection map[string]pendingProjectionEntry
|
||||
baselineCache map[string]sourceBaselineCacheEntry
|
||||
mileageKeysByPrefix map[string]map[string]struct{}
|
||||
projectionKeysByPrefix map[string]map[string]struct{}
|
||||
baselineKeysByPrefix map[string]map[string]struct{}
|
||||
lastGPSAccumulation map[string]time.Time
|
||||
hydrogenTankCapacities map[string]float64
|
||||
hydrogenEnergyParameters map[string]HydrogenRealtimeParameters
|
||||
gpsAccumulationInterval time.Duration
|
||||
}
|
||||
|
||||
type MetricSample struct {
|
||||
@@ -151,26 +152,27 @@ func NewWriter(exec Execer, loc *time.Location) *Writer {
|
||||
loc = time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
}
|
||||
writer := &Writer{
|
||||
exec: exec,
|
||||
loc: loc,
|
||||
sourceTouchInterval: time.Minute,
|
||||
projectionInterval: 15 * time.Second,
|
||||
cacheRetention: defaultCacheRetention,
|
||||
cacheCleanupInterval: defaultCacheCleanupInterval,
|
||||
baselineMissTTL: defaultBaselineMissTTL,
|
||||
baselineHitTTL: defaultBaselineHitTTL,
|
||||
maxCacheEntries: defaultMaxCacheEntries,
|
||||
lastTotalMileage: map[string]float64{},
|
||||
lastSourceSeen: map[string]time.Time{},
|
||||
lastProjection: map[string]projectionCacheEntry{},
|
||||
pendingProjection: map[string]pendingProjectionEntry{},
|
||||
baselineCache: map[string]sourceBaselineCacheEntry{},
|
||||
mileageKeysByPrefix: map[string]map[string]struct{}{},
|
||||
projectionKeysByPrefix: map[string]map[string]struct{}{},
|
||||
baselineKeysByPrefix: map[string]map[string]struct{}{},
|
||||
lastGPSAccumulation: map[string]time.Time{},
|
||||
hydrogenTankCapacities: map[string]float64{},
|
||||
gpsAccumulationInterval: defaultGPSAccumulationInterval,
|
||||
exec: exec,
|
||||
loc: loc,
|
||||
sourceTouchInterval: time.Minute,
|
||||
projectionInterval: 15 * time.Second,
|
||||
cacheRetention: defaultCacheRetention,
|
||||
cacheCleanupInterval: defaultCacheCleanupInterval,
|
||||
baselineMissTTL: defaultBaselineMissTTL,
|
||||
baselineHitTTL: defaultBaselineHitTTL,
|
||||
maxCacheEntries: defaultMaxCacheEntries,
|
||||
lastTotalMileage: map[string]float64{},
|
||||
lastSourceSeen: map[string]time.Time{},
|
||||
lastProjection: map[string]projectionCacheEntry{},
|
||||
pendingProjection: map[string]pendingProjectionEntry{},
|
||||
baselineCache: map[string]sourceBaselineCacheEntry{},
|
||||
mileageKeysByPrefix: map[string]map[string]struct{}{},
|
||||
projectionKeysByPrefix: map[string]map[string]struct{}{},
|
||||
baselineKeysByPrefix: map[string]map[string]struct{}{},
|
||||
lastGPSAccumulation: map[string]time.Time{},
|
||||
hydrogenTankCapacities: map[string]float64{},
|
||||
hydrogenEnergyParameters: map[string]HydrogenRealtimeParameters{},
|
||||
gpsAccumulationInterval: defaultGPSAccumulationInterval,
|
||||
}
|
||||
if query, ok := exec.(Queryer); ok {
|
||||
writer.query = query
|
||||
@@ -197,6 +199,25 @@ func (w *Writer) HydrogenTankCapacityLiters(vin string) (float64, bool) {
|
||||
return capacity, ok
|
||||
}
|
||||
|
||||
func (w *Writer) ReloadHydrogenRealtimeParameters(ctx context.Context) (int, error) {
|
||||
date := time.Now().In(w.loc).Format("2006-01-02")
|
||||
parameters, err := LoadHydrogenRealtimeParameters(ctx, w.query, date)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
w.mu.Lock()
|
||||
w.hydrogenEnergyParameters = parameters
|
||||
w.mu.Unlock()
|
||||
return len(parameters), nil
|
||||
}
|
||||
|
||||
func (w *Writer) HydrogenRealtimeParameters(vin string) HydrogenRealtimeParameters {
|
||||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return normalizeHydrogenRealtimeParameters(w.hydrogenEnergyParameters[vin])
|
||||
}
|
||||
|
||||
func (w *Writer) SetSourceTouchInterval(interval time.Duration) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
@@ -315,6 +336,11 @@ func (w *Writer) EnsureSchema(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, statement := range HydrogenRealtimeAlterSQL {
|
||||
if _, err := w.exec.ExecContext(ctx, statement); err != nil && !isIgnorableSchemaChangeError(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -333,7 +359,8 @@ func (w *Writer) AppendWithResult(ctx context.Context, env envelope.FrameEnvelop
|
||||
w.maybeCleanupCaches(seenAt)
|
||||
identity, hasSource := NewSourceIdentityFromEnvelope(env)
|
||||
capacityLiters, _ := w.HydrogenTankCapacityLiters(env.VIN)
|
||||
hydrogen, err := AppendHydrogenStream(ctx, w.exec, env, w.loc, time.Now(), capacityLiters)
|
||||
hydrogenParams := w.HydrogenRealtimeParameters(env.VIN)
|
||||
hydrogen, err := AppendHydrogenV35Realtime(ctx, w.exec, env, w.loc, time.Now(), capacityLiters, hydrogenParams)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
@@ -1950,7 +1950,7 @@ func TestWriterEnsuresSchemaAndUpsertsDailyMileage(t *testing.T) {
|
||||
t.Fatalf("Append() error = %v", err)
|
||||
}
|
||||
|
||||
schemaCalls := 8 + len(DailyMileageAlterSQL)
|
||||
schemaCalls := 8 + len(DailyMileageAlterSQL) + len(HydrogenRealtimeAlterSQL)
|
||||
if len(exec.calls) != schemaCalls+5 {
|
||||
t.Fatalf("exec calls = %d", len(exec.calls))
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ const HydrogenSegmentStreamStateTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_o
|
||||
last_event_time DATETIME(3) NOT NULL,
|
||||
last_event_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
state_json JSON NOT NULL,
|
||||
calculation_method VARCHAR(48) NOT NULL DEFAULT 'PRESSURE_NIST_SEGMENT_MEDIAN_5',
|
||||
calculation_method VARCHAR(80) NOT NULL DEFAULT 'PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5',
|
||||
quality_status VARCHAR(24) NOT NULL DEFAULT 'NO_DATA',
|
||||
quality_reason VARCHAR(255) NOT NULL DEFAULT '有效工作段关键帧不足10条',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
@@ -116,6 +116,18 @@ type HydrogenStreamSample struct {
|
||||
FuelCellActive bool
|
||||
FuelCellStateKnown bool
|
||||
ConsumptionEligible bool
|
||||
MileageKM float64
|
||||
MileageKnown bool
|
||||
SOCPercent float64
|
||||
SOCKnown bool
|
||||
VehicleState int
|
||||
VehicleStateKnown bool
|
||||
ChargeState int
|
||||
ChargeStateKnown bool
|
||||
RunningMode int
|
||||
RunningModeKnown bool
|
||||
FuelCellPowerKW float64
|
||||
FuelCellPowerKnown bool
|
||||
}
|
||||
|
||||
type HydrogenStreamResult struct {
|
||||
@@ -453,6 +465,26 @@ func HydrogenStreamSampleFromEnvelope(env envelope.FrameEnvelope, loc *time.Loca
|
||||
fuelCellStateKnown = true
|
||||
}
|
||||
}
|
||||
mileageKM, mileageKnown := firstHydrogenNumber(env.Fields, []string{
|
||||
envelope.FieldTotalMileageKM, "gb32960.vehicle.total_mileage_km",
|
||||
})
|
||||
socPercent, socKnown := firstHydrogenNumber(env.Fields, []string{
|
||||
envelope.FieldSOCPercent, "gb32960.vehicle.soc_percent",
|
||||
})
|
||||
vehicleStateValue, vehicleStateKnown := firstHydrogenNumber(env.Fields, []string{
|
||||
"vehicle_status", "gb32960.vehicle.vehicle_status",
|
||||
})
|
||||
chargeStateValue, chargeStateKnown := firstHydrogenNumber(env.Fields, []string{
|
||||
"charge_status", "gb32960.vehicle.charge_status",
|
||||
})
|
||||
runningModeValue, runningModeKnown := firstHydrogenNumber(env.Fields, []string{
|
||||
envelope.FieldVehicleRunningMode, "gb32960.vehicle.running_mode",
|
||||
})
|
||||
fuelCellVoltage, voltageKnown := firstHydrogenNumber(env.Fields, []string{
|
||||
"fuel_cell_voltage_v", "gb32960.fuel_cell.fuel_cell_voltage_v",
|
||||
})
|
||||
fuelCellCurrent, currentKnown := firstHydrogenNumber(env.Fields, hydrogenCurrentFieldKeys)
|
||||
fuelCellPowerKnown := voltageKnown && currentKnown && fuelCellVoltage >= 0 && fuelCellCurrent >= 0
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
}
|
||||
@@ -475,6 +507,12 @@ func HydrogenStreamSampleFromEnvelope(env envelope.FrameEnvelope, loc *time.Loca
|
||||
NoiseKG: noiseKG, RefuelThresholdKG: refuelThresholdKG,
|
||||
FuelCellActive: fuelCellActive, FuelCellStateKnown: fuelCellStateKnown,
|
||||
ConsumptionEligible: !fuelCellStateKnown || fuelCellActive,
|
||||
MileageKM: mileageKM, MileageKnown: mileageKnown,
|
||||
SOCPercent: socPercent, SOCKnown: socKnown && socPercent >= 0 && socPercent <= 100,
|
||||
VehicleState: int(vehicleStateValue), VehicleStateKnown: vehicleStateKnown,
|
||||
ChargeState: int(chargeStateValue), ChargeStateKnown: chargeStateKnown,
|
||||
RunningMode: int(runningModeValue), RunningModeKnown: runningModeKnown,
|
||||
FuelCellPowerKW: fuelCellVoltage * fuelCellCurrent / 1000, FuelCellPowerKnown: fuelCellPowerKnown,
|
||||
}, "", true
|
||||
}
|
||||
|
||||
@@ -647,13 +685,13 @@ const insertHydrogenSegmentStreamStateSQL = `INSERT INTO vehicle_open_hydrogen_s
|
||||
vin,stat_date,source_endpoint,finalized_consumption_kg,projected_consumption_kg,
|
||||
sample_count,refuel_count,abnormal_drop_count,eligible_interval_count,qualified_segment_count,
|
||||
last_mass_kg,last_event_time,last_event_id,state_json,calculation_method,quality_status,quality_reason
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'PRESSURE_NIST_SEGMENT_MEDIAN_5',?,?)`
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5',?,?)`
|
||||
|
||||
const updateHydrogenSegmentStreamStateSQL = `UPDATE vehicle_open_hydrogen_segment_stream_state
|
||||
SET source_endpoint=?,finalized_consumption_kg=?,projected_consumption_kg=?,sample_count=?,
|
||||
refuel_count=?,abnormal_drop_count=?,eligible_interval_count=?,qualified_segment_count=?,
|
||||
last_mass_kg=?,last_event_time=?,last_event_id=?,state_json=?,
|
||||
calculation_method='PRESSURE_NIST_SEGMENT_MEDIAN_5',quality_status=?,quality_reason=?
|
||||
calculation_method='PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5',quality_status=?,quality_reason=?
|
||||
WHERE vin=? AND stat_date=?`
|
||||
|
||||
const projectHydrogenSegmentStreamDailySQL = `INSERT INTO vehicle_open_daily_energy(
|
||||
|
||||
@@ -49,6 +49,29 @@ func TestHydrogenStreamSampleUsesPressureTemperatureAndCapacity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrogenStreamSampleExtractsV35Inputs(t *testing.T) {
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
eventTime := time.Date(2026, 9, 4, 10, 30, 0, 0, loc)
|
||||
env := pressureEnvelope(eventTime)
|
||||
env.Fields[envelope.FieldTotalMileageKM] = 12005.6
|
||||
env.Fields[envelope.FieldSOCPercent] = 72.5
|
||||
env.Fields[envelope.FieldVehicleRunningMode] = 2
|
||||
env.Fields[envelope.FieldFuelCellWorkMode] = 2
|
||||
env.Fields["vehicle_status"] = 1
|
||||
env.Fields["charge_status"] = 3
|
||||
env.Fields["fuel_cell_voltage_v"] = 480.0
|
||||
env.Fields["fuel_cell_current_a"] = 100.0
|
||||
sample, reason, ok := HydrogenStreamSampleFromEnvelope(env, loc, eventTime, 520)
|
||||
if !ok || reason != "" {
|
||||
t.Fatalf("sample rejected: reason=%q", reason)
|
||||
}
|
||||
if !sample.MileageKnown || sample.MileageKM != 12005.6 || !sample.SOCKnown || sample.SOCPercent != 72.5 ||
|
||||
!sample.VehicleStateKnown || sample.VehicleState != 1 || !sample.ChargeStateKnown || sample.ChargeState != 3 ||
|
||||
!sample.RunningModeKnown || sample.RunningMode != 2 || !sample.FuelCellPowerKnown || sample.FuelCellPowerKW != 48 {
|
||||
t.Fatalf("V3.5 fields=%#v", sample)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrogenStreamMarksInactiveFuelCellIneligible(t *testing.T) {
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
eventTime := time.Date(2026, 8, 15, 13, 31, 7, 0, loc)
|
||||
@@ -288,13 +311,13 @@ func TestWriterAppendWithResultUsesInMemoryCapacity(t *testing.T) {
|
||||
eventTime := time.Now().In(loc).Truncate(time.Millisecond)
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta("SELECT state_json")).WillReturnError(sql.ErrNoRows)
|
||||
mock.ExpectQuery(regexp.QuoteMeta("SELECT source_endpoint,consumption_kg,first_mass_kg,last_mass_kg,")).WillReturnError(sql.ErrNoRows)
|
||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_hydrogen_segment_stream_state")).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy")).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
writer := NewWriter(db, loc)
|
||||
writer.hydrogenTankCapacities["LA9GG64L0NBAF4175"] = 2050
|
||||
writer.hydrogenEnergyParameters["LA9GG64L0NBAF4175"] = HydrogenRealtimeParameters{BatteryCapacityKWh: 80, HydrogenEnergyKWhKG: 16}
|
||||
result, err := writer.AppendWithResult(context.Background(), pressureEnvelope(eventTime))
|
||||
if err != nil {
|
||||
t.Fatalf("AppendWithResult() error = %v", err)
|
||||
@@ -311,7 +334,7 @@ func TestHydrogenStreamSQLUsesVINDateStateAndSegmentMethod(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"PRIMARY KEY (vin, stat_date)",
|
||||
"state_json JSON NOT NULL",
|
||||
"PRESSURE_NIST_SEGMENT_MEDIAN_5",
|
||||
HydrogenV35RealtimeAlgorithmVersion,
|
||||
} {
|
||||
if !strings.Contains(HydrogenSegmentStreamStateTableSQL, want) {
|
||||
t.Fatalf("segment stream schema missing %q", want)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
var HydrogenRealtimeAlterSQL = []string{
|
||||
"ALTER TABLE vehicle_open_hydrogen_segment_stream_state MODIFY COLUMN calculation_method VARCHAR(80) NOT NULL DEFAULT 'PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5'",
|
||||
"ALTER TABLE vehicle_open_daily_energy ADD COLUMN calculation_phase VARCHAR(16) NOT NULL DEFAULT 'FINAL' AFTER algorithm_version",
|
||||
}
|
||||
|
||||
const DataSourceTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_data_source (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
protocol VARCHAR(32) NOT NULL,
|
||||
|
||||
@@ -26,6 +26,7 @@ func main() {
|
||||
noise := flag.Float64("hydrogen-noise-kg", envFloat("OPEN_STAT_HYDROGEN_NOISE_KG", 0.05), "ignored mass jitter")
|
||||
maxDrop := flag.Float64("hydrogen-max-drop-kg", envFloat("OPEN_STAT_HYDROGEN_MAX_DROP_KG", 20), "maximum accepted drop between samples")
|
||||
vin := flag.String("vin", "", "optional 17-character VIN for a scoped rebuild")
|
||||
brand := flag.String("brand", "", "optional exact vehicle brand for an atomic scoped rebuild")
|
||||
vinWorkers := flag.Int("vin-workers", envInt("OPEN_STAT_VIN_WORKERS", 4), "parallel per-VIN queries for an all-vehicle day")
|
||||
allDates := flag.Bool("all-dates", false, "rebuild every available completed event date; optionally scoped by -vin")
|
||||
dryRun := flag.Bool("dry-run", false, "calculate and print results without changing MySQL")
|
||||
@@ -62,9 +63,16 @@ func main() {
|
||||
log.Fatal("-vin-workers must be between 1 and 16")
|
||||
}
|
||||
normalizedVIN := strings.ToUpper(strings.TrimSpace(*vin))
|
||||
normalizedBrand := strings.TrimSpace(*brand)
|
||||
if normalizedVIN != "" && normalizedBrand != "" {
|
||||
log.Fatal("-vin and -brand cannot be combined")
|
||||
}
|
||||
if *seedStream && normalizedVIN != "" {
|
||||
log.Fatal("-seed-stream-state requires an all-vehicle rebuild")
|
||||
}
|
||||
if *seedStream && normalizedBrand != "" {
|
||||
log.Fatal("-seed-stream-state cannot be combined with -brand")
|
||||
}
|
||||
if *seedStream && *dryRun {
|
||||
log.Fatal("-seed-stream-state cannot be combined with -dry-run")
|
||||
}
|
||||
@@ -84,6 +92,29 @@ func main() {
|
||||
log.Fatalf("no active hydrogen tank capacity for VIN %s", normalizedVIN)
|
||||
}
|
||||
}
|
||||
var brandVINs []string
|
||||
if normalizedBrand != "" {
|
||||
brandVINs, err = openplatform.LoadHydrogenBrandVINs(ctx, mysqlDB, normalizedBrand)
|
||||
if err != nil {
|
||||
log.Fatalf("load VINs for brand %q: %v", normalizedBrand, err)
|
||||
}
|
||||
if len(brandVINs) == 0 {
|
||||
log.Fatalf("no vehicles found for brand %q", normalizedBrand)
|
||||
}
|
||||
brandSet := make(map[string]struct{}, len(brandVINs))
|
||||
for _, brandVIN := range brandVINs {
|
||||
brandSet[brandVIN] = struct{}{}
|
||||
}
|
||||
for capacityVIN := range capacities {
|
||||
if _, ok := brandSet[capacityVIN]; !ok {
|
||||
delete(capacities, capacityVIN)
|
||||
}
|
||||
}
|
||||
if len(capacities) == 0 {
|
||||
log.Fatalf("no active hydrogen tank capacities found for brand %q", normalizedBrand)
|
||||
}
|
||||
fmt.Printf("brand=%s scoped_vins=%d capacity_vins=%d\n", normalizedBrand, len(brandVINs), len(capacities))
|
||||
}
|
||||
startDate := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, location).AddDate(0, 0, -(*lookback - 1))
|
||||
if *allDates {
|
||||
var first, last time.Time
|
||||
@@ -112,6 +143,8 @@ func main() {
|
||||
scope := "all-vehicles"
|
||||
if normalizedVIN != "" {
|
||||
scope = normalizedVIN
|
||||
} else if normalizedBrand != "" {
|
||||
scope = "brand:" + normalizedBrand
|
||||
}
|
||||
fmt.Printf("scope=%s date_from=%s date_to=%s mode=all-dates\n", scope, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"))
|
||||
}
|
||||
@@ -141,6 +174,8 @@ func main() {
|
||||
if !*dryRun {
|
||||
if *seedStream {
|
||||
err = openplatform.ReplaceHydrogenDailyStatsAndSeedStream(ctx, mysqlDB, date, stats)
|
||||
} else if normalizedBrand != "" {
|
||||
err = openplatform.ReplaceHydrogenDailyStatsForVINs(ctx, mysqlDB, date, brandVINs, stats)
|
||||
} else if normalizedVIN == "" {
|
||||
err = openplatform.ReplaceHydrogenDailyStats(ctx, mysqlDB, date, stats)
|
||||
} else {
|
||||
|
||||
@@ -150,6 +150,10 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/api/v2/admin/users") && !principal.CanMenu("users") {
|
||||
httpx.WriteError(w, http.StatusForbidden, "MENU_PERMISSION_DENIED", "当前账号无权访问账号管理", "users", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
required := requiredRole(r)
|
||||
if roleRank(principal.Role) < roleRank(required) {
|
||||
httpx.WriteError(w, http.StatusForbidden, "PERMISSION_DENIED", "当前角色无权执行该操作", "需要 "+required+" 角色", requestTraceID(r))
|
||||
@@ -243,7 +247,7 @@ func requiredMenu(r *http.Request) string {
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/api/v2/monitor"), path == "/api/v2/alerts/events":
|
||||
return "monitor"
|
||||
case path == "/api/map/reverse-geocode", path == "/api/realtime/vehicles", path == "/api/realtime/locations", path == "/api/vehicle-service", path == "/api/vehicle-service/overview", strings.HasSuffix(path, "/telemetry/latest"):
|
||||
case path == "/api/map/reverse-geocode", path == "/api/realtime/vehicles", path == "/api/realtime/locations", path == "/api/vehicle-service", path == "/api/vehicle-service/overview", path == "/api/vehicle-service/overviews", strings.HasSuffix(path, "/telemetry/latest"):
|
||||
return "shared"
|
||||
case path == "/api/v2/tracks":
|
||||
return "tracks"
|
||||
|
||||
@@ -40,6 +40,11 @@ var customerMenuSet = map[string]bool{
|
||||
|
||||
var adminMenus = []string{"monitor", "vehicles", "tracks", "history", "statistics", "alerts", "access", "operations", "users"}
|
||||
|
||||
var adminMenuSet = map[string]bool{
|
||||
"monitor": true, "vehicles": true, "tracks": true, "history": true, "statistics": true,
|
||||
"alerts": true, "access": true, "operations": true, "users": true,
|
||||
}
|
||||
|
||||
type authUser struct {
|
||||
ID uint64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
@@ -551,14 +556,13 @@ func (s *authStore) localCredential(ctx context.Context, username string) (local
|
||||
}
|
||||
|
||||
func (s *authStore) principalForUser(ctx context.Context, user authUser) (platform.Principal, error) {
|
||||
menus := append([]string(nil), adminMenus...)
|
||||
menus := []string{}
|
||||
vehicles := []string{}
|
||||
vehicleGrants := []platform.VehicleGrant{}
|
||||
businessScopeLevel := ""
|
||||
departmentIDs := []string{}
|
||||
responsibleUserID := ""
|
||||
if user.UserType == "customer" {
|
||||
menus = []string{}
|
||||
if user.UserType == "customer" || user.UserType == "admin" {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key`, user.ID)
|
||||
if err != nil {
|
||||
return platform.Principal{}, err
|
||||
@@ -569,14 +573,19 @@ func (s *authStore) principalForUser(ctx context.Context, user authUser) (platfo
|
||||
rows.Close()
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
if customerMenuSet[value] {
|
||||
if (user.UserType == "customer" && customerMenuSet[value]) || (user.UserType == "admin" && adminMenuSet[value]) {
|
||||
menus = append(menus, value)
|
||||
}
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
rows, err = s.db.QueryContext(ctx, `SELECT vin,COALESCE(valid_from,granted_at),valid_to FROM platform_user_vehicle WHERE user_id=? AND (valid_from IS NULL OR valid_from<=NOW(3)) AND (valid_to IS NULL OR valid_to>NOW(3)) ORDER BY vin`, user.ID)
|
||||
}
|
||||
if user.UserType == "admin" && len(menus) == 0 {
|
||||
menus = append([]string(nil), adminMenus...)
|
||||
}
|
||||
if user.UserType == "customer" {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT vin,COALESCE(valid_from,granted_at),valid_to FROM platform_user_vehicle WHERE user_id=? AND (valid_from IS NULL OR valid_from<=NOW(3)) AND (valid_to IS NULL OR valid_to>NOW(3)) ORDER BY vin`, user.ID)
|
||||
if err != nil {
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
|
||||
@@ -90,6 +90,30 @@ FROM platform_user_business_scope WHERE user_id=? AND enabled=1`)).
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrincipalForAdminHonorsExplicitMenus(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := &authStore{db: db, cache: map[string]cachedSession{}}
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key`)).
|
||||
WithArgs(uint64(8)).WillReturnRows(sqlmock.NewRows([]string{"menu_key"}).AddRow("monitor").AddRow("operations"))
|
||||
|
||||
principal, err := store.principalForUser(context.Background(), authUser{
|
||||
ID: 8, DisplayName: "业务管理", Username: "ln-bm", UserType: "admin", AuthProvider: "local",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !principal.CanMenu("operations") || principal.CanMenu("users") {
|
||||
t.Fatalf("restricted admin menus were not honored: %+v", principal.MenuKeys)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrincipalForOneOSOrdinaryUserUsesResponsibleVehicles(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -212,6 +213,39 @@ func TestAuthSelfServiceEndpointsAllowCustomerRole(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestrictedAdminCannotAccessAccountManagement(t *testing.T) {
|
||||
token := "restricted-admin-token-at-least-16"
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
authenticator := &apiAuthenticator{
|
||||
mode: "enforce",
|
||||
tokens: []tokenPrincipal{{
|
||||
hash: hash,
|
||||
principal: platform.Principal{
|
||||
Name: "业务管理", Username: "ln-bm", Role: "admin", UserType: "admin",
|
||||
MenuKeys: []string{"monitor", "vehicles", "tracks", "history", "statistics", "alerts", "access", "operations"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||
handler := authenticator.middleware(next)
|
||||
|
||||
usersRequest := httptest.NewRequest(http.MethodGet, "/api/v2/admin/users", nil)
|
||||
usersRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
usersResponse := httptest.NewRecorder()
|
||||
handler.ServeHTTP(usersResponse, usersRequest)
|
||||
if usersResponse.Code != http.StatusForbidden || !strings.Contains(usersResponse.Body.String(), "MENU_PERMISSION_DENIED") {
|
||||
t.Fatalf("restricted admin account management status=%d body=%s", usersResponse.Code, usersResponse.Body.String())
|
||||
}
|
||||
|
||||
operationsRequest := httptest.NewRequest(http.MethodGet, "/api/v2/operations/source-rules", nil)
|
||||
operationsRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
operationsResponse := httptest.NewRecorder()
|
||||
handler.ServeHTTP(operationsResponse, operationsRequest)
|
||||
if operationsResponse.Code != http.StatusNoContent {
|
||||
t.Fatalf("restricted admin should retain other admin permissions, status=%d", operationsResponse.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileagePostQueriesAllowCustomerRole(t *testing.T) {
|
||||
for _, path := range []string{"/api/mileage/daily", "/api/v2/statistics/mileage"} {
|
||||
req := httptest.NewRequest(http.MethodPost, path, nil)
|
||||
@@ -224,6 +258,29 @@ func TestMileagePostQueriesAllowCustomerRole(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchVehicleLookupAllowsEveryCustomerMenuScope(t *testing.T) {
|
||||
const token = "customer-token-at-least-16"
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||
for _, menu := range customerMenuKeys {
|
||||
t.Run(menu, func(t *testing.T) {
|
||||
authenticator := &apiAuthenticator{mode: "enforce", tokens: []tokenPrincipal{{
|
||||
hash: hash,
|
||||
principal: platform.Principal{
|
||||
Name: "客户甲", Role: "customer", UserType: "customer", MenuKeys: []string{menu},
|
||||
},
|
||||
}}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/vehicle-service/overviews", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
authenticator.middleware(next).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("customer with %s menu should reach batch lookup, status=%d body=%s", menu, rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIAuthMisconfigurationFailsClosed(t *testing.T) {
|
||||
cases := []config.Config{
|
||||
{AuthMode: "enforce"},
|
||||
|
||||
@@ -620,6 +620,13 @@ components:
|
||||
format: double
|
||||
nullable: true
|
||||
description: 单日用氢量,kg;无数据时为 null
|
||||
calculationPhase:
|
||||
type: string
|
||||
enum: [PRELIMINARY, FINAL]
|
||||
description: 当天流式结果为 PRELIMINARY,日终重算结果为 FINAL
|
||||
algorithmVersion:
|
||||
type: string
|
||||
description: 氢耗计算算法版本
|
||||
status:
|
||||
$ref: '#/components/schemas/DataStatus'
|
||||
MileageResult:
|
||||
|
||||
@@ -49,6 +49,8 @@ type HydrogenResult struct {
|
||||
PlateNumber string `json:"plateNumber"`
|
||||
Date string `json:"date"`
|
||||
HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg"`
|
||||
CalculationPhase string `json:"calculationPhase,omitempty"`
|
||||
AlgorithmVersion string `json:"algorithmVersion,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
@@ -281,11 +283,13 @@ type AppCredential struct {
|
||||
}
|
||||
|
||||
type DailyHydrogen struct {
|
||||
VIN string
|
||||
Date string
|
||||
ConsumptionKg float64
|
||||
SampleCount int
|
||||
QualityStatus string
|
||||
VIN string
|
||||
Date string
|
||||
ConsumptionKg float64
|
||||
SampleCount int
|
||||
QualityStatus string
|
||||
CalculationPhase string
|
||||
AlgorithmVersion string
|
||||
}
|
||||
|
||||
type DailyMileage struct {
|
||||
|
||||
@@ -312,7 +312,8 @@ func (r *MySQLRepository) DailyHydrogen(ctx context.Context, vins []string, date
|
||||
return map[string]DailyHydrogen{}, nil
|
||||
}
|
||||
query, args := inQuery(`
|
||||
SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),consumption_kg,sample_count,quality_status
|
||||
SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),consumption_kg,sample_count,quality_status,
|
||||
calculation_phase,algorithm_version
|
||||
FROM vehicle_open_daily_energy
|
||||
WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins)
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
@@ -323,7 +324,7 @@ WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins)
|
||||
out := make(map[string]DailyHydrogen, len(vins))
|
||||
for rows.Next() {
|
||||
var value DailyHydrogen
|
||||
if err := rows.Scan(&value.VIN, &value.Date, &value.ConsumptionKg, &value.SampleCount, &value.QualityStatus); err != nil {
|
||||
if err := rows.Scan(&value.VIN, &value.Date, &value.ConsumptionKg, &value.SampleCount, &value.QualityStatus, &value.CalculationPhase, &value.AlgorithmVersion); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[value.VIN] = value
|
||||
|
||||
@@ -391,10 +391,14 @@ func (s *Service) QueryHydrogen(ctx context.Context, appKey, traceID string, req
|
||||
// Sampling sufficiency is decided by the producer and persisted in
|
||||
// quality_status. Imported refuelling-ledger rows can be authoritative
|
||||
// with one transaction, while pressure-derived rows require two samples.
|
||||
if value, ok := values[vehicle.VIN]; ok && strings.EqualFold(value.QualityStatus, "OK") {
|
||||
consumption := round3(value.ConsumptionKg)
|
||||
item.HydrogenConsumptionKg = &consumption
|
||||
item.Status = StatusNormal
|
||||
if value, ok := values[vehicle.VIN]; ok {
|
||||
item.CalculationPhase = value.CalculationPhase
|
||||
item.AlgorithmVersion = value.AlgorithmVersion
|
||||
if strings.EqualFold(value.QualityStatus, "OK") {
|
||||
consumption := round3(value.ConsumptionKg)
|
||||
item.HydrogenConsumptionKg = &consumption
|
||||
item.Status = StatusNormal
|
||||
}
|
||||
}
|
||||
results = append(results, item)
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ func TestExternalHydrogenAndMileageQueriesPreserveRequestedVehicles(t *testing.T
|
||||
"粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"},
|
||||
},
|
||||
hydrogen: map[string]DailyHydrogen{
|
||||
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", ConsumptionKg: 12.3154, SampleCount: 1, QualityStatus: "OK"},
|
||||
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", ConsumptionKg: 12.3154, SampleCount: 1, QualityStatus: "OK", CalculationPhase: "PRELIMINARY", AlgorithmVersion: trustedHydrogenAlgorithmVersion},
|
||||
},
|
||||
mileage: map[string]DailyMileage{
|
||||
"LTEST32960VIN0001": {
|
||||
@@ -282,6 +282,9 @@ func TestExternalHydrogenAndMileageQueriesPreserveRequestedVehicles(t *testing.T
|
||||
if len(hydrogen) != 2 || hydrogen[0].HydrogenConsumptionKg == nil || *hydrogen[0].HydrogenConsumptionKg != 12.315 || hydrogen[1].Status != StatusNoData || hydrogen[1].HydrogenConsumptionKg != nil {
|
||||
t.Fatalf("hydrogen = %#v", hydrogen)
|
||||
}
|
||||
if hydrogen[0].CalculationPhase != "PRELIMINARY" || hydrogen[0].AlgorithmVersion != trustedHydrogenAlgorithmVersion {
|
||||
t.Fatalf("hydrogen calculation metadata = %#v", hydrogen[0])
|
||||
}
|
||||
mileage, err := service.QueryMileage(context.Background(), key, "trace-m", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -387,6 +387,33 @@ func LoadHydrogenCapacities(ctx context.Context, db *sql.DB) (map[string]float64
|
||||
return capacities, rows.Err()
|
||||
}
|
||||
|
||||
func LoadHydrogenBrandVINs(ctx context.Context, db *sql.DB, brand string) ([]string, error) {
|
||||
brand = strings.TrimSpace(brand)
|
||||
if brand == "" {
|
||||
return nil, errors.New("hydrogen vehicle brand is required")
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `SELECT UPPER(TRIM(vin))
|
||||
FROM vehicle_profile
|
||||
WHERE TRIM(brand_name)=?
|
||||
ORDER BY vin`, brand)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
vins := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var vin string
|
||||
if err := rows.Scan(&vin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||||
if validHydrogenVIN(vin) {
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
}
|
||||
return vins, rows.Err()
|
||||
}
|
||||
|
||||
func LoadHydrogenCalculationParameters(ctx context.Context, db *sql.DB, date time.Time) (map[string]HydrogenCalculationParameters, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT UPPER(TRIM(vin)),battery_capacity_kwh,hydrogen_energy_kwh_per_kg
|
||||
FROM vehicle_hydrogen_energy_parameter
|
||||
@@ -811,7 +838,7 @@ func PressureHydrogenMassKg(pressureMPa, temperatureC, capacityLiter float64) (f
|
||||
}
|
||||
|
||||
func ReplaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, stats []HydrogenDailyStat) error {
|
||||
return replaceHydrogenDailyStats(ctx, db, date, "", stats, false)
|
||||
return replaceHydrogenDailyStats(ctx, db, date, nil, stats, false)
|
||||
}
|
||||
|
||||
// ReplaceHydrogenDailyStatsAndSeedStream performs the current-day deployment
|
||||
@@ -819,7 +846,7 @@ func ReplaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, sta
|
||||
// included in the rebuild, so Kafka backlog already covered by the rebuild is
|
||||
// ignored instead of counted twice.
|
||||
func ReplaceHydrogenDailyStatsAndSeedStream(ctx context.Context, db *sql.DB, date string, stats []HydrogenDailyStat) error {
|
||||
return replaceHydrogenDailyStats(ctx, db, date, "", stats, true)
|
||||
return replaceHydrogenDailyStats(ctx, db, date, nil, stats, true)
|
||||
}
|
||||
|
||||
func ReplaceHydrogenDailyStatsForVIN(ctx context.Context, db *sql.DB, date, vin string, stats []HydrogenDailyStat) error {
|
||||
@@ -827,10 +854,30 @@ func ReplaceHydrogenDailyStatsForVIN(ctx context.Context, db *sql.DB, date, vin
|
||||
if !validHydrogenVIN(vin) {
|
||||
return fmt.Errorf("invalid VIN %q", vin)
|
||||
}
|
||||
return replaceHydrogenDailyStats(ctx, db, date, vin, stats, false)
|
||||
return replaceHydrogenDailyStats(ctx, db, date, []string{vin}, stats, false)
|
||||
}
|
||||
|
||||
func replaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date, vin string, stats []HydrogenDailyStat, seedStream bool) error {
|
||||
func ReplaceHydrogenDailyStatsForVINs(ctx context.Context, db *sql.DB, date string, vins []string, stats []HydrogenDailyStat) error {
|
||||
normalized := make([]string, 0, len(vins))
|
||||
seen := make(map[string]struct{}, len(vins))
|
||||
for _, vin := range vins {
|
||||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||||
if !validHydrogenVIN(vin) {
|
||||
return fmt.Errorf("invalid VIN %q", vin)
|
||||
}
|
||||
if _, exists := seen[vin]; exists {
|
||||
continue
|
||||
}
|
||||
seen[vin] = struct{}{}
|
||||
normalized = append(normalized, vin)
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return errors.New("at least one scoped VIN is required")
|
||||
}
|
||||
return replaceHydrogenDailyStats(ctx, db, date, normalized, stats, false)
|
||||
}
|
||||
|
||||
func replaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, scopedVINs []string, stats []HydrogenDailyStat, seedStream bool) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -839,12 +886,26 @@ func replaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date, vin string
|
||||
// A pressure-based rebuild is authoritative for the whole day. Delete every
|
||||
// previous hydrogen row first so legacy rate/direct-mass results cannot remain
|
||||
// for vehicles without valid pressure observations in this run.
|
||||
if vin == "" {
|
||||
allowedVINs := make(map[string]struct{}, len(scopedVINs))
|
||||
for _, vin := range scopedVINs {
|
||||
allowedVINs[vin] = struct{}{}
|
||||
}
|
||||
if len(scopedVINs) == 0 {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN'`, date); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if len(scopedVINs) == 1 {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN' AND vin=?`, date, scopedVINs[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN' AND vin=?`, date, vin); err != nil {
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(scopedVINs)), ",")
|
||||
args := make([]any, 0, len(scopedVINs)+1)
|
||||
args = append(args, date)
|
||||
for _, vin := range scopedVINs {
|
||||
args = append(args, vin)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN' AND vin IN (`+placeholders+`)`, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -854,8 +915,11 @@ func replaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date, vin string
|
||||
}
|
||||
}
|
||||
for _, stat := range stats {
|
||||
if vin != "" && !strings.EqualFold(strings.TrimSpace(stat.VIN), vin) {
|
||||
return fmt.Errorf("refusing to write VIN %q during scoped rebuild for %q", stat.VIN, vin)
|
||||
statVIN := strings.ToUpper(strings.TrimSpace(stat.VIN))
|
||||
if len(allowedVINs) > 0 {
|
||||
if _, allowed := allowedVINs[statVIN]; !allowed {
|
||||
return fmt.Errorf("refusing to write VIN %q outside scoped rebuild", stat.VIN)
|
||||
}
|
||||
}
|
||||
parameterJSON, err := json.Marshal(stat.CalculationParameters)
|
||||
if err != nil {
|
||||
@@ -872,8 +936,8 @@ INSERT INTO vehicle_open_daily_energy(
|
||||
raw_consumption_kg,battery_soc_delta_pct,battery_discharge_kwh,battery_equivalent_kg,
|
||||
soc_balanced_consumption_kg,mixed_mileage_km,pure_electric_mileage_km,
|
||||
consumption_kg_per_100km,soc_balanced_kg_per_100km,charge_count,valid_segment_count,
|
||||
invalid_segment_count,algorithm_version,parameter_json,evidence_json
|
||||
) VALUES(?,?,'HYDROGEN',?,?,'kg',?,?,?,?,?,?,NOW(3),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
invalid_segment_count,algorithm_version,calculation_phase,parameter_json,evidence_json
|
||||
) VALUES(?,?,'HYDROGEN',?,?,'kg',?,?,?,?,?,?,NOW(3),?,?,?,?,?,?,?,?,?,?,?,?,?,'FINAL',?,?)`,
|
||||
stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.FirstMassKg, stat.LastMassKg,
|
||||
stat.SampleCount, stat.RefuelCount, stat.QualityStatus, stat.QualityReason,
|
||||
stat.ConsumptionKg, stat.BatterySOCDeltaPct, stat.BatteryDischargeKWh, stat.BatteryEquivalentKg,
|
||||
@@ -883,7 +947,10 @@ INSERT INTO vehicle_open_daily_energy(
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if seedStream {
|
||||
// A NO_DATA row can legitimately have no effective boundary observation.
|
||||
// Keep its daily result, but do not write a zero timestamp into the stream
|
||||
// watermark table; the next valid realtime frame will create the state.
|
||||
if seedStream && !stat.LastObservation.ObservedAt.IsZero() {
|
||||
stateJSON, err := hydrogenSegmentStreamSeedJSON(stat)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -893,10 +960,10 @@ INSERT INTO vehicle_open_hydrogen_segment_stream_state(
|
||||
vin,stat_date,source_endpoint,finalized_consumption_kg,projected_consumption_kg,
|
||||
sample_count,refuel_count,abnormal_drop_count,eligible_interval_count,qualified_segment_count,
|
||||
last_mass_kg,last_event_time,last_event_id,state_json,calculation_method,quality_status,quality_reason
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'PRESSURE_NIST_SEGMENT_MEDIAN_5',?,?)`,
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5',?,?)`,
|
||||
stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.ConsumptionKg,
|
||||
stat.SampleCount, stat.RefuelCount, stat.AbnormalDropCount, stat.EligibleIntervalCount,
|
||||
stat.QualifiedSegmentCount, stat.LastMassKg, stat.LastObservation.ObservedAt, "",
|
||||
stat.QualifiedSegmentCount, stat.LastMassKg, stat.LastObservation.ObservedAt, stat.LastObservation.EventID,
|
||||
stateJSON, stat.QualityStatus, stat.QualityReason,
|
||||
); err != nil {
|
||||
return err
|
||||
@@ -907,54 +974,113 @@ INSERT INTO vehicle_open_hydrogen_segment_stream_state(
|
||||
}
|
||||
|
||||
func hydrogenSegmentStreamSeedJSON(stat HydrogenDailyStat) ([]byte, error) {
|
||||
type segmentWindow struct {
|
||||
Count int `json:"count"`
|
||||
First []any `json:"first"`
|
||||
Tail []any `json:"tail"`
|
||||
CycleMinimumMassKg float64 `json:"cycleMinimumMassKg"`
|
||||
type point struct {
|
||||
EventID string `json:"eventId"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
MassKg float64 `json:"massKg"`
|
||||
PressureMpa float64 `json:"pressureMpa"`
|
||||
TemperatureC float64 `json:"temperatureC"`
|
||||
NoiseKg float64 `json:"noiseKg"`
|
||||
MileageKm float64 `json:"mileageKm"`
|
||||
MileageKnown bool `json:"mileageKnown"`
|
||||
SocPercent float64 `json:"socPercent"`
|
||||
SocKnown bool `json:"socKnown"`
|
||||
RunningMode int `json:"runningMode"`
|
||||
FuelCellActive bool `json:"fuelCellActive"`
|
||||
FuelCellStateKnown bool `json:"fuelCellStateKnown"`
|
||||
FuelCellPowerKw float64 `json:"fuelCellPowerKw"`
|
||||
FuelCellPowerKnown bool `json:"fuelCellPowerKnown"`
|
||||
}
|
||||
toPoint := func(value HydrogenObservation) point {
|
||||
return point{
|
||||
EventID: value.EventID, ObservedAt: value.ObservedAt, MassKg: value.MassKg,
|
||||
PressureMpa: value.PressureMPa, TemperatureC: value.TemperatureC, NoiseKg: value.NoiseKg,
|
||||
MileageKm: value.MileageKm, MileageKnown: value.MileageKnown,
|
||||
SocPercent: value.SOCPercent, SocKnown: value.SOCKnown, RunningMode: value.RunningMode,
|
||||
FuelCellActive: value.FuelCellActive, FuelCellStateKnown: value.FuelCellStateKnown,
|
||||
FuelCellPowerKw: value.FuelCellVoltageV * value.FuelCellCurrentA / 1000,
|
||||
FuelCellPowerKnown: value.FuelCellPowerKnown,
|
||||
}
|
||||
}
|
||||
first, last := toPoint(stat.FirstObservation), toPoint(stat.LastObservation)
|
||||
lastIntervalType := ""
|
||||
mixedCycles := 0
|
||||
for _, interval := range stat.Intervals {
|
||||
if interval.Type == "MIXED" {
|
||||
mixedCycles++
|
||||
}
|
||||
lastIntervalType = interval.Type
|
||||
}
|
||||
cycle := map[string]any{
|
||||
"first": last, "last": last, "hasRun": !last.ObservedAt.IsZero(),
|
||||
"startsPure": lastIntervalType == "PURE_ELECTRIC", "mixedLocked": lastIntervalType == "MIXED",
|
||||
"mixedConfirmed": lastIntervalType == "MIXED", "mixedStart": last,
|
||||
}
|
||||
state := struct {
|
||||
SourceEndpoint string `json:"sourceEndpoint"`
|
||||
FinalizedConsumptionKg float64 `json:"finalizedConsumptionKg"`
|
||||
SampleCount int `json:"sampleCount"`
|
||||
RefuelCount int `json:"refuelCount"`
|
||||
AbnormalDropCount int `json:"abnormalDropCount"`
|
||||
EligibleIntervalCount int `json:"eligibleIntervalCount"`
|
||||
QualifiedSegmentCount int `json:"qualifiedSegmentCount"`
|
||||
FirstMassKg float64 `json:"firstMassKg"`
|
||||
RemainingMassKg float64 `json:"remainingMassKg"`
|
||||
MetadataCycleMinimumKg float64 `json:"metadataCycleMinimumKg"`
|
||||
LastObservedMassKg float64 `json:"lastObservedMassKg"`
|
||||
TankCapacityLiters float64 `json:"tankCapacityLiters"`
|
||||
FirstPressureMpa float64 `json:"firstPressureMpa"`
|
||||
LastPressureMpa float64 `json:"lastPressureMpa"`
|
||||
FirstTemperatureC float64 `json:"firstTemperatureC"`
|
||||
LastTemperatureC float64 `json:"lastTemperatureC"`
|
||||
FirstEventTime time.Time `json:"firstEventTime"`
|
||||
LastEventTime time.Time `json:"lastEventTime"`
|
||||
LastEventID string `json:"lastEventId"`
|
||||
LastFuelCellActive bool `json:"lastFuelCellActive"`
|
||||
LastFuelCellStateKnown bool `json:"lastFuelCellStateKnown"`
|
||||
Segment segmentWindow `json:"segment"`
|
||||
AlgorithmVersion string `json:"algorithmVersion"`
|
||||
SourceEndpoint string `json:"sourceEndpoint"`
|
||||
SampleCount int `json:"sampleCount"`
|
||||
LastEventID string `json:"lastEventId"`
|
||||
LastEventTime time.Time `json:"lastEventTime"`
|
||||
Parameters map[string]float64 `json:"parameters"`
|
||||
FirstMassKg float64 `json:"firstMassKg"`
|
||||
LastMassKg float64 `json:"lastMassKg"`
|
||||
FirstEffective point `json:"firstEffective"`
|
||||
LastEffective point `json:"lastEffective"`
|
||||
HasEffective bool `json:"hasEffective"`
|
||||
EffectiveRunCount int `json:"effectiveRunCount"`
|
||||
Cycle map[string]any `json:"cycle"`
|
||||
InitialStateUsed bool `json:"initialStateUsed"`
|
||||
ChargeCount int `json:"chargeCount"`
|
||||
ChargeEnergyKWh float64 `json:"chargeEnergyKWh"`
|
||||
CompletedPureKm float64 `json:"completedPureKm"`
|
||||
CompletedMixedKm float64 `json:"completedMixedKm"`
|
||||
CompletedSOCDelta float64 `json:"completedSocDelta"`
|
||||
CompletedMixedCycles int `json:"completedMixedCycles"`
|
||||
HydrogenSegmentStart point `json:"hydrogenSegmentStart"`
|
||||
LastHydrogenRun point `json:"lastHydrogenRun"`
|
||||
HasHydrogenSegment bool `json:"hasHydrogenSegment"`
|
||||
HydrogenSegmentRuns int `json:"hydrogenSegmentRuns"`
|
||||
FinalizedHydrogenKg float64 `json:"finalizedHydrogenKg"`
|
||||
HydrogenSegments int `json:"hydrogenSegments"`
|
||||
RefuelCount int `json:"refuelCount"`
|
||||
RefuelAmountKg float64 `json:"refuelAmountKg"`
|
||||
MinimumPressureMpa float64 `json:"minimumPressureMpa"`
|
||||
MinimumMassKg float64 `json:"minimumMassKg"`
|
||||
MinimumObservedAt time.Time `json:"minimumObservedAt"`
|
||||
PreviousSample point `json:"previousSample"`
|
||||
HasPreviousSample bool `json:"hasPreviousSample"`
|
||||
AbnormalDropCount int `json:"abnormalDropCount"`
|
||||
InvalidSampleCount int `json:"invalidSampleCount"`
|
||||
SuspectedLeakCount int `json:"suspectedLeakCount"`
|
||||
SuspectedLeakMaxMpa float64 `json:"suspectedLeakMaxMpa"`
|
||||
}{
|
||||
SourceEndpoint: stat.Source, FinalizedConsumptionKg: stat.ConsumptionKg,
|
||||
SampleCount: stat.SampleCount, RefuelCount: stat.RefuelCount,
|
||||
AbnormalDropCount: stat.AbnormalDropCount, EligibleIntervalCount: stat.EligibleIntervalCount,
|
||||
QualifiedSegmentCount: stat.QualifiedSegmentCount,
|
||||
FirstMassKg: stat.FirstMassKg, RemainingMassKg: stat.LastMassKg,
|
||||
MetadataCycleMinimumKg: stat.CycleMinimumMassKg,
|
||||
LastObservedMassKg: stat.LastObservation.MassKg,
|
||||
TankCapacityLiters: stat.LastObservation.TankCapacityLiter,
|
||||
FirstPressureMpa: stat.FirstObservation.PressureMPa, LastPressureMpa: stat.LastObservation.PressureMPa,
|
||||
FirstTemperatureC: stat.FirstObservation.TemperatureC, LastTemperatureC: stat.LastObservation.TemperatureC,
|
||||
FirstEventTime: stat.FirstObservation.ObservedAt, LastEventTime: stat.LastObservation.ObservedAt,
|
||||
LastFuelCellActive: stat.LastObservation.FuelCellActive,
|
||||
LastFuelCellStateKnown: stat.LastObservation.FuelCellStateKnown,
|
||||
Segment: segmentWindow{First: []any{}, Tail: []any{}},
|
||||
AlgorithmVersion: trustedHydrogenAlgorithmVersion, SourceEndpoint: stat.Source,
|
||||
SampleCount: stat.SampleCount, LastEventID: stat.LastObservation.EventID, LastEventTime: stat.LastObservation.ObservedAt,
|
||||
Parameters: map[string]float64{"batteryCapacityKWh": stat.CalculationParameters.BatteryCapacityKWh, "hydrogenEnergyKWhPerKg": stat.CalculationParameters.HydrogenEnergyKWhKg},
|
||||
FirstMassKg: stat.FirstMassKg, LastMassKg: stat.LastMassKg,
|
||||
FirstEffective: first, LastEffective: last, HasEffective: !last.ObservedAt.IsZero(), EffectiveRunCount: stat.EligibleIntervalCount,
|
||||
Cycle: cycle, InitialStateUsed: true, ChargeCount: stat.ChargeCount, ChargeEnergyKWh: stat.ChargeEnergyKWh,
|
||||
CompletedPureKm: stat.PureElectricMileageKm, CompletedMixedKm: stat.MixedMileageKm,
|
||||
CompletedSOCDelta: valueOrZero(stat.BatterySOCDeltaPct), CompletedMixedCycles: mixedCycles,
|
||||
HydrogenSegmentStart: last, LastHydrogenRun: last, HasHydrogenSegment: !last.ObservedAt.IsZero(), HydrogenSegmentRuns: 1,
|
||||
FinalizedHydrogenKg: stat.ConsumptionKg, HydrogenSegments: stat.QualifiedSegmentCount,
|
||||
RefuelCount: stat.RefuelCount, RefuelAmountKg: stat.RefuelAmountKg,
|
||||
MinimumPressureMpa: last.PressureMpa, MinimumMassKg: last.MassKg, MinimumObservedAt: last.ObservedAt,
|
||||
PreviousSample: last, HasPreviousSample: !last.ObservedAt.IsZero(),
|
||||
AbnormalDropCount: stat.AbnormalDropCount, InvalidSampleCount: stat.InvalidSegmentCount,
|
||||
SuspectedLeakCount: stat.SuspectedLeakCount, SuspectedLeakMaxMpa: stat.SuspectedLeakMaxPressureDropMPa,
|
||||
}
|
||||
return json.Marshal(state)
|
||||
}
|
||||
|
||||
func valueOrZero(value *float64) float64 {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func numericValue(value any) (float64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
|
||||
@@ -301,6 +301,26 @@ func TestLoadHydrogenObservationVINsFiltersMissingCapacity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadHydrogenBrandVINsUsesExactBrandAndValidVINs(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery(regexp.QuoteMeta("SELECT UPPER(TRIM(vin))")).
|
||||
WithArgs("现代").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin"}).
|
||||
AddRow("lb9a32a25r0ls1452").
|
||||
AddRow("invalid"))
|
||||
vins, err := LoadHydrogenBrandVINs(context.Background(), db, " 现代 ")
|
||||
if err != nil || len(vins) != 1 || vins[0] != "LB9A32A25R0LS1452" {
|
||||
t.Fatalf("vins=%v err=%v", vins, err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrogenObservationDateRangeForAllUsesBoundedQueries(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
@@ -351,6 +371,37 @@ func TestReplaceHydrogenDailyStatsForVINDeletesOnlyScopedVehicle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceHydrogenDailyStatsForVINsDeletesOnlyScopedBrandVehicles(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
vins := []string{"LB9A32A24R0LS1376", "LB9A32A25R0LS1452"}
|
||||
stat := HydrogenDailyStat{
|
||||
VIN: vins[0], Date: "2026-08-12", Source: "source-a", ConsumptionKg: 2.5,
|
||||
FirstMassKg: 12, LastMassKg: 9.5, SampleCount: 500, QualityStatus: "OK",
|
||||
}
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN' AND vin IN (?,?)")).
|
||||
WithArgs(stat.Date, vins[0], vins[1]).
|
||||
WillReturnResult(sqlmock.NewResult(0, 2))
|
||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy(")).
|
||||
WithArgs(stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.FirstMassKg, stat.LastMassKg,
|
||||
stat.SampleCount, 0, stat.QualityStatus, "", stat.ConsumptionKg,
|
||||
sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), 0.0, 0.0,
|
||||
sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 0, 0, "", sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
if err := ReplaceHydrogenDailyStatsForVINs(context.Background(), db, stat.Date, vins, []HydrogenDailyStat{stat}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceHydrogenDailyStatsAndSeedStreamIsAtomic(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
@@ -396,7 +447,37 @@ func TestReplaceHydrogenDailyStatsAndSeedStreamIsAtomic(t *testing.T) {
|
||||
if err := json.Unmarshal(encoded, &state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state["lastObservedMassKg"] != 9.4 || state["finalizedConsumptionKg"] != 2.6 || state["lastEventTime"] == "" {
|
||||
if state["lastMassKg"] != 9.4 || state["finalizedHydrogenKg"] != 2.6 || state["lastEventTime"] == "" || state["algorithmVersion"] != trustedHydrogenAlgorithmVersion {
|
||||
t.Fatalf("seed state=%s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceHydrogenDailyStatsAndSeedStreamSkipsMissingWatermark(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
stat := HydrogenDailyStat{
|
||||
VIN: "LB9A32A24R0LS1376", Date: "2026-09-04", Source: "source-a",
|
||||
QualityStatus: "NO_DATA", QualityReason: "有效运行分界点不足2条",
|
||||
}
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN'")).
|
||||
WithArgs("2026-09-04").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM vehicle_open_hydrogen_segment_stream_state WHERE stat_date=?")).
|
||||
WithArgs("2026-09-04").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy(")).
|
||||
WithArgs(stat.VIN, stat.Date, stat.Source, 0.0, 0.0, 0.0, 0, 0, stat.QualityStatus, stat.QualityReason,
|
||||
0.0, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), 0.0, 0.0,
|
||||
sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 0, 0, "", sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
if err := ReplaceHydrogenDailyStatsAndSeedStream(context.Background(), db, stat.Date, []HydrogenDailyStat{stat}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,7 +441,7 @@ func TestHandlerVehicleCoverage(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, want := range []string{"sourceCount", "onlineSourceCount", "protocols", "LB9A32A24R0LS1426"} {
|
||||
for _, want := range []string{"sourceCount", "onlineSourceCount", "protocols", "LB9A32A24R0LS1426", `"brandName":"飞驰"`, `"modelName":"新能源运营车"`} {
|
||||
if !strings.Contains(rec.Body.String(), want) {
|
||||
t.Fatalf("response missing %q: %s", want, rec.Body.String())
|
||||
}
|
||||
|
||||
@@ -776,6 +776,8 @@ func (m *MockStore) vehicleRowServiceStatus(row VehicleRow) *VehicleServiceStatu
|
||||
}
|
||||
|
||||
func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[VehicleCoverageRow], error) {
|
||||
m.profileMu.RLock()
|
||||
defer m.profileMu.RUnlock()
|
||||
vehicles := filterVehicles(m.vehicles, query)
|
||||
byVIN := map[string]*VehicleCoverageRow{}
|
||||
for _, vehicle := range vehicles {
|
||||
@@ -805,6 +807,12 @@ func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[V
|
||||
}
|
||||
items := make([]VehicleCoverageRow, 0, len(byVIN))
|
||||
for _, row := range byVIN {
|
||||
if profile, ok := m.profiles[row.VIN]; ok {
|
||||
row.BrandName = firstNonEmpty(profile.BrandName, row.OEM)
|
||||
row.ModelName = profile.ModelName
|
||||
} else {
|
||||
row.BrandName = row.OEM
|
||||
}
|
||||
row.MissingProtocols = missingCanonicalProtocols(row.Protocols)
|
||||
onlineProtocols := make([]string, 0, row.OnlineSourceCount)
|
||||
for _, vehicle := range vehicles {
|
||||
@@ -1230,8 +1238,24 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V
|
||||
|
||||
func (m *MockStore) VehicleServiceOverviews(_ context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
|
||||
items := make([]VehicleServiceOverview, 0, len(query.Keywords))
|
||||
var allowedVINs map[string]bool
|
||||
if query.ScopeVINs != nil {
|
||||
allowedVINs = make(map[string]bool, len(query.ScopeVINs))
|
||||
for _, vin := range query.ScopeVINs {
|
||||
allowedVINs[strings.ToUpper(strings.TrimSpace(vin))] = true
|
||||
}
|
||||
}
|
||||
for _, keyword := range normalizedKeywords(query.Keywords) {
|
||||
vehicles := m.vehiclesForKeyword(keyword, query.Protocol)
|
||||
if allowedVINs != nil {
|
||||
scoped := vehicles[:0]
|
||||
for _, vehicle := range vehicles {
|
||||
if allowedVINs[strings.ToUpper(strings.TrimSpace(vehicle.VIN))] {
|
||||
scoped = append(scoped, vehicle)
|
||||
}
|
||||
}
|
||||
vehicles = scoped
|
||||
}
|
||||
resolution := buildVehicleIdentityResolution(keyword, vehicles)
|
||||
identity := resolveVehicleIdentity(keyword, vehicles)
|
||||
resolvedVIN := ""
|
||||
@@ -1241,7 +1265,11 @@ func (m *MockStore) VehicleServiceOverviews(_ context.Context, query VehicleOver
|
||||
resolvedVIN = resolution.VIN
|
||||
}
|
||||
if resolvedVIN == "" {
|
||||
items = append(items, *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{}))
|
||||
missing := *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{})
|
||||
if query.ScopeVINs != nil {
|
||||
missing.VIN = ""
|
||||
}
|
||||
items = append(items, missing)
|
||||
continue
|
||||
}
|
||||
summary := m.realtimeSummaryForVIN(resolvedVIN, query.Protocol)
|
||||
|
||||
@@ -1351,6 +1351,8 @@ type VehicleCoverageRow struct {
|
||||
Plate string `json:"plate"`
|
||||
Phone string `json:"phone"`
|
||||
OEM string `json:"oem"`
|
||||
BrandName string `json:"brandName"`
|
||||
ModelName string `json:"modelName"`
|
||||
Protocols []string `json:"protocols"`
|
||||
MissingProtocols []string `json:"missingProtocols"`
|
||||
SourceStatus []VehicleSourceStatus `json:"sourceStatus"`
|
||||
|
||||
@@ -159,6 +159,7 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
|
||||
}
|
||||
groupSQL := `FROM (` + vehicleSetSQL + `) v ` +
|
||||
`LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin ` +
|
||||
`LEFT JOIN vehicle_profile p ON p.vin = v.vin ` +
|
||||
`LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin ` +
|
||||
`LEFT JOIN business_scope_state bst ON bst.id = 1 ` +
|
||||
`LEFT JOIN business_customer_vehicle_scope bs ON BINARY bs.source_version = BINARY bst.active_version AND BINARY bs.vin = BINARY v.vin ` +
|
||||
@@ -169,6 +170,8 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
|
||||
Text: `SELECT v.vin, ` +
|
||||
`COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), b.plate, '') AS plate, ` +
|
||||
`COALESCE(b.phone, '') AS phone, COALESCE(b.oem, '') AS oem, ` +
|
||||
`COALESCE(NULLIF(MAX(NULLIF(p.brand_name, '')), ''), NULLIF(b.oem, ''), '') AS brand_name, ` +
|
||||
`COALESCE(MAX(p.model_name), '') AS model_name, ` +
|
||||
`COALESCE(GROUP_CONCAT(DISTINCT s.protocol ORDER BY s.protocol SEPARATOR ','), '') AS protocols, ` +
|
||||
`COALESCE(GROUP_CONCAT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END ORDER BY s.protocol SEPARATOR ','), '') AS online_protocols, ` +
|
||||
`COUNT(DISTINCT s.protocol) AS source_count, ` +
|
||||
|
||||
@@ -41,14 +41,17 @@ func (p Principal) Clone() Principal {
|
||||
}
|
||||
|
||||
func (p Principal) CanMenu(key string) bool {
|
||||
if p.UserType == "admin" || p.Role == "admin" {
|
||||
return true
|
||||
}
|
||||
for _, value := range p.MenuKeys {
|
||||
if value == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Legacy admin principals did not carry an explicit menu list. Keep those
|
||||
// principals unrestricted while allowing persisted admin menus to remove a
|
||||
// sensitive surface such as account management.
|
||||
if len(p.MenuKeys) == 0 && (p.UserType == "admin" || p.Role == "admin") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -321,7 +321,7 @@ func (s *ProductionStore) VehicleCoverage(ctx context.Context, query url.Values)
|
||||
var protocols string
|
||||
var onlineProtocols string
|
||||
var online int
|
||||
if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &protocols, &onlineProtocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.LastSeen, &row.BindingStatus); err != nil {
|
||||
if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &row.BrandName, &row.ModelName, &protocols, &onlineProtocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.LastSeen, &row.BindingStatus); err != nil {
|
||||
return Page[VehicleCoverageRow]{}, err
|
||||
}
|
||||
row.Protocols = splitCSV(protocols)
|
||||
@@ -503,7 +503,11 @@ func (s *ProductionStore) VehicleServiceOverviews(ctx context.Context, query Veh
|
||||
continue
|
||||
}
|
||||
resolution := VehicleIdentityResolution{LookupKey: keyword, Protocols: []string{}}
|
||||
items = append(items, *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{}))
|
||||
missing := *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{})
|
||||
if query.ScopeVINs != nil {
|
||||
missing.VIN = ""
|
||||
}
|
||||
items = append(items, missing)
|
||||
}
|
||||
return Page[VehicleServiceOverview]{Items: items, Total: len(items), Limit: query.Limit, Offset: query.Offset}, nil
|
||||
}
|
||||
@@ -518,6 +522,18 @@ func buildVehicleServiceOverviewBatchSQL(query VehicleOverviewBatchQuery) SQLQue
|
||||
protocolJoin = " AND s.protocol = ? "
|
||||
args = append(args, protocol)
|
||||
}
|
||||
scopeWhere := ""
|
||||
if query.ScopeVINs != nil {
|
||||
scopeVINs := normalizedKeywords(query.ScopeVINs)
|
||||
if len(scopeVINs) == 0 {
|
||||
scopeWhere = " AND 1 = 0 "
|
||||
} else {
|
||||
scopeWhere = " AND i.vin IN (" + strings.TrimSuffix(strings.Repeat("?,", len(scopeVINs)), ",") + ") "
|
||||
for _, vin := range scopeVINs {
|
||||
args = append(args, strings.ToUpper(vin))
|
||||
}
|
||||
}
|
||||
}
|
||||
return SQLQuery{
|
||||
Text: `SELECT i.vin, COALESCE(MAX(NULLIF(i.plate, '')), '') AS plate, ` +
|
||||
`COALESCE(MAX(NULLIF(i.phone, '')), '') AS phone, COALESCE(MAX(NULLIF(i.oem, '')), '') AS oem, ` +
|
||||
@@ -534,7 +550,7 @@ func buildVehicleServiceOverviewBatchSQL(query VehicleOverviewBatchQuery) SQLQue
|
||||
`WHERE ` + realtimeWhere + ` ` +
|
||||
`GROUP BY s.vin, b.plate, b.phone, b.oem` +
|
||||
`) i LEFT JOIN vehicle_realtime_snapshot s ON s.vin = i.vin ` + protocolJoin +
|
||||
`WHERE i.vin IS NOT NULL AND i.vin <> '' GROUP BY i.vin`,
|
||||
`WHERE i.vin IS NOT NULL AND i.vin <> ''` + scopeWhere + `GROUP BY i.vin`,
|
||||
Args: args,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,23 @@ func TestBuildVehicleServiceOverviewBatchSQLUsesFuzzyKeywordMatching(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleServiceOverviewBatchSQLRestrictsCustomerVINScope(t *testing.T) {
|
||||
built := buildVehicleServiceOverviewBatchSQL(VehicleOverviewBatchQuery{
|
||||
Keywords: []string{"粤A"}, ScopeVINs: []string{"vin001", "VIN002"},
|
||||
})
|
||||
if !strings.Contains(built.Text, "i.vin IN (?,?)") {
|
||||
t.Fatalf("batch overview SQL should restrict results to granted VINs, got %s", built.Text)
|
||||
}
|
||||
if got := built.Args[len(built.Args)-2:]; got[0] != "VIN001" || got[1] != "VIN002" {
|
||||
t.Fatalf("customer VIN scope should be normalized and bound last, got %#v", built.Args)
|
||||
}
|
||||
|
||||
empty := buildVehicleServiceOverviewBatchSQL(VehicleOverviewBatchQuery{Keywords: []string{"粤A"}, ScopeVINs: []string{}})
|
||||
if !strings.Contains(empty.Text, "AND 1 = 0") {
|
||||
t.Fatalf("empty customer VIN scope must fail closed, got %s", empty.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrogenRatePer100KmUsesMatchedPureHydrogenMileage(t *testing.T) {
|
||||
rate := hydrogenRatePer100Km(7.3, 193.3, 2)
|
||||
if rate == nil || *rate < 3.77 || *rate > 3.78 {
|
||||
|
||||
@@ -77,7 +77,7 @@ func TestBuildVehicleListSQLFiltersServiceStatus(t *testing.T) {
|
||||
func TestBuildVehicleCoverageSQL(t *testing.T) {
|
||||
query := url.Values{"keyword": {"粤A"}, "protocol": {"GB32960"}, "coverage": {"multi"}, "online": {"online"}, "bindingStatus": {"bound"}, "limit": {"8"}, "offset": {"16"}}
|
||||
built := buildVehicleCoverageSQL(query)
|
||||
for _, want := range []string{"GROUP BY v.vin", "HAVING", "GROUP_CONCAT(DISTINCT s.protocol", "source_count", "online_source_count", "ORDER BY MAX(s.updated_at) DESC, v.vin ASC"} {
|
||||
for _, want := range []string{"LEFT JOIN vehicle_profile p ON p.vin = v.vin", "brand_name", "model_name", "GROUP BY v.vin", "HAVING", "GROUP_CONCAT(DISTINCT s.protocol", "source_count", "online_source_count", "ORDER BY MAX(s.updated_at) DESC, v.vin ASC"} {
|
||||
if !strings.Contains(built.Text, want) {
|
||||
t.Fatalf("SQL missing %q: %s", want, built.Text)
|
||||
}
|
||||
|
||||
@@ -91,10 +91,11 @@ type RawFrameQuery struct {
|
||||
}
|
||||
|
||||
type VehicleOverviewBatchQuery struct {
|
||||
Keywords []string `json:"keywords"`
|
||||
Protocol string `json:"protocol"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
Keywords []string `json:"keywords"`
|
||||
Protocol string `json:"protocol"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
ScopeVINs []string `json:"-"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
@@ -869,10 +870,12 @@ func (s *Service) VehicleServiceOverview(ctx context.Context, keyword string, pr
|
||||
}
|
||||
}
|
||||
if batchStore, ok := s.store.(VehicleOverviewBatchStore); ok {
|
||||
scopeVINs := principalVehicleVINScope(ctx)
|
||||
page, err := batchStore.VehicleServiceOverviews(ctx, VehicleOverviewBatchQuery{
|
||||
Keywords: []string{keyword},
|
||||
Protocol: protocol,
|
||||
Limit: 1,
|
||||
Keywords: []string{keyword},
|
||||
Protocol: protocol,
|
||||
Limit: 1,
|
||||
ScopeVINs: scopeVINs,
|
||||
})
|
||||
if err != nil {
|
||||
return VehicleServiceOverview{}, err
|
||||
@@ -927,11 +930,13 @@ func (s *Service) VehicleServiceOverviews(ctx context.Context, query VehicleOver
|
||||
query.Keywords = keywords
|
||||
query.Limit = limit
|
||||
query.Offset = offset
|
||||
query.ScopeVINs = principalVehicleVINScope(ctx)
|
||||
if batchStore, ok := s.store.(VehicleOverviewBatchStore); ok {
|
||||
page, err := batchStore.VehicleServiceOverviews(ctx, query)
|
||||
if err != nil {
|
||||
return Page[VehicleServiceOverview]{}, err
|
||||
}
|
||||
page = restrictVehicleOverviewPage(ctx, page)
|
||||
page.Total = total
|
||||
page.Limit = limit
|
||||
page.Offset = offset
|
||||
@@ -948,6 +953,34 @@ func (s *Service) VehicleServiceOverviews(ctx context.Context, query VehicleOver
|
||||
return Page[VehicleServiceOverview]{Items: items, Total: total, Limit: limit, Offset: offset}, nil
|
||||
}
|
||||
|
||||
func principalVehicleVINScope(ctx context.Context) []string {
|
||||
principal, ok := PrincipalFromContext(ctx)
|
||||
if !ok || principal.UserType != "customer" {
|
||||
return nil
|
||||
}
|
||||
scope := make([]string, 0, len(principal.VehicleVINs))
|
||||
for _, vin := range principal.VehicleVINs {
|
||||
if vin = strings.ToUpper(strings.TrimSpace(vin)); vin != "" {
|
||||
scope = append(scope, vin)
|
||||
}
|
||||
}
|
||||
return scope
|
||||
}
|
||||
|
||||
func restrictVehicleOverviewPage(ctx context.Context, page Page[VehicleServiceOverview]) Page[VehicleServiceOverview] {
|
||||
principal, ok := PrincipalFromContext(ctx)
|
||||
if !ok || principal.UserType != "customer" {
|
||||
return page
|
||||
}
|
||||
for index := range page.Items {
|
||||
if principal.CanVIN(strings.ToUpper(strings.TrimSpace(page.Items[index].VIN))) {
|
||||
continue
|
||||
}
|
||||
page.Items[index] = *buildVehicleServiceOverview("", "", &VehicleIdentityResolution{Protocols: []string{}}, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{})
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string) (VehicleDetail, error) {
|
||||
keyword := strings.TrimSpace(vin)
|
||||
protocol = strings.TrimSpace(protocol)
|
||||
|
||||
@@ -547,6 +547,7 @@ type countingStore struct {
|
||||
vehiclesCalls int
|
||||
vehicleRealtimeCalls int
|
||||
overviewBatchCalls int
|
||||
lastOverviewBatchQuery VehicleOverviewBatchQuery
|
||||
lastVehicleQuery url.Values
|
||||
lastRealtimeQuery url.Values
|
||||
lastHistoryQuery url.Values
|
||||
@@ -1010,6 +1011,7 @@ func TestVehicleSourcePolicyUpdateRequiresAdminAndUsesOptimisticVersion(t *testi
|
||||
|
||||
func (s *countingStore) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
|
||||
s.overviewBatchCalls++
|
||||
s.lastOverviewBatchQuery = query
|
||||
return s.MockStore.VehicleServiceOverviews(ctx, query)
|
||||
}
|
||||
|
||||
@@ -1046,6 +1048,26 @@ func TestVehicleServiceOverviewsUsesBatchDataPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerVehicleServiceOverviewsStayInsideGrantedVINScope(t *testing.T) {
|
||||
store := newCountingStore()
|
||||
service := NewService(store)
|
||||
ctx := WithPrincipal(context.Background(), Principal{
|
||||
Name: "客户甲", Role: "customer", UserType: "customer", VehicleVINs: []string{"LB9A32A24R0LS1426"},
|
||||
})
|
||||
page, err := service.VehicleServiceOverviews(ctx, VehicleOverviewBatchQuery{
|
||||
Keywords: []string{"粤AG18312", "LMRKH9AC2R1004087"}, Limit: 200,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("customer batch lookup returned error: %v", err)
|
||||
}
|
||||
if len(store.lastOverviewBatchQuery.ScopeVINs) != 1 || store.lastOverviewBatchQuery.ScopeVINs[0] != "LB9A32A24R0LS1426" {
|
||||
t.Fatalf("customer grant scope was not passed to batch store: %+v", store.lastOverviewBatchQuery.ScopeVINs)
|
||||
}
|
||||
if page.Total != 2 || len(page.Items) != 2 || page.Items[0].VIN != "LB9A32A24R0LS1426" || page.Items[1].VIN != "" {
|
||||
t.Fatalf("batch lookup must preserve input order without exposing ungranted vehicles: %+v", page)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleServiceSummaryCountsProtocolOnlineByProtocolSlot(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
summary, err := service.VehicleServiceSummary(context.Background())
|
||||
|
||||
@@ -19,9 +19,21 @@ func Handler(dir string, fallback http.Handler) http.Handler {
|
||||
}
|
||||
path := filepath.Join(dir, filepath.Clean(r.URL.Path))
|
||||
if info, err := os.Stat(path); err == nil && !info.IsDir() {
|
||||
setCachePolicy(w, r.URL.Path)
|
||||
fs.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
http.ServeFile(w, r, filepath.Join(dir, "index.html"))
|
||||
})
|
||||
}
|
||||
|
||||
func setCachePolicy(w http.ResponseWriter, path string) {
|
||||
if path == "/" || path == "/index.html" || path == "/app-config.js" {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(path, "/assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }) {
|
||||
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 <>
|
||||
<h1>{product.name}</h1><p className="docs-lead">{product.description} 查询范围同时受 AppKey、车辆授权和授权有效期约束。</p>
|
||||
<Endpoint method={product.method} path={product.path} />
|
||||
@@ -150,7 +150,7 @@ function APIReference({ kind }: { kind: "hydrogen" | "mileage" }) {
|
||||
"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></>}
|
||||
<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>实际选中的 GB32960、MQTT 或 JT808;NO_DATA 时为 null</td></tr></>}<tr><td><code>status</code></td><td>string</td><td>NORMAL 或 NO_DATA</td></tr></tbody></table>
|
||||
<h2 id="响应字段">响应字段</h2><table className="docs-table"><thead><tr><th>字段</th><th>类型</th><th>说明</th></tr></thead><tbody>{kind === "mileage" && <tr><td><code>vin</code></td><td>string</td><td>车辆 VIN</td></tr>}<tr><td><code>plateNumber</code></td><td>string</td><td>请求中的车牌号</td></tr><tr><td><code>date</code></td><td>string</td><td>统计自然日</td></tr><tr><td><code>{kind === "hydrogen" ? "hydrogenConsumptionKg" : "dailyMileageKm"}</code></td><td>number | null</td><td>{kind === "hydrogen" ? "用氢量,单位 kg" : "当日行驶里程,单位 km"}</td></tr>{kind === "hydrogen" && <><tr><td><code>calculationPhase</code></td><td>string</td><td>当天实时暂估为 PRELIMINARY,日终重算为 FINAL</td></tr><tr><td><code>algorithmVersion</code></td><td>string</td><td>氢耗计算算法版本</td></tr></>}{kind === "mileage" && <><tr><td><code>totalMileageKm</code></td><td>number | null</td><td>同一协议在当日最后有效时刻的累计总里程</td></tr><tr><td><code>dataTime</code></td><td>date-time | null</td><td>统计实际采用的最后一条车辆源数据时间</td></tr><tr><td><code>updatedAt</code></td><td>date-time | null</td><td>日统计投影最后更新时间</td></tr><tr><td><code>sourceProtocol</code></td><td>string | null</td><td>实际选中的 GB32960、MQTT 或 JT808;NO_DATA 时为 null</td></tr></>}<tr><td><code>status</code></td><td>string</td><td>NORMAL 或 NO_DATA</td></tr></tbody></table>
|
||||
<h2 id="响应示例">响应示例</h2><CodeBlock language="json" value={`{
|
||||
"code": "SUCCESS",
|
||||
"message": "success",
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
AlertRuleRevision,
|
||||
AlertSummary,
|
||||
DailyMileageRow,
|
||||
HydrogenDailyEvidence,
|
||||
DashboardSummary,
|
||||
HistoryLocationRow,
|
||||
HistoryDataResponse,
|
||||
@@ -487,6 +488,10 @@ export const api = {
|
||||
dailyMileage: (query: MileageQuery, signal?: AbortSignal) => request<Page<DailyMileageRow>>('/api/mileage/daily', withSignal({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||
}, 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({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||
}, signal)),
|
||||
|
||||
@@ -470,6 +470,8 @@ export interface VehicleCoverageRow {
|
||||
plate: string;
|
||||
phone: string;
|
||||
oem: string;
|
||||
brandName?: string;
|
||||
modelName?: string;
|
||||
protocols: string[];
|
||||
missingProtocols: string[];
|
||||
sourceStatus: VehicleSourceStatus[];
|
||||
@@ -908,10 +910,40 @@ export interface DailyMileageRow {
|
||||
pureHydrogenMileageKm?: number;
|
||||
hydrogenConsumptionKg?: number | null;
|
||||
hydrogenConsumptionKgPer100Km?: number | null;
|
||||
hydrogenSocBalancedKg?: number | null;
|
||||
hydrogenSocBalancedKgPer100Km?: number | null;
|
||||
hydrogenEvidenceAvailable?: boolean;
|
||||
hydrogenQualityStatus?: string;
|
||||
hydrogenAlgorithmVersion?: string;
|
||||
source: string;
|
||||
anomalySeverity?: string;
|
||||
}
|
||||
|
||||
export interface HydrogenIntervalEvidence {
|
||||
index: number; type: 'MIXED' | 'PURE_ELECTRIC'; startTime: string; endTime: string;
|
||||
startEventId: string; endEventId: string; source: string;
|
||||
startPressureMpa: number; endPressureMpa: number; startTemperatureC: number; endTemperatureC: number;
|
||||
startMassKg: number; endMassKg: number; rawHydrogenConsumptionKg: number;
|
||||
startSocPercent?: number; endSocPercent?: number; batteryDischargeKWh?: number; batteryEquivalentKg?: number;
|
||||
socBalancedConsumptionKg?: number; startMileageKm?: number; endMileageKm?: number; mileageKm?: number;
|
||||
consumptionKgPer100Km?: number; sampleCount: number; qualityStatus: string; qualityReason: string;
|
||||
}
|
||||
|
||||
export interface HydrogenDailyEvidence {
|
||||
vin: string; plate: string; date: string; source: string; rawConsumptionKg: number;
|
||||
batterySocDeltaPct?: number; batteryDischargeKWh?: number; batteryEquivalentKg?: number;
|
||||
socBalancedConsumptionKg?: number; mixedMileageKm: number; pureElectricMileageKm: number;
|
||||
consumptionKgPer100Km?: number; socBalancedKgPer100Km?: number; sampleCount: number;
|
||||
refuelCount: number; chargeCount: number; validSegmentCount: number; invalidSegmentCount: number;
|
||||
qualityStatus: string; qualityReason: string; algorithmVersion: string; calculatedAt: string;
|
||||
parameters: {
|
||||
batteryCapacityKWh: number; hydrogenEnergyKWhPerKg: number; powerOnDelaySeconds: number;
|
||||
powerOffLeadSeconds: number; refuelRiseMpa: number; refuelSustainSeconds: number;
|
||||
pureElectricDropMpa: number; pureElectricWindowSeconds: number; algorithmVersion: string;
|
||||
};
|
||||
intervals: HydrogenIntervalEvidence[];
|
||||
}
|
||||
|
||||
export interface MileageSummary {
|
||||
vehicleCount: number;
|
||||
recordCount: number;
|
||||
|
||||
@@ -261,6 +261,7 @@ export function VehicleDetail({
|
||||
const displayLookupKey = resolution?.lookupKey || detail?.lookupKey || query.keyword;
|
||||
const protocols = useMemo(() => resolution?.protocols?.length ? resolution.protocols : detail?.sources ?? [], [detail?.sources, resolution?.protocols]);
|
||||
const latestRaw = detail?.raw?.items?.[0];
|
||||
const latestRawType = latestRaw?.frameType === 'realtime_projection' ? '最新状态投影' : latestRaw?.frameType;
|
||||
const qualityCount = detail?.quality?.total ?? 0;
|
||||
const priorityQualityIssue = useMemo(() => {
|
||||
const items = detail?.quality?.items ?? [];
|
||||
@@ -617,7 +618,7 @@ export function VehicleDetail({
|
||||
{
|
||||
label: '历史数据',
|
||||
value: formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0),
|
||||
detail: latestRaw?.frameType || '解析字段',
|
||||
detail: latestRawType || '解析字段',
|
||||
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const
|
||||
},
|
||||
{
|
||||
@@ -766,7 +767,7 @@ export function VehicleDetail({
|
||||
{
|
||||
title: '历史数据',
|
||||
value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 帧`,
|
||||
meta: latestRaw?.frameType || '字段明细',
|
||||
meta: latestRawType || '字段明细',
|
||||
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
|
||||
detail: '查看历史明细和字段明细,用于复核定位、里程和告警依据。',
|
||||
disabled: !hasResolvedVIN,
|
||||
@@ -863,7 +864,7 @@ export function VehicleDetail({
|
||||
},
|
||||
{
|
||||
title: '查询历史数据',
|
||||
evidence: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 条历史数据,最新类型 ${latestRaw?.frameType || '-'}`,
|
||||
evidence: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 条历史数据,最新类型 ${latestRawType || '-'}`,
|
||||
acceptance: '关键字段来自解析字段而不是页面二次推断。',
|
||||
action: '历史数据',
|
||||
disabled: !hasResolvedVIN,
|
||||
@@ -938,7 +939,7 @@ export function VehicleDetail({
|
||||
{
|
||||
title: '查询历史数据',
|
||||
value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 帧`,
|
||||
detail: latestRaw?.frameType ? `最新历史数据类型 ${latestRaw.frameType},用于核对解析字段。` : '按车辆和数据通道查询历史明细与解析字段。',
|
||||
detail: latestRawType ? `最新历史数据类型 ${latestRawType},用于核对解析字段。` : '按车辆和数据通道查询历史明细与解析字段。',
|
||||
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
|
||||
primaryAction: '历史数据',
|
||||
secondaryAction: '导出档案',
|
||||
@@ -991,7 +992,7 @@ export function VehicleDetail({
|
||||
{
|
||||
title: '历史数据查询',
|
||||
value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 帧`,
|
||||
detail: latestRaw?.frameType ? `最新 ${latestRaw.frameType}` : 'RAW 与解析字段证据',
|
||||
detail: latestRawType ? `最新 ${latestRawType}` : 'RAW 与解析字段证据',
|
||||
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
|
||||
action: '查询历史',
|
||||
disabled: !hasResolvedVIN,
|
||||
@@ -1047,7 +1048,7 @@ export function VehicleDetail({
|
||||
{
|
||||
title: '数据导出',
|
||||
value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 帧`,
|
||||
detail: latestRaw?.frameType ? `最新 ${latestRaw.frameType}` : '历史明细与字段',
|
||||
detail: latestRawType ? `最新 ${latestRawType}` : '历史明细与字段',
|
||||
action: '历史导出',
|
||||
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
|
||||
disabled: !hasResolvedVIN,
|
||||
@@ -1085,7 +1086,7 @@ export function VehicleDetail({
|
||||
{
|
||||
title: '历史证据',
|
||||
value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 帧`,
|
||||
detail: latestRaw?.frameType ? `最新 ${latestRaw.frameType}` : '历史明细与解析字段',
|
||||
detail: latestRawType ? `最新 ${latestRawType}` : '历史明细与解析字段',
|
||||
action: '历史查询',
|
||||
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
|
||||
disabled: !hasResolvedVIN,
|
||||
@@ -2175,7 +2176,7 @@ export function VehicleDetail({
|
||||
dataSource={detail?.raw?.items ?? []}
|
||||
columns={[
|
||||
{ title: '来源', dataIndex: 'protocol', width: 130 },
|
||||
{ title: '帧类型', dataIndex: 'frameType', width: 190 },
|
||||
{ title: '帧类型', dataIndex: 'frameType', width: 190, render: (value: string) => value === 'realtime_projection' ? '最新状态投影' : value },
|
||||
{ title: '大小 B', dataIndex: 'rawSizeBytes', width: 100 },
|
||||
{ title: '设备时间', dataIndex: 'deviceTime', width: 190 },
|
||||
{ title: '入库时间', dataIndex: 'serverTime', width: 190 }
|
||||
|
||||
@@ -75,6 +75,28 @@ test('presents failed credentials as a clear Semi UI alert without losing the lo
|
||||
expect(screen.getByPlaceholderText('请输入用户名')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not flash the anonymous session error while a login request is pending', async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
let resolveLogin!: (value: { accessToken: string; expiresAt: string; session: { name: string; role: string } }) => void;
|
||||
mocks.session.mockImplementation(async () => {
|
||||
if (getAccessToken() === 'authenticated-token') return { name: 'ln-bm', role: 'admin', authMode: 'enforce' };
|
||||
throw new Error('需要有效的访问令牌');
|
||||
});
|
||||
mocks.login.mockReturnValue(new Promise((resolve) => { resolveLogin = resolve; }));
|
||||
|
||||
render(<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 () => {
|
||||
mocks.logout.mockResolvedValue({ loggedOut: true });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
@@ -192,7 +192,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
return <AuthLoadingState />;
|
||||
}
|
||||
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">
|
||||
<section className="v2-auth-intro" aria-labelledby="v2-auth-intro-title">
|
||||
<header>
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { canAdminister, canOperate, clearAccessToken, getAccessToken, notifyUnauthorizedSession, PLATFORM_UNAUTHORIZED_EVENT, setAccessToken } from './session';
|
||||
import {
|
||||
canAdminister,
|
||||
canOperate,
|
||||
clearAccessToken,
|
||||
getAccessToken,
|
||||
hasMenu,
|
||||
notifyUnauthorizedSession,
|
||||
PLATFORM_UNAUTHORIZED_EVENT,
|
||||
setAccessToken,
|
||||
type PlatformSession,
|
||||
} from './session';
|
||||
|
||||
afterEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
@@ -21,6 +31,17 @@ test('role helpers follow the server permission hierarchy', () => {
|
||||
expect(canAdminister({ name: 'a', role: 'admin', authMode: 'enforce' })).toBe(true);
|
||||
});
|
||||
|
||||
test('an administrator can have account management removed explicitly', () => {
|
||||
const session = {
|
||||
role: 'admin',
|
||||
userType: 'admin',
|
||||
menuKeys: ['monitor', 'operations'],
|
||||
} as PlatformSession;
|
||||
|
||||
expect(hasMenu(session, 'operations')).toBe(true);
|
||||
expect(hasMenu(session, 'users')).toBe(false);
|
||||
});
|
||||
|
||||
test('invalidates only the currently active rejected token', () => {
|
||||
const unauthorized = vi.fn();
|
||||
window.addEventListener(PLATFORM_UNAUTHORIZED_EVENT, unauthorized);
|
||||
|
||||
@@ -29,9 +29,8 @@ export function canOperate(session: PlatformSession) {
|
||||
}
|
||||
|
||||
export function hasMenu(session: PlatformSession, menu: string) {
|
||||
if (session.role === 'admin') return true;
|
||||
if (!session.menuKeys) return session.role !== 'customer';
|
||||
return session.menuKeys.includes(menu);
|
||||
if (session.menuKeys) return session.menuKeys.includes(menu);
|
||||
return session.role !== 'customer';
|
||||
}
|
||||
|
||||
export function canAdminister(session: PlatformSession) {
|
||||
|
||||
@@ -10,8 +10,8 @@ test('creates a styled numeric mileage workbook with formulas and frozen panes',
|
||||
dateTo: '2026-07-14',
|
||||
dates: ['2026-07-13', '2026-07-14'],
|
||||
vehicles: [
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345' },
|
||||
{ vin: 'LTEST000000000002', plate: '粤A54321' }
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', brandName: '现代', modelName: 'XCIENT' },
|
||||
{ vin: 'LTEST000000000002', plate: '粤A54321', brandName: '飞驰', modelName: 'FSQ' }
|
||||
],
|
||||
mileageRows: [
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 100, endMileageKm: 188.7, dailyMileageKm: 88.7, pureHydrogenMileageKm: 56.2, hydrogenConsumptionKg: 3.1, hydrogenConsumptionKgPer100Km: 5.5, source: 'GB32960' },
|
||||
@@ -26,25 +26,29 @@ test('creates a styled numeric mileage workbook with formulas and frozen panes',
|
||||
const sheet = workbook.getWorksheet('里程查询')!;
|
||||
|
||||
expect(sheet.getCell('A1').value).toBe('车辆里程查询明细');
|
||||
expect(sheet.getCell('C6').value).toBeInstanceOf(Date);
|
||||
expect(sheet.getCell('C6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'center' });
|
||||
expect(sheet.getCell('D6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'center' });
|
||||
expect(sheet.getCell('C6').value).toBe('品牌');
|
||||
expect(sheet.getCell('D6').value).toBe('车型');
|
||||
expect(sheet.getCell('E6').value).toBeInstanceOf(Date);
|
||||
expect(sheet.getCell('E6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'center' });
|
||||
expect(sheet.getCell('F6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'center' });
|
||||
expect(sheet.getCell('A6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'left' });
|
||||
expect(sheet.getCell('E6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'right' });
|
||||
expect(sheet.getCell('C7').value).toBe(88.7);
|
||||
expect(sheet.getCell('E7').value).toMatchObject({ formula: 'SUM(C7:D7)', result: 193.3 });
|
||||
expect(sheet.getCell('E7').numFmt).toBe('#,##0.0" km"');
|
||||
expect(sheet.views[0]).toMatchObject({ state: 'frozen', xSplit: 2, ySplit: 6, showGridLines: false });
|
||||
expect(sheet.autoFilter).toEqual({ from: { row: 6, column: 1 }, to: { row: 8, column: 5 } });
|
||||
expect(sheet.getCell('G6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'right' });
|
||||
expect(sheet.getCell('C7').value).toBe('现代');
|
||||
expect(sheet.getCell('D7').value).toBe('XCIENT');
|
||||
expect(sheet.getCell('E7').value).toBe(88.7);
|
||||
expect(sheet.getCell('G7').value).toMatchObject({ formula: 'SUM(E7:F7)', result: 193.3 });
|
||||
expect(sheet.getCell('G7').numFmt).toBe('#,##0.0" km"');
|
||||
expect(sheet.views[0]).toMatchObject({ state: 'frozen', xSplit: 4, ySplit: 6, showGridLines: false });
|
||||
expect(sheet.autoFilter).toEqual({ from: { row: 6, column: 1 }, to: { row: 8, column: 7 } });
|
||||
expect((sheet as unknown as { conditionalFormattings: unknown[] }).conditionalFormattings).toHaveLength(1);
|
||||
const hydrogenSheet = workbook.getWorksheet('氢能明细')!;
|
||||
expect(hydrogenSheet.getRow(1).values).toEqual([
|
||||
undefined, '日期', '车牌', 'VIN', '总里程 (km)', '纯氢里程 (km)', '耗氢 (kg)', '百公里纯氢耗 (kg/100km)', '来源'
|
||||
undefined, '日期', '车牌', 'VIN', '品牌', '车型', '总里程 (km)', '纯氢里程 (km)', '耗氢 (kg)', '百公里纯氢耗 (kg/100km)', '来源'
|
||||
]);
|
||||
expect(hydrogenSheet.getRow(2).values).toEqual([
|
||||
undefined, '2026-07-13', '粤A12345', 'LTEST000000000001', 88.7, 56.2, 3.1, 5.5, 'GB32960'
|
||||
undefined, '2026-07-13', '粤A12345', 'LTEST000000000001', '现代', 'XCIENT', 88.7, 56.2, 3.1, 5.5, 'GB32960'
|
||||
]);
|
||||
expect(hydrogenSheet.autoFilter).toEqual({ from: { row: 1, column: 1 }, to: { row: 3, column: 8 } });
|
||||
expect(hydrogenSheet.autoFilter).toEqual({ from: { row: 1, column: 1 }, to: { row: 3, column: 10 } });
|
||||
expect((await workbook.xlsx.writeBuffer()).byteLength).toBeGreaterThan(5_000);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { DailyMileageRow } from '../../api/types';
|
||||
import { downloadBlob } from './download';
|
||||
|
||||
export type MileageExportVehicle = { vin: string; plate: string };
|
||||
export type MileageExportVehicle = { vin: string; plate: string; brandName?: string; modelName?: string };
|
||||
export type MileageExportSource = { protocol: string; label: string; mileageType: string };
|
||||
export type MileageWorkbookRow = Pick<DailyMileageRow, 'vin' | 'date' | 'dailyMileageKm'> & Partial<Omit<DailyMileageRow, 'vin' | 'date' | 'dailyMileageKm'>>;
|
||||
|
||||
|
||||
@@ -27,11 +27,11 @@ export async function createMileageWorkbook(input: MileageExportInput) {
|
||||
workbook.calcProperties.fullCalcOnLoad = true;
|
||||
|
||||
const sheet = workbook.addWorksheet('里程查询', {
|
||||
views: [{ state: 'frozen', xSplit: 2, ySplit: 6, activeCell: 'C7', showGridLines: false }],
|
||||
views: [{ state: 'frozen', xSplit: 4, ySplit: 6, activeCell: 'E7', showGridLines: false }],
|
||||
pageSetup: { orientation: 'landscape', fitToPage: true, fitToWidth: 1, fitToHeight: 0, paperSize: 9, margins: { left: .25, right: .25, top: .45, bottom: .45, header: .2, footer: .2 } },
|
||||
properties: { defaultRowHeight: 21 }
|
||||
});
|
||||
const firstDateColumn = 3;
|
||||
const firstDateColumn = 5;
|
||||
const lastDateColumn = firstDateColumn + input.dates.length - 1;
|
||||
const totalColumn = lastDateColumn + 1;
|
||||
const lastColumnLetter = excelColumn(totalColumn);
|
||||
@@ -72,14 +72,14 @@ export async function createMileageWorkbook(input: MileageExportInput) {
|
||||
sheet.getRow(5).height = 8;
|
||||
|
||||
const header = sheet.getRow(headerRowNumber);
|
||||
header.values = ['车牌', 'VIN', ...input.dates.map((date) => new Date(`${date}T12:00:00Z`)), '区间总里程'];
|
||||
header.values = ['车牌', 'VIN', '品牌', '车型', ...input.dates.map((date) => new Date(`${date}T12:00:00Z`)), '区间总里程'];
|
||||
header.height = 30;
|
||||
header.eachCell((cell, columnNumber) => {
|
||||
const isDateHeader = columnNumber >= firstDateColumn && columnNumber <= lastDateColumn;
|
||||
cell.font = { name: 'Microsoft YaHei', size: 10, bold: true, color: { argb: columnNumber === totalColumn ? 'FF1C4F91' : 'FF304158' } };
|
||||
cell.alignment = {
|
||||
vertical: 'middle',
|
||||
horizontal: columnNumber <= 2 ? 'left' : isDateHeader ? 'center' : 'right'
|
||||
horizontal: columnNumber <= 4 ? 'left' : isDateHeader ? 'center' : 'right'
|
||||
};
|
||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: columnNumber === totalColumn ? 'FFDCEBFF' : 'FFEAF0F7' } };
|
||||
cell.border = { bottom: { style: 'medium', color: { argb: 'FFC3D0DF' } } };
|
||||
@@ -97,11 +97,11 @@ export async function createMileageWorkbook(input: MileageExportInput) {
|
||||
const dailyValues = input.dates.map((date) => mileageByDate.get(date)?.dailyMileageKm ?? null);
|
||||
const total = dailyValues.reduce<number>((sum, value) => sum + (value ?? 0), 0);
|
||||
const row = sheet.getRow(rowNumber);
|
||||
row.values = [vehicle.plate || '未绑定', vehicle.vin, ...dailyValues, null];
|
||||
row.values = [vehicle.plate || '未绑定', vehicle.vin, vehicle.brandName || '待维护', vehicle.modelName || '待维护', ...dailyValues, null];
|
||||
row.height = 25;
|
||||
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => {
|
||||
cell.font = { name: columnNumber === 2 ? 'Consolas' : 'Microsoft YaHei', size: columnNumber === 2 ? 9 : 10, color: { argb: columnNumber === totalColumn ? 'FF1D4E89' : 'FF34445A' }, bold: columnNumber === 1 || columnNumber === totalColumn };
|
||||
cell.alignment = { vertical: 'middle', horizontal: columnNumber <= 2 ? 'left' : 'right' };
|
||||
cell.alignment = { vertical: 'middle', horizontal: columnNumber <= 4 ? 'left' : 'right' };
|
||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFF9FBFD' : 'FFFFFFFF' } };
|
||||
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } };
|
||||
if (columnNumber >= firstDateColumn) cell.numFmt = '#,##0.0" km"';
|
||||
@@ -116,6 +116,8 @@ export async function createMileageWorkbook(input: MileageExportInput) {
|
||||
|
||||
sheet.getColumn(1).width = 15;
|
||||
sheet.getColumn(2).width = 24;
|
||||
sheet.getColumn(3).width = 18;
|
||||
sheet.getColumn(4).width = 24;
|
||||
for (let column = firstDateColumn; column <= lastDateColumn; column += 1) sheet.getColumn(column).width = 12;
|
||||
sheet.getColumn(totalColumn).width = 17;
|
||||
if (input.vehicles.length) {
|
||||
@@ -135,7 +137,7 @@ export async function createMileageWorkbook(input: MileageExportInput) {
|
||||
views: [{ state: 'frozen', ySplit: 1, activeCell: 'A2', showGridLines: false }],
|
||||
properties: { defaultRowHeight: 21 }
|
||||
});
|
||||
hydrogenSheet.addRow(['日期', '车牌', 'VIN', '总里程 (km)', '纯氢里程 (km)', '耗氢 (kg)', '百公里纯氢耗 (kg/100km)', '来源']);
|
||||
hydrogenSheet.addRow(['日期', '车牌', 'VIN', '品牌', '车型', '总里程 (km)', '纯氢里程 (km)', '耗氢 (kg)', '百公里纯氢耗 (kg/100km)', '来源']);
|
||||
const hydrogenHeader = hydrogenSheet.getRow(1);
|
||||
hydrogenHeader.height = 30;
|
||||
hydrogenHeader.eachCell((cell) => {
|
||||
@@ -144,13 +146,16 @@ export async function createMileageWorkbook(input: MileageExportInput) {
|
||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEAF0F7' } };
|
||||
cell.border = { bottom: { style: 'medium', color: { argb: 'FFC3D0DF' } } };
|
||||
});
|
||||
const plateByVIN = new Map(input.vehicles.map((vehicle) => [vehicle.vin, vehicle.plate || '未绑定']));
|
||||
const vehicleByVIN = new Map(input.vehicles.map((vehicle) => [vehicle.vin, vehicle]));
|
||||
const energyRows = [...input.mileageRows].sort((left, right) => left.date.localeCompare(right.date) || left.vin.localeCompare(right.vin));
|
||||
energyRows.forEach((value, index) => {
|
||||
const vehicle = vehicleByVIN.get(value.vin);
|
||||
const row = hydrogenSheet.addRow([
|
||||
value.date,
|
||||
plateByVIN.get(value.vin) ?? value.plate ?? '未绑定',
|
||||
vehicle?.plate || value.plate || '未绑定',
|
||||
value.vin,
|
||||
vehicle?.brandName || '待维护',
|
||||
vehicle?.modelName || '待维护',
|
||||
value.dailyMileageKm,
|
||||
value.pureHydrogenMileageKm ?? null,
|
||||
value.hydrogenConsumptionKg ?? null,
|
||||
@@ -160,17 +165,17 @@ export async function createMileageWorkbook(input: MileageExportInput) {
|
||||
row.height = 24;
|
||||
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => {
|
||||
cell.font = { name: columnNumber === 3 ? 'Consolas' : 'Microsoft YaHei', size: columnNumber === 3 ? 9 : 10, color: { argb: 'FF34445A' } };
|
||||
cell.alignment = { vertical: 'middle', horizontal: columnNumber >= 4 && columnNumber <= 7 ? 'right' : 'left' };
|
||||
cell.alignment = { vertical: 'middle', horizontal: columnNumber >= 6 && columnNumber <= 9 ? 'right' : 'left' };
|
||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFF9FBFD' : 'FFFFFFFF' } };
|
||||
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } };
|
||||
if (columnNumber >= 4 && columnNumber <= 7) cell.numFmt = '#,##0.0';
|
||||
if (columnNumber >= 6 && columnNumber <= 9) cell.numFmt = '#,##0.0';
|
||||
});
|
||||
});
|
||||
[13, 15, 24, 15, 17, 14, 27, 16].forEach((width, index) => {
|
||||
[13, 15, 24, 18, 24, 15, 17, 14, 27, 16].forEach((width, index) => {
|
||||
hydrogenSheet.getColumn(index + 1).width = width;
|
||||
});
|
||||
if (energyRows.length) {
|
||||
hydrogenSheet.autoFilter = { from: { row: 1, column: 1 }, to: { row: energyRows.length + 1, column: 8 } };
|
||||
hydrogenSheet.autoFilter = { from: { row: 1, column: 1 }, to: { row: energyRows.length + 1, column: 10 } };
|
||||
}
|
||||
hydrogenSheet.headerFooter.oddFooter = sheet.headerFooter.oddFooter;
|
||||
return workbook;
|
||||
|
||||
@@ -136,6 +136,23 @@ test('summarizes only material automation changes before release', () => {
|
||||
{ key: 'status', label: '发布状态', before: '启用', after: '停用' }
|
||||
]);
|
||||
expect(automationReleaseChanges(baseline, baseline)).toEqual([]);
|
||||
|
||||
const pressureBaseline = {
|
||||
...baseline,
|
||||
id: 'hydrogen-pressure-rule',
|
||||
name: '氢系统压力过低',
|
||||
metric: 'hydrogen_pressure_mpa',
|
||||
operator: 'lt',
|
||||
threshold: 2,
|
||||
recoveryOperator: 'gt',
|
||||
recoveryThreshold: 3,
|
||||
description: ''
|
||||
};
|
||||
const pressureDraft = { ...pressureBaseline, recoveryThreshold: 3.5, description: '压力恢复后自动关闭事件' };
|
||||
expect(automationReleaseChanges(pressureBaseline, pressureDraft, { hydrogen_pressure_mpa: '最高氢气压力' }, { hydrogen_pressure_mpa: 'MPa' })).toEqual([
|
||||
{ key: 'recovery', label: '恢复条件', before: '> 3 MPa', after: '> 3.5 MPa' },
|
||||
{ key: 'description', label: '规则说明', before: '未填写', after: '压力恢复后自动关闭事件' }
|
||||
]);
|
||||
});
|
||||
|
||||
test('preserves an unsubmitted event filter draft across alert tab URL changes', async () => {
|
||||
@@ -207,7 +224,7 @@ test('keeps creation contextual to the automation workspace', async () => {
|
||||
const editor = await screen.findByRole('dialog', { name: '事件自动化编辑' });
|
||||
expect(editor.closest('.v2-alert-rule-editor-dialog')).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /自动化/ })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByRole('navigation', { name: '自动化编辑步骤' })).toHaveTextContent(/事件与条件.*车辆范围.*执行动作.*测试并发布/);
|
||||
expect(screen.getByRole('navigation', { name: '自动化编辑步骤' })).toHaveTextContent(/事件与条件.*车辆范围.*执行动作.*检查并发布/);
|
||||
expect(screen.getByLabelText('自动化实时摘要')).toBeInTheDocument();
|
||||
const nameInput = screen.getByLabelText('规则名称');
|
||||
const thresholdInput = screen.getByLabelText('触发阈值');
|
||||
@@ -972,7 +989,7 @@ test('opens a lightweight mobile rule detail before editing in a Semi bottom Sid
|
||||
expect(document.querySelector('.v2-alert-rule-editor-sidesheet')).toHaveClass('semi-sidesheet-bottom', 'v2-workspace-editor-sidesheet');
|
||||
expect(document.querySelector('.v2-alert-rule-editor-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(96dvh, 920px)' });
|
||||
expect(within(editor).getByDisplayValue('测试超速规则')).toBeInTheDocument();
|
||||
expect(within(editor).getByRole('navigation', { name: '自动化编辑步骤' })).toHaveTextContent(/事件与条件.*车辆范围.*执行动作.*测试并发布/);
|
||||
expect(within(editor).getByRole('navigation', { name: '自动化编辑步骤' })).toHaveTextContent(/事件与条件.*车辆范围.*执行动作.*检查并发布/);
|
||||
expect(within(editor).getByRole('heading', { name: '什么时候触发' })).toBeInTheDocument();
|
||||
expect(within(editor).queryByRole('heading', { name: '哪些车辆生效' })).not.toBeInTheDocument();
|
||||
expect(within(editor).queryByRole('heading', { name: '系统自动做什么' })).not.toBeInTheDocument();
|
||||
@@ -981,15 +998,15 @@ test('opens a lightweight mobile rule detail before editing in a Semi bottom Sid
|
||||
expect(within(editor).getByRole('heading', { name: '哪些车辆生效' })).toBeInTheDocument();
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '下一步:执行动作' }));
|
||||
expect(within(editor).getByRole('heading', { name: '系统自动做什么' })).toBeInTheDocument();
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '下一步:测试规则' }));
|
||||
expect(within(editor).getByRole('region', { name: '样例事件测试' })).toBeInTheDocument();
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '下一步:检查发布' }));
|
||||
expect(within(editor).getByRole('region', { name: /样例事件测试/ })).toBeInTheDocument();
|
||||
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled();
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '运行测试' }));
|
||||
expect(await within(editor).findByText(/测试成功/)).toBeInTheDocument();
|
||||
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled();
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '编辑事件来源' }));
|
||||
fireEvent.change(within(editor).getByDisplayValue('测试超速规则'), { target: { value: '测试超速规则 v4' } });
|
||||
fireEvent.click(within(editor).getByRole('button', { name: /测试并发布/ }));
|
||||
fireEvent.click(within(editor).getByRole('button', { name: /检查并发布/ }));
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '运行测试' }));
|
||||
expect(await within(editor).findByText(/测试成功/)).toBeInTheDocument();
|
||||
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeEnabled();
|
||||
@@ -1083,7 +1100,7 @@ test('keeps a changed mobile automation in its editor until discard is confirmed
|
||||
await waitFor(() => expect(document.querySelector('.v2-alert-rule-editor-sidesheet .v2-alert-rule-editor')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
test('opens desktop automation editing in a centered review dialog and gates publishing on a sample test', async () => {
|
||||
test('opens desktop automation editing in a centered review dialog and publishes valid changes without requiring a sample test', async () => {
|
||||
mocks.alertRulesV2.mockResolvedValue([alertRule()]);
|
||||
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 3, offset: 0 });
|
||||
mocks.metricCatalog.mockResolvedValue({
|
||||
@@ -1100,7 +1117,7 @@ test('opens desktop automation editing in a centered review dialog and gates pub
|
||||
expect(editor.closest('.v2-alert-rule-editor-dialog')).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-alert-rule-editor-sidesheet')).not.toBeInTheDocument();
|
||||
expect(within(editor).getByLabelText('自动化配置摘要')).toBeInTheDocument();
|
||||
expect(within(editor).getByRole('region', { name: '样例事件测试' })).toBeInTheDocument();
|
||||
expect(within(editor).getByRole('region', { name: /样例事件测试/ })).toBeInTheDocument();
|
||||
expect(within(editor).getByRole('region', { name: '执行跟踪' })).toBeInTheDocument();
|
||||
expect(within(editor).getByText('0 项变更')).toBeInTheDocument();
|
||||
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled();
|
||||
@@ -1110,21 +1127,58 @@ test('opens desktop automation editing in a centered review dialog and gates pub
|
||||
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled();
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '编辑事件来源' }));
|
||||
fireEvent.change(within(editor).getByDisplayValue('测试超速规则'), { target: { value: '测试超速规则 v4' } });
|
||||
fireEvent.click(within(editor).getByRole('button', { name: /测试并发布/ }));
|
||||
fireEvent.click(within(editor).getByRole('button', { name: /检查并发布/ }));
|
||||
expect(within(editor).getByText('1 项变更')).toBeInTheDocument();
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '运行测试' }));
|
||||
expect(await within(editor).findByText(/测试成功/)).toBeInTheDocument();
|
||||
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeEnabled();
|
||||
expect(within(editor).getByRole('status')).toHaveTextContent('配置已就绪,可直接发布更新');
|
||||
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '上一步' }));
|
||||
expect(within(editor).getByRole('heading', { name: '系统自动做什么' })).toBeInTheDocument();
|
||||
expect(within(editor).queryByRole('heading', { name: '什么时候触发' })).not.toBeInTheDocument();
|
||||
expect(within(editor).getByRole('button', { name: '下一步:测试规则' })).toBeEnabled();
|
||||
expect(within(editor).getByRole('button', { name: '下一步:检查发布' })).toBeEnabled();
|
||||
|
||||
fireEvent.click(within(editor).getByRole('button', { name: /事件与条件/ }));
|
||||
expect(within(editor).getByRole('heading', { name: '什么时候触发' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('enables publishing a hydrogen pressure automation when only its recovery threshold changes', async () => {
|
||||
const pressureRule: AlertRule = {
|
||||
...alertRule(),
|
||||
id: 'hydrogen-pressure-rule',
|
||||
name: '氢系统压力过低',
|
||||
description: '',
|
||||
triggerType: 'metric',
|
||||
metric: 'hydrogen_pressure_mpa',
|
||||
operator: 'lt',
|
||||
threshold: 2,
|
||||
thresholdHigh: 100,
|
||||
durationSec: 3,
|
||||
recoveryOperator: 'gt',
|
||||
recoveryThreshold: 3,
|
||||
scopeProtocols: ['GB32960']
|
||||
};
|
||||
mocks.alertRulesV2.mockResolvedValue([pressureRule]);
|
||||
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 3, offset: 0 });
|
||||
mocks.metricCatalog.mockResolvedValue({
|
||||
metrics: [{ key: 'hydrogen_pressure_mpa', label: '最高氢气压力', unit: 'MPa', category: 'fuel-cell', valueType: 'numeric', protocols: ['GB32960'], sourceFields: { GB32960: 'gb32960.fuel_cell.max_hydrogen_pressure_mpa' }, searchable: true, chartable: true, alertable: true }],
|
||||
asOf: ''
|
||||
});
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<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 () => {
|
||||
const source = alertRule();
|
||||
const created = { ...source, id: 'speed-rule-copy', name: '测试超速规则(副本)', enabled: false, version: 1 };
|
||||
@@ -1142,9 +1196,6 @@ test('copies an automation into an audited disabled draft without changing the s
|
||||
expect(within(editor).getByText('2 项变更')).toBeInTheDocument();
|
||||
expect(within(editor).getByRole('list', { name: '自动化待发布变更' })).toHaveTextContent('测试超速规则(副本)');
|
||||
expect(within(editor).getByText('副本将保持停用')).toBeInTheDocument();
|
||||
expect(within(editor).getByRole('button', { name: '创建副本' })).toBeDisabled();
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '运行测试' }));
|
||||
expect(await within(editor).findByText(/测试成功/)).toBeInTheDocument();
|
||||
expect(within(editor).getByRole('button', { name: '创建副本' })).toBeEnabled();
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '创建副本' }));
|
||||
|
||||
@@ -1252,8 +1303,8 @@ test('creates auditable drafts from simple offline and hydrogen rule templates',
|
||||
expect(within(fenceEditor).getByDisplayValue('VIN-QUICK-001')).toBeInTheDocument();
|
||||
fireEvent.click(within(fenceEditor).getByRole('button', { name: '下一步:执行动作' }));
|
||||
expect(within(fenceEditor).getAllByText('发送高优先级站内通知').length).toBeGreaterThan(0);
|
||||
fireEvent.click(within(fenceEditor).getByRole('button', { name: '下一步:测试规则' }));
|
||||
expect(within(fenceEditor).getByRole('button', { name: '发布自动化' })).toBeDisabled();
|
||||
fireEvent.click(within(fenceEditor).getByRole('button', { name: '下一步:检查发布' }));
|
||||
expect(within(fenceEditor).getByRole('button', { name: '发布自动化' })).toBeEnabled();
|
||||
fireEvent.click(within(fenceEditor).getByRole('button', { name: '运行测试' }));
|
||||
expect(await within(fenceEditor).findByText(/测试成功/)).toBeInTheDocument();
|
||||
expect(within(fenceEditor).getByRole('button', { name: '发布自动化' })).toBeEnabled();
|
||||
|
||||
@@ -85,7 +85,7 @@ const AUTOMATION_EDITOR_STEPS: Array<{ step: AutomationEditorStep; label: string
|
||||
{ step: 1, label: '事件与条件', description: '定义触发信号' },
|
||||
{ step: 2, label: '车辆范围', description: '选择生效对象' },
|
||||
{ step: 3, label: '执行动作', description: '通知、恢复与冷却' },
|
||||
{ step: 4, label: '测试并发布', description: '验证后正式启用' }
|
||||
{ step: 4, label: '检查并发布', description: '确认配置后发布' }
|
||||
];
|
||||
|
||||
export function alertFiltersFromParams(params: URLSearchParams): Filters {
|
||||
@@ -691,22 +691,31 @@ function ruleDraft(rule: AlertRule): AlertRuleInput {
|
||||
export type AutomationReleaseChange = { key: string; label: string; before: string; after: string };
|
||||
|
||||
export function automationReleaseChanges(baseline: AlertRuleInput, draft: AlertRuleInput, catalogLabels: Record<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) => ({
|
||||
name: rule.name || '未命名自动化',
|
||||
event: `${automationSource(rule)} · ${ruleCondition(rule, catalogLabels, catalogUnits, false)}`,
|
||||
event: `${automationSource(rule)} · ${ruleCondition(rule, catalogLabels, catalogUnits, false)}${rule.durationSec ? ` · 持续 ${formatAlertDuration(rule.durationSec)}` : ' · 立即判断'}`,
|
||||
scope: ruleScopeSummary(rule),
|
||||
action: `${ruleActionSummary(rule)} · ${rule.repeatIntervalSec ? `${formatAlertDuration(rule.repeatIntervalSec)}内不重复` : '允许每次匹配执行'}`,
|
||||
recovery: rule.recoveryOperator ? `${operatorLabels[rule.recoveryOperator]} ${rule.recoveryThreshold}${catalogUnits[rule.metric] ? ` ${catalogUnits[rule.metric]}` : ''}` : '状态恢复后自动关闭事件',
|
||||
description: rule.description.trim() || '未填写',
|
||||
status: rule.enabled ? '启用' : '停用'
|
||||
});
|
||||
const before = snapshot(baseline);
|
||||
const after = snapshot(draft);
|
||||
return [
|
||||
{ key: 'name', label: '规则名称', before: before.name, after: after.name },
|
||||
{ key: 'event', label: '事件与条件', before: before.event, after: after.event },
|
||||
{ key: 'scope', label: '车辆范围', before: before.scope, after: after.scope },
|
||||
{ key: 'action', label: '执行动作', before: before.action, after: after.action },
|
||||
{ key: 'status', label: '发布状态', before: before.status, after: after.status }
|
||||
].filter((item) => item.before !== item.after);
|
||||
changed((rule) => rule.name) ? { key: 'name', label: '规则名称', before: before.name, after: after.name } : undefined,
|
||||
changed((rule) => ({ triggerType: rule.triggerType, metric: rule.metric, valueType: rule.valueType, operator: rule.operator, threshold: rule.threshold, thresholdHigh: rule.thresholdHigh, booleanThreshold: rule.booleanThreshold, durationSec: rule.durationSec, fenceName: rule.fenceName, fenceLongitude: rule.fenceLongitude, fenceLatitude: rule.fenceLatitude, fenceRadiusM: rule.fenceRadiusM })) ? { key: 'event', label: '事件与条件', before: before.event, after: after.event } : undefined,
|
||||
changed((rule) => ({ scopeProtocols: stableList(rule.scopeProtocols), scopeVins: stableList(rule.scopeVins), scopeOems: stableList(rule.scopeOems), scopeModels: stableList(rule.scopeModels), scopeCompanies: stableList(rule.scopeCompanies) })) ? { key: 'scope', label: '车辆范围', before: before.scope, after: after.scope } : undefined,
|
||||
changed((rule) => ({ severity: rule.severity, notificationChannels: stableList(rule.notificationChannels), notificationTargets: stableTargets(rule), repeatIntervalSec: rule.repeatIntervalSec })) ? { key: 'action', label: '执行动作', before: before.action, after: after.action } : undefined,
|
||||
changed((rule) => ({ recoveryOperator: rule.recoveryOperator, recoveryThreshold: rule.recoveryThreshold })) ? { key: 'recovery', label: '恢复条件', before: before.recovery, after: after.recovery } : undefined,
|
||||
changed((rule) => rule.description.trim()) ? { key: 'description', label: '规则说明', before: before.description, after: after.description } : undefined,
|
||||
changed((rule) => rule.enabled) ? { key: 'status', label: '发布状态', before: before.status, after: after.status } : undefined
|
||||
].filter((item): item is AutomationReleaseChange => Boolean(item));
|
||||
}
|
||||
|
||||
function RuleFormSection({ id, title, description, children }: { id: string; title: string; description: string; children: ReactNode }) {
|
||||
@@ -938,7 +947,7 @@ function RulesWorkspace({ page, rulesReady, rulesLoading, rulesError, onRetryRul
|
||||
moveToStep((editorStep + 1) as AutomationEditorStep);
|
||||
return;
|
||||
}
|
||||
if (testRan) save.mutate(draft);
|
||||
save.mutate(draft);
|
||||
};
|
||||
const openNewRule = () => {
|
||||
const nextDraft = emptyRule();
|
||||
@@ -1168,7 +1177,7 @@ function RulesWorkspace({ page, rulesReady, rulesLoading, rulesError, onRetryRul
|
||||
</div>
|
||||
<div className="v2-automation-review-test">
|
||||
<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>
|
||||
</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>;
|
||||
const editorPrimaryLabel = save.isPending ? '发布中' : editorStep === 1 ? '下一步:车辆范围' : editorStep === 2 ? '下一步:执行动作' : editorStep === 3 ? '下一步:测试规则' : cloneSource ? '创建副本' : draft.version ? '发布更新' : '发布自动化';
|
||||
const editorGuidance = editorStep === 1 ? '先完成事件与条件,后续步骤只展示相关配置。' : editorStep === 2 ? '可以搜索并多选车辆;留空代表全部授权车辆。' : editorStep === 3 ? '动作配置完成后进入样例测试,不会立即发送通知。' : '样例测试只验证契约与计划动作,不会产生真实通知。';
|
||||
const editorPrimaryLabel = save.isPending ? '发布中' : editorStep === 1 ? '下一步:车辆范围' : editorStep === 2 ? '下一步:执行动作' : editorStep === 3 ? '下一步:检查发布' : cloneSource ? '创建副本' : draft.version ? '发布更新' : '发布自动化';
|
||||
const editorGuidance = editorStep === 1 ? '先完成事件与条件,后续步骤只展示相关配置。' : editorStep === 2 ? '可以搜索并多选车辆;留空代表全部授权车辆。' : editorStep === 3 ? '动作配置完成后进入发布检查,不会立即发送通知。' : '配置有效即可发布;样例测试为可选预演,不会产生真实通知。';
|
||||
const editorFooterNote = currentStepError
|
||||
? <em className="v2-automation-editor-footer-error" role="alert">{currentStepError}</em>
|
||||
: mutationError
|
||||
? <em className="v2-automation-editor-footer-error" role="alert">{mutationError.message}</em>
|
||||
: reviewMode && draft.version > 0 && !draftDirty
|
||||
? <span>当前配置与线上版本一致,没有可发布的更改</span>
|
||||
: draftDirty
|
||||
? <span className="v2-automation-editor-draft-status" role="status">草稿未保存 · 关闭前会再次确认</span>
|
||||
? <span className="v2-automation-editor-draft-status" role="status">{reviewMode ? '配置已就绪,可直接发布更新' : '草稿未保存 · 关闭前会再次确认'}</span>
|
||||
: editorGuidance;
|
||||
const editorPrimaryDisabled = save.isPending || toggle.isPending || Boolean(currentStepError) || (reviewMode && (!testRan || (draft.version > 0 && !releaseChanges.length)));
|
||||
const editorPrimaryDisabled = save.isPending || toggle.isPending || Boolean(currentStepError) || (reviewMode && draft.version > 0 && !draftDirty);
|
||||
const closeEditor = () => {
|
||||
setDiscardConfirmOpen(false);
|
||||
setCloneSource(undefined);
|
||||
|
||||
@@ -2,12 +2,12 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import StatisticsPage, { mileageRangeContainsDate, parseMileageVehicleIdentifiers } from './StatisticsPage';
|
||||
import StatisticsPage, { mileageExportDateWindows, mileageRangeContainsDate, parseMileageVehicleIdentifiers } from './StatisticsPage';
|
||||
import { buildMonitorPath, withMonitorReturn } from '../routing/monitorContext';
|
||||
import { ROUTER_FUTURE } from '../routing/routerConfig';
|
||||
import { buildVehicleDetailPath, withVehicleReturn } from '../routing/vehicleContext';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ mileageStatistics: vi.fn(), dailyMileage: vi.fn(), vehicles: vi.fn(), vehicleCoverage: vi.fn(), vehicleServiceOverviews: vi.fn() }));
|
||||
const mocks = vi.hoisted(() => ({ mileageStatistics: vi.fn(), dailyMileage: vi.fn(), hydrogenDailyEvidence: vi.fn(), vehicles: vi.fn(), vehicleCoverage: vi.fn(), vehicleServiceOverviews: vi.fn() }));
|
||||
const exportMocks = vi.hoisted(() => ({ createMileageExportStream: vi.fn(), appendRows: vi.fn(), finish: vi.fn(), dispose: vi.fn() }));
|
||||
const layout = vi.hoisted(() => ({ mobile: false }));
|
||||
const auth = vi.hoisted(() => ({ role: 'admin' as 'admin' | 'customer', userType: 'admin' as 'admin' | 'customer' }));
|
||||
@@ -29,17 +29,28 @@ function prepareData() {
|
||||
mocks.mileageStatistics.mockResolvedValue({
|
||||
dateFrom: '2026-07-01', dateTo: '2026-07-14', vehicleCount: 1, recordCount: 2, sourceCount: 2,
|
||||
periodMileageKm: 193.3, periodPureHydrogenMileageKm: 128.4,
|
||||
hydrogenMatchedMileageKm: 193.3, hydrogenDataDays: 2, periodHydrogenConsumptionKg: 7.3, hydrogenConsumptionKgPer100Km: 3.8,
|
||||
hydrogenMatchedMileageKm: 128.4, hydrogenDataDays: 2, periodHydrogenConsumptionKg: 7.3, hydrogenConsumptionKgPer100Km: 5.7,
|
||||
fleetLatestMileageKm: 119925, averageMileagePerVin: 193.3, averageDailyMileageKm: 96.65,
|
||||
trend: [], ranking: [{ vin: 'LTEST000000000001', plate: '粤A12345', mileageKm: 193.3, latestMileageKm: 119925, activeDays: 2 }],
|
||||
asOf: '2026-07-14 13:20:00', evidence: 'production mileage evidence'
|
||||
});
|
||||
mocks.dailyMileage.mockResolvedValue({ items: [
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 119731.7, endMileageKm: 119820.4, dailyMileageKm: 88.7, hydrogenConsumptionKg: 3.1, hydrogenConsumptionKgPer100Km: 3.5, source: 'GB32960' },
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 119820.4, endMileageKm: 119925, dailyMileageKm: 104.6, hydrogenConsumptionKg: 4.2, hydrogenConsumptionKgPer100Km: 4, source: 'GB32960' }
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 119731.7, endMileageKm: 119820.4, dailyMileageKm: 88.7, pureHydrogenMileageKm: 56.2, hydrogenConsumptionKg: 3.1, hydrogenConsumptionKgPer100Km: 5.5, hydrogenEvidenceAvailable: true, hydrogenQualityStatus: 'OK', hydrogenAlgorithmVersion: 'PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2', source: 'GB32960' },
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 119820.4, endMileageKm: 119925, dailyMileageKm: 104.6, pureHydrogenMileageKm: 72.2, hydrogenConsumptionKg: 4.2, hydrogenConsumptionKgPer100Km: 5.8, hydrogenEvidenceAvailable: true, hydrogenQualityStatus: 'OK', hydrogenAlgorithmVersion: 'PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2', source: 'GB32960' }
|
||||
], total: 2, limit: 10000, offset: 0 });
|
||||
mocks.hydrogenDailyEvidence.mockResolvedValue({
|
||||
vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', source: 'factory-a',
|
||||
rawConsumptionKg: 3.1, batterySocDeltaPct: -2, batteryDischargeKWh: 0.421,
|
||||
batteryEquivalentKg: 0.026, socBalancedConsumptionKg: 3.126,
|
||||
mixedMileageKm: 56.2, pureElectricMileageKm: 32.5, consumptionKgPer100Km: 5.516,
|
||||
socBalancedKgPer100Km: 5.562, sampleCount: 120, refuelCount: 1, chargeCount: 1,
|
||||
validSegmentCount: 1, invalidSegmentCount: 0, qualityStatus: 'OK', qualityReason: '',
|
||||
algorithmVersion: 'PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2', calculatedAt: '2026-07-14 00:05:00.000',
|
||||
parameters: { batteryCapacityKWh: 21.04, hydrogenEnergyKWhPerKg: 16, powerOnDelaySeconds: 60, powerOffLeadSeconds: 60, refuelRiseMpa: 3, refuelSustainSeconds: 300, pureElectricDropMpa: 5, pureElectricWindowSeconds: 1800, algorithmVersion: 'PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2' },
|
||||
intervals: [{ index: 1, type: 'MIXED', startTime: '2026-07-13T08:01:00+08:00', endTime: '2026-07-13T10:30:00+08:00', startEventId: 'event-start', endEventId: 'event-end', source: 'factory-a', startPressureMpa: 30, endPressureMpa: 22, startTemperatureC: 30, endTemperatureC: 35, startMassKg: 11.3, endMassKg: 8.2, rawHydrogenConsumptionKg: 3.1, startSocPercent: 80, endSocPercent: 78, batteryDischargeKWh: 0.421, batteryEquivalentKg: 0.026, socBalancedConsumptionKg: 3.126, startMileageKm: 1000, endMileageKm: 1056.2, mileageKm: 56.2, consumptionKgPer100Km: 5.562, sampleCount: 120, qualityStatus: 'OK', qualityReason: '' }]
|
||||
});
|
||||
mocks.vehicles.mockResolvedValue({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345', phone: '', oem: '', protocol: 'GB32960', online: true, lastSeen: '', locationText: '', bindingScore: 100 }], total: 1, limit: 12, offset: 0 });
|
||||
mocks.vehicleCoverage.mockResolvedValue({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345' }], total: 1, limit: 20, offset: 0 });
|
||||
mocks.vehicleCoverage.mockResolvedValue({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345', brandName: '现代', modelName: 'XCIENT' }], total: 1, limit: 20, offset: 0 });
|
||||
mocks.vehicleServiceOverviews.mockResolvedValue({ items: [], total: 0, limit: 200, offset: 0 });
|
||||
}
|
||||
|
||||
@@ -56,12 +67,23 @@ test('refreshes only ranges that include the current business date', () => {
|
||||
expect(mileageRangeContainsDate({ dateFrom: '2026-07-20', dateTo: '2026-07-20' }, '2026-07-19')).toBe(false);
|
||||
});
|
||||
|
||||
test('bounds full-fleet export queries by vehicle-day volume', () => {
|
||||
const windows = mileageExportDateWindows('2026-07-01', '2026-07-30', 1024);
|
||||
expect(windows).toEqual([
|
||||
{ dateFrom: '2026-07-01', dateTo: '2026-07-07' },
|
||||
{ dateFrom: '2026-07-08', dateTo: '2026-07-14' },
|
||||
{ dateFrom: '2026-07-15', dateTo: '2026-07-21' },
|
||||
{ dateFrom: '2026-07-22', dateTo: '2026-07-28' },
|
||||
{ dateFrom: '2026-07-29', dateTo: '2026-07-30' }
|
||||
]);
|
||||
});
|
||||
|
||||
test('renders only the desktop matrix with dates as columns and a period total', async () => {
|
||||
prepareData();
|
||||
const view = renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
|
||||
expect(await screen.findByText('车辆每日里程')).toBeInTheDocument();
|
||||
expect((await screen.findAllByText('193.3 km')).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('区间总里程').length).toBeGreaterThan(1);
|
||||
expect(screen.getByText('区间合计')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('104.6 km').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('88.7 km').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('7/13').length).toBeGreaterThan(0);
|
||||
@@ -116,25 +138,58 @@ test('renders only the desktop matrix with dates as columns and a period total',
|
||||
});
|
||||
fireEvent.keyDown(matrixRegion, { key: 'ArrowRight' });
|
||||
expect(matrixScroller!.scrollLeft).toBe(96);
|
||||
expect(view.container.querySelector('[aria-label="2026-07-13,里程 88.7 公里,来源 GB32960"]')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('[aria-label="2026-07-13,里程 88.7 km,来源 GB32960"]')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-mileage-table.semi-table-wrapper')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-mileage-table-wrap > table')).not.toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-mileage-mobile-list')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '氢耗' }));
|
||||
expect(await screen.findByRole('heading', { name: '车辆每日氢耗' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('note', { name: '氢耗矩阵读表说明' })).toHaveTextContent('每日耗氢量');
|
||||
const hydrogenGuide = screen.getByRole('note', { name: '氢耗矩阵读表说明' });
|
||||
expect(hydrogenGuide).toHaveTextContent('日期子列里程固定展示');
|
||||
expect(screen.getByRole('group', { name: '日期子列' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '用氢量' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: '纯电里程' })).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByRole('button', { name: '纯氢里程' })).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByRole('button', { name: '百公里氢耗' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(hydrogenGuide).toHaveTextContent('里程 当日末累计里程-同源日基线');
|
||||
expect(hydrogenGuide).toHaveTextContent('纯电里程 总里程-纯氢里程');
|
||||
expect(hydrogenGuide).toHaveTextContent('纯氢里程 运行模式 0x02 的相邻里程差累计');
|
||||
expect(hydrogenGuide).toHaveTextContent('百公里氢耗 有效用氢量÷纯氢里程×100');
|
||||
expect(screen.getByRole('region', { name: '车辆每日氢耗矩阵,可横向滚动查看日期' })).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('氢耗匹配里程193.3 km按有氢耗数据的车辆日总里程计算');
|
||||
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('区间氢耗7.3 kg3.8 kg/100km · 2 个有效车辆日');
|
||||
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('百公里氢耗3.8 kg/100km2 个有效车辆日');
|
||||
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('氢耗匹配纯氢里程128.4 km纯氢里程=总里程-纯电模式里程');
|
||||
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('区间氢耗7.3 kg5.7 kg/100km · 2 个有效车辆日');
|
||||
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('百公里纯氢耗5.7 kg/100km2 个有效车辆日');
|
||||
expect(screen.getAllByText('3.1 kg').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('4.2 kg').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('7.3 kg').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('5.5 kg/100km').length).toBeGreaterThan(0);
|
||||
fireEvent.click(screen.getByRole('button', { name: '纯电里程' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '纯氢里程' }));
|
||||
expect(screen.getByRole('button', { name: '纯电里程' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: '纯氢里程' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getAllByText('32.5 km').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('56.2 km').length).toBeGreaterThan(0);
|
||||
await waitFor(() => expect(mocks.dailyMileage).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.dailyMileage.mock.calls[0][0].limit).toBe(10000);
|
||||
expect(mocks.dailyMileage.mock.calls[0][0].protocols).toEqual(['GB32960', 'JT808', 'YUTONG_MQTT']);
|
||||
});
|
||||
|
||||
test('opens traceable hydrogen formula, parameters, intervals and raw event ids', async () => {
|
||||
prepareData();
|
||||
renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
|
||||
fireEvent.click(await screen.findByRole('button', { name: '氢耗' }));
|
||||
const traceButtons = await screen.findAllByTitle('查看计算公式、区间和原始报文标识');
|
||||
fireEvent.click(traceButtons[0]);
|
||||
expect(await screen.findByText('计算公式与口径')).toBeInTheDocument();
|
||||
expect(screen.getByText(/m = P × 1000 × 0.00201588/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/SOC平衡氢耗 = 物理耗氢/)).toBeInTheDocument();
|
||||
expect(screen.getByText('120')).toBeInTheDocument();
|
||||
expect(screen.getByText('event-start')).toBeInTheDocument();
|
||||
expect(screen.getByText('event-end')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('3.126 kg').length).toBeGreaterThan(0);
|
||||
expect(mocks.hydrogenDailyEvidence).toHaveBeenCalledWith('LTEST000000000001', '2026-07-13', expect.any(AbortSignal));
|
||||
});
|
||||
|
||||
test('distinguishes missing mileage from a reported zero and explains partial coverage', async () => {
|
||||
prepareData();
|
||||
mocks.mileageStatistics.mockResolvedValue({
|
||||
@@ -163,6 +218,18 @@ test('does not expose the hydrogen consumption view to business customers', asyn
|
||||
expect(screen.queryByText('纯氢里程 56.2 km')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('keeps the daily mileage matrix available when the fleet summary times out', async () => {
|
||||
prepareData();
|
||||
mocks.mileageStatistics.mockRejectedValue(new Error('请求处理超时'));
|
||||
|
||||
const view = renderPage('/statistics?dateFrom=2026-07-13&dateTo=2026-07-14');
|
||||
|
||||
expect(await screen.findByRole('region', { name: '车辆每日里程矩阵,可横向滚动查看日期' })).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-mileage-summary')).toHaveTextContent('汇总暂不可用');
|
||||
expect(screen.queryByText('数据暂时无法加载')).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText('粤A12345').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('shows query failures inside the result panel without a misleading empty matrix', async () => {
|
||||
prepareData();
|
||||
mocks.dailyMileage.mockRejectedValue(new Error('日里程服务超时'));
|
||||
@@ -273,7 +340,7 @@ test('uses one responsive mileage matrix without viewport listeners', async () =
|
||||
expect(view.container.querySelector('.v2-mileage-table.semi-table-wrapper')).toBeInTheDocument();
|
||||
expect(view.container.querySelectorAll('.v2-mileage-table th.is-date')).toHaveLength(2);
|
||||
expect(view.container.querySelector('.v2-mileage-table th.is-plate')).toHaveTextContent('车牌');
|
||||
expect(view.container.querySelector('.v2-mileage-table th.is-total')).toHaveTextContent('区间总里程');
|
||||
expect(view.container.querySelector('.v2-mileage-table th.is-total-group')).toHaveTextContent('区间合计');
|
||||
expect(view.container.querySelector('.v2-mileage-table th.is-vin')).not.toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-mileage-table th.is-plate')).toHaveClass('is-plate');
|
||||
expect(view.container.querySelector('.v2-mileage-table th.is-total')).toHaveClass('is-total');
|
||||
@@ -476,7 +543,7 @@ test('paginates all unique vehicles when no license plate is selected', async ()
|
||||
await waitFor(() => expect(mocks.vehicleCoverage.mock.calls[mocks.vehicleCoverage.mock.calls.length - 1]?.[0].get('offset')).toBe('20'));
|
||||
});
|
||||
|
||||
test('exports the full fleet mileage with one paginated query instead of VIN batches', async () => {
|
||||
test('exports the full fleet mileage with fleet-scoped time windows instead of VIN batches', async () => {
|
||||
prepareData();
|
||||
const exportRows = [
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 119731.7, endMileageKm: 119820.4, dailyMileageKm: 88.7, source: 'GB32960' },
|
||||
@@ -499,12 +566,50 @@ test('exports the full fleet mileage with one paginated query instead of VIN bat
|
||||
expect(exportMocks.appendRows).toHaveBeenCalledTimes(2);
|
||||
expect(exportMocks.appendRows.mock.calls.map(([rows]) => rows.map((row: { date: string }) => row.date))).toEqual([['2026-07-13'], ['2026-07-14']]);
|
||||
expect(exportMocks.finish.mock.calls[0][0]).toHaveLength(1);
|
||||
expect(exportMocks.finish.mock.calls[0][0][0]).toMatchObject({ brandName: '现代', modelName: 'XCIENT' });
|
||||
expect(mocks.vehicleCoverage.mock.calls[mocks.vehicleCoverage.mock.calls.length - 1][1]).toBeInstanceOf(AbortSignal);
|
||||
expect(mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1][1]).toBeInstanceOf(AbortSignal);
|
||||
expect(exportMocks.createMileageExportStream.mock.calls[0][1]).toBeInstanceOf(AbortSignal);
|
||||
expect(screen.getByText(/已导出 1 辆车/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('loads archive brand and model before exporting selected vehicles', async () => {
|
||||
prepareData();
|
||||
renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /导出 Excel/ })).toBeEnabled());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /导出 Excel/ }));
|
||||
|
||||
await waitFor(() => expect(exportMocks.finish).toHaveBeenCalledTimes(1));
|
||||
const archiveCall = mocks.vehicleCoverage.mock.calls.find(([params]) => params.get('keywords') === 'LTEST000000000001');
|
||||
expect(archiveCall?.[0].get('bindingStatus')).toBe('bound');
|
||||
expect(archiveCall?.[1]).toBeInstanceOf(AbortSignal);
|
||||
expect(exportMocks.finish.mock.calls[0][0]).toEqual([
|
||||
expect.objectContaining({ vin: 'LTEST000000000001', plate: '粤A12345', brandName: '现代', modelName: 'XCIENT' })
|
||||
]);
|
||||
});
|
||||
|
||||
test('splits a 1024-vehicle full-fleet export into bounded time windows', async () => {
|
||||
prepareData();
|
||||
const fleet = Array.from({ length: 1024 }, (_, index) => ({ vin: `VIN${String(index + 1).padStart(14, '0')}`, plate: `粤A${String(index + 1).padStart(5, '0')}` }));
|
||||
mocks.vehicleCoverage.mockImplementation((params: URLSearchParams) => Promise.resolve(params.get('limit') === String(2_000)
|
||||
? { items: fleet, total: fleet.length, limit: fleet.length, offset: 0 }
|
||||
: { items: fleet.slice(0, 20), total: fleet.length, limit: 20, offset: 0 }));
|
||||
mocks.dailyMileage.mockResolvedValue({ items: [], total: 0, limit: 10000, offset: 0 });
|
||||
renderPage('/statistics?dateFrom=2026-07-01&dateTo=2026-07-30');
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /导出 Excel/ })).toBeEnabled());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /导出 Excel/ }));
|
||||
|
||||
await waitFor(() => expect(exportMocks.finish).toHaveBeenCalledTimes(1));
|
||||
const exportCalls = mocks.dailyMileage.mock.calls
|
||||
.map(([query]) => query as { vehicleScope?: string; vins?: string[]; dateFrom: string; dateTo: string })
|
||||
.filter((query) => query.vehicleScope === 'bound');
|
||||
expect(exportCalls.map(({ dateFrom, dateTo }) => ({ dateFrom, dateTo }))).toEqual(mileageExportDateWindows('2026-07-01', '2026-07-30', 1024));
|
||||
expect(exportCalls.every((query) => query.vins === undefined)).toBe(true);
|
||||
expect(exportMocks.finish.mock.calls[0][0]).toHaveLength(1024);
|
||||
});
|
||||
|
||||
test('shows export progress and lets the user cancel the active request', async () => {
|
||||
prepareData();
|
||||
let exportSignal: AbortSignal | undefined;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { IconArrowDown, IconArrowUp, IconClose, IconDownload, IconInfoCircle, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
|
||||
import { Button, ButtonGroup, Card, DatePicker, Input, Progress, Spin, Switch, Table, Tag, TextArea } from '@douyinfe/semi-ui';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { type CSSProperties, FormEvent, type KeyboardEvent, memo, type RefObject, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { type CSSProperties, FormEvent, type KeyboardEvent, memo, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api, type MileageQuery } from '../../api/client';
|
||||
import type { DailyMileageRow, MileageStatistics, Page, VehicleRow } from '../../api/types';
|
||||
import type { DailyMileageRow, HydrogenDailyEvidence, MileageStatistics, Page, VehicleRow } from '../../api/types';
|
||||
import { createMileageExportStream, type MileageExportStream } from '../domain/mileageExport';
|
||||
import { formatZhNumber } from '../domain/formatters';
|
||||
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
|
||||
@@ -32,15 +32,34 @@ const BATCH_RESOLVE_SIZE = 200;
|
||||
const PAGE_SIZE = 20;
|
||||
const EXPORT_VEHICLE_PAGE_SIZE = 2_000;
|
||||
const EXPORT_VIN_BATCH_SIZE = 50;
|
||||
const EXPORT_QUERY_TARGET_ROWS = 8_000;
|
||||
const EXPORT_MAX_DAYS_PER_QUERY = 31;
|
||||
const MAX_MILEAGE_RANGE_DAYS = 366;
|
||||
const CURRENT_DAY_REFRESH_MS = 60_000;
|
||||
|
||||
type VehicleOption = Pick<VehicleRow, 'vin' | 'plate'>;
|
||||
type VehicleOption = Pick<VehicleRow, 'vin' | 'plate'> & { brandName?: string; modelName?: string };
|
||||
type MileageProtocol = 'GB32960' | 'JT808' | 'YUTONG_MQTT';
|
||||
type MileageSourceOption = { protocol: MileageProtocol; label: string; mileageType: string; enabled: boolean };
|
||||
type Criteria = { vehicles: VehicleOption[]; dateFrom: string; dateTo: string; sources: MileageSourceOption[] };
|
||||
type ExportProgress = { label: string; completed?: number; total?: number };
|
||||
type MetricView = 'mileage' | 'hydrogen';
|
||||
type HydrogenMatrixMetric = 'consumption' | 'pureElectric' | 'pureHydrogen' | 'rate';
|
||||
type MatrixMetric = 'mileage' | HydrogenMatrixMetric;
|
||||
|
||||
const HYDROGEN_MATRIX_METRICS: Array<{ key: HydrogenMatrixMetric; label: string }> = [
|
||||
{ key: 'consumption', label: '用氢量' },
|
||||
{ key: 'pureElectric', label: '纯电里程' },
|
||||
{ key: 'pureHydrogen', label: '纯氢里程' },
|
||||
{ key: 'rate', label: '百公里氢耗' }
|
||||
];
|
||||
const DEFAULT_HYDROGEN_MATRIX_METRICS: HydrogenMatrixMetric[] = ['consumption', 'rate'];
|
||||
const MATRIX_METRIC_META: Record<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 DEFAULT_SOURCES: MileageSourceOption[] = [
|
||||
@@ -147,6 +166,20 @@ function mileageQuery(criteria: Criteria, offset = 0): MileageQuery {
|
||||
return query;
|
||||
}
|
||||
|
||||
export function mileageExportDateWindows(dateFrom: string, dateTo: string, vehicleCount: number) {
|
||||
const start = new Date(`${dateFrom}T00:00:00Z`);
|
||||
const end = new Date(`${dateTo}T00:00:00Z`);
|
||||
if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime()) || start > end) return [];
|
||||
const daysPerQuery = Math.max(1, Math.min(EXPORT_MAX_DAYS_PER_QUERY, Math.floor(EXPORT_QUERY_TARGET_ROWS / Math.max(1, vehicleCount))));
|
||||
const windows: Array<{ dateFrom: string; dateTo: string }> = [];
|
||||
for (let cursor = start; cursor <= end;) {
|
||||
const windowEnd = new Date(Math.min(end.getTime(), cursor.getTime() + (daysPerQuery - 1) * DAY));
|
||||
windows.push({ dateFrom: cursor.toISOString().slice(0, 10), dateTo: windowEnd.toISOString().slice(0, 10) });
|
||||
cursor = new Date(windowEnd.getTime() + DAY);
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
function mileageRouteParams(criteria: Criteria) {
|
||||
const params = new URLSearchParams({ dateFrom: criteria.dateFrom, dateTo: criteria.dateTo });
|
||||
params.set('protocols', criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(','));
|
||||
@@ -502,7 +535,7 @@ function MetricViewSwitch({ value, onChange }: { value: MetricView; onChange: (v
|
||||
return <div className="v2-mileage-view-switch" role="group" aria-label="数据视图">
|
||||
<span>
|
||||
<strong>{value === 'mileage' ? '里程视图' : '氢耗视图'}</strong>
|
||||
<small>{value === 'mileage' ? '只看车辆行驶里程' : '按当日总里程计算氢耗'}</small>
|
||||
<small>{value === 'mileage' ? '只看车辆行驶里程' : '按纯氢里程计算百公里氢耗'}</small>
|
||||
</span>
|
||||
<ButtonGroup aria-label="切换里程或氢耗视图">
|
||||
<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>;
|
||||
}
|
||||
|
||||
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 }) {
|
||||
const days = inclusiveDays(criteria.dateFrom, criteria.dateTo);
|
||||
const scopeVehicleCount = criteria.vehicles.length || fleetTotal || 0;
|
||||
@@ -542,9 +595,9 @@ function SummaryRail({ data, criteria, fleetTotal, loading, view, unavailable =
|
||||
tone: 'success',
|
||||
emphasis: 'primary'
|
||||
}] : [{
|
||||
label: '氢耗匹配里程',
|
||||
label: '氢耗匹配纯氢里程',
|
||||
value: pending ? '—' : withUnit(formatKm(data?.hydrogenMatchedMileageKm), 'km'),
|
||||
note: unavailable ? '里程汇总暂不可用' : loading ? '正在汇总车辆日总里程' : '按有氢耗数据的车辆日总里程计算',
|
||||
note: unavailable ? '里程汇总暂不可用' : loading ? '正在汇总车辆日纯氢里程' : '纯氢里程=总里程-纯电模式里程',
|
||||
tone: 'success',
|
||||
emphasis: 'primary'
|
||||
}, {
|
||||
@@ -560,7 +613,7 @@ function SummaryRail({ data, criteria, fleetTotal, loading, view, unavailable =
|
||||
tone: 'warning',
|
||||
emphasis: 'primary'
|
||||
}, {
|
||||
label: '百公里氢耗',
|
||||
label: '百公里纯氢耗',
|
||||
value: pending || !data?.hydrogenDataDays ? '—' : withUnit(formatKm(data.hydrogenConsumptionKgPer100Km), 'kg/100km'),
|
||||
note: unavailable ? '氢耗效率暂不可用' : loading ? '正在计算氢耗效率' : `${data?.hydrogenDataDays ?? 0} 个有效车辆日`,
|
||||
tone: 'warning',
|
||||
@@ -579,11 +632,25 @@ function SummaryRail({ data, criteria, fleetTotal, loading, view, unavailable =
|
||||
/>;
|
||||
}
|
||||
|
||||
type HydrogenDayMetric = {
|
||||
consumptionKg: number;
|
||||
rateKgPer100Km?: number;
|
||||
socBalancedKg?: number;
|
||||
socBalancedRateKgPer100Km?: number;
|
||||
evidenceAvailable: boolean;
|
||||
qualityStatus?: string;
|
||||
algorithmVersion?: string;
|
||||
};
|
||||
|
||||
type VehicleMileageMatrix = VehicleOption & {
|
||||
days: Map<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>;
|
||||
totalMileageKm?: number;
|
||||
totalPureElectricMileageKm?: number;
|
||||
totalPureHydrogenMileageKm?: number;
|
||||
totalHydrogenConsumptionKg?: number;
|
||||
totalHydrogenRateKgPer100Km?: number;
|
||||
};
|
||||
@@ -604,14 +671,31 @@ function dateLabel(date: string) {
|
||||
return `${Number(month)}/${Number(day)}`;
|
||||
}
|
||||
|
||||
function MileageMatrixGuide({ rows, view }: { rows: VehicleMileageMatrix[]; view: MetricView }) {
|
||||
function MileageMatrixGuide({
|
||||
rows,
|
||||
view,
|
||||
hydrogenMetrics,
|
||||
onHydrogenMetricsChange
|
||||
}: {
|
||||
rows: VehicleMileageMatrix[];
|
||||
view: MetricView;
|
||||
hydrogenMetrics: HydrogenMatrixMetric[];
|
||||
onHydrogenMetricsChange: (value: HydrogenMatrixMetric[]) => void;
|
||||
}) {
|
||||
const vehiclesWithMileage = rows.filter((row) => row.days.size > 0).length;
|
||||
if (view === 'hydrogen') return <aside id="v2-mileage-matrix-guide" className="v2-mileage-matrix-guide is-hydrogen" role="note" aria-label="氢耗矩阵读表说明">
|
||||
<span className="v2-mileage-matrix-guide-title"><IconInfoCircle /><strong>本页 {vehiclesWithMileage} / {rows.length} 辆有数据</strong></span>
|
||||
<span><Tag color="orange" type="light" size="small">kg</Tag>每日耗氢量</span>
|
||||
<span><Tag color="green" type="light" size="small">km</Tag>当日总里程</span>
|
||||
<span><Tag color="amber" type="light" size="small">kg/100km</Tag>百公里氢耗</span>
|
||||
<span className="is-mobile-hint">左右滑动查看日期 · 车牌与区间合计固定</span>
|
||||
if (view === 'hydrogen') return <aside id="v2-mileage-matrix-guide" className="v2-mileage-matrix-guide is-hydrogen has-metric-selector" role="note" aria-label="氢耗矩阵读表说明">
|
||||
<div className="v2-mileage-matrix-guide-toolbar">
|
||||
<span className="v2-mileage-matrix-guide-title"><IconInfoCircle /><strong>本页 {vehiclesWithMileage} / {rows.length} 辆有数据</strong></span>
|
||||
<HydrogenMetricSelector value={hydrogenMetrics} onChange={onHydrogenMetricsChange} />
|
||||
</div>
|
||||
<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>;
|
||||
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>
|
||||
@@ -638,79 +722,112 @@ function scrollMileageMatrixFromKeyboard(event: KeyboardEvent<HTMLDivElement>) {
|
||||
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(() => {
|
||||
let maxDailyMileage = 1;
|
||||
for (const row of rows) {
|
||||
for (const mileage of row.days.values()) maxDailyMileage = Math.max(maxDailyMileage, mileage);
|
||||
}
|
||||
const dateColumnWidth = view === 'hydrogen' ? 128 : 96;
|
||||
const totalColumnWidth = view === 'hydrogen' ? 148 : 128;
|
||||
const metrics: MatrixMetric[] = view === 'hydrogen' ? ['mileage', ...hydrogenMetrics] : ['mileage'];
|
||||
const metricsWidth = metrics.reduce((sum, metric) => sum + MATRIX_METRIC_META[metric].width, 0);
|
||||
return {
|
||||
columns: [
|
||||
{ title: '车牌', dataIndex: 'plate', className: 'is-plate', width: 120, render: (_value: string, row: VehicleMileageMatrix) => <strong>{row.plate || '未绑定'}</strong> },
|
||||
...dates.map((date) => ({
|
||||
title: dateLabel(date), dataIndex: date, className: 'is-number is-date', width: dateColumnWidth,
|
||||
onHeaderCell: () => ({ title: date, 'aria-label': `${date} 每日${view === 'mileage' ? '里程' : '氢耗'}` }),
|
||||
onCell: (row?: VehicleMileageMatrix) => {
|
||||
const mileage = row?.days.get(date);
|
||||
const hydrogen = row?.hydrogenDays.get(date);
|
||||
const source = row?.sources.get(date);
|
||||
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},无可用氢耗数据`
|
||||
};
|
||||
title: dateLabel(date),
|
||||
dataIndex: `date:${date}`,
|
||||
className: 'is-date-group',
|
||||
onHeaderCell: () => ({ title: date, 'aria-label': `${date} 指标分组` }),
|
||||
children: metrics.map((metric) => {
|
||||
const meta = MATRIX_METRIC_META[metric];
|
||||
return {
|
||||
className: `is-number is-date${mileage != null ? ' is-daily' : ' is-empty'}`,
|
||||
title: mileage != null ? `里程 ${formatKm(mileage)} km · 来源:${source || '—'}` : '无可用里程',
|
||||
'aria-label': mileage != null
|
||||
? `${date},里程 ${formatKm(mileage)} 公里,来源 ${source || '未知'}`
|
||||
: `${date},无可用里程`,
|
||||
style: intensity ? { backgroundColor: `rgba(37, 99, 235, ${intensity.toFixed(3)})` } : undefined
|
||||
title: <MatrixMetricHeader metric={metric} />,
|
||||
dataIndex: `${date}:${metric}`,
|
||||
className: `is-number is-date is-metric-${metric}`,
|
||||
width: meta.width,
|
||||
onHeaderCell: () => ({ 'aria-label': `${date} ${meta.label} ${meta.unit}` }),
|
||||
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,
|
||||
onCell: (row?: VehicleMileageMatrix) => ({
|
||||
className: `is-number is-period is-total${view === 'hydrogen' ? ' is-hydrogen-total' : ''}${row?.totalMileageKm == null ? ' is-empty' : ''}`,
|
||||
'aria-label': row?.totalMileageKm == null
|
||||
? view === 'mileage' ? '区间总里程,无可用里程' : '区间氢耗,无可用氢耗数据'
|
||||
: view === 'mileage'
|
||||
? `区间总里程,${formatKm(row.totalMileageKm)} 公里`
|
||||
: `区间耗氢 ${formatKm(row.totalHydrogenConsumptionKg)} 千克,总里程 ${formatKm(row.totalMileageKm)} 公里,百公里氢耗 ${formatKm(row.totalHydrogenRateKgPer100Km)} 千克`
|
||||
}),
|
||||
render: (value: number | undefined, row: VehicleMileageMatrix) => {
|
||||
if (value == null) return '—';
|
||||
if (view === 'hydrogen') return <span className="v2-mileage-cell-value is-hydrogen">
|
||||
<strong>{formatKm(row.totalHydrogenConsumptionKg)} kg</strong>
|
||||
<small>总里程 {formatKm(row.totalMileageKm)} km</small>
|
||||
<small>百公里 {formatKm(row.totalHydrogenRateKgPer100Km)} kg</small>
|
||||
</span>;
|
||||
return <strong>{formatKm(value)} km</strong>;
|
||||
}
|
||||
title: '区间合计',
|
||||
dataIndex: 'period',
|
||||
className: 'is-total is-total-group',
|
||||
children: metrics.map((metric) => {
|
||||
const meta = MATRIX_METRIC_META[metric];
|
||||
return {
|
||||
title: <MatrixMetricHeader metric={metric} />,
|
||||
dataIndex: `period:${metric}`,
|
||||
className: `is-number is-total is-metric-${metric}`,
|
||||
width: meta.width,
|
||||
onHeaderCell: () => ({ 'aria-label': `区间${meta.label} ${meta.unit}` }),
|
||||
onCell: (row?: VehicleMileageMatrix) => {
|
||||
const value = row ? matrixTotalMetricValue(row, metric) : undefined;
|
||||
return {
|
||||
className: `is-number is-period is-total is-metric-${metric}${value == null ? ' is-empty' : ''}`,
|
||||
'aria-label': value == null
|
||||
? 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
|
||||
className={`v2-mileage-table-wrap${view === 'hydrogen' ? ' is-hydrogen-view' : ''}`}
|
||||
ref={scrollRef}
|
||||
@@ -719,12 +836,101 @@ const MileageTable = memo(function MileageTable({ rows, dates, scrollRef, view }
|
||||
aria-describedby="v2-mileage-matrix-guide"
|
||||
tabIndex={0}
|
||||
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) }} />
|
||||
</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() {
|
||||
const { session } = usePlatformSession();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -740,11 +946,16 @@ export default function StatisticsPage() {
|
||||
const [validationError, setValidationError] = useState('');
|
||||
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
|
||||
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 mobileFiltersOpen = mobileLayout && !filtersCollapsed;
|
||||
const exportControllerRef = useRef<AbortController | null>(null);
|
||||
const pageRef = 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);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
@@ -816,20 +1027,30 @@ export default function StatisticsPage() {
|
||||
const index = new Map<string, {
|
||||
plate: string;
|
||||
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>;
|
||||
}>();
|
||||
for (const row of mileage.data?.items ?? []) {
|
||||
let entry = index.get(row.vin);
|
||||
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);
|
||||
} else if (!entry.plate && row.plate) entry.plate = row.plate;
|
||||
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) {
|
||||
entry.hydrogenDays.set(row.date, {
|
||||
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);
|
||||
@@ -841,23 +1062,35 @@ export default function StatisticsPage() {
|
||||
const daily = mileageByVin.get(vehicle.vin);
|
||||
const ranking = rankingByVin.get(vehicle.vin);
|
||||
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>();
|
||||
let dailyTotal = 0;
|
||||
let hydrogenTotal = 0;
|
||||
let hydrogenRatedMileage = 0;
|
||||
let pureElectricTotal = 0;
|
||||
let pureHydrogenTotal = 0;
|
||||
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) {
|
||||
const matchedMileage = pureHydrogenDays.get(date) ?? 0;
|
||||
if (matchedMileage <= 0) continue;
|
||||
hydrogenTotal += value.consumptionKg;
|
||||
hydrogenRatedMileage += days.get(date) ?? 0;
|
||||
hydrogenRatedMileage += matchedMileage;
|
||||
}
|
||||
return {
|
||||
...vehicle,
|
||||
plate: vehicle.plate || daily?.plate || ranking?.plate || '',
|
||||
days,
|
||||
pureElectricDays,
|
||||
pureHydrogenDays,
|
||||
hydrogenDays,
|
||||
sources,
|
||||
totalMileageKm: days.size ? ranking?.mileageKm ?? dailyTotal : undefined,
|
||||
totalPureElectricMileageKm: pureElectricDays.size ? pureElectricTotal : undefined,
|
||||
totalPureHydrogenMileageKm: pureHydrogenDays.size ? pureHydrogenTotal : undefined,
|
||||
totalHydrogenConsumptionKg: hydrogenDays.size ? hydrogenTotal : 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 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
|
||||
? Math.min(100, Math.round((exportProgress.completed ?? 0) / exportProgress.total * 100))
|
||||
: undefined;
|
||||
@@ -924,14 +1161,39 @@ export default function StatisticsPage() {
|
||||
setExportFeedback('');
|
||||
let exportStream: MileageExportStream | undefined;
|
||||
try {
|
||||
let vehicles: VehicleOption[] = criteria.vehicles.map((vehicle) => ({ ...vehicle }));
|
||||
if (!vehicles.length) {
|
||||
let vehicles: VehicleOption[];
|
||||
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 = [];
|
||||
let offset = 0;
|
||||
reportProgress({ label: '正在读取车辆档案', completed: 0, total: totalVehicles });
|
||||
while (offset < totalVehicles) {
|
||||
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 });
|
||||
if (!result.items.length) break;
|
||||
offset += result.items.length;
|
||||
@@ -949,21 +1211,27 @@ export default function StatisticsPage() {
|
||||
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))
|
||||
: [[] 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;
|
||||
const mileageLabel = vehicleBatches.length > 1
|
||||
? `正在读取里程数据(${batchIndex + 1}/${vehicleBatches.length} 批)`
|
||||
: '正在读取全部车辆里程';
|
||||
reportProgress({ label: mileageLabel });
|
||||
const batchLabel = vehicleBatches.length > 1 ? ` · 车辆批次 ${batchIndex + 1}/${vehicleBatches.length}` : '';
|
||||
const mileageLabel = `正在读取里程数据(${window.dateFrom} 至 ${window.dateTo}${batchLabel})`;
|
||||
reportProgress({ label: mileageLabel, completed: completedWindows, total: exportWindows.length });
|
||||
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);
|
||||
for (const row of result.items) if (row.plate) plateByVin.set(row.vin, row.plate);
|
||||
exportStream.appendRows(result.items);
|
||||
offset += result.items.length;
|
||||
reportProgress({ label: mileageLabel, completed: offset, total: result.total });
|
||||
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) || '' }));
|
||||
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}%`} />}
|
||||
</div> : 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 ? <PanelLoading className="v2-mileage-loading" title={`正在查询${metricView === 'mileage' ? '里程' : '氢耗'}`} description="新筛选范围返回前不会展示上一范围的数据。" /> : displayVehicles.length ? <MileageTable rows={matrixRows} dates={dates} scrollRef={tableScrollRef} 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} hydrogenMetrics={hydrogenMatrixMetrics} onOpenEvidence={openHydrogenEvidence} /> : 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>
|
||||
</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>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -28523,6 +28523,300 @@
|
||||
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) {
|
||||
.v2-mileage-page {
|
||||
gap: 7px;
|
||||
@@ -28563,6 +28857,40 @@
|
||||
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 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
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/036_alert_rule_archive.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 ! -e "$root/current/lingniu-vehicle-platform.service"
|
||||
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;
|
||||
@@ -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/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/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 `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 `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",
|
||||
"date": "2026-07-01",
|
||||
"hydrogenConsumptionKg": 12.315,
|
||||
"calculationPhase": "FINAL",
|
||||
"algorithmVersion": "PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5",
|
||||
"status": "NORMAL"
|
||||
},
|
||||
{
|
||||
@@ -81,6 +83,8 @@ POST /api/v1/vehicles/hydrogen-consumption/query
|
||||
}
|
||||
```
|
||||
|
||||
当天查询可能返回 `calculationPhase: PRELIMINARY`,表示结果由 V3.5 流式状态机生成,后续帧可能使结果回修;完成日终全量重算后变为 `FINAL`。
|
||||
|
||||
## 单日里程
|
||||
|
||||
```http
|
||||
|
||||
Reference in New Issue
Block a user