fix(stats): map protocol mileage fields
This commit is contained in:
@@ -30,7 +30,6 @@ func BuildFieldsEnvelope(env envelope.FrameEnvelope) (envelope.FrameEnvelope, bo
|
|||||||
if !ok {
|
if !ok {
|
||||||
return envelope.FrameEnvelope{}, false
|
return envelope.FrameEnvelope{}, false
|
||||||
}
|
}
|
||||||
statFields := fieldsWithCanonicalStats(fields, env.Fields)
|
|
||||||
out := envelope.FrameEnvelope{
|
out := envelope.FrameEnvelope{
|
||||||
EventID: env.StableEventID() + ":fields",
|
EventID: env.StableEventID() + ":fields",
|
||||||
TraceID: env.TraceID,
|
TraceID: env.TraceID,
|
||||||
@@ -45,7 +44,7 @@ func BuildFieldsEnvelope(env envelope.FrameEnvelope) (envelope.FrameEnvelope, bo
|
|||||||
SourceEndpoint: env.SourceEndpoint,
|
SourceEndpoint: env.SourceEndpoint,
|
||||||
EventTimeMS: env.EventTimeMS,
|
EventTimeMS: env.EventTimeMS,
|
||||||
ReceivedAtMS: env.ReceivedAtMS,
|
ReceivedAtMS: env.ReceivedAtMS,
|
||||||
Fields: statFields,
|
Fields: fields,
|
||||||
ParsedFields: fields,
|
ParsedFields: fields,
|
||||||
ParsedFieldTypes: fieldTypes,
|
ParsedFieldTypes: fieldTypes,
|
||||||
Parsed: map[string]any{
|
Parsed: map[string]any{
|
||||||
@@ -57,22 +56,6 @@ func BuildFieldsEnvelope(env envelope.FrameEnvelope) (envelope.FrameEnvelope, bo
|
|||||||
return out, true
|
return out, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func fieldsWithCanonicalStats(mapped map[string]any, canonical map[string]any) map[string]any {
|
|
||||||
out := cloneAnyMap(mapped)
|
|
||||||
for _, key := range []string{
|
|
||||||
envelope.FieldTotalMileageKM,
|
|
||||||
envelope.FieldSpeedKMH,
|
|
||||||
envelope.FieldSOCPercent,
|
|
||||||
envelope.FieldLongitude,
|
|
||||||
envelope.FieldLatitude,
|
|
||||||
} {
|
|
||||||
if value, ok := canonical[key]; ok && value != nil {
|
|
||||||
out[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func EnsureParsedFields(env *envelope.FrameEnvelope) bool {
|
func EnsureParsedFields(env *envelope.FrameEnvelope) bool {
|
||||||
if env == nil {
|
if env == nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func TestRealtimeKVFieldsFromGB32960ParsedDomains(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildFieldsEnvelopeKeepsMappedAndCanonicalStatFields(t *testing.T) {
|
func TestBuildFieldsEnvelopeUsesOnlyProtocolMappedFieldNames(t *testing.T) {
|
||||||
fieldsEnv, ok := BuildFieldsEnvelope(envelope.FrameEnvelope{
|
fieldsEnv, ok := BuildFieldsEnvelope(envelope.FrameEnvelope{
|
||||||
Protocol: envelope.ProtocolGB32960,
|
Protocol: envelope.ProtocolGB32960,
|
||||||
VIN: "VIN001",
|
VIN: "VIN001",
|
||||||
@@ -65,9 +65,9 @@ func TestBuildFieldsEnvelopeKeepsMappedAndCanonicalStatFields(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("BuildFieldsEnvelope() should emit mapped fields")
|
t.Fatal("BuildFieldsEnvelope() should emit mapped fields")
|
||||||
}
|
}
|
||||||
for _, bareKey := range []string{"charge_status"} {
|
for _, bareKey := range []string{"charge_status", "soc_percent", "total_mileage_km"} {
|
||||||
if _, exists := fieldsEnv.Fields[bareKey]; exists {
|
if _, exists := fieldsEnv.Fields[bareKey]; exists {
|
||||||
t.Fatalf("fields envelope should not expose non-canonical bare env.Fields key %q: %#v", bareKey, fieldsEnv.Fields)
|
t.Fatalf("fields envelope should not expose non-protocol bare key %q: %#v", bareKey, fieldsEnv.Fields)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, mappedKey := range []string{
|
for _, mappedKey := range []string{
|
||||||
@@ -79,14 +79,6 @@ func TestBuildFieldsEnvelopeKeepsMappedAndCanonicalStatFields(t *testing.T) {
|
|||||||
t.Fatalf("fields envelope missing mapped key %q: %#v", mappedKey, fieldsEnv.Fields)
|
t.Fatalf("fields envelope missing mapped key %q: %#v", mappedKey, fieldsEnv.Fields)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, canonicalKey := range []string{envelope.FieldSOCPercent, envelope.FieldTotalMileageKM} {
|
|
||||||
if _, exists := fieldsEnv.Fields[canonicalKey]; !exists {
|
|
||||||
t.Fatalf("fields envelope should keep canonical stat key %q: %#v", canonicalKey, fieldsEnv.Fields)
|
|
||||||
}
|
|
||||||
if _, exists := fieldsEnv.ParsedFields[canonicalKey]; exists {
|
|
||||||
t.Fatalf("parsed fields should stay protocol-mapped without canonical duplicate %q: %#v", canonicalKey, fieldsEnv.ParsedFields)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRealtimeKVFieldsFromJT808ParsedFields(t *testing.T) {
|
func TestRealtimeKVFieldsFromJT808ParsedFields(t *testing.T) {
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ func SamplesFromEnvelope(env envelope.FrameEnvelope, loc *time.Location) ([]Metr
|
|||||||
if vin == "" {
|
if vin == "" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
totalMileage, ok := floatField(env, envelope.FieldTotalMileageKM)
|
totalMileage, ok := totalMileageKMFromEnvelope(env)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -125,6 +125,41 @@ func SamplesFromEnvelope(env envelope.FrameEnvelope, loc *time.Location) ([]Metr
|
|||||||
}}, nil
|
}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type mileageFieldMapping struct {
|
||||||
|
key string
|
||||||
|
scale float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func totalMileageKMFromEnvelope(env envelope.FrameEnvelope) (float64, bool) {
|
||||||
|
if value, ok := floatField(env, envelope.FieldTotalMileageKM); ok {
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
for _, mapping := range mileageMappingsByProtocol(env.Protocol) {
|
||||||
|
value, ok := floatField(env, mapping.key)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return value * mapping.scale, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func mileageMappingsByProtocol(protocol envelope.Protocol) []mileageFieldMapping {
|
||||||
|
switch protocol {
|
||||||
|
case envelope.ProtocolGB32960:
|
||||||
|
return []mileageFieldMapping{{key: "gb32960.vehicle.total_mileage_km", scale: 1}}
|
||||||
|
case envelope.ProtocolJT808:
|
||||||
|
return []mileageFieldMapping{{key: "jt808.location.total_mileage_km", scale: 1}}
|
||||||
|
case envelope.ProtocolYutongMQTT:
|
||||||
|
return []mileageFieldMapping{
|
||||||
|
{key: "yutong_mqtt.data.total_mileage", scale: 0.001},
|
||||||
|
{key: "yutong_mqtt.root.data.total_mileage", scale: 0.001},
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const upsertDailyMileageSQL = `
|
const upsertDailyMileageSQL = `
|
||||||
INSERT INTO vehicle_daily_mileage
|
INSERT INTO vehicle_daily_mileage
|
||||||
(vin, stat_date, protocol, daily_mileage_km,
|
(vin, stat_date, protocol, daily_mileage_km,
|
||||||
|
|||||||
@@ -40,6 +40,60 @@ func TestSamplesFromEnvelopeDerivesDailyMileageSample(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSamplesFromEnvelopeMapsProtocolMileageFields(t *testing.T) {
|
||||||
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
protocol envelope.Protocol
|
||||||
|
fields map[string]any
|
||||||
|
wantKM float64
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "gb32960",
|
||||||
|
protocol: envelope.ProtocolGB32960,
|
||||||
|
fields: map[string]any{"gb32960.vehicle.total_mileage_km": "38587"},
|
||||||
|
wantKM: 38587,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "jt808",
|
||||||
|
protocol: envelope.ProtocolJT808,
|
||||||
|
fields: map[string]any{"jt808.location.total_mileage_km": "12432.4"},
|
||||||
|
wantKM: 12432.4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "yutong mqtt meters",
|
||||||
|
protocol: envelope.ProtocolYutongMQTT,
|
||||||
|
fields: map[string]any{"yutong_mqtt.data.total_mileage": "120756000"},
|
||||||
|
wantKM: 120756,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "yutong mqtt root meters",
|
||||||
|
protocol: envelope.ProtocolYutongMQTT,
|
||||||
|
fields: map[string]any{"yutong_mqtt.root.data.total_mileage": float64(22858000)},
|
||||||
|
wantKM: 22858,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
samples, err := SamplesFromEnvelope(envelope.FrameEnvelope{
|
||||||
|
Protocol: tt.protocol,
|
||||||
|
VIN: "LNBVIN00000000001",
|
||||||
|
EventTimeMS: time.Date(2026, 7, 1, 8, 30, 0, 0, loc).UnixMilli(),
|
||||||
|
Fields: tt.fields,
|
||||||
|
}, loc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SamplesFromEnvelope() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(samples) != 1 {
|
||||||
|
t.Fatalf("sample count = %d", len(samples))
|
||||||
|
}
|
||||||
|
if samples[0].TotalMileageKM != tt.wantKM {
|
||||||
|
t.Fatalf("total mileage = %v, want %v", samples[0].TotalMileageKM, tt.wantKM)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSamplesFromEnvelopeSkipsMissingVIN(t *testing.T) {
|
func TestSamplesFromEnvelopeSkipsMissingVIN(t *testing.T) {
|
||||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||||
samples, err := SamplesFromEnvelope(envelope.FrameEnvelope{
|
samples, err := SamplesFromEnvelope(envelope.FrameEnvelope{
|
||||||
|
|||||||
Reference in New Issue
Block a user