feat(platform): add mileage BI publish package
This commit is contained in:
@@ -177,6 +177,57 @@ function mileageSummaryText({
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function mileageBIPublishText({
|
||||
filters,
|
||||
summary,
|
||||
pageMileageTotal,
|
||||
pageCoverageRate,
|
||||
anomalyCount,
|
||||
closureStatus,
|
||||
closureDeltaKm,
|
||||
confidenceStatus,
|
||||
sourceConsistencyText,
|
||||
statisticsURL,
|
||||
vehicleURL
|
||||
}: {
|
||||
filters: Record<string, string>;
|
||||
summary: MileageSummary;
|
||||
pageMileageTotal: number;
|
||||
pageCoverageRate: number;
|
||||
anomalyCount: number;
|
||||
closureStatus: string;
|
||||
closureDeltaKm?: number;
|
||||
confidenceStatus: string;
|
||||
sourceConsistencyText: string;
|
||||
statisticsURL: string;
|
||||
vehicleURL?: string;
|
||||
}) {
|
||||
const keyword = filters.keyword?.trim() || '全部车辆';
|
||||
const protocol = filters.protocol?.trim() || '全部来源';
|
||||
const dateFrom = filters.dateFrom?.trim();
|
||||
const dateTo = filters.dateTo?.trim();
|
||||
const dateRange = dateFrom || dateTo ? `${dateFrom || '-'} 至 ${dateTo || '-'}` : '全部日期';
|
||||
const publishable = confidenceStatus === '可用于 BI' && closureStatus === '闭合通过';
|
||||
return [
|
||||
'【BI里程发布口径】',
|
||||
`发布结论:${publishable ? '可发布' : '需复核后发布'}`,
|
||||
`车辆范围:${keyword}`,
|
||||
`数据来源:${protocol}`,
|
||||
`日期范围:${dateRange}`,
|
||||
`统计口径:日里程按首末总里程差值计算,区间总值等于每日里程之和`,
|
||||
`累计里程:${formatKm(summary.totalMileageKm)} km`,
|
||||
`明细合计:${formatKm(pageMileageTotal)} km`,
|
||||
`闭合状态:${closureStatus}${closureDeltaKm == null ? '' : `,差值 ${formatKm(Math.abs(closureDeltaKm))} km`}`,
|
||||
`记录覆盖率:${formatPercent(pageCoverageRate)}`,
|
||||
`异常记录:${anomalyCount.toLocaleString()}`,
|
||||
`来源一致性:${sourceConsistencyText}`,
|
||||
`统计车辆:${summary.vehicleCount.toLocaleString()},记录:${summary.recordCount.toLocaleString()},来源:${summary.sourceCount.toLocaleString()}`,
|
||||
`发布风险:${publishable ? '当前筛选范围闭合且无异常记录' : '存在分页未全量、异常记录或闭合差值,需要先复核轨迹和 RAW 证据'}`,
|
||||
`统计查询:${statisticsURL}`,
|
||||
...(vehicleURL ? [`车辆服务:${vehicleURL}`] : [])
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function mileageEvidencePackageText(row: DailyMileageRow) {
|
||||
const dateFrom = row.date?.trim() ?? '';
|
||||
const dateTo = nextDate(dateFrom);
|
||||
@@ -215,9 +266,11 @@ function onlineStatusHandoffText({
|
||||
}) {
|
||||
const keyword = filters.keyword?.trim() || '全部车辆';
|
||||
const protocol = filters.protocol?.trim() || '全部来源';
|
||||
const offlineRows = rows.filter((row) => !row.online);
|
||||
const protocolLines = (summary.protocolStats ?? []).length > 0
|
||||
? summary.protocolStats.map((item) => `${item.protocol} ${item.online.toLocaleString()}/${item.total.toLocaleString()}`).join(';')
|
||||
const handoffRows = Array.isArray(rows) ? rows : [];
|
||||
const offlineRows = handoffRows.filter((row) => !row.online);
|
||||
const protocolStats = Array.isArray(summary.protocolStats) ? summary.protocolStats : [];
|
||||
const protocolLines = protocolStats.length > 0
|
||||
? protocolStats.map((item) => `${item.protocol} ${item.online.toLocaleString()}/${item.total.toLocaleString()}`).join(';')
|
||||
: '-';
|
||||
return [
|
||||
'【车辆在线状态交接】',
|
||||
@@ -339,7 +392,7 @@ export function Mileage({
|
||||
setLoading(true);
|
||||
api.dailyMileage(mileageParams(values, pageSize, (page - 1) * pageSize))
|
||||
.then((nextPage) => {
|
||||
setRows(nextPage.items);
|
||||
setRows(Array.isArray(nextPage.items) ? nextPage.items : []);
|
||||
setPagination({ currentPage: page, pageSize, total: nextPage.total });
|
||||
})
|
||||
.catch((error: Error) => Toast.error(error.message))
|
||||
@@ -350,7 +403,7 @@ export function Mileage({
|
||||
setOnlineLoading(true);
|
||||
api.onlineVehicleStatuses(mileageParams(values, pageSize, (page - 1) * pageSize))
|
||||
.then((nextPage) => {
|
||||
setOnlineRows(nextPage.items);
|
||||
setOnlineRows(Array.isArray(nextPage.items) ? nextPage.items : []);
|
||||
setOnlinePagination({ currentPage: page, pageSize, total: nextPage.total });
|
||||
})
|
||||
.catch((error: Error) => Toast.error(error.message))
|
||||
@@ -417,6 +470,27 @@ export function Mileage({
|
||||
'统计摘要'
|
||||
);
|
||||
};
|
||||
const copyBIPublishText = () => {
|
||||
const vehicleURL = currentVehicleKeyword
|
||||
? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol }))
|
||||
: undefined;
|
||||
copyText(
|
||||
mileageBIPublishText({
|
||||
filters,
|
||||
summary,
|
||||
pageMileageTotal,
|
||||
pageCoverageRate,
|
||||
anomalyCount: anomalyRows.length,
|
||||
closureStatus,
|
||||
closureDeltaKm,
|
||||
confidenceStatus,
|
||||
sourceConsistencyText,
|
||||
statisticsURL: appURL(window.location.hash || buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol })),
|
||||
vehicleURL
|
||||
}),
|
||||
'BI里程发布口径'
|
||||
);
|
||||
};
|
||||
const copyOnlineStatusHandoff = () => {
|
||||
copyText(
|
||||
onlineStatusHandoffText({
|
||||
@@ -580,6 +654,29 @@ export function Mileage({
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
<Card bordered title="BI发布口径" style={{ marginTop: 16 }}>
|
||||
<div className="vp-bi-publish-board">
|
||||
<div className="vp-bi-publish-main">
|
||||
<Tag color={confidenceColor}>{confidenceStatus}</Tag>
|
||||
<div className="vp-monitor-metric-value">
|
||||
{confidenceStatus === '可用于 BI' && closureStatus === '闭合通过' ? '可发布' : '需复核后发布'}
|
||||
</div>
|
||||
<Typography.Text type="secondary">
|
||||
日里程按首末总里程差值计算,区间总值必须等于每日里程之和;发布前同步检查异常记录、分页覆盖和来源一致性。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="vp-bi-publish-grid">
|
||||
<div><span>闭合状态</span><strong>{closureStatus}</strong></div>
|
||||
<div><span>记录覆盖</span><strong>{formatPercent(pageCoverageRate)}</strong></div>
|
||||
<div><span>异常记录</span><strong>{anomalyRows.length.toLocaleString()}</strong></div>
|
||||
<div><span>来源一致性</span><strong>{sourceConsistencyText}</strong></div>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Button size="small" onClick={copyBIPublishText}>复制BI发布口径</Button>
|
||||
<Button size="small" onClick={copyStatisticsSummary}>复制统计摘要</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="vp-stat-workspace">
|
||||
<Card bordered title="区间趋势">
|
||||
<div className="vp-stat-chart">
|
||||
|
||||
@@ -991,6 +991,47 @@ button.vp-realtime-command-item:focus-visible {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vp-bi-publish-board {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.9fr) auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vp-bi-publish-main {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.vp-bi-publish-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vp-bi-publish-grid div {
|
||||
min-height: 68px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--vp-border);
|
||||
border-radius: var(--vp-radius);
|
||||
background: #fbfcff;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.vp-bi-publish-grid span {
|
||||
color: var(--vp-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.vp-bi-publish-grid strong {
|
||||
color: var(--vp-text);
|
||||
font-size: 15px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.vp-trajectory-anomaly-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -1516,6 +1557,8 @@ button.vp-realtime-command-item:focus-visible {
|
||||
.vp-stat-insight-grid,
|
||||
.vp-stat-definition-grid,
|
||||
.vp-stat-closure-board,
|
||||
.vp-bi-publish-board,
|
||||
.vp-bi-publish-grid,
|
||||
.vp-trajectory-anomaly-grid,
|
||||
.vp-vehicle-map-layout,
|
||||
.vp-playback-layout,
|
||||
|
||||
@@ -7060,12 +7060,12 @@ test('shows mileage statistics workspace with trend source and definition', asyn
|
||||
expect(screen.getByText('明细合计')).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.getByRole('button', { name: '闭合复核轨迹' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '闭合复核 RAW' })).toBeInTheDocument();
|
||||
expect(screen.getByText('异常记录')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('异常记录').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('1').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('最大单日')).toBeInTheDocument();
|
||||
expect(screen.getByText('60 km')).toBeInTheDocument();
|
||||
@@ -7199,7 +7199,7 @@ test('shows vehicle-first statistics domains for one vehicle service', async ()
|
||||
expect(screen.getAllByText('里程统计').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('在线率与离线时长')).toBeInTheDocument();
|
||||
expect(screen.getByText('数据完整性')).toBeInTheDocument();
|
||||
expect(screen.getByText('来源一致性')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('来源一致性').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('三类数据源最终汇总为一个车辆服务统计视图')).toBeInTheDocument();
|
||||
expect(screen.getByText('当前统计证据')).toBeInTheDocument();
|
||||
expect(screen.getByText('3 车 / 2 来源 / 3 条明细')).toBeInTheDocument();
|
||||
@@ -7280,7 +7280,9 @@ test('copies mileage statistics summary for operations reporting', async () => {
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText('区间趋势')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制统计摘要' }));
|
||||
expect(screen.getByText('BI发布口径')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '复制BI发布口径' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getAllByRole('button', { name: '复制统计摘要' })[0]);
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【里程统计摘要】'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('车辆范围:VIN-MILEAGE-COPY'));
|
||||
@@ -7293,6 +7295,15 @@ test('copies mileage statistics summary for operations reporting', async () => {
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('统计口径:区间总值等于各日里程之和'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('统计查询:http://localhost:3000/#/mileage?keyword=VIN-MILEAGE-COPY&protocol=JT808&dateFrom=2026-07-01&dateTo=2026-07-03'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('车辆服务:http://localhost:3000/#/detail?keyword=VIN-MILEAGE-COPY&protocol=JT808'));
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制BI发布口径' }));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【BI里程发布口径】'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('发布结论:需复核后发布'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('统计口径:日里程按首末总里程差值计算,区间总值等于每日里程之和'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('闭合状态:分页抽样'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('记录覆盖率:50%'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('异常记录:1'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('来源一致性:可做跨来源核对'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('发布风险:存在分页未全量、异常记录或闭合差值,需要先复核轨迹和 RAW 证据'));
|
||||
});
|
||||
|
||||
test('exports current mileage page as CSV', async () => {
|
||||
|
||||
Reference in New Issue
Block a user