feat(platform): add online statistics summary
This commit is contained in:
@@ -42,6 +42,7 @@ func (h *Handler) routes() {
|
|||||||
h.mux.HandleFunc("POST /api/history/raw-frames/query", h.handleRawFramesPost)
|
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/summary", h.handleMileageSummary)
|
||||||
h.mux.HandleFunc("GET /api/mileage/daily", h.handleDailyMileage)
|
h.mux.HandleFunc("GET /api/mileage/daily", h.handleDailyMileage)
|
||||||
|
h.mux.HandleFunc("GET /api/statistics/online-summary", h.handleOnlineStatisticsSummary)
|
||||||
h.mux.HandleFunc("GET /api/quality/summary", h.handleQualitySummary)
|
h.mux.HandleFunc("GET /api/quality/summary", h.handleQualitySummary)
|
||||||
h.mux.HandleFunc("GET /api/quality/issues", h.handleQualityIssues)
|
h.mux.HandleFunc("GET /api/quality/issues", h.handleQualityIssues)
|
||||||
h.mux.HandleFunc("GET /api/quality/notification-plan", h.handleQualityNotificationPlan)
|
h.mux.HandleFunc("GET /api/quality/notification-plan", h.handleQualityNotificationPlan)
|
||||||
@@ -170,6 +171,11 @@ func (h *Handler) handleMileageSummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.write(w, r, data, err)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) handleQualityIssues(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleQualityIssues(w http.ResponseWriter, r *http.Request) {
|
||||||
data, err := h.service.QualityIssues(r.Context(), r.URL.Query())
|
data, err := h.service.QualityIssues(r.Context(), r.URL.Query())
|
||||||
h.write(w, r, data, err)
|
h.write(w, r, data, err)
|
||||||
|
|||||||
@@ -745,6 +745,7 @@ func TestHandlerHistoryMileageQualityOps(t *testing.T) {
|
|||||||
{"/api/history/raw-frames?protocol=GB32960&vin=LB9A32A24R0LS1426&limit=1&includeFields=true", "plate"},
|
{"/api/history/raw-frames?protocol=GB32960&vin=LB9A32A24R0LS1426&limit=1&includeFields=true", "plate"},
|
||||||
{"/api/mileage/summary?limit=10", "totalMileageKm"},
|
{"/api/mileage/summary?limit=10", "totalMileageKm"},
|
||||||
{"/api/mileage/daily?limit=10", "dailyMileageKm"},
|
{"/api/mileage/daily?limit=10", "dailyMileageKm"},
|
||||||
|
{"/api/statistics/online-summary?limit=10", "onlineRatePercent"},
|
||||||
{"/api/quality/summary?limit=10", "issueVehicleCount"},
|
{"/api/quality/summary?limit=10", "issueVehicleCount"},
|
||||||
{"/api/quality/issues?limit=10", "sourceEndpoint"},
|
{"/api/quality/issues?limit=10", "sourceEndpoint"},
|
||||||
{"/api/ops/health", "linkHealth"},
|
{"/api/ops/health", "linkHealth"},
|
||||||
@@ -894,12 +895,12 @@ func TestHandlerQualityNotificationPlan(t *testing.T) {
|
|||||||
|
|
||||||
func TestHandlerOpsHealthIncludesVehicleServiceRuntime(t *testing.T) {
|
func TestHandlerOpsHealthIncludesVehicleServiceRuntime(t *testing.T) {
|
||||||
handler := NewHandler(NewServiceWithRuntime(NewMockStore(), RuntimeInfo{
|
handler := NewHandler(NewServiceWithRuntime(NewMockStore(), RuntimeInfo{
|
||||||
RequestTimeoutMs: 1500,
|
RequestTimeoutMs: 1500,
|
||||||
AMapWebJSConfigured: true,
|
AMapWebJSConfigured: true,
|
||||||
AMapSecurityProxyEnabled: true,
|
AMapSecurityProxyEnabled: true,
|
||||||
AMapSecurityCodeExposed: false,
|
AMapSecurityCodeExposed: false,
|
||||||
AMapSecurityServiceHost: "/_AMapService",
|
AMapSecurityServiceHost: "/_AMapService",
|
||||||
PlatformRelease: "platform-20260704153005",
|
PlatformRelease: "platform-20260704153005",
|
||||||
}))
|
}))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/ops/health", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/ops/health", nil)
|
||||||
|
|||||||
@@ -265,6 +265,16 @@ type MileageSummary struct {
|
|||||||
AverageMileagePerVIN float64 `json:"averageMileagePerVin"`
|
AverageMileagePerVIN float64 `json:"averageMileagePerVin"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OnlineStatisticsSummary struct {
|
||||||
|
VehicleCount int `json:"vehicleCount"`
|
||||||
|
OnlineVehicleCount int `json:"onlineVehicleCount"`
|
||||||
|
OfflineVehicleCount int `json:"offlineVehicleCount"`
|
||||||
|
OnlineRatePercent float64 `json:"onlineRatePercent"`
|
||||||
|
RedisOnlineKeys *int `json:"redisOnlineKeys"`
|
||||||
|
ProtocolStats []ProtocolStat `json:"protocolStats"`
|
||||||
|
Evidence string `json:"evidence"`
|
||||||
|
}
|
||||||
|
|
||||||
type QualityIssueRow struct {
|
type QualityIssueRow struct {
|
||||||
VIN string `json:"vin"`
|
VIN string `json:"vin"`
|
||||||
Plate string `json:"plate"`
|
Plate string `json:"plate"`
|
||||||
|
|||||||
@@ -871,6 +871,44 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail
|
|||||||
return s.store.DailyMileage(ctx, resolvedQuery)
|
return s.store.DailyMileage(ctx, resolvedQuery)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) OnlineStatisticsSummary(ctx context.Context, query url.Values) (OnlineStatisticsSummary, error) {
|
||||||
|
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return OnlineStatisticsSummary{}, err
|
||||||
|
}
|
||||||
|
coverage, err := s.store.VehicleCoverageSummary(ctx, resolvedQuery)
|
||||||
|
if err != nil {
|
||||||
|
return OnlineStatisticsSummary{}, err
|
||||||
|
}
|
||||||
|
serviceSummary, err := s.store.VehicleServiceSummary(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return OnlineStatisticsSummary{}, err
|
||||||
|
}
|
||||||
|
opsHealth, err := s.store.OpsHealth(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return OnlineStatisticsSummary{}, err
|
||||||
|
}
|
||||||
|
total := coverage.TotalVehicles
|
||||||
|
online := coverage.OnlineVehicles
|
||||||
|
offline := total - online
|
||||||
|
if offline < 0 {
|
||||||
|
offline = 0
|
||||||
|
}
|
||||||
|
rate := 0.0
|
||||||
|
if total > 0 {
|
||||||
|
rate = float64(online) / float64(total) * 100
|
||||||
|
}
|
||||||
|
return OnlineStatisticsSummary{
|
||||||
|
VehicleCount: total,
|
||||||
|
OnlineVehicleCount: online,
|
||||||
|
OfflineVehicleCount: offline,
|
||||||
|
OnlineRatePercent: rate,
|
||||||
|
RedisOnlineKeys: opsHealth.RedisOnlineKeys,
|
||||||
|
ProtocolStats: serviceSummary.Protocols,
|
||||||
|
Evidence: "由车辆服务覆盖汇总、实时来源状态和 Redis 在线 key 计算",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) QualitySummary(ctx context.Context, query url.Values) (QualitySummary, error) {
|
func (s *Service) QualitySummary(ctx context.Context, query url.Values) (QualitySummary, error) {
|
||||||
return s.store.QualitySummary(ctx, query)
|
return s.store.QualitySummary(ctx, query)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
HistoryLocationRow,
|
HistoryLocationRow,
|
||||||
MileageSummary,
|
MileageSummary,
|
||||||
|
OnlineStatisticsSummary,
|
||||||
OpsHealth,
|
OpsHealth,
|
||||||
QualityNotificationPlan,
|
QualityNotificationPlan,
|
||||||
Page,
|
Page,
|
||||||
@@ -105,6 +106,7 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
mileageSummary: (params = new URLSearchParams()) => request<MileageSummary>(`/api/mileage/summary?${params.toString()}`),
|
mileageSummary: (params = new URLSearchParams()) => request<MileageSummary>(`/api/mileage/summary?${params.toString()}`),
|
||||||
dailyMileage: (params = new URLSearchParams()) => request<Page<DailyMileageRow>>(`/api/mileage/daily?${params.toString()}`),
|
dailyMileage: (params = new URLSearchParams()) => request<Page<DailyMileageRow>>(`/api/mileage/daily?${params.toString()}`),
|
||||||
|
onlineStatisticsSummary: (params = new URLSearchParams()) => request<OnlineStatisticsSummary>(`/api/statistics/online-summary?${params.toString()}`),
|
||||||
qualitySummary: (params = new URLSearchParams()) => request<QualitySummary>(`/api/quality/summary?${params.toString()}`),
|
qualitySummary: (params = new URLSearchParams()) => request<QualitySummary>(`/api/quality/summary?${params.toString()}`),
|
||||||
qualityIssues: (params = new URLSearchParams()) => request<Page<QualityIssueRow>>(`/api/quality/issues?${params.toString()}`),
|
qualityIssues: (params = new URLSearchParams()) => request<Page<QualityIssueRow>>(`/api/quality/issues?${params.toString()}`),
|
||||||
qualityNotificationPlan: (params = new URLSearchParams()) => request<QualityNotificationPlan>(`/api/quality/notification-plan?${params.toString()}`),
|
qualityNotificationPlan: (params = new URLSearchParams()) => request<QualityNotificationPlan>(`/api/quality/notification-plan?${params.toString()}`),
|
||||||
|
|||||||
@@ -255,6 +255,16 @@ export interface MileageSummary {
|
|||||||
averageMileagePerVin: number;
|
averageMileagePerVin: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OnlineStatisticsSummary {
|
||||||
|
vehicleCount: number;
|
||||||
|
onlineVehicleCount: number;
|
||||||
|
offlineVehicleCount: number;
|
||||||
|
onlineRatePercent: number;
|
||||||
|
redisOnlineKeys?: number | null;
|
||||||
|
protocolStats: ProtocolStat[];
|
||||||
|
evidence: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface QualityIssueRow {
|
export interface QualityIssueRow {
|
||||||
vin: string;
|
vin: string;
|
||||||
plate: string;
|
plate: string;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Button, Card, Form, Select, Space, Table, Tag, Toast, Typography } from '@douyinfe/semi-ui';
|
import { Button, Card, Form, Select, Space, Table, Tag, Toast, Typography } from '@douyinfe/semi-ui';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { DailyMileageRow, MileageSummary } from '../api/types';
|
import type { DailyMileageRow, MileageSummary, OnlineStatisticsSummary } from '../api/types';
|
||||||
import { PageHeader } from '../components/PageHeader';
|
import { PageHeader } from '../components/PageHeader';
|
||||||
import { buildAppHash } from '../domain/appRoute';
|
import { buildAppHash } from '../domain/appRoute';
|
||||||
import { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport';
|
import { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport';
|
||||||
@@ -14,6 +14,16 @@ const emptySummary: MileageSummary = {
|
|||||||
averageMileagePerVin: 0
|
averageMileagePerVin: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const emptyOnlineSummary: OnlineStatisticsSummary = {
|
||||||
|
vehicleCount: 0,
|
||||||
|
onlineVehicleCount: 0,
|
||||||
|
offlineVehicleCount: 0,
|
||||||
|
onlineRatePercent: 0,
|
||||||
|
redisOnlineKeys: null,
|
||||||
|
protocolStats: [],
|
||||||
|
evidence: ''
|
||||||
|
};
|
||||||
|
|
||||||
function mileageParams(values: Record<string, string>, pageSize?: number, offset?: number) {
|
function mileageParams(values: Record<string, string>, pageSize?: number, offset?: number) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (pageSize != null) params.set('limit', String(pageSize));
|
if (pageSize != null) params.set('limit', String(pageSize));
|
||||||
@@ -191,6 +201,7 @@ export function Mileage({
|
|||||||
}) {
|
}) {
|
||||||
const [rows, setRows] = useState<DailyMileageRow[]>([]);
|
const [rows, setRows] = useState<DailyMileageRow[]>([]);
|
||||||
const [summary, setSummary] = useState<MileageSummary>(emptySummary);
|
const [summary, setSummary] = useState<MileageSummary>(emptySummary);
|
||||||
|
const [onlineSummary, setOnlineSummary] = useState<OnlineStatisticsSummary>(emptyOnlineSummary);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [summaryLoading, setSummaryLoading] = useState(true);
|
const [summaryLoading, setSummaryLoading] = useState(true);
|
||||||
const [filters, setFilters] = useState<Record<string, string>>(mergeInitialFilters(initialVin, initialProtocol, initialFilters));
|
const [filters, setFilters] = useState<Record<string, string>>(mergeInitialFilters(initialVin, initialProtocol, initialFilters));
|
||||||
@@ -212,6 +223,16 @@ export function Mileage({
|
|||||||
const confidenceColor = confidenceStatus === '可用于 BI' ? 'green' as const : 'orange' as const;
|
const confidenceColor = confidenceStatus === '可用于 BI' ? 'green' as const : 'orange' as const;
|
||||||
const currentEvidenceText = `${summary.vehicleCount.toLocaleString()} 车 / ${summary.sourceCount.toLocaleString()} 来源 / ${rows.length.toLocaleString()} 条明细`;
|
const currentEvidenceText = `${summary.vehicleCount.toLocaleString()} 车 / ${summary.sourceCount.toLocaleString()} 来源 / ${rows.length.toLocaleString()} 条明细`;
|
||||||
const sourceConsistencyText = summary.sourceCount > 1 ? '可做跨来源核对' : summary.sourceCount === 1 ? '单来源车辆需关注' : '等待来源证据';
|
const sourceConsistencyText = summary.sourceCount > 1 ? '可做跨来源核对' : summary.sourceCount === 1 ? '单来源车辆需关注' : '等待来源证据';
|
||||||
|
const onlineVehicleCount = Number(onlineSummary.onlineVehicleCount ?? 0);
|
||||||
|
const offlineVehicleCount = Number(onlineSummary.offlineVehicleCount ?? 0);
|
||||||
|
const onlineRatePercent = Number(onlineSummary.onlineRatePercent ?? 0);
|
||||||
|
const redisOnlineKeyText = onlineSummary.redisOnlineKeys == null ? '' : `Redis 在线 ${onlineSummary.redisOnlineKeys.toLocaleString()} key`;
|
||||||
|
const onlineStatsDetail = [
|
||||||
|
`在线 ${onlineVehicleCount.toLocaleString()} 车`,
|
||||||
|
`离线 ${offlineVehicleCount.toLocaleString()} 车`,
|
||||||
|
redisOnlineKeyText,
|
||||||
|
onlineSummary.evidence
|
||||||
|
].filter(Boolean).join(',');
|
||||||
const maxDateMileage = Math.max(...dateSeries.map((item) => item.value), 0);
|
const maxDateMileage = Math.max(...dateSeries.map((item) => item.value), 0);
|
||||||
const maxSourceMileage = Math.max(...sourceSeries.map((item) => item.value), 0);
|
const maxSourceMileage = Math.max(...sourceSeries.map((item) => item.value), 0);
|
||||||
const filterSummary = [
|
const filterSummary = [
|
||||||
@@ -227,6 +248,9 @@ export function Mileage({
|
|||||||
.then(setSummary)
|
.then(setSummary)
|
||||||
.catch((error: Error) => Toast.error(error.message))
|
.catch((error: Error) => Toast.error(error.message))
|
||||||
.finally(() => setSummaryLoading(false));
|
.finally(() => setSummaryLoading(false));
|
||||||
|
api.onlineStatisticsSummary(mileageParams(values))
|
||||||
|
.then(setOnlineSummary)
|
||||||
|
.catch((error: Error) => Toast.error(error.message));
|
||||||
};
|
};
|
||||||
|
|
||||||
const load = (values: Record<string, string> = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => {
|
const load = (values: Record<string, string> = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => {
|
||||||
@@ -338,9 +362,9 @@ export function Mileage({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '在线率与离线时长',
|
title: '在线率与离线时长',
|
||||||
value: currentVehicleKeyword ? '车辆维度' : '车队维度',
|
value: formatPercent(onlineRatePercent),
|
||||||
color: 'green' as const,
|
color: 'green' as const,
|
||||||
detail: '后续接入在线区间聚合,按 VIN 统计在线率、离线时间和无更新时长。'
|
detail: onlineStatsDetail || '按 VIN 统计在线率、离线时间和无更新时长。'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '数据完整性',
|
title: '数据完整性',
|
||||||
|
|||||||
@@ -4516,6 +4516,27 @@ test('opens same-day mileage statistics from quality issue row', async () => {
|
|||||||
})
|
})
|
||||||
} as Response;
|
} as Response;
|
||||||
}
|
}
|
||||||
|
if (path.includes('/api/statistics/online-summary')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
vehicleCount: 4,
|
||||||
|
onlineVehicleCount: 3,
|
||||||
|
offlineVehicleCount: 1,
|
||||||
|
onlineRatePercent: 75,
|
||||||
|
redisOnlineKeys: 92,
|
||||||
|
protocolStats: [
|
||||||
|
{ protocol: 'JT808', online: 2, total: 3 },
|
||||||
|
{ protocol: 'GB32960', online: 1, total: 2 }
|
||||||
|
],
|
||||||
|
evidence: '由车辆服务覆盖汇总、实时来源状态和 Redis 在线 key 计算'
|
||||||
|
},
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
@@ -6541,6 +6562,27 @@ test('shows vehicle-first statistics domains for one vehicle service', async ()
|
|||||||
})
|
})
|
||||||
} as Response;
|
} as Response;
|
||||||
}
|
}
|
||||||
|
if (path.includes('/api/statistics/online-summary')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
vehicleCount: 4,
|
||||||
|
onlineVehicleCount: 3,
|
||||||
|
offlineVehicleCount: 1,
|
||||||
|
onlineRatePercent: 75,
|
||||||
|
redisOnlineKeys: 92,
|
||||||
|
protocolStats: [
|
||||||
|
{ protocol: 'JT808', online: 2, total: 3 },
|
||||||
|
{ protocol: 'GB32960', online: 1, total: 2 }
|
||||||
|
],
|
||||||
|
evidence: '由车辆服务覆盖汇总、实时来源状态和 Redis 在线 key 计算'
|
||||||
|
},
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
@@ -6561,6 +6603,10 @@ test('shows vehicle-first statistics domains for one vehicle service', async ()
|
|||||||
expect(screen.getByText('三类数据源最终汇总为一个车辆服务统计视图')).toBeInTheDocument();
|
expect(screen.getByText('三类数据源最终汇总为一个车辆服务统计视图')).toBeInTheDocument();
|
||||||
expect(screen.getByText('当前统计证据')).toBeInTheDocument();
|
expect(screen.getByText('当前统计证据')).toBeInTheDocument();
|
||||||
expect(screen.getByText('3 车 / 2 来源 / 3 条明细')).toBeInTheDocument();
|
expect(screen.getByText('3 车 / 2 来源 / 3 条明细')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('75%')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText((content) => content.includes('离线 1 车'))).toBeInTheDocument();
|
||||||
|
expect(screen.getByText((content) => content.includes('Redis 在线 92 key'))).toBeInTheDocument();
|
||||||
|
expect(screen.getByText((content) => content.includes('由车辆服务覆盖汇总、实时来源状态和 Redis 在线 key 计算'))).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('copies mileage statistics summary for operations reporting', async () => {
|
test('copies mileage statistics summary for operations reporting', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user