- Globally rename user-facing 通知 → 干预 (list badge, detail button, batch modal, CSV header, server response messages, db table comment) - 已干预 row in detail is now clickable — opens SwapPreview which shows a read-only summary plus a 取消干预 action (PATCH notify /:id with status=cancelled). Sending is blocked while already intervened. - Selected suggestion now follows the latest data snapshot so status changes from within the detail flow propagate immediately. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
104 lines
3.4 KiB
TypeScript
104 lines
3.4 KiB
TypeScript
import type { SchedulingSuggestion, CandidateVehicle } from './types';
|
|
|
|
function csvCell(v: string | number | null | undefined): string {
|
|
if (v === null || v === undefined) return '';
|
|
const s = typeof v === 'number' ? String(v) : v;
|
|
if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
|
|
return s;
|
|
}
|
|
|
|
function pickTopCandidate(s: SchedulingSuggestion): CandidateVehicle | null {
|
|
if (s.candidates.length === 0) return null;
|
|
const sameRegion = s.candidates.filter(c => c.isSameRegion);
|
|
const pool = sameRegion.length > 0 ? sameRegion : s.candidates;
|
|
return pool.find(c => c.canQualifyAfterSwap) ?? pool[0];
|
|
}
|
|
|
|
function pctString(rate: number): string {
|
|
return (rate * 100).toFixed(1) + '%';
|
|
}
|
|
|
|
function typeLabel(s: SchedulingSuggestion): string {
|
|
return s.type === 'replace_qualified' ? '里程高·换下' : '里程低·换走';
|
|
}
|
|
|
|
const HEADERS = [
|
|
'车牌号',
|
|
'业务部门',
|
|
'业务负责人',
|
|
'客户',
|
|
'车型',
|
|
'运营区域',
|
|
'调度类型',
|
|
'当前年里程(km)',
|
|
'年度考核(km)',
|
|
'年度完成率',
|
|
'客户30日均(km)',
|
|
'客户7日均(km)',
|
|
'剩余天数',
|
|
'最优候选车牌',
|
|
'候选当前里程(km)',
|
|
'候选替换后预估(km)',
|
|
'候选可达标',
|
|
'候选区域',
|
|
'干预状态',
|
|
] as const;
|
|
|
|
export function buildSuggestionsCsv(suggestions: SchedulingSuggestion[]): string {
|
|
const rows: string[] = [HEADERS.map(csvCell).join(',')];
|
|
for (const s of suggestions) {
|
|
const v = s.currentVehicle;
|
|
const top = pickTopCandidate(s);
|
|
const notifStatus =
|
|
s.candidates.find(c => c.notificationStatus === 'executed') ? '已执行'
|
|
: s.candidates.find(c => c.notificationStatus === 'sent') ? '待执行'
|
|
: '';
|
|
rows.push([
|
|
csvCell(v.plateNumber),
|
|
csvCell(v.department ?? ''),
|
|
csvCell(v.manager ?? ''),
|
|
csvCell(v.customer ?? ''),
|
|
csvCell(v.vehicleType),
|
|
csvCell(v.region),
|
|
csvCell(typeLabel(s)),
|
|
csvCell(Math.round(v.currentYearMileage)),
|
|
csvCell(Math.round(v.yearTarget)),
|
|
csvCell(pctString(v.completionRate)),
|
|
csvCell(Math.round(v.customerAvgDaily)),
|
|
csvCell(Math.round(v.customerAvgDaily7d)),
|
|
csvCell(v.daysLeft),
|
|
csvCell(top?.plateNumber ?? ''),
|
|
csvCell(top ? Math.round(top.totalMileage) : ''),
|
|
csvCell(top ? Math.round(top.predictedAfterSwap) : ''),
|
|
csvCell(top ? (top.canQualifyAfterSwap ? '是' : '否') : ''),
|
|
csvCell(top?.region ?? ''),
|
|
csvCell(notifStatus),
|
|
].join(','));
|
|
}
|
|
return rows.join('\r\n');
|
|
}
|
|
|
|
export function downloadCsv(filename: string, csv: string): void {
|
|
// UTF-8 BOM so Excel opens Chinese characters correctly
|
|
const blob = new Blob(['\uFEFF', csv], { type: 'text/csv;charset=utf-8' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
export function exportSuggestionsCsv(suggestions: SchedulingSuggestion[], prefix = '调度建议'): void {
|
|
const now = new Date();
|
|
const y = now.getFullYear();
|
|
const m = String(now.getMonth() + 1).padStart(2, '0');
|
|
const d = String(now.getDate()).padStart(2, '0');
|
|
const hh = String(now.getHours()).padStart(2, '0');
|
|
const mm = String(now.getMinutes()).padStart(2, '0');
|
|
const csv = buildSuggestionsCsv(suggestions);
|
|
downloadCsv(`${prefix}_${y}${m}${d}_${hh}${mm}.csv`, csv);
|
|
}
|