feat(platform): add mileage publish audit
This commit is contained in:
@@ -228,6 +228,64 @@ function mileageBIPublishText({
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MileagePublishAuditItem = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
status: 'pass' | 'review' | 'blocked';
|
||||||
|
value: string;
|
||||||
|
detail: string;
|
||||||
|
action: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function auditStatusColor(status: MileagePublishAuditItem['status']): 'green' | 'orange' | 'red' {
|
||||||
|
if (status === 'pass') return 'green';
|
||||||
|
if (status === 'blocked') return 'red';
|
||||||
|
return 'orange';
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditStatusLabel(status: MileagePublishAuditItem['status']) {
|
||||||
|
if (status === 'pass') return '通过';
|
||||||
|
if (status === 'blocked') return '阻断';
|
||||||
|
return '复核';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mileagePublishAuditReport({
|
||||||
|
filters,
|
||||||
|
items,
|
||||||
|
summary,
|
||||||
|
statisticsURL,
|
||||||
|
vehicleURL
|
||||||
|
}: {
|
||||||
|
filters: Record<string, string>;
|
||||||
|
items: MileagePublishAuditItem[];
|
||||||
|
summary: MileageSummary;
|
||||||
|
statisticsURL: string;
|
||||||
|
vehicleURL?: string;
|
||||||
|
}) {
|
||||||
|
const blocked = items.filter((item) => item.status === 'blocked').length;
|
||||||
|
const review = items.filter((item) => item.status === 'review').length;
|
||||||
|
const pass = items.filter((item) => item.status === 'pass').length;
|
||||||
|
const keyword = filters.keyword?.trim() || '全部车辆';
|
||||||
|
const protocol = filters.protocol?.trim() || '全部来源';
|
||||||
|
return [
|
||||||
|
'【统计发布审计】',
|
||||||
|
`发布结论:${blocked > 0 ? '阻断发布' : review > 0 ? '复核后发布' : '可发布'}`,
|
||||||
|
`车辆范围:${keyword}`,
|
||||||
|
`数据来源:${protocol}`,
|
||||||
|
`统计规模:${summary.vehicleCount.toLocaleString()} 车 / ${summary.recordCount.toLocaleString()} 条 / ${summary.sourceCount.toLocaleString()} 来源`,
|
||||||
|
`审计结果:通过 ${pass.toLocaleString()} / 复核 ${review.toLocaleString()} / 阻断 ${blocked.toLocaleString()}`,
|
||||||
|
'',
|
||||||
|
...items.map((item, index) => [
|
||||||
|
`${index + 1}. [${auditStatusLabel(item.status)}] ${item.label}:${item.value}`,
|
||||||
|
` 说明:${item.detail}`,
|
||||||
|
` 动作:${item.action}`
|
||||||
|
].join('\n')),
|
||||||
|
'',
|
||||||
|
`统计查询:${statisticsURL}`,
|
||||||
|
...(vehicleURL ? [`车辆服务:${vehicleURL}`] : [])
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
function mileageEvidencePackageText(row: DailyMileageRow) {
|
function mileageEvidencePackageText(row: DailyMileageRow) {
|
||||||
const dateFrom = row.date?.trim() ?? '';
|
const dateFrom = row.date?.trim() ?? '';
|
||||||
const dateTo = nextDate(dateFrom);
|
const dateTo = nextDate(dateFrom);
|
||||||
@@ -368,6 +426,52 @@ export function Mileage({
|
|||||||
redisOnlineKeyText,
|
redisOnlineKeyText,
|
||||||
onlineSummary.evidence
|
onlineSummary.evidence
|
||||||
].filter(Boolean).join(',');
|
].filter(Boolean).join(',');
|
||||||
|
const publishAuditItems: MileagePublishAuditItem[] = [
|
||||||
|
{
|
||||||
|
key: 'closure',
|
||||||
|
label: '区间闭合',
|
||||||
|
status: closureDeltaKm == null ? 'review' : closurePassed ? 'pass' : 'blocked',
|
||||||
|
value: closureStatus,
|
||||||
|
detail: closureDeltaKm == null ? '当前分页未覆盖全量记录,无法直接证明区间总值等于每日之和。' : `闭合差值 ${formatKm(Math.abs(closureDeltaKm))} km。`,
|
||||||
|
action: closurePassed ? '保留当前统计证据,可继续审计异常和来源。' : '缩小筛选范围、翻页或导出后重新核验闭合。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'coverage',
|
||||||
|
label: '记录覆盖',
|
||||||
|
status: pageCoverageRate >= 100 ? 'pass' : summary.recordCount > rows.length ? 'review' : 'pass',
|
||||||
|
value: formatPercent(pageCoverageRate),
|
||||||
|
detail: `${rows.length.toLocaleString()} / ${summary.recordCount.toLocaleString()} 条明细在当前页。`,
|
||||||
|
action: pageCoverageRate >= 100 ? '当前明细已覆盖统计范围。' : '发布前需要导出或拉齐全量明细。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'anomaly',
|
||||||
|
label: '异常记录',
|
||||||
|
status: anomalyRows.length > 0 ? 'review' : 'pass',
|
||||||
|
value: anomalyRows.length.toLocaleString(),
|
||||||
|
detail: anomalyRows.length > 0 ? '存在日里程异常标记,需核对首末总里程、补传和断链。' : '当前筛选范围未发现异常里程记录。',
|
||||||
|
action: anomalyRows.length > 0 ? '优先打开异常行的轨迹和 RAW 证据复核。' : '继续检查来源一致性和在线证据。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'source',
|
||||||
|
label: '来源一致性',
|
||||||
|
status: summary.sourceCount > 1 ? 'pass' : summary.sourceCount === 1 ? 'review' : 'blocked',
|
||||||
|
value: sourceConsistencyText,
|
||||||
|
detail: summary.sourceCount > 1 ? '可使用多协议来源交叉验证车辆统计。' : summary.sourceCount === 1 ? '只有单一来源,统计可用但可信度低于多来源。' : '当前统计缺少来源证据。',
|
||||||
|
action: summary.sourceCount > 1 ? '保留来源贡献作为审计附件。' : '核对 32960、808、MQTT 是否有缺失来源。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'online',
|
||||||
|
label: '在线证据',
|
||||||
|
status: onlineVehicleCount > 0 ? 'pass' : offlineVehicleCount > 0 ? 'review' : 'review',
|
||||||
|
value: `${onlineVehicleCount.toLocaleString()} 在线 / ${offlineVehicleCount.toLocaleString()} 离线`,
|
||||||
|
detail: onlineStatsDetail || '等待在线统计证据。',
|
||||||
|
action: onlineVehicleCount > 0 ? '实时在线证据可辅助统计发布。' : '统计发布前建议检查离线时长和平台转发状态。'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const publishBlockedCount = publishAuditItems.filter((item) => item.status === 'blocked').length;
|
||||||
|
const publishReviewCount = publishAuditItems.filter((item) => item.status === 'review').length;
|
||||||
|
const publishAuditConclusion = publishBlockedCount > 0 ? '阻断发布' : publishReviewCount > 0 ? '复核后发布' : '可发布';
|
||||||
|
const publishAuditColor = publishBlockedCount > 0 ? 'red' as const : publishReviewCount > 0 ? 'orange' as const : 'green' as const;
|
||||||
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 = [
|
||||||
@@ -491,6 +595,21 @@ export function Mileage({
|
|||||||
'BI里程发布口径'
|
'BI里程发布口径'
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
const copyPublishAudit = () => {
|
||||||
|
const vehicleURL = currentVehicleKeyword
|
||||||
|
? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol }))
|
||||||
|
: undefined;
|
||||||
|
copyText(
|
||||||
|
mileagePublishAuditReport({
|
||||||
|
filters,
|
||||||
|
items: publishAuditItems,
|
||||||
|
summary,
|
||||||
|
statisticsURL: appURL(window.location.hash || buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol })),
|
||||||
|
vehicleURL
|
||||||
|
}),
|
||||||
|
'统计发布审计'
|
||||||
|
);
|
||||||
|
};
|
||||||
const copyOnlineStatusHandoff = () => {
|
const copyOnlineStatusHandoff = () => {
|
||||||
copyText(
|
copyText(
|
||||||
onlineStatusHandoffText({
|
onlineStatusHandoffText({
|
||||||
@@ -677,6 +796,36 @@ export function Mileage({
|
|||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card bordered title="统计发布审计" style={{ marginTop: 16 }}>
|
||||||
|
<div className="vp-stat-audit-board">
|
||||||
|
<div className="vp-stat-audit-summary">
|
||||||
|
<Tag color={publishAuditColor}>{publishAuditConclusion}</Tag>
|
||||||
|
<div className="vp-monitor-metric-value">
|
||||||
|
通过 {publishAuditItems.length - publishReviewCount - publishBlockedCount} / 复核 {publishReviewCount} / 阻断 {publishBlockedCount}
|
||||||
|
</div>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
发布前统一审计闭合、覆盖、异常、来源和在线证据;不满足时先回到轨迹和 RAW 证据复核。
|
||||||
|
</Typography.Text>
|
||||||
|
<Space wrap>
|
||||||
|
<Button size="small" onClick={copyPublishAudit}>复制统计发布审计</Button>
|
||||||
|
<Button size="small" disabled={!currentVehicleKeyword} onClick={() => onOpenVehicle(currentVehicleKeyword, currentProtocol)}>车辆服务</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<div className="vp-stat-audit-grid">
|
||||||
|
{publishAuditItems.map((item) => (
|
||||||
|
<div key={item.key} className="vp-stat-audit-item">
|
||||||
|
<Space wrap>
|
||||||
|
<Tag color={auditStatusColor(item.status)}>{auditStatusLabel(item.status)}</Tag>
|
||||||
|
<Tag color="blue">{item.label}</Tag>
|
||||||
|
</Space>
|
||||||
|
<strong>{item.value}</strong>
|
||||||
|
<Typography.Text type="secondary">{item.detail}</Typography.Text>
|
||||||
|
<div className="vp-stat-audit-action">{item.action}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
<div className="vp-stat-workspace">
|
<div className="vp-stat-workspace">
|
||||||
<Card bordered title="区间趋势">
|
<Card bordered title="区间趋势">
|
||||||
<div className="vp-stat-chart">
|
<div className="vp-stat-chart">
|
||||||
|
|||||||
@@ -1093,6 +1093,54 @@ button.vp-realtime-command-item:focus-visible {
|
|||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vp-stat-audit-board {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 0.7fr) minmax(0, 1.3fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-stat-audit-summary {
|
||||||
|
min-height: 168px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid rgba(22, 100, 255, 0.28);
|
||||||
|
border-radius: var(--vp-radius);
|
||||||
|
background: #f5f9ff;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-stat-audit-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-stat-audit-item {
|
||||||
|
min-height: 184px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--vp-border);
|
||||||
|
border-radius: var(--vp-radius);
|
||||||
|
background: #fbfcff;
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-stat-audit-item strong {
|
||||||
|
color: var(--vp-text);
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-stat-audit-action {
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--vp-border);
|
||||||
|
color: var(--vp-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.vp-trajectory-anomaly-grid {
|
.vp-trajectory-anomaly-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
@@ -1808,6 +1856,8 @@ button.vp-realtime-command-item:focus-visible {
|
|||||||
.vp-stat-closure-board,
|
.vp-stat-closure-board,
|
||||||
.vp-bi-publish-board,
|
.vp-bi-publish-board,
|
||||||
.vp-bi-publish-grid,
|
.vp-bi-publish-grid,
|
||||||
|
.vp-stat-audit-board,
|
||||||
|
.vp-stat-audit-grid,
|
||||||
.vp-trajectory-anomaly-grid,
|
.vp-trajectory-anomaly-grid,
|
||||||
.vp-source-fusion-grid,
|
.vp-source-fusion-grid,
|
||||||
.vp-source-fusion-facts,
|
.vp-source-fusion-facts,
|
||||||
|
|||||||
@@ -6920,7 +6920,7 @@ test('opens vehicle service from mileage row with row source evidence', async ()
|
|||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
expect(await screen.findByText('VIN-MILEAGE-ROW')).toBeInTheDocument();
|
expect(await screen.findByText('VIN-MILEAGE-ROW')).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole('button', { name: '车辆服务' }));
|
fireEvent.click(screen.getAllByRole('button', { name: '车辆服务' })[0]);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(window.location.hash).toBe('#/detail?keyword=VIN-MILEAGE-ROW&protocol=JT808');
|
expect(window.location.hash).toBe('#/detail?keyword=VIN-MILEAGE-ROW&protocol=JT808');
|
||||||
@@ -7236,6 +7236,12 @@ test('shows mileage statistics workspace with trend source and definition', asyn
|
|||||||
expect(screen.getByRole('button', { name: '复制闭合摘要' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: '复制闭合摘要' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: '闭合复核轨迹' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: '闭合复核轨迹' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: '闭合复核 RAW' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: '闭合复核 RAW' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('统计发布审计')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('复核后发布')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('区间闭合')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('记录覆盖').length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getByText('在线证据')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: '复制统计发布审计' })).toBeInTheDocument();
|
||||||
expect(screen.getAllByText('异常记录').length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText('异常记录').length).toBeGreaterThanOrEqual(1);
|
||||||
expect(screen.getAllByText('1').length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText('1').length).toBeGreaterThanOrEqual(1);
|
||||||
expect(screen.getByText('最大单日')).toBeInTheDocument();
|
expect(screen.getByText('最大单日')).toBeInTheDocument();
|
||||||
@@ -7376,8 +7382,8 @@ test('shows vehicle-first statistics domains for one vehicle service', async ()
|
|||||||
expect(screen.getByText('3 车 / 2 来源 / 3 条明细')).toBeInTheDocument();
|
expect(screen.getByText('3 车 / 2 来源 / 3 条明细')).toBeInTheDocument();
|
||||||
expect(await screen.findByText('75%')).toBeInTheDocument();
|
expect(await screen.findByText('75%')).toBeInTheDocument();
|
||||||
expect(screen.getAllByText((content) => content.includes('离线 1 车')).length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText((content) => content.includes('离线 1 车')).length).toBeGreaterThanOrEqual(1);
|
||||||
expect(screen.getByText((content) => content.includes('Redis 在线 92 key'))).toBeInTheDocument();
|
expect(screen.getAllByText((content) => content.includes('Redis 在线 92 key')).length).toBeGreaterThanOrEqual(1);
|
||||||
expect(screen.getByText((content) => content.includes('由车辆服务覆盖汇总、实时来源状态和 Redis 在线 key 计算'))).toBeInTheDocument();
|
expect(screen.getAllByText((content) => content.includes('由车辆服务覆盖汇总、实时来源状态和 Redis 在线 key 计算')).length).toBeGreaterThanOrEqual(1);
|
||||||
expect(await screen.findByText('在线状态明细')).toBeInTheDocument();
|
expect(await screen.findByText('在线状态明细')).toBeInTheDocument();
|
||||||
expect(await screen.findByText('VIN-STATS-SERVICE-OFFLINE')).toBeInTheDocument();
|
expect(await screen.findByText('VIN-STATS-SERVICE-OFFLINE')).toBeInTheDocument();
|
||||||
expect(screen.getByText('粤A离线1')).toBeInTheDocument();
|
expect(screen.getByText('粤A离线1')).toBeInTheDocument();
|
||||||
@@ -7453,6 +7459,7 @@ test('copies mileage statistics summary for operations reporting', async () => {
|
|||||||
expect(await screen.findByText('区间趋势')).toBeInTheDocument();
|
expect(await screen.findByText('区间趋势')).toBeInTheDocument();
|
||||||
expect(screen.getByText('BI发布口径')).toBeInTheDocument();
|
expect(screen.getByText('BI发布口径')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: '复制BI发布口径' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: '复制BI发布口径' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: '复制统计发布审计' })).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getAllByRole('button', { name: '复制统计摘要' })[0]);
|
fireEvent.click(screen.getAllByRole('button', { name: '复制统计摘要' })[0]);
|
||||||
|
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【里程统计摘要】'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【里程统计摘要】'));
|
||||||
@@ -7475,6 +7482,12 @@ test('copies mileage statistics summary for operations reporting', async () => {
|
|||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('异常记录:1'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('异常记录:1'));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('来源一致性:可做跨来源核对'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('来源一致性:可做跨来源核对'));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('发布风险:存在分页未全量、异常记录或闭合差值,需要先复核轨迹和 RAW 证据'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('发布风险:存在分页未全量、异常记录或闭合差值,需要先复核轨迹和 RAW 证据'));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '复制统计发布审计' }));
|
||||||
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【统计发布审计】'));
|
||||||
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('发布结论:复核后发布'));
|
||||||
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('审计结果:通过 1 / 复核 4 / 阻断 0'));
|
||||||
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('[复核] 记录覆盖:50%'));
|
||||||
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('[复核] 异常记录:1'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('exports current mileage page as CSV', async () => {
|
test('exports current mileage page as CSV', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user