feat: add vehicle mileage statistics

This commit is contained in:
lingniu
2026-07-14 13:18:52 +08:00
parent bb59303a4b
commit 6cddc0a43d
15 changed files with 522 additions and 1 deletions

View File

@@ -42,6 +42,7 @@ func (h *Handler) routes() {
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/v2/statistics/mileage", h.handleMileageStatistics)
h.mux.HandleFunc("GET /api/statistics/online-summary", h.handleOnlineStatisticsSummary)
h.mux.HandleFunc("GET /api/statistics/online-vehicles", h.handleOnlineVehicleStatuses)
h.mux.HandleFunc("GET /api/quality/summary", h.handleQualitySummary)
@@ -428,6 +429,11 @@ func (h *Handler) handleMileageSummary(w http.ResponseWriter, r *http.Request) {
h.write(w, r, data, err)
}
func (h *Handler) handleMileageStatistics(w http.ResponseWriter, r *http.Request) {
data, err := h.service.MileageStatistics(r.Context(), r.URL.Query())
h.write(w, r, data, err)
}
func (h *Handler) handleOnlineStatisticsSummary(w http.ResponseWriter, r *http.Request) {
data, err := h.service.OnlineStatisticsSummary(r.Context(), r.URL.Query())
h.write(w, r, data, err)

View File

@@ -880,6 +880,7 @@ func TestHandlerHistoryMileageQualityOps(t *testing.T) {
{"/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/v2/statistics/mileage?dateFrom=2026-07-01&dateTo=2026-07-31", "periodMileageKm"},
{"/api/statistics/online-summary?limit=10", "onlineRatePercent"},
{"/api/statistics/online-vehicles?limit=10", "offlineDurationMinutes"},
{"/api/quality/summary?limit=10", "issueVehicleCount"},

View File

@@ -843,6 +843,50 @@ func (m *MockStore) MileageSummary(_ context.Context, query url.Values) (Mileage
return summary, nil
}
func (m *MockStore) MileageStatistics(_ context.Context, query url.Values) (MileageStatistics, error) {
rows := m.dailyMileageRows(query)
byDate := map[string]*MileageTrendPoint{}
result := MileageStatistics{DateFrom: query.Get("dateFrom"), DateTo: query.Get("dateTo"), Trend: []MileageTrendPoint{}, Ranking: []MileageVehicleRank{}, AsOf: "2026-07-03 20:12:06", Evidence: "mock mileage statistics"}
vehicles, sources := map[string]*MileageVehicleRank{}, map[string]struct{}{}
for _, row := range rows {
result.PeriodMileageKm += row.DailyMileageKm
result.RecordCount++
sources[row.Source] = struct{}{}
point := byDate[row.Date]
if point == nil {
point = &MileageTrendPoint{Date: row.Date}
byDate[row.Date] = point
}
point.MileageKm += row.DailyMileageKm
point.Vehicles++
rank := vehicles[row.VIN]
if rank == nil {
rank = &MileageVehicleRank{VIN: row.VIN, Plate: row.Plate}
vehicles[row.VIN] = rank
}
rank.MileageKm += row.DailyMileageKm
rank.LatestMileageKm = row.EndMileageKm
rank.ActiveDays++
}
result.VehicleCount, result.SourceCount = len(vehicles), len(sources)
for _, point := range byDate {
result.Trend = append(result.Trend, *point)
}
sort.Slice(result.Trend, func(i, j int) bool { return result.Trend[i].Date < result.Trend[j].Date })
for _, rank := range vehicles {
result.Ranking = append(result.Ranking, *rank)
result.FleetLatestMileageKm += rank.LatestMileageKm
}
sort.Slice(result.Ranking, func(i, j int) bool { return result.Ranking[i].MileageKm > result.Ranking[j].MileageKm })
if result.VehicleCount > 0 {
result.AverageMileagePerVIN = result.PeriodMileageKm / float64(result.VehicleCount)
}
if result.RecordCount > 0 {
result.AverageDailyMileageKm = result.PeriodMileageKm / float64(result.RecordCount)
}
return result, 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"},

View File

@@ -1064,6 +1064,36 @@ type MileageSummary struct {
AverageMileagePerVIN float64 `json:"averageMileagePerVin"`
}
type MileageTrendPoint struct {
Date string `json:"date"`
MileageKm float64 `json:"mileageKm"`
Vehicles int `json:"vehicles"`
}
type MileageVehicleRank struct {
VIN string `json:"vin"`
Plate string `json:"plate"`
MileageKm float64 `json:"mileageKm"`
LatestMileageKm float64 `json:"latestMileageKm"`
ActiveDays int `json:"activeDays"`
}
type MileageStatistics struct {
DateFrom string `json:"dateFrom"`
DateTo string `json:"dateTo"`
VehicleCount int `json:"vehicleCount"`
RecordCount int `json:"recordCount"`
SourceCount int `json:"sourceCount"`
PeriodMileageKm float64 `json:"periodMileageKm"`
FleetLatestMileageKm float64 `json:"fleetLatestMileageKm"`
AverageMileagePerVIN float64 `json:"averageMileagePerVin"`
AverageDailyMileageKm float64 `json:"averageDailyMileageKm"`
Trend []MileageTrendPoint `json:"trend"`
Ranking []MileageVehicleRank `json:"ranking"`
AsOf string `json:"asOf"`
Evidence string `json:"evidence"`
}
type OnlineStatisticsSummary struct {
VehicleCount int `json:"vehicleCount"`
OnlineVehicleCount int `json:"onlineVehicleCount"`

View File

@@ -474,6 +474,83 @@ func buildMileageSummarySQL(query url.Values) SQLQuery {
}
}
func buildMileageStatisticsWhere(query url.Values) (string, []any) {
where := []string{"1 = 1"}
args := []any{}
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)
}
return strings.Join(where, " AND "), args
}
func buildMileageStatisticsBaseSQL(query url.Values) (string, []any) {
where, args := buildMileageStatisticsWhere(query)
return `SELECT m.vin, COALESCE(MAX(NULLIF(b.plate, '')), '') AS plate, m.stat_date, ` +
`MAX(COALESCE(m.daily_mileage_km, 0)) AS daily_mileage_km, ` +
`MAX(COALESCE(m.latest_total_mileage_km, 0)) AS latest_mileage_km ` +
`FROM vehicle_daily_mileage m LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin ` +
`WHERE ` + where + ` GROUP BY m.vin, m.stat_date`, args
}
func buildMileageStatisticsSummarySQL(query url.Values) SQLQuery {
base, args := buildMileageStatisticsBaseSQL(query)
return SQLQuery{Text: `SELECT COUNT(DISTINCT d.vin), COUNT(*), COALESCE(SUM(d.daily_mileage_km), 0), ` +
`COALESCE(AVG(d.daily_mileage_km), 0) FROM (` + base + `) d`, Args: args}
}
func buildMileageStatisticsTrendSQL(query url.Values) SQLQuery {
base, args := buildMileageStatisticsBaseSQL(query)
return SQLQuery{Text: `SELECT DATE_FORMAT(d.stat_date, '%Y-%m-%d'), COALESCE(SUM(d.daily_mileage_km), 0), ` +
`COUNT(DISTINCT d.vin) FROM (` + base + `) d GROUP BY d.stat_date ORDER BY d.stat_date ASC`, Args: args}
}
func buildMileageStatisticsRankingSQL(query url.Values) SQLQuery {
base, args := buildMileageStatisticsBaseSQL(query)
return SQLQuery{Text: `SELECT d.vin, COALESCE(MAX(d.plate), ''), COALESCE(SUM(d.daily_mileage_km), 0), ` +
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(d.latest_mileage_km AS CHAR) ORDER BY d.stat_date DESC), ',', 1) AS DECIMAL(18,3)), 0), ` +
`COUNT(DISTINCT d.stat_date) FROM (` + base + `) d ` +
`GROUP BY d.vin ORDER BY SUM(d.daily_mileage_km) DESC, d.vin ASC LIMIT 20`, Args: args}
}
func buildMileageStatisticsSourceCountSQL(query url.Values) SQLQuery {
where, args := buildMileageStatisticsWhere(query)
return SQLQuery{Text: `SELECT COUNT(DISTINCT m.protocol) FROM vehicle_daily_mileage m ` +
`LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin WHERE ` + where, Args: args}
}
func buildFleetLatestMileageSQL(query url.Values) SQLQuery {
where := []string{"l.total_mileage_km IS NOT NULL", "l.total_mileage_km >= 0"}
args := []any{}
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
where = append(where, "(l.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, "l.protocol = ?")
args = append(args, protocol)
}
inner := `SELECT l.vin, CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.total_mileage_km AS CHAR) ` +
`ORDER BY l.updated_at DESC, l.protocol ASC), ',', 1) AS DECIMAL(18,3)) AS latest_mileage_km ` +
`FROM vehicle_realtime_location l LEFT JOIN vehicle_identity_binding b ON b.vin = l.vin WHERE ` +
strings.Join(where, " AND ") + ` GROUP BY l.vin`
return SQLQuery{Text: `SELECT COALESCE(SUM(x.latest_mileage_km), 0) FROM (` + inner + `) x`, Args: args}
}
func buildLimitOffset(query url.Values) (int, int) {
return parsePositive(query.Get("limit"), 20), parsePositive(query.Get("offset"), 0)
}

View File

@@ -1012,6 +1012,67 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P
return Page[DailyMileageRow]{Items: items, Total: total, Limit: limit, Offset: offset}, nil
}
func (s *ProductionStore) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
result := MileageStatistics{
DateFrom: query.Get("dateFrom"), DateTo: query.Get("dateTo"),
Trend: []MileageTrendPoint{}, Ranking: []MileageVehicleRank{},
Evidence: "vehicle_daily_mileage按车辆和日期去重/ vehicle_realtime_location最新里程表",
}
summary := buildMileageStatisticsSummarySQL(query)
if err := s.db.QueryRowContext(ctx, summary.Text, summary.Args...).Scan(&result.VehicleCount, &result.RecordCount, &result.PeriodMileageKm, &result.AverageDailyMileageKm); err != nil {
return MileageStatistics{}, err
}
if result.VehicleCount > 0 {
result.AverageMileagePerVIN = result.PeriodMileageKm / float64(result.VehicleCount)
}
sources := buildMileageStatisticsSourceCountSQL(query)
if err := s.db.QueryRowContext(ctx, sources.Text, sources.Args...).Scan(&result.SourceCount); err != nil {
return MileageStatistics{}, err
}
fleet := buildFleetLatestMileageSQL(query)
if err := s.db.QueryRowContext(ctx, fleet.Text, fleet.Args...).Scan(&result.FleetLatestMileageKm); err != nil {
return MileageStatistics{}, err
}
trend := buildMileageStatisticsTrendSQL(query)
rows, err := s.db.QueryContext(ctx, trend.Text, trend.Args...)
if err != nil {
return MileageStatistics{}, err
}
for rows.Next() {
var point MileageTrendPoint
if err := rows.Scan(&point.Date, &point.MileageKm, &point.Vehicles); err != nil {
rows.Close()
return MileageStatistics{}, err
}
result.Trend = append(result.Trend, point)
}
if err := rows.Err(); err != nil {
rows.Close()
return MileageStatistics{}, err
}
if err := rows.Close(); err != nil {
return MileageStatistics{}, err
}
ranking := buildMileageStatisticsRankingSQL(query)
rows, err = s.db.QueryContext(ctx, ranking.Text, ranking.Args...)
if err != nil {
return MileageStatistics{}, err
}
defer rows.Close()
for rows.Next() {
var item MileageVehicleRank
if err := rows.Scan(&item.VIN, &item.Plate, &item.MileageKm, &item.LatestMileageKm, &item.ActiveDays); err != nil {
return MileageStatistics{}, err
}
result.Ranking = append(result.Ranking, item)
}
if err := rows.Err(); err != nil {
return MileageStatistics{}, err
}
result.AsOf = time.Now().Format("2006-01-02 15:04:05")
return result, nil
}
func buildQualityIssueWhere(query url.Values) (string, []any) {
where := []string{"1 = 1"}
args := []any{}

View File

@@ -280,6 +280,53 @@ func TestBuildMileageSummarySQL(t *testing.T) {
}
}
func TestBuildMileageStatisticsSQLDeduplicatesVehicleDays(t *testing.T) {
query := url.Values{"vin": {"粤A"}, "protocol": {"GB32960"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-31"}}
summary := buildMileageStatisticsSummarySQL(query)
for _, want := range []string{"GROUP BY m.vin, m.stat_date", "MAX(COALESCE(m.daily_mileage_km", "COUNT(DISTINCT d.vin)", "SUM(d.daily_mileage_km)"} {
if !strings.Contains(summary.Text, want) {
t.Fatalf("statistics summary SQL missing %q: %s", want, summary.Text)
}
}
if len(summary.Args) != 7 || summary.Args[0] != "%粤A%" || summary.Args[4] != "GB32960" {
t.Fatalf("statistics args = %#v", summary.Args)
}
trend := buildMileageStatisticsTrendSQL(query)
if !strings.Contains(trend.Text, "GROUP BY d.stat_date ORDER BY d.stat_date ASC") {
t.Fatalf("statistics trend SQL should be chronologically stable: %s", trend.Text)
}
ranking := buildMileageStatisticsRankingSQL(query)
if !strings.Contains(ranking.Text, "ORDER BY SUM(d.daily_mileage_km) DESC") || !strings.Contains(ranking.Text, "ORDER BY d.stat_date DESC") || !strings.Contains(ranking.Text, "LIMIT 20") {
t.Fatalf("statistics ranking SQL should be bounded and stable: %s", ranking.Text)
}
}
func TestNormalizeMileageStatisticsWindow(t *testing.T) {
now := time.Date(2026, 7, 14, 12, 0, 0, 0, time.Local)
defaults, err := normalizeMileageStatisticsWindow(url.Values{}, now)
if err != nil || defaults.Get("dateFrom") != "2026-06-15" || defaults.Get("dateTo") != "2026-07-14" {
t.Fatalf("default statistics window = %v, err=%v", defaults, err)
}
if _, err := normalizeMileageStatisticsWindow(url.Values{"dateFrom": {"2025-01-01"}, "dateTo": {"2026-07-14"}}, now); err == nil {
t.Fatal("statistics window over 366 days must be rejected")
}
if _, err := normalizeMileageStatisticsWindow(url.Values{"dateFrom": {"2026-07-15"}, "dateTo": {"2026-07-14"}}, now); err == nil {
t.Fatal("reversed statistics window must be rejected")
}
}
func TestBuildFleetLatestMileageSQLUsesOneLatestValuePerVehicle(t *testing.T) {
built := buildFleetLatestMileageSQL(url.Values{"vin": {"VIN001"}, "protocol": {"JT808"}})
for _, want := range []string{"GROUP_CONCAT", "ORDER BY l.updated_at DESC", "GROUP BY l.vin", "SUM(x.latest_mileage_km)"} {
if !strings.Contains(built.Text, want) {
t.Fatalf("fleet latest mileage SQL missing %q: %s", want, built.Text)
}
}
if len(built.Args) != 5 || built.Args[0] != "%VIN001%" || built.Args[4] != "JT808" {
t.Fatalf("fleet latest mileage args = %#v", built.Args)
}
}
func TestBuildRawFrameSQL(t *testing.T) {
built := buildRawFrameSQL("lingniu_vehicle_ts", RawFrameQuery{
Protocol: "GB32960",

View File

@@ -33,6 +33,7 @@ type Store interface {
RawFrames(context.Context, RawFrameQuery) (Page[RawFrameRow], error)
MileageSummary(context.Context, url.Values) (MileageSummary, error)
DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error)
MileageStatistics(context.Context, url.Values) (MileageStatistics, error)
QualitySummary(context.Context, url.Values) (QualitySummary, error)
QualityIssues(context.Context, url.Values) (Page[QualityIssueRow], error)
OpsHealth(context.Context) (OpsHealth, error)
@@ -3105,6 +3106,42 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail
return s.store.DailyMileage(ctx, resolvedQuery)
}
func (s *Service) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
if err != nil {
return MileageStatistics{}, err
}
resolvedQuery, err = normalizeMileageStatisticsWindow(resolvedQuery, time.Now())
if err != nil {
return MileageStatistics{}, err
}
return s.store.MileageStatistics(ctx, resolvedQuery)
}
func normalizeMileageStatisticsWindow(query url.Values, now time.Time) (url.Values, error) {
next := cloneValues(query)
dateTo := strings.TrimSpace(next.Get("dateTo"))
if dateTo == "" {
dateTo = now.Format("2006-01-02")
next.Set("dateTo", dateTo)
}
dateFrom := strings.TrimSpace(next.Get("dateFrom"))
if dateFrom == "" {
parsedTo, _ := time.ParseInLocation("2006-01-02", dateTo, now.Location())
dateFrom = parsedTo.AddDate(0, 0, -29).Format("2006-01-02")
next.Set("dateFrom", dateFrom)
}
from, fromErr := time.ParseInLocation("2006-01-02", dateFrom, now.Location())
to, toErr := time.ParseInLocation("2006-01-02", dateTo, now.Location())
if fromErr != nil || toErr != nil || from.After(to) {
return nil, clientError{Code: "INVALID_DATE_RANGE", Message: "统计日期范围无效"}
}
if to.Sub(from) > 365*24*time.Hour {
return nil, clientError{Code: "DATE_RANGE_TOO_LARGE", Message: "车辆统计最多查询 366 个自然日"}
}
return next, nil
}
func (s *Service) OnlineStatisticsSummary(ctx context.Context, query url.Values) (OnlineStatisticsSummary, error) {
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
if err != nil {