diff --git a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx index 940876fc..ecc6fa98 100644 --- a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx @@ -240,6 +240,73 @@ function mileageDeliveryPackageText({ ].join('\n'); } +function mileageCustomerDecisionText({ + filters, + summary, + pageMileageTotal, + pageCoverageRate, + anomalyCount, + closureStatus, + closureDeltaKm, + confidenceStatus, + publishAuditConclusion, + onlineSummary, + peakMileage, + statisticsURL, + vehicleURL +}: { + filters: Record; + summary: MileageSummary; + pageMileageTotal: number; + pageCoverageRate: number; + anomalyCount: number; + closureStatus: string; + closureDeltaKm?: number; + confidenceStatus: string; + publishAuditConclusion: string; + 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 deliveryState = publishAuditConclusion === '可发布' && confidenceStatus === '可用于 BI' + ? '可交付' + : publishAuditConclusion === '阻断发布' ? '暂缓交付' : '复核后交付'; + const peak = peakMileage + ? `${peakMileage.plate || peakMileage.vin} / ${peakMileage.date} / ${formatKm(peakMileage.dailyMileageKm)} km` + : '-'; + return [ + '【客户里程决策说明】', + `决策结论:${deliveryState}`, + `车辆范围:${keyword}`, + `数据来源:${protocol}`, + `时间范围:${dateRange}`, + `区间总里程:${formatKm(summary.totalMileageKm)} km`, + `当前明细合计:${formatKm(pageMileageTotal)} km`, + `区间闭合:${closureStatus}${closureDeltaKm == null ? ',当前为分页抽样' : `,差值 ${formatKm(Math.abs(closureDeltaKm))} km`}`, + `记录覆盖:${formatPercent(pageCoverageRate)}`, + `异常记录:${anomalyCount.toLocaleString()} 条`, + `最高单日:${peak}`, + `在线影响:${onlineSummary.onlineVehicleCount.toLocaleString()} 在线 / ${onlineSummary.offlineVehicleCount.toLocaleString()} 离线 / 在线率 ${formatPercent(onlineSummary.onlineRatePercent)}`, + `BI发布判断:${publishAuditConclusion}`, + '', + '客户判断路径:', + '1. 先看区间闭合:区间总值必须等于每日里程之和。', + '2. 再看记录覆盖:发布前需要覆盖全量明细或导出全量。', + '3. 再看异常记录:异常日必须回看轨迹和 RAW。', + '4. 最后看在线影响:离线车辆会影响当天里程解释。', + `里程统计:${statisticsURL}`, + `轨迹复核:${appURL(buildAppHash({ page: 'history', keyword: filters.keyword?.trim() || '', protocol: filters.protocol?.trim() || '', filters: { ...(dateFrom ? { dateFrom } : {}), ...(dateTo ? { dateTo } : {}) } }))}`, + `RAW证据:${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, @@ -675,6 +742,48 @@ export function Mileage({ color: publishAuditColor } ]; + const mileageDecisionItems = [ + { + label: '交付结论', + value: mileageDeliveryState, + detail: mileageDeliveryState === '可交付' ? '可进入客户查询、BI发布和运营日报。' : mileageDeliveryState === '暂缓交付' ? '存在阻断项,先完成证据复核。' : '需补齐闭合、覆盖、异常或在线证据后交付。', + color: mileageDeliveryColor, + action: '复制决策', + onClick: () => copyMileageDecision() + }, + { + label: '区间闭合', + value: closureStatus, + detail: closureDeltaKm == null ? '当前页是分页抽样,发布前请拉齐全量明细。' : `闭合差值 ${formatKm(Math.abs(closureDeltaKm))} km。`, + color: closureStatusColor, + action: '统计摘要', + onClick: () => copyStatisticsSummary() + }, + { + label: '覆盖率', + value: formatPercent(pageCoverageRate), + detail: `${rows.length.toLocaleString()} / ${summary.recordCount.toLocaleString()} 条记录,交付前建议覆盖全量。`, + color: pageCoverageRate >= 100 ? 'green' as const : pageCoverageRate >= 60 ? 'orange' as const : 'red' as const, + action: '导出明细', + onClick: () => exportMileage() + }, + { + label: '异常复核', + value: `${anomalyRows.length.toLocaleString()} 条`, + detail: anomalyRows[0] ? `${anomalyRows[0].plate || anomalyRows[0].vin} / ${anomalyRows[0].date} 需要回看轨迹。` : '当前明细未命中异常标记。', + color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const, + action: '异常证据', + onClick: () => anomalyRows[0] ? copyMileageEvidence(anomalyRows[0]) : copyMileageDecision() + }, + { + label: '在线影响', + value: formatPercent(onlineRatePercent), + detail: `${onlineVehicleCount.toLocaleString()} 在线 / ${offlineVehicleCount.toLocaleString()} 离线。`, + color: onlineRatePercent >= 90 ? 'green' as const : onlineRatePercent >= 60 ? 'orange' as const : 'red' as const, + action: '在线态势', + onClick: () => copyOnlineOperationsReport() + } + ]; const maxDateMileage = Math.max(...dateSeries.map((item) => item.value), 0); const maxSourceMileage = Math.max(...sourceSeries.map((item) => item.value), 0); const filterSummary = [ @@ -855,6 +964,29 @@ export function Mileage({ '客户里程复核交付包' ); }; + const copyMileageDecision = () => { + const vehicleURL = currentVehicleKeyword + ? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol })) + : undefined; + copyText( + mileageCustomerDecisionText({ + filters, + summary, + pageMileageTotal, + pageCoverageRate, + anomalyCount: anomalyRows.length, + closureStatus, + closureDeltaKm, + confidenceStatus, + publishAuditConclusion, + 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 })) @@ -1056,6 +1188,47 @@ export function Mileage({ ))} + 客户里程决策台} + style={{ marginTop: 16 }} + > +
+
+ + {mileageDeliveryState} + {publishAuditConclusion} + {confidenceStatus} + + 先判断能不能交付,再进入轨迹、RAW 和导出复核 + + 客户关心的是区间里程是否可信、异常是否解释清楚、离线是否影响统计。协议来源只作为可追溯证据,不作为页面主入口。 + + + + + + + +
+
+ {mileageDecisionItems.map((item) => ( + + ))} +
+
+
diff --git a/vehicle-data-platform/apps/web/src/styles/global.css b/vehicle-data-platform/apps/web/src/styles/global.css index 1096601f..70cc5824 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -2408,6 +2408,72 @@ button.vp-realtime-command-item:focus-visible { gap: 16px; } +.vp-mileage-decision-board { + display: grid; + grid-template-columns: minmax(300px, 0.72fr) minmax(0, 1.28fr); + gap: 16px; +} + +.vp-mileage-decision-summary { + min-height: 206px; + padding: 14px; + border: 1px solid rgba(11, 159, 119, 0.22); + border-radius: var(--vp-radius); + background: #f5fbf9; + display: grid; + gap: 10px; + align-content: start; +} + +.vp-mileage-decision-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +.vp-mileage-decision-item { + min-width: 0; + min-height: 206px; + padding: 12px; + border: 1px solid var(--vp-border); + border-radius: var(--vp-radius); + background: #fbfcff; + color: inherit; + text-align: left; + display: grid; + gap: 10px; + align-content: start; + cursor: pointer; +} + +.vp-mileage-decision-item:hover, +.vp-mileage-decision-item:focus-visible { + border-color: rgba(11, 159, 119, 0.42); + box-shadow: 0 10px 24px rgba(24, 39, 75, 0.08); + outline: none; +} + +.vp-mileage-decision-item strong { + color: var(--vp-text); + font-size: 20px; + line-height: 26px; + font-weight: 700; + word-break: break-word; +} + +.vp-mileage-decision-item span { + color: var(--vp-muted); + font-size: 12px; + line-height: 18px; +} + +.vp-mileage-decision-item em { + color: var(--vp-primary); + font-size: 12px; + font-style: normal; + font-weight: 700; +} + .vp-mileage-delivery-summary { min-height: 186px; padding: 14px; @@ -4297,6 +4363,8 @@ button.vp-realtime-command-item:focus-visible { .vp-online-ops-board, .vp-online-ops-grid, .vp-stat-insight-grid, + .vp-mileage-decision-board, + .vp-mileage-decision-grid, .vp-stat-definition-grid, .vp-stat-impact-board, .vp-stat-impact-grid, 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 d41222b1..37b2bca4 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -7803,7 +7803,7 @@ test('shows mileage statistics workspace with trend source and definition', asyn expect(screen.getByRole('button', { name: '闭合复核 RAW' })).toBeInTheDocument(); expect(screen.getByText('统计发布审计')).toBeInTheDocument(); expect(screen.getAllByText('复核后发布').length).toBeGreaterThanOrEqual(1); - expect(screen.getByText('区间闭合')).toBeInTheDocument(); + expect(screen.getAllByText('区间闭合').length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText('记录覆盖').length).toBeGreaterThanOrEqual(1); expect(screen.getByText('在线证据')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '复制统计发布审计' })).toBeInTheDocument(); @@ -7818,7 +7818,7 @@ test('shows mileage statistics workspace with trend source and definition', asyn expect(screen.getByText('42.9%')).toBeInTheDocument(); expect(screen.getByText('分页覆盖率')).toBeInTheDocument(); expect(screen.getAllByText('100%').length).toBeGreaterThanOrEqual(1); - expect(screen.getByText('闭合校验')).toBeInTheDocument(); + expect(screen.getAllByText('闭合校验').length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText('0 km').length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText('统计口径').length).toBeGreaterThanOrEqual(1); expect(screen.getByText((content) => content.includes('区间总值应等于各日里程之和'))).toBeInTheDocument(); @@ -7945,6 +7945,14 @@ test('shows vehicle-first statistics domains for one vehicle service', async () expect(screen.getByText('三类数据源最终汇总为一个车辆服务统计视图')).toBeInTheDocument(); expect(screen.getByText('当前统计证据')).toBeInTheDocument(); expect(screen.getByText('3 车 / 2 来源 / 3 条明细')).toBeInTheDocument(); + expect(screen.getByText('客户里程决策台')).toBeInTheDocument(); + expect(screen.getByText('先判断能不能交付,再进入轨迹、RAW 和导出复核')).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: '复制决策说明' }).length).toBeGreaterThanOrEqual(1); + 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: '客户里程决策 在线影响 在线态势' })).toBeInTheDocument(); expect(screen.getByText('客户里程复核交付包')).toBeInTheDocument(); expect(screen.getByText('面向客户交付里程时,先确认区间里程、闭合状态、异常记录和在线影响,再导出明细或进入轨迹与 RAW 证据复核。')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '里程交付 区间里程 复制交付' })).toBeInTheDocument(); @@ -7991,6 +7999,11 @@ 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('业务影响:当前统计范围需要先复核轨迹、RAW和离线车辆影响')); + 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('1. 先看区间闭合:区间总值必须等于每日里程之和。')); fireEvent.click(screen.getByRole('button', { name: '复制在线态势' })); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【在线统计运营态势】')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('车辆范围:VIN-STATS-SERVICE'));