feat(platform): add customer mileage reconciliation command

This commit is contained in:
lingniu
2026-07-06 05:14:11 +08:00
parent 378706cc5c
commit ce3a6225ec
2 changed files with 168 additions and 0 deletions

View File

@@ -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>

View File

@@ -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('决策结论:复核后交付'));