diff --git a/vehicle-data-platform/apps/web/src/domain/vehicleService.test.ts b/vehicle-data-platform/apps/web/src/domain/vehicleService.test.ts new file mode 100644 index 00000000..d55bd87c --- /dev/null +++ b/vehicle-data-platform/apps/web/src/domain/vehicleService.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from 'vitest'; +import { summarizeVehicleService } from './vehicleService'; + +describe('summarizeVehicleService', () => { + test('marks unresolved vehicles as unavailable', () => { + expect(summarizeVehicleService({ resolved: false, sourceCount: 1, onlineSourceCount: 1 })).toMatchObject({ + status: '不可服务', + risk: '身份未归并到 VIN' + }); + }); + + test('prioritizes quality issues before missing source warnings', () => { + expect(summarizeVehicleService({ + resolved: true, + sourceCount: 3, + onlineSourceCount: 2, + qualityIssueCount: 3, + missingProtocols: ['YUTONG_MQTT'] + })).toMatchObject({ + status: '降级可服务', + risk: '3 项质量问题影响可信度' + }); + }); + + test('detects missing protocol evidence', () => { + expect(summarizeVehicleService({ + resolved: true, + sourceCount: 2, + onlineSourceCount: 2, + missingProtocols: ['JT808', 'YUTONG_MQTT'] + })).toMatchObject({ + status: '降级可服务', + risk: '缺少 JT808、YUTONG_MQTT 来源' + }); + }); + + test('marks complete online evidence as serviceable', () => { + expect(summarizeVehicleService({ resolved: true, sourceCount: 3, onlineSourceCount: 3 })).toMatchObject({ + status: '可服务', + color: 'green' + }); + }); +}); diff --git a/vehicle-data-platform/apps/web/src/domain/vehicleService.ts b/vehicle-data-platform/apps/web/src/domain/vehicleService.ts new file mode 100644 index 00000000..0082993e --- /dev/null +++ b/vehicle-data-platform/apps/web/src/domain/vehicleService.ts @@ -0,0 +1,85 @@ +export type VehicleServiceVerdict = { + status: string; + color: 'green' | 'orange' | 'red'; + risk: string; + nextStep: string; +}; + +export type VehicleServiceVerdictInput = { + resolved?: boolean; + bindingStatus?: string; + sourceCount?: number; + onlineSourceCount?: number; + qualityIssueCount?: number; + missingProtocols?: string[]; + mileageDeltaKm?: number; +}; + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function formatCompactNumber(value: number, suffix = '') { + return `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}${suffix}`; +} + +export function summarizeVehicleService(input: VehicleServiceVerdictInput): VehicleServiceVerdict { + if (input.resolved === false || input.bindingStatus === 'unbound') { + return { + status: '不可服务', + color: 'red', + risk: '身份未归并到 VIN', + nextStep: '先维护车辆身份绑定,再使用实时、历史和里程服务。' + }; + } + const sourceCount = input.sourceCount ?? 0; + const onlineSourceCount = input.onlineSourceCount ?? 0; + if (sourceCount <= 0) { + return { + status: '不可服务', + color: 'red', + risk: '没有任何来源证据', + nextStep: '优先确认平台转发、端口、订阅和绑定配置。' + }; + } + if (onlineSourceCount <= 0) { + return { + status: '不可服务', + color: 'red', + risk: '全部来源离线', + nextStep: '先恢复至少一个实时来源,再判断车辆状态。' + }; + } + const qualityIssueCount = input.qualityIssueCount ?? 0; + if (qualityIssueCount > 0) { + return { + status: '降级可服务', + color: 'orange', + risk: `${qualityIssueCount} 项质量问题影响可信度`, + nextStep: '先处理质量问题,再将该车用于 BI、告警或对外查询。' + }; + } + const missingProtocols = input.missingProtocols ?? []; + if (missingProtocols.length > 0) { + return { + status: '降级可服务', + color: 'orange', + risk: `缺少 ${missingProtocols.join('、')} 来源`, + nextStep: '车辆仍可查询,但需要补齐缺失来源后再做跨来源校验。' + }; + } + if (isFiniteNumber(input.mileageDeltaKm) && input.mileageDeltaKm > 1) { + return { + status: '降级可服务', + color: 'orange', + risk: `跨来源里程差异 ${formatCompactNumber(input.mileageDeltaKm, ' km')}`, + nextStep: '核对各来源总里程口径,确认统计使用的主来源。' + }; + } + return { + status: '可服务', + color: 'green', + risk: '身份、来源和核心实时证据可用', + nextStep: '可进入实时、历史、RAW 和里程查询继续分析。' + }; +} diff --git a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx index 047d2172..83de05d9 100644 --- a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx +++ b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx @@ -7,6 +7,7 @@ import { PageHeader } from '../components/PageHeader'; import { SourceStatusTags } from '../components/SourceStatusTags'; import { StatusTag } from '../components/StatusTag'; import { isLikelyVIN } from '../domain/vehicleLookup'; +import { summarizeVehicleService } from '../domain/vehicleService'; type VehicleQuery = { keyword: string; @@ -69,13 +70,6 @@ type VehicleServiceAction = { onClick: () => void; }; -type VehicleServiceConclusion = { - status: string; - color: 'green' | 'orange' | 'red'; - risk: string; - nextStep: string; -}; - async function copyVehicleServiceURL() { try { await navigator.clipboard.writeText(`${window.location.origin}${window.location.pathname}${window.location.hash}`); @@ -237,62 +231,14 @@ export function VehicleDetail({ onQueryChange?.(nextQuery.keyword, nextQuery.protocol); load(nextQuery); }; - const serviceConclusion = useMemo(() => { - if (!hasResolvedVIN) { - return { - status: '不可服务', - color: 'red', - risk: '身份未归并到 VIN', - nextStep: '先维护车辆身份绑定,再使用实时、历史和里程服务。' - }; - } - if (sourceCount <= 0) { - return { - status: '不可服务', - color: 'red', - risk: '没有任何来源证据', - nextStep: '优先确认平台转发、端口、订阅和绑定配置。' - }; - } - if (onlineSourceCount <= 0) { - return { - status: '不可服务', - color: 'red', - risk: '全部来源离线', - nextStep: '先恢复至少一个实时来源,再判断车辆状态。' - }; - } - if (qualityCount > 0) { - return { - status: '降级可服务', - color: 'orange', - risk: `${qualityCount} 项质量问题影响可信度`, - nextStep: '先处理质量问题,再将该车用于 BI、告警或对外查询。' - }; - } - if (missingProtocols.length > 0) { - return { - status: '降级可服务', - color: 'orange', - risk: `缺少 ${missingProtocols.join('、')} 来源`, - nextStep: '车辆仍可查询,但需要补齐缺失来源后再做跨来源校验。' - }; - } - if (isFiniteNumber(consistencyMileageDelta) && consistencyMileageDelta > 1) { - return { - status: '降级可服务', - color: 'orange', - risk: `跨来源里程差异 ${formatCompactNumber(consistencyMileageDelta, ' km')}`, - nextStep: '核对各来源总里程口径,确认统计使用的主来源。' - }; - } - return { - status: '可服务', - color: 'green', - risk: '身份、来源和核心实时证据可用', - nextStep: '可进入实时、历史、RAW 和里程查询继续分析。' - }; - }, [consistencyMileageDelta, hasResolvedVIN, missingProtocols, onlineSourceCount, qualityCount, sourceCount]); + const serviceConclusion = useMemo(() => summarizeVehicleService({ + resolved: hasResolvedVIN, + sourceCount, + onlineSourceCount, + qualityIssueCount: qualityCount, + missingProtocols, + mileageDeltaKm: consistencyMileageDelta + }), [consistencyMileageDelta, hasResolvedVIN, missingProtocols, onlineSourceCount, qualityCount, sourceCount]); const qualityFiltersForCurrentVehicle = () => ({ keyword: resolvedVIN, ...(activeProtocol ? { protocol: activeProtocol } : {}) diff --git a/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx b/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx index bac9784d..45f07d26 100644 --- a/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx @@ -7,6 +7,7 @@ import { DataEmpty } from '../components/DataEmpty'; import { PageHeader } from '../components/PageHeader'; import { SourceStatusTags } from '../components/SourceStatusTags'; import { StatusTag } from '../components/StatusTag'; +import { summarizeVehicleService } from '../domain/vehicleService'; function vehicleServiceStatus(row: VehicleCoverageRow) { if (row.serviceStatus) { @@ -15,19 +16,14 @@ function vehicleServiceStatus(row: VehicleCoverageRow) { color: row.serviceStatus.severity === 'ok' ? 'green' as const : row.serviceStatus.severity === 'error' ? 'red' as const : 'orange' as const }; } - if (row.bindingStatus !== 'bound') { - return { label: '身份未绑定', color: 'orange' as const }; - } - if (row.sourceCount <= 0) { - return { label: '暂无数据来源', color: 'orange' as const }; - } - if (row.onlineSourceCount <= 0) { - return { label: '车辆离线', color: 'red' as const }; - } - if (row.onlineSourceCount < row.sourceCount) { - return { label: '来源不完整', color: 'orange' as const }; - } - return { label: '服务正常', color: 'green' as const }; + const verdict = summarizeVehicleService({ + bindingStatus: row.bindingStatus, + sourceCount: row.sourceCount, + onlineSourceCount: row.onlineSourceCount, + missingProtocols: row.missingProtocols, + mileageDeltaKm: row.sourceConsistency?.mileageDeltaKm + }); + return { label: verdict.status, color: verdict.color }; } function sourceEvidenceText(row: VehicleCoverageRow) { @@ -101,19 +97,26 @@ type VehicleActionRecommendation = { }; function vehicleActionRecommendation(row: VehicleCoverageRow): VehicleActionRecommendation { + const verdict = summarizeVehicleService({ + bindingStatus: row.bindingStatus, + sourceCount: row.sourceCount, + onlineSourceCount: row.onlineSourceCount, + missingProtocols: row.missingProtocols, + mileageDeltaKm: row.sourceConsistency?.mileageDeltaKm + }); if (row.bindingStatus !== 'bound') { - return { label: '维护身份绑定', color: 'orange' as const, filters: { bindingStatus: 'unbound' } }; + return { label: '维护身份绑定', color: verdict.color, filters: { bindingStatus: 'unbound' } }; } if ((row.missingProtocols ?? []).length > 0) { - return { label: `补齐 ${row.missingProtocols.join('、')} 来源`, color: 'orange' as const, filters: { missingProtocol: row.missingProtocols[0] } }; + return { label: `补齐 ${row.missingProtocols.join('、')} 来源`, color: verdict.color, filters: { missingProtocol: row.missingProtocols[0] } }; } if (row.sourceCount <= 0) { - return { label: '确认平台转发配置', color: 'orange' as const, filters: { serviceStatus: 'no_data' } }; + return { label: '确认平台转发配置', color: verdict.color, filters: { serviceStatus: 'no_data' } }; } if (row.onlineSourceCount <= 0) { - return { label: '排查离线链路', color: 'red' as const, filters: { online: 'offline' } }; + return { label: '排查离线链路', color: verdict.color, filters: { online: 'offline' } }; } - return { label: '持续观察', color: 'green' as const, filters: null }; + return { label: '持续观察', color: verdict.color, filters: null }; } function sourceConsistencyAction(row: VehicleCoverageRow, onFilter: (filters: Record) => void) {