chore: snapshot production code before Apple Design UI refinement
This commit is contained in:
@@ -413,6 +413,9 @@ func (s *Service) ActOnAlert(ctx context.Context, id string, request AlertAction
|
||||
|
||||
func normalizeVehicleEvent(event AlertEvent) AlertEvent {
|
||||
event.EventCategory, event.EventType = canonicalVehicleEvent(event.TriggerType, event.Metric, event.Operator)
|
||||
if event.RuleID == nativeAlarmRuleID {
|
||||
event.EventCategory, event.EventType = "safety", "vehicle.safety.gb32960_alarm"
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(event.Status)) {
|
||||
case "processing":
|
||||
event.ExecutionState = "processing"
|
||||
|
||||
@@ -169,6 +169,20 @@ func (s *ProductionStore) AlertEvent(ctx context.Context, id string) (AlertEvent
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
if event.RuleID == nativeAlarmRuleID {
|
||||
var payload []byte
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT fields_json FROM vehicle_native_alarm_evidence WHERE event_id=?`, id).Scan(&payload); err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
if err := json.Unmarshal(payload, &event.NativeAlarmFields); err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
if description, valid := DescribeNativeAlarm(event.NativeAlarmFields); valid {
|
||||
event.RuleName = description.Title
|
||||
event.NativeAlarmNames = description.Names
|
||||
event.NativeAlarmReservedBits = description.ReservedBits
|
||||
}
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, alertActionSelect, id)
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
|
||||
@@ -22,6 +22,9 @@ func evaluateAlertStreamRecordsTx(ctx context.Context, tx *sql.Tx, records []Ale
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err := recordNativeAlarmsTx(ctx, tx, records, &result); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCorrectedDailyHydrogenRate(t *testing.T) {
|
||||
physical, corrected, mixed, zero := 10.0, 12.0, 200.0, 0.0
|
||||
row := DailyMileageRow{HydrogenConsumptionKg: &physical, HydrogenSOCBalancedKg: &corrected, PureHydrogenMileageKm: 100, MixedMileageKm: &mixed}
|
||||
if got := correctedDailyHydrogenRate(row); got == nil || *got != 6 {
|
||||
t.Fatalf("rate must use corrected mass and matching mileage: %v", got)
|
||||
}
|
||||
row.MixedMileageKm = nil
|
||||
if got := correctedDailyHydrogenRate(row); got == nil || *got != 12 {
|
||||
t.Fatalf("fallback mileage: %v", got)
|
||||
}
|
||||
row.HydrogenSOCBalancedKg = nil
|
||||
if got := correctedDailyHydrogenRate(row); got != nil {
|
||||
t.Fatalf("missing correction must not use physical mass: %v", *got)
|
||||
}
|
||||
row.HydrogenSOCBalancedKg = &zero
|
||||
if got := correctedDailyHydrogenRate(row); got == nil || *got != 0 {
|
||||
t.Fatalf("real zero must survive: %v", got)
|
||||
}
|
||||
row.PureHydrogenMileageKm = 0
|
||||
if got := correctedDailyHydrogenRate(row); got != nil {
|
||||
t.Fatalf("zero mileage must have no rate: %v", *got)
|
||||
}
|
||||
}
|
||||
|
||||
type distinctHydrogenStore struct{ *MockStore }
|
||||
|
||||
func (s *distinctHydrogenStore) DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error) {
|
||||
physical, corrected, distance := 12.813, 8.714, 77.0
|
||||
return Page[DailyMileageRow]{Items: []DailyMileageRow{{VIN: "LA9GG64L1NBAF4167", Date: "2026-09-01", HydrogenConsumptionKg: &physical, HydrogenSOCBalancedKg: &corrected, MixedMileageKm: &distance}}}, nil
|
||||
}
|
||||
func TestDailyMileageHistoricalPublicFieldsUseCorrection(t *testing.T) {
|
||||
result, err := NewService(&distinctHydrogenStore{NewMockStore()}).DailyMileage(context.Background(), url.Values{"dateFrom": {"2026-09-01"}, "dateTo": {"2026-09-01"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row := result.Items[0]
|
||||
if row.HydrogenConsumptionKg == nil || *row.HydrogenConsumptionKg != 8.714 || row.HydrogenPhysicalConsumptionKg == nil || *row.HydrogenPhysicalConsumptionKg != 12.813 {
|
||||
t.Fatalf("historical primary amount must be corrected, with physical amount separately available: %+v", row)
|
||||
}
|
||||
if row.HydrogenConsumptionKgPer100Km == nil || *row.HydrogenConsumptionKgPer100Km != 8.714*100/77 {
|
||||
t.Fatalf("historical rate mismatch: %+v", row)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,9 @@ func TestProductionStoreHydrogenDailyEvidenceReturnsParametersIntervalsAndRawEve
|
||||
if result.Parameters.BatteryCapacityKWh != 21.04 || result.RefuelAmountKg == nil || *result.RefuelAmountKg != 8.976 || result.ChargeEnergyKWh == nil || *result.ChargeEnergyKWh != 12.5 || len(result.Intervals) != 1 || result.Intervals[0].StartEventID != "event-start" || result.Intervals[0].EndEventID != "event-end" {
|
||||
t.Fatalf("result=%#v", result)
|
||||
}
|
||||
if result.ConsumptionKgPer100Km == nil || *result.ConsumptionKgPer100Km != 5.562 || result.PhysicalConsumptionKgPer100Km == nil || *result.PhysicalConsumptionKgPer100Km != 5.516 {
|
||||
t.Fatalf("evidence must expose corrected rate separately from physical rate: %+v", result)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package platform
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Page[T any] struct {
|
||||
Items []T `json:"items"`
|
||||
@@ -1000,37 +1003,40 @@ type AlertRuleLifecycleRequest struct {
|
||||
}
|
||||
|
||||
type AlertEvent struct {
|
||||
ID string `json:"id"`
|
||||
EventType string `json:"eventType"`
|
||||
EventCategory string `json:"eventCategory"`
|
||||
ExecutionState string `json:"executionState"`
|
||||
RuleID string `json:"ruleId"`
|
||||
RuleName string `json:"ruleName"`
|
||||
RuleVersion int `json:"ruleVersion"`
|
||||
Severity string `json:"severity"`
|
||||
TriggerType string `json:"triggerType"`
|
||||
Status string `json:"status"`
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
Protocol string `json:"protocol"`
|
||||
Metric string `json:"metric"`
|
||||
Operator string `json:"operator"`
|
||||
TriggerValue float64 `json:"triggerValue"`
|
||||
Threshold float64 `json:"threshold"`
|
||||
ThresholdHigh float64 `json:"thresholdHigh"`
|
||||
Unit string `json:"unit"`
|
||||
DurationSec int `json:"durationSec"`
|
||||
Location string `json:"location"`
|
||||
Longitude *float64 `json:"longitude,omitempty"`
|
||||
Latitude *float64 `json:"latitude,omitempty"`
|
||||
SourceEventID string `json:"sourceEventId"`
|
||||
EventAt string `json:"eventAt"`
|
||||
ReceivedAt string `json:"receivedAt"`
|
||||
TriggeredAt string `json:"triggeredAt"`
|
||||
RecoveredAt string `json:"recoveredAt"`
|
||||
Handler string `json:"handler"`
|
||||
Version int `json:"version"`
|
||||
Actions []AlertAction `json:"actions,omitempty"`
|
||||
NativeAlarmNames []string `json:"nativeAlarmNames,omitempty"`
|
||||
NativeAlarmReservedBits []int `json:"nativeAlarmReservedBits,omitempty"`
|
||||
NativeAlarmFields map[string]json.RawMessage `json:"nativeAlarmFields,omitempty"`
|
||||
ID string `json:"id"`
|
||||
EventType string `json:"eventType"`
|
||||
EventCategory string `json:"eventCategory"`
|
||||
ExecutionState string `json:"executionState"`
|
||||
RuleID string `json:"ruleId"`
|
||||
RuleName string `json:"ruleName"`
|
||||
RuleVersion int `json:"ruleVersion"`
|
||||
Severity string `json:"severity"`
|
||||
TriggerType string `json:"triggerType"`
|
||||
Status string `json:"status"`
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
Protocol string `json:"protocol"`
|
||||
Metric string `json:"metric"`
|
||||
Operator string `json:"operator"`
|
||||
TriggerValue float64 `json:"triggerValue"`
|
||||
Threshold float64 `json:"threshold"`
|
||||
ThresholdHigh float64 `json:"thresholdHigh"`
|
||||
Unit string `json:"unit"`
|
||||
DurationSec int `json:"durationSec"`
|
||||
Location string `json:"location"`
|
||||
Longitude *float64 `json:"longitude,omitempty"`
|
||||
Latitude *float64 `json:"latitude,omitempty"`
|
||||
SourceEventID string `json:"sourceEventId"`
|
||||
EventAt string `json:"eventAt"`
|
||||
ReceivedAt string `json:"receivedAt"`
|
||||
TriggeredAt string `json:"triggeredAt"`
|
||||
RecoveredAt string `json:"recoveredAt"`
|
||||
Handler string `json:"handler"`
|
||||
Version int `json:"version"`
|
||||
Actions []AlertAction `json:"actions,omitempty"`
|
||||
}
|
||||
|
||||
type AlertAction struct {
|
||||
@@ -1874,6 +1880,12 @@ type LatestTelemetryResponse struct {
|
||||
}
|
||||
|
||||
type DailyMileageRow struct {
|
||||
Province string `json:"province,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
LocationTime string `json:"locationTime,omitempty"`
|
||||
LocationStatus string `json:"locationStatus"`
|
||||
HydrogenPhysicalConsumptionKg *float64 `json:"hydrogenPhysicalConsumptionKg,omitempty"`
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
Date string `json:"date"`
|
||||
@@ -1942,32 +1954,33 @@ type HydrogenIntervalEvidenceRow struct {
|
||||
}
|
||||
|
||||
type HydrogenDailyEvidence struct {
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
Date string `json:"date"`
|
||||
Source string `json:"source"`
|
||||
RawConsumptionKg float64 `json:"rawConsumptionKg"`
|
||||
BatterySOCDeltaPct *float64 `json:"batterySocDeltaPct,omitempty"`
|
||||
BatteryDischargeKWh *float64 `json:"batteryDischargeKWh,omitempty"`
|
||||
BatteryEquivalentKg *float64 `json:"batteryEquivalentKg,omitempty"`
|
||||
SOCBalancedConsumptionKg *float64 `json:"socBalancedConsumptionKg,omitempty"`
|
||||
MixedMileageKm float64 `json:"mixedMileageKm"`
|
||||
PureElectricMileageKm float64 `json:"pureElectricMileageKm"`
|
||||
ConsumptionKgPer100Km *float64 `json:"consumptionKgPer100Km,omitempty"`
|
||||
SOCBalancedKgPer100Km *float64 `json:"socBalancedKgPer100Km,omitempty"`
|
||||
SampleCount int `json:"sampleCount"`
|
||||
RefuelCount int `json:"refuelCount"`
|
||||
RefuelAmountKg *float64 `json:"refuelAmountKg,omitempty"`
|
||||
ChargeCount int `json:"chargeCount"`
|
||||
ChargeEnergyKWh *float64 `json:"chargeEnergyKWh,omitempty"`
|
||||
ValidSegmentCount int `json:"validSegmentCount"`
|
||||
InvalidSegmentCount int `json:"invalidSegmentCount"`
|
||||
QualityStatus string `json:"qualityStatus"`
|
||||
QualityReason string `json:"qualityReason"`
|
||||
AlgorithmVersion string `json:"algorithmVersion"`
|
||||
Parameters HydrogenCalculationParameterEvidence `json:"parameters"`
|
||||
Intervals []HydrogenIntervalEvidenceRow `json:"intervals"`
|
||||
CalculatedAt string `json:"calculatedAt"`
|
||||
PhysicalConsumptionKgPer100Km *float64 `json:"physicalConsumptionKgPer100Km,omitempty"`
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
Date string `json:"date"`
|
||||
Source string `json:"source"`
|
||||
RawConsumptionKg float64 `json:"rawConsumptionKg"`
|
||||
BatterySOCDeltaPct *float64 `json:"batterySocDeltaPct,omitempty"`
|
||||
BatteryDischargeKWh *float64 `json:"batteryDischargeKWh,omitempty"`
|
||||
BatteryEquivalentKg *float64 `json:"batteryEquivalentKg,omitempty"`
|
||||
SOCBalancedConsumptionKg *float64 `json:"socBalancedConsumptionKg,omitempty"`
|
||||
MixedMileageKm float64 `json:"mixedMileageKm"`
|
||||
PureElectricMileageKm float64 `json:"pureElectricMileageKm"`
|
||||
ConsumptionKgPer100Km *float64 `json:"consumptionKgPer100Km,omitempty"`
|
||||
SOCBalancedKgPer100Km *float64 `json:"socBalancedKgPer100Km,omitempty"`
|
||||
SampleCount int `json:"sampleCount"`
|
||||
RefuelCount int `json:"refuelCount"`
|
||||
RefuelAmountKg *float64 `json:"refuelAmountKg,omitempty"`
|
||||
ChargeCount int `json:"chargeCount"`
|
||||
ChargeEnergyKWh *float64 `json:"chargeEnergyKWh,omitempty"`
|
||||
ValidSegmentCount int `json:"validSegmentCount"`
|
||||
InvalidSegmentCount int `json:"invalidSegmentCount"`
|
||||
QualityStatus string `json:"qualityStatus"`
|
||||
QualityReason string `json:"qualityReason"`
|
||||
AlgorithmVersion string `json:"algorithmVersion"`
|
||||
Parameters HydrogenCalculationParameterEvidence `json:"parameters"`
|
||||
Intervals []HydrogenIntervalEvidenceRow `json:"intervals"`
|
||||
CalculatedAt string `json:"calculatedAt"`
|
||||
}
|
||||
|
||||
// MileageQuery is the POST contract used by mileage statistics and daily
|
||||
|
||||
@@ -565,7 +565,8 @@ func buildDailyMileageSQL(query url.Values) SQLQuery {
|
||||
LEFT JOIN vehicle_open_daily_energy h
|
||||
ON h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci
|
||||
AND h.stat_date = m.stat_date
|
||||
AND h.energy_type = 'HYDROGEN' AND h.quality_status IN ('OK','SUSPECT')`
|
||||
AND h.energy_type = 'HYDROGEN' AND h.quality_status IN ('OK','SUSPECT')
|
||||
LEFT JOIN vehicle_daily_geography g ON g.vin = m.vin COLLATE utf8mb4_unicode_ci AND g.stat_date = m.stat_date`
|
||||
|
||||
if query.Get("deduplicate") == "1" || strings.EqualFold(query.Get("deduplicate"), "true") {
|
||||
selectionOrder := `m.daily_mileage_km DESC, m.protocol ASC`
|
||||
@@ -594,7 +595,7 @@ LEFT JOIN vehicle_open_daily_energy h
|
||||
`h.pure_electric_mileage_km, h.mixed_mileage_km, h.battery_soc_delta_pct, h.charge_count, h.charge_energy_kwh, h.refuel_count, h.refuel_amount_kg, ` +
|
||||
`CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END AS hydrogen_evidence_available, ` +
|
||||
`COALESCE(h.quality_status, '') AS hydrogen_quality_status, COALESCE(h.quality_reason, '') AS hydrogen_quality_reason, ` +
|
||||
`COALESCE(h.algorithm_version, '') AS hydrogen_algorithm_version, m.protocol ` +
|
||||
`COALESCE(h.algorithm_version, '') AS hydrogen_algorithm_version, m.protocol, ` + dailyGeographyProjectionSQL + ` ` +
|
||||
`FROM (` + pageSQL + `) m` + enrichmentSQL + ` ORDER BY m.stat_date DESC, m.vin ASC`,
|
||||
Args: args,
|
||||
CountText: `SELECT COUNT(*) FROM (SELECT m.vin ` + groupSQL + `) vehicle_daily_mileage_count`,
|
||||
@@ -613,7 +614,7 @@ LEFT JOIN vehicle_open_daily_energy h
|
||||
`COALESCE(m.pure_hydrogen_mileage_km, 0), h.consumption_kg, h.consumption_kg_per_100km, h.soc_balanced_consumption_kg, ` +
|
||||
`h.soc_balanced_kg_per_100km, h.pure_electric_mileage_km, h.mixed_mileage_km, h.battery_soc_delta_pct, ` +
|
||||
`h.charge_count, h.charge_energy_kwh, h.refuel_count, h.refuel_amount_kg, CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END, ` +
|
||||
`COALESCE(h.quality_status, ''), COALESCE(h.quality_reason, ''), COALESCE(h.algorithm_version, ''), m.protocol ` +
|
||||
`COALESCE(h.quality_status, ''), COALESCE(h.quality_reason, ''), COALESCE(h.algorithm_version, ''), m.protocol, ` + dailyGeographyProjectionSQL + ` ` +
|
||||
`FROM (SELECT m.* ` + filterFromSQL + ` ORDER BY m.stat_date DESC, m.vin ASC, m.protocol ASC LIMIT ? OFFSET ?) m` + enrichmentSQL + ` ORDER BY m.stat_date DESC, m.vin ASC, m.protocol ASC`,
|
||||
Args: args,
|
||||
CountText: `SELECT COUNT(*) ` + filterFromSQL,
|
||||
@@ -710,7 +711,7 @@ func buildMileageStatisticsBaseSQL(query url.Values) (string, []any) {
|
||||
dailyMileageExpression + ` AS daily_mileage_km, ` +
|
||||
pureHydrogenMileageExpression + ` AS pure_hydrogen_mileage_km, ` +
|
||||
`CASE WHEN MAX(COALESCE(h.mixed_mileage_km,0))>0 THEN MAX(h.mixed_mileage_km) ELSE ` + pureHydrogenMileageExpression + ` END AS hydrogen_matched_mileage_km, ` +
|
||||
`MAX(h.consumption_kg) AS hydrogen_consumption_kg, ` +
|
||||
`MAX(h.soc_balanced_consumption_kg) AS hydrogen_consumption_kg, ` +
|
||||
latestMileageExpression + ` AS latest_mileage_km ` +
|
||||
`FROM vehicle_daily_mileage m
|
||||
LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin
|
||||
@@ -917,3 +918,5 @@ func mustInt(value string) int {
|
||||
n, _ := strconv.Atoi(value)
|
||||
return n
|
||||
}
|
||||
|
||||
const dailyGeographyProjectionSQL = `COALESCE(g.province,''),COALESCE(g.city,''),COALESCE(g.region,''),COALESCE(DATE_FORMAT(g.location_time,'%Y-%m-%dT%H:%i:%s.%f+08:00'),''),CASE COALESCE(g.status,'PENDING') WHEN 'RESOLVED' THEN '已解析' WHEN 'NO_LOCATION' THEN '当天无有效定位' WHEN 'ERROR' THEN '解析失败,后台重试中' ELSE '后台待解析' END`
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
const nativeAlarmRuleID = "native-gb32960-alarm"
|
||||
|
||||
// Require the complete 0x07 unit: absent/invalid data must never clear an alarm.
|
||||
func nativeAlarmFields(record AlertStreamRecord) (map[string]json.RawMessage, int, bool, bool) {
|
||||
if record.Protocol != "GB32960" {
|
||||
return nil, 0, false, false
|
||||
}
|
||||
fields := make(map[string]json.RawMessage, 6)
|
||||
level, levelOK := alertStreamRawNumber(record.Fields["gb32960.alarm.max_alarm_level"])
|
||||
flag, flagOK := alertStreamRawNumber(record.Fields["gb32960.alarm.general_alarm_flag"])
|
||||
if !levelOK || !flagOK || level < 0 || level > 3 || math.Trunc(level) != level || flag < 0 || flag > math.MaxUint32 || math.Trunc(flag) != flag {
|
||||
return nil, 0, false, false
|
||||
}
|
||||
mask := nativeAlarm2016Mask
|
||||
if nativeAlarmVersion(record.Fields) == "V2025" {
|
||||
mask = math.MaxUint32
|
||||
}
|
||||
active := level > 0 || uint32(flag)&mask != 0
|
||||
if version := record.Fields["gb32960.header.version"]; len(version) > 0 {
|
||||
fields["gb32960.header.version"] = version
|
||||
}
|
||||
for _, name := range []string{"max_alarm_level", "general_alarm_flag", "battery_faults", "motor_faults", "engine_faults", "other_faults"} {
|
||||
key := "gb32960.alarm." + name
|
||||
raw := record.Fields[key]
|
||||
if name != "max_alarm_level" && name != "general_alarm_flag" {
|
||||
// The gateway's FIELDS contract serializes complex KV values as JSON strings.
|
||||
var encoded string
|
||||
if json.Unmarshal(raw, &encoded) == nil {
|
||||
raw = json.RawMessage(encoded)
|
||||
}
|
||||
var codes []string
|
||||
if len(raw) == 0 || string(raw) == "null" || json.Unmarshal(raw, &codes) != nil || codes == nil {
|
||||
return nil, 0, false, false
|
||||
}
|
||||
active = active || len(codes) > 0
|
||||
}
|
||||
fields[key] = raw
|
||||
}
|
||||
return fields, int(level), active, true
|
||||
}
|
||||
|
||||
// Runs in the same transaction as the Kafka checkpoint, independently of rules.
|
||||
func recordNativeAlarmsTx(ctx context.Context, tx *sql.Tx, records []AlertStreamRecord, result *AlertEvaluationResult) error {
|
||||
eligible := make([]AlertStreamRecord, 0)
|
||||
for _, record := range records {
|
||||
if _, _, _, ok := nativeAlarmFields(record); ok && !record.Late {
|
||||
eligible = append(eligible, record)
|
||||
}
|
||||
}
|
||||
if len(eligible) == 0 {
|
||||
return nil
|
||||
}
|
||||
metadata, err := loadAlertStreamVehicleMetadata(ctx, tx, eligible)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, record := range eligible {
|
||||
fields, level, active, _ := nativeAlarmFields(record)
|
||||
// Serialize concurrent consumers for this VIN, including the first observation.
|
||||
if _, err = tx.ExecContext(ctx, `INSERT IGNORE INTO vehicle_native_alarm_state(vin,last_event_at) VALUES(?,'1970-01-01 00:00:00')`, record.VIN); err != nil {
|
||||
return err
|
||||
}
|
||||
var lastAt time.Time
|
||||
var eventID string
|
||||
if err = tx.QueryRowContext(ctx, `SELECT last_event_at,active_event_id FROM vehicle_native_alarm_state WHERE vin=? FOR UPDATE`, record.VIN).Scan(&lastAt, &eventID); err != nil {
|
||||
return err
|
||||
}
|
||||
if !record.EventAt.After(lastAt) {
|
||||
result.LateObservations++
|
||||
continue
|
||||
}
|
||||
if active && eventID == "" {
|
||||
eventID, err = newAlertID("alert")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
severity := "minor"
|
||||
if level == 2 {
|
||||
severity = "major"
|
||||
}
|
||||
if level == 3 {
|
||||
severity = "critical"
|
||||
}
|
||||
description, _ := DescribeNativeAlarm(fields)
|
||||
title := description.Title
|
||||
rule := AlertRule{ID: nativeAlarmRuleID, Name: title, Severity: severity, TriggerType: "metric", Metric: "alarm_active", Operator: "eq", Threshold: 1}
|
||||
item := alertStreamEvidence(record, metadata[record.VIN], nil)
|
||||
shanghai := time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
item.EventAt = record.EventAt.In(shanghai).Format("2006-01-02 15:04:05.999")
|
||||
item.ReceivedAt = record.ReceivedAt.In(shanghai).Format("2006-01-02 15:04:05.999")
|
||||
query, args := buildAlertEventInsert(eventID, nativeAlarmRuleID+"|"+record.VIN+"|GB32960", rule, item, 1)
|
||||
// Missing coordinates must not appear as a real position at (0,0).
|
||||
if !item.HasLocation {
|
||||
args[17], args[18], args[19] = "", nil, nil
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, query, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
payload, marshalErr := json.Marshal(fields)
|
||||
if marshalErr != nil {
|
||||
return marshalErr
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_native_alarm_evidence(event_id,fields_json) VALUES(?,?)`, eventID, string(payload)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_event_action(event_id,action,from_status,to_status,actor,note) VALUES(?,'trigger','','unprocessed','native-gb32960','车辆上报原生告警')`, eventID); err != nil {
|
||||
return err
|
||||
}
|
||||
result.Opened++
|
||||
} else if !active && eventID != "" {
|
||||
update, updateErr := tx.ExecContext(ctx, `UPDATE vehicle_alert_event SET status='recovered',recovered_at=?,version=version+1 WHERE id=? AND status IN ('unprocessed','processing')`, record.EventAt, eventID)
|
||||
if updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
count, countErr := update.RowsAffected()
|
||||
if countErr != nil {
|
||||
return countErr
|
||||
}
|
||||
if count > 0 {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_event_action(event_id,action,from_status,to_status,actor,note) VALUES(?,'recover','','recovered','native-gb32960','车辆上报原生告警解除')`, eventID); err != nil {
|
||||
return err
|
||||
}
|
||||
result.Recovered++
|
||||
}
|
||||
eventID = ""
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE vehicle_native_alarm_state SET last_event_at=?,active_event_id=? WHERE vin=?`, record.EventAt, eventID, record.VIN); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GB/T 32960.3-2016, table 18. Bits 19–31 are reserved, not faults.
|
||||
var nativeAlarmBitNames = [...]string{
|
||||
"温度差异报警", "电池高温报警", "车载储能装置类型过压报警", "车载储能装置类型欠压报警",
|
||||
"SOC低报警", "单体电池过压报警", "单体电池欠压报警", "SOC过高报警", "SOC跳变报警",
|
||||
"可充电储能系统不匹配报警", "电池单体一致性差报警", "绝缘报警", "DC-DC温度报警", "制动系统报警",
|
||||
"DC-DC状态报警", "驱动电机控制器温度报警", "高压互锁状态报警", "驱动电机温度报警", "车载储能装置类型过充报警",
|
||||
}
|
||||
|
||||
const nativeAlarm2016Mask uint32 = (1 << 19) - 1
|
||||
|
||||
// NativeAlarmDescription is also used by the repair command so historical and
|
||||
// newly ingested events use the same interpretation of their saved evidence.
|
||||
type NativeAlarmDescription struct {
|
||||
Title string `json:"title"`
|
||||
Names []string `json:"names"`
|
||||
ReservedBits []int `json:"reservedBits"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
func nativeAlarmVersion(fields map[string]json.RawMessage) string {
|
||||
var version string
|
||||
_ = json.Unmarshal(fields["gb32960.header.version"], &version)
|
||||
return version
|
||||
}
|
||||
|
||||
func DescribeNativeAlarm(fields map[string]json.RawMessage) (NativeAlarmDescription, bool) {
|
||||
fields, level, active, valid := nativeAlarmFields(AlertStreamRecord{Protocol: "GB32960", Fields: fields})
|
||||
result := NativeAlarmDescription{Names: []string{}, ReservedBits: []int{}, Active: active}
|
||||
if !valid {
|
||||
return result, false
|
||||
}
|
||||
value, _ := alertStreamRawNumber(fields["gb32960.alarm.general_alarm_flag"])
|
||||
flag := uint32(value)
|
||||
for bit, name := range nativeAlarmBitNames {
|
||||
if flag&(uint32(1)<<bit) != 0 {
|
||||
result.Names = append(result.Names, name)
|
||||
}
|
||||
}
|
||||
for bit := 19; bit < 32; bit++ {
|
||||
if flag&(uint32(1)<<bit) == 0 {
|
||||
continue
|
||||
}
|
||||
if nativeAlarmVersion(fields) == "V2025" {
|
||||
result.Names = append(result.Names, fmt.Sprintf("扩展报警位 bit%d(待匹配2025版定义)", bit))
|
||||
} else {
|
||||
result.ReservedBits = append(result.ReservedBits, bit)
|
||||
}
|
||||
}
|
||||
for _, group := range []struct{ key, label string }{
|
||||
{"battery_faults", "可充电储能装置"}, {"motor_faults", "驱动电机"}, {"engine_faults", "发动机"}, {"other_faults", "其他"},
|
||||
} {
|
||||
var codes []string
|
||||
_ = json.Unmarshal(fields["gb32960.alarm."+group.key], &codes)
|
||||
for _, code := range codes {
|
||||
result.Names = append(result.Names, group.label+"故障码 "+code+"(厂商定义)")
|
||||
}
|
||||
}
|
||||
if len(result.Names) == 0 && level > 0 {
|
||||
result.Names = append(result.Names, fmt.Sprintf("%d级故障(车辆未上报具体故障项)", level))
|
||||
}
|
||||
if len(result.Names) == 0 {
|
||||
result.Title = "无标准告警"
|
||||
return result, true
|
||||
}
|
||||
result.Title = strings.Join(result.Names, "、")
|
||||
// Persist a useful, bounded title in the existing VARCHAR(80) column; detail
|
||||
// keeps every name and code, including concurrent faults.
|
||||
if len([]rune(result.Title)) > 80 {
|
||||
first := []rune(result.Names[0])
|
||||
if len(first) > 55 {
|
||||
first = first[:55]
|
||||
}
|
||||
result.Title = fmt.Sprintf("%s 等%d项告警", string(first), len(result.Names))
|
||||
}
|
||||
return result, true
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func nativeAlarmRecord(level, flag string) AlertStreamRecord {
|
||||
fields := map[string]json.RawMessage{"gb32960.alarm.max_alarm_level": json.RawMessage(level), "gb32960.alarm.general_alarm_flag": json.RawMessage(flag)}
|
||||
for _, name := range []string{"battery_faults", "motor_faults", "engine_faults", "other_faults"} {
|
||||
fields["gb32960.alarm."+name] = json.RawMessage(`[]`)
|
||||
}
|
||||
return AlertStreamRecord{Protocol: "GB32960", VIN: "VIN1", SourceEventID: "native-source", EventAt: time.Date(2026, 9, 17, 1, 0, 0, 0, time.UTC), ReceivedAt: time.Date(2026, 9, 17, 1, 0, 1, 0, time.UTC), Fields: fields, Valid: true}
|
||||
}
|
||||
|
||||
func TestNativeAlarmFields(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, level, flag string
|
||||
active, valid bool
|
||||
}{
|
||||
{"clear", "0", `"0x00000000"`, false, true},
|
||||
{"level", "3", `0`, true, true},
|
||||
{"flag", "0", `"0x00000004"`, true, true},
|
||||
{"reserved only", "0", `"0x00300000"`, false, true},
|
||||
{"reserved and insulation", "0", `"0x00380800"`, true, true},
|
||||
{"abnormal", "254", `0`, false, false},
|
||||
{"invalid", "255", `0`, false, false},
|
||||
{"fraction", "1.5", `0`, false, false},
|
||||
{"bad bitmap", "0", `"garbage"`, false, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _, active, valid := nativeAlarmFields(nativeAlarmRecord(tc.level, tc.flag))
|
||||
if active != tc.active || valid != tc.valid {
|
||||
t.Fatalf("active=%v valid=%v", active, valid)
|
||||
}
|
||||
})
|
||||
}
|
||||
encodedRecord := nativeAlarmRecord(`"2"`, `"0x00000004"`)
|
||||
for _, name := range []string{"battery_faults", "motor_faults", "engine_faults", "other_faults"} {
|
||||
encodedRecord.Fields["gb32960.alarm."+name] = json.RawMessage(`"[]"`)
|
||||
}
|
||||
encodedRecord.Fields["gb32960.alarm.motor_faults"] = json.RawMessage(`"[\"0x00000001\"]"`)
|
||||
fields, _, active, valid := nativeAlarmFields(encodedRecord)
|
||||
if !valid || !active || string(fields["gb32960.alarm.motor_faults"]) != `["0x00000001"]` {
|
||||
t.Fatalf("gateway string-encoded fault arrays lost: %v", fields)
|
||||
}
|
||||
record := nativeAlarmRecord("0", "0")
|
||||
record.Fields["gb32960.alarm.motor_faults"] = json.RawMessage(`["0x00000001"]`)
|
||||
if _, _, active, valid := nativeAlarmFields(record); !active || !valid {
|
||||
t.Fatal("fault code alone must activate alarm")
|
||||
}
|
||||
delete(record.Fields, "gb32960.alarm.battery_faults")
|
||||
if _, _, _, valid := nativeAlarmFields(record); valid {
|
||||
t.Fatal("partial unit must not activate or recover")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeAlarmLifecycleWithoutRules(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, existing string
|
||||
clear, stale, closed bool
|
||||
}{
|
||||
{name: "open without rules"},
|
||||
{name: "continuous alarm deduplicated", existing: "existing"},
|
||||
{name: "recover", existing: "existing", clear: true},
|
||||
{name: "manual closure preserved", existing: "existing", clear: true, closed: true},
|
||||
{name: "out of order ignored", existing: "existing", clear: true, stale: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
record := nativeAlarmRecord("2", `"0x00000004"`)
|
||||
if tc.clear {
|
||||
record = nativeAlarmRecord("0", "0")
|
||||
}
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE enabled=1 AND archived_at IS NULL AND metric<>'freshness_sec' ORDER BY id FOR UPDATE`)).WillReturnRows(sqlmock.NewRows([]string{"id"}))
|
||||
mock.ExpectQuery(`SELECT v.vin,`).WithArgs("VIN1").WillReturnRows(sqlmock.NewRows([]string{"vin", "plate", "oem", "model", "company"}).AddRow("VIN1", "粤A12345", "", "", ""))
|
||||
mock.ExpectExec(`INSERT IGNORE INTO vehicle_native_alarm_state`).WithArgs("VIN1").WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
last := record.EventAt.Add(-time.Second)
|
||||
if tc.stale {
|
||||
last = record.EventAt.Add(time.Second)
|
||||
}
|
||||
mock.ExpectQuery(`SELECT last_event_at,active_event_id`).WithArgs("VIN1").WillReturnRows(sqlmock.NewRows([]string{"last_event_at", "active_event_id"}).AddRow(last, tc.existing))
|
||||
if !tc.stale {
|
||||
if tc.existing == "" {
|
||||
mock.ExpectExec(regexp.QuoteMeta(alertEventInsertSQL)).WithArgs(sqlmock.AnyArg(), nativeAlarmRuleID+"|VIN1|GB32960", nativeAlarmRuleID, "车载储能装置类型过压报警", 0, "major", "metric", "VIN1", "粤A12345", "GB32960", "alarm_active", "eq", float64(1), float64(1), float64(0), "", 0, "", nil, nil, "native-source", sqlmock.AnyArg(), sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
payload, _ := json.Marshal(record.Fields)
|
||||
mock.ExpectExec(`INSERT INTO vehicle_native_alarm_evidence`).WithArgs(sqlmock.AnyArg(), string(payload)).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec(`INSERT INTO vehicle_alert_event_action`).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
} else if tc.clear {
|
||||
count := int64(1)
|
||||
if tc.closed {
|
||||
count = 0
|
||||
}
|
||||
mock.ExpectExec(`UPDATE vehicle_alert_event SET status='recovered'`).WithArgs(record.EventAt, tc.existing).WillReturnResult(sqlmock.NewResult(0, count))
|
||||
if !tc.closed {
|
||||
mock.ExpectExec(`INSERT INTO vehicle_alert_event_action`).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
}
|
||||
}
|
||||
mock.ExpectExec(`UPDATE vehicle_native_alarm_state`).WithArgs(record.EventAt, sqlmock.AnyArg(), "VIN1").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
}
|
||||
mock.ExpectCommit()
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := evaluateAlertStreamRecordsTx(t.Context(), tx, []AlertStreamRecord{record}, record.ReceivedAt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.RulesEvaluated != 0 {
|
||||
t.Fatal("native alarms require no automation")
|
||||
}
|
||||
if tc.existing == "" && result.Opened != 1 {
|
||||
t.Fatalf("not opened: %+v", result)
|
||||
}
|
||||
if tc.existing != "" && result.Opened != 0 {
|
||||
t.Fatal("duplicate event")
|
||||
}
|
||||
if tc.clear && !tc.closed && !tc.stale && result.Recovered != 1 {
|
||||
t.Fatal("not recovered")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeAlarmLateAndMissingUnitsDoNotWrite(t *testing.T) {
|
||||
db, mock, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
late := nativeAlarmRecord("3", "1")
|
||||
late.Late = true
|
||||
missing := nativeAlarmRecord("0", "0")
|
||||
delete(missing.Fields, "gb32960.alarm.general_alarm_flag")
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectCommit()
|
||||
tx, _ := db.Begin()
|
||||
if err := recordNativeAlarmsTx(t.Context(), tx, []AlertStreamRecord{late, missing}, &AlertEvaluationResult{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeAlarmDescriptionDecodesSpecificFaultsAndReservedBits(t *testing.T) {
|
||||
record := nativeAlarmRecord("0", `"0x00380800"`)
|
||||
d, ok := DescribeNativeAlarm(record.Fields)
|
||||
if !ok || !d.Active || d.Title != "绝缘报警" || len(d.Names) != 1 || len(d.ReservedBits) != 3 || d.ReservedBits[0] != 19 {
|
||||
t.Fatalf("wrong description: %+v", d)
|
||||
}
|
||||
record = nativeAlarmRecord("0", `"0x00380000"`)
|
||||
d, ok = DescribeNativeAlarm(record.Fields)
|
||||
if !ok || d.Active || len(d.Names) != 0 {
|
||||
t.Fatalf("reserved bits became faults: %+v", d)
|
||||
}
|
||||
record = nativeAlarmRecord("2", `"0x00020810"`)
|
||||
record.Fields["gb32960.alarm.motor_faults"] = json.RawMessage(`"[\"0x12345678\"]"`)
|
||||
d, ok = DescribeNativeAlarm(record.Fields)
|
||||
if !ok || len(d.Names) != 4 || d.Names[0] != "SOC低报警" || d.Names[1] != "绝缘报警" || d.Names[2] != "驱动电机温度报警" || d.Names[3] != "驱动电机故障码 0x12345678(厂商定义)" {
|
||||
t.Fatalf("lost concurrent faults: %+v", d)
|
||||
}
|
||||
record = nativeAlarmRecord("3", `"0x00000000"`)
|
||||
d, ok = DescribeNativeAlarm(record.Fields)
|
||||
if !ok || !d.Active || d.Title != "3级故障(车辆未上报具体故障项)" {
|
||||
t.Fatalf("invented fault meaning: %+v", d)
|
||||
}
|
||||
for bit, name := range nativeAlarmBitNames {
|
||||
record = nativeAlarmRecord("0", fmt.Sprintf("%d", uint32(1)<<bit))
|
||||
d, ok = DescribeNativeAlarm(record.Fields)
|
||||
if !ok || !d.Active || d.Title != name {
|
||||
t.Fatalf("bit %d decoded as %+v", bit, d)
|
||||
}
|
||||
}
|
||||
record = nativeAlarmRecord("0", `"0x00300000"`)
|
||||
record.Fields["gb32960.header.version"] = json.RawMessage(`"V2025"`)
|
||||
d, ok = DescribeNativeAlarm(record.Fields)
|
||||
if !ok || !d.Active || len(d.Names) != 2 || len(d.ReservedBits) != 0 {
|
||||
t.Fatalf("2025 extension must not be silently discarded: %+v", d)
|
||||
}
|
||||
record = nativeAlarmRecord("2", `"0x0007FFFF"`)
|
||||
d, _ = DescribeNativeAlarm(record.Fields)
|
||||
if len(d.Names) != 19 || len([]rune(d.Title)) > 80 {
|
||||
t.Fatalf("title overflow or lost detail: %+v", d)
|
||||
}
|
||||
}
|
||||
@@ -1158,7 +1158,7 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P
|
||||
var evidenceAvailable int
|
||||
if err := rows.Scan(&row.VIN, &row.Plate, &row.Date, &row.StartMileageKm, &row.EndMileageKm, &row.DailyMileageKm, &row.PureHydrogenMileageKm,
|
||||
&hydrogen, &physicalRate, &balanced, &balancedRate, &pureElectric, &mixed, &socDelta, &chargeCount, &chargeEnergy, &refuelCount, &refuelAmount,
|
||||
&evidenceAvailable, &row.HydrogenQualityStatus, &row.HydrogenQualityReason, &row.HydrogenAlgorithmVersion, &row.Source); err != nil {
|
||||
&evidenceAvailable, &row.HydrogenQualityStatus, &row.HydrogenQualityReason, &row.HydrogenAlgorithmVersion, &row.Source, &row.Province, &row.City, &row.Region, &row.LocationTime, &row.LocationStatus); err != nil {
|
||||
return Page[DailyMileageRow]{}, err
|
||||
}
|
||||
if hydrogen.Valid {
|
||||
@@ -1237,7 +1237,8 @@ LIMIT 1`, vin, date)
|
||||
result.BatteryDischargeKWh = nullableFloatPointer(discharge)
|
||||
result.BatteryEquivalentKg = nullableFloatPointer(equivalent)
|
||||
result.SOCBalancedConsumptionKg = nullableFloatPointer(balanced)
|
||||
result.ConsumptionKgPer100Km = nullableFloatPointer(physicalRate)
|
||||
result.PhysicalConsumptionKgPer100Km = nullableFloatPointer(physicalRate)
|
||||
result.ConsumptionKgPer100Km = nullableFloatPointer(balancedRate)
|
||||
result.SOCBalancedKgPer100Km = nullableFloatPointer(balancedRate)
|
||||
result.RefuelAmountKg = nullableFloatPointer(refuelAmount)
|
||||
result.ChargeEnergyKWh = nullableFloatPointer(chargeEnergy)
|
||||
@@ -1267,7 +1268,7 @@ func (s *ProductionStore) MileageStatistics(ctx context.Context, query url.Value
|
||||
result := MileageStatistics{
|
||||
DateFrom: query.Get("dateFrom"), DateTo: query.Get("dateTo"),
|
||||
Trend: []MileageTrendPoint{}, Ranking: []MileageVehicleRank{},
|
||||
Evidence: "vehicle_daily_mileage(按车辆和日期去重;百公里氢耗按匹配车辆日的纯氢里程计算)/ vehicle_open_daily_energy(质量通过的日用氢量)/ vehicle_realtime_location(最新里程表)",
|
||||
Evidence: "vehicle_daily_mileage(按车辆和日期去重;百公里氢耗按匹配车辆日的纯氢里程计算)/ vehicle_open_daily_energy(质量通过的日修正用氢量)/ vehicle_realtime_location(最新里程表)",
|
||||
}
|
||||
summary := buildMileageStatisticsSummarySQL(query)
|
||||
if err := s.db.QueryRowContext(ctx, summary.Text, summary.Args...).Scan(
|
||||
|
||||
@@ -24,14 +24,14 @@ func TestProductionStoreDailyMileageReturnsSuspectHydrogenExportDetails(t *testi
|
||||
"hydrogen_consumption_kg", "hydrogen_consumption_kg_per_100km", "hydrogen_soc_balanced_kg", "hydrogen_soc_balanced_kg_per_100km",
|
||||
"pure_electric_mileage_km", "mixed_mileage_km", "battery_soc_delta_pct", "charge_count", "charge_energy_kwh",
|
||||
"refuel_count", "refuel_amount_kg", "hydrogen_evidence_available", "hydrogen_quality_status", "hydrogen_quality_reason",
|
||||
"hydrogen_algorithm_version", "protocol",
|
||||
"hydrogen_algorithm_version", "protocol", "province", "city", "region", "location_time", "location_status",
|
||||
}
|
||||
mock.ExpectQuery("SELECT m.vin").
|
||||
WithArgs(20, 0).
|
||||
WillReturnRows(sqlmock.NewRows(columns).AddRow(
|
||||
"LB9A32A29R0LS1423", "粤AGR9816", "2026-08-26", 23119.8, 23333.1, 213.3, 213.3,
|
||||
4.491, 2.106, 4.544, 2.130, 0.0, 213.3, -4.0, 1, 12.5,
|
||||
1, 8.976, 1, "SUSPECT", "疑似管路泄压", "PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5", "GB32960",
|
||||
1, 8.976, 1, "SUSPECT", "疑似管路泄压", "PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5", "GB32960", "广东省", "广州市", "华南", "2026-08-26T23:59:00+08:00", "已解析",
|
||||
))
|
||||
|
||||
page, err := (&ProductionStore{db: db}).DailyMileage(context.Background(), url.Values{"skipCount": {"1"}})
|
||||
@@ -42,6 +42,9 @@ func TestProductionStoreDailyMileageReturnsSuspectHydrogenExportDetails(t *testi
|
||||
t.Fatalf("items=%#v", page.Items)
|
||||
}
|
||||
row := page.Items[0]
|
||||
if row.Province != "广东省" || row.City != "广州市" || row.Region != "华南" || row.LocationStatus != "已解析" {
|
||||
t.Fatalf("missing persisted geography: %+v", row)
|
||||
}
|
||||
if row.HydrogenConsumptionKg == nil || *row.HydrogenConsumptionKg != 4.491 || row.HydrogenQualityStatus != "SUSPECT" || row.HydrogenQualityReason == "" ||
|
||||
row.ChargeCount == nil || *row.ChargeCount != 1 || row.ChargeEnergyKWh == nil || *row.ChargeEnergyKWh != 12.5 ||
|
||||
row.RefuelCount == nil || *row.RefuelCount != 1 || row.RefuelAmountKg == nil || *row.RefuelAmountKg != 8.976 {
|
||||
|
||||
@@ -693,7 +693,7 @@ func TestBuildMileageStatisticsSQLDeduplicatesVehicleDays(t *testing.T) {
|
||||
"GROUP BY m.vin, m.stat_date",
|
||||
"MAX(COALESCE(m.daily_mileage_km",
|
||||
"MAX(COALESCE(m.pure_hydrogen_mileage_km",
|
||||
"MAX(h.consumption_kg)",
|
||||
"MAX(h.soc_balanced_consumption_kg)",
|
||||
"COUNT(DISTINCT d.vin)",
|
||||
"SUM(d.daily_mileage_km)",
|
||||
"SUM(d.pure_hydrogen_mileage_km)",
|
||||
|
||||
@@ -5591,10 +5591,18 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail
|
||||
if err != nil {
|
||||
return Page[DailyMileageRow]{}, err
|
||||
}
|
||||
for index := range result.Items {
|
||||
row := &result.Items[index]
|
||||
row.HydrogenPhysicalConsumptionKg = row.HydrogenConsumptionKg
|
||||
row.HydrogenConsumptionKg = row.HydrogenSOCBalancedKg
|
||||
row.HydrogenConsumptionKgPer100Km = correctedDailyHydrogenRate(*row)
|
||||
}
|
||||
|
||||
if !hydrogenConsumptionAllowed(ctx) {
|
||||
for index := range result.Items {
|
||||
result.Items[index].PureHydrogenMileageKm = 0
|
||||
result.Items[index].HydrogenConsumptionKg = nil
|
||||
result.Items[index].HydrogenPhysicalConsumptionKg = nil
|
||||
result.Items[index].HydrogenConsumptionKgPer100Km = nil
|
||||
result.Items[index].HydrogenSOCBalancedKg = nil
|
||||
result.Items[index].HydrogenSOCBalancedKgPer100Km = nil
|
||||
@@ -6702,3 +6710,16 @@ func boolToInt(value bool) int {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// correctedDailyHydrogenRate keeps physical consumption available for audit,
|
||||
// but never uses it as a fallback for the displayed consumption rate.
|
||||
func correctedDailyHydrogenRate(row DailyMileageRow) *float64 {
|
||||
if row.HydrogenSOCBalancedKg == nil {
|
||||
return nil
|
||||
}
|
||||
mileage := row.PureHydrogenMileageKm
|
||||
if row.MixedMileageKm != nil && *row.MixedMileageKm > 0 {
|
||||
mileage = *row.MixedMileageKm
|
||||
}
|
||||
return hydrogenRatePer100Km(*row.HydrogenSOCBalancedKg, mileage, 1)
|
||||
}
|
||||
|
||||
@@ -629,7 +629,7 @@ func TestHydrogenConsumptionMetricsAreNotExposedToCustomerAccounts(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(daily.Items) != 1 || daily.Items[0].PureHydrogenMileageKm != 0 || daily.Items[0].HydrogenConsumptionKg != nil || daily.Items[0].HydrogenConsumptionKgPer100Km != nil ||
|
||||
daily.Items[0].ChargeEnergyKWh != nil || daily.Items[0].RefuelAmountKg != nil || daily.Items[0].HydrogenQualityStatus != "" || daily.Items[0].HydrogenEvidenceAvailable {
|
||||
daily.Items[0].HydrogenPhysicalConsumptionKg != nil || daily.Items[0].ChargeEnergyKWh != nil || daily.Items[0].RefuelAmountKg != nil || daily.Items[0].HydrogenQualityStatus != "" || daily.Items[0].HydrogenEvidenceAvailable {
|
||||
t.Fatalf("customer daily mileage exposed hydrogen metrics: %+v", daily.Items)
|
||||
}
|
||||
summary, err := service.MileageStatistics(customer, query)
|
||||
|
||||
Reference in New Issue
Block a user