feat(platform): summarize mileage queries

This commit is contained in:
lingniu
2026-07-03 23:21:51 +08:00
parent a4e9a8afb8
commit 4ca1bdb8fe
11 changed files with 179 additions and 13 deletions

View File

@@ -34,6 +34,7 @@ func (h *Handler) routes() {
h.mux.HandleFunc("GET /api/history/locations", h.handleHistoryLocations)
h.mux.HandleFunc("GET /api/history/raw-frames", h.handleRawFramesGet)
h.mux.HandleFunc("POST /api/history/raw-frames/query", h.handleRawFramesPost)
h.mux.HandleFunc("GET /api/mileage/summary", h.handleMileageSummary)
h.mux.HandleFunc("GET /api/mileage/daily", h.handleDailyMileage)
h.mux.HandleFunc("GET /api/quality/issues", h.handleQualityIssues)
h.mux.HandleFunc("GET /api/ops/health", h.handleOpsHealth)
@@ -111,6 +112,11 @@ func (h *Handler) handleDailyMileage(w http.ResponseWriter, r *http.Request) {
h.write(w, r, data, err)
}
func (h *Handler) handleMileageSummary(w http.ResponseWriter, r *http.Request) {
data, err := h.service.MileageSummary(r.Context(), r.URL.Query())
h.write(w, r, data, err)
}
func (h *Handler) handleQualityIssues(w http.ResponseWriter, r *http.Request) {
data, err := h.service.QualityIssues(r.Context(), r.URL.Query())
h.write(w, r, data, err)

View File

@@ -111,6 +111,7 @@ func TestHandlerHistoryMileageQualityOps(t *testing.T) {
}{
{"/api/history/locations?limit=10", "totalMileageKm"},
{"/api/history/raw-frames?protocol=GB32960&vin=LB9A32A24R0LS1426&limit=1&includeFields=true", "plate"},
{"/api/mileage/summary?limit=10", "totalMileageKm"},
{"/api/mileage/daily?limit=10", "dailyMileageKm"},
{"/api/quality/issues?limit=10", "issueType"},
{"/api/ops/health", "linkHealth"},

View File

@@ -231,11 +231,47 @@ func (m *MockStore) RawFrames(_ context.Context, query RawFrameQuery) (Page[RawF
}
func (m *MockStore) DailyMileage(_ context.Context, query url.Values) (Page[DailyMileageRow], error) {
rows := m.dailyMileageRows(query)
return page(rows, query), nil
}
func (m *MockStore) MileageSummary(_ context.Context, query url.Values) (MileageSummary, error) {
rows := m.dailyMileageRows(query)
vehicles := map[string]struct{}{}
sources := map[string]struct{}{}
total := 0.0
for _, row := range rows {
vehicles[row.VIN] = struct{}{}
sources[row.Source] = struct{}{}
total += row.DailyMileageKm
}
summary := MileageSummary{
VehicleCount: len(vehicles),
RecordCount: len(rows),
SourceCount: len(sources),
TotalMileageKm: total,
}
if summary.VehicleCount > 0 {
summary.AverageMileagePerVIN = summary.TotalMileageKm / float64(summary.VehicleCount)
}
return summary, nil
}
func (m *MockStore) dailyMileageRows(query url.Values) []DailyMileageRow {
rows := []DailyMileageRow{
{VIN: "LB9A32A24R0LS1426", Plate: "粤AG18312", Date: "2026-07-03", StartMileageKm: 48710.2, EndMileageKm: 48798.9, DailyMileageKm: 88.7, Source: "JT808"},
{VIN: "LNXNEGRR7SR318212", Plate: "川AHTWO1", Date: "2026-07-03", StartMileageKm: 119820.4, EndMileageKm: 119925, DailyMileageKm: 104.6, Source: "GB32960"},
}
return page(rows, query), nil
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
keyword := strings.ToLower(vin)
rows = keep(rows, func(row DailyMileageRow) bool {
return strings.Contains(strings.ToLower(row.VIN+row.Plate), keyword)
})
}
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
rows = keep(rows, func(row DailyMileageRow) bool { return row.Source == protocol })
}
return rows
}
func (m *MockStore) QualityIssues(_ context.Context, query url.Values) (Page[QualityIssueRow], error) {

View File

@@ -142,6 +142,14 @@ type DailyMileageRow struct {
AnomalySeverity string `json:"anomalySeverity,omitempty"`
}
type MileageSummary struct {
VehicleCount int `json:"vehicleCount"`
RecordCount int `json:"recordCount"`
SourceCount int `json:"sourceCount"`
TotalMileageKm float64 `json:"totalMileageKm"`
AverageMileagePerVIN float64 `json:"averageMileagePerVin"`
}
type QualityIssueRow struct {
VIN string `json:"vin"`
Plate string `json:"plate"`

View File

@@ -198,9 +198,9 @@ func buildDailyMileageSQL(query url.Values) SQLQuery {
args := []any{}
where := []string{"1 = 1"}
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
where = append(where, "(m.vin LIKE ? OR b.plate LIKE ?)")
where = append(where, "(m.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
like := "%" + vin + "%"
args = append(args, like, like)
args = append(args, like, like, like, like)
}
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
where = append(where, "m.protocol = ?")
@@ -227,6 +227,34 @@ func buildDailyMileageSQL(query url.Values) SQLQuery {
}
}
func buildMileageSummarySQL(query url.Values) SQLQuery {
args := []any{}
where := []string{"1 = 1"}
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
where = append(where, "(m.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
like := "%" + vin + "%"
args = append(args, like, like, like, like)
}
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
where = append(where, "m.protocol = ?")
args = append(args, protocol)
}
if dateFrom := strings.TrimSpace(query.Get("dateFrom")); dateFrom != "" {
where = append(where, "m.stat_date >= ?")
args = append(args, dateFrom)
}
if dateTo := strings.TrimSpace(query.Get("dateTo")); dateTo != "" {
where = append(where, "m.stat_date <= ?")
args = append(args, dateTo)
}
fromSQL := `FROM vehicle_daily_mileage m LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin WHERE ` + strings.Join(where, " AND ")
return SQLQuery{
Text: `SELECT COUNT(DISTINCT m.vin) AS vehicle_count, COUNT(*) AS record_count, ` +
`COUNT(DISTINCT m.protocol) AS source_count, COALESCE(SUM(m.daily_mileage_km), 0) AS total_mileage_km ` + fromSQL,
Args: args,
}
}
func buildLimitOffset(query url.Values) (int, int) {
return parsePositive(query.Get("limit"), 20), parsePositive(query.Get("offset"), 0)
}

View File

@@ -417,6 +417,18 @@ func (s *ProductionStore) RawFrames(ctx context.Context, query RawFrameQuery) (P
return Page[RawFrameRow]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, nil
}
func (s *ProductionStore) MileageSummary(ctx context.Context, query url.Values) (MileageSummary, error) {
built := buildMileageSummarySQL(query)
var summary MileageSummary
if err := s.db.QueryRowContext(ctx, built.Text, built.Args...).Scan(&summary.VehicleCount, &summary.RecordCount, &summary.SourceCount, &summary.TotalMileageKm); err != nil {
return MileageSummary{}, err
}
if summary.VehicleCount > 0 {
summary.AverageMileagePerVIN = summary.TotalMileageKm / float64(summary.VehicleCount)
}
return summary, nil
}
func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (Page[DailyMileageRow], error) {
built := buildDailyMileageSQL(query)
total := 0

View File

@@ -98,14 +98,30 @@ func TestBuildDailyMileageSQL(t *testing.T) {
if !strings.Contains(built.CountText, "COUNT(*)") || strings.Contains(built.CountText, "LIMIT") {
t.Fatalf("count SQL = %s", built.CountText)
}
if len(built.Args) != 7 || built.Args[0] != "%VIN001%" || built.Args[2] != "JT808" || built.Args[5] != 20 || built.Args[6] != 0 {
if len(built.Args) != 9 || built.Args[0] != "%VIN001%" || built.Args[4] != "JT808" || built.Args[7] != 20 || built.Args[8] != 0 {
t.Fatalf("args = %#v", built.Args)
}
if len(built.CountArgs) != 5 || built.CountArgs[0] != "%VIN001%" || built.CountArgs[2] != "JT808" {
if len(built.CountArgs) != 7 || built.CountArgs[0] != "%VIN001%" || built.CountArgs[4] != "JT808" {
t.Fatalf("count args = %#v", built.CountArgs)
}
}
func TestBuildMileageSummarySQL(t *testing.T) {
query := url.Values{"vin": {"粤A"}, "protocol": {"GB32960"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-03"}}
built := buildMileageSummarySQL(query)
for _, want := range []string{"COUNT(DISTINCT m.vin)", "COUNT(DISTINCT m.protocol)", "SUM(m.daily_mileage_km)", "vehicle_daily_mileage", "vehicle_identity_binding"} {
if !strings.Contains(built.Text, want) {
t.Fatalf("SQL missing %q: %s", want, built.Text)
}
}
if strings.Contains(built.Text, "LIMIT") || strings.Contains(built.Text, "OFFSET") {
t.Fatalf("summary SQL should not paginate: %s", built.Text)
}
if len(built.Args) != 7 || built.Args[0] != "%粤A%" || built.Args[4] != "GB32960" {
t.Fatalf("args = %#v", built.Args)
}
}
func TestBuildRawFrameSQL(t *testing.T) {
built := buildRawFrameSQL("lingniu_vehicle_ts", RawFrameQuery{
Protocol: "GB32960",

View File

@@ -16,6 +16,7 @@ type Store interface {
HistoryLocations(context.Context, url.Values) (Page[HistoryLocationRow], error)
HistoryLocationsFromTDengine(context.Context, url.Values) (Page[HistoryLocationRow], error)
RawFrames(context.Context, RawFrameQuery) (Page[RawFrameRow], error)
MileageSummary(context.Context, url.Values) (MileageSummary, error)
DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error)
QualityIssues(context.Context, url.Values) (Page[QualityIssueRow], error)
OpsHealth(context.Context) (OpsHealth, error)
@@ -185,6 +186,10 @@ func (s *Service) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawF
return s.store.RawFrames(ctx, query)
}
func (s *Service) MileageSummary(ctx context.Context, query url.Values) (MileageSummary, error) {
return s.store.MileageSummary(ctx, query)
}
func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[DailyMileageRow], error) {
return s.store.DailyMileage(ctx, query)
}