feat(platform): share vehicle service verdict logic
This commit is contained in:
@@ -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'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
85
vehicle-data-platform/apps/web/src/domain/vehicleService.ts
Normal file
85
vehicle-data-platform/apps/web/src/domain/vehicleService.ts
Normal file
@@ -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 和里程查询继续分析。'
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { PageHeader } from '../components/PageHeader';
|
|||||||
import { SourceStatusTags } from '../components/SourceStatusTags';
|
import { SourceStatusTags } from '../components/SourceStatusTags';
|
||||||
import { StatusTag } from '../components/StatusTag';
|
import { StatusTag } from '../components/StatusTag';
|
||||||
import { isLikelyVIN } from '../domain/vehicleLookup';
|
import { isLikelyVIN } from '../domain/vehicleLookup';
|
||||||
|
import { summarizeVehicleService } from '../domain/vehicleService';
|
||||||
|
|
||||||
type VehicleQuery = {
|
type VehicleQuery = {
|
||||||
keyword: string;
|
keyword: string;
|
||||||
@@ -69,13 +70,6 @@ type VehicleServiceAction = {
|
|||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type VehicleServiceConclusion = {
|
|
||||||
status: string;
|
|
||||||
color: 'green' | 'orange' | 'red';
|
|
||||||
risk: string;
|
|
||||||
nextStep: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function copyVehicleServiceURL() {
|
async function copyVehicleServiceURL() {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(`${window.location.origin}${window.location.pathname}${window.location.hash}`);
|
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);
|
onQueryChange?.(nextQuery.keyword, nextQuery.protocol);
|
||||||
load(nextQuery);
|
load(nextQuery);
|
||||||
};
|
};
|
||||||
const serviceConclusion = useMemo<VehicleServiceConclusion>(() => {
|
const serviceConclusion = useMemo(() => summarizeVehicleService({
|
||||||
if (!hasResolvedVIN) {
|
resolved: hasResolvedVIN,
|
||||||
return {
|
sourceCount,
|
||||||
status: '不可服务',
|
onlineSourceCount,
|
||||||
color: 'red',
|
qualityIssueCount: qualityCount,
|
||||||
risk: '身份未归并到 VIN',
|
missingProtocols,
|
||||||
nextStep: '先维护车辆身份绑定,再使用实时、历史和里程服务。'
|
mileageDeltaKm: consistencyMileageDelta
|
||||||
};
|
}), [consistencyMileageDelta, hasResolvedVIN, missingProtocols, onlineSourceCount, qualityCount, sourceCount]);
|
||||||
}
|
|
||||||
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 qualityFiltersForCurrentVehicle = () => ({
|
const qualityFiltersForCurrentVehicle = () => ({
|
||||||
keyword: resolvedVIN,
|
keyword: resolvedVIN,
|
||||||
...(activeProtocol ? { protocol: activeProtocol } : {})
|
...(activeProtocol ? { protocol: activeProtocol } : {})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { DataEmpty } from '../components/DataEmpty';
|
|||||||
import { PageHeader } from '../components/PageHeader';
|
import { PageHeader } from '../components/PageHeader';
|
||||||
import { SourceStatusTags } from '../components/SourceStatusTags';
|
import { SourceStatusTags } from '../components/SourceStatusTags';
|
||||||
import { StatusTag } from '../components/StatusTag';
|
import { StatusTag } from '../components/StatusTag';
|
||||||
|
import { summarizeVehicleService } from '../domain/vehicleService';
|
||||||
|
|
||||||
function vehicleServiceStatus(row: VehicleCoverageRow) {
|
function vehicleServiceStatus(row: VehicleCoverageRow) {
|
||||||
if (row.serviceStatus) {
|
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
|
color: row.serviceStatus.severity === 'ok' ? 'green' as const : row.serviceStatus.severity === 'error' ? 'red' as const : 'orange' as const
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (row.bindingStatus !== 'bound') {
|
const verdict = summarizeVehicleService({
|
||||||
return { label: '身份未绑定', color: 'orange' as const };
|
bindingStatus: row.bindingStatus,
|
||||||
}
|
sourceCount: row.sourceCount,
|
||||||
if (row.sourceCount <= 0) {
|
onlineSourceCount: row.onlineSourceCount,
|
||||||
return { label: '暂无数据来源', color: 'orange' as const };
|
missingProtocols: row.missingProtocols,
|
||||||
}
|
mileageDeltaKm: row.sourceConsistency?.mileageDeltaKm
|
||||||
if (row.onlineSourceCount <= 0) {
|
});
|
||||||
return { label: '车辆离线', color: 'red' as const };
|
return { label: verdict.status, color: verdict.color };
|
||||||
}
|
|
||||||
if (row.onlineSourceCount < row.sourceCount) {
|
|
||||||
return { label: '来源不完整', color: 'orange' as const };
|
|
||||||
}
|
|
||||||
return { label: '服务正常', color: 'green' as const };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sourceEvidenceText(row: VehicleCoverageRow) {
|
function sourceEvidenceText(row: VehicleCoverageRow) {
|
||||||
@@ -101,19 +97,26 @@ type VehicleActionRecommendation = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function vehicleActionRecommendation(row: VehicleCoverageRow): 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') {
|
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) {
|
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) {
|
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) {
|
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<string, string>) => void) {
|
function sourceConsistencyAction(row: VehicleCoverageRow, onFilter: (filters: Record<string, string>) => void) {
|
||||||
|
|||||||
Reference in New Issue
Block a user