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:
272
src/modules/scheduling/NotificationHistory.tsx
Normal file
272
src/modules/scheduling/NotificationHistory.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { X, RotateCcw, Clock, CheckCircle2, XCircle, Send, Loader2 } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { fetchNotifications, updateNotification } from './api';
|
||||
import type { NotificationRecord, NotificationStatus } from './types';
|
||||
import Blur from '../../components/Blur';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onChange?: () => void;
|
||||
}
|
||||
|
||||
type StatusTab = 'all' | NotificationStatus;
|
||||
|
||||
const STATUS_TABS: { key: StatusTab; label: string }[] = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'sent', label: '待执行' },
|
||||
{ key: 'executed', label: '已执行' },
|
||||
{ key: 'cancelled', label: '已取消' },
|
||||
];
|
||||
|
||||
function statusBadge(status: NotificationStatus) {
|
||||
if (status === 'sent') return { text: '待执行', icon: <Send size={9} />, cls: 'text-amber-700 bg-amber-50' };
|
||||
if (status === 'executed') return { text: '已执行', icon: <CheckCircle2 size={9} />, cls: 'text-emerald-700 bg-emerald-50' };
|
||||
return { text: '已取消', icon: <XCircle size={9} />, cls: 'text-slate-500 bg-slate-100' };
|
||||
}
|
||||
|
||||
function fmtDateTime(iso: string): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${y}-${m}-${day} ${hh}:${mm}`;
|
||||
}
|
||||
|
||||
export default function NotificationHistory({ onClose, onChange }: Props) {
|
||||
const [records, setRecords] = useState<NotificationRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tab, setTab] = useState<StatusTab>('all');
|
||||
const [mutatingId, setMutatingId] = useState<number | null>(null);
|
||||
const [executeTarget, setExecuteTarget] = useState<NotificationRecord | null>(null);
|
||||
const [afterMileageInput, setAfterMileageInput] = useState('');
|
||||
const [notesInput, setNotesInput] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const resp = await fetchNotifications(tab === 'all' ? undefined : tab);
|
||||
setRecords(resp.records);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tab]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleExecuteClick = (rec: NotificationRecord) => {
|
||||
setExecuteTarget(rec);
|
||||
setAfterMileageInput('');
|
||||
setNotesInput('');
|
||||
};
|
||||
|
||||
const handleExecuteConfirm = async () => {
|
||||
if (!executeTarget) return;
|
||||
setMutatingId(executeTarget.id);
|
||||
try {
|
||||
const body: { status: NotificationStatus; notes?: string; afterMileage?: number } = { status: 'executed' };
|
||||
if (notesInput.trim()) body.notes = notesInput.trim();
|
||||
const parsed = Number(afterMileageInput);
|
||||
if (Number.isFinite(parsed) && parsed > 0) body.afterMileage = parsed;
|
||||
await updateNotification(executeTarget.id, body);
|
||||
setExecuteTarget(null);
|
||||
await load();
|
||||
onChange?.();
|
||||
} finally {
|
||||
setMutatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (rec: NotificationRecord) => {
|
||||
if (!confirm(`确定取消 ${rec.currentPlate} → ${rec.candidatePlate} 的替换通知?`)) return;
|
||||
setMutatingId(rec.id);
|
||||
try {
|
||||
await updateNotification(rec.id, { status: 'cancelled' });
|
||||
await load();
|
||||
onChange?.();
|
||||
} finally {
|
||||
setMutatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-end sm:items-center justify-center" onClick={onClose}>
|
||||
<motion.div
|
||||
initial={{ y: 40, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:max-w-xl overflow-hidden flex flex-col max-h-[85vh] sm:mx-4"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={16} className="text-white" />
|
||||
<span className="text-white font-bold text-sm">调度记录</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={load} disabled={loading} className="text-slate-300 hover:text-white p-1 cursor-pointer">
|
||||
<RotateCcw size={14} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button onClick={onClose} className="text-slate-300 hover:text-white p-1 cursor-pointer">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status tabs */}
|
||||
<div className="border-b border-slate-100 px-4 py-2 flex gap-1.5 flex-shrink-0">
|
||||
{STATUS_TABS.map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`text-[11px] px-3 py-1 rounded-full font-medium cursor-pointer transition-colors ${
|
||||
tab === t.key ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && records.length === 0 ? (
|
||||
<div className="py-16 text-center text-slate-400 text-xs flex items-center justify-center gap-2">
|
||||
<Loader2 size={14} className="animate-spin" /> 加载中
|
||||
</div>
|
||||
) : records.length === 0 ? (
|
||||
<div className="py-16 text-center text-slate-400">
|
||||
<Clock className="w-8 h-8 text-slate-200 mx-auto mb-2" />
|
||||
<p className="text-sm">暂无记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-50">
|
||||
{records.map(rec => {
|
||||
const badge = statusBadge(rec.status);
|
||||
const busy = mutatingId === rec.id;
|
||||
return (
|
||||
<div key={rec.id} className="px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-1.5 text-xs min-w-0">
|
||||
<span className="font-mono font-bold text-slate-900"><Blur>{rec.currentPlate}</Blur></span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span className="font-mono font-bold text-blue-700"><Blur>{rec.candidatePlate}</Blur></span>
|
||||
</div>
|
||||
<span className={`text-[9px] font-bold px-1.5 py-0.5 rounded flex items-center gap-0.5 flex-shrink-0 ${badge.cls}`}>
|
||||
{badge.icon} {badge.text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[10px] text-slate-400">
|
||||
{rec.operatorName && <span>操作人 {rec.operatorName}</span>}
|
||||
<span>{fmtDateTime(rec.createdAt)}</span>
|
||||
{rec.status === 'executed' && rec.executedAt && (
|
||||
<span className="text-emerald-500">执行 {fmtDateTime(rec.executedAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
{rec.notes && (
|
||||
<div className="mt-1 text-[10px] text-slate-500 bg-slate-50 rounded px-2 py-1">{rec.notes}</div>
|
||||
)}
|
||||
{rec.status === 'sent' && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleExecuteClick(rec)}
|
||||
disabled={busy}
|
||||
className="text-[10px] font-bold text-white bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 px-2.5 py-1 rounded cursor-pointer transition-colors flex items-center gap-1"
|
||||
>
|
||||
<CheckCircle2 size={10} /> 标记已执行
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCancel(rec)}
|
||||
disabled={busy}
|
||||
className="text-[10px] font-medium text-slate-500 hover:text-rose-600 disabled:opacity-50 px-2 py-1 rounded cursor-pointer transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Execute confirmation modal */}
|
||||
<AnimatePresence>
|
||||
{executeTarget && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[80] flex items-end sm:items-center justify-center"
|
||||
onClick={() => mutatingId === null && setExecuteTarget(null)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: 40, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 40, opacity: 0 }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:max-w-sm overflow-hidden flex flex-col sm:mx-4"
|
||||
>
|
||||
<div className="bg-emerald-600 px-4 py-3 flex items-center justify-between">
|
||||
<span className="text-white font-bold text-sm">确认已执行</span>
|
||||
<button
|
||||
onClick={() => mutatingId === null && setExecuteTarget(null)}
|
||||
disabled={mutatingId !== null}
|
||||
className="text-emerald-100 hover:text-white p-1 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-4 py-4 space-y-3">
|
||||
<div className="text-xs text-slate-500">
|
||||
<span className="font-mono font-bold text-slate-900"><Blur>{executeTarget.currentPlate}</Blur></span>
|
||||
<span className="mx-1.5">→</span>
|
||||
<span className="font-mono font-bold text-blue-700"><Blur>{executeTarget.candidatePlate}</Blur></span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 uppercase font-bold block mb-1">执行后里程 (km, 可选)</label>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={afterMileageInput}
|
||||
onChange={e => setAfterMileageInput(e.target.value)}
|
||||
placeholder="例如 45230"
|
||||
className="w-full px-3 py-2 text-xs bg-slate-50 rounded-lg border border-slate-200 outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-400 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 uppercase font-bold block mb-1">备注 (可选)</label>
|
||||
<textarea
|
||||
value={notesInput}
|
||||
onChange={e => setNotesInput(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="例如:司机已到位,交接完成"
|
||||
className="w-full px-3 py-2 text-xs bg-slate-50 rounded-lg border border-slate-200 outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-400 transition-all resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-slate-100 px-4 py-3 flex gap-2">
|
||||
<button
|
||||
onClick={() => setExecuteTarget(null)}
|
||||
disabled={mutatingId !== null}
|
||||
className="flex-1 py-2 text-xs font-bold text-slate-500 bg-slate-50 hover:bg-slate-100 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExecuteConfirm}
|
||||
disabled={mutatingId !== null}
|
||||
className="flex-1 py-2 text-xs font-bold text-white bg-emerald-600 hover:bg-emerald-500 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{mutatingId !== null ? '保存中...' : '确认'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { Filter, RotateCcw, X, Search, ChevronDown, CheckSquare, Send } from 'lucide-react';
|
||||
import { Filter, RotateCcw, X, Search, ChevronDown, CheckSquare, Send, Clock, Download } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { fetchSuggestions, sendNotifyBatch } from './api';
|
||||
import type { SchedulingResponse, SchedulingSuggestion, CandidateVehicle } from './types';
|
||||
import SuggestionList from './SuggestionList';
|
||||
import SuggestionDetail from './SuggestionDetail';
|
||||
import NotificationHistory from './NotificationHistory';
|
||||
import { exportSuggestionsCsv } from './csv-export';
|
||||
import Blur from '../../components/Blur';
|
||||
|
||||
type TypeFilter = 'all' | 'qualified' | 'hopeless';
|
||||
@@ -165,6 +167,7 @@ export default function SchedulingModule() {
|
||||
const [showBatchConfirm, setShowBatchConfirm] = useState(false);
|
||||
const [batchInFlight, setBatchInFlight] = useState(false);
|
||||
const [batchResultMsg, setBatchResultMsg] = useState<string | null>(null);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -346,6 +349,21 @@ export default function SchedulingModule() {
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer">
|
||||
<RotateCcw size={15} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => exportSuggestionsCsv(filteredSuggestions)}
|
||||
disabled={filteredSuggestions.length === 0}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="导出 CSV"
|
||||
>
|
||||
<Download size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowHistory(true)}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer"
|
||||
title="调度记录"
|
||||
>
|
||||
<Clock size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectMode) exitSelectMode();
|
||||
@@ -486,6 +504,10 @@ export default function SchedulingModule() {
|
||||
<SuggestionDetail suggestion={selectedSuggestion} onClose={() => setSelectedSuggestion(null)} onNotifySuccess={handleNotifySuccess} />
|
||||
)}
|
||||
|
||||
{showHistory && (
|
||||
<NotificationHistory onClose={() => setShowHistory(false)} onChange={loadData} />
|
||||
)}
|
||||
|
||||
{/* Batch action bar */}
|
||||
<AnimatePresence>
|
||||
{selectMode && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
X, MapPin, AlertTriangle, CheckCircle, ArrowDown, ArrowUp, ArrowRight, ArrowUpDown,
|
||||
TrendingUp, TrendingDown, Minus,
|
||||
} from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import type { SchedulingSuggestion, CandidateVehicle } from './types';
|
||||
@@ -175,7 +176,28 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
|
||||
{v.manager && <span><b className="text-slate-700">{v.manager}</b></span>}
|
||||
{(v.department || v.manager) && <span className="text-slate-200">|</span>}
|
||||
<span>客户 <b className="text-slate-700"><Blur>{v.customer || '-'}</Blur></b></span>
|
||||
<span>日均 <b className="text-slate-700">{Math.round(v.customerAvgDaily)}</b> km</span>
|
||||
<span className="flex items-center gap-1">
|
||||
30日均 <b className="text-slate-700">{Math.round(v.customerAvgDaily)}</b> km
|
||||
{v.customerAvgDaily > 0 && v.customerAvgDaily7d > 0 && (() => {
|
||||
const diff = (v.customerAvgDaily7d - v.customerAvgDaily) / v.customerAvgDaily;
|
||||
const pct = Math.round(diff * 100);
|
||||
if (diff >= 0.1) return (
|
||||
<span className="text-emerald-600 flex items-center gap-0.5" title={`7日均 ${Math.round(v.customerAvgDaily7d)} km`}>
|
||||
<TrendingUp size={10} /> 7日 +{pct}%
|
||||
</span>
|
||||
);
|
||||
if (diff <= -0.1) return (
|
||||
<span className="text-rose-500 flex items-center gap-0.5" title={`7日均 ${Math.round(v.customerAvgDaily7d)} km`}>
|
||||
<TrendingDown size={10} /> 7日 {pct}%
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span className="text-slate-400 flex items-center gap-0.5" title={`7日均 ${Math.round(v.customerAvgDaily7d)} km`}>
|
||||
<Minus size={10} /> 7日平稳
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
{/* Metrics */}
|
||||
<div className="px-3 pb-2">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { ArrowRightLeft, ChevronRight, ArrowDown, ArrowUp, ArrowUpDown, CheckCircle, Check } from 'lucide-react';
|
||||
import { ArrowRightLeft, ChevronRight, ArrowDown, ArrowUp, ArrowUpDown, CheckCircle, Check, TrendingUp, TrendingDown } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import type { SchedulingSuggestion } from './types';
|
||||
import Blur from '../../components/Blur';
|
||||
@@ -149,7 +149,15 @@ export default function SuggestionList({ suggestions, onSelect, selectMode = fal
|
||||
<span className="truncate"><Blur>{v.customer || '-'}</Blur></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
|
||||
<span className="text-slate-500">客户日均 <span className="text-slate-700 font-medium">{Math.round(v.customerAvgDaily)}</span> km</span>
|
||||
<span className="text-slate-500 flex items-center gap-0.5">
|
||||
客户日均 <span className="text-slate-700 font-medium">{Math.round(v.customerAvgDaily)}</span> km
|
||||
{v.customerAvgDaily > 0 && v.customerAvgDaily7d > 0 && (() => {
|
||||
const diff = (v.customerAvgDaily7d - v.customerAvgDaily) / v.customerAvgDaily;
|
||||
if (diff >= 0.1) return <TrendingUp size={10} className="text-emerald-500" />;
|
||||
if (diff <= -0.1) return <TrendingDown size={10} className="text-rose-500" />;
|
||||
return null;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -62,6 +62,7 @@ export function toVehicleInfo(v: EnrichedVehicle): SchedulingVehicleInfo {
|
||||
department: v.department,
|
||||
manager: v.manager,
|
||||
customerAvgDaily: v.customerAvgDaily,
|
||||
customerAvgDaily7d: v.customerAvgDaily7d,
|
||||
predictedYearEnd: v.predictedYearEnd,
|
||||
daysLeft: v.daysLeft,
|
||||
};
|
||||
|
||||
@@ -114,12 +114,16 @@ app.get('/', async (c) => {
|
||||
// ---- Collect all plates for Query 6 ----
|
||||
const allPlates = assessmentRows.map((r: any) => r.plate_number as string);
|
||||
|
||||
// ---- Query 6: Customer daily avg (from mileage DB) ----
|
||||
// ---- Query 6: Customer daily avg (from mileage DB) — 30d baseline + 7d recent ----
|
||||
const customerAvgDailyMap = new Map<string, number>();
|
||||
const customerAvgDaily7dMap = new Map<string, number>();
|
||||
if (allPlates.length > 0) {
|
||||
const placeholders = allPlates.map(() => '?').join(',');
|
||||
// Single query returning both windows per plate.
|
||||
const [dailyRows] = await mileagePool.execute(
|
||||
`SELECT plate, AVG(daily_km) as avg_daily
|
||||
`SELECT plate,
|
||||
AVG(CASE WHEN stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) THEN daily_km END) AS avg_30d,
|
||||
AVG(CASE WHEN stat_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) THEN daily_km END) AS avg_7d
|
||||
FROM v_vehicle_daily_stats
|
||||
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
AND stat_date < CURDATE()
|
||||
@@ -128,25 +132,30 @@ app.get('/', async (c) => {
|
||||
allPlates,
|
||||
) as [any[], unknown];
|
||||
|
||||
// Build plate → avg_daily map
|
||||
const plateAvgMap = new Map<string, number>();
|
||||
const plateAvg30Map = new Map<string, number>();
|
||||
const plateAvg7Map = new Map<string, number>();
|
||||
for (const row of dailyRows) {
|
||||
plateAvgMap.set(row.plate, Number(row.avg_daily) || 0);
|
||||
if (row.avg_30d !== null) plateAvg30Map.set(row.plate, Number(row.avg_30d));
|
||||
if (row.avg_7d !== null) plateAvg7Map.set(row.plate, Number(row.avg_7d));
|
||||
}
|
||||
|
||||
// Aggregate per customer: average of all plates belonging to each customer
|
||||
const customerPlates = new Map<string, number[]>();
|
||||
const customerPlates30 = new Map<string, number[]>();
|
||||
const customerPlates7 = new Map<string, number[]>();
|
||||
for (const plate of allPlates) {
|
||||
const info = vehicleInfoMap.get(plate);
|
||||
const customer = info?.customer || '未知客户';
|
||||
if (!customerPlates.has(customer)) customerPlates.set(customer, []);
|
||||
const avg = plateAvgMap.get(plate);
|
||||
if (avg !== undefined) customerPlates.get(customer)!.push(avg);
|
||||
if (!customerPlates30.has(customer)) customerPlates30.set(customer, []);
|
||||
if (!customerPlates7.has(customer)) customerPlates7.set(customer, []);
|
||||
const v30 = plateAvg30Map.get(plate);
|
||||
const v7 = plateAvg7Map.get(plate);
|
||||
if (v30 !== undefined) customerPlates30.get(customer)!.push(v30);
|
||||
if (v7 !== undefined) customerPlates7.get(customer)!.push(v7);
|
||||
}
|
||||
for (const [customer, avgs] of customerPlates) {
|
||||
if (avgs.length > 0) {
|
||||
customerAvgDailyMap.set(customer, avgs.reduce((s, v) => s + v, 0) / avgs.length);
|
||||
for (const [customer, avgs] of customerPlates30) {
|
||||
if (avgs.length > 0) customerAvgDailyMap.set(customer, avgs.reduce((s, v) => s + v, 0) / avgs.length);
|
||||
}
|
||||
for (const [customer, avgs] of customerPlates7) {
|
||||
if (avgs.length > 0) customerAvgDaily7dMap.set(customer, avgs.reduce((s, v) => s + v, 0) / avgs.length);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +217,7 @@ app.get('/', async (c) => {
|
||||
|
||||
const customer = info?.customer || null;
|
||||
const customerAvgDaily = customerAvgDailyMap.get(customer || '未知客户') || 0;
|
||||
const customerAvgDaily7d = customerAvgDaily7dMap.get(customer || '未知客户') || 0;
|
||||
const currentYearMileage = Number(row.current_year_mileage) || 0;
|
||||
const yearTarget = Number(row.current_year_mileage_task) || 0;
|
||||
const predictedYearEnd = currentYearMileage + customerAvgDaily * daysLeft;
|
||||
@@ -233,6 +243,7 @@ app.get('/', async (c) => {
|
||||
department: info?.department || null,
|
||||
manager: info?.manager || null,
|
||||
customerAvgDaily,
|
||||
customerAvgDaily7d,
|
||||
predictedYearEnd,
|
||||
daysLeft,
|
||||
classification,
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface EnrichedVehicle {
|
||||
department: string | null;
|
||||
manager: string | null;
|
||||
customerAvgDaily: number;
|
||||
customerAvgDaily7d: number;
|
||||
predictedYearEnd: number;
|
||||
daysLeft: number;
|
||||
classification: VehicleClassification;
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface SchedulingVehicleInfo {
|
||||
department: string | null;
|
||||
manager: string | null;
|
||||
customerAvgDaily: number;
|
||||
customerAvgDaily7d: number;
|
||||
predictedYearEnd: number;
|
||||
daysLeft: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user