Add monitoring metrics, numeric sorting, report-time display, vehicle number labels, and map layout fixes. Include accumulated mileage, hydrogen, OneOS authentication, open platform, ingestion, deployment, and UI updates. Exclude temporary files, generated reports, and credentials. Validate frontend 632 tests and production build, API packages, vehicle-map tests, and release installer tests. Gateway suite has one pre-existing date-sensitive identity throttling test failure reproduced on the parent commit.
186 lines
6.8 KiB
Go
186 lines
6.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats"
|
|
)
|
|
|
|
const customerHydrogenAlgorithm = "MODE_INTERVAL_V1"
|
|
|
|
type customerHydrogenDay struct {
|
|
VIN, Date, Protocol, SourceKey string
|
|
HydrogenKM, ObservedKM, ClassifiedKM float64
|
|
Samples, KnownSamples, Intervals, Rejected int64
|
|
First, Last time.Time
|
|
PreviousKM float64
|
|
}
|
|
|
|
func addCustomerHydrogenSample(days map[string]*customerHydrogenDay, s stats.MetricSample) {
|
|
if s.Protocol != envelope.ProtocolGB32960 && s.Protocol != envelope.ProtocolYutongMQTT {
|
|
return
|
|
}
|
|
if math.IsNaN(s.TotalMileageKM) || math.IsInf(s.TotalMileageKM, 0) || s.TotalMileageKM < 0 {
|
|
return
|
|
}
|
|
key := s.VIN + "/" + s.StatDate + "/" + string(s.Protocol) + "/" + s.SourceKey
|
|
d := days[key]
|
|
if d == nil {
|
|
d = &customerHydrogenDay{VIN: s.VIN, Date: s.StatDate, Protocol: string(s.Protocol), SourceKey: s.SourceKey, First: s.EventTime, Last: s.EventTime, PreviousKM: s.TotalMileageKM, Samples: 1}
|
|
if s.PureHydrogenModeKnown {
|
|
d.KnownSamples = 1
|
|
}
|
|
days[key] = d
|
|
return
|
|
}
|
|
// Do not count duplicate or out-of-order reports as extra evidence.
|
|
if !s.EventTime.After(d.Last) {
|
|
return
|
|
}
|
|
elapsed := s.EventTime.Sub(d.Last)
|
|
delta := s.TotalMileageKM - d.PreviousKM
|
|
d.Samples++
|
|
if s.PureHydrogenModeKnown {
|
|
d.KnownSamples++
|
|
}
|
|
if delta < -0.2 || delta > math.Max(0.5, elapsed.Hours()*130) || elapsed > 15*time.Minute {
|
|
d.Rejected++
|
|
} else {
|
|
delta = math.Max(0, delta)
|
|
d.ObservedKM += delta
|
|
d.Intervals++
|
|
if s.PureHydrogenModeKnown {
|
|
d.ClassifiedKM += delta
|
|
if s.PureHydrogenActive {
|
|
d.HydrogenKM += delta
|
|
}
|
|
}
|
|
}
|
|
d.Last = s.EventTime
|
|
d.PreviousKM = s.TotalMileageKM
|
|
}
|
|
|
|
// This path reads raw work-mode evidence and only writes the dedicated customer
|
|
// monitor projection. It never resets/reprojects the existing GPS/daily tables.
|
|
func runCustomerHydrogenBackfill(ctx context.Context, mysqlDB, td *sql.DB, cfg config) error {
|
|
return runCustomerHydrogenBackfillWithLease(ctx, mysqlDB, td, cfg, "")
|
|
}
|
|
|
|
func runCustomerHydrogenBackfillWithLease(ctx context.Context, mysqlDB, td *sql.DB, cfg config, owner string) error {
|
|
if cfg.Reset {
|
|
return fmt.Errorf("hydrogen-only backfill does not allow reset")
|
|
}
|
|
if cfg.Limit > 0 && !cfg.DryRun {
|
|
return fmt.Errorf("limited scan cannot publish a daily result")
|
|
}
|
|
for _, p := range cfg.Protocols {
|
|
if p != envelope.ProtocolGB32960 && p != envelope.ProtocolYutongMQTT {
|
|
return fmt.Errorf("hydrogen-only backfill accepts GB32960,YUTONG_MQTT only")
|
|
}
|
|
}
|
|
rows, err := queryRawFrames(ctx, td, cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
days := map[string]*customerHydrogenDay{}
|
|
var scanned, skipped int64
|
|
for rows.Next() {
|
|
scanned++
|
|
row, err := scanRawFrame(rows)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
text := strings.TrimSpace(row.ParsedJSON)
|
|
if isChunkManifest(text) {
|
|
text, err = readChunkedPayload(ctx, td, cfg.TDengineDatabase, row.EventID, "parsed_fields")
|
|
if err != nil {
|
|
return fmt.Errorf("read complete mode evidence: %w", err)
|
|
}
|
|
}
|
|
fields := fieldsForStats(row.Protocol, row.VIN, text)
|
|
if len(fields) == 0 && row.Protocol == envelope.ProtocolYutongMQTT {
|
|
fields = fieldsForStats(row.Protocol, row.VIN, row.RawText)
|
|
}
|
|
samples, err := stats.SamplesFromEnvelope(envelope.FrameEnvelope{EventID: row.EventID, Protocol: row.Protocol, MessageID: row.MessageID, VIN: row.VIN, Phone: row.Phone, DeviceID: row.DeviceID, SourceEndpoint: row.SourceEndpoint, EventTimeMS: row.EventTimeMS, ReceivedAtMS: row.ReceivedAtMS, Fields: fields, ParsedFields: fields, ParseStatus: envelope.ParseOK}, cfg.Location)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(samples) == 0 {
|
|
skipped++
|
|
}
|
|
for _, s := range samples {
|
|
if s.StatDate >= cfg.DateFrom && s.StatDate <= cfg.DateTo {
|
|
addCustomerHydrogenSample(days, s)
|
|
}
|
|
}
|
|
if cfg.ProgressEvery > 0 && scanned%cfg.ProgressEvery == 0 {
|
|
slog.Info("customer hydrogen scan", "scanned", scanned, "source_days", len(days), "skipped", skipped)
|
|
}
|
|
}
|
|
if err = rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
var known, samples int64
|
|
var hydrogen float64
|
|
for _, d := range days {
|
|
known += d.KnownSamples
|
|
samples += d.Samples
|
|
hydrogen += d.HydrogenKM
|
|
}
|
|
slog.Info("customer hydrogen evidence", "scanned", scanned, "samples", samples, "known_mode_samples", known, "source_days", len(days), "hydrogen_km_across_sources", hydrogen, "dry_run", cfg.DryRun)
|
|
if cfg.DryRun {
|
|
return nil
|
|
}
|
|
return publishCustomerHydrogenDays(ctx, mysqlDB, days, cfg.DateFrom, owner)
|
|
}
|
|
|
|
func publishCustomerHydrogenDays(ctx context.Context, mysqlDB *sql.DB, days map[string]*customerHydrogenDay, date, owner string) error {
|
|
// Publish only after a complete successful scan. Upserts are deterministic;
|
|
// failures roll back the entire projection update.
|
|
tx, err := mysqlDB.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
if owner != "" {
|
|
// Fence publication with the same row lock used to claim jobs. A stale
|
|
// worker must not overwrite results even before its next heartbeat.
|
|
var valid int
|
|
if err = tx.QueryRowContext(ctx, `SELECT 1 FROM platform_customer_hydrogen_job WHERE stat_date=? AND lease_owner=? AND status='running' AND lease_until>NOW(3) FOR UPDATE`, date, owner).Scan(&valid); err != nil {
|
|
return fmt.Errorf("customer hydrogen publication lease lost: %w", err)
|
|
}
|
|
}
|
|
keys := make([]string, 0, len(days))
|
|
for k := range days {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for start := 0; start < len(keys); start += 200 {
|
|
end := start + 200
|
|
if end > len(keys) {
|
|
end = len(keys)
|
|
}
|
|
values := []string{}
|
|
args := []any{}
|
|
for _, key := range keys[start:end] {
|
|
d := days[key]
|
|
values = append(values, "(?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(3))")
|
|
args = append(args, d.VIN, d.Date, d.Protocol, d.SourceKey, d.HydrogenKM, d.ObservedKM, d.ClassifiedKM, d.Samples, d.KnownSamples, d.Intervals, d.Rejected, d.First, d.Last, customerHydrogenAlgorithm)
|
|
}
|
|
_, err = tx.ExecContext(ctx, `INSERT INTO platform_customer_hydrogen_daily(vin,stat_date,protocol,source_key,hydrogen_km,observed_km,classified_km,sample_count,known_sample_count,interval_count,rejected_intervals,first_event_time,last_event_time,algorithm_version,calculated_at) VALUES `+strings.Join(values, ",")+` ON DUPLICATE KEY UPDATE hydrogen_km=VALUES(hydrogen_km),observed_km=VALUES(observed_km),classified_km=VALUES(classified_km),sample_count=VALUES(sample_count),known_sample_count=VALUES(known_sample_count),interval_count=VALUES(interval_count),rejected_intervals=VALUES(rejected_intervals),first_event_time=VALUES(first_event_time),last_event_time=VALUES(last_event_time),algorithm_version=VALUES(algorithm_version),calculated_at=VALUES(calculated_at)`, args...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|