chore: snapshot production code before Apple Design UI refinement
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
_ "github.com/taosdata/driver-go/v3/taosWS"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/dailygeo"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if e := run(); e != nil {
|
||||
log.Fatal(e)
|
||||
}
|
||||
}
|
||||
func run() error {
|
||||
mode := flag.String("mode", "status", "status, backfill, resolve, or live")
|
||||
from := flag.String("from", "", "first historical date, defaults to first hydrogen day")
|
||||
to := flag.String("to", "", "last historical date, defaults to yesterday")
|
||||
concurrency := flag.Int("concurrency", 3, "maximum concurrent geocoding requests (1-8)")
|
||||
flag.Parse()
|
||||
cfg := config.Load()
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
db, e := sql.Open("mysql", cfg.MySQLDSN)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer db.Close()
|
||||
db.SetMaxOpenConns(12)
|
||||
if *from == "" {
|
||||
if e = db.QueryRowContext(ctx, `SELECT COALESCE(DATE_FORMAT(MIN(stat_date),'%Y-%m-%d'),'') FROM vehicle_open_daily_energy WHERE energy_type='HYDROGEN'`).Scan(from); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
if *to == "" {
|
||||
*to = time.Now().In(dailygeo.Shanghai).AddDate(0, 0, -1).Format("2006-01-02")
|
||||
}
|
||||
first, e := time.ParseInLocation("2006-01-02", *from, dailygeo.Shanghai)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
last, e := time.ParseInLocation("2006-01-02", *to, dailygeo.Shanghai)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if first.After(last) {
|
||||
return fmt.Errorf("invalid date range")
|
||||
}
|
||||
if *mode == "status" {
|
||||
return printStatus(ctx, db, *from, *to)
|
||||
}
|
||||
if cfg.AMapAPIKey == "" {
|
||||
return fmt.Errorf("AMAP_API_KEY required")
|
||||
}
|
||||
td, e := sql.Open(cfg.TDengineDriver, cfg.TDengineDSN)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer td.Close()
|
||||
td.SetMaxOpenConns(2)
|
||||
w := dailygeo.New(db, td, cfg.TDengineDatabase, &dailygeo.AMap{Key: cfg.AMapAPIKey})
|
||||
w.Concurrency = *concurrency
|
||||
if *mode == "live" {
|
||||
w.Run(ctx)
|
||||
return ctx.Err()
|
||||
}
|
||||
if *mode != "backfill" && *mode != "resolve" {
|
||||
return fmt.Errorf("invalid mode")
|
||||
}
|
||||
return w.WithLock(ctx, "vehicle_daily_geography_backfill", func() error {
|
||||
if *mode == "backfill" {
|
||||
for d := first; !d.After(last); d = d.AddDate(0, 0, 1) {
|
||||
rctx, c := context.WithTimeout(ctx, 90*time.Second)
|
||||
n, e := w.RefreshDay(rctx, d.Format("2006-01-02"))
|
||||
c()
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
log.Printf("daily_geography_seed date=%s points=%d", d.Format("2006-01-02"), n)
|
||||
}
|
||||
}
|
||||
for {
|
||||
n, failed, e := w.ResolvePending(ctx, *from, *to, 1000)
|
||||
log.Printf("daily_geography_batch resolved=%d failed=%d error=%v", n, failed, e)
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if e != nil && failed == 0 {
|
||||
return e
|
||||
}
|
||||
if n+failed == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return printStatus(ctx, db, *from, *to)
|
||||
})
|
||||
}
|
||||
func printStatus(ctx context.Context, db *sql.DB, from, to string) error {
|
||||
rows, e := db.QueryContext(ctx, `SELECT status,COUNT(*) FROM vehicle_daily_geography WHERE stat_date BETWEEN ? AND ? GROUP BY status`, from, to)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer rows.Close()
|
||||
counts := map[string]int{}
|
||||
for rows.Next() {
|
||||
var s string
|
||||
var n int
|
||||
if e = rows.Scan(&s, &n); e != nil {
|
||||
return e
|
||||
}
|
||||
counts[s] = n
|
||||
}
|
||||
if e = rows.Err(); e != nil {
|
||||
return e
|
||||
}
|
||||
rows.Close()
|
||||
var missing int
|
||||
e = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_open_daily_energy h LEFT JOIN vehicle_daily_geography g ON g.vin=h.vin COLLATE utf8mb4_unicode_ci AND g.stat_date=h.stat_date WHERE h.energy_type='HYDROGEN' AND h.stat_date BETWEEN ? AND ? AND g.vin IS NULL`, from, to).Scan(&missing)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
return json.NewEncoder(os.Stdout).Encode(map[string]any{"from": from, "to": to, "counts": counts, "hydrogenDaysMissing": missing})
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Read-only production verification of the same repository used by HTTP APIs.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/openplatform"
|
||||
"math"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
start := flag.String("start", "2026-09-11", "first date")
|
||||
end := flag.String("end", "2026-09-15", "last date")
|
||||
vin := flag.String("vins", "", "VINs, empty = all current vehicles")
|
||||
flag.Parse()
|
||||
db, err := sql.Open("mysql", os.Getenv("MYSQL_DSN"))
|
||||
must(err)
|
||||
defer db.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
vins := strings.Split(*vin, ",")
|
||||
if *vin == "" {
|
||||
vins = nil
|
||||
rows, e := db.QueryContext(ctx, "SELECT DISTINCT vin FROM vehicle_identity_binding WHERE vin<>''")
|
||||
must(e)
|
||||
for rows.Next() {
|
||||
var v string
|
||||
must(rows.Scan(&v))
|
||||
vins = append(vins, v)
|
||||
}
|
||||
must(rows.Err())
|
||||
rows.Close()
|
||||
}
|
||||
r := openplatform.NewMySQLRepository(db)
|
||||
began := time.Now()
|
||||
priorities := []string{"GB32960", "YUTONG_MQTT"}
|
||||
all, err := r.ReconciledMileageRange(ctx, vins, *start, *end, priorities)
|
||||
must(err)
|
||||
first, e := time.Parse("2006-01-02", *start)
|
||||
must(e)
|
||||
last, e := time.Parse("2006-01-02", *end)
|
||||
must(e)
|
||||
mismatches := 0
|
||||
differences := 0
|
||||
normal := 0
|
||||
qualities := map[string]int{}
|
||||
samples := map[string]openplatform.DailyMileage{}
|
||||
for date := first; !date.After(last); date = date.AddDate(0, 0, 1) {
|
||||
d := date.Format("2006-01-02")
|
||||
single, e := r.ReconciledMileageRange(ctx, vins, d, d, priorities)
|
||||
must(e)
|
||||
for _, v := range vins {
|
||||
key := v + "\x00" + d
|
||||
value, ok := all[key]
|
||||
one, exists := single[key]
|
||||
if ok != exists || !reflect.DeepEqual(value, one) {
|
||||
differences++
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
qualities[value.DataQuality]++
|
||||
prev, hasPrev := all[v+"\x00"+date.AddDate(0, 0, -1).Format("2006-01-02")]
|
||||
if value.DataQuality == "" || value.DataQuality == "CARRIED_FORWARD" {
|
||||
normal++
|
||||
if hasPrev && math.Abs(value.TotalMileageKm-prev.TotalMileageKm-value.MileageKm) > 0.001 {
|
||||
mismatches++
|
||||
}
|
||||
}
|
||||
if len(vins) < 10 {
|
||||
samples[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
must(json.NewEncoder(os.Stdout).Encode(map[string]any{"vehicles": len(vins), "rows": len(all), "normal": normal, "qualities": qualities, "reconciliationMismatches": mismatches, "singleRangeDifferences": differences, "samples": samples, "elapsed": time.Since(began).String()}))
|
||||
if mismatches > 0 || differences > 0 {
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
func must(e error) {
|
||||
if e != nil {
|
||||
fmt.Fprintln(os.Stderr, e)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
)
|
||||
|
||||
func main() {
|
||||
apply := flag.Bool("apply", false, "apply corrections after writing a durable backup")
|
||||
backup := flag.String("backup", "", "new backup JSON path, required with --apply")
|
||||
flag.Parse()
|
||||
if *apply && *backup == "" {
|
||||
panic("--backup is required with --apply")
|
||||
}
|
||||
db, err := sql.Open("mysql", os.Getenv("MYSQL_DSN"))
|
||||
if err != nil {
|
||||
panic("invalid database configuration")
|
||||
}
|
||||
defer db.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
if err = run(ctx, db, *apply, *backup); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func rows(ctx context.Context, tx *sql.Tx, query string) ([]map[string]any, error) {
|
||||
r, err := tx.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer r.Close()
|
||||
columns, err := r.Columns()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := []map[string]any{}
|
||||
for r.Next() {
|
||||
values := make([]any, len(columns))
|
||||
targets := make([]any, len(columns))
|
||||
for i := range values {
|
||||
targets[i] = &values[i]
|
||||
}
|
||||
if err = r.Scan(targets...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := map[string]any{}
|
||||
for i, k := range columns {
|
||||
if b, ok := values[i].([]byte); ok {
|
||||
item[k] = string(b)
|
||||
} else {
|
||||
item[k] = values[i]
|
||||
}
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, r.Err()
|
||||
}
|
||||
|
||||
type correction struct {
|
||||
id, title, status string
|
||||
remove bool
|
||||
}
|
||||
|
||||
func run(ctx context.Context, db *sql.DB, apply bool, backup string) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
archive := map[string]any{"createdAt": time.Now().UTC().Format(time.RFC3339), "reason": "decode GB32960 table 18; remove reserved-only false alarms"}
|
||||
data := map[string][]map[string]any{}
|
||||
// Consumer must be stopped for --apply. Lock event rows as well so a concurrent
|
||||
// operator's action is serialized with the correction and fully backed up.
|
||||
for _, q := range []struct{ key, sql string }{
|
||||
{"events", `SELECT * FROM vehicle_alert_event WHERE rule_id='native-gb32960-alarm' ORDER BY id FOR UPDATE`},
|
||||
{"evidence", `SELECT n.* FROM vehicle_native_alarm_evidence n JOIN vehicle_alert_event e ON e.id=n.event_id WHERE e.rule_id='native-gb32960-alarm'`},
|
||||
{"actions", `SELECT a.* FROM vehicle_alert_event_action a JOIN vehicle_alert_event e ON e.id=a.event_id WHERE e.rule_id='native-gb32960-alarm'`},
|
||||
{"state", `SELECT s.* FROM vehicle_native_alarm_state s JOIN vehicle_alert_event e ON e.id=s.active_event_id WHERE e.rule_id='native-gb32960-alarm'`},
|
||||
{"notifications", `SELECT n.id,n.event_id FROM vehicle_alert_notification n JOIN vehicle_alert_event e ON e.id=n.event_id WHERE e.rule_id='native-gb32960-alarm'`},
|
||||
} {
|
||||
items, e := rows(ctx, tx, q.sql)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
data[q.key] = items
|
||||
archive[q.key] = items
|
||||
}
|
||||
evidence := map[string]string{}
|
||||
for _, row := range data["evidence"] {
|
||||
evidence[fmt.Sprint(row["event_id"])] = fmt.Sprint(row["fields_json"])
|
||||
}
|
||||
notified := map[string]bool{}
|
||||
for _, row := range data["notifications"] {
|
||||
notified[fmt.Sprint(row["event_id"])] = true
|
||||
}
|
||||
changes := []correction{}
|
||||
renamed, removed := 0, 0
|
||||
names := map[string]int{}
|
||||
for _, row := range data["events"] {
|
||||
id := fmt.Sprint(row["id"])
|
||||
var fields map[string]json.RawMessage
|
||||
if err = json.Unmarshal([]byte(evidence[id]), &fields); err != nil {
|
||||
return fmt.Errorf("event %s has no valid evidence; no changes applied", id)
|
||||
}
|
||||
description, valid := platform.DescribeNativeAlarm(fields)
|
||||
if !valid {
|
||||
return fmt.Errorf("event %s has unsupported evidence; no changes applied", id)
|
||||
}
|
||||
names[description.Title]++
|
||||
if !description.Active {
|
||||
if notified[id] {
|
||||
return fmt.Errorf("event %s has notifications; refusing automatic deletion", id)
|
||||
}
|
||||
changes = append(changes, correction{id: id, remove: true})
|
||||
removed++
|
||||
} else if fmt.Sprint(row["rule_name"]) != description.Title {
|
||||
changes = append(changes, correction{id: id, title: description.Title, status: fmt.Sprint(row["status"])})
|
||||
renamed++
|
||||
}
|
||||
}
|
||||
if apply && len(changes) > 0 {
|
||||
file, e := os.OpenFile(backup, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
e = json.NewEncoder(file).Encode(archive)
|
||||
if e == nil {
|
||||
e = file.Sync()
|
||||
}
|
||||
closeErr := file.Close()
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
for _, change := range changes {
|
||||
if change.remove {
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE vehicle_native_alarm_state SET active_event_id='' WHERE active_event_id=?`, change.id); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, table := range []string{"vehicle_alert_event_action", "vehicle_native_alarm_evidence"} {
|
||||
if _, err = tx.ExecContext(ctx, "DELETE FROM "+table+" WHERE event_id=?", change.id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM vehicle_alert_event WHERE id=? AND rule_id='native-gb32960-alarm'`, change.id); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE vehicle_alert_event SET rule_name=?,version=version+1 WHERE id=? AND rule_id='native-gb32960-alarm'`, change.title, change.id); 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(?,'repair',?,?,'native-alarm-repair','按GB/T 32960.3-2016表18修正具体告警名称,保留原始证据')`, change.id, change.status, change.status); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return json.NewEncoder(os.Stdout).Encode(map[string]any{"applied": apply, "scanned": len(data["events"]), "renamed": renamed, "removed": removed, "descriptions": names})
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func expectSnapshot(mock sqlmock.Sqlmock, notified bool) {
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(`SELECT \* FROM vehicle_alert_event`).WillReturnRows(sqlmock.NewRows([]string{"id", "rule_name", "status"}).AddRow("false-event", "车辆原生告警", "unprocessed").AddRow("real-event", "车辆原生告警", "processing"))
|
||||
payload := func(flag string) string {
|
||||
data := map[string]any{"gb32960.alarm.max_alarm_level": "0", "gb32960.alarm.general_alarm_flag": flag}
|
||||
for _, key := range []string{"battery_faults", "motor_faults", "engine_faults", "other_faults"} {
|
||||
data["gb32960.alarm."+key] = []string{}
|
||||
}
|
||||
b, _ := json.Marshal(data)
|
||||
return string(b)
|
||||
}
|
||||
mock.ExpectQuery(`SELECT n.\* FROM vehicle_native_alarm_evidence`).WillReturnRows(sqlmock.NewRows([]string{"event_id", "fields_json"}).AddRow("false-event", payload("0x00300000")).AddRow("real-event", payload("0x00380800")))
|
||||
mock.ExpectQuery(`SELECT a.\* FROM vehicle_alert_event_action`).WillReturnRows(sqlmock.NewRows([]string{"event_id", "action"}).AddRow("false-event", "trigger"))
|
||||
mock.ExpectQuery(`SELECT s.\* FROM vehicle_native_alarm_state`).WillReturnRows(sqlmock.NewRows([]string{"vin", "active_event_id"}).AddRow("VIN1", "false-event"))
|
||||
notifications := sqlmock.NewRows([]string{"id", "event_id"})
|
||||
if notified {
|
||||
notifications.AddRow(1, "false-event")
|
||||
}
|
||||
mock.ExpectQuery(`SELECT n.id,n.event_id`).WillReturnRows(notifications)
|
||||
}
|
||||
|
||||
func TestRepairDryRunNeverWrites(t *testing.T) {
|
||||
db, mock, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
expectSnapshot(mock, false)
|
||||
mock.ExpectRollback()
|
||||
if err := run(t.Context(), db, false, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestRepairBacksUpAndCorrectsOnlyNativeEvents(t *testing.T) {
|
||||
db, mock, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
expectSnapshot(mock, false)
|
||||
mock.ExpectExec(`UPDATE vehicle_native_alarm_state`).WithArgs("false-event").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`DELETE FROM vehicle_alert_event_action`).WithArgs("false-event").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`DELETE FROM vehicle_native_alarm_evidence`).WithArgs("false-event").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`DELETE FROM vehicle_alert_event WHERE id=\? AND rule_id='native-gb32960-alarm'`).WithArgs("false-event").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`UPDATE vehicle_alert_event SET rule_name=\?,version=version\+1 WHERE id=\? AND rule_id='native-gb32960-alarm'`).WithArgs("绝缘报警", "real-event").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`INSERT INTO vehicle_alert_event_action`).WithArgs("real-event", "processing", "processing").WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
path := filepath.Join(t.TempDir(), "backup.json")
|
||||
if err := run(t.Context(), db, true, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var archive map[string]json.RawMessage
|
||||
if err = json.Unmarshal(data, &archive); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{"events", "evidence", "actions", "state"} {
|
||||
if len(archive[key]) == 0 {
|
||||
t.Fatalf("missing backup %s", key)
|
||||
}
|
||||
}
|
||||
info, _ := os.Stat(path)
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Fatal("backup permissions")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestRepairRefusesToOverwriteBackup(t *testing.T) {
|
||||
db, mock, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
expectSnapshot(mock, false)
|
||||
mock.ExpectRollback()
|
||||
path := filepath.Join(t.TempDir(), "backup.json")
|
||||
if err := os.WriteFile(path, []byte("previous backup"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := run(t.Context(), db, true, path); err == nil {
|
||||
t.Fatal("overwrote backup")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestRepairRefusesToOrphanNotifications(t *testing.T) {
|
||||
db, mock, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
expectSnapshot(mock, true)
|
||||
mock.ExpectRollback()
|
||||
if err := run(t.Context(), db, true, filepath.Join(t.TempDir(), "backup.json")); err == nil {
|
||||
t.Fatal("deleted notified event")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/dailygeo"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/httpx"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/openplatform"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
@@ -69,6 +70,11 @@ func NewServer(cfg config.Config) http.Handler {
|
||||
log.Printf("production capacity-check probe enabled")
|
||||
}
|
||||
productionStore.WithAlertStreamConfig(cfg.AlertStreamMode, cfg.AlertStreamKafkaGroup)
|
||||
if cfg.DailyGeographyEnabled && tdengine != nil && cfg.AMapAPIKey != "" {
|
||||
geographyWorker := dailygeo.New(db, tdengine, cfg.TDengineDatabase, &dailygeo.AMap{Key: cfg.AMapAPIKey})
|
||||
geographyWorker.RequestInterval = 100 * time.Millisecond
|
||||
go geographyWorker.Run(context.Background())
|
||||
}
|
||||
store = productionStore
|
||||
storeErr = nil
|
||||
log.Printf("production mysql store enabled")
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DailyGeographyEnabled bool
|
||||
HTTPAddr string
|
||||
StaticDir string
|
||||
MySQLDSN string
|
||||
@@ -97,6 +98,7 @@ func Load() Config {
|
||||
RequestTimeout: time.Duration(envInt("REQUEST_TIMEOUT_MS", 5000)) * time.Millisecond,
|
||||
AMapWebJSKey: os.Getenv("AMAP_WEB_JS_KEY"),
|
||||
AMapAPIKey: os.Getenv("AMAP_API_KEY"),
|
||||
DailyGeographyEnabled: envBool("DAILY_GEOGRAPHY_ENABLED", false),
|
||||
AMapSecurityCode: os.Getenv("AMAP_SECURITY_JS_CODE"),
|
||||
AMapServiceHost: os.Getenv("AMAP_SECURITY_SERVICE_HOST"),
|
||||
PlatformRelease: os.Getenv("PLATFORM_RELEASE"),
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package dailygeo
|
||||
|
||||
import "math"
|
||||
|
||||
func WGS84ToGCJ02(longitude float64, latitude float64) (float64, float64) {
|
||||
if longitude < 72.004 || longitude > 137.8347 || latitude < 0.8293 || latitude > 55.8271 {
|
||||
return longitude, latitude
|
||||
}
|
||||
const semiMajorAxis = 6378245.0
|
||||
const eccentricitySquared = 0.006693421622965943
|
||||
longitudeOffset := transformGCJLongitude(longitude-105, latitude-35)
|
||||
latitudeOffset := transformGCJLatitude(longitude-105, latitude-35)
|
||||
radianLatitude := latitude / 180 * math.Pi
|
||||
magic := 1 - eccentricitySquared*math.Pow(math.Sin(radianLatitude), 2)
|
||||
squareRootMagic := math.Sqrt(magic)
|
||||
convertedLatitude := latitude + latitudeOffset*180/((semiMajorAxis*(1-eccentricitySquared))/(magic*squareRootMagic)*math.Pi)
|
||||
convertedLongitude := longitude + longitudeOffset*180/(semiMajorAxis/squareRootMagic*math.Cos(radianLatitude)*math.Pi)
|
||||
return convertedLongitude, convertedLatitude
|
||||
}
|
||||
|
||||
func transformGCJLatitude(longitude float64, latitude float64) float64 {
|
||||
value := -100 + 2*longitude + 3*latitude + 0.2*latitude*latitude + 0.1*longitude*latitude + 0.2*math.Sqrt(math.Abs(longitude))
|
||||
value += (20*math.Sin(6*longitude*math.Pi) + 20*math.Sin(2*longitude*math.Pi)) * 2 / 3
|
||||
value += (20*math.Sin(latitude*math.Pi) + 40*math.Sin(latitude/3*math.Pi)) * 2 / 3
|
||||
value += (160*math.Sin(latitude/12*math.Pi) + 320*math.Sin(latitude*math.Pi/30)) * 2 / 3
|
||||
return value
|
||||
}
|
||||
|
||||
func transformGCJLongitude(longitude float64, latitude float64) float64 {
|
||||
value := 300 + longitude + 2*latitude + 0.1*longitude*longitude + 0.1*longitude*latitude + 0.1*math.Sqrt(math.Abs(longitude))
|
||||
value += (20*math.Sin(6*longitude*math.Pi) + 20*math.Sin(2*longitude*math.Pi)) * 2 / 3
|
||||
value += (20*math.Sin(longitude*math.Pi) + 40*math.Sin(longitude/3*math.Pi)) * 2 / 3
|
||||
value += (150*math.Sin(longitude/12*math.Pi) + 300*math.Sin(longitude/30*math.Pi)) * 2 / 3
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package dailygeo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Address struct{ Province, City, Region, Adcode string }
|
||||
type Resolver interface {
|
||||
Resolve(context.Context, float64, float64) (Address, error)
|
||||
}
|
||||
type AMap struct {
|
||||
Key, BaseURL string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
func (a *AMap) Resolve(ctx context.Context, lng, lat float64) (Address, error) {
|
||||
if a.Key == "" {
|
||||
return Address{}, fmt.Errorf("AMAP_API_KEY is not configured")
|
||||
}
|
||||
base := a.BaseURL
|
||||
if base == "" {
|
||||
base = "https://restapi.amap.com"
|
||||
}
|
||||
u, e := url.Parse(base + "/v3/geocode/regeo")
|
||||
if e != nil {
|
||||
return Address{}, fmt.Errorf("invalid geocoder URL")
|
||||
}
|
||||
x, y := WGS84ToGCJ02(lng, lat)
|
||||
q := u.Query()
|
||||
q.Set("key", a.Key)
|
||||
q.Set("location", fmt.Sprintf("%.6f,%.6f", x, y))
|
||||
q.Set("extensions", "base")
|
||||
q.Set("radius", "1000")
|
||||
u.RawQuery = q.Encode()
|
||||
req, e := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if e != nil {
|
||||
return Address{}, fmt.Errorf("invalid geocoder request")
|
||||
}
|
||||
client := a.Client
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
resp, e := client.Do(req)
|
||||
if e != nil {
|
||||
return Address{}, fmt.Errorf("geocoder request failed")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return Address{}, fmt.Errorf("geocoder HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Status string
|
||||
Infocode string
|
||||
Regeocode struct {
|
||||
AddressComponent struct{ Province, City, District, Adcode json.RawMessage } `json:"addressComponent"`
|
||||
}
|
||||
}
|
||||
if e = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); e != nil {
|
||||
return Address{}, fmt.Errorf("invalid geocoder response")
|
||||
}
|
||||
if body.Status != "1" {
|
||||
return Address{}, fmt.Errorf("geocoder rejected: %s", body.Infocode)
|
||||
}
|
||||
p, c := textValue(body.Regeocode.AddressComponent.Province), textValue(body.Regeocode.AddressComponent.City)
|
||||
if c == "" {
|
||||
switch p {
|
||||
case "北京市", "上海市", "天津市", "重庆市", "香港特别行政区", "澳门特别行政区":
|
||||
c = p
|
||||
}
|
||||
}
|
||||
// Province-administered county-level cities (for example 潜江市) are
|
||||
// returned in district, with city=[], by AMap.
|
||||
if c == "" && p != "" {
|
||||
district := textValue(body.Regeocode.AddressComponent.District)
|
||||
if strings.HasSuffix(district, "市") {
|
||||
c = district
|
||||
}
|
||||
}
|
||||
if p == "" || c == "" {
|
||||
return Address{}, fmt.Errorf("geocoder returned incomplete province/city")
|
||||
}
|
||||
return Address{p, c, Region(p), textValue(body.Regeocode.AddressComponent.Adcode)}, nil
|
||||
}
|
||||
func textValue(raw json.RawMessage) string {
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
return s
|
||||
}
|
||||
var ss []string
|
||||
if json.Unmarshal(raw, &ss) == nil {
|
||||
return strings.Join(ss, "")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func Region(province string) string {
|
||||
groups := []struct {
|
||||
name string
|
||||
provinces []string
|
||||
}{
|
||||
{"华东", []string{"上海", "江苏", "浙江", "安徽", "福建", "江西", "山东", "台湾"}},
|
||||
{"华北", []string{"北京", "天津", "河北", "山西", "内蒙古"}},
|
||||
{"华中", []string{"河南", "湖北", "湖南"}},
|
||||
{"华南", []string{"广东", "广西", "海南", "香港", "澳门"}},
|
||||
{"东北", []string{"辽宁", "吉林", "黑龙江"}},
|
||||
{"西南", []string{"重庆", "四川", "贵州", "云南", "西藏"}},
|
||||
{"西北", []string{"陕西", "甘肃", "青海", "宁夏", "新疆"}},
|
||||
}
|
||||
for _, g := range groups {
|
||||
for _, p := range g.provinces {
|
||||
if strings.HasPrefix(province, p) {
|
||||
return g.name
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package dailygeo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var Shanghai = time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
var identifier = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
|
||||
type Point struct {
|
||||
VIN, Protocol string
|
||||
Time time.Time
|
||||
Longitude, Latitude float64
|
||||
}
|
||||
type Worker struct {
|
||||
RequestInterval time.Duration
|
||||
DB, TD *sql.DB
|
||||
Database string
|
||||
Resolver Resolver
|
||||
Concurrency int
|
||||
requestMu sync.Mutex
|
||||
nextRequest time.Time
|
||||
}
|
||||
|
||||
func New(db, td *sql.DB, database string, r Resolver) *Worker {
|
||||
return &Worker{DB: db, TD: td, Database: database, Resolver: r, Concurrency: 3}
|
||||
}
|
||||
func validPoint(p Point) bool {
|
||||
return p.VIN != "" && !(p.Longitude == -0.999999 && p.Latitude == -0.999999) && !p.Time.IsZero() && p.Longitude != 0 && p.Latitude != 0 && !math.IsNaN(p.Longitude) && !math.IsNaN(p.Latitude) && math.Abs(p.Longitude) <= 180 && math.Abs(p.Latitude) <= 90
|
||||
}
|
||||
func newer(a, b Point) bool {
|
||||
return a.Time.After(b.Time) || (a.Time.Equal(b.Time) && a.Protocol < b.Protocol)
|
||||
}
|
||||
|
||||
// Last() is applied only to non-null valid coordinates, partitioned by VIN and
|
||||
// protocol. All selected columns therefore refer to the same final source row.
|
||||
func (w *Worker) RefreshDay(ctx context.Context, date string) (int, error) {
|
||||
day, e := time.ParseInLocation("2006-01-02", date, Shanghai)
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
if w.TD == nil || !identifier.MatchString(w.Database) {
|
||||
return 0, fmt.Errorf("historical location store unavailable")
|
||||
}
|
||||
q := fmt.Sprintf(`SELECT vin,protocol,LAST(ts),LAST(longitude),LAST(latitude) FROM %s.vehicle_locations WHERE ts >= '%s' AND ts < '%s' AND NOT (longitude = -0.999999 AND latitude = -0.999999) AND longitude <> 0 AND longitude BETWEEN -180 AND 180 AND latitude <> 0 AND latitude BETWEEN -90 AND 90 PARTITION BY vin,protocol`, w.Database, day.Format(time.RFC3339), day.AddDate(0, 0, 1).Format(time.RFC3339))
|
||||
rows, e := w.TD.QueryContext(ctx, q)
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
points := map[string]Point{}
|
||||
for rows.Next() {
|
||||
var p Point
|
||||
if e = rows.Scan(&p.VIN, &p.Protocol, &p.Time, &p.Longitude, &p.Latitude); e != nil {
|
||||
rows.Close()
|
||||
return 0, e
|
||||
}
|
||||
if !validPoint(p) || p.Time.In(Shanghai).Format("2006-01-02") != date {
|
||||
continue
|
||||
}
|
||||
if old, ok := points[p.VIN]; !ok || newer(p, old) {
|
||||
points[p.VIN] = p
|
||||
}
|
||||
}
|
||||
e = rows.Err()
|
||||
rows.Close()
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
// Repair the device's invalid-location sentinel before applying real points.
|
||||
// Clearing its timestamp allows an earlier valid point from this day to win.
|
||||
_, e = w.DB.ExecContext(ctx, `UPDATE vehicle_daily_geography SET longitude=NULL,latitude=NULL,location_time=NULL,province='',city='',region='',adcode='',status='NO_LOCATION',attempts=0,last_error='',next_attempt_at=NULL,resolved_at=NULL WHERE stat_date=? AND longitude=-0.999999 AND latitude=-0.999999`, date)
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
for _, p := range points {
|
||||
if e = w.SavePoint(ctx, p); e != nil {
|
||||
return 0, e
|
||||
}
|
||||
}
|
||||
// A successful historical lookup with no point is distinct from an unavailable
|
||||
// history service. Never record NO_LOCATION when the lookup itself failed.
|
||||
_, e = w.DB.ExecContext(ctx, `INSERT IGNORE INTO vehicle_daily_geography(vin,stat_date,status) SELECT vin,stat_date,'NO_LOCATION' FROM vehicle_open_daily_energy WHERE energy_type='HYDROGEN' AND stat_date=?`, date)
|
||||
return len(points), e
|
||||
}
|
||||
|
||||
const savePointSQL = `INSERT INTO vehicle_daily_geography(vin,stat_date,longitude,latitude,location_time,source_protocol,status) VALUES(?,?,?,?,?,?,'PENDING') ON DUPLICATE KEY UPDATE
|
||||
province=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',province),
|
||||
city=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',city),
|
||||
region=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',region),
|
||||
adcode=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',adcode),
|
||||
status=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'PENDING',status),
|
||||
attempts=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),0,attempts),
|
||||
next_attempt_at=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),NULL,next_attempt_at),
|
||||
last_error=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',last_error),
|
||||
resolved_at=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),NULL,resolved_at),
|
||||
longitude=IF(location_time IS NULL OR VALUES(location_time)>location_time,VALUES(longitude),longitude),
|
||||
latitude=IF(location_time IS NULL OR VALUES(location_time)>location_time,VALUES(latitude),latitude),
|
||||
source_protocol=IF(location_time IS NULL OR VALUES(location_time)>location_time,VALUES(source_protocol),source_protocol),
|
||||
location_time=IF(location_time IS NULL OR VALUES(location_time)>location_time,VALUES(location_time),location_time)`
|
||||
|
||||
func (w *Worker) SavePoint(ctx context.Context, p Point) error {
|
||||
if !validPoint(p) {
|
||||
return fmt.Errorf("invalid historical location")
|
||||
}
|
||||
_, e := w.DB.ExecContext(ctx, savePointSQL, p.VIN, p.Time.In(Shanghai).Format("2006-01-02"), math.Round(p.Longitude*1e6)/1e6, math.Round(p.Latitude*1e6)/1e6, p.Time.In(Shanghai), p.Protocol)
|
||||
return e
|
||||
}
|
||||
|
||||
type pending struct {
|
||||
VIN, Date string
|
||||
Longitude, Latitude float64
|
||||
}
|
||||
|
||||
func (w *Worker) ResolvePending(ctx context.Context, from, to string, limit int) (int, int, error) {
|
||||
if limit <= 0 || limit > 5000 {
|
||||
limit = 500
|
||||
}
|
||||
rows, e := w.DB.QueryContext(ctx, `SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),longitude,latitude FROM vehicle_daily_geography WHERE stat_date BETWEEN ? AND ? AND status IN ('PENDING','ERROR') AND (next_attempt_at IS NULL OR next_attempt_at<=NOW(3)) ORDER BY stat_date DESC,vin LIMIT ?`, from, to, limit)
|
||||
if e != nil {
|
||||
return 0, 0, e
|
||||
}
|
||||
items := []pending{}
|
||||
for rows.Next() {
|
||||
var p pending
|
||||
if e = rows.Scan(&p.VIN, &p.Date, &p.Longitude, &p.Latitude); e != nil {
|
||||
rows.Close()
|
||||
return 0, 0, e
|
||||
}
|
||||
items = append(items, p)
|
||||
}
|
||||
e = rows.Err()
|
||||
rows.Close()
|
||||
if e != nil {
|
||||
return 0, 0, e
|
||||
}
|
||||
jobs := make(chan pending, len(items))
|
||||
for _, p := range items {
|
||||
jobs <- p
|
||||
}
|
||||
close(jobs)
|
||||
count := w.Concurrency
|
||||
if count < 1 {
|
||||
count = 1
|
||||
}
|
||||
if count > 8 {
|
||||
count = 8
|
||||
}
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
ok, failed := 0, 0
|
||||
var first error
|
||||
for i := 0; i < count; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for p := range jobs {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
e := w.resolveOne(ctx, p)
|
||||
mu.Lock()
|
||||
if e == nil {
|
||||
ok++
|
||||
} else {
|
||||
failed++
|
||||
if first == nil {
|
||||
first = e
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if ctx.Err() != nil {
|
||||
return ok, failed, ctx.Err()
|
||||
}
|
||||
return ok, failed, first
|
||||
}
|
||||
func (w *Worker) resolveOne(ctx context.Context, p pending) error {
|
||||
var a Address
|
||||
e := w.DB.QueryRowContext(ctx, `SELECT province,city,region,adcode FROM vehicle_geography_cache WHERE longitude=? AND latitude=? AND resolved_at>=DATE_SUB(NOW(),INTERVAL 180 DAY)`, p.Longitude, p.Latitude).Scan(&a.Province, &a.City, &a.Region, &a.Adcode)
|
||||
if e != nil && e != sql.ErrNoRows {
|
||||
return e
|
||||
}
|
||||
if e == sql.ErrNoRows {
|
||||
// At most three external calls in flight by default; quota/network failures
|
||||
// are persisted and retried with backoff, never exported as a guessed city.
|
||||
w.requestMu.Lock()
|
||||
delay := time.Until(w.nextRequest)
|
||||
if delay > 0 {
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
w.requestMu.Unlock()
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
interval := w.RequestInterval
|
||||
if interval <= 0 {
|
||||
interval = 20 * time.Millisecond
|
||||
}
|
||||
w.nextRequest = time.Now().Add(interval)
|
||||
w.requestMu.Unlock()
|
||||
rctx, cancel := context.WithTimeout(ctx, 12*time.Second)
|
||||
a, e = w.Resolver.Resolve(rctx, p.Longitude, p.Latitude)
|
||||
cancel()
|
||||
if e != nil {
|
||||
_, saveErr := w.DB.ExecContext(ctx, `UPDATE vehicle_daily_geography SET status='ERROR',attempts=attempts+1,last_error=?,next_attempt_at=DATE_ADD(NOW(3),INTERVAL LEAST(3600,60*POW(2,LEAST(attempts,6))) SECOND) WHERE vin=? AND stat_date=? AND longitude=? AND latitude=?`, e.Error(), p.VIN, p.Date, p.Longitude, p.Latitude)
|
||||
if saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
return e
|
||||
}
|
||||
_, e = w.DB.ExecContext(ctx, `INSERT INTO vehicle_geography_cache(longitude,latitude,province,city,region,adcode) VALUES(?,?,?,?,?,?) ON DUPLICATE KEY UPDATE province=VALUES(province),city=VALUES(city),region=VALUES(region),adcode=VALUES(adcode),resolved_at=NOW(3)`, p.Longitude, p.Latitude, a.Province, a.City, a.Region, a.Adcode)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
_, e = w.DB.ExecContext(ctx, `UPDATE vehicle_daily_geography SET province=?,city=?,region=?,adcode=?,status='RESOLVED',last_error='',attempts=0,next_attempt_at=NULL,resolved_at=NOW(3) WHERE vin=? AND stat_date=? AND longitude=? AND latitude=?`, a.Province, a.City, a.Region, a.Adcode, p.VIN, p.Date, p.Longitude, p.Latitude)
|
||||
return e
|
||||
}
|
||||
|
||||
// An advisory lock is tied to this dedicated connection and released even on
|
||||
// cancellation/crash, preventing multiple API replicas from running the loop.
|
||||
func (w *Worker) WithLock(ctx context.Context, name string, fn func() error) error {
|
||||
conn, e := w.DB.Conn(ctx)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer conn.Close()
|
||||
var acquired int
|
||||
if e = conn.QueryRowContext(ctx, `SELECT GET_LOCK(?,0)`, name).Scan(&acquired); e != nil {
|
||||
return e
|
||||
}
|
||||
if acquired != 1 {
|
||||
return fmt.Errorf("daily geography worker already running")
|
||||
}
|
||||
defer conn.ExecContext(context.Background(), `SELECT RELEASE_LOCK(?)`, name)
|
||||
return fn()
|
||||
}
|
||||
func (w *Worker) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
round := 0
|
||||
for {
|
||||
e := w.WithLock(ctx, "vehicle_daily_geography_live", func() error {
|
||||
today := time.Now().In(Shanghai)
|
||||
date := today.Format("2006-01-02")
|
||||
rctx, c := context.WithTimeout(ctx, 50*time.Second)
|
||||
defer c()
|
||||
if _, e := w.RefreshDay(rctx, date); e != nil {
|
||||
return e
|
||||
}
|
||||
if round%60 == 0 {
|
||||
for d := 1; d <= 2; d++ {
|
||||
if _, e := w.RefreshDay(rctx, today.AddDate(0, 0, -d).Format("2006-01-02")); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
}
|
||||
n, failed, e := w.ResolvePending(rctx, today.AddDate(0, 0, -2).Format("2006-01-02"), date, 500)
|
||||
log.Printf("daily_geography resolved=%d failed=%d", n, failed)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
// Historical retry is independent of the live queue. The same lock used
|
||||
// by the CLI prevents duplicate external calls during a bulk backfill.
|
||||
_ = w.WithLock(rctx, "vehicle_daily_geography_backfill", func() error {
|
||||
n, failed, e := w.ResolvePending(rctx, "2000-01-01", today.AddDate(0, 0, -3).Format("2006-01-02"), 200)
|
||||
if n+failed > 0 {
|
||||
log.Printf("daily_geography_history_retry resolved=%d failed=%d error=%v", n, failed, e)
|
||||
}
|
||||
return e
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if e != nil {
|
||||
log.Printf("daily_geography: %v", e)
|
||||
}
|
||||
round++
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package dailygeo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type resolverFunc func(context.Context, float64, float64) (Address, error)
|
||||
|
||||
func (f resolverFunc) Resolve(c context.Context, x, y float64) (Address, error) { return f(c, x, y) }
|
||||
func TestAMapMunicipalityAndRegion(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("key") != "test-key" {
|
||||
t.Error("missing credential")
|
||||
}
|
||||
w.Write([]byte(`{"status":"1","regeocode":{"addressComponent":{"province":"上海市","city":[],"adcode":"310101"}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
a, e := (&AMap{Key: "test-key", BaseURL: server.URL}).Resolve(context.Background(), 121.47, 31.23)
|
||||
if e != nil || a.Province != "上海市" || a.City != "上海市" || a.Region != "华东" {
|
||||
t.Fatalf("address=%+v error=%v", a, e)
|
||||
}
|
||||
for p, want := range map[string]string{"广东省": "华南", "四川省": "西南", "陕西省": "西北", "山西省": "华北", "湖北省": "华中", "吉林省": "东北", "": ""} {
|
||||
if Region(p) != want {
|
||||
t.Fatal(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestAMapQuotaFailureDoesNotLeakKey(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"status":"0","infocode":"10021"}`)) }))
|
||||
defer server.Close()
|
||||
_, e := (&AMap{Key: "secret-value", BaseURL: server.URL}).Resolve(context.Background(), 121, 31)
|
||||
if e == nil || !strings.Contains(e.Error(), "10021") || strings.Contains(e.Error(), "secret-value") {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
func TestCachedCoordinateNeedsNoGeocoding(t *testing.T) {
|
||||
db, m, e := sqlmock.New()
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer db.Close()
|
||||
m.ExpectQuery("SELECT province,city,region,adcode").WithArgs(121.0, 31.0).WillReturnRows(sqlmock.NewRows([]string{"province", "city", "region", "adcode"}).AddRow("上海市", "上海市", "华东", "310000"))
|
||||
m.ExpectExec("UPDATE vehicle_daily_geography SET province=.*WHERE vin=\\? AND stat_date=\\? AND longitude=\\? AND latitude=\\?").WithArgs("上海市", "上海市", "华东", "310000", "VIN1", "2026-08-31", 121.0, 31.0).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
w := New(db, nil, "", resolverFunc(func(context.Context, float64, float64) (Address, error) {
|
||||
t.Fatal("cache hit must not call external API")
|
||||
return Address{}, nil
|
||||
}))
|
||||
if e = w.resolveOne(context.Background(), pending{"VIN1", "2026-08-31", 121, 31}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e = m.ExpectationsWereMet(); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
func TestResolverFailurePersistsRetryInsteadOfFalseUnknown(t *testing.T) {
|
||||
db, m, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
m.ExpectQuery("SELECT province,city,region,adcode").WillReturnError(sql.ErrNoRows)
|
||||
m.ExpectExec("UPDATE vehicle_daily_geography SET status='ERROR'.*next_attempt_at=.*WHERE vin=\\? AND stat_date=\\? AND longitude=\\? AND latitude=\\?").WithArgs("quota", "VIN1", "2026-08-31", 121.0, 31.0).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
w := New(db, nil, "", resolverFunc(func(context.Context, float64, float64) (Address, error) { return Address{}, errors.New("quota") }))
|
||||
if e := w.resolveOne(context.Background(), pending{"VIN1", "2026-08-31", 121, 31}); e == nil {
|
||||
t.Fatal("expected failure")
|
||||
}
|
||||
if e := m.ExpectationsWereMet(); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
func TestHistoricalFailureDoesNotMarkMissingLocation(t *testing.T) {
|
||||
db, m, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
td, tm, _ := sqlmock.New()
|
||||
defer td.Close()
|
||||
tm.ExpectQuery("SELECT vin,protocol,LAST").WillReturnError(errors.New("historical store unavailable"))
|
||||
w := New(db, td, "test_ts", nil)
|
||||
if _, e := w.RefreshDay(context.Background(), "2026-08-31"); e == nil {
|
||||
t.Fatal("expected history error")
|
||||
}
|
||||
if e := m.ExpectationsWereMet(); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e := tm.ExpectationsWereMet(); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
func TestHistoryUsesLatestSourceInsideBusinessDate(t *testing.T) {
|
||||
db, m, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
td, tm, _ := sqlmock.New()
|
||||
defer td.Close()
|
||||
early := time.Date(2026, 8, 31, 2, 0, 0, 0, Shanghai)
|
||||
late := early.Add(20 * time.Hour)
|
||||
tm.ExpectQuery("SELECT vin,protocol,LAST.*2026-08-31T00:00:00\\+08:00.*2026-09-01T00:00:00\\+08:00.*PARTITION BY vin,protocol").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "ts", "longitude", "latitude"}).AddRow("VIN1", "GB32960", late, 121.0, 31.0).AddRow("VIN1", "JT808", early, 113.0, 23.0).AddRow("VIN2", "JT808", late.Add(5*time.Hour), 110.0, 20.0))
|
||||
m.ExpectExec("UPDATE vehicle_daily_geography SET longitude=NULL").WithArgs("2026-08-31").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
m.ExpectExec(regexp.QuoteMeta(savePointSQL)).WithArgs("VIN1", "2026-08-31", 121.0, 31.0, late, "GB32960").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
m.ExpectExec("INSERT IGNORE INTO vehicle_daily_geography").WithArgs("2026-08-31").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
w := New(db, td, "test_ts", nil)
|
||||
n, e := w.RefreshDay(context.Background(), "2026-08-31")
|
||||
if e != nil || n != 1 {
|
||||
t.Fatalf("n=%d error=%v", n, e)
|
||||
}
|
||||
if e = m.ExpectationsWereMet(); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e = tm.ExpectationsWereMet(); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvinceAdministeredCity(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"status":"1","regeocode":{"addressComponent":{"province":"湖北省","city":[],"district":"潜江市","adcode":"429005"}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
a, e := (&AMap{Key: "test", BaseURL: server.URL}).Resolve(context.Background(), 112.807848, 30.381828)
|
||||
if e != nil || a.City != "潜江市" || a.Region != "华中" {
|
||||
t.Fatalf("address=%+v error=%v", a, e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidLocationSentinel(t *testing.T) {
|
||||
p := Point{VIN: "VIN1", Time: time.Now(), Longitude: -0.999999, Latitude: -0.999999}
|
||||
if validPoint(p) {
|
||||
t.Fatal("invalid device sentinel accepted")
|
||||
}
|
||||
p.Longitude, p.Latitude = 114.3, 30.5
|
||||
if !validPoint(p) {
|
||||
t.Fatal("valid location rejected")
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,7 @@
|
||||
|
||||
<section class="section" id="daily-hydrogen"><div class="section-head"><div><h2>车辆单日用氢量</h2><div class="endpoint"><span class="method">POST</span>/api/v1/vehicles/hydrogen-consumption/query</div><p>查询车辆单日用氢量,单位 kg。NORMAL + OK + PRELIMINARY 仅供初步监控;正式报表要求 FINAL 并核对证据与版本。两个日统计接口没有共同快照,区间缺失或不同不能直接计算百公里氢耗。</p></div><a class="anchor" href="#daily-hydrogen">#</a></div><div class="grid"><div><h3 class="subhead">请求参数</h3><table class="param-table"><tr><th>字段</th><th>必填</th><th>说明</th></tr><tr><td>date</td><td class="required">是</td><td>日期,格式 yyyy-MM-dd</td></tr><tr><td>plateNumbers</td><td class="optional">否</td><td>车牌数组;省略或 [] 时查询全部授权车辆</td></tr></table></div><div><h3 class="subhead">成功返回 · 200</h3><pre class="code">{<br> <span class="key">"code"</span>: <span class="string">"SUCCESS"</span>,<br> <span class="key">"data"</span>: [{<br> <span class="key">"plateNumber"</span>: <span class="string">"浙F06618F"</span>,<br> <span class="key">"hydrogenConsumptionKg"</span>: <span class="number">12.315</span>,<br> <span class="key">"status"</span>: <span class="string">"NORMAL"</span><br> }]<br>}</pre><ul class="errors"><li>400:日期或车牌格式不正确</li><li>401:appKey 无效、停用或过期</li><li>403:指定车辆未授权</li></ul></div></div></section>
|
||||
|
||||
<section class="section" id="daily-mileage"><div class="section-head"><div><h2>车辆单日里程</h2><div class="endpoint"><span class="method">POST</span>/api/v1/vehicles/mileage/query</div><p>返回当日行驶里程、当日累计总里程和实际选用的数据协议,单位 km。</p></div><a class="anchor" href="#daily-mileage">#</a></div><div class="grid"><div><h3 class="subhead">请求参数</h3><table class="param-table"><tr><th>字段</th><th>必填</th><th>说明</th></tr><tr><td>date</td><td class="required">是</td><td>日期,格式 yyyy-MM-dd</td></tr><tr><td>plateNumbers</td><td class="optional">否</td><td>省略时查询全部授权车辆</td></tr><tr><td>protocolPriority</td><td class="optional">否</td><td>协议选源顺序,例如 ["GB32960","MQTT","JT808"]</td></tr></table><div class="note"><strong>缺数规则:</strong>若当日没有有效里程,日里程为 0;累计总里程沿用上一个有效统计周期,计算时间也显示该周期时间。</div></div><div><h3 class="subhead">成功返回 · 200</h3><pre class="code">{<br> <span class="key">"code"</span>: <span class="string">"SUCCESS"</span>,<br> <span class="key">"data"</span>: [{<br> <span class="key">"dailyMileageKm"</span>: <span class="number">182.437</span>,<br> <span class="key">"totalMileageKm"</span>: <span class="number">12345.679</span>,<br> <span class="key">"sourceProtocol"</span>: <span class="string">"GB32960"</span>,<br> <span class="key">"status"</span>: <span class="string">"NORMAL"</span><br> }]<br>}</pre><ul class="errors"><li>400:日期、车牌或协议参数错误</li><li>403:授权期未覆盖查询日</li><li>无统计:单车以 NO_DATA 返回</li></ul></div></div></section>
|
||||
<section class="section" id="daily-mileage"><div class="section-head"><div><h2>车辆单日里程</h2><div class="endpoint"><span class="method">POST</span>/api/v1/vehicles/mileage/query</div><p>返回当日行驶里程、当日累计总里程和实际选用的数据协议,单位 km。</p></div><a class="anchor" href="#daily-mileage">#</a></div><div class="grid"><div><h3 class="subhead">请求参数</h3><table class="param-table"><tr><th>字段</th><th>必填</th><th>说明</th></tr><tr><td>date</td><td class="required">是</td><td>日期,格式 yyyy-MM-dd</td></tr><tr><td>plateNumbers</td><td class="optional">否</td><td>省略时查询全部授权车辆</td></tr><tr><td>protocolPriority</td><td class="optional">否</td><td>协议选源顺序,例如 ["GB32960","MQTT","JT808"]</td></tr></table><div class="note"><strong>缺数规则:</strong>日里程按同来源相邻日累计差计算;缺报日沿用累计值、日里程补 0(CARRIED_FORWARD),跨缺报期增量计入恢复日。首次基线、来源切换或累计回退返回 DATA_ANOMALY,日里程为 null。GPS 估算不参与本接口。</div></div><div><h3 class="subhead">成功返回 · 200</h3><pre class="code">{<br> <span class="key">"code"</span>: <span class="string">"SUCCESS"</span>,<br> <span class="key">"data"</span>: [{<br> <span class="key">"dailyMileageKm"</span>: <span class="number">182.437</span>,<br> <span class="key">"totalMileageKm"</span>: <span class="number">12345.679</span>,<br> <span class="key">"sourceProtocol"</span>: <span class="string">"GB32960"</span>,<br> <span class="key">"status"</span>: <span class="string">"NORMAL"</span><br> }]<br>}</pre><ul class="errors"><li>400:日期、车牌或协议参数错误</li><li>403:授权期未覆盖查询日</li><li>无统计:单车以 NO_DATA 返回</li></ul></div></div></section>
|
||||
|
||||
<section class="section" id="mileage-range"><div class="section-head"><div><h2>车辆区间日里程</h2><div class="endpoint"><span class="method">POST</span>/api/v1/vehicles/mileage/range/query</div><p>按车辆、日期分页返回区间日里程,最长查询区间为 366 天。</p></div><a class="anchor" href="#mileage-range">#</a></div><div class="grid"><div><h3 class="subhead">请求参数</h3><table class="param-table"><tr><th>字段</th><th>必填</th><th>说明</th></tr><tr><td>startDate / endDate</td><td class="required">是</td><td>日期区间,yyyy-MM-dd</td></tr><tr><td>plateNumbers</td><td class="optional">否</td><td>车牌数组,最多 5000 辆</td></tr><tr><td>protocolPriority</td><td class="optional">否</td><td>协议选源顺序</td></tr><tr><td>pageSize / cursor</td><td class="optional">否</td><td>分页大小及下一页游标</td></tr></table></div><div><h3 class="subhead">成功返回 · 200</h3><pre class="code">{<br> <span class="key">"code"</span>: <span class="string">"SUCCESS"</span>,<br> <span class="key">"data"</span>: [{<br> <span class="key">"date"</span>: <span class="string">"2026-08-06"</span>,<br> <span class="key">"dailyMileageKm"</span>: <span class="number">182.437</span>,<br> <span class="key">"totalMileageKm"</span>: <span class="number">12345.679</span><br> }],<br> <span class="key">"nextCursor"</span>: <span class="string">null</span><br>}</pre><ul class="errors"><li>400:区间超限或游标与原参数不一致</li><li>403:授权未完整覆盖查询区间</li></ul></div></div></section>
|
||||
|
||||
|
||||
@@ -102,9 +102,9 @@ paths:
|
||||
summary: 查询车辆单日里程
|
||||
description: |
|
||||
plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。
|
||||
protocolPriority 传入时,逐车按数组顺序选择第一个有效协议,未列出的协议完全禁用且不会兜底;省略时保持平台默认选源行为。
|
||||
NORMAL 结果同时包含日里程、日末累计总里程、实际来源协议、源数据时间和投影更新时间。日里程可由 GPS 轨迹估算;累计总里程读取每日统计中的 day_end_total_mileage_km,始终优先采用同协议终端上报的累计里程,不会使用 GPS 日里程估算值冒充累计里程。
|
||||
当日无有效里程时,日里程补 0,累计总里程、来源协议、dataTime 和 updatedAt 沿用此前最近的有效统计;updatedAt 仍为上一统计周期的计算时间。
|
||||
protocolPriority 传入时,逐车按数组顺序选择截至查询日已有有效累计读数的第一个协议;高优先级协议缺报时沿用其历史累计值,不切换累计基准,未列出的协议完全禁用且不会兜底;省略时保持平台默认选源行为。
|
||||
NORMAL 结果同时包含日里程、日末累计总里程、实际来源协议、源数据时间和投影更新时间。日里程按同来源相邻自然日累计读数之差计算,缺报期间增量计入恢复上报日;GPS 估算不参与本接口。首次基线、来源切换、累计回退返回 DATA_ANOMALY、dailyMileageKm=null 和 dataQuality。
|
||||
当日缺报时,日里程补 0,累计总里程、来源协议、dataTime 和 updatedAt 沿用同协议历史读数,dataQuality=CARRIED_FORWARD。
|
||||
operationId: queryDailyMileage
|
||||
security:
|
||||
- AppKeyAuth: []
|
||||
@@ -141,7 +141,7 @@ paths:
|
||||
首次请求返回 snapshotId 和 nextCursor;后续请求保持原参数并传回 nextCursor。
|
||||
快照仅固化授权车辆清单,逐页读取已建立索引的日统计投影,不扫描原始时序明细。
|
||||
protocolPriority 对区间内每辆车、每个自然日独立生效;未列出的协议完全禁用。
|
||||
某日无有效里程时,dailyMileageKm 补 0,其余里程证据沿用此前最近的有效统计。若终端累计里程回退,返回 DATA_ANOMALY 与 dataQuality=TOTAL_MILEAGE_ROLLBACK,绝不沿用历史值伪装为正常数据。日里程与日末累计总里程独立统计:GPS 轨迹估算仅用于日里程,累计总里程读取每日统计字段 day_end_total_mileage_km。
|
||||
某日无有效里程时,dailyMileageKm 补 0,其余里程证据沿用此前最近的有效统计。若终端累计里程回退,返回 DATA_ANOMALY 与 dataQuality=TOTAL_MILEAGE_ROLLBACK。日里程与累计值使用同一终端来源;日里程为相邻自然日累计差,GPS 估算不参与本接口。单日、区间和分页共用相同计算规则。
|
||||
operationId: queryDailyMileageRange
|
||||
security:
|
||||
- AppKeyAuth: []
|
||||
@@ -784,7 +784,7 @@ components:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: 单日里程,km;当日无记录但存在历史累计里程时为 0
|
||||
description: 相邻自然日同来源累计值之差,km;跨缺报期增量计入恢复日;缺报日为 0;首次基线、来源变化和累计异常时为 null
|
||||
totalMileageKm:
|
||||
type: number
|
||||
format: double
|
||||
@@ -794,7 +794,7 @@ components:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: 当日统计所选来源的最早 first_event_time,跨日基线可早于当日零点,不是自然日起始;历史结转补零或无数据时为 null
|
||||
description: 日里程起点累计读数的时间;可跨越多个缺报日;历史结转补零或无数据时为 null
|
||||
statisticsEndTime:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -818,8 +818,8 @@ components:
|
||||
dataQuality:
|
||||
type: string
|
||||
nullable: true
|
||||
enum: [TOTAL_MILEAGE_ROLLBACK]
|
||||
description: status=DATA_ANOMALY 时的异常原因;累计里程回退时为 TOTAL_MILEAGE_ROLLBACK
|
||||
enum: [TOTAL_MILEAGE_ROLLBACK, ODOMETER_SOURCE_CHANGED, NO_PREVIOUS_BASELINE, PREVIOUS_ODOMETER_ANOMALY, CARRIED_FORWARD, outside_daily_range, INVALID_DELTA]
|
||||
description: CARRIED_FORWARD 表示缺报结转;其他值表示无法连续对账的原因,此时 dailyMileageKm 为 null,保留累计读数及来源证据
|
||||
status:
|
||||
$ref: '#/components/schemas/DataStatus'
|
||||
MileageRangeResult:
|
||||
@@ -862,8 +862,8 @@ components:
|
||||
dataQuality:
|
||||
type: string
|
||||
nullable: true
|
||||
enum: [TOTAL_MILEAGE_ROLLBACK]
|
||||
description: status=DATA_ANOMALY 时的异常原因;累计里程回退时为 TOTAL_MILEAGE_ROLLBACK
|
||||
enum: [TOTAL_MILEAGE_ROLLBACK, ODOMETER_SOURCE_CHANGED, NO_PREVIOUS_BASELINE, PREVIOUS_ODOMETER_ANOMALY, CARRIED_FORWARD, outside_daily_range, INVALID_DELTA]
|
||||
description: CARRIED_FORWARD 表示缺报结转;其他值表示无法连续对账的原因,此时 dailyMileageKm 为 null,保留累计读数及来源证据
|
||||
status:
|
||||
$ref: '#/components/schemas/DataStatus'
|
||||
DataStatus:
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ReconciledMileageRange uses terminal odometers, including one preceding
|
||||
// observation per protocol. The same function serves single days and every
|
||||
// range page: neither the requested start date nor page size changes a result.
|
||||
func (r *MySQLRepository) ReconciledMileageRange(ctx context.Context, vins []string, start, end string, protocols []string) (map[string]DailyMileage, error) {
|
||||
if len(vins) == 0 {
|
||||
return map[string]DailyMileage{}, nil
|
||||
}
|
||||
if len(protocols) == 0 {
|
||||
protocols = []string{"GB32960", "YUTONG_MQTT", "JT808"}
|
||||
}
|
||||
placeholders := func(n int) string { return strings.TrimRight(strings.Repeat("?,", n), ",") }
|
||||
// Unknown-protocol legacy imports and GPS distance are not terminal odometers.
|
||||
eligible := func(alias string) string {
|
||||
return fmt.Sprintf(`%[1]s.quality_status IN ('OK','INVALID_DELTA')
|
||||
AND %[1]s.latest_total_mileage_km > 0
|
||||
AND COALESCE(%[1]s.quality_reason,'') <> 'gps_coordinate_accumulation'
|
||||
AND %[1]s.source_ip NOT IN ('legacy-mysql.lingniu-prod','manual-lingniu-prod-day-mileage')
|
||||
AND %[1]s.latest_event_time IS NOT NULL
|
||||
AND %[1]s.latest_event_time < TIMESTAMP(%[1]s.stat_date)+INTERVAL 1 DAY`, alias)
|
||||
}
|
||||
filter := `vin IN (` + placeholders(len(vins)) + `) AND protocol IN (` + placeholders(len(protocols)) + `)`
|
||||
query := `WITH wanted AS (
|
||||
SELECT DISTINCT vin,protocol,stat_date FROM vehicle_daily_mileage_source p
|
||||
WHERE ` + filter + ` AND stat_date BETWEEN ? AND ? AND ` + eligible("p") + `
|
||||
UNION ALL
|
||||
SELECT vin,protocol,MAX(stat_date) AS stat_date FROM vehicle_daily_mileage_source p
|
||||
WHERE ` + filter + ` AND stat_date<? AND ` + eligible("p") + ` GROUP BY vin,protocol
|
||||
)
|
||||
SELECT s.vin,DATE_FORMAT(s.stat_date,'%Y-%m-%d'),s.protocol,s.source_key,
|
||||
s.daily_mileage_km,s.latest_total_mileage_km,
|
||||
DATE_FORMAT(s.latest_event_time,'%Y-%m-%dT%H:%i:%s+08:00'),
|
||||
DATE_FORMAT(s.updated_at,'%Y-%m-%dT%H:%i:%s+08:00'),
|
||||
COALESCE(DATE_FORMAT(s.first_event_time,'%Y-%m-%dT%H:%i:%s+08:00'),''),
|
||||
CASE WHEN s.quality_status='INVALID_DELTA' THEN COALESCE(s.quality_reason,'INVALID_DELTA') ELSE '' END
|
||||
FROM wanted w JOIN vehicle_daily_mileage_source s
|
||||
ON s.vin=w.vin AND s.protocol=w.protocol AND s.stat_date=w.stat_date
|
||||
WHERE ` + eligible("s") + `
|
||||
ORDER BY s.stat_date,s.vin,s.protocol,s.is_selected DESC,
|
||||
CASE WHEN s.quality_status='OK' THEN 0 ELSE 1 END,s.latest_event_time DESC,s.sample_count DESC,s.source_key`
|
||||
args := make([]any, 0, 2*(len(vins)+len(protocols))+3)
|
||||
for _, v := range vins {
|
||||
args = append(args, v)
|
||||
}
|
||||
for _, p := range protocols {
|
||||
args = append(args, p)
|
||||
}
|
||||
args = append(args, start, end)
|
||||
for _, v := range vins {
|
||||
args = append(args, v)
|
||||
}
|
||||
for _, p := range protocols {
|
||||
args = append(args, p)
|
||||
}
|
||||
args = append(args, start)
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var points []DailyMileage
|
||||
seen := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var v DailyMileage
|
||||
if err := rows.Scan(&v.VIN, &v.Date, &v.Protocol, &v.SourceKey, &v.MileageKm, &v.TotalMileageKm, &v.DataTime, &v.UpdatedAt, &v.StatisticsStartTime, &v.DataQuality); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := v.VIN + "|" + v.Date + "|" + v.Protocol
|
||||
if !seen[key] {
|
||||
points = append(points, v)
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reconcileMileage(points, vins, start, end, protocols)
|
||||
}
|
||||
|
||||
func reconcileMileage(points []DailyMileage, vins []string, start, end string, protocols []string) (map[string]DailyMileage, error) {
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
first, err := time.ParseInLocation("2006-01-02", start, loc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
last, err := time.ParseInLocation("2006-01-02", end, loc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if last.Before(first) {
|
||||
return nil, fmt.Errorf("invalid mileage interval")
|
||||
}
|
||||
if len(protocols) == 0 {
|
||||
protocols = []string{"GB32960", "YUTONG_MQTT", "JT808"}
|
||||
}
|
||||
sort.SliceStable(points, func(i, j int) bool { return points[i].Date < points[j].Date })
|
||||
state := map[string]map[string]DailyMileage{}
|
||||
pick := func(vin string) (DailyMileage, bool) {
|
||||
for _, p := range protocols {
|
||||
if v, ok := state[vin][p]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return DailyMileage{}, false
|
||||
}
|
||||
put := func(v DailyMileage) {
|
||||
if state[v.VIN] == nil {
|
||||
state[v.VIN] = map[string]DailyMileage{}
|
||||
}
|
||||
state[v.VIN][v.Protocol] = v
|
||||
}
|
||||
i := 0
|
||||
for i < len(points) && points[i].Date < start {
|
||||
put(points[i])
|
||||
i++
|
||||
}
|
||||
out := map[string]DailyMileage{}
|
||||
for day := first; !day.After(last); day = day.AddDate(0, 0, 1) {
|
||||
date := day.Format("2006-01-02")
|
||||
before := map[string]DailyMileage{}
|
||||
for _, vin := range vins {
|
||||
if p, ok := pick(vin); ok {
|
||||
before[vin] = p
|
||||
}
|
||||
}
|
||||
for i < len(points) && points[i].Date == date {
|
||||
put(points[i])
|
||||
i++
|
||||
}
|
||||
for _, vin := range vins {
|
||||
current, ok := pick(vin)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
result := current
|
||||
previous, hasPrevious := before[vin]
|
||||
if current.Date < date {
|
||||
result.MileageKm = 0
|
||||
result.StatisticsStartTime = ""
|
||||
if result.DataQuality == "" {
|
||||
result.DataQuality = "CARRIED_FORWARD"
|
||||
}
|
||||
} else if current.DataQuality != "" {
|
||||
result.MileageKm = 0
|
||||
} else if !hasPrevious {
|
||||
result.DataQuality = "NO_PREVIOUS_BASELINE"
|
||||
} else if current.Protocol != previous.Protocol || current.SourceKey != previous.SourceKey {
|
||||
result.DataQuality = "ODOMETER_SOURCE_CHANGED"
|
||||
} else if current.TotalMileageKm < previous.TotalMileageKm {
|
||||
result.DataQuality = mileageTotalRollbackQuality
|
||||
} else if previous.DataQuality != "" {
|
||||
result.DataQuality = "PREVIOUS_ODOMETER_ANOMALY"
|
||||
} else {
|
||||
result.MileageKm = math.Round((current.TotalMileageKm-previous.TotalMileageKm)*1000) / 1000
|
||||
result.StatisticsStartTime = previous.DataTime
|
||||
}
|
||||
out[dailyMileageKey(vin, date)] = result
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReconciliationRecoveryAndPagination(t *testing.T) {
|
||||
points := []DailyMileage{
|
||||
{VIN: "v", Date: "2026-09-13", Protocol: "GB32960", SourceKey: "a", TotalMileageKm: 14366, DataTime: "2026-09-13T07:17:27+08:00"},
|
||||
{VIN: "v", Date: "2026-09-15", Protocol: "GB32960", SourceKey: "a", TotalMileageKm: 14467.4, DataTime: "2026-09-15T20:57:04+08:00"},
|
||||
}
|
||||
all, err := reconcileMileage(points, []string{"v"}, "2026-09-14", "2026-09-15", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if all["v\x002026-09-15"].MileageKm != 101.4 {
|
||||
t.Fatalf("recovery=%+v", all)
|
||||
}
|
||||
for _, date := range []string{"2026-09-14", "2026-09-15"} {
|
||||
one, _ := reconcileMileage(points, []string{"v"}, date, date, nil)
|
||||
if !reflect.DeepEqual(one[dailyMileageKey("v", date)], all[dailyMileageKey("v", date)]) {
|
||||
t.Fatal("page changes result")
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestReconciliationPinsProtocolThroughMissingDays(t *testing.T) {
|
||||
points := []DailyMileage{{VIN: "v", Date: "2026-09-11", Protocol: "GB32960", SourceKey: "a", TotalMileageKm: 100}, {VIN: "v", Date: "2026-09-12", Protocol: "YUTONG_MQTT", SourceKey: "b", TotalMileageKm: 9000}, {VIN: "v", Date: "2026-09-14", Protocol: "GB32960", SourceKey: "a", TotalMileageKm: 120}}
|
||||
got, _ := reconcileMileage(points, []string{"v"}, "2026-09-12", "2026-09-14", nil)
|
||||
if got[dailyMileageKey("v", "2026-09-12")].TotalMileageKm != 100 || got[dailyMileageKey("v", "2026-09-14")].MileageKm != 20 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
func TestReconciliationMarksDiscontinuities(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, source string
|
||||
total float64
|
||||
want string
|
||||
}{{"rollback", "a", 90, mileageTotalRollbackQuality}, {"replacement", "b", 120, "ODOMETER_SOURCE_CHANGED"}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, _ := reconcileMileage([]DailyMileage{{VIN: "v", Date: "2026-09-12", Protocol: "JT808", SourceKey: "a", TotalMileageKm: 100}, {VIN: "v", Date: "2026-09-13", Protocol: "JT808", SourceKey: tc.source, TotalMileageKm: tc.total}}, []string{"v"}, "2026-09-13", "2026-09-13", []string{"JT808"})
|
||||
v := got[dailyMileageKey("v", "2026-09-13")]
|
||||
var response MileageResult
|
||||
fillMileageResult(&response, v, v.MileageKm)
|
||||
if response.DailyMileageKm != nil || response.Status != StatusDataAnomaly || v.DataQuality != tc.want {
|
||||
t.Fatalf("%+v %+v", v, response)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconciliationFirstObservationHasNoInventedDistance(t *testing.T) {
|
||||
got, _ := reconcileMileage([]DailyMileage{{VIN: "v", Date: "2026-09-12", Protocol: "JT808", SourceKey: "a", TotalMileageKm: 100, DataTime: "2026-09-12T10:00:00+08:00"}}, []string{"v"}, "2026-09-12", "2026-09-12", nil)
|
||||
v := got[dailyMileageKey("v", "2026-09-12")]
|
||||
var result MileageResult
|
||||
fillMileageResult(&result, v, v.MileageKm)
|
||||
if result.DailyMileageKm != nil || result.TotalMileageKm == nil || *result.DataQuality != "NO_PREVIOUS_BASELINE" {
|
||||
t.Fatalf("%+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// A source replacement is flagged only on its boundary; starting a new page
|
||||
// must not hide that boundary or change the following day's odometer delta.
|
||||
func TestReconciliationSourceReplacementAcrossPageBoundary(t *testing.T) {
|
||||
points := []DailyMileage{
|
||||
{VIN: "v", Date: "2026-09-11", Protocol: "JT808", SourceKey: "old", TotalMileageKm: 9000},
|
||||
{VIN: "v", Date: "2026-09-12", Protocol: "JT808", SourceKey: "new", TotalMileageKm: 100},
|
||||
{VIN: "v", Date: "2026-09-14", Protocol: "JT808", SourceKey: "new", TotalMileageKm: 140},
|
||||
}
|
||||
all, _ := reconcileMileage(points, []string{"v"}, "2026-09-12", "2026-09-14", nil)
|
||||
if all[dailyMileageKey("v", "2026-09-12")].DataQuality != "ODOMETER_SOURCE_CHANGED" {
|
||||
t.Fatal(all)
|
||||
}
|
||||
if all[dailyMileageKey("v", "2026-09-14")].MileageKm != 40 {
|
||||
t.Fatal(all)
|
||||
}
|
||||
for _, day := range []string{"2026-09-12", "2026-09-13", "2026-09-14"} {
|
||||
one, _ := reconcileMileage(points, []string{"v"}, day, day, nil)
|
||||
if !reflect.DeepEqual(one[dailyMileageKey("v", day)], all[dailyMileageKey("v", day)]) {
|
||||
t.Fatal(day)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,6 +314,8 @@ type DailyHydrogen struct {
|
||||
}
|
||||
|
||||
type DailyMileage struct {
|
||||
SourceKey string
|
||||
DataQuality string
|
||||
StatisticsStartTime string
|
||||
VIN string
|
||||
Date string
|
||||
|
||||
@@ -29,6 +29,7 @@ var (
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
ReconciledMileageRange(context.Context, []string, string, string, []string) (map[string]DailyMileage, error)
|
||||
Authenticate(context.Context, [sha256.Size]byte, time.Time, time.Time, time.Time) (AppCredential, error)
|
||||
AuthorizedVehicles(context.Context, uint64, []string, time.Time, time.Time) (map[string]AuthorizedVehicle, error)
|
||||
DailyHydrogen(context.Context, []string, string) (map[string]DailyHydrogen, error)
|
||||
@@ -435,35 +436,17 @@ func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, requ
|
||||
plates = vehiclePlates(vehicles)
|
||||
}
|
||||
vins := vehicleVINs(vehicles)
|
||||
values, err := s.repository.DailyMileage(ctx, vins, date, protocols)
|
||||
values, err := s.repository.ReconciledMileageRange(ctx, vins, date, date, protocols)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
rollbacks, err := s.repository.MileageRollbacks(ctx, vins, date, date, protocols)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
missingVINs := missingMileageVINs(vins, values, rollbacks, date)
|
||||
carried := map[string]DailyMileage{}
|
||||
if len(missingVINs) > 0 {
|
||||
carried, err = s.repository.LatestMileageBefore(ctx, missingVINs, date, protocols)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
results := make([]MileageResult, 0, len(plates))
|
||||
for _, plate := range plates {
|
||||
vehicle := vehicles[plate]
|
||||
item := MileageResult{VIN: vehicle.VIN, PlateNumber: plate, Date: date, Status: StatusNoData}
|
||||
if value, ok := values[vehicle.VIN]; ok && validDailyMileage(value) {
|
||||
if value, ok := values[dailyMileageKey(vehicle.VIN, date)]; ok {
|
||||
fillMileageResult(&item, value, value.MileageKm)
|
||||
} else if rollbacks[dailyMileageKey(vehicle.VIN, date)] {
|
||||
fillMileageAnomaly(&item)
|
||||
} else if value, ok := carried[vehicle.VIN]; ok && validDailyMileage(value) {
|
||||
fillMileageResult(&item, value, 0)
|
||||
}
|
||||
results = append(results, item)
|
||||
}
|
||||
@@ -556,35 +539,17 @@ func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string,
|
||||
vinSet[vehicle.VIN] = struct{}{}
|
||||
}
|
||||
values := map[string]DailyMileage{}
|
||||
carried := map[string]DailyMileage{}
|
||||
rollbacks := map[string]bool{}
|
||||
if len(positions) > 0 {
|
||||
vins := make([]string, 0, len(vinSet))
|
||||
for vin := range vinSet {
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
sort.Strings(vins)
|
||||
values, err = s.repository.DailyMileageRange(ctx, vins, queryStart.Format("2006-01-02"), queryEnd.Format("2006-01-02"), protocols)
|
||||
values, err = s.repository.ReconciledMileageRange(ctx, vins, queryStart.Format("2006-01-02"), queryEnd.Format("2006-01-02"), protocols)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error())
|
||||
return MileageRangeResponse{}, err
|
||||
}
|
||||
rollbacks, err = s.repository.MileageRollbacks(ctx, vins, queryStart.Format("2006-01-02"), queryEnd.Format("2006-01-02"), protocols)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error())
|
||||
return MileageRangeResponse{}, err
|
||||
}
|
||||
missingVINs := missingMileageRangeInitialVINs(positions, values, rollbacks)
|
||||
if len(missingVINs) > 0 {
|
||||
carried, err = s.repository.LatestMileageBefore(ctx, missingVINs, queryStart.Format("2006-01-02"), protocols)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error())
|
||||
return MileageRangeResponse{}, err
|
||||
}
|
||||
if carried == nil {
|
||||
carried = map[string]DailyMileage{}
|
||||
}
|
||||
}
|
||||
}
|
||||
results := make([]MileageRangeResult, 0, len(positions))
|
||||
for _, position := range positions {
|
||||
@@ -594,14 +559,8 @@ func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string,
|
||||
Date: position.date,
|
||||
Status: StatusNoData,
|
||||
}
|
||||
if value, ok := values[dailyMileageKey(position.vehicle.VIN, position.date)]; ok && validDailyMileage(value) {
|
||||
if value, ok := values[dailyMileageKey(position.vehicle.VIN, position.date)]; ok {
|
||||
fillMileageRangeResult(&item, value, value.MileageKm)
|
||||
carried[position.vehicle.VIN] = value
|
||||
} else if rollbacks[dailyMileageKey(position.vehicle.VIN, position.date)] {
|
||||
fillMileageRangeAnomaly(&item)
|
||||
delete(carried, position.vehicle.VIN)
|
||||
} else if value, ok := carried[position.vehicle.VIN]; ok && validDailyMileage(value) {
|
||||
fillMileageRangeResult(&item, value, 0)
|
||||
}
|
||||
results = append(results, item)
|
||||
}
|
||||
@@ -1001,6 +960,10 @@ func missingMileageRangeInitialVINs(positions []mileageRangePosition, values map
|
||||
}
|
||||
|
||||
func fillMileageResult(item *MileageResult, value DailyMileage, dailyMileage float64) {
|
||||
if value.DataQuality != "" && value.DataTime == "" {
|
||||
fillMileageAnomaly(item)
|
||||
return
|
||||
}
|
||||
if value.Date == item.Date {
|
||||
item.StatisticsStartTime, item.StatisticsEndTime = validatedStatisticsInterval(value.StatisticsStartTime, value.DataTime)
|
||||
}
|
||||
@@ -1014,9 +977,20 @@ func fillMileageResult(item *MileageResult, value DailyMileage, dailyMileage flo
|
||||
sourceProtocol := externalMileageProtocol(value.Protocol)
|
||||
item.SourceProtocol = &sourceProtocol
|
||||
item.Status = StatusNormal
|
||||
if value.DataQuality != "" {
|
||||
item.DataQuality = stringPointer(value.DataQuality)
|
||||
if value.DataQuality != "CARRIED_FORWARD" {
|
||||
item.Status = StatusDataAnomaly
|
||||
item.DailyMileageKm = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fillMileageRangeResult(item *MileageRangeResult, value DailyMileage, dailyMileage float64) {
|
||||
if value.DataQuality != "" && value.DataTime == "" {
|
||||
fillMileageRangeAnomaly(item)
|
||||
return
|
||||
}
|
||||
item.DailyMileageKm = &dailyMileage
|
||||
totalMileage := value.TotalMileageKm
|
||||
item.TotalMileageKm = &totalMileage
|
||||
@@ -1027,6 +1001,13 @@ func fillMileageRangeResult(item *MileageRangeResult, value DailyMileage, dailyM
|
||||
sourceProtocol := externalMileageProtocol(value.Protocol)
|
||||
item.SourceProtocol = &sourceProtocol
|
||||
item.Status = StatusNormal
|
||||
if value.DataQuality != "" {
|
||||
item.DataQuality = stringPointer(value.DataQuality)
|
||||
if value.DataQuality != "CARRIED_FORWARD" {
|
||||
item.Status = StatusDataAnomaly
|
||||
item.DailyMileageKm = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mileageTotalRollbackQuality = "TOTAL_MILEAGE_ROLLBACK"
|
||||
|
||||
@@ -47,6 +47,56 @@ func (f *fakeRepository) AuthorizedVehicles(_ context.Context, _ uint64, plates
|
||||
f.requestedPlates = append([]string(nil), plates...)
|
||||
return f.vehicles, nil
|
||||
}
|
||||
|
||||
// Repository stub retains fixture values; reconciliation itself is tested with
|
||||
// actual dated observations in mileage_reconciliation_test.go.
|
||||
func (f *fakeRepository) ReconciledMileageRange(ctx context.Context, vins []string, start, end string, protocols []string) (map[string]DailyMileage, error) {
|
||||
f.dailyVINs = append([]string(nil), vins...)
|
||||
f.rangeVINs = append([]string(nil), vins...)
|
||||
f.dailyProtocols = protocols
|
||||
out := map[string]DailyMileage{}
|
||||
carried := map[string]DailyMileage{}
|
||||
var missing []string
|
||||
for _, vin := range vins {
|
||||
v, ok := f.mileage[dailyMileageKey(vin, start)]
|
||||
if !ok {
|
||||
v, ok = f.mileage[vin]
|
||||
}
|
||||
if (!ok || !validDailyMileage(v)) && !f.rollbacks[dailyMileageKey(vin, start)] {
|
||||
missing = append(missing, vin)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
prior, _ := f.LatestMileageBefore(ctx, missing, start, protocols)
|
||||
for vin, v := range prior {
|
||||
carried[vin] = v
|
||||
}
|
||||
}
|
||||
first, _ := time.Parse("2006-01-02", start)
|
||||
last, _ := time.Parse("2006-01-02", end)
|
||||
for day := first; !day.After(last); day = day.AddDate(0, 0, 1) {
|
||||
date := day.Format("2006-01-02")
|
||||
for _, vin := range vins {
|
||||
key := dailyMileageKey(vin, date)
|
||||
v, ok := f.mileage[key]
|
||||
if !ok {
|
||||
v, ok = f.mileage[vin]
|
||||
}
|
||||
if ok && validDailyMileage(v) {
|
||||
out[key] = v
|
||||
carried[vin] = v
|
||||
} else if f.rollbacks[key] {
|
||||
out[key] = DailyMileage{DataQuality: mileageTotalRollbackQuality}
|
||||
delete(carried, vin)
|
||||
} else if v, ok = carried[vin]; ok && validDailyMileage(v) {
|
||||
v.MileageKm = 0
|
||||
out[key] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeRepository) DailyHydrogen(_ context.Context, vins []string, _ string) (map[string]DailyHydrogen, error) {
|
||||
f.dailyVINs = append([]string(nil), vins...)
|
||||
return f.hydrogen, nil
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/openplatform"
|
||||
)
|
||||
|
||||
const (
|
||||
baseURL = "http://115.29.187.205:20200"
|
||||
hydrogenKWh = 16.0
|
||||
)
|
||||
|
||||
type candidate struct {
|
||||
VIN string `json:"vin"`
|
||||
StatDate string `json:"stat_date"`
|
||||
PlatformName string `json:"platform_name"`
|
||||
DailyMileageKm float64 `json:"daily_mileage_km"`
|
||||
RawTotal int `json:"raw_total"`
|
||||
Plate string `json:"plate,omitempty"`
|
||||
SampleNo int `json:"sample_no,omitempty"`
|
||||
MileageBin int `json:"mileage_bin,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
TankCapacityL float64 `json:"tank_capacity_l,omitempty"`
|
||||
BatteryKWh float64 `json:"battery_capacity_kwh,omitempty"`
|
||||
}
|
||||
|
||||
type capacityRecord struct {
|
||||
VIN, Plate, Model string
|
||||
TankCapacityL float64
|
||||
Active int
|
||||
}
|
||||
|
||||
type rawFrame struct {
|
||||
TS string `json:"ts"`
|
||||
FrameID string `json:"frame_id"`
|
||||
EventID string `json:"event_id"`
|
||||
MessageID int `json:"message_id"`
|
||||
MessageIDHex string `json:"message_id_hex"`
|
||||
EventTime string `json:"event_time"`
|
||||
ReceivedAt string `json:"received_at"`
|
||||
RawSizeBytes int `json:"raw_size_bytes"`
|
||||
RawHex string `json:"raw_hex"`
|
||||
ParsedFields map[string]any `json:"parsed_fields"`
|
||||
ParseStatus string `json:"parse_status"`
|
||||
SourceEndpoint string `json:"source_endpoint"`
|
||||
Protocol string `json:"protocol"`
|
||||
VIN string `json:"vin"`
|
||||
}
|
||||
|
||||
type rawResponse struct {
|
||||
Items []rawFrame `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type dayOutput struct {
|
||||
Candidate candidate `json:"candidate"`
|
||||
APIRawFrameCount int `json:"apiRawFrameCount"`
|
||||
UniqueFrameCount int `json:"uniqueFrameCount"`
|
||||
DuplicateFrameCount int `json:"duplicateFrameCount"`
|
||||
AlgorithmSamples int `json:"algorithmSamples"`
|
||||
CriticalFieldRows int `json:"criticalFieldRows"`
|
||||
EarliestEventTime string `json:"earliestEventTime"`
|
||||
LatestEventTime string `json:"latestEventTime"`
|
||||
RawArchiveFile string `json:"rawArchiveFile"`
|
||||
RawArchiveSHA256 string `json:"rawArchiveSha256"`
|
||||
RawArchiveBytes int64 `json:"rawArchiveBytes"`
|
||||
Stat openplatform.HydrogenDailyStat `json:"stat"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 5 && len(os.Args) != 6 {
|
||||
panic("usage: audit <vehicle-metadata.json> <start-date> <end-date> <output-dir> [source-archive-dir]")
|
||||
}
|
||||
metadataPath, startDate, endDate, outputDir := os.Args[1], os.Args[2], os.Args[3], os.Args[4]
|
||||
sourceArchiveDir := ""
|
||||
if len(os.Args) == 6 {
|
||||
sourceArchiveDir = os.Args[5]
|
||||
}
|
||||
start, err := time.Parse("2006-01-02", startDate)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
end, err := time.Parse("2006-01-02", endDate)
|
||||
if err != nil || end.Before(start) {
|
||||
panic("invalid date range")
|
||||
}
|
||||
var metadata []struct {
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
Model string `json:"model"`
|
||||
TankCapacityL float64 `json:"tank_capacity_l"`
|
||||
BatteryCapacityKWh float64 `json:"battery_capacity_kwh"`
|
||||
}
|
||||
mustReadJSON(metadataPath, &metadata)
|
||||
if len(metadata) == 0 {
|
||||
panic("vehicle metadata is empty")
|
||||
}
|
||||
selected := make([]candidate, 0, len(metadata)*int(end.Sub(start).Hours()/24+1))
|
||||
for day := start; !day.After(end); day = day.AddDate(0, 0, 1) {
|
||||
statDate := day.Format("2006-01-02")
|
||||
for _, item := range metadata {
|
||||
if len(strings.TrimSpace(item.VIN)) != 17 || item.TankCapacityL <= 0 || item.BatteryCapacityKWh <= 0 {
|
||||
continue
|
||||
}
|
||||
selected = append(selected, candidate{
|
||||
VIN: strings.ToUpper(strings.TrimSpace(item.VIN)), StatDate: statDate,
|
||||
Plate: item.Plate, Model: item.Model, TankCapacityL: item.TankCapacityL,
|
||||
BatteryKWh: item.BatteryCapacityKWh,
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.Slice(selected, func(i, j int) bool {
|
||||
if selected[i].StatDate != selected[j].StatDate {
|
||||
return selected[i].StatDate < selected[j].StatDate
|
||||
}
|
||||
if selected[i].Plate != selected[j].Plate {
|
||||
return selected[i].Plate < selected[j].Plate
|
||||
}
|
||||
return selected[i].VIN < selected[j].VIN
|
||||
})
|
||||
for index := range selected {
|
||||
selected[index].SampleNo = index + 1
|
||||
}
|
||||
if sourceArchiveDir == "" {
|
||||
for day := start; !day.After(end); day = day.AddDate(0, 0, 1) {
|
||||
if err := os.MkdirAll(filepath.Join(outputDir, "raw", day.Format("2006-01-02")), 0o755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(filepath.Join(outputDir, "selected_vehicles.json"), selected)
|
||||
|
||||
jobs := make(chan candidate)
|
||||
results := make(chan dayOutput)
|
||||
errs := make(chan error, len(selected))
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 12; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for c := range jobs {
|
||||
out, err := processDay(c, outputDir, sourceArchiveDir)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
continue
|
||||
}
|
||||
results <- out
|
||||
}
|
||||
}()
|
||||
}
|
||||
go func() {
|
||||
for _, c := range selected {
|
||||
jobs <- c
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
close(errs)
|
||||
}()
|
||||
outputs := make([]dayOutput, 0, len(selected))
|
||||
for out := range results {
|
||||
if out.APIRawFrameCount == 0 {
|
||||
continue
|
||||
}
|
||||
outputs = append(outputs, out)
|
||||
fmt.Fprintf(os.Stderr, "completed %03d %s %s raw=%d samples=%d quality=%s\n", out.Candidate.SampleNo, out.Candidate.Plate, out.Candidate.VIN, out.APIRawFrameCount, out.AlgorithmSamples, out.Stat.QualityStatus)
|
||||
}
|
||||
var allErrs []string
|
||||
for err := range errs {
|
||||
allErrs = append(allErrs, err.Error())
|
||||
}
|
||||
if len(allErrs) > 0 {
|
||||
panic(strings.Join(allErrs, "\n"))
|
||||
}
|
||||
sort.Slice(outputs, func(i, j int) bool { return outputs[i].Candidate.SampleNo < outputs[j].Candidate.SampleNo })
|
||||
writeJSON(filepath.Join(outputDir, "daily_results.json"), outputs)
|
||||
writeManifest(filepath.Join(outputDir, "raw_manifest.csv"), outputs)
|
||||
fmt.Printf("processed=%d raw_frames=%d algorithm_samples=%d intervals=%d\n", len(outputs), sumRaw(outputs), sumSamples(outputs), sumIntervals(outputs))
|
||||
}
|
||||
|
||||
func stratifiedSelect(all []candidate, plateByVIN map[string]string, capacityByVIN map[string]capacityRecord) []candidate {
|
||||
general, cold := make([]candidate, 0), make([]candidate, 0)
|
||||
for _, c := range all {
|
||||
cap, ok := capacityByVIN[strings.ToUpper(c.VIN)]
|
||||
if !ok || cap.Active != 1 || c.DailyMileageKm < 10 || c.DailyMileageKm > 600 || c.RawTotal < 300 {
|
||||
continue
|
||||
}
|
||||
if cap.Model != "4.5吨货车" && cap.Model != "帕力安牌4.5吨冷链车" {
|
||||
continue
|
||||
}
|
||||
c.Plate = cap.Plate
|
||||
if c.Plate == "" {
|
||||
c.Plate = plateByVIN[strings.ToUpper(c.VIN)]
|
||||
}
|
||||
c.Model = cap.Model
|
||||
c.TankCapacityL = cap.TankCapacityL
|
||||
if cap.Model == "4.5吨货车" {
|
||||
general = append(general, c)
|
||||
} else {
|
||||
cold = append(cold, c)
|
||||
}
|
||||
}
|
||||
if len(general) < 41 || len(cold) < 59 {
|
||||
panic(fmt.Sprintf("insufficient pools general=%d cold=%d", len(general), len(cold)))
|
||||
}
|
||||
selected := append(selectEvenly(general, 41), selectEvenly(cold, 59)...)
|
||||
sort.Slice(selected, func(i, j int) bool {
|
||||
if selected[i].DailyMileageKm != selected[j].DailyMileageKm {
|
||||
return selected[i].DailyMileageKm < selected[j].DailyMileageKm
|
||||
}
|
||||
return selected[i].VIN < selected[j].VIN
|
||||
})
|
||||
for i := range selected {
|
||||
selected[i].SampleNo = i + 1
|
||||
selected[i].MileageBin = i/10 + 1
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
func selectEvenly(values []candidate, count int) []candidate {
|
||||
sort.Slice(values, func(i, j int) bool {
|
||||
if values[i].DailyMileageKm != values[j].DailyMileageKm {
|
||||
return values[i].DailyMileageKm < values[j].DailyMileageKm
|
||||
}
|
||||
return values[i].VIN < values[j].VIN
|
||||
})
|
||||
out := make([]candidate, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
idx := 0
|
||||
if count > 1 {
|
||||
idx = int(math.Round(float64(i) * float64(len(values)-1) / float64(count-1)))
|
||||
}
|
||||
out = append(out, values[idx])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func processDay(c candidate, outputDir, sourceArchiveDir string) (dayOutput, error) {
|
||||
archiveName := rawArchiveName(c)
|
||||
archivePath := filepath.Join(outputDir, "raw", c.StatDate, archiveName)
|
||||
readArchivePath := archivePath
|
||||
if sourceArchiveDir != "" {
|
||||
readArchivePath = filepath.Join(sourceArchiveDir, "raw", c.StatDate, archiveName)
|
||||
if _, statErr := os.Stat(readArchivePath); os.IsNotExist(statErr) {
|
||||
// Sample numbers depend on the selected vehicle/date set. Reuse an
|
||||
// existing archive by its stable plate/VIN/date suffix when rerunning
|
||||
// only a small subset for validation.
|
||||
pattern := filepath.Join(sourceArchiveDir, "raw", c.StatDate, "*_"+c.Plate+"_"+c.VIN+"_"+c.StatDate+".csv.gz")
|
||||
if matches, _ := filepath.Glob(pattern); len(matches) == 1 {
|
||||
readArchivePath = matches[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
frames := []rawFrame(nil)
|
||||
total := 0
|
||||
var err error
|
||||
if _, statErr := os.Stat(readArchivePath); statErr == nil {
|
||||
frames, err = readRawArchive(readArchivePath)
|
||||
total = len(frames)
|
||||
} else if sourceArchiveDir != "" && os.IsNotExist(statErr) {
|
||||
return dayOutput{Candidate: c}, nil
|
||||
} else {
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
frames, total, err = fetchFrames(c.VIN, c.StatDate)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if attempt < 3 {
|
||||
time.Sleep(time.Duration(attempt) * time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return dayOutput{}, fmt.Errorf("%s %s: %w", c.StatDate, c.VIN, err)
|
||||
}
|
||||
if total == 0 {
|
||||
return dayOutput{Candidate: c}, nil
|
||||
}
|
||||
sort.SliceStable(frames, func(i, j int) bool {
|
||||
if frames[i].EventTime != frames[j].EventTime {
|
||||
return frames[i].EventTime < frames[j].EventTime
|
||||
}
|
||||
if frames[i].SourceEndpoint != frames[j].SourceEndpoint {
|
||||
return frames[i].SourceEndpoint < frames[j].SourceEndpoint
|
||||
}
|
||||
return frames[i].TS < frames[j].TS
|
||||
})
|
||||
seen := map[string]bool{}
|
||||
duplicateFrames := 0
|
||||
observations := make([]openplatform.HydrogenObservation, 0, len(frames))
|
||||
criticalRows := 0
|
||||
for _, f := range frames {
|
||||
if f.FrameID != "" {
|
||||
if seen[f.FrameID] {
|
||||
duplicateFrames++
|
||||
}
|
||||
seen[f.FrameID] = true
|
||||
}
|
||||
obs, ok, critical := observationFromFrame(f, c.TankCapacityL, c.StatDate)
|
||||
if critical {
|
||||
criticalRows++
|
||||
}
|
||||
if ok {
|
||||
observations = append(observations, obs)
|
||||
}
|
||||
}
|
||||
params := map[string]openplatform.HydrogenCalculationParameters{c.VIN: {BatteryCapacityKWh: c.BatteryKWh, HydrogenEnergyKWhKg: hydrogenKWh}}
|
||||
stats := openplatform.BuildHydrogenDailyStatsOrderedWithParameters(observations, c.StatDate, 0.05, 20, params)
|
||||
var stat openplatform.HydrogenDailyStat
|
||||
if len(stats) == 1 {
|
||||
stat = stats[0]
|
||||
} else {
|
||||
stat = openplatform.HydrogenDailyStat{VIN: c.VIN, Date: c.StatDate, SampleCount: len(observations), QualityStatus: "NO_DATA", QualityReason: "无有效压力温度样本"}
|
||||
}
|
||||
roles := map[string]string{}
|
||||
for _, interval := range stat.Intervals {
|
||||
appendRole(roles, interval.StartEventID, fmt.Sprintf("运行区间%d(%s)起点", interval.Index, interval.Type))
|
||||
appendRole(roles, interval.EndEventID, fmt.Sprintf("运行区间%d(%s)终点", interval.Index, interval.Type))
|
||||
}
|
||||
for _, interval := range stat.HydrogenIntervals {
|
||||
appendRole(roles, interval.StartEventID, fmt.Sprintf("氢量分段%d起点", interval.Index))
|
||||
appendRole(roles, interval.EndEventID, fmt.Sprintf("氢量分段%d终点", interval.Index))
|
||||
}
|
||||
// Even when the original frames are reused, rewrite the audit CSV so its
|
||||
// “计算角色/排除原因” column matches the current algorithm version and
|
||||
// interval boundaries. The original HEX and parsed fields remain unchanged.
|
||||
if sourceArchiveDir == "" {
|
||||
if err := writeRawCSV(archivePath, c, frames, roles); err != nil {
|
||||
return dayOutput{}, err
|
||||
}
|
||||
}
|
||||
checksum, size, err := fileSHA256(readArchivePath)
|
||||
if err != nil {
|
||||
return dayOutput{}, err
|
||||
}
|
||||
out := dayOutput{Candidate: c, APIRawFrameCount: total, UniqueFrameCount: len(seen), DuplicateFrameCount: duplicateFrames, AlgorithmSamples: len(observations), CriticalFieldRows: criticalRows, RawArchiveFile: filepath.ToSlash(filepath.Join("raw", c.StatDate, archiveName)), RawArchiveSHA256: checksum, RawArchiveBytes: size, Stat: stat}
|
||||
if len(frames) > 0 {
|
||||
out.EarliestEventTime = frames[0].EventTime
|
||||
out.LatestEventTime = frames[len(frames)-1].EventTime
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func rawArchiveName(c candidate) string {
|
||||
return fmt.Sprintf("%04d_%s_%s_%s.csv.gz", c.SampleNo, safeName(c.Plate), c.VIN, c.StatDate)
|
||||
}
|
||||
|
||||
func appendRole(roles map[string]string, eventID, role string) {
|
||||
if eventID == "" {
|
||||
return
|
||||
}
|
||||
if roles[eventID] == "" {
|
||||
roles[eventID] = role
|
||||
return
|
||||
}
|
||||
roles[eventID] += ";" + role
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, int64, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer file.Close()
|
||||
hash := sha256.New()
|
||||
size, err := io.Copy(hash, file)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return fmt.Sprintf("%x", hash.Sum(nil)), size, nil
|
||||
}
|
||||
|
||||
func readRawArchive(path string) ([]rawFrame, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
gz, err := gzip.NewReader(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer gz.Close()
|
||||
reader := csv.NewReader(gz)
|
||||
rows, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, fmt.Errorf("empty raw archive %s", path)
|
||||
}
|
||||
columns := map[string]int{}
|
||||
for index, name := range rows[0] {
|
||||
columns[name] = index
|
||||
}
|
||||
cell := func(row []string, name string) string {
|
||||
index, ok := columns[name]
|
||||
if !ok || index >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return row[index]
|
||||
}
|
||||
frames := make([]rawFrame, 0, len(rows)-1)
|
||||
for _, row := range rows[1:] {
|
||||
fields := map[string]any{}
|
||||
if rawFields := cell(row, "完整解析字段JSON"); rawFields != "" {
|
||||
_ = json.Unmarshal([]byte(rawFields), &fields)
|
||||
}
|
||||
messageID, _ := strconv.Atoi(cell(row, "消息ID"))
|
||||
rawSize, _ := strconv.Atoi(cell(row, "原始字节数"))
|
||||
frames = append(frames, rawFrame{
|
||||
FrameID: cell(row, "帧ID"), EventID: cell(row, "事件ID"), MessageID: messageID,
|
||||
MessageIDHex: cell(row, "消息ID_HEX"), EventTime: cell(row, "事件时间"), ReceivedAt: cell(row, "接收时间"),
|
||||
RawSizeBytes: rawSize, RawHex: cell(row, "原始报文HEX"), ParsedFields: fields,
|
||||
ParseStatus: cell(row, "解析状态"), SourceEndpoint: cell(row, "源端点"), Protocol: "GB32960", VIN: cell(row, "VIN"),
|
||||
})
|
||||
}
|
||||
return frames, nil
|
||||
}
|
||||
|
||||
func fetchFrames(vin, statDate string) ([]rawFrame, int, error) {
|
||||
const limit = 500
|
||||
first, err := fetchPage(vin, statDate, 0, limit, true)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
frames := append([]rawFrame(nil), first.Items...)
|
||||
for offset := limit; offset < first.Total; offset += limit {
|
||||
page, err := fetchPage(vin, statDate, offset, limit, false)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
frames = append(frames, page.Items...)
|
||||
}
|
||||
if len(frames) != first.Total {
|
||||
return nil, first.Total, fmt.Errorf("API total=%d fetched=%d", first.Total, len(frames))
|
||||
}
|
||||
return frames, first.Total, nil
|
||||
}
|
||||
|
||||
func fetchPage(vin, statDate string, offset, limit int, includeTotal bool) (rawResponse, error) {
|
||||
q := url.Values{"protocol": {"GB32960"}, "vin": {vin}, "dateFrom": {statDate + " 00:00:00"}, "dateTo": {statDate + " 23:59:59"}, "orderBy": {"eventTime"}, "limit": {strconv.Itoa(limit)}, "offset": {strconv.Itoa(offset)}, "includeFields": {"true"}, "includePayload": {"true"}, "includeTotal": {strconv.FormatBool(includeTotal)}}
|
||||
req, _ := http.NewRequest(http.MethodGet, baseURL+"/api/history/raw-frames?"+q.Encode(), nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
client := &http.Client{Timeout: 90 * time.Second}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return rawResponse{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
if res.StatusCode == http.StatusInternalServerError && bytes.Contains(body, []byte("Table does not exist")) {
|
||||
return rawResponse{}, nil
|
||||
}
|
||||
return rawResponse{}, fmt.Errorf("HTTP %d: %s", res.StatusCode, body)
|
||||
}
|
||||
var reader io.Reader = res.Body
|
||||
if res.Header.Get("Content-Encoding") == "gzip" {
|
||||
gz, err := gzip.NewReader(res.Body)
|
||||
if err != nil {
|
||||
return rawResponse{}, err
|
||||
}
|
||||
defer gz.Close()
|
||||
reader = gz
|
||||
}
|
||||
var out rawResponse
|
||||
if err := json.NewDecoder(reader).Decode(&out); err != nil {
|
||||
return rawResponse{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func observationFromFrame(f rawFrame, tankCapacity float64, statDate string) (openplatform.HydrogenObservation, bool, bool) {
|
||||
if f.MessageID != 2 || f.ParseStatus != "OK" {
|
||||
return openplatform.HydrogenObservation{}, false, false
|
||||
}
|
||||
pressure, pok := num(f.ParsedFields["gb32960.fuel_cell.max_hydrogen_pressure_mpa"])
|
||||
temp, tok := num(f.ParsedFields["gb32960.fuel_cell.max_hydrogen_temperature_c"])
|
||||
critical := pok && tok
|
||||
if !pok || !tok || pressure <= 0 || pressure > 70 || temp <= -40 || temp > 726.85 {
|
||||
return openplatform.HydrogenObservation{}, false, critical
|
||||
}
|
||||
mass, ok := openplatform.PressureHydrogenMassKg(pressure, temp, tankCapacity)
|
||||
if !ok {
|
||||
return openplatform.HydrogenObservation{}, false, critical
|
||||
}
|
||||
step, _ := openplatform.PressureHydrogenMassKg(math.Max(0, pressure-0.2), temp, tankCapacity)
|
||||
parsed, _ := json.Marshal(f.ParsedFields)
|
||||
_, _, active, known, _ := openplatform.ExtractHydrogenTelemetry(string(parsed))
|
||||
t, err := parseEventTime(f.EventTime)
|
||||
if err != nil || t.Format("2006-01-02") != statDate {
|
||||
return openplatform.HydrogenObservation{}, false, critical
|
||||
}
|
||||
o := openplatform.HydrogenObservation{VIN: f.VIN, Source: f.SourceEndpoint, EventID: f.EventID, ObservedAt: t, MassKg: mass, TankCapacityLiter: tankCapacity, PressureMPa: pressure, TemperatureC: temp, NoiseKg: math.Min(1, math.Max(0.05, mass-step)), RefuelThresholdKg: math.Max(1, mass*0.05), FuelCellActive: active, FuelCellStateKnown: known}
|
||||
if voltage, vok := num(f.ParsedFields["gb32960.fuel_cell.fuel_cell_voltage_v"]); vok && voltage > 0 && voltage <= 1000 {
|
||||
if current, cok := num(f.ParsedFields["gb32960.fuel_cell.fuel_cell_current_a"]); cok && current >= 0 && current <= 2000 {
|
||||
o.FuelCellVoltageV = voltage
|
||||
o.FuelCellCurrentA = current
|
||||
o.FuelCellPowerKnown = true
|
||||
}
|
||||
}
|
||||
if v, ok := num(f.ParsedFields["gb32960.vehicle.soc_percent"]); ok && v >= 0 && v <= 100 {
|
||||
o.SOCPercent = v
|
||||
o.SOCKnown = true
|
||||
}
|
||||
if v, ok := num(f.ParsedFields["gb32960.vehicle.total_mileage_km"]); ok && v >= 0 {
|
||||
o.MileageKm = v
|
||||
o.MileageKnown = true
|
||||
}
|
||||
if v, ok := num(f.ParsedFields["gb32960.vehicle.vehicle_status"]); ok {
|
||||
o.VehicleState = int(v)
|
||||
o.VehicleStateKnown = v >= 0 && v <= 255
|
||||
}
|
||||
if v, ok := num(f.ParsedFields["gb32960.vehicle.charge_status"]); ok {
|
||||
o.ChargeState = int(v)
|
||||
o.ChargeStateKnown = v >= 0 && v <= 255
|
||||
}
|
||||
if v, ok := num(f.ParsedFields["gb32960.vehicle.running_mode"]); ok {
|
||||
o.RunningMode = int(v)
|
||||
o.RunningModeKnown = v >= 0 && v <= 255
|
||||
}
|
||||
return o, true, critical
|
||||
}
|
||||
|
||||
var rawHeaders = []string{"序号", "车牌", "VIN", "统计日期", "车型", "储氢总容积(L)", "额定电量(kWh)", "事件时间", "接收时间", "帧ID", "事件ID", "消息ID", "消息ID_HEX", "源端点", "解析状态", "原始字节数", "原始报文HEX", "完整解析字段JSON", "最高氢压(MPa)", "最高氢温(℃)", "电池SOC(%)", "仪表总里程(km)", "车辆状态", "充电状态", "运行模式", "燃料电池工作状态", "燃料电池电流(A)", "车端氢气质量(kg)", "系统压力换算剩余氢量(kg)", "噪声阈值(kg)", "是否算法有效样本", "计算角色/排除原因"}
|
||||
|
||||
func writeRawCSV(path string, c candidate, frames []rawFrame, roles map[string]string) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
gz := gzip.NewWriter(f)
|
||||
defer gz.Close()
|
||||
w := csv.NewWriter(gz)
|
||||
defer w.Flush()
|
||||
if err := w.Write(rawHeaders); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rf := range frames {
|
||||
o, valid, _ := observationFromFrame(rf, c.TankCapacityL, c.StatDate)
|
||||
role := roles[rf.EventID]
|
||||
if !valid {
|
||||
if t, err := parseEventTime(rf.EventTime); err == nil && t.Format("2006-01-02") != c.StatDate {
|
||||
role = "事件时间不在统计日,不进入计算"
|
||||
} else if rf.MessageID != 2 {
|
||||
role = "非实时信息上报帧,不进入计算"
|
||||
} else if rf.ParseStatus != "OK" {
|
||||
role = "解析状态非OK,不进入计算"
|
||||
} else {
|
||||
role = "压力/温度无效或缺失,不进入计算"
|
||||
}
|
||||
} else if role == "" {
|
||||
role = "有效候选帧(由分段规则判定)"
|
||||
}
|
||||
parsedJSON, _ := json.Marshal(rf.ParsedFields)
|
||||
row := []string{fmt.Sprintf("%04d", c.SampleNo), c.Plate, c.VIN, c.StatDate, c.Model, strconv.FormatFloat(c.TankCapacityL, 'f', 2, 64), strconv.FormatFloat(c.BatteryKWh, 'f', 2, 64), rf.EventTime, rf.ReceivedAt, rf.FrameID, rf.EventID, strconv.Itoa(rf.MessageID), rf.MessageIDHex, rf.SourceEndpoint, rf.ParseStatus, strconv.Itoa(rf.RawSizeBytes), rf.RawHex, string(parsedJSON), val(rf.ParsedFields, "gb32960.fuel_cell.max_hydrogen_pressure_mpa"), val(rf.ParsedFields, "gb32960.fuel_cell.max_hydrogen_temperature_c"), val(rf.ParsedFields, "gb32960.vehicle.soc_percent"), val(rf.ParsedFields, "gb32960.vehicle.total_mileage_km"), val(rf.ParsedFields, "gb32960.vehicle.vehicle_status"), val(rf.ParsedFields, "gb32960.vehicle.charge_status"), val(rf.ParsedFields, "gb32960.vehicle.running_mode"), val(rf.ParsedFields, "gb32960.gd_fc_stack.engine_work_state"), val(rf.ParsedFields, "gb32960.fuel_cell.fuel_cell_current_a"), val(rf.ParsedFields, "gb32960.gd_fc_vehicle_info.hydrogen_mass_kg"), blankFloat(valid, o.MassKg), blankFloat(valid, o.NoiseKg), yesNo(valid), role}
|
||||
if err := w.Write(row); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return w.Error()
|
||||
}
|
||||
|
||||
func writeManifest(path string, outputs []dayOutput) {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer f.Close()
|
||||
w := csv.NewWriter(f)
|
||||
defer w.Flush()
|
||||
_ = w.Write([]string{"序号", "车牌", "VIN", "日期", "车型", "储氢总容积(L)", "额定电量(kWh)", "API原始帧数", "唯一帧数", "重复帧数", "算法样本数", "关键字段帧数", "最早事件时间", "最晚事件时间", "原始文件", "压缩包SHA256", "压缩字节数", "质量状态", "质量原因"})
|
||||
for _, o := range outputs {
|
||||
_ = w.Write([]string{fmt.Sprintf("%04d", o.Candidate.SampleNo), o.Candidate.Plate, o.Candidate.VIN, o.Candidate.StatDate, o.Candidate.Model, strconv.FormatFloat(o.Candidate.TankCapacityL, 'f', 2, 64), strconv.FormatFloat(o.Candidate.BatteryKWh, 'f', 2, 64), strconv.Itoa(o.APIRawFrameCount), strconv.Itoa(o.UniqueFrameCount), strconv.Itoa(o.DuplicateFrameCount), strconv.Itoa(o.AlgorithmSamples), strconv.Itoa(o.CriticalFieldRows), o.EarliestEventTime, o.LatestEventTime, o.RawArchiveFile, o.RawArchiveSHA256, strconv.FormatInt(o.RawArchiveBytes, 10), o.Stat.QualityStatus, o.Stat.QualityReason})
|
||||
}
|
||||
}
|
||||
|
||||
func num(v any) (float64, bool) {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x, true
|
||||
case string:
|
||||
n, e := strconv.ParseFloat(strings.TrimSpace(x), 64)
|
||||
return n, e == nil
|
||||
case json.Number:
|
||||
n, e := x.Float64()
|
||||
return n, e == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
func val(m map[string]any, k string) string {
|
||||
if v, ok := m[k]; ok {
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func blankFloat(ok bool, v float64) string {
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatFloat(v, 'f', 6, 64)
|
||||
}
|
||||
func yesNo(v bool) string {
|
||||
if v {
|
||||
return "是"
|
||||
}
|
||||
return "否"
|
||||
}
|
||||
func safeName(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return "无车牌"
|
||||
}
|
||||
return strings.NewReplacer("/", "_", "\\", "_", " ", "_").Replace(v)
|
||||
}
|
||||
func parseEventTime(value string) (time.Time, error) {
|
||||
loc := time.FixedZone("CST", 8*3600)
|
||||
if t, e := time.ParseInLocation("2006-01-02 15:04:05.000", value, loc); e == nil {
|
||||
return t, nil
|
||||
}
|
||||
return time.ParseInLocation("2006-01-02 15:04:05", value, loc)
|
||||
}
|
||||
func mustReadJSON(path string, v any) {
|
||||
b, e := os.ReadFile(path)
|
||||
if e != nil {
|
||||
panic(e)
|
||||
}
|
||||
if e = json.Unmarshal(b, v); e != nil {
|
||||
panic(e)
|
||||
}
|
||||
}
|
||||
func writeJSON(path string, v any) {
|
||||
b, e := json.MarshalIndent(v, "", " ")
|
||||
if e != nil {
|
||||
panic(e)
|
||||
}
|
||||
if e = os.WriteFile(path, b, 0o644); e != nil {
|
||||
panic(e)
|
||||
}
|
||||
}
|
||||
func sumRaw(v []dayOutput) int {
|
||||
n := 0
|
||||
for _, x := range v {
|
||||
n += x.APIRawFrameCount
|
||||
}
|
||||
return n
|
||||
}
|
||||
func sumSamples(v []dayOutput) int {
|
||||
n := 0
|
||||
for _, x := range v {
|
||||
n += x.AlgorithmSamples
|
||||
}
|
||||
return n
|
||||
}
|
||||
func sumIntervals(v []dayOutput) int {
|
||||
n := 0
|
||||
for _, x := range v {
|
||||
n += len(x.Stat.Intervals)
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args)!=3 { panic("usage: query_capacity <selected.json> <output.json>") }
|
||||
var selected []struct{ VIN string `json:"vin"` }
|
||||
b,err:=os.ReadFile(os.Args[1]);if err!=nil{panic(err)};if err=json.Unmarshal(b,&selected);err!=nil{panic(err)}
|
||||
prefix := "HYDROGEN_DB_"
|
||||
host := strings.TrimSpace(os.Getenv(prefix + "HOST"))
|
||||
port := strings.TrimSpace(os.Getenv(prefix + "PORT")); if port == "" { port = "3306" }
|
||||
name := strings.TrimSpace(os.Getenv(prefix + "NAME"))
|
||||
user := os.Getenv(prefix + "USER"); pass := os.Getenv(prefix + "PASSWORD")
|
||||
dsn := user+":"+pass+"@tcp("+net.JoinHostPort(host,port)+")/"+name+"?parseTime=true&loc="+url.QueryEscape("Asia/Shanghai")+"&timeout=5s&readTimeout=15s"
|
||||
db,err:=sql.Open("mysql",dsn); if err!=nil{panic(err)}; defer db.Close(); db.SetConnMaxLifetime(time.Minute); if err=db.Ping();err!=nil{panic(err)}
|
||||
vins:=make([]string,0,len(selected));args:=make([]any,0,len(selected));for _,s:=range selected{v:=strings.ToUpper(strings.TrimSpace(s.VIN));vins=append(vins,v);args=append(args,v)}
|
||||
placeholders:=strings.TrimRight(strings.Repeat("?,",len(vins)),",")
|
||||
rows,err:=db.Query(`SELECT UPPER(TRIM(vin)),plate_number,brand_name,model_name,tank_capacity_l,active,source_model_id,source_updated_at,synced_at FROM lingniu_vehicle_data.vehicle_hydrogen_tank_capacity WHERE UPPER(TRIM(vin)) IN (`+placeholders+`) ORDER BY vin`,args...);if err!=nil{panic(err)};defer rows.Close()
|
||||
type row struct{VIN,Plate,Brand,Model string;TankCapacityL float64;Active int;SourceModelID *int64;SourceUpdatedAt, SyncedAt *time.Time}
|
||||
out:=make([]row,0,len(vins));for rows.Next(){var r row;if err=rows.Scan(&r.VIN,&r.Plate,&r.Brand,&r.Model,&r.TankCapacityL,&r.Active,&r.SourceModelID,&r.SourceUpdatedAt,&r.SyncedAt);err!=nil{panic(err)};out=append(out,r)};if err=rows.Err();err!=nil{panic(err)}
|
||||
sort.Slice(out,func(i,j int)bool{return out[i].VIN<out[j].VIN});data,err:=json.MarshalIndent(out,""," ");if err!=nil{panic(err)};if err=os.WriteFile(os.Args[2],data,0o644);err!=nil{panic(err)}
|
||||
counts:=map[float64]int{};models:=map[string]int{};for _,r:=range out{counts[r.TankCapacityL]++;models[r.Model]++}
|
||||
fmt.Printf("requested=%d found=%d capacities=%v models=%v\n",len(vins),len(out),counts,models)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
type metadataRecord struct {
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
Brand string `json:"brand"`
|
||||
Model string `json:"model"`
|
||||
VehicleModelID int64 `json:"vehicle_model_id"`
|
||||
BatteryCapacityKWh float64 `json:"battery_capacity_kwh"`
|
||||
TankCapacityL float64 `json:"tank_capacity_l"`
|
||||
SourceUpdatedAt *time.Time `json:"source_updated_at,omitempty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 2 {
|
||||
panic("usage: query_metadata <output.json>")
|
||||
}
|
||||
dsn := strings.TrimSpace(os.Getenv("MYSQL_DSN"))
|
||||
if dsn == "" {
|
||||
panic("MYSQL_DSN is required")
|
||||
}
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer db.Close()
|
||||
rows, err := db.Query(`SELECT UPPER(TRIM(vi.vin)),COALESCE(vi.plate_number,''),
|
||||
COALESCE(vm.brand,''),COALESCE(vm.model,''),vm.id,vm.battery_capacity,vm.tank_capacity,
|
||||
CASE
|
||||
WHEN vi.update_time IS NULL THEN vm.update_time
|
||||
WHEN vm.update_time IS NULL THEN vi.update_time
|
||||
ELSE GREATEST(vi.update_time,vm.update_time)
|
||||
END
|
||||
FROM ln_asset_management.vehicle_info vi
|
||||
JOIN ln_asset_management.vehicle_model vm ON vm.id=vi.vehicle_model_id
|
||||
WHERE COALESCE(vi.del_flag,'0')='0' AND COALESCE(vm.del_flag,'0')='0'
|
||||
AND LOWER(TRIM(vm.brand))='hyundai'
|
||||
AND LENGTH(TRIM(vi.vin))=17
|
||||
AND vm.tank_capacity>0
|
||||
AND vm.battery_capacity>0
|
||||
ORDER BY vi.vin,vi.update_time DESC,vi.id DESC`)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
byVIN := map[string]metadataRecord{}
|
||||
for rows.Next() {
|
||||
var row metadataRecord
|
||||
var updated sql.NullTime
|
||||
if err := rows.Scan(&row.VIN, &row.Plate, &row.Brand, &row.Model, &row.VehicleModelID, &row.BatteryCapacityKWh, &row.TankCapacityL, &updated); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if _, exists := byVIN[row.VIN]; exists {
|
||||
continue
|
||||
}
|
||||
if updated.Valid {
|
||||
value := updated.Time
|
||||
row.SourceUpdatedAt = &value
|
||||
}
|
||||
byVIN[row.VIN] = row
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
vins := make([]string, 0, len(byVIN))
|
||||
for vin := range byVIN {
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
sort.Strings(vins)
|
||||
out := make([]metadataRecord, 0, len(vins))
|
||||
for _, vin := range vins {
|
||||
out = append(out, byVIN[vin])
|
||||
}
|
||||
output, err := os.Create(os.Args[1])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer output.Close()
|
||||
encoder := json.NewEncoder(output)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(out); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/openplatform"
|
||||
)
|
||||
|
||||
const (
|
||||
baseURL = "http://115.29.187.205:20200"
|
||||
hydrogenEnergyKWh = 16.0
|
||||
maxIntegrationGap = 30 * time.Second
|
||||
)
|
||||
|
||||
type vehicleMetadata struct {
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
Model string `json:"model"`
|
||||
BatteryCapacityKWh float64 `json:"battery_capacity_kwh"`
|
||||
TankCapacityL float64 `json:"tank_capacity_l"`
|
||||
}
|
||||
|
||||
type rawFrame struct {
|
||||
TS string `json:"ts"`
|
||||
FrameID string `json:"frame_id"`
|
||||
EventID string `json:"event_id"`
|
||||
MessageID int `json:"message_id"`
|
||||
EventTime string `json:"event_time"`
|
||||
ParseStatus string `json:"parse_status"`
|
||||
SourceEndpoint string `json:"source_endpoint"`
|
||||
VIN string `json:"vin"`
|
||||
ParsedFields map[string]any `json:"parsed_fields"`
|
||||
}
|
||||
|
||||
type rawResponse struct {
|
||||
Items []rawFrame `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type energySample struct {
|
||||
At time.Time
|
||||
VehicleStatus int
|
||||
ChargeStatus int
|
||||
SOC float64
|
||||
Mileage float64
|
||||
BatteryVoltageV float64
|
||||
BatteryCurrentA float64
|
||||
FuelCellVoltageV float64
|
||||
FuelCellCurrentA float64
|
||||
HasSOC bool
|
||||
HasMileage bool
|
||||
HasBatteryPower bool
|
||||
HasFuelCellPower bool
|
||||
HasVehicleStatus bool
|
||||
HasChargeStatus bool
|
||||
}
|
||||
|
||||
type energyResult struct {
|
||||
FirstTime string `json:"firstTime"`
|
||||
LastTime string `json:"lastTime"`
|
||||
StartSOC float64 `json:"startSoc"`
|
||||
EndSOC float64 `json:"endSoc"`
|
||||
SOCChangePctEndMinusStart float64 `json:"socChangePctEndMinusStart"`
|
||||
StoredEnergyChangeKWhSOC float64 `json:"storedEnergyChangeKWhSoc"`
|
||||
BatteryNetOutputKWhSOC float64 `json:"batteryNetOutputKWhSoc"`
|
||||
BatteryNetOutputKWhIntegrated float64 `json:"batteryNetOutputKWhIntegrated"`
|
||||
FuelCellOutputKWhIntegrated float64 `json:"fuelCellOutputKWhIntegrated"`
|
||||
BatteryCoverageSeconds float64 `json:"batteryCoverageSeconds"`
|
||||
FuelCellCoverageSeconds float64 `json:"fuelCellCoverageSeconds"`
|
||||
OperatingSpanSeconds float64 `json:"operatingSpanSeconds"`
|
||||
BatteryCoverageRatio float64 `json:"batteryCoverageRatio"`
|
||||
FuelCellCoverageRatio float64 `json:"fuelCellCoverageRatio"`
|
||||
ExternalChargeFrameCount int `json:"externalChargeFrameCount"`
|
||||
EnergySampleCount int `json:"energySampleCount"`
|
||||
}
|
||||
|
||||
type row struct {
|
||||
Date string `json:"date"`
|
||||
Plate string `json:"plate"`
|
||||
VIN string `json:"vin"`
|
||||
Model string `json:"model"`
|
||||
RawFrameCount int `json:"rawFrameCount"`
|
||||
CurrentQuality string `json:"currentQuality"`
|
||||
CurrentReason string `json:"currentReason"`
|
||||
RefuelCount int `json:"refuelCount"`
|
||||
ChargeCount int `json:"chargeCount"`
|
||||
TotalMileageKm float64 `json:"totalMileageKm"`
|
||||
PureElectricMileageKm float64 `json:"pureElectricMileageKm"`
|
||||
CurrentMixedMileageKm float64 `json:"currentMixedMileageKm"`
|
||||
PhysicalHydrogenKg float64 `json:"physicalHydrogenKg"`
|
||||
CurrentSOCBalancedKg float64 `json:"currentSocBalancedKg"`
|
||||
CurrentRatePerMixedKm float64 `json:"currentRatePerMixedKm"`
|
||||
PhysicalRatePerTotalKm float64 `json:"physicalRatePerTotalKm"`
|
||||
FullDayStartSOC float64 `json:"fullDayStartSoc"`
|
||||
FullDayEndSOC float64 `json:"fullDayEndSoc"`
|
||||
FullDaySOCChangePct float64 `json:"fullDaySocChangePct"`
|
||||
StoredEnergyChangeKWhSOC float64 `json:"storedEnergyChangeKWhSoc"`
|
||||
BatteryEquivalentKgFixed16 float64 `json:"batteryEquivalentKgFixed16"`
|
||||
StandardLikeBalancedKg float64 `json:"standardLikeBalancedKg"`
|
||||
StandardLikeRatePerTotalKm float64 `json:"standardLikeRatePerTotalKm"`
|
||||
StandardLikeApplicable bool `json:"standardLikeApplicable"`
|
||||
StandardLikeReason string `json:"standardLikeReason"`
|
||||
BatteryNetOutputKWhIntegrated float64 `json:"batteryNetOutputKWhIntegrated"`
|
||||
FuelCellOutputKWhIntegrated float64 `json:"fuelCellOutputKWhIntegrated"`
|
||||
BatteryEnergyShare float64 `json:"batteryEnergyShare"`
|
||||
FuelCellEnergyShare float64 `json:"fuelCellEnergyShare"`
|
||||
BatteryContributionKm float64 `json:"batteryContributionKm"`
|
||||
FuelCellContributionKm float64 `json:"fuelCellContributionKm"`
|
||||
HydrogenRatePerFCContributionKm float64 `json:"hydrogenRatePerFcContributionKm"`
|
||||
BatteryCoverageRatio float64 `json:"batteryCoverageRatio"`
|
||||
FuelCellCoverageRatio float64 `json:"fuelCellCoverageRatio"`
|
||||
ExternalChargeFrameCount int `json:"externalChargeFrameCount"`
|
||||
PowerIntegrationUsable bool `json:"powerIntegrationUsable"`
|
||||
}
|
||||
|
||||
type job struct {
|
||||
Vehicle vehicleMetadata
|
||||
Date string
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 5 {
|
||||
panic("usage: standard-model-trial <metadata.json> <start-date> <end-date> <output-dir>")
|
||||
}
|
||||
metadataPath, startDate, endDate, outputDir := os.Args[1], os.Args[2], os.Args[3], os.Args[4]
|
||||
start := mustDate(startDate)
|
||||
end := mustDate(endDate)
|
||||
if end.Before(start) {
|
||||
panic("end date is before start date")
|
||||
}
|
||||
var vehicles []vehicleMetadata
|
||||
mustReadJSON(metadataPath, &vehicles)
|
||||
if len(vehicles) == 0 {
|
||||
panic("metadata is empty")
|
||||
}
|
||||
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
jobs := make(chan job)
|
||||
results := make(chan row)
|
||||
errs := make(chan error, 128)
|
||||
var wg sync.WaitGroup
|
||||
for worker := 0; worker < 16; worker++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for item := range jobs {
|
||||
result, err := process(item)
|
||||
if err != nil {
|
||||
errs <- fmt.Errorf("%s %s %s: %w", item.Date, item.Vehicle.Plate, item.Vehicle.VIN, err)
|
||||
continue
|
||||
}
|
||||
if result.RawFrameCount > 0 {
|
||||
results <- result
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
go func() {
|
||||
for day := start; !day.After(end); day = day.AddDate(0, 0, 1) {
|
||||
for _, vehicle := range vehicles {
|
||||
if len(strings.TrimSpace(vehicle.VIN)) != 17 || vehicle.TankCapacityL <= 0 || vehicle.BatteryCapacityKWh <= 0 {
|
||||
continue
|
||||
}
|
||||
jobs <- job{Vehicle: vehicle, Date: day.Format("2006-01-02")}
|
||||
}
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
close(errs)
|
||||
}()
|
||||
|
||||
var rows []row
|
||||
completed := 0
|
||||
for result := range results {
|
||||
rows = append(rows, result)
|
||||
completed++
|
||||
if completed%25 == 0 {
|
||||
fmt.Fprintf(os.Stderr, "completed=%d latest=%s %s quality=%s raw=%d\n", completed, result.Date, result.Plate, result.CurrentQuality, result.RawFrameCount)
|
||||
}
|
||||
}
|
||||
var errorMessages []string
|
||||
for err := range errs {
|
||||
errorMessages = append(errorMessages, err.Error())
|
||||
}
|
||||
if len(errorMessages) > 0 {
|
||||
panic(strings.Join(errorMessages, "\n"))
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].Date != rows[j].Date {
|
||||
return rows[i].Date < rows[j].Date
|
||||
}
|
||||
if rows[i].Plate != rows[j].Plate {
|
||||
return rows[i].Plate < rows[j].Plate
|
||||
}
|
||||
return rows[i].VIN < rows[j].VIN
|
||||
})
|
||||
writeJSON(filepath.Join(outputDir, "results.json"), rows)
|
||||
writeCSV(filepath.Join(outputDir, "results.csv"), rows)
|
||||
writeJSON(filepath.Join(outputDir, "summary.json"), summarize(rows))
|
||||
fmt.Printf("vehicle_days=%d output=%s\n", len(rows), outputDir)
|
||||
}
|
||||
|
||||
func process(item job) (row, error) {
|
||||
frames, total, err := fetchFrames(item.Vehicle.VIN, item.Date)
|
||||
if err != nil || total == 0 {
|
||||
return row{Date: item.Date, Plate: item.Vehicle.Plate, VIN: item.Vehicle.VIN, Model: item.Vehicle.Model, RawFrameCount: total}, err
|
||||
}
|
||||
sort.SliceStable(frames, func(i, j int) bool {
|
||||
if frames[i].EventTime != frames[j].EventTime {
|
||||
return frames[i].EventTime < frames[j].EventTime
|
||||
}
|
||||
return frames[i].EventID < frames[j].EventID
|
||||
})
|
||||
observations := make([]openplatform.HydrogenObservation, 0, len(frames))
|
||||
energySamples := make([]energySample, 0, len(frames))
|
||||
for _, frame := range frames {
|
||||
if observation, ok := observationFromFrame(frame, item.Vehicle.TankCapacityL, item.Date); ok {
|
||||
observations = append(observations, observation)
|
||||
}
|
||||
if sample, ok := energySampleFromFrame(frame, item.Date); ok {
|
||||
energySamples = append(energySamples, sample)
|
||||
}
|
||||
}
|
||||
params := map[string]openplatform.HydrogenCalculationParameters{
|
||||
item.Vehicle.VIN: {BatteryCapacityKWh: item.Vehicle.BatteryCapacityKWh, HydrogenEnergyKWhKg: hydrogenEnergyKWh},
|
||||
}
|
||||
stats := openplatform.BuildHydrogenDailyStatsOrderedWithParameters(observations, item.Date, 0.05, 20, params)
|
||||
stat := openplatform.HydrogenDailyStat{VIN: item.Vehicle.VIN, Date: item.Date, QualityStatus: "NO_DATA", QualityReason: "无有效压力温度样本"}
|
||||
if len(stats) == 1 {
|
||||
stat = stats[0]
|
||||
}
|
||||
energy := calculateEnergy(energySamples, item.Vehicle.BatteryCapacityKWh)
|
||||
totalMileage := stat.MixedMileageKm + stat.PureElectricMileageKm
|
||||
result := row{
|
||||
Date: item.Date, Plate: item.Vehicle.Plate, VIN: item.Vehicle.VIN, Model: item.Vehicle.Model,
|
||||
RawFrameCount: total, CurrentQuality: stat.QualityStatus, CurrentReason: stat.QualityReason,
|
||||
RefuelCount: stat.RefuelCount, ChargeCount: stat.ChargeCount,
|
||||
TotalMileageKm: totalMileage, PureElectricMileageKm: stat.PureElectricMileageKm,
|
||||
CurrentMixedMileageKm: stat.MixedMileageKm, PhysicalHydrogenKg: stat.ConsumptionKg,
|
||||
FullDayStartSOC: energy.StartSOC, FullDayEndSOC: energy.EndSOC,
|
||||
FullDaySOCChangePct: energy.SOCChangePctEndMinusStart,
|
||||
StoredEnergyChangeKWhSOC: energy.StoredEnergyChangeKWhSOC,
|
||||
BatteryNetOutputKWhIntegrated: energy.BatteryNetOutputKWhIntegrated,
|
||||
FuelCellOutputKWhIntegrated: energy.FuelCellOutputKWhIntegrated,
|
||||
BatteryCoverageRatio: energy.BatteryCoverageRatio,
|
||||
FuelCellCoverageRatio: energy.FuelCellCoverageRatio,
|
||||
ExternalChargeFrameCount: energy.ExternalChargeFrameCount,
|
||||
}
|
||||
if stat.SOCBalancedConsumptionKg != nil {
|
||||
result.CurrentSOCBalancedKg = *stat.SOCBalancedConsumptionKg
|
||||
}
|
||||
if stat.SOCBalancedKgPer100Km != nil {
|
||||
result.CurrentRatePerMixedKm = *stat.SOCBalancedKgPer100Km
|
||||
}
|
||||
if totalMileage > 0 {
|
||||
result.PhysicalRatePerTotalKm = round(stat.ConsumptionKg * 100 / totalMileage)
|
||||
result.BatteryEquivalentKgFixed16 = round(-energy.StoredEnergyChangeKWhSOC / hydrogenEnergyKWh)
|
||||
result.StandardLikeBalancedKg = round(stat.ConsumptionKg + result.BatteryEquivalentKgFixed16)
|
||||
result.StandardLikeRatePerTotalKm = round(result.StandardLikeBalancedKg * 100 / totalMileage)
|
||||
switch {
|
||||
case energy.ExternalChargeFrameCount > 0:
|
||||
result.StandardLikeReason = "检测到外部充电,整日SOC平衡修正不适用,应按CD/CS分段"
|
||||
case result.StandardLikeBalancedKg < 0:
|
||||
result.StandardLikeReason = "SOC平衡氢量为负,端点、SOC或换算系数需复核"
|
||||
default:
|
||||
result.StandardLikeApplicable = true
|
||||
}
|
||||
} else {
|
||||
result.StandardLikeReason = "无有效总里程"
|
||||
}
|
||||
// The GB/T 43252 contribution trial uses positive battery net output and
|
||||
// fuel-cell output measured over non-external-charge vehicle-on intervals.
|
||||
// Coverage gates prevent sparse power fields from being treated as precise.
|
||||
if totalMileage >= 10 && stat.ConsumptionKg > 0 && energy.ExternalChargeFrameCount == 0 && energy.BatteryCoverageRatio >= 0.98 && energy.FuelCellCoverageRatio >= 0.98 && energy.FuelCellOutputKWhIntegrated > 0 {
|
||||
batteryOutput := math.Max(0, energy.BatteryNetOutputKWhIntegrated)
|
||||
totalOutput := batteryOutput + energy.FuelCellOutputKWhIntegrated
|
||||
if totalOutput > 0 {
|
||||
result.PowerIntegrationUsable = true
|
||||
result.BatteryEnergyShare = round(batteryOutput / totalOutput)
|
||||
result.FuelCellEnergyShare = round(energy.FuelCellOutputKWhIntegrated / totalOutput)
|
||||
result.BatteryContributionKm = round(totalMileage * result.BatteryEnergyShare)
|
||||
result.FuelCellContributionKm = round(totalMileage * result.FuelCellEnergyShare)
|
||||
if result.FuelCellContributionKm > 0 {
|
||||
result.HydrogenRatePerFCContributionKm = round(stat.ConsumptionKg * 100 / result.FuelCellContributionKm)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func calculateEnergy(samples []energySample, batteryCapacityKWh float64) energyResult {
|
||||
result := energyResult{EnergySampleCount: len(samples)}
|
||||
if len(samples) == 0 {
|
||||
return result
|
||||
}
|
||||
for _, sample := range samples {
|
||||
if sample.HasChargeStatus && sample.ChargeStatus == 1 {
|
||||
result.ExternalChargeFrameCount++
|
||||
}
|
||||
}
|
||||
operating := make([]energySample, 0, len(samples))
|
||||
for _, sample := range samples {
|
||||
if sample.HasVehicleStatus && sample.VehicleStatus == 1 && (!sample.HasChargeStatus || sample.ChargeStatus != 1) {
|
||||
operating = append(operating, sample)
|
||||
}
|
||||
}
|
||||
if len(operating) == 0 {
|
||||
return result
|
||||
}
|
||||
result.FirstTime = operating[0].At.Format(time.RFC3339)
|
||||
result.LastTime = operating[len(operating)-1].At.Format(time.RFC3339)
|
||||
result.OperatingSpanSeconds = operating[len(operating)-1].At.Sub(operating[0].At).Seconds()
|
||||
for _, sample := range operating {
|
||||
if sample.HasSOC {
|
||||
result.StartSOC = sample.SOC
|
||||
break
|
||||
}
|
||||
}
|
||||
for index := len(operating) - 1; index >= 0; index-- {
|
||||
if operating[index].HasSOC {
|
||||
result.EndSOC = operating[index].SOC
|
||||
break
|
||||
}
|
||||
}
|
||||
result.SOCChangePctEndMinusStart = round(result.EndSOC - result.StartSOC)
|
||||
result.StoredEnergyChangeKWhSOC = round(batteryCapacityKWh * result.SOCChangePctEndMinusStart / 100)
|
||||
result.BatteryNetOutputKWhSOC = round(-result.StoredEnergyChangeKWhSOC)
|
||||
for index := 1; index < len(operating); index++ {
|
||||
previous, current := operating[index-1], operating[index]
|
||||
delta := current.At.Sub(previous.At)
|
||||
if delta <= 0 || delta > maxIntegrationGap {
|
||||
continue
|
||||
}
|
||||
hours := delta.Hours()
|
||||
if previous.HasBatteryPower && current.HasBatteryPower {
|
||||
power0 := previous.BatteryVoltageV * previous.BatteryCurrentA / 1000
|
||||
power1 := current.BatteryVoltageV * current.BatteryCurrentA / 1000
|
||||
result.BatteryNetOutputKWhIntegrated += (power0 + power1) * 0.5 * hours
|
||||
result.BatteryCoverageSeconds += delta.Seconds()
|
||||
}
|
||||
if previous.HasFuelCellPower && current.HasFuelCellPower {
|
||||
power0 := previous.FuelCellVoltageV * previous.FuelCellCurrentA / 1000
|
||||
power1 := current.FuelCellVoltageV * current.FuelCellCurrentA / 1000
|
||||
result.FuelCellOutputKWhIntegrated += (power0 + power1) * 0.5 * hours
|
||||
result.FuelCellCoverageSeconds += delta.Seconds()
|
||||
}
|
||||
}
|
||||
result.BatteryNetOutputKWhIntegrated = round(result.BatteryNetOutputKWhIntegrated)
|
||||
result.FuelCellOutputKWhIntegrated = round(result.FuelCellOutputKWhIntegrated)
|
||||
if result.OperatingSpanSeconds > 0 {
|
||||
result.BatteryCoverageRatio = round(result.BatteryCoverageSeconds / result.OperatingSpanSeconds)
|
||||
result.FuelCellCoverageRatio = round(result.FuelCellCoverageSeconds / result.OperatingSpanSeconds)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func observationFromFrame(frame rawFrame, tankCapacity float64, statDate string) (openplatform.HydrogenObservation, bool) {
|
||||
if frame.MessageID != 2 || !strings.EqualFold(frame.ParseStatus, "OK") {
|
||||
return openplatform.HydrogenObservation{}, false
|
||||
}
|
||||
pressure, pressureOK := number(frame.ParsedFields["gb32960.fuel_cell.max_hydrogen_pressure_mpa"])
|
||||
temperature, temperatureOK := number(frame.ParsedFields["gb32960.fuel_cell.max_hydrogen_temperature_c"])
|
||||
if !pressureOK || !temperatureOK || pressure <= 0 || pressure > 70 || temperature <= -40 || temperature > 726.85 {
|
||||
return openplatform.HydrogenObservation{}, false
|
||||
}
|
||||
mass, ok := openplatform.PressureHydrogenMassKg(pressure, temperature, tankCapacity)
|
||||
if !ok {
|
||||
return openplatform.HydrogenObservation{}, false
|
||||
}
|
||||
step, _ := openplatform.PressureHydrogenMassKg(math.Max(0, pressure-0.2), temperature, tankCapacity)
|
||||
at, err := parseEventTime(frame.EventTime)
|
||||
if err != nil || at.Format("2006-01-02") != statDate {
|
||||
return openplatform.HydrogenObservation{}, false
|
||||
}
|
||||
parsed, _ := json.Marshal(frame.ParsedFields)
|
||||
_, _, active, known, _ := openplatform.ExtractHydrogenTelemetry(string(parsed))
|
||||
observation := openplatform.HydrogenObservation{
|
||||
VIN: frame.VIN, Source: frame.SourceEndpoint, EventID: frame.EventID, ObservedAt: at,
|
||||
MassKg: mass, TankCapacityLiter: tankCapacity, PressureMPa: pressure, TemperatureC: temperature,
|
||||
NoiseKg: math.Min(1, math.Max(0.05, mass-step)), RefuelThresholdKg: math.Max(1, mass*0.05),
|
||||
FuelCellActive: active, FuelCellStateKnown: known,
|
||||
}
|
||||
if value, ok := number(frame.ParsedFields["gb32960.vehicle.soc_percent"]); ok && value >= 0 && value <= 100 {
|
||||
observation.SOCPercent, observation.SOCKnown = value, true
|
||||
}
|
||||
if value, ok := number(frame.ParsedFields["gb32960.vehicle.total_mileage_km"]); ok && value >= 0 {
|
||||
observation.MileageKm, observation.MileageKnown = value, true
|
||||
}
|
||||
if value, ok := number(frame.ParsedFields["gb32960.vehicle.vehicle_status"]); ok {
|
||||
observation.VehicleState, observation.VehicleStateKnown = int(value), value >= 0 && value <= 255
|
||||
}
|
||||
if value, ok := number(frame.ParsedFields["gb32960.vehicle.charge_status"]); ok {
|
||||
observation.ChargeState, observation.ChargeStateKnown = int(value), value >= 0 && value <= 255
|
||||
}
|
||||
if value, ok := number(frame.ParsedFields["gb32960.vehicle.running_mode"]); ok {
|
||||
observation.RunningMode, observation.RunningModeKnown = int(value), value >= 0 && value <= 255
|
||||
}
|
||||
return observation, true
|
||||
}
|
||||
|
||||
func energySampleFromFrame(frame rawFrame, statDate string) (energySample, bool) {
|
||||
if frame.MessageID != 2 || !strings.EqualFold(frame.ParseStatus, "OK") {
|
||||
return energySample{}, false
|
||||
}
|
||||
at, err := parseEventTime(frame.EventTime)
|
||||
if err != nil || at.Format("2006-01-02") != statDate {
|
||||
return energySample{}, false
|
||||
}
|
||||
sample := energySample{At: at}
|
||||
if value, ok := number(frame.ParsedFields["gb32960.vehicle.vehicle_status"]); ok {
|
||||
sample.VehicleStatus, sample.HasVehicleStatus = int(value), true
|
||||
}
|
||||
if value, ok := number(frame.ParsedFields["gb32960.vehicle.charge_status"]); ok {
|
||||
sample.ChargeStatus, sample.HasChargeStatus = int(value), true
|
||||
}
|
||||
if value, ok := number(frame.ParsedFields["gb32960.vehicle.soc_percent"]); ok && value >= 0 && value <= 100 {
|
||||
sample.SOC, sample.HasSOC = value, true
|
||||
}
|
||||
if value, ok := number(frame.ParsedFields["gb32960.vehicle.total_mileage_km"]); ok && value >= 0 {
|
||||
sample.Mileage, sample.HasMileage = value, true
|
||||
}
|
||||
batteryVoltage, batteryVoltageOK := number(frame.ParsedFields["gb32960.vehicle.total_voltage_v"])
|
||||
batteryCurrent, batteryCurrentOK := number(frame.ParsedFields["gb32960.vehicle.total_current_a"])
|
||||
if batteryVoltageOK && batteryCurrentOK && batteryVoltage > 0 && batteryVoltage <= 1000 && batteryCurrent >= -1000 && batteryCurrent <= 1000 {
|
||||
sample.BatteryVoltageV, sample.BatteryCurrentA, sample.HasBatteryPower = batteryVoltage, batteryCurrent, true
|
||||
}
|
||||
fuelCellVoltage, fuelCellVoltageOK := number(frame.ParsedFields["gb32960.fuel_cell.fuel_cell_voltage_v"])
|
||||
fuelCellCurrent, fuelCellCurrentOK := number(frame.ParsedFields["gb32960.fuel_cell.fuel_cell_current_a"])
|
||||
if fuelCellVoltageOK && fuelCellCurrentOK && fuelCellVoltage >= 0 && fuelCellVoltage <= 2000 && fuelCellCurrent >= 0 && fuelCellCurrent <= 2000 {
|
||||
sample.FuelCellVoltageV, sample.FuelCellCurrentA, sample.HasFuelCellPower = fuelCellVoltage, fuelCellCurrent, true
|
||||
}
|
||||
return sample, sample.HasVehicleStatus || sample.HasSOC || sample.HasBatteryPower || sample.HasFuelCellPower
|
||||
}
|
||||
|
||||
func fetchFrames(vin, date string) ([]rawFrame, int, error) {
|
||||
const limit = 500
|
||||
first, err := fetchPage(vin, date, 0, limit, true)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
frames := append([]rawFrame(nil), first.Items...)
|
||||
for offset := limit; offset < first.Total; offset += limit {
|
||||
page, err := fetchPage(vin, date, offset, limit, false)
|
||||
if err != nil {
|
||||
return nil, first.Total, err
|
||||
}
|
||||
frames = append(frames, page.Items...)
|
||||
}
|
||||
if len(frames) != first.Total {
|
||||
return nil, first.Total, fmt.Errorf("API total=%d fetched=%d", first.Total, len(frames))
|
||||
}
|
||||
return frames, first.Total, nil
|
||||
}
|
||||
|
||||
func fetchPage(vin, date string, offset, limit int, includeTotal bool) (rawResponse, error) {
|
||||
query := url.Values{
|
||||
"protocol": {"GB32960"}, "vin": {vin},
|
||||
"dateFrom": {date + " 00:00:00"}, "dateTo": {date + " 23:59:59"},
|
||||
"orderBy": {"eventTime"}, "limit": {strconv.Itoa(limit)}, "offset": {strconv.Itoa(offset)},
|
||||
"includeFields": {"true"}, "includePayload": {"false"}, "includeTotal": {strconv.FormatBool(includeTotal)},
|
||||
}
|
||||
var response *http.Response
|
||||
var err error
|
||||
for attempt := 1; attempt <= 4; attempt++ {
|
||||
request, _ := http.NewRequest(http.MethodGet, baseURL+"/api/history/raw-frames?"+query.Encode(), nil)
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
response, err = (&http.Client{Timeout: 90 * time.Second}).Do(request)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if attempt < 4 {
|
||||
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return rawResponse{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
if response.StatusCode == http.StatusInternalServerError && bytes.Contains(body, []byte("Table does not exist")) {
|
||||
return rawResponse{}, nil
|
||||
}
|
||||
return rawResponse{}, fmt.Errorf("HTTP %d: %s", response.StatusCode, body)
|
||||
}
|
||||
var reader io.Reader = response.Body
|
||||
if response.Header.Get("Content-Encoding") == "gzip" {
|
||||
gzipReader, err := gzip.NewReader(response.Body)
|
||||
if err != nil {
|
||||
return rawResponse{}, err
|
||||
}
|
||||
defer gzipReader.Close()
|
||||
reader = gzipReader
|
||||
}
|
||||
var result rawResponse
|
||||
if err := json.NewDecoder(reader).Decode(&result); err != nil {
|
||||
return rawResponse{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func summarize(rows []row) map[string]any {
|
||||
byDate := map[string]map[string]any{}
|
||||
for _, date := range uniqueDates(rows) {
|
||||
var dateRows []row
|
||||
for _, value := range rows {
|
||||
if value.Date == date {
|
||||
dateRows = append(dateRows, value)
|
||||
}
|
||||
}
|
||||
quality := map[string]int{}
|
||||
valid := 0
|
||||
standardApplicable := 0
|
||||
powerUsable := 0
|
||||
physicalTotal, balancedTotal, mileageTotal, balancedMileageTotal := 0.0, 0.0, 0.0, 0.0
|
||||
for _, value := range dateRows {
|
||||
quality[value.CurrentQuality]++
|
||||
if value.CurrentQuality != "NO_DATA" && value.TotalMileageKm > 0 {
|
||||
valid++
|
||||
physicalTotal += value.PhysicalHydrogenKg
|
||||
mileageTotal += value.TotalMileageKm
|
||||
}
|
||||
if value.StandardLikeApplicable {
|
||||
standardApplicable++
|
||||
balancedTotal += value.StandardLikeBalancedKg
|
||||
balancedMileageTotal += value.TotalMileageKm
|
||||
}
|
||||
if value.PowerIntegrationUsable {
|
||||
powerUsable++
|
||||
}
|
||||
}
|
||||
physicalRate, balancedRate := 0.0, 0.0
|
||||
if mileageTotal > 0 {
|
||||
physicalRate = round(physicalTotal * 100 / mileageTotal)
|
||||
}
|
||||
if balancedMileageTotal > 0 {
|
||||
balancedRate = round(balancedTotal * 100 / balancedMileageTotal)
|
||||
}
|
||||
byDate[date] = map[string]any{
|
||||
"vehicleDaysWithFrames": len(dateRows), "validVehicleDays": valid, "standardLikeApplicableVehicleDays": standardApplicable, "qualityCounts": quality,
|
||||
"powerIntegrationUsableVehicleDays": powerUsable, "totalMileageKm": round(mileageTotal),
|
||||
"physicalHydrogenKg": round(physicalTotal), "standardLikeBalancedHydrogenKg": round(balancedTotal),
|
||||
"standardLikeMileageKm": round(balancedMileageTotal), "fleetPhysicalRateKgPer100Km": physicalRate, "fleetStandardLikeRateKgPer100Km": balancedRate,
|
||||
}
|
||||
}
|
||||
return map[string]any{"vehicleDays": len(rows), "byDate": byDate}
|
||||
}
|
||||
|
||||
func writeCSV(path string, rows []row) {
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
writer := csv.NewWriter(file)
|
||||
defer writer.Flush()
|
||||
headers := []string{"日期", "车牌", "VIN", "车型", "原始帧数", "当前质量状态", "当前质量原因", "加氢次数", "充电次数", "总里程km", "当前纯电里程km", "当前混动里程km", "物理耗氢kg", "当前SOC平衡氢量kg", "当前修正氢耗_按混动里程", "物理氢耗_按总里程", "全日起始SOC", "全日终止SOC", "全日SOC变化_末减初", "全日电池储能变化kWh", "电池等效氢量_16kWhkg", "标准式平衡氢量kg", "标准式平衡氢耗_按总里程", "标准式修正可用", "标准式修正说明", "电流积分电池净输出kWh", "燃料电池输出积分kWh", "电池能量贡献占比", "燃料电池能量贡献占比", "电池贡献里程km", "燃料电池贡献里程km", "按燃料电池贡献里程氢耗", "电池功率覆盖率", "燃料电池功率覆盖率", "外充状态帧数", "功率积分可用"}
|
||||
_ = writer.Write(headers)
|
||||
for _, value := range rows {
|
||||
_ = writer.Write([]string{
|
||||
value.Date, value.Plate, value.VIN, value.Model, integer(value.RawFrameCount), value.CurrentQuality, value.CurrentReason,
|
||||
integer(value.RefuelCount), integer(value.ChargeCount), decimal(value.TotalMileageKm), decimal(value.PureElectricMileageKm),
|
||||
decimal(value.CurrentMixedMileageKm), decimal(value.PhysicalHydrogenKg), decimal(value.CurrentSOCBalancedKg),
|
||||
decimal(value.CurrentRatePerMixedKm), decimal(value.PhysicalRatePerTotalKm), decimal(value.FullDayStartSOC),
|
||||
decimal(value.FullDayEndSOC), decimal(value.FullDaySOCChangePct), decimal(value.StoredEnergyChangeKWhSOC),
|
||||
decimal(value.BatteryEquivalentKgFixed16), decimal(value.StandardLikeBalancedKg), decimal(value.StandardLikeRatePerTotalKm),
|
||||
strconv.FormatBool(value.StandardLikeApplicable), value.StandardLikeReason,
|
||||
decimal(value.BatteryNetOutputKWhIntegrated), decimal(value.FuelCellOutputKWhIntegrated), decimal(value.BatteryEnergyShare),
|
||||
decimal(value.FuelCellEnergyShare), decimal(value.BatteryContributionKm), decimal(value.FuelCellContributionKm),
|
||||
decimal(value.HydrogenRatePerFCContributionKm), decimal(value.BatteryCoverageRatio), decimal(value.FuelCellCoverageRatio),
|
||||
integer(value.ExternalChargeFrameCount), strconv.FormatBool(value.PowerIntegrationUsable),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func uniqueDates(rows []row) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, value := range rows {
|
||||
seen[value.Date] = true
|
||||
}
|
||||
dates := make([]string, 0, len(seen))
|
||||
for date := range seen {
|
||||
dates = append(dates, date)
|
||||
}
|
||||
sort.Strings(dates)
|
||||
return dates
|
||||
}
|
||||
|
||||
func number(value any) (float64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return typed, true
|
||||
case json.Number:
|
||||
parsed, err := typed.Float64()
|
||||
return parsed, err == nil
|
||||
case string:
|
||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
|
||||
return parsed, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func parseEventTime(value string) (time.Time, error) {
|
||||
location := time.FixedZone("CST", 8*3600)
|
||||
if parsed, err := time.ParseInLocation("2006-01-02 15:04:05.000", value, location); err == nil {
|
||||
return parsed, nil
|
||||
}
|
||||
return time.ParseInLocation("2006-01-02 15:04:05", value, location)
|
||||
}
|
||||
|
||||
func mustDate(value string) time.Time {
|
||||
parsed, err := time.Parse("2006-01-02", value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func mustReadJSON(path string, target any) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := json.Unmarshal(data, target); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(path string, value any) {
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func round(value float64) float64 { return math.Round(value*1000) / 1000 }
|
||||
func decimal(value float64) string { return strconv.FormatFloat(value, 'f', 3, 64) }
|
||||
func integer(value int) string { return strconv.Itoa(value) }
|
||||
@@ -477,7 +477,7 @@ export const api = {
|
||||
}),
|
||||
vehicleRealtime: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<VehicleRealtimeRow>>(`/api/realtime/vehicles?${params.toString()}`, signal ? { signal } : undefined),
|
||||
realtimeLocations: (params = new URLSearchParams()) => request<Page<RealtimeLocationRow>>(`/api/realtime/locations?${params.toString()}`),
|
||||
historyLocations: (params = new URLSearchParams()) => request<Page<HistoryLocationRow>>(`/api/history/locations?${params.toString()}`),
|
||||
historyLocations: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<HistoryLocationRow>>(`/api/history/locations?${params.toString()}`, withSignal(undefined, signal)),
|
||||
rawFrames: (params = new URLSearchParams()) => request<Page<RawFrameRow>>(`/api/history/raw-frames?${params.toString()}`),
|
||||
rawFramesQuery: (query: RawFrameQuery) => request<Page<RawFrameRow>>('/api/history/raw-frames/query', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -381,6 +381,8 @@ export interface AlertQuery { keyword?: string; severity?: string; status?: stri
|
||||
export interface AlertSummary { active: number; unprocessed: number; processing: number; recovered: number; closed: number; ignored: number; unreadNotifications: number; asOf: string; }
|
||||
export interface AlertAction { id: number; action: string; fromStatus: string; toStatus: string; actor: string; note: string; createdAt: string; }
|
||||
export interface AlertEvent {
|
||||
nativeAlarmFields?: Record<string, unknown>;
|
||||
nativeAlarmNames?: string[]; nativeAlarmReservedBits?: number[];
|
||||
id: string; eventType?: string; eventCategory?: string; executionState?: 'pending' | 'processing' | 'recovered' | 'completed' | 'ignored'; ruleId: string; ruleName: string; ruleVersion: number; severity: AlertSeverity; triggerType?: AlertTriggerType; status: AlertStatus;
|
||||
vin: string; plate: string; protocol: string; metric: string; operator: string; triggerValue: number; threshold: number; thresholdHigh: number;
|
||||
unit: string; durationSec: number; location: string; longitude?: number; latitude?: number; sourceEventId: string;
|
||||
@@ -901,6 +903,12 @@ export interface LatestTelemetryResponse {
|
||||
}
|
||||
|
||||
export interface DailyMileageRow {
|
||||
province?: string;
|
||||
city?: string;
|
||||
region?: string;
|
||||
locationTime?: string;
|
||||
locationStatus?: string;
|
||||
hydrogenPhysicalConsumptionKg?: number | null;
|
||||
vin: string;
|
||||
plate: string;
|
||||
date: string;
|
||||
@@ -941,7 +949,7 @@ 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;
|
||||
physicalConsumptionKgPer100Km?: number; consumptionKgPer100Km?: number; socBalancedKgPer100Km?: number; sampleCount: number;
|
||||
refuelCount: number; refuelAmountKg?: number; chargeCount: number; chargeEnergyKWh?: number; validSegmentCount: number; invalidSegmentCount: number;
|
||||
qualityStatus: string; qualityReason: string; algorithmVersion: string; calculatedAt: string;
|
||||
parameters: {
|
||||
|
||||
@@ -14,7 +14,7 @@ const LEGACY_LOCAL_SQL_TIMESTAMP = /\.\d{6}Z$/;
|
||||
|
||||
export const severityLabels: Record<AlertSeverity, string> = { critical: '紧急', major: '重要', minor: '一般' };
|
||||
export const statusLabels: Record<AlertStatus, string> = { unprocessed: '未处理', processing: '处理中', recovered: '已恢复', closed: '已关闭', ignored: '已忽略' };
|
||||
export const actionLabels: Record<string, string> = { trigger: '触发', acknowledge: '已确认', close: '已关闭', ignore: '已忽略', recover: '已恢复', processing: '处理中', recovered: '已恢复', closed: '已关闭', ignored: '已忽略' };
|
||||
export const actionLabels: Record<string, string> = { repair: '修正告警信息', trigger: '触发', acknowledge: '已确认', close: '已关闭', ignore: '已忽略', recover: '已恢复', processing: '处理中', recovered: '已恢复', closed: '已关闭', ignored: '已忽略' };
|
||||
export const metricLabels: Record<string, string> = { speed_kmh: '速度', soc_percent: 'SOC', alarm_active: '协议告警位', freshness_sec: '离线时长', data_delay_sec: '数据延迟' };
|
||||
export const operatorLabels: Record<string, string> = { gt: '>', gte: '≥', lt: '<', lte: '≤', eq: '=', neq: '≠', between: '区间内', outside: '区间外', changed: '状态变化' };
|
||||
export const triggerTypeLabels: Record<string, string> = { metric: '数值触发', geofence: '电子围栏', stationary: '长时间静止', offline: '长时间离线' };
|
||||
|
||||
@@ -145,3 +145,25 @@ export function automationSource(rule: Pick<AlertRule, 'scopeProtocols'>) {
|
||||
if (protocols.length === 1) return protocolEventSources.find((item) => item.protocol === protocols[0])?.label ?? protocols[0];
|
||||
return `${protocols.length} 类协议`;
|
||||
}
|
||||
|
||||
export function isNativeAlarm(event: Pick<AlertEvent, 'ruleId'>) {
|
||||
return event.ruleId === 'native-gb32960-alarm';
|
||||
}
|
||||
|
||||
export function nativeAlarmDetails(event: AlertEvent) {
|
||||
const fields = event.nativeAlarmFields;
|
||||
if (!fields) return [];
|
||||
return [
|
||||
['max_alarm_level', '最高报警等级'],
|
||||
['general_alarm_flag', '通用报警标志'],
|
||||
['battery_faults', '可充电储能装置故障码'],
|
||||
['motor_faults', '驱动电机故障码'],
|
||||
['engine_faults', '发动机故障码'],
|
||||
['other_faults', '其他故障码'],
|
||||
].map(([name, label]) => {
|
||||
const value = fields[`gb32960.alarm.${name}`];
|
||||
let text = value == null ? '未上报' : Array.isArray(value) ? (value.length ? value.join('、') : '无') : String(value);
|
||||
if (name === 'max_alarm_level' && value != null) text = ({ '0': '0 · 无故障', '1': '1 · 一级故障', '2': '2 · 二级故障', '3': '3 · 三级故障', '254': '254 · 异常', '255': '255 · 无效' } as Record<string, string>)[String(value)] ?? text;
|
||||
return { key: label, value: text };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ test('creates a styled numeric mileage workbook with formulas and frozen panes',
|
||||
{ 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, pureElectricMileageKm: 32.5, mixedMileageKm: 56.2, hydrogenConsumptionKg: 3.1, hydrogenConsumptionKgPer100Km: 5.5, hydrogenSocBalancedKg: 3.126, batterySocDeltaPct: -2, chargeCount: 1, chargeEnergyKWh: 12.5, refuelCount: 1, refuelAmountKg: 8.976, hydrogenQualityStatus: 'SUSPECT', hydrogenQualityReason: '疑似管路泄压', source: 'GB32960' },
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', province: '广东省', city: '广州市', region: '华南', locationTime: '2026-07-13 23:00:00', locationStatus: '已解析', startMileageKm: 100, endMileageKm: 188.7, dailyMileageKm: 88.7, pureHydrogenMileageKm: 56.2, pureElectricMileageKm: 32.5, mixedMileageKm: 56.2, hydrogenConsumptionKg: 3.126, hydrogenPhysicalConsumptionKg: 3.1, hydrogenConsumptionKgPer100Km: 5.5, hydrogenSocBalancedKg: 3.126, batterySocDeltaPct: -2, chargeCount: 1, chargeEnergyKWh: 12.5, refuelCount: 1, refuelAmountKg: 8.976, hydrogenQualityStatus: 'SUSPECT', hydrogenQualityReason: '疑似管路泄压', source: 'GB32960' },
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 188.7, endMileageKm: 293.3, dailyMileageKm: 104.6, pureHydrogenMileageKm: 72.2, hydrogenConsumptionKg: 4.2, hydrogenConsumptionKgPer100Km: 5.8, source: 'GB32960' }
|
||||
],
|
||||
sources: [
|
||||
@@ -43,12 +43,12 @@ test('creates a styled numeric mileage workbook with formulas and frozen panes',
|
||||
expect((sheet as unknown as { conditionalFormattings: unknown[] }).conditionalFormattings).toHaveLength(1);
|
||||
const hydrogenSheet = workbook.getWorksheet('氢能明细')!;
|
||||
expect(hydrogenSheet.getRow(1).values).toEqual([
|
||||
undefined, '日期', '车牌', 'VIN', '品牌', '车型', '每日氢耗 (kg/100km)', '结果状态', '总里程 (km)', '纯电里程 (km)', '混动里程 (km)', '电SOC差值 (百分点)', '充电次数', '充电量 (kWh)', '加氢次数', '加氢量 (kg)', '修正用氢量 (kg)', '原因', '物理用氢量 (kg)', '来源'
|
||||
undefined, '日期', '车牌', '当天所在省', '当天所在市', '大区', 'VIN', '品牌', '车型', '每日氢耗 (kg/100km)', '结果状态', '总里程 (km)', '纯电里程 (km)', '混动里程 (km)', '电SOC差值 (百分点)', '充电次数', '充电量 (kWh)', '加氢次数', '加氢量 (kg)', '修正用氢量 (kg)', '原因', '物理用氢量 (kg)', '来源', '定位时间(当日最后有效点)', '位置状态'
|
||||
]);
|
||||
expect(hydrogenSheet.getRow(2).values).toEqual([
|
||||
undefined, '2026-07-13', '粤A12345', 'LTEST000000000001', '现代', 'XCIENT', 5.5, 'SUSPECT', 88.7, 32.5, 56.2, -2, 1, 12.5, 1, 8.976, 3.126, '疑似管路泄压', 3.1, 'GB32960'
|
||||
undefined, '2026-07-13', '粤A12345', '广东省', '广州市', '华南', 'LTEST000000000001', '现代', 'XCIENT', 5.5, 'SUSPECT', 88.7, 32.5, 56.2, -2, 1, 12.5, 1, 8.976, 3.126, '疑似管路泄压', 3.1, 'GB32960', '2026-07-13 23:00:00', '已解析'
|
||||
]);
|
||||
expect(hydrogenSheet.autoFilter).toEqual({ from: { row: 1, column: 1 }, to: { row: 3, column: 19 } });
|
||||
expect(hydrogenSheet.autoFilter).toEqual({ from: { row: 1, column: 1 }, to: { row: 3, column: 24 } });
|
||||
expect((await workbook.xlsx.writeBuffer()).byteLength).toBeGreaterThan(5_000);
|
||||
});
|
||||
|
||||
@@ -93,18 +93,20 @@ test('terminates the workbook worker when an active export is cancelled', async
|
||||
dateTo: '2026-07-14',
|
||||
dates: ['2026-07-13', '2026-07-14'],
|
||||
vehicles: [],
|
||||
mileageRows: [{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 100, endMileageKm: 188.7, dailyMileageKm: 88.7, source: 'GB32960' }],
|
||||
mileageRows: [{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', province: '广东省', city: '广州市', region: '华南', locationTime: '2026-07-13 23:00:00', locationStatus: '已解析', startMileageKm: 100, endMileageKm: 188.7, dailyMileageKm: 88.7, source: 'GB32960' }],
|
||||
sources: []
|
||||
}, controller.signal);
|
||||
|
||||
expect(postMessage).toHaveBeenCalledTimes(3);
|
||||
expect(postMessage.mock.calls.map(([message]) => message.type)).toEqual(['start', 'rows', 'finish']);
|
||||
expect(postMessage.mock.calls[1][0].rows).toEqual([{
|
||||
province: '广东省', city: '广州市', region: '华南', locationTime: '2026-07-13 23:00:00', locationStatus: '已解析',
|
||||
vin: 'LTEST000000000001',
|
||||
date: '2026-07-13',
|
||||
dailyMileageKm: 88.7,
|
||||
pureHydrogenMileageKm: undefined,
|
||||
hydrogenConsumptionKg: undefined,
|
||||
hydrogenPhysicalConsumptionKg: undefined,
|
||||
hydrogenConsumptionKgPer100Km: undefined,
|
||||
hydrogenSocBalancedKg: undefined,
|
||||
pureElectricMileageKm: undefined,
|
||||
@@ -143,3 +145,10 @@ test('releases a streaming export before finish when the route aborts', async ()
|
||||
expect(terminate).toHaveBeenCalledTimes(1);
|
||||
await expect(stream.finish([{ vin: 'VIN001', plate: '粤A00001' }])).rejects.toMatchObject({ name: 'AbortError' });
|
||||
});
|
||||
|
||||
|
||||
test('hydrogen export opens the hydrogen sheet with geography in the first five columns', async () => {
|
||||
const workbook = await createMileageWorkbook({ metricView: 'hydrogen', dateFrom: '2026-09-01', dateTo: '2026-09-01', dates: ['2026-09-01'], vehicles: [], mileageRows: [], sources: [] });
|
||||
expect(workbook.worksheets[0].name).toBe('氢能明细');
|
||||
expect(Array.from(workbook.worksheets[0].getRow(1).values as unknown[]).slice(1, 6)).toEqual(['日期', '车牌', '当天所在省', '当天所在市', '大区']);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ export type MileageExportSource = { protocol: string; label: string; mileageType
|
||||
export type MileageWorkbookRow = Pick<DailyMileageRow, 'vin' | 'date' | 'dailyMileageKm'> & Partial<Omit<DailyMileageRow, 'vin' | 'date' | 'dailyMileageKm'>>;
|
||||
|
||||
export type MileageExportInput = {
|
||||
metricView?: 'mileage' | 'hydrogen';
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
dates: string[];
|
||||
@@ -68,7 +69,7 @@ export function createMileageExportStream(input: Omit<MileageExportInput, 'vehic
|
||||
}
|
||||
const buffer = event.data.buffer;
|
||||
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
downloadBlob(blob, `车辆里程查询_${input.dateFrom.split('-').join('')}-${input.dateTo.split('-').join('')}_${finishVehicles.length}辆.xlsx`);
|
||||
downloadBlob(blob, `车辆${input.metricView === 'hydrogen' ? '氢耗明细' : '里程查询'}_${input.dateFrom.split('-').join('')}-${input.dateTo.split('-').join('')}_${finishVehicles.length}辆.xlsx`);
|
||||
resolveFinish?.();
|
||||
dispose();
|
||||
};
|
||||
@@ -84,11 +85,13 @@ export function createMileageExportStream(input: Omit<MileageExportInput, 'vehic
|
||||
post({
|
||||
type: 'rows',
|
||||
rows: rows.map((row) => ({
|
||||
province: row.province, city: row.city, region: row.region, locationTime: row.locationTime, locationStatus: row.locationStatus,
|
||||
vin: row.vin,
|
||||
date: row.date,
|
||||
dailyMileageKm: row.dailyMileageKm,
|
||||
pureHydrogenMileageKm: row.pureHydrogenMileageKm,
|
||||
hydrogenConsumptionKg: row.hydrogenConsumptionKg,
|
||||
hydrogenPhysicalConsumptionKg: row.hydrogenPhysicalConsumptionKg,
|
||||
hydrogenConsumptionKgPer100Km: row.hydrogenConsumptionKgPer100Km,
|
||||
hydrogenSocBalancedKg: row.hydrogenSocBalancedKg,
|
||||
pureElectricMileageKm: row.pureElectricMileageKm,
|
||||
@@ -126,6 +129,7 @@ export function createMileageExportStream(input: Omit<MileageExportInput, 'vehic
|
||||
|
||||
export async function downloadMileageWorkbook(input: MileageExportInput, signal?: AbortSignal) {
|
||||
const stream = createMileageExportStream({
|
||||
metricView: input.metricView,
|
||||
dateFrom: input.dateFrom,
|
||||
dateTo: input.dateTo,
|
||||
dates: input.dates,
|
||||
|
||||
@@ -26,6 +26,8 @@ export async function createMileageWorkbook(input: MileageExportInput) {
|
||||
workbook.modified = input.exportedAt ?? new Date();
|
||||
workbook.calcProperties.fullCalcOnLoad = true;
|
||||
|
||||
// Create the selected view first so Excel/WPS opens the requested export.
|
||||
const primaryHydrogenSheet = input.metricView === 'hydrogen' ? workbook.addWorksheet('氢能明细') : undefined;
|
||||
const sheet = workbook.addWorksheet('里程查询', {
|
||||
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 } },
|
||||
@@ -133,14 +135,13 @@ export async function createMileageWorkbook(input: MileageExportInput) {
|
||||
}
|
||||
sheet.headerFooter.oddFooter = '&L灵牛车辆数据中台&C第 &P / &N 页&R导出于 ' + localDateTime(exportedAt);
|
||||
|
||||
const hydrogenSheet = workbook.addWorksheet('氢能明细', {
|
||||
views: [{ state: 'frozen', ySplit: 1, activeCell: 'A2', showGridLines: false }],
|
||||
properties: { defaultRowHeight: 21 }
|
||||
});
|
||||
const hydrogenSheet = primaryHydrogenSheet ?? workbook.addWorksheet('氢能明细');
|
||||
hydrogenSheet.views = [{ state: 'frozen', xSplit: 2, ySplit: 1, activeCell: 'C2', showGridLines: false }];
|
||||
hydrogenSheet.properties.defaultRowHeight = 21;
|
||||
const hydrogenHeaders = [
|
||||
'日期', '车牌', 'VIN', '品牌', '车型', '每日氢耗 (kg/100km)', '结果状态', '总里程 (km)',
|
||||
'日期', '车牌', '当天所在省', '当天所在市', '大区', 'VIN', '品牌', '车型', '每日氢耗 (kg/100km)', '结果状态', '总里程 (km)',
|
||||
'纯电里程 (km)', '混动里程 (km)', '电SOC差值 (百分点)', '充电次数', '充电量 (kWh)', '加氢次数', '加氢量 (kg)',
|
||||
'修正用氢量 (kg)', '原因', '物理用氢量 (kg)', '来源'
|
||||
'修正用氢量 (kg)', '原因', '物理用氢量 (kg)', '来源', '定位时间(当日最后有效点)', '位置状态'
|
||||
];
|
||||
hydrogenSheet.addRow(hydrogenHeaders);
|
||||
const hydrogenHeader = hydrogenSheet.getRow(1);
|
||||
@@ -158,6 +159,7 @@ export async function createMileageWorkbook(input: MileageExportInput) {
|
||||
const row = hydrogenSheet.addRow([
|
||||
value.date,
|
||||
vehicle?.plate || value.plate || '未绑定',
|
||||
value.province || '未知', value.city || '未知', value.region || '未知',
|
||||
value.vin,
|
||||
vehicle?.brandName || '待维护',
|
||||
vehicle?.modelName || '待维护',
|
||||
@@ -173,20 +175,21 @@ export async function createMileageWorkbook(input: MileageExportInput) {
|
||||
value.refuelAmountKg ?? null,
|
||||
value.hydrogenSocBalancedKg ?? null,
|
||||
value.hydrogenQualityReason || '',
|
||||
value.hydrogenConsumptionKg ?? null,
|
||||
value.source ?? ''
|
||||
value.hydrogenPhysicalConsumptionKg ?? null,
|
||||
value.source ?? '',
|
||||
value.locationTime || '', value.locationStatus || '未查询'
|
||||
]);
|
||||
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 >= 6 && columnNumber <= 16 ? 'right' : 'left' };
|
||||
cell.font = { name: columnNumber === 6 ? 'Consolas' : 'Microsoft YaHei', size: columnNumber === 6 ? 9 : 10, color: { argb: 'FF34445A' } };
|
||||
cell.alignment = { vertical: 'middle', horizontal: columnNumber >= 9 && columnNumber <= 19 ? 'right' : 'left' };
|
||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFF9FBFD' : 'FFFFFFFF' } };
|
||||
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } };
|
||||
if ([6, 8, 9, 10, 11, 13, 15, 16, 18].includes(columnNumber)) cell.numFmt = '#,##0.000';
|
||||
if ([12, 14].includes(columnNumber)) cell.numFmt = '0';
|
||||
if ([9, 11, 12, 13, 14, 16, 18, 19, 21].includes(columnNumber)) cell.numFmt = '#,##0.000';
|
||||
if ([15, 17].includes(columnNumber)) cell.numFmt = '0';
|
||||
});
|
||||
});
|
||||
[13, 15, 24, 18, 24, 22, 14, 15, 17, 17, 22, 12, 17, 12, 16, 19, 52, 19, 16].forEach((width, index) => {
|
||||
[13, 15, 18, 18, 12, 24, 18, 24, 22, 14, 15, 17, 17, 22, 12, 17, 12, 16, 19, 52, 19, 16, 30, 24].forEach((width, index) => {
|
||||
hydrogenSheet.getColumn(index + 1).width = width;
|
||||
});
|
||||
if (energyRows.length) {
|
||||
|
||||
@@ -1359,3 +1359,42 @@ test('opens a recent automation run in a focused event inbox', async () => {
|
||||
expect(screen.getByTestId('alert-route-state')).not.toHaveTextContent('eventId=');
|
||||
expect(screen.getByTestId('alert-route-state')).not.toHaveTextContent('runFromAutomationId=');
|
||||
});
|
||||
|
||||
|
||||
test.each([false, true])('displays native GB32960 alarms without automation (mobile=%s)', async (mobile) => {
|
||||
layout.mobile = mobile;
|
||||
const event: AlertEvent = {
|
||||
...alertEvent('native-event', 'NATIVEVIN', '粤A原生01'),
|
||||
ruleId: 'native-gb32960-alarm', ruleName: '绝缘报警、驱动电机温度报警', ruleVersion: 0,
|
||||
protocol: 'GB32960', metric: 'alarm_active',
|
||||
nativeAlarmNames: ['绝缘报警', '驱动电机温度报警'], nativeAlarmReservedBits: [20, 21],
|
||||
nativeAlarmFields: {
|
||||
'gb32960.alarm.max_alarm_level': 2,
|
||||
'gb32960.alarm.general_alarm_flag': '0x00000004',
|
||||
'gb32960.alarm.battery_faults': ['0x00000001'],
|
||||
'gb32960.alarm.motor_faults': [],
|
||||
'gb32960.alarm.engine_faults': ['0x00000002'],
|
||||
'gb32960.alarm.other_faults': [],
|
||||
},
|
||||
};
|
||||
mocks.alertSummaryV2.mockResolvedValue({ active: 1, unprocessed: 1, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
|
||||
mocks.alertEventsV2.mockResolvedValue({ items: [event], total: 1, limit: 20, offset: 0 });
|
||||
mocks.alertEventV2.mockResolvedValue(event);
|
||||
mocks.alertRulesV2.mockResolvedValue([]);
|
||||
mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?eventId=native-event']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
|
||||
const fields = await screen.findByRole('region', { name: '32960 原生告警字段' });
|
||||
await waitFor(() => expect(fields).toHaveTextContent('2 · 二级故障'));
|
||||
expect(fields).toHaveTextContent('0x00000004');
|
||||
expect(fields).toHaveTextContent('可充电储能装置故障码0x00000001');
|
||||
expect(fields).toHaveTextContent('驱动电机故障码无');
|
||||
expect(fields).toHaveTextContent('发动机故障码0x00000002');
|
||||
expect(fields).toHaveTextContent('其他故障码无');
|
||||
expect(screen.queryByLabelText('事件证据')).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('具体告警信息')).toHaveTextContent('绝缘报警');
|
||||
expect(screen.getByLabelText('具体告警信息')).toHaveTextContent('驱动电机温度报警');
|
||||
expect(screen.getByLabelText('具体告警信息')).toHaveTextContent('bit20、bit21');
|
||||
expect(screen.getByLabelText('事件执行轨迹')).toHaveTextContent('车辆原生告警入库');
|
||||
expect(screen.getByLabelText('事件执行轨迹')).not.toHaveTextContent('匹配自动化');
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { AlertEvent, AlertNotification, AlertNotificationChannelCapability, AlertNotificationConfig, AlertNotificationRetryResult, AlertNotificationTarget, AlertQuery, AlertRule, AlertRuleInput, AlertRulePage, AlertRuleRevision, AlertStatus, AlertTriggerType, MetricDefinition, Page } from '../../api/types';
|
||||
import { actionLabels, alertDeltaText, alertValue, canAct, formatAlertDuration, formatAlertTime, operatorLabels, ruleCondition, thresholdText, triggerTypeLabels } from '../domain/alert';
|
||||
import { automationEventType, automationSource, eventContract, eventEvidenceHistoryPath, protocolEventSources, toVehicleEvent } from '../domain/event';
|
||||
import { automationEventType, automationSource, eventContract, eventEvidenceHistoryPath, isNativeAlarm, nativeAlarmDetails, protocolEventSources, toVehicleEvent } from '../domain/event';
|
||||
import { InlineError, PanelEmpty, PanelError, PanelLoading } from '../shared/AsyncState';
|
||||
import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet';
|
||||
import { ProtocolTag } from '../shared/ProtocolTag';
|
||||
@@ -341,7 +341,7 @@ const AlertEventTable = memo(function AlertEventTable({ rows, selectedID, compac
|
||||
},
|
||||
{ title: '协议', dataIndex: 'protocol', className: 'is-protocol', width: 98, render: (value: string) => <ProtocolTag className="v2-alert-protocol-tag" protocol={value} compact /> },
|
||||
{ title: '发生时间', dataIndex: 'triggeredAt', className: 'is-triggered-at', width: 124, render: (value: string) => <AlertTime value={value} /> },
|
||||
{ title: '匹配自动化', dataIndex: 'ruleName', className: 'is-rule', width: 154, render: (value: string) => <span className="v2-alert-event-rule" title={value}>{value}</span> },
|
||||
{ title: '事件来源 / 自动化', dataIndex: 'ruleName', className: 'is-rule', width: 154, render: (value: string, event: AlertEvent) => <span className="v2-alert-event-rule" title={value}>{isNativeAlarm(event) ? '车辆原生告警' : value}</span> },
|
||||
{ title: '执行状态', dataIndex: 'status', className: 'is-status', width: 92, render: (_: AlertEvent['status'], event: AlertEvent) => <EventExecutionTag event={event} /> },
|
||||
];
|
||||
return compact
|
||||
@@ -390,14 +390,22 @@ function EventInspector({ event, loading = false, detailError, note, acting, act
|
||||
<div className="v2-alert-focus-facts" aria-label="事件上下文">
|
||||
<span><small>协议来源</small><strong><ProtocolTag className="v2-alert-protocol-tag" protocol={event.protocol} compact unknownLabel="未知协议" /></strong></span>
|
||||
<span><small>发生时间</small><strong><AlertTime value={normalized.occurredAt} /></strong></span>
|
||||
<span><small>匹配自动化</small><strong>v{event.ruleVersion}</strong></span>
|
||||
<span><small>{isNativeAlarm(event) ? '事件来源' : '匹配自动化'}</small><strong>{isNativeAlarm(event) ? '车辆原生告警' : `v${event.ruleVersion}`}</strong></span>
|
||||
</div>
|
||||
<div className="v2-alert-evidence" aria-label="事件证据">
|
||||
{isNativeAlarm(event) ? <section aria-label="32960 原生告警字段">
|
||||
<div className="v2-native-alarm-meaning" aria-label="具体告警信息">
|
||||
<strong>具体告警信息</strong>
|
||||
{event.nativeAlarmNames?.length ? <ul>{event.nativeAlarmNames.map((name) => <li key={name}>{name}</li>)}</ul> : <p>{event.ruleName}</p>}
|
||||
{event.nativeAlarmReservedBits?.length ? <p>预留位 {event.nativeAlarmReservedBits.map((bit) => `bit${bit}`).join('、')} 置位,未计入具体告警。</p> : null}
|
||||
</div>
|
||||
<p>触发时车辆上报的原始字段</p>
|
||||
{event.nativeAlarmFields ? <Descriptions className="v2-alert-descriptions" align="left" size="small" data={nativeAlarmDetails(event)} /> : <p>{loading ? '正在读取告警字段…' : '告警字段暂不可用'}</p>}
|
||||
</section> : <div className="v2-alert-evidence" aria-label="事件证据">
|
||||
<Card className="v2-alert-evidence-value is-trigger"><small>观测值</small><strong>{alertValue(event)}</strong></Card>
|
||||
<Card className="v2-alert-evidence-value"><small>匹配条件</small><strong>{thresholdText(event)}</strong></Card>
|
||||
<Card className="v2-alert-evidence-value is-delta"><small>变化幅度</small><strong>{alertDeltaText(event)}</strong></Card>
|
||||
</div>
|
||||
<div className="v2-alert-focus-rule"><Tag color="blue" type="light" size="small">自动化</Tag><span>{event.ruleName}</span></div>
|
||||
</div>}
|
||||
<div className="v2-alert-focus-rule"><Tag color="blue" type="light" size="small">{isNativeAlarm(event) ? '车辆原生告警' : '自动化'}</Tag><span>{event.ruleName}</span></div>
|
||||
</Card>
|
||||
{normalized.execution.requiresAttention ? <Card className="v2-alert-detail-card v2-alert-disposition-card" title={<span className="v2-alert-detail-title"><strong>处理说明</strong><Tag color={editable ? 'orange' : 'grey'} type="light" size="small">{editable ? '选填' : '只读'}</Tag></span>} headerLine aria-label="事件处置操作">
|
||||
{editable ? <><TextArea maxCount={200} autosize={{ minRows: 2, maxRows: 4 }} placeholder="补充处理说明(选填)" value={note} onChange={onNote} />{actionError ? <p className="v2-alert-action-error">{actionError}</p> : null}</> : <p className="v2-role-notice">当前为只读角色,可查看完整证据与执行记录。</p>}
|
||||
@@ -406,8 +414,8 @@ function EventInspector({ event, loading = false, detailError, note, acting, act
|
||||
<Card className="v2-alert-detail-card v2-alert-progress-card" title={<span className="v2-alert-detail-title"><strong>执行时间线</strong><Tag color="blue" type="light" size="small">{normalized.execution.label}</Tag></span>} headerLine>
|
||||
<Timeline className="v2-alert-timeline v2-event-execution-trace" aria-label="事件执行轨迹">
|
||||
<Timeline.Item time={<AlertTime value={event.receivedAt} />}><strong>事件已接收</strong><p>{event.protocol} 原始事件进入事件中心</p></Timeline.Item>
|
||||
<Timeline.Item time={<AlertTime value={event.triggeredAt} />}><strong>匹配自动化</strong><p>{event.ruleName} · v{event.ruleVersion}</p></Timeline.Item>
|
||||
<Timeline.Item type={normalized.execution.requiresAttention ? 'ongoing' : 'default'} time={<AlertTime value={event.triggeredAt} />}><strong>{normalized.execution.requiresAttention ? '创建待办并通知' : '执行动作并记录'}</strong><p>{normalized.execution.requiresAttention ? '等待人工确认' : normalized.execution.label}</p></Timeline.Item>
|
||||
<Timeline.Item time={<AlertTime value={event.triggeredAt} />}><strong>{isNativeAlarm(event) ? '车辆原生告警入库' : '匹配自动化'}</strong><p>{event.ruleName}{isNativeAlarm(event) ? '' : ` · v${event.ruleVersion}`}</p></Timeline.Item>
|
||||
<Timeline.Item type={normalized.execution.requiresAttention ? 'ongoing' : 'default'} time={<AlertTime value={event.triggeredAt} />}><strong>{isNativeAlarm(event) ? '记录告警事件' : normalized.execution.requiresAttention ? '创建待办并通知' : '执行动作并记录'}</strong><p>{normalized.execution.requiresAttention ? '等待人工确认' : normalized.execution.label}</p></Timeline.Item>
|
||||
{event.actions?.filter((item) => item.action !== 'trigger').map((item) => <Timeline.Item key={item.id} type="default" time={<span>{item.actor} · <AlertTime value={item.createdAt} /></span>}><strong>{actionLabels[item.action] ?? item.action}</strong>{item.note ? <p>{item.note}</p> : null}</Timeline.Item>)}
|
||||
</Timeline>
|
||||
</Card>
|
||||
@@ -420,7 +428,7 @@ function EventInspector({ event, loading = false, detailError, note, acting, act
|
||||
...eventContract(event).map((item) => ({ key: item.key, value: <code>{item.value || '—'}</code> })),
|
||||
{ key: 'event.id', value: <code>{event.id}</code> },
|
||||
{ key: 'source.event_id', value: <code>{event.sourceEventId || '—'}</code> },
|
||||
{ key: 'automation', value: <code>{event.ruleId} / v{event.ruleVersion}</code> }
|
||||
{ key: isNativeAlarm(event) ? 'source.kind' : 'automation', value: <code>{isNativeAlarm(event) ? 'native-gb32960' : `${event.ruleId} / v${event.ruleVersion}`}</code> }
|
||||
]} />
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
@@ -567,7 +575,7 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, editable,
|
||||
aria-label={mobileLayout ? '事件列表,可上下滚动' : undefined}
|
||||
>
|
||||
{mobileLayout
|
||||
? <div className="v2-alert-mobile-list">{rows.map((event) => <Card key={event.id} className={`v2-alert-mobile-card${selectedID === event.id ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}><Button theme="borderless" type="tertiary" className="v2-alert-mobile-action" aria-pressed={selectedID === event.id} aria-expanded={selectedID === event.id} aria-label={`查看 ${event.plate || event.vin} ${event.ruleName} 事件详情`} onClick={() => selectEvent(event.id)}><span className="v2-alert-mobile-card-content"><header><strong>{event.ruleName}</strong><EventExecutionTag event={event} /></header><p><strong>{event.plate || '未绑定车牌'}</strong><span>{event.vin}</span></p><div className="v2-alert-mobile-facts"><ProtocolTag className="v2-alert-protocol-tag" protocol={event.protocol} compact /><span><AlertTime value={event.triggeredAt} /></span><span className="v2-alert-mobile-condition"><i>匹配条件</i>{thresholdText(event)}</span></div><footer aria-hidden="true"><IconChevronRight /></footer></span></Button></Card>)}</div>
|
||||
? <div className="v2-alert-mobile-list">{rows.map((event) => <Card key={event.id} className={`v2-alert-mobile-card${selectedID === event.id ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}><Button theme="borderless" type="tertiary" className="v2-alert-mobile-action" aria-pressed={selectedID === event.id} aria-expanded={selectedID === event.id} aria-label={`查看 ${event.plate || event.vin} ${event.ruleName} 事件详情`} onClick={() => selectEvent(event.id)}><span className="v2-alert-mobile-card-content"><header><strong>{event.ruleName}</strong><EventExecutionTag event={event} /></header><p><strong>{event.plate || '未绑定车牌'}</strong><span>{event.vin}</span></p><div className="v2-alert-mobile-facts"><ProtocolTag className="v2-alert-protocol-tag" protocol={event.protocol} compact /><span><AlertTime value={event.triggeredAt} /></span><span className="v2-alert-mobile-condition"><i>{isNativeAlarm(event) ? '事件来源' : '匹配条件'}</i>{isNativeAlarm(event) ? '车辆原生告警' : thresholdText(event)}</span></div><footer aria-hidden="true"><IconChevronRight /></footer></span></Button></Card>)}</div>
|
||||
: <AlertEventTable rows={rows} selectedID={selectedID} compact={Boolean(selectedID)} onSelect={selectEvent} />}
|
||||
{events.isFetching ? <PanelLoading className="v2-alert-loading" title="正在更新事件…" description="告警列表返回后会自动更新。" compact={Boolean(rows.length)} /> : null}
|
||||
{!events.isFetching && !events.isError && !rows.length ? <PanelEmpty
|
||||
|
||||
@@ -7,7 +7,7 @@ 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(), hydrogenDailyEvidence: vi.fn(), vehicles: vi.fn(), vehicleCoverage: vi.fn(), vehicleServiceOverviews: vi.fn() }));
|
||||
const mocks = vi.hoisted(() => ({ historyLocations: vi.fn(), 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' }));
|
||||
@@ -24,6 +24,7 @@ function renderPage(initialEntry = '/statistics') {
|
||||
}
|
||||
|
||||
function prepareData() {
|
||||
mocks.historyLocations.mockResolvedValue({ items: [], total: 0 });
|
||||
exportMocks.finish.mockResolvedValue(undefined);
|
||||
exportMocks.createMileageExportStream.mockReturnValue({ appendRows: exportMocks.appendRows, finish: exportMocks.finish, dispose: exportMocks.dispose });
|
||||
mocks.mileageStatistics.mockResolvedValue({
|
||||
@@ -35,8 +36,8 @@ function prepareData() {
|
||||
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, 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' }
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 119731.7, endMileageKm: 119820.4, dailyMileageKm: 88.7, pureHydrogenMileageKm: 56.2, hydrogenConsumptionKg: 2.9, hydrogenSocBalancedKg: 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.0, hydrogenSocBalancedKg: 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',
|
||||
@@ -147,14 +148,14 @@ test('renders only the desktop matrix with dates as columns and a period total',
|
||||
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', '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(hydrogenGuide).toHaveTextContent('百公里氢耗 修正用氢量÷纯氢里程×100');
|
||||
expect(screen.getByRole('region', { name: '车辆每日氢耗矩阵,可横向滚动查看日期' })).toBeInTheDocument();
|
||||
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 个有效车辆日');
|
||||
@@ -655,3 +656,16 @@ test('aborts an active export when the mileage route unmounts', async () => {
|
||||
expect(exportSignal?.aborted).toBe(true);
|
||||
expect(exportMocks.createMileageExportStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
test('exports stored daily geography without querying history or geocoding', async () => {
|
||||
prepareData();
|
||||
const row = { vin: 'LTEST000000000001', date: '2026-07-13', dailyMileageKm: 100, province: '广东省', city: '广州市', region: '华南', locationTime: '2026-07-13T23:00:00+08:00', locationStatus: '已解析' };
|
||||
mocks.dailyMileage.mockResolvedValue({ items: [row], total: 1, limit: 10000, offset: 0 });
|
||||
renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-13');
|
||||
fireEvent.click(await screen.findByRole('button', { name: '氢耗' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '导出 Excel' }));
|
||||
await waitFor(() => expect(exportMocks.finish).toHaveBeenCalledTimes(1));
|
||||
expect(exportMocks.appendRows).toHaveBeenCalledWith([row]);
|
||||
expect(mocks.historyLocations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -47,7 +47,7 @@ type HydrogenMatrixMetric = 'consumption' | 'pureElectric' | 'pureHydrogen' | 'r
|
||||
type MatrixMetric = 'mileage' | HydrogenMatrixMetric;
|
||||
|
||||
const HYDROGEN_MATRIX_METRICS: Array<{ key: HydrogenMatrixMetric; label: string }> = [
|
||||
{ key: 'consumption', label: '用氢量' },
|
||||
{ key: 'consumption', label: '修正用氢量' },
|
||||
{ key: 'pureElectric', label: '纯电里程' },
|
||||
{ key: 'pureHydrogen', label: '纯氢里程' },
|
||||
{ key: 'rate', label: '百公里氢耗' }
|
||||
@@ -55,7 +55,7 @@ const HYDROGEN_MATRIX_METRICS: Array<{ key: HydrogenMatrixMetric; label: string
|
||||
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 },
|
||||
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 }
|
||||
@@ -633,7 +633,8 @@ function SummaryRail({ data, criteria, fleetTotal, loading, view, unavailable =
|
||||
}
|
||||
|
||||
type HydrogenDayMetric = {
|
||||
consumptionKg: number;
|
||||
consumptionKg?: number;
|
||||
matchedMileageKm: number;
|
||||
rateKgPer100Km?: number;
|
||||
socBalancedKg?: number;
|
||||
socBalancedRateKgPer100Km?: number;
|
||||
@@ -690,10 +691,10 @@ function MileageMatrixGuide({
|
||||
</div>
|
||||
<div className="v2-mileage-metric-logic" aria-label="指标取值逻辑">
|
||||
<span><strong>里程</strong> 当日末累计里程-同源日基线</span>
|
||||
<span><strong>用氢量</strong> 有效工作段剩余氢量下降分段累计,加氢与噪声跳过</span>
|
||||
<span><strong>修正用氢量</strong> 物理用氢量加上电池SOC变化折算用氢量</span>
|
||||
<span><strong>纯电里程</strong> 总里程-纯氢里程</span>
|
||||
<span><strong>纯氢里程</strong> 运行模式 0x02 的相邻里程差累计</span>
|
||||
<span><strong>百公里氢耗</strong> 有效用氢量÷纯氢里程×100</span>
|
||||
<span><strong>百公里氢耗</strong> 修正用氢量÷纯氢里程×100</span>
|
||||
<span><strong>—</strong> 无有效数据或不满足计算条件</span>
|
||||
</div>
|
||||
</aside>;
|
||||
@@ -872,7 +873,8 @@ function HydrogenEvidenceSheet({ target, onClose }: { target?: HydrogenEvidenceT
|
||||
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: '百公里氢耗', value: data.consumptionKgPer100Km == null ? '未计算' : `${evidenceNumber(data.consumptionKgPer100Km)} kg/100km`, detail: '修正用氢量÷匹配里程×100', tone: 'primary' },
|
||||
{ label: '物理耗氢', value: `${evidenceNumber(data.rawConsumptionKg)} kg`, detail: '原始计算量,供核对', tone: 'neutral' },
|
||||
{ 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' }
|
||||
] : []}
|
||||
@@ -1044,9 +1046,10 @@ export default function StatisticsPage() {
|
||||
const pureHydrogenMileage = Math.max(0, Math.min(row.dailyMileageKm, row.pureHydrogenMileageKm ?? 0));
|
||||
entry.pureHydrogenDays.set(row.date, pureHydrogenMileage);
|
||||
entry.pureElectricDays.set(row.date, Math.max(0, row.dailyMileageKm - pureHydrogenMileage));
|
||||
if (row.hydrogenConsumptionKg != null) {
|
||||
if (row.hydrogenConsumptionKg != null || row.hydrogenSocBalancedKg != null) {
|
||||
entry.hydrogenDays.set(row.date, {
|
||||
consumptionKg: row.hydrogenConsumptionKg,
|
||||
consumptionKg: row.hydrogenSocBalancedKg ?? undefined,
|
||||
matchedMileageKm: row.mixedMileageKm != null && row.mixedMileageKm > 0 ? row.mixedMileageKm : row.pureHydrogenMileageKm ?? 0,
|
||||
rateKgPer100Km: row.hydrogenConsumptionKgPer100Km ?? undefined,
|
||||
socBalancedKg: row.hydrogenSocBalancedKg ?? undefined,
|
||||
socBalancedRateKgPer100Km: row.hydrogenSocBalancedKgPer100Km ?? undefined,
|
||||
@@ -1070,17 +1073,21 @@ export default function StatisticsPage() {
|
||||
const sources = daily?.sources ?? new Map<string, string>();
|
||||
let dailyTotal = 0;
|
||||
let hydrogenTotal = 0;
|
||||
let hydrogenRatedTotal = 0;
|
||||
let correctedDays = 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;
|
||||
for (const value of hydrogenDays.values()) {
|
||||
if (value.consumptionKg == null) continue;
|
||||
hydrogenTotal += value.consumptionKg;
|
||||
hydrogenRatedMileage += matchedMileage;
|
||||
correctedDays++;
|
||||
if (value.matchedMileageKm <= 0 || value.qualityStatus !== 'OK' || value.rateKgPer100Km == null) continue;
|
||||
hydrogenRatedTotal += value.consumptionKg;
|
||||
hydrogenRatedMileage += value.matchedMileageKm;
|
||||
}
|
||||
return {
|
||||
...vehicle,
|
||||
@@ -1093,8 +1100,8 @@ export default function StatisticsPage() {
|
||||
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
|
||||
totalHydrogenConsumptionKg: correctedDays ? hydrogenTotal : undefined,
|
||||
totalHydrogenRateKgPer100Km: hydrogenDays.size && hydrogenRatedMileage > 0 ? hydrogenRatedTotal * 100 / hydrogenRatedMileage : undefined
|
||||
};
|
||||
}), [displayVehicles, mileageByVin, rankingByVin]);
|
||||
const totalVehicles = hasVehicles ? criteria.vehicles.length : fleetVehicles.data?.total ?? 0;
|
||||
@@ -1204,6 +1211,7 @@ export default function StatisticsPage() {
|
||||
}
|
||||
const plateByVin = new Map(vehicles.filter((vehicle) => vehicle.plate).map((vehicle) => [vehicle.vin, vehicle.plate]));
|
||||
exportStream = createMileageExportStream({
|
||||
metricView,
|
||||
dateFrom: criteria.dateFrom,
|
||||
dateTo: criteria.dateTo,
|
||||
dates,
|
||||
|
||||
Reference in New Issue
Block a user