feat(scheduling): history view, execute/cancel lifecycle, CSV export, 7d trend
- Add 调度记录 modal: lists notifications by status, supports 标记已执行 (with after-mileage + notes) and 取消 for open records - Add CSV export of filtered suggestions (UTF-8 BOM for Excel); top candidate per row picked by same-region > can-qualify preference - Compute customer 7-day average alongside 30-day baseline in a single query; show trend indicator (up/down/flat) next to 客户日均 in list and detail card Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
103
src/modules/scheduling/csv-export.ts
Normal file
103
src/modules/scheduling/csv-export.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user