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

This commit is contained in:
lingniu
2026-09-04 15:42:02 +08:00
parent a26179c8fe
commit e402eb5e60
53 changed files with 2855 additions and 320 deletions
@@ -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)
}
}