From ce3a6225ec3c6fcfeb81ba95bf8fa1548edeb68e Mon Sep 17 00:00:00 2001 From: lingniu Date: Mon, 6 Jul 2026 05:14:11 +0800 Subject: [PATCH] feat(platform): add customer mileage reconciliation command --- .../apps/web/src/pages/Mileage.tsx | 154 ++++++++++++++++++ .../apps/web/src/test/App.test.tsx | 14 ++ 2 files changed, 168 insertions(+) diff --git a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx index 80f0ef7f..1940390e 100644 --- a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx @@ -324,6 +324,58 @@ function mileageCustomerDecisionText({ ].join('\n'); } +function mileageReconciliationControlText({ + filters, + summary, + pageMileageTotal, + closureStatus, + closureDeltaKm, + anomalyCount, + pageCoverageRate, + onlineSummary, + peakMileage, + statisticsURL, + vehicleURL +}: { + filters: Record; + 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({ filters, 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 vehicleURL = currentVehicleKeyword ? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol })) @@ -1461,6 +1534,50 @@ export function Mileage({ 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 = [ { label: '区间里程', @@ -1735,6 +1852,43 @@ export function Mileage({ )} /> +
+
+ + 客户里程对账总控 + {mileageDeliveryState} + {closureStatus} + + + 把区间里程、每日闭合、轨迹证明、异常通知和报表导出收敛成客户可验收的对账链路。 + + + 客户只需要知道这段时间跑了多少、是否闭合、哪里需要复核,以及能拿到哪份导出报表。 + + + + + + +
+
+ {mileageReconciliationControlItems.map((item) => ( + + ))} +
+
diff --git a/vehicle-data-platform/apps/web/src/test/App.test.tsx b/vehicle-data-platform/apps/web/src/test/App.test.tsx index 9d751cf4..d208f05c 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -9863,6 +9863,13 @@ test('shows vehicle-first statistics domains for one vehicle service', async () render(); 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.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('发布判断:复核后发布')); 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]); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【客户里程决策说明】')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('决策结论:复核后交付'));