feat(platform): add customer mileage reconciliation command
This commit is contained in:
@@ -324,6 +324,58 @@ function mileageCustomerDecisionText({
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mileageReconciliationControlText({
|
||||||
|
filters,
|
||||||
|
summary,
|
||||||
|
pageMileageTotal,
|
||||||
|
closureStatus,
|
||||||
|
closureDeltaKm,
|
||||||
|
anomalyCount,
|
||||||
|
pageCoverageRate,
|
||||||
|
onlineSummary,
|
||||||
|
peakMileage,
|
||||||
|
statisticsURL,
|
||||||
|
vehicleURL
|
||||||
|
}: {
|
||||||
|
filters: Record<string, string>;
|
||||||
|
summary: MileageSummary;
|
||||||
|
pageMileageTotal: number;
|
||||||
|
closureStatus: string;
|
||||||
|
closureDeltaKm?: number;
|
||||||
|
anomalyCount: number;
|
||||||
|
pageCoverageRate: number;
|
||||||
|
onlineSummary: OnlineStatisticsSummary;
|
||||||
|
peakMileage?: DailyMileageRow;
|
||||||
|
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 peak = peakMileage
|
||||||
|
? `${peakMileage.plate || peakMileage.vin} / ${peakMileage.date} / ${formatKm(peakMileage.dailyMileageKm)} km`
|
||||||
|
: '-';
|
||||||
|
return [
|
||||||
|
'【客户里程对账总控包】',
|
||||||
|
`服务链路:区间里程 -> 每日闭合 -> 轨迹证明 -> 报表导出`,
|
||||||
|
`对账范围:${keyword} / ${protocol} / ${dateRange}`,
|
||||||
|
`区间里程:${formatKm(summary.totalMileageKm)} km`,
|
||||||
|
`明细合计:${formatKm(pageMileageTotal)} km`,
|
||||||
|
`闭合状态:${closureStatus}${closureDeltaKm == null ? '' : `,差值 ${formatKm(Math.abs(closureDeltaKm))} km`}`,
|
||||||
|
`异常记录:${anomalyCount.toLocaleString()} 条`,
|
||||||
|
`覆盖率:${formatPercent(pageCoverageRate)}`,
|
||||||
|
`在线影响:${onlineSummary.onlineVehicleCount.toLocaleString()} 在线 / ${onlineSummary.offlineVehicleCount.toLocaleString()} 离线 / 在线率 ${formatPercent(onlineSummary.onlineRatePercent)}`,
|
||||||
|
`轨迹证明:${peak}`,
|
||||||
|
`统计查询:${statisticsURL}`,
|
||||||
|
`报表导出:${statisticsURL}`,
|
||||||
|
`轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: filters.keyword?.trim() || '', protocol: filters.protocol?.trim() || '', filters: { ...(dateFrom ? { dateFrom } : {}), ...(dateTo ? { dateTo } : {}) } }))}`,
|
||||||
|
`历史明细:${appURL(buildAppHash({ page: 'history-query', keyword: filters.keyword?.trim() || '', protocol: filters.protocol?.trim() || '', filters: { ...(dateFrom ? { dateFrom } : {}), ...(dateTo ? { dateTo } : {}), tab: 'raw', includeFields: 'true' } }))}`,
|
||||||
|
...(vehicleURL ? [`车辆服务:${vehicleURL}`] : [])
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
function mileageBIPublishText({
|
function mileageBIPublishText({
|
||||||
filters,
|
filters,
|
||||||
summary,
|
summary,
|
||||||
@@ -1326,6 +1378,27 @@ export function Mileage({
|
|||||||
'客户里程决策说明'
|
'客户里程决策说明'
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
const copyMileageReconciliationControl = () => {
|
||||||
|
const vehicleURL = currentVehicleKeyword
|
||||||
|
? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol }))
|
||||||
|
: undefined;
|
||||||
|
copyText(
|
||||||
|
mileageReconciliationControlText({
|
||||||
|
filters,
|
||||||
|
summary,
|
||||||
|
pageMileageTotal,
|
||||||
|
closureStatus,
|
||||||
|
closureDeltaKm,
|
||||||
|
anomalyCount: anomalyRows.length,
|
||||||
|
pageCoverageRate,
|
||||||
|
onlineSummary,
|
||||||
|
peakMileage,
|
||||||
|
statisticsURL: appURL(window.location.hash || buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol })),
|
||||||
|
vehicleURL
|
||||||
|
}),
|
||||||
|
'客户里程对账总控包'
|
||||||
|
);
|
||||||
|
};
|
||||||
const copyBIPublishText = () => {
|
const copyBIPublishText = () => {
|
||||||
const vehicleURL = currentVehicleKeyword
|
const vehicleURL = currentVehicleKeyword
|
||||||
? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol }))
|
? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol }))
|
||||||
@@ -1461,6 +1534,50 @@ export function Mileage({
|
|||||||
onClick: exportMileage
|
onClick: exportMileage
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
const mileageReconciliationControlItems = [
|
||||||
|
{
|
||||||
|
title: '区间里程',
|
||||||
|
value: `${formatKm(summary.totalMileageKm)} km`,
|
||||||
|
detail: `${summary.vehicleCount.toLocaleString()} 辆车,${mileageRangeText}。`,
|
||||||
|
action: '复制摘要',
|
||||||
|
color: 'blue' as const,
|
||||||
|
disabled: false,
|
||||||
|
onClick: copyStatisticsSummary
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '每日闭合',
|
||||||
|
value: closureStatus,
|
||||||
|
detail: closureDeltaKm == null ? `当前页覆盖 ${formatPercent(pageCoverageRate)},交付前需要拉齐全量明细。` : `闭合差值 ${formatKm(Math.abs(closureDeltaKm))} km。`,
|
||||||
|
action: '闭合审计',
|
||||||
|
color: closureStatusColor,
|
||||||
|
disabled: false,
|
||||||
|
onClick: copyPublishAudit
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '轨迹证明',
|
||||||
|
value: peakMileage?.date || mileageRangeText,
|
||||||
|
detail: peakMileage ? `${peakMileage.plate || peakMileage.vin} 最大单日 ${formatKm(peakMileage.dailyMileageKm)} km。` : '选择车辆后按同一时间窗回放轨迹。',
|
||||||
|
action: '轨迹复核',
|
||||||
|
color: currentVehicleKeyword ? 'blue' as const : 'grey' as const,
|
||||||
|
disabled: !currentVehicleKeyword || !onOpenHistory,
|
||||||
|
onClick: () => {
|
||||||
|
if (peakMileage) {
|
||||||
|
openMileageEvidence(peakMileage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '报表导出',
|
||||||
|
value: `${rows.length.toLocaleString()} 条`,
|
||||||
|
detail: '导出当前里程明细和口径说明,作为客户对账附件。',
|
||||||
|
action: '导出报表',
|
||||||
|
color: rows.length > 0 ? 'blue' as const : 'grey' as const,
|
||||||
|
disabled: rows.length === 0,
|
||||||
|
onClick: exportMileage
|
||||||
|
}
|
||||||
|
];
|
||||||
const mileageReconciliationItems = [
|
const mileageReconciliationItems = [
|
||||||
{
|
{
|
||||||
label: '区间里程',
|
label: '区间里程',
|
||||||
@@ -1735,6 +1852,43 @@ export function Mileage({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<section className="vp-mileage-conclusion-strip" aria-label="客户里程对账总控">
|
||||||
|
<div className="vp-mileage-conclusion-copy">
|
||||||
|
<Space wrap>
|
||||||
|
<Tag color="blue">客户里程对账总控</Tag>
|
||||||
|
<Tag color={mileageDeliveryColor}>{mileageDeliveryState}</Tag>
|
||||||
|
<Tag color={closureStatusColor}>{closureStatus}</Tag>
|
||||||
|
</Space>
|
||||||
|
<Typography.Title heading={5} style={{ margin: 0 }}>
|
||||||
|
把区间里程、每日闭合、轨迹证明、异常通知和报表导出收敛成客户可验收的对账链路。
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
客户只需要知道这段时间跑了多少、是否闭合、哪里需要复核,以及能拿到哪份导出报表。
|
||||||
|
</Typography.Text>
|
||||||
|
<Space wrap>
|
||||||
|
<Button size="small" theme="solid" type="primary" onClick={copyMileageReconciliationControl}>复制对账总控包</Button>
|
||||||
|
<Button size="small" disabled={!currentVehicleKeyword || !onOpenHistory} onClick={() => onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' })}>轨迹复核</Button>
|
||||||
|
<Button size="small" disabled={rows.length === 0} onClick={exportMileage}>导出报表</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<div className="vp-mileage-conclusion-metrics">
|
||||||
|
{mileageReconciliationControlItems.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.title}
|
||||||
|
type="button"
|
||||||
|
className="vp-mileage-conclusion-metric"
|
||||||
|
disabled={item.disabled}
|
||||||
|
onClick={item.onClick}
|
||||||
|
aria-label={`客户里程对账总控 ${item.title} ${item.value} ${item.action}`}
|
||||||
|
>
|
||||||
|
<Tag color={item.color}>{item.title}</Tag>
|
||||||
|
<strong>{item.value}</strong>
|
||||||
|
<span>{item.detail}</span>
|
||||||
|
<em>{item.action}</em>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section className="vp-mileage-conclusion-strip" aria-label="客户里程结论条">
|
<section className="vp-mileage-conclusion-strip" aria-label="客户里程结论条">
|
||||||
<div className="vp-mileage-conclusion-copy">
|
<div className="vp-mileage-conclusion-copy">
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
|
|||||||
@@ -9863,6 +9863,13 @@ test('shows vehicle-first statistics domains for one vehicle service', async ()
|
|||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
expect(await screen.findByText('客户统计查询')).toBeInTheDocument();
|
expect(await screen.findByText('客户统计查询')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('客户里程对账总控')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('把区间里程、每日闭合、轨迹证明、异常通知和报表导出收敛成客户可验收的对账链路。')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: '客户里程对账总控 区间里程 210 km 复制摘要' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: '客户里程对账总控 每日闭合 分页抽样 闭合审计' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: '客户里程对账总控 轨迹证明 2026-07-02 轨迹复核' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: '客户里程对账总控 报表导出 3 条 导出报表' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /复制对账总控包/ })).toBeInTheDocument();
|
||||||
expect(screen.getByText('客户里程结论条')).toBeInTheDocument();
|
expect(screen.getByText('客户里程结论条')).toBeInTheDocument();
|
||||||
expect(screen.getByText('先给客户一个可交付结论,再进入轨迹复核、明细导出和告警通知。')).toBeInTheDocument();
|
expect(screen.getByText('先给客户一个可交付结论,再进入轨迹复核、明细导出和告警通知。')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: '客户里程结论 区间累计 210 km' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: '客户里程结论 区间累计 210 km' })).toBeInTheDocument();
|
||||||
@@ -9989,6 +9996,13 @@ test('shows vehicle-first statistics domains for one vehicle service', async ()
|
|||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('在线状态:3 在线 / 1 离线 / 在线率 75%'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('在线状态:3 在线 / 1 离线 / 在线率 75%'));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('发布判断:复核后发布'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('发布判断:复核后发布'));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('业务影响:当前统计范围需要先复核轨迹、历史明细和离线车辆影响'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('业务影响:当前统计范围需要先复核轨迹、历史明细和离线车辆影响'));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /复制对账总控包/ }));
|
||||||
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【客户里程对账总控包】'));
|
||||||
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('服务链路:区间里程 -> 每日闭合 -> 轨迹证明 -> 报表导出'));
|
||||||
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('对账范围:VIN-STATS-SERVICE / JT808 / 全部日期'));
|
||||||
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('区间里程:210 km'));
|
||||||
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('闭合状态:分页抽样'));
|
||||||
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('报表导出:http://localhost:3000/#/mileage?keyword=VIN-STATS-SERVICE&protocol=JT808'));
|
||||||
fireEvent.click(screen.getAllByRole('button', { name: '复制决策说明' })[0]);
|
fireEvent.click(screen.getAllByRole('button', { name: '复制决策说明' })[0]);
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【客户里程决策说明】'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【客户里程决策说明】'));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('决策结论:复核后交付'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('决策结论:复核后交付'));
|
||||||
|
|||||||
Reference in New Issue
Block a user