Compare commits
10 Commits
335282a2c3
...
2ea00a5383
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ea00a5383 | ||
|
|
cf138f67c0 | ||
|
|
e32b0b58b3 | ||
|
|
9d1e8c4d30 | ||
|
|
ba1e0e9f16 | ||
|
|
1b2ad68743 | ||
|
|
210db7f8ff | ||
|
|
1d9f4cb43d | ||
|
|
3ef0d4edfa | ||
|
|
31716c6547 |
342
src/modules/scheduling/NotificationHistory.tsx
Normal file
342
src/modules/scheduling/NotificationHistory.tsx
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { X, RotateCcw, Clock, CheckCircle2, XCircle, Send, Loader2, ChevronRight } from 'lucide-react';
|
||||||
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
|
import { fetchNotifications, updateNotification } from './api';
|
||||||
|
import type { NotificationRecord, NotificationStatus, SchedulingSuggestion, CandidateVehicle } from './types';
|
||||||
|
import Blur from '../../components/Blur';
|
||||||
|
import SwapPreview from './SwapPreview';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
onChange?: () => void;
|
||||||
|
/** When true, pre-filter to the last 7 days (excluding cancelled). */
|
||||||
|
recentOnly?: boolean;
|
||||||
|
/** Current suggestions used to enrich records with customer/dept/manager and enable drill-down. */
|
||||||
|
suggestions?: SchedulingSuggestion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function shortDept(dept: string | null | undefined): string {
|
||||||
|
return (dept || '').replace('业务', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
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, recentOnly = false, suggestions }: Props) {
|
||||||
|
const [records, setRecords] = useState<NotificationRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [tab, setTab] = useState<StatusTab>('all');
|
||||||
|
const [recent7d, setRecent7d] = useState(recentOnly);
|
||||||
|
const [mutatingId, setMutatingId] = useState<number | null>(null);
|
||||||
|
const [executeTarget, setExecuteTarget] = useState<NotificationRecord | null>(null);
|
||||||
|
const [afterMileageInput, setAfterMileageInput] = useState('');
|
||||||
|
const [notesInput, setNotesInput] = useState('');
|
||||||
|
const [drillTarget, setDrillTarget] = useState<{ suggestion: SchedulingSuggestion; candidate: CandidateVehicle } | null>(null);
|
||||||
|
|
||||||
|
const suggestionById = useMemo(() => {
|
||||||
|
const map = new Map<string, SchedulingSuggestion>();
|
||||||
|
for (const s of suggestions ?? []) map.set(s.id, s);
|
||||||
|
return map;
|
||||||
|
}, [suggestions]);
|
||||||
|
|
||||||
|
const visibleRecords = recent7d
|
||||||
|
? records.filter(r => {
|
||||||
|
const t = Date.parse(r.createdAt);
|
||||||
|
return Number.isFinite(t) && Date.now() - t <= SEVEN_DAYS_MS;
|
||||||
|
})
|
||||||
|
: records;
|
||||||
|
|
||||||
|
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 flex-wrap items-center">
|
||||||
|
{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 className="ml-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => setRecent7d(v => !v)}
|
||||||
|
className={`text-[11px] px-3 py-1 rounded-full font-medium cursor-pointer transition-colors ${
|
||||||
|
recent7d ? 'bg-emerald-600 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||||
|
}`}
|
||||||
|
title="仅看最近 7 天"
|
||||||
|
>
|
||||||
|
近 7 天
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
) : visibleRecords.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">{recent7d ? '最近 7 天暂无干预记录' : '暂无记录'}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-slate-50">
|
||||||
|
{visibleRecords.map(rec => {
|
||||||
|
const badge = statusBadge(rec.status);
|
||||||
|
const busy = mutatingId === rec.id;
|
||||||
|
const suggestion = suggestionById.get(rec.suggestionId);
|
||||||
|
const candidate = suggestion?.candidates.find(c => c.plateNumber === rec.candidatePlate) ?? null;
|
||||||
|
const canDrill = !!suggestion && !!candidate;
|
||||||
|
const v = suggestion?.currentVehicle;
|
||||||
|
|
||||||
|
const handleRowClick = () => {
|
||||||
|
if (canDrill && suggestion && candidate) setDrillTarget({ suggestion, candidate });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={rec.id}
|
||||||
|
onClick={handleRowClick}
|
||||||
|
className={`px-4 py-3 transition-colors ${canDrill ? 'cursor-pointer hover:bg-slate-50/60 active:bg-slate-100' : ''}`}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
<span className={`text-[9px] font-bold px-1.5 py-0.5 rounded flex items-center gap-0.5 ${badge.cls}`}>
|
||||||
|
{badge.icon} {badge.text}
|
||||||
|
</span>
|
||||||
|
{canDrill && <ChevronRight size={12} className="text-slate-300" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{v && (
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] text-slate-500 mb-0.5 truncate">
|
||||||
|
{v.department && <span className="font-medium">{shortDept(v.department)}</span>}
|
||||||
|
{v.manager && <span>{v.manager}</span>}
|
||||||
|
<span className="text-slate-400 truncate"><Blur>{v.customer || '-'}</Blur></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" onClick={e => e.stopPropagation()}>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Drill-down: replacement plan */}
|
||||||
|
{drillTarget && (
|
||||||
|
<SwapPreview
|
||||||
|
suggestion={drillTarget.suggestion}
|
||||||
|
candidate={drillTarget.candidate}
|
||||||
|
onClose={() => setDrillTarget(null)}
|
||||||
|
onSuccess={() => { load(); onChange?.(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import { Filter, RotateCcw, X, Search, ChevronDown } from 'lucide-react';
|
import { Filter, RotateCcw, X, Search, ChevronDown, CheckSquare, Send, Clock, Download } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { fetchSuggestions } from './api';
|
import { fetchSuggestions, sendNotifyBatch } from './api';
|
||||||
import type { SchedulingResponse, SchedulingSuggestion } from './types';
|
import type { SchedulingResponse, SchedulingSuggestion, CandidateVehicle } from './types';
|
||||||
import SuggestionList from './SuggestionList';
|
import SuggestionList from './SuggestionList';
|
||||||
import SuggestionDetail from './SuggestionDetail';
|
import SuggestionDetail from './SuggestionDetail';
|
||||||
|
import NotificationHistory from './NotificationHistory';
|
||||||
|
import { exportSuggestionsCsv } from './csv-export';
|
||||||
|
import Blur from '../../components/Blur';
|
||||||
|
|
||||||
type TypeFilter = 'all' | 'qualified' | 'hopeless';
|
type TypeFilter = 'all' | 'qualified' | 'hopeless';
|
||||||
|
|
||||||
@@ -143,6 +146,16 @@ function SkeletonPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickBestCandidate(s: SchedulingSuggestion): CandidateVehicle | null {
|
||||||
|
// Business rule: at most one active intervention per suggestion. If ANY
|
||||||
|
// candidate is already intervened, skip the whole suggestion in batch flow.
|
||||||
|
const hasActive = s.candidates.some(
|
||||||
|
c => c.notificationStatus === 'sent' || c.notificationStatus === 'executed',
|
||||||
|
);
|
||||||
|
if (hasActive) return null;
|
||||||
|
return s.candidates.find(c => c.canQualifyAfterSwap) ?? s.candidates[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function SchedulingModule() {
|
export default function SchedulingModule() {
|
||||||
const [data, setData] = useState<SchedulingResponse | null>(null);
|
const [data, setData] = useState<SchedulingResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -152,6 +165,13 @@ export default function SchedulingModule() {
|
|||||||
const [showFilter, setShowFilter] = useState(false);
|
const [showFilter, setShowFilter] = useState(false);
|
||||||
const [filters, setFilters] = useState<AdvancedFilters>(EMPTY_FILTERS);
|
const [filters, setFilters] = useState<AdvancedFilters>(EMPTY_FILTERS);
|
||||||
const [tempFilters, setTempFilters] = useState<AdvancedFilters>(EMPTY_FILTERS);
|
const [tempFilters, setTempFilters] = useState<AdvancedFilters>(EMPTY_FILTERS);
|
||||||
|
const [selectMode, setSelectMode] = useState(false);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
const [showBatchConfirm, setShowBatchConfirm] = useState(false);
|
||||||
|
const [batchInFlight, setBatchInFlight] = useState(false);
|
||||||
|
const [batchResultMsg, setBatchResultMsg] = useState<string | null>(null);
|
||||||
|
const [showHistory, setShowHistory] = useState(false);
|
||||||
|
const [historyRecentOnly, setHistoryRecentOnly] = useState(false);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -161,6 +181,64 @@ export default function SchedulingModule() {
|
|||||||
useEffect(() => { loadData(); }, [loadData]);
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
const handleNotifySuccess = useCallback(() => { loadData(); }, [loadData]);
|
const handleNotifySuccess = useCallback(() => { loadData(); }, [loadData]);
|
||||||
|
|
||||||
|
// Keep selectedSuggestion synced with latest data so candidate notification
|
||||||
|
// status changes (登记 / 取消干预) propagate into the open detail modal.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedSuggestion || !data) return;
|
||||||
|
const fresh = data.suggestions.find(s => s.id === selectedSuggestion.id);
|
||||||
|
if (!fresh) setSelectedSuggestion(null);
|
||||||
|
else if (fresh !== selectedSuggestion) setSelectedSuggestion(fresh);
|
||||||
|
}, [data, selectedSuggestion]);
|
||||||
|
|
||||||
|
const toggleSelect = useCallback((id: string) => {
|
||||||
|
setSelectedIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const exitSelectMode = useCallback(() => {
|
||||||
|
setSelectMode(false);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
setShowBatchConfirm(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const batchItems = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
return [...selectedIds]
|
||||||
|
.map(id => data.suggestions.find(s => s.id === id))
|
||||||
|
.filter((s): s is SchedulingSuggestion => !!s)
|
||||||
|
.map(s => {
|
||||||
|
const candidate = pickBestCandidate(s);
|
||||||
|
if (!candidate) return null;
|
||||||
|
return { suggestion: s, candidate };
|
||||||
|
})
|
||||||
|
.filter((x): x is { suggestion: SchedulingSuggestion; candidate: CandidateVehicle } => !!x);
|
||||||
|
}, [data, selectedIds]);
|
||||||
|
|
||||||
|
const handleBatchSubmit = useCallback(async () => {
|
||||||
|
if (batchItems.length === 0) return;
|
||||||
|
setBatchInFlight(true);
|
||||||
|
try {
|
||||||
|
const resp = await sendNotifyBatch({
|
||||||
|
items: batchItems.map(i => ({
|
||||||
|
suggestionId: i.suggestion.id,
|
||||||
|
currentPlate: i.suggestion.currentVehicle.plateNumber,
|
||||||
|
candidatePlate: i.candidate.plateNumber,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
setBatchResultMsg(resp.message);
|
||||||
|
await loadData();
|
||||||
|
exitSelectMode();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('batch notify failed:', e);
|
||||||
|
setBatchResultMsg('批量干预失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setBatchInFlight(false);
|
||||||
|
}
|
||||||
|
}, [batchItems, loadData, exitSelectMode]);
|
||||||
|
|
||||||
const filterOptions = useMemo(() => {
|
const filterOptions = useMemo(() => {
|
||||||
if (!data) return { regions: [], vehicleTypes: [], customers: [], departments: [], managers: [] };
|
if (!data) return { regions: [], vehicleTypes: [], customers: [], departments: [], managers: [] };
|
||||||
const r = new Set<string>(), t = new Set<string>(), c = new Set<string>(), d = new Set<string>(), m = new Set<string>();
|
const r = new Set<string>(), t = new Set<string>(), c = new Set<string>(), d = new Set<string>(), m = new Set<string>();
|
||||||
@@ -200,7 +278,7 @@ export default function SchedulingModule() {
|
|||||||
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 md:pb-0">
|
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 md:pb-0">
|
||||||
|
|
||||||
{/* ===== Summary Cards ===== */}
|
{/* ===== Summary Cards ===== */}
|
||||||
<div className="grid grid-cols-3 gap-2.5">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2.5">
|
||||||
{/* 里程高·换下 — warm orange */}
|
{/* 里程高·换下 — warm orange */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setTypeFilter(typeFilter === 'qualified' ? 'all' : 'qualified')}
|
onClick={() => setTypeFilter(typeFilter === 'qualified' ? 'all' : 'qualified')}
|
||||||
@@ -253,7 +331,7 @@ export default function SchedulingModule() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'all' ? 'text-slate-300' : 'text-slate-500'}`}>
|
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'all' ? 'text-slate-300' : 'text-slate-500'}`}>
|
||||||
调度方案
|
替换建议
|
||||||
</div>
|
</div>
|
||||||
<div className={`text-2xl font-black ${typeFilter === 'all' ? 'text-white' : 'text-slate-800'}`}>
|
<div className={`text-2xl font-black ${typeFilter === 'all' ? 'text-white' : 'text-slate-800'}`}>
|
||||||
{loading && !data ? '-' : summary?.suggestionCount ?? 0}
|
{loading && !data ? '-' : summary?.suggestionCount ?? 0}
|
||||||
@@ -263,6 +341,23 @@ export default function SchedulingModule() {
|
|||||||
执行后预计 +{summary?.estimatedGain ?? 0} 台达标
|
执行后预计 +{summary?.estimatedGain ?? 0} 台达标
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* 近期已干预 — emerald */}
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowHistory(true); setHistoryRecentOnly(true); }}
|
||||||
|
className="p-3.5 rounded-2xl text-left transition-all cursor-pointer bg-gradient-to-br from-emerald-50 to-teal-50 border border-emerald-200/60"
|
||||||
|
>
|
||||||
|
<div className="text-[10px] font-bold mb-1 text-emerald-600">
|
||||||
|
近期已干预
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-emerald-700">
|
||||||
|
{loading && !data ? '-' : summary?.recentInterventionCount ?? 0}
|
||||||
|
<span className="text-[10px] font-normal ml-1 text-emerald-400">条</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] mt-0.5 text-emerald-400">
|
||||||
|
最近 7 天 · 点击查看
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ===== List Card ===== */}
|
{/* ===== List Card ===== */}
|
||||||
@@ -277,6 +372,33 @@ 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">
|
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' : ''} />
|
<RotateCcw size={15} className={loading ? 'animate-spin' : ''} />
|
||||||
</button>
|
</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); setHistoryRecentOnly(false); }}
|
||||||
|
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();
|
||||||
|
else { setSelectMode(true); setSelectedSuggestion(null); }
|
||||||
|
}}
|
||||||
|
className={`relative p-1.5 transition-colors rounded-lg cursor-pointer ${
|
||||||
|
selectMode ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
title={selectMode ? '退出多选' : '多选模式'}
|
||||||
|
>
|
||||||
|
<CheckSquare size={15} />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowFilter(!showFilter); setTempFilters(filters); }}
|
onClick={() => { setShowFilter(!showFilter); setTempFilters(filters); }}
|
||||||
className={`relative p-1.5 transition-colors rounded-lg cursor-pointer ${
|
className={`relative p-1.5 transition-colors rounded-lg cursor-pointer ${
|
||||||
@@ -391,13 +513,124 @@ export default function SchedulingModule() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<SuggestionList suggestions={filteredSuggestions} onSelect={setSelectedSuggestion} />
|
<SuggestionList
|
||||||
|
suggestions={filteredSuggestions}
|
||||||
|
onSelect={setSelectedSuggestion}
|
||||||
|
selectMode={selectMode}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
onToggleSelect={toggleSelect}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedSuggestion && (
|
{selectedSuggestion && (
|
||||||
<SuggestionDetail suggestion={selectedSuggestion} onClose={() => setSelectedSuggestion(null)} onNotifySuccess={handleNotifySuccess} />
|
<SuggestionDetail suggestion={selectedSuggestion} onClose={() => setSelectedSuggestion(null)} onNotifySuccess={handleNotifySuccess} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showHistory && (
|
||||||
|
<NotificationHistory
|
||||||
|
onClose={() => setShowHistory(false)}
|
||||||
|
onChange={loadData}
|
||||||
|
recentOnly={historyRecentOnly}
|
||||||
|
suggestions={data?.suggestions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Batch action bar */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{selectMode && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: 80, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: 80, opacity: 0 }}
|
||||||
|
className="fixed bottom-4 left-3 right-3 md:left-auto md:right-6 md:bottom-6 md:w-[360px] z-40 bg-slate-900 text-white rounded-2xl shadow-2xl px-4 py-3 flex items-center justify-between gap-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium">已选</span>
|
||||||
|
<span className="text-lg font-black">{selectedIds.size}</span>
|
||||||
|
<span className="text-xs text-slate-400">条</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={exitSelectMode}
|
||||||
|
className="text-xs font-medium text-slate-300 hover:text-white px-2 py-1.5 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBatchConfirm(true)}
|
||||||
|
disabled={selectedIds.size === 0}
|
||||||
|
className="flex items-center gap-1.5 text-xs font-bold bg-blue-600 hover:bg-blue-500 disabled:bg-slate-700 disabled:text-slate-400 text-white px-3 py-1.5 rounded-lg cursor-pointer disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<Send size={12} /> 批量干预
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Batch confirmation modal */}
|
||||||
|
{showBatchConfirm && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[70] flex items-end sm:items-center justify-center" onClick={() => !batchInFlight && setShowBatchConfirm(false)}>
|
||||||
|
<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-md overflow-hidden flex flex-col max-h-[80vh] sm:mx-4"
|
||||||
|
>
|
||||||
|
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between flex-shrink-0">
|
||||||
|
<span className="text-white font-bold text-sm">确认批量干预</span>
|
||||||
|
<button
|
||||||
|
onClick={() => !batchInFlight && setShowBatchConfirm(false)}
|
||||||
|
disabled={batchInFlight}
|
||||||
|
className="text-slate-400 hover:text-white transition-colors p-1 cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 overflow-y-auto flex-1">
|
||||||
|
<p className="text-xs text-slate-500 mb-3">
|
||||||
|
将登记 <span className="font-bold text-slate-800">{batchItems.length}</span> 条干预,已排除无可用候选车的建议。
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{batchItems.map(({ suggestion, candidate }) => (
|
||||||
|
<div key={suggestion.id} className="text-[11px] bg-slate-50 rounded-lg px-3 py-2 flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span className="font-mono font-bold text-slate-900"><Blur>{suggestion.currentVehicle.plateNumber}</Blur></span>
|
||||||
|
<span className="text-slate-400">→</span>
|
||||||
|
<span className="font-mono font-bold text-blue-700"><Blur>{candidate.plateNumber}</Blur></span>
|
||||||
|
</div>
|
||||||
|
{candidate.canQualifyAfterSwap ? (
|
||||||
|
<span className="text-emerald-600 text-[9px] font-bold flex-shrink-0">可达标</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-amber-500 text-[9px] font-bold flex-shrink-0">需关注</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{batchResultMsg && (
|
||||||
|
<p className="mt-3 text-[11px] text-slate-500">{batchResultMsg}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-slate-100 px-4 py-3 flex-shrink-0 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBatchConfirm(false)}
|
||||||
|
disabled={batchInFlight}
|
||||||
|
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={handleBatchSubmit}
|
||||||
|
disabled={batchInFlight || batchItems.length === 0}
|
||||||
|
className="flex-1 py-2 text-xs font-bold text-white bg-blue-600 hover:bg-blue-500 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{batchInFlight ? '登记中...' : `确认登记 ${batchItems.length} 条`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
X, MapPin, AlertTriangle, CheckCircle, ArrowDown, ArrowUp, ArrowRight, ArrowUpDown,
|
X, MapPin, AlertTriangle, CheckCircle, ArrowDown, ArrowUp, ArrowRight, ArrowUpDown, Lock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import type { SchedulingSuggestion, CandidateVehicle } from './types';
|
import type { SchedulingSuggestion, CandidateVehicle } from './types';
|
||||||
@@ -25,6 +25,8 @@ function fmtRate(rate: number): string {
|
|||||||
return (rate * 100).toFixed(1) + '%';
|
return (rate * 100).toFixed(1) + '%';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CUSTOMER_REASON_LABELS = new Set(['客户日均']);
|
||||||
|
|
||||||
export default function SuggestionDetail({ suggestion: s, onClose, onNotifySuccess }: Props) {
|
export default function SuggestionDetail({ suggestion: s, onClose, onNotifySuccess }: Props) {
|
||||||
const [previewCandidate, setPreviewCandidate] = useState<CandidateVehicle | null>(null);
|
const [previewCandidate, setPreviewCandidate] = useState<CandidateVehicle | null>(null);
|
||||||
const [sentPlates, setSentPlates] = useState<Set<string>>(new Set());
|
const [sentPlates, setSentPlates] = useState<Set<string>>(new Set());
|
||||||
@@ -35,6 +37,13 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
|
|||||||
const v = s.currentVehicle;
|
const v = s.currentVehicle;
|
||||||
const isRescue = s.type === 'rescue_hopeless';
|
const isRescue = s.type === 'rescue_hopeless';
|
||||||
|
|
||||||
|
// Business rule: a current vehicle can have AT MOST ONE active intervention.
|
||||||
|
// Find the active candidate (if any) — other candidates are blocked until
|
||||||
|
// this one is cancelled.
|
||||||
|
const activeIntervention = s.candidates.find(
|
||||||
|
cc => cc.notificationStatus === 'sent' || cc.notificationStatus === 'executed',
|
||||||
|
);
|
||||||
|
|
||||||
// Batch options from candidates
|
// Batch options from candidates
|
||||||
const batchOptions = useMemo(() => {
|
const batchOptions = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
@@ -42,22 +51,96 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
|
|||||||
return [...set].sort();
|
return [...set].sort();
|
||||||
}, [s.candidates]);
|
}, [s.candidates]);
|
||||||
|
|
||||||
// Filtered + sorted candidates
|
// Filtered + sorted candidates, grouped by region
|
||||||
const displayCandidates = useMemo(() => {
|
const { sameRegion, crossRegion } = useMemo(() => {
|
||||||
let list = s.candidates;
|
let list = s.candidates;
|
||||||
if (batchFilter.size > 0) list = list.filter(c => c.targetName != null && batchFilter.has(c.targetName));
|
if (batchFilter.size > 0) list = list.filter(c => c.targetName != null && batchFilter.has(c.targetName));
|
||||||
return [...list].sort((a, b) => {
|
const sorted = [...list].sort((a, b) => {
|
||||||
const va = sortKey === 'predicted' ? a.predictedAfterSwap : a.totalMileage;
|
const va = sortKey === 'predicted' ? a.predictedAfterSwap : a.totalMileage;
|
||||||
const vb = sortKey === 'predicted' ? b.predictedAfterSwap : b.totalMileage;
|
const vb = sortKey === 'predicted' ? b.predictedAfterSwap : b.totalMileage;
|
||||||
return sortDir === 'desc' ? vb - va : va - vb;
|
return sortDir === 'desc' ? vb - va : va - vb;
|
||||||
});
|
});
|
||||||
|
return {
|
||||||
|
sameRegion: sorted.filter(c => c.isSameRegion),
|
||||||
|
crossRegion: sorted.filter(c => !c.isSameRegion),
|
||||||
|
};
|
||||||
}, [s.candidates, batchFilter, sortKey, sortDir]);
|
}, [s.candidates, batchFilter, sortKey, sortDir]);
|
||||||
|
|
||||||
|
const displayCount = sameRegion.length + crossRegion.length;
|
||||||
|
|
||||||
const toggleSort = (key: SortKey) => {
|
const toggleSort = (key: SortKey) => {
|
||||||
if (sortKey === key) { setSortDir(d => d === 'desc' ? 'asc' : 'desc'); }
|
if (sortKey === key) { setSortDir(d => d === 'desc' ? 'asc' : 'desc'); }
|
||||||
else { setSortKey(key); setSortDir('desc'); }
|
else { setSortKey(key); setSortDir('desc'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderCandidate = (c: CandidateVehicle) => {
|
||||||
|
const sent =
|
||||||
|
sentPlates.has(c.plateNumber) ||
|
||||||
|
c.notificationStatus === 'sent' ||
|
||||||
|
c.notificationStatus === 'executed';
|
||||||
|
const blockedByOther = !!activeIntervention && activeIntervention.plateNumber !== c.plateNumber;
|
||||||
|
return (
|
||||||
|
<div key={c.plateNumber} className={`rounded-xl border overflow-hidden bg-white ${blockedByOther ? 'border-slate-200 opacity-60' : 'border-slate-200'}`}>
|
||||||
|
<div className="flex items-center justify-between px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{c.plateNumber}</Blur></span>
|
||||||
|
<span className={`text-[9px] px-1.5 py-0.5 rounded flex items-center gap-0.5 ${c.isSameRegion ? 'bg-slate-100 text-slate-500' : 'bg-amber-50 text-amber-600'}`}>
|
||||||
|
<MapPin size={9} />{c.region}{!c.isSameRegion && ' · 跨区'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] text-slate-400">{c.vehicleType}</span>
|
||||||
|
<span className="text-[9px] text-slate-300">{c.targetName || '库存'}</span>
|
||||||
|
<span className="text-[9px] text-slate-400">剩余{c.daysLeft}天</span>
|
||||||
|
</div>
|
||||||
|
{c.canQualifyAfterSwap ? (
|
||||||
|
<span className="text-[9px] font-bold text-emerald-600 flex items-center gap-0.5 bg-emerald-50 px-1.5 py-0.5 rounded flex-shrink-0">
|
||||||
|
<CheckCircle size={10} /> 可达标
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[9px] font-bold text-amber-500 flex items-center gap-0.5 bg-amber-50 px-1.5 py-0.5 rounded flex-shrink-0">
|
||||||
|
<AlertTriangle size={10} /> 需关注
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-3 pb-2">
|
||||||
|
<div className="flex text-[10px] bg-slate-50 rounded-lg overflow-hidden divide-x divide-slate-200">
|
||||||
|
<div className="flex-1 py-1.5 px-2 text-center">
|
||||||
|
<div className="text-slate-400">当前</div>
|
||||||
|
<div className="font-bold text-slate-700">{fmtKm(c.totalMileage)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 py-1.5 px-2 text-center">
|
||||||
|
<div className="text-slate-400">替换后预计</div>
|
||||||
|
<div className={`font-bold ${c.canQualifyAfterSwap ? 'text-emerald-600' : 'text-amber-600'}`}>{fmtKm(c.predictedAfterSwap)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 py-1.5 px-2 text-center">
|
||||||
|
<div className="text-blue-400">考核</div>
|
||||||
|
<div className="font-bold text-blue-700">{c.yearTarget ? fmtKm(c.yearTarget) : '-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-3 pb-2.5">
|
||||||
|
{blockedByOther ? (
|
||||||
|
<div className="w-full flex items-center justify-center gap-1.5 text-[11px] font-medium py-2 rounded-lg bg-slate-50 text-slate-400 cursor-not-allowed">
|
||||||
|
<Lock size={11} /> 该车已有其他干预,请先解除
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setPreviewCandidate(c)}
|
||||||
|
className={`w-full flex items-center justify-center gap-1.5 text-[11px] font-bold py-2 rounded-lg transition-all cursor-pointer ${
|
||||||
|
sent
|
||||||
|
? 'bg-emerald-50 hover:bg-emerald-100 text-emerald-700 border border-emerald-200'
|
||||||
|
: 'bg-slate-800 hover:bg-slate-900 text-white active:scale-[0.98] shadow-sm'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{sent ? <><CheckCircle size={12} /> 已干预 · 查看 <ArrowRight size={12} /></> : <>查看替换方案 <ArrowRight size={12} /></>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-end sm:items-center justify-center" onClick={onClose}>
|
<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
|
<motion.div
|
||||||
@@ -107,7 +190,9 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
|
|||||||
{v.manager && <span><b className="text-slate-700">{v.manager}</b></span>}
|
{v.manager && <span><b className="text-slate-700">{v.manager}</b></span>}
|
||||||
{(v.department || v.manager) && <span className="text-slate-200">|</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"><Blur>{v.customer || '-'}</Blur></b></span>
|
||||||
<span>日均 <b className="text-slate-700">{Math.round(v.customerAvgDaily)}</b> km</span>
|
<span>
|
||||||
|
30日均 <b className="text-slate-700">{Math.round(v.customerAvgDaily)}</b> km
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Metrics */}
|
{/* Metrics */}
|
||||||
<div className="px-3 pb-2">
|
<div className="px-3 pb-2">
|
||||||
@@ -129,44 +214,61 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reason — structured lines */}
|
{/* Reason — customer vs vehicle columns */}
|
||||||
<div className="px-4 py-2.5 border-b border-slate-100 bg-slate-50/60 space-y-1">
|
<div className="px-4 py-2.5 border-b border-slate-100 bg-slate-50/60">
|
||||||
{s.reason.split('\n').map((line, i) => {
|
<div className="grid grid-cols-2 gap-x-5">
|
||||||
const isConclusion = line.startsWith('!!');
|
{(() => {
|
||||||
const text = isConclusion ? line.slice(2) : line;
|
const customerLines = s.reason.lines.filter(l => CUSTOMER_REASON_LABELS.has(l.label));
|
||||||
if (isConclusion) {
|
const vehicleLines = s.reason.lines.filter(l => !CUSTOMER_REASON_LABELS.has(l.label));
|
||||||
return (
|
return (
|
||||||
<div key={i} className="mt-1.5 pt-1.5 border-t border-slate-200">
|
<>
|
||||||
<span className="text-xs font-bold text-rose-600">{text}</span>
|
<div>
|
||||||
|
<div className="text-[9px] font-bold text-slate-400 uppercase tracking-wider mb-1">客户</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{customerLines.map((line, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between text-[11px]">
|
||||||
|
<span className="text-slate-500">{line.label}</span>
|
||||||
|
<span className="text-slate-700 font-medium">{line.value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
}
|
|
||||||
// Split by | for two-column layout
|
|
||||||
if (text.includes('|')) {
|
|
||||||
const parts = text.split('|').map(p => p.trim());
|
|
||||||
return (
|
|
||||||
<div key={i} className="flex items-center justify-between text-[11px] py-0.5">
|
|
||||||
<span className="text-slate-600">{parts[0]}</span>
|
|
||||||
<span className="text-slate-600">{parts[1]}</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div key={i} className="flex items-center gap-1.5 text-[11px] py-0.5">
|
|
||||||
<span className="w-1 h-1 rounded-full bg-slate-300 flex-shrink-0" />
|
|
||||||
<span className="text-slate-600">{text}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] font-bold text-slate-400 uppercase tracking-wider mb-1">车辆</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{vehicleLines.map((line, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between text-[11px]">
|
||||||
|
<span className="text-slate-500">{line.label}</span>
|
||||||
|
<span className="text-slate-700 font-medium">{line.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
})}
|
})()}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 pt-2 border-t border-slate-200">
|
||||||
|
<span className="text-xs font-bold text-rose-600">{s.reason.conclusion}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Candidates */}
|
{/* Candidates */}
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<span className="text-xs font-bold text-slate-700">当前区域可替换在库车辆</span>
|
<span className="text-xs font-bold text-slate-700">可替换在库车辆</span>
|
||||||
<span className="text-[10px] text-slate-400">{displayCandidates.length}/{s.candidates.length} 辆</span>
|
<span className="text-[10px] text-slate-400">{displayCount}/{s.candidates.length} 辆</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeIntervention && (
|
||||||
|
<div className="mb-2.5 flex items-start gap-2 rounded-lg bg-emerald-50 border border-emerald-200 px-3 py-2 text-[11px] text-emerald-800">
|
||||||
|
<Lock size={12} className="mt-0.5 flex-shrink-0" />
|
||||||
|
<span>
|
||||||
|
此车已干预替换为 <b className="font-mono"><Blur>{activeIntervention.plateNumber}</Blur></b>。如需更换方案,请先在该候选车处解除干预。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Filter + Sort controls */}
|
{/* Filter + Sort controls */}
|
||||||
<div className="flex items-center gap-2 mb-2.5 flex-wrap">
|
<div className="flex items-center gap-2 mb-2.5 flex-wrap">
|
||||||
{/* Batch multi-select pills */}
|
{/* Batch multi-select pills */}
|
||||||
@@ -222,67 +324,28 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{sameRegion.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{displayCandidates.map(c => {
|
{sameRegion.map(c => renderCandidate(c))}
|
||||||
const sent = sentPlates.has(c.plateNumber);
|
|
||||||
return (
|
|
||||||
<div key={c.plateNumber} className="rounded-xl border border-slate-200 overflow-hidden bg-white">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-3 py-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{c.plateNumber}</Blur></span>
|
|
||||||
<span className="text-[9px] text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded flex items-center gap-0.5"><MapPin size={9} />{c.region}</span>
|
|
||||||
<span className="text-[9px] text-slate-400">{c.vehicleType}</span>
|
|
||||||
<span className="text-[9px] text-slate-300">{c.targetName || '库存'}</span>
|
|
||||||
<span className="text-[9px] text-slate-400">剩余{c.daysLeft}天</span>
|
|
||||||
</div>
|
</div>
|
||||||
{c.canQualifyAfterSwap ? (
|
|
||||||
<span className="text-[9px] font-bold text-emerald-600 flex items-center gap-0.5 bg-emerald-50 px-1.5 py-0.5 rounded">
|
|
||||||
<CheckCircle size={10} /> 可达标
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-[9px] font-bold text-amber-500 flex items-center gap-0.5 bg-amber-50 px-1.5 py-0.5 rounded">
|
|
||||||
<AlertTriangle size={10} /> 需关注
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Metrics */}
|
{crossRegion.length > 0 && (
|
||||||
<div className="px-3 pb-2">
|
<>
|
||||||
<div className="flex text-[10px] bg-slate-50 rounded-lg overflow-hidden divide-x divide-slate-200">
|
<div className="flex items-center gap-2 my-3">
|
||||||
<div className="flex-1 py-1.5 px-2 text-center">
|
<div className="flex-1 h-px bg-slate-200" />
|
||||||
<div className="text-slate-400">当前</div>
|
<span className="text-[10px] text-slate-400 font-medium">跨区候选 · {crossRegion.length} 辆</span>
|
||||||
<div className="font-bold text-slate-700">{fmtKm(c.totalMileage)}</div>
|
<div className="flex-1 h-px bg-slate-200" />
|
||||||
</div>
|
|
||||||
<div className="flex-1 py-1.5 px-2 text-center">
|
|
||||||
<div className="text-slate-400">替换后预计</div>
|
|
||||||
<div className={`font-bold ${c.canQualifyAfterSwap ? 'text-emerald-600' : 'text-amber-600'}`}>{fmtKm(c.predictedAfterSwap)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 py-1.5 px-2 text-center">
|
|
||||||
<div className="text-blue-400">考核</div>
|
|
||||||
<div className="font-bold text-blue-700">{c.yearTarget ? fmtKm(c.yearTarget) : '-'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{crossRegion.map(c => renderCandidate(c))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Action */}
|
{displayCount === 0 && (
|
||||||
<div className="px-3 pb-2.5">
|
<div className="py-8 text-center text-xs text-slate-400">当前筛选条件下无可替换车辆</div>
|
||||||
<button
|
)}
|
||||||
onClick={() => setPreviewCandidate(c)}
|
|
||||||
disabled={sent}
|
|
||||||
className={`w-full flex items-center justify-center gap-1.5 text-[11px] font-bold py-2 rounded-lg transition-all cursor-pointer ${
|
|
||||||
sent
|
|
||||||
? 'bg-emerald-50 text-emerald-600'
|
|
||||||
: 'bg-slate-800 hover:bg-slate-900 text-white active:scale-[0.98] shadow-sm'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{sent ? <><CheckCircle size={12} /> 已通知</> : <>查看替换方案 <ArrowRight size={12} /></>}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { ArrowRightLeft, ChevronRight, ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react';
|
import { ArrowRightLeft, ChevronRight, ArrowDown, ArrowUp, ArrowUpDown, CheckCircle, Check } from 'lucide-react';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import type { SchedulingSuggestion } from './types';
|
import type { SchedulingSuggestion } from './types';
|
||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
@@ -7,6 +7,13 @@ import Blur from '../../components/Blur';
|
|||||||
interface Props {
|
interface Props {
|
||||||
suggestions: SchedulingSuggestion[];
|
suggestions: SchedulingSuggestion[];
|
||||||
onSelect: (s: SchedulingSuggestion) => void;
|
onSelect: (s: SchedulingSuggestion) => void;
|
||||||
|
selectMode?: boolean;
|
||||||
|
selectedIds?: Set<string>;
|
||||||
|
onToggleSelect?: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasActiveNotification(s: SchedulingSuggestion): boolean {
|
||||||
|
return s.candidates.some(c => c.notificationStatus === 'sent' || c.notificationStatus === 'executed');
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtRate(rate: number): string {
|
function fmtRate(rate: number): string {
|
||||||
@@ -16,7 +23,7 @@ function fmtRate(rate: number): string {
|
|||||||
type SortKey = 'default' | 'avgDaily' | 'completion';
|
type SortKey = 'default' | 'avgDaily' | 'completion';
|
||||||
type SortDir = 'asc' | 'desc';
|
type SortDir = 'asc' | 'desc';
|
||||||
|
|
||||||
export default function SuggestionList({ suggestions, onSelect }: Props) {
|
export default function SuggestionList({ suggestions, onSelect, selectMode = false, selectedIds, onToggleSelect }: Props) {
|
||||||
const [sortKey, setSortKey] = useState<SortKey>('default');
|
const [sortKey, setSortKey] = useState<SortKey>('default');
|
||||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||||
|
|
||||||
@@ -73,6 +80,17 @@ export default function SuggestionList({ suggestions, onSelect }: Props) {
|
|||||||
{sorted.map((s, idx) => {
|
{sorted.map((s, idx) => {
|
||||||
const isRescue = s.type === 'rescue_hopeless';
|
const isRescue = s.type === 'rescue_hopeless';
|
||||||
const v = s.currentVehicle;
|
const v = s.currentVehicle;
|
||||||
|
const notified = hasActiveNotification(s);
|
||||||
|
const isSelected = selectedIds?.has(s.id) ?? false;
|
||||||
|
const canSelect = selectMode && !notified;
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (selectMode) {
|
||||||
|
if (canSelect) onToggleSelect?.(s.id);
|
||||||
|
} else {
|
||||||
|
onSelect(s);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -80,9 +98,26 @@ export default function SuggestionList({ suggestions, onSelect }: Props) {
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ delay: Math.min(idx * 0.02, 0.3) }}
|
transition={{ delay: Math.min(idx * 0.02, 0.3) }}
|
||||||
className="px-4 py-3 hover:bg-slate-50/60 cursor-pointer transition-colors active:bg-slate-100 flex items-center gap-3"
|
className={`px-4 py-3 hover:bg-slate-50/60 transition-colors flex items-center gap-3 ${
|
||||||
onClick={() => onSelect(s)}
|
canSelect || !selectMode ? 'cursor-pointer active:bg-slate-100' : 'cursor-default opacity-60'
|
||||||
|
} ${isSelected ? 'bg-blue-50/60' : ''}`}
|
||||||
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
|
{/* Checkbox (select mode) */}
|
||||||
|
{selectMode && (
|
||||||
|
<div
|
||||||
|
className={`w-4 h-4 rounded flex-shrink-0 flex items-center justify-center border transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-blue-600 border-blue-600 text-white'
|
||||||
|
: notified
|
||||||
|
? 'bg-slate-100 border-slate-200'
|
||||||
|
: 'bg-white border-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSelected && <Check size={12} strokeWidth={3} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Color bar */}
|
{/* Color bar */}
|
||||||
<div className={`w-1 h-10 rounded-full flex-shrink-0 ${isRescue ? 'bg-blue-400' : 'bg-amber-400'}`} />
|
<div className={`w-1 h-10 rounded-full flex-shrink-0 ${isRescue ? 'bg-blue-400' : 'bg-amber-400'}`} />
|
||||||
|
|
||||||
@@ -96,6 +131,11 @@ export default function SuggestionList({ suggestions, onSelect }: Props) {
|
|||||||
<span className="text-[9px] text-slate-400">{v.vehicleType}</span>
|
<span className="text-[9px] text-slate-400">{v.vehicleType}</span>
|
||||||
<span className="text-[9px] text-slate-300">·</span>
|
<span className="text-[9px] text-slate-300">·</span>
|
||||||
<span className="text-[9px] text-slate-400">{v.region}</span>
|
<span className="text-[9px] text-slate-400">{v.region}</span>
|
||||||
|
{notified && (
|
||||||
|
<span className="text-[9px] font-bold text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded flex items-center gap-0.5">
|
||||||
|
<CheckCircle size={9} /> 已干预
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] flex-shrink-0">
|
<span className="text-[10px] flex-shrink-0">
|
||||||
<span className="text-slate-500">年度考核 </span>
|
<span className="text-slate-500">年度考核 </span>
|
||||||
@@ -109,16 +149,20 @@ export default function SuggestionList({ suggestions, onSelect }: Props) {
|
|||||||
<span className="truncate"><Blur>{v.customer || '-'}</Blur></span>
|
<span className="truncate"><Blur>{v.customer || '-'}</Blur></span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
|
<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">
|
||||||
|
客户日均 <span className="text-slate-700 font-medium">{Math.round(v.customerAvgDaily)}</span> km
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right */}
|
{/* Right */}
|
||||||
|
{!selectMode && (
|
||||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||||
<span className="text-[9px] text-slate-400">干预</span>
|
<span className="text-[9px] text-slate-400">干预</span>
|
||||||
<ChevronRight size={14} className="text-slate-300" />
|
<ChevronRight size={14} className="text-slate-300" />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ArrowDownUp, CheckCircle, Send, X } from 'lucide-react';
|
import { ArrowDownUp, CheckCircle, Send, X, Ban } from 'lucide-react';
|
||||||
import { sendNotify } from './api';
|
import { sendNotify, updateNotification } from './api';
|
||||||
import type { SchedulingSuggestion, CandidateVehicle } from './types';
|
import type { SchedulingSuggestion, CandidateVehicle } from './types';
|
||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
|
|
||||||
@@ -23,10 +23,15 @@ function fmtRate(rate: number): string {
|
|||||||
export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSuccess }: Props) {
|
export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSuccess }: Props) {
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [sent, setSent] = useState(false);
|
const [sent, setSent] = useState(false);
|
||||||
|
const [cancelling, setCancelling] = useState(false);
|
||||||
const v = s.currentVehicle;
|
const v = s.currentVehicle;
|
||||||
|
|
||||||
|
const alreadyIntervened =
|
||||||
|
!sent && (c.notificationStatus === 'sent' || c.notificationStatus === 'executed');
|
||||||
|
const isExecuted = c.notificationStatus === 'executed';
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
if (sending || sent) return;
|
if (sending || sent || alreadyIntervened) return;
|
||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
const result = await sendNotify({ suggestionId: s.id, currentPlate: v.plateNumber, candidatePlate: c.plateNumber });
|
const result = await sendNotify({ suggestionId: s.id, currentPlate: v.plateNumber, candidatePlate: c.plateNumber });
|
||||||
@@ -34,6 +39,21 @@ export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSu
|
|||||||
} catch { alert('网络错误'); } finally { setSending(false); }
|
} catch { alert('网络错误'); } finally { setSending(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
if (!c.notificationId || cancelling) return;
|
||||||
|
if (isExecuted) {
|
||||||
|
if (!confirm('此干预已标记为执行。确定要取消吗?')) return;
|
||||||
|
} else {
|
||||||
|
if (!confirm(`确定取消 ${v.plateNumber} → ${c.plateNumber} 的干预?`)) return;
|
||||||
|
}
|
||||||
|
setCancelling(true);
|
||||||
|
try {
|
||||||
|
const result = await updateNotification(c.notificationId, { status: 'cancelled' });
|
||||||
|
if (result.success) { onSuccess(); onClose(); }
|
||||||
|
else { alert(result.message || '取消失败'); }
|
||||||
|
} catch { alert('网络错误'); } finally { setCancelling(false); }
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[80] bg-[#F0F4F8] flex flex-col">
|
<div className="fixed inset-0 z-[80] bg-[#F0F4F8] flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -115,7 +135,22 @@ export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSu
|
|||||||
|
|
||||||
{/* Bottom */}
|
{/* Bottom */}
|
||||||
<div className="px-5 pb-6 pt-2 flex-shrink-0 bg-[#F0F4F8]">
|
<div className="px-5 pb-6 pt-2 flex-shrink-0 bg-[#F0F4F8]">
|
||||||
<div className="max-w-sm mx-auto">
|
<div className="max-w-sm mx-auto space-y-2">
|
||||||
|
{alreadyIntervened && (
|
||||||
|
<div className="rounded-xl bg-emerald-50 border border-emerald-200 px-3 py-2 text-[11px] text-emerald-700 flex items-center gap-2">
|
||||||
|
<CheckCircle size={13} />
|
||||||
|
<span>此车已{isExecuted ? '执行干预' : '登记干预'},如需重新干预请先取消。</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{alreadyIntervened ? (
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={cancelling}
|
||||||
|
className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-bold bg-white text-rose-600 border border-rose-200 hover:bg-rose-50 active:scale-[0.98] transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Ban size={16} /> {cancelling ? '取消中...' : '取消干预'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={sending || sent}
|
disabled={sending || sent}
|
||||||
@@ -123,8 +158,9 @@ export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSu
|
|||||||
sent ? 'bg-emerald-100 text-emerald-600' : 'bg-slate-800 hover:bg-slate-900 text-white active:scale-[0.98] shadow-lg'
|
sent ? 'bg-emerald-100 text-emerald-600' : 'bg-slate-800 hover:bg-slate-900 text-white active:scale-[0.98] shadow-lg'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{sent ? <><CheckCircle size={16} /> 已发送</> : <><Send size={16} /> 发送替换通知</>}
|
{sent ? <><CheckCircle size={16} /> 已登记</> : <><Send size={16} /> 登记干预</>}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { fetchJson } from '../../auth/api-client';
|
import { fetchJson } from '../../auth/api-client';
|
||||||
import type { SchedulingResponse } from './types';
|
import type {
|
||||||
|
SchedulingResponse,
|
||||||
|
NotifyRequest,
|
||||||
|
NotifyBatchRequest,
|
||||||
|
NotifyBatchResult,
|
||||||
|
NotificationRecord,
|
||||||
|
NotificationStatus,
|
||||||
|
UpdateNotificationRequest,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
const BASE = '/api/scheduling';
|
const BASE = '/api/scheduling';
|
||||||
|
|
||||||
@@ -10,14 +18,44 @@ export async function fetchSuggestions(targetId?: number): Promise<SchedulingRes
|
|||||||
return fetchJson<SchedulingResponse>(`${BASE}/suggestions${qs ? `?${qs}` : ''}`);
|
return fetchJson<SchedulingResponse>(`${BASE}/suggestions${qs ? `?${qs}` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendNotify(body: {
|
export async function sendNotify(
|
||||||
suggestionId: string;
|
body: NotifyRequest,
|
||||||
currentPlate: string;
|
): Promise<{ success: boolean; message: string; record?: NotificationRecord }> {
|
||||||
candidatePlate: string;
|
return fetchJson(`${BASE}/notify`, {
|
||||||
}): Promise<{ success: boolean; message: string }> {
|
|
||||||
return fetchJson<{ success: boolean; message: string }>(`${BASE}/notify`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendNotifyBatch(
|
||||||
|
body: NotifyBatchRequest,
|
||||||
|
): Promise<{ success: boolean; message: string; result: NotifyBatchResult }> {
|
||||||
|
return fetchJson(`${BASE}/notify/batch`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchNotifications(
|
||||||
|
status?: NotificationStatus,
|
||||||
|
limit?: number,
|
||||||
|
): Promise<{ records: NotificationRecord[] }> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (status) params.set('status', status);
|
||||||
|
if (limit) params.set('limit', String(limit));
|
||||||
|
const qs = params.toString();
|
||||||
|
return fetchJson(`${BASE}/notify${qs ? `?${qs}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateNotification(
|
||||||
|
id: number,
|
||||||
|
body: UpdateNotificationRequest,
|
||||||
|
): Promise<{ success: boolean; record?: NotificationRecord; message?: string }> {
|
||||||
|
return fetchJson(`${BASE}/notify/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@@ -1,62 +1,16 @@
|
|||||||
export interface SchedulingVehicleInfo {
|
export type {
|
||||||
plateNumber: string;
|
SchedulingVehicleInfo,
|
||||||
targetId: number;
|
CandidateVehicle,
|
||||||
targetName: string;
|
SchedulingSuggestion,
|
||||||
vehicleType: string;
|
SchedulingSummary,
|
||||||
totalMileage: number;
|
SchedulingTargetOption,
|
||||||
currentYearMileage: number;
|
SchedulingResponse,
|
||||||
completionRate: number;
|
NotifyRequest,
|
||||||
yearTarget: number;
|
NotifyBatchRequest,
|
||||||
region: string;
|
NotifyBatchResult,
|
||||||
province: string;
|
NotificationStatus,
|
||||||
customer: string | null;
|
NotificationRecord,
|
||||||
department: string | null;
|
UpdateNotificationRequest,
|
||||||
manager: string | null;
|
ReasonLine,
|
||||||
customerAvgDaily: number;
|
ReasonBlock,
|
||||||
predictedYearEnd: number;
|
} from '../../shared/scheduling/types';
|
||||||
daysLeft: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CandidateVehicle {
|
|
||||||
plateNumber: string;
|
|
||||||
targetId: number | null;
|
|
||||||
targetName: string | null;
|
|
||||||
vehicleType: string;
|
|
||||||
totalMileage: number;
|
|
||||||
completionRate: number;
|
|
||||||
yearTarget: number | null;
|
|
||||||
daysLeft: number;
|
|
||||||
region: string;
|
|
||||||
province: string;
|
|
||||||
mileageGap: number;
|
|
||||||
predictedAfterSwap: number;
|
|
||||||
canQualifyAfterSwap: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SchedulingSuggestion {
|
|
||||||
id: string;
|
|
||||||
priority: 'high' | 'medium';
|
|
||||||
type: 'replace_qualified' | 'rescue_hopeless';
|
|
||||||
currentVehicle: SchedulingVehicleInfo;
|
|
||||||
candidates: CandidateVehicle[];
|
|
||||||
reason: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SchedulingSummary {
|
|
||||||
qualifiedCount: number;
|
|
||||||
hopelessCount: number;
|
|
||||||
suggestionCount: number;
|
|
||||||
estimatedGain: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SchedulingTargetOption {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
vehicleCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SchedulingResponse {
|
|
||||||
summary: SchedulingSummary;
|
|
||||||
suggestions: SchedulingSuggestion[];
|
|
||||||
targets: SchedulingTargetOption[];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import dotenv from 'dotenv';
|
|||||||
import vehiclesRouter from './routes/vehicles.js';
|
import vehiclesRouter from './routes/vehicles.js';
|
||||||
import mileageRouter from './routes/mileage/index.js';
|
import mileageRouter from './routes/mileage/index.js';
|
||||||
import schedulingRouter from './routes/scheduling/index.js';
|
import schedulingRouter from './routes/scheduling/index.js';
|
||||||
|
import { ensureSchedulingTables } from './routes/scheduling/db-schema.js';
|
||||||
import authRouter from './auth/login.js';
|
import authRouter from './auth/login.js';
|
||||||
import { authMiddleware } from './auth/middleware.js';
|
import { authMiddleware } from './auth/middleware.js';
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ app.use('/*', serveStatic({ root: './dist', path: 'index.html' }));
|
|||||||
const port = Number(process.env.SERVER_PORT) || 3001;
|
const port = Number(process.env.SERVER_PORT) || 3001;
|
||||||
|
|
||||||
console.log(`Server starting on port ${port}...`);
|
console.log(`Server starting on port ${port}...`);
|
||||||
|
ensureSchedulingTables().catch(e => console.error('scheduling bootstrap error:', e));
|
||||||
serve({ fetch: app.fetch, port }, () => {
|
serve({ fetch: app.fetch, port }, () => {
|
||||||
console.log(`Server running at http://localhost:${port}`);
|
console.log(`Server running at http://localhost:${port}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
EnrichedVehicle, InventoryVehicle, SchedulingSuggestion,
|
EnrichedVehicle, InventoryVehicle, SchedulingSuggestion,
|
||||||
CandidateVehicle, VehicleClassification, SchedulingSummary,
|
CandidateVehicle, VehicleClassification, SchedulingSummary,
|
||||||
|
ReasonBlock,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
function fmtKmSimple(v: number): string {
|
function fmtKmSimple(v: number): string {
|
||||||
@@ -61,6 +62,7 @@ export function toVehicleInfo(v: EnrichedVehicle): SchedulingVehicleInfo {
|
|||||||
department: v.department,
|
department: v.department,
|
||||||
manager: v.manager,
|
manager: v.manager,
|
||||||
customerAvgDaily: v.customerAvgDaily,
|
customerAvgDaily: v.customerAvgDaily,
|
||||||
|
customerAvgDaily7d: v.customerAvgDaily7d,
|
||||||
predictedYearEnd: v.predictedYearEnd,
|
predictedYearEnd: v.predictedYearEnd,
|
||||||
daysLeft: v.daysLeft,
|
daysLeft: v.daysLeft,
|
||||||
};
|
};
|
||||||
@@ -93,7 +95,6 @@ export function generateSuggestions(
|
|||||||
const candidates: CandidateVehicle[] = inventoryVehicles
|
const candidates: CandidateVehicle[] = inventoryVehicles
|
||||||
.filter((inv) => {
|
.filter((inv) => {
|
||||||
if (!isTypeCompatible(vehicle.vehicleType, inv.vehicleType)) return false;
|
if (!isTypeCompatible(vehicle.vehicleType, inv.vehicleType)) return false;
|
||||||
if (inv.region !== vehicle.region) return false;
|
|
||||||
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
|
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
|
||||||
if (effectiveTarget > 0 && inv.totalMileage >= effectiveTarget) return false;
|
if (effectiveTarget > 0 && inv.totalMileage >= effectiveTarget) return false;
|
||||||
return true;
|
return true;
|
||||||
@@ -101,7 +102,6 @@ export function generateSuggestions(
|
|||||||
.map((inv) => {
|
.map((inv) => {
|
||||||
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
|
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
|
||||||
const mileageGap = Math.max(0, effectiveTarget - inv.totalMileage);
|
const mileageGap = Math.max(0, effectiveTarget - inv.totalMileage);
|
||||||
// Use candidate's own daysLeft for prediction
|
|
||||||
const candidateCanAdd = vehicle.customerAvgDaily * inv.daysLeft;
|
const candidateCanAdd = vehicle.customerAvgDaily * inv.daysLeft;
|
||||||
const predictedAfterSwap = inv.totalMileage + candidateCanAdd;
|
const predictedAfterSwap = inv.totalMileage + candidateCanAdd;
|
||||||
const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget;
|
const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget;
|
||||||
@@ -119,22 +119,32 @@ export function generateSuggestions(
|
|||||||
mileageGap,
|
mileageGap,
|
||||||
predictedAfterSwap,
|
predictedAfterSwap,
|
||||||
canQualifyAfterSwap,
|
canQualifyAfterSwap,
|
||||||
|
isSameRegion: inv.region === vehicle.region,
|
||||||
|
notificationId: null,
|
||||||
|
notificationStatus: null,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
// 1. Prefer "can qualify after swap" first
|
// 1. Same-region first (business rule: prefer same-region swaps)
|
||||||
|
if (a.isSameRegion !== b.isSameRegion) return a.isSameRegion ? -1 : 1;
|
||||||
|
// 2. Can-qualify next
|
||||||
if (a.canQualifyAfterSwap !== b.canQualifyAfterSwap)
|
if (a.canQualifyAfterSwap !== b.canQualifyAfterSwap)
|
||||||
return a.canQualifyAfterSwap ? -1 : 1;
|
return a.canQualifyAfterSwap ? -1 : 1;
|
||||||
// 2. Among qualifiable: smallest gap first (easiest to finish)
|
// 3. Smallest gap (closest to target)
|
||||||
// Among non-qualifiable: smallest gap first (closest to target)
|
|
||||||
return a.mileageGap - b.mileageGap;
|
return a.mileageGap - b.mileageGap;
|
||||||
})
|
})
|
||||||
;
|
;
|
||||||
|
|
||||||
const gap = Math.max(0, vehicle.yearTarget - vehicle.currentYearMileage);
|
const gap = Math.max(0, vehicle.yearTarget - vehicle.currentYearMileage);
|
||||||
const dailyReq = vehicle.daysLeft > 0 ? Math.round(gap / vehicle.daysLeft) : 0;
|
const dailyReq = vehicle.daysLeft > 0 ? Math.round(gap / vehicle.daysLeft) : 0;
|
||||||
const predictedTotal = Math.round(vehicle.currentYearMileage + vehicle.customerAvgDaily * vehicle.daysLeft);
|
const reason: ReasonBlock = {
|
||||||
const reason = `客户日均 ${Math.round(vehicle.customerAvgDaily)} km | 考核周期剩余 ${vehicle.daysLeft} 天 · 日均需 ${fmtKmSimple(dailyReq)} km\n!!预估无法达标,需替换`;
|
lines: [
|
||||||
|
{ label: '客户日均', value: `${Math.round(vehicle.customerAvgDaily)} km` },
|
||||||
|
{ label: '考核剩余', value: `${vehicle.daysLeft} 天` },
|
||||||
|
{ label: '日均需', value: `${fmtKmSimple(dailyReq)} km` },
|
||||||
|
],
|
||||||
|
conclusion: '预估无法达标,需替换',
|
||||||
|
};
|
||||||
|
|
||||||
suggestions.push({
|
suggestions.push({
|
||||||
id: `hopeless-${vehicle.plateNumber}`,
|
id: `hopeless-${vehicle.plateNumber}`,
|
||||||
@@ -156,8 +166,6 @@ export function generateSuggestions(
|
|||||||
const candidates: CandidateVehicle[] = inventoryVehicles
|
const candidates: CandidateVehicle[] = inventoryVehicles
|
||||||
.filter((inv) => {
|
.filter((inv) => {
|
||||||
if (!isTypeCompatible(vehicle.vehicleType, inv.vehicleType)) return false;
|
if (!isTypeCompatible(vehicle.vehicleType, inv.vehicleType)) return false;
|
||||||
if (inv.region !== vehicle.region) return false;
|
|
||||||
// Must still need mileage — exclude already-qualified inventory
|
|
||||||
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
|
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
|
||||||
if (effectiveTarget > 0 && inv.totalMileage >= effectiveTarget) return false;
|
if (effectiveTarget > 0 && inv.totalMileage >= effectiveTarget) return false;
|
||||||
return true;
|
return true;
|
||||||
@@ -165,7 +173,6 @@ export function generateSuggestions(
|
|||||||
.map((inv) => {
|
.map((inv) => {
|
||||||
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
|
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
|
||||||
const mileageGap = Math.max(0, effectiveTarget - inv.totalMileage);
|
const mileageGap = Math.max(0, effectiveTarget - inv.totalMileage);
|
||||||
// Use candidate's own daysLeft for prediction
|
|
||||||
const candidateCanAdd = vehicle.customerAvgDaily * inv.daysLeft;
|
const candidateCanAdd = vehicle.customerAvgDaily * inv.daysLeft;
|
||||||
const predictedAfterSwap = inv.totalMileage + candidateCanAdd;
|
const predictedAfterSwap = inv.totalMileage + candidateCanAdd;
|
||||||
const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget;
|
const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget;
|
||||||
@@ -183,17 +190,20 @@ export function generateSuggestions(
|
|||||||
mileageGap,
|
mileageGap,
|
||||||
predictedAfterSwap,
|
predictedAfterSwap,
|
||||||
canQualifyAfterSwap,
|
canQualifyAfterSwap,
|
||||||
|
isSameRegion: inv.region === vehicle.region,
|
||||||
|
notificationId: null,
|
||||||
|
notificationStatus: null,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
// Only keep candidates that can actually qualify at this customer —
|
||||||
|
// swapping in a car that still can't reach target wastes the high-mileage customer
|
||||||
|
.filter(c => c.canQualifyAfterSwap)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
// 1. canQualifyAfterSwap first
|
// 1. Same-region first
|
||||||
if (a.canQualifyAfterSwap !== b.canQualifyAfterSwap)
|
if (a.isSameRegion !== b.isSameRegion) return a.isSameRegion ? -1 : 1;
|
||||||
return a.canQualifyAfterSwap ? -1 : 1;
|
// 2. Biggest gap first (most value from the swap)
|
||||||
// 2. Among qualifiable: biggest gap first (most value from the swap)
|
|
||||||
return b.mileageGap - a.mileageGap;
|
return b.mileageGap - a.mileageGap;
|
||||||
})
|
})
|
||||||
// Only keep candidates that can actually qualify at this customer
|
|
||||||
.filter(c => c.canQualifyAfterSwap)
|
|
||||||
;
|
;
|
||||||
|
|
||||||
// Skip if no candidate can reach target — swap would be pointless
|
// Skip if no candidate can reach target — swap would be pointless
|
||||||
@@ -201,7 +211,15 @@ export function generateSuggestions(
|
|||||||
|
|
||||||
const yearRate = vehicle.yearTarget > 0 ? Math.round((vehicle.currentYearMileage / vehicle.yearTarget) * 100) : 0;
|
const yearRate = vehicle.yearTarget > 0 ? Math.round((vehicle.currentYearMileage / vehicle.yearTarget) * 100) : 0;
|
||||||
const canAddKm = vehicle.customerAvgDaily * vehicle.daysLeft;
|
const canAddKm = vehicle.customerAvgDaily * vehicle.daysLeft;
|
||||||
const reason = `客户日均 ${Math.round(vehicle.customerAvgDaily)} km\n已完成考核(完成率 ${yearRate}%)\n考核周期剩余 ${vehicle.daysLeft} 天,可为新车贡献约 ${fmtKmSimple(Math.round(canAddKm))} km\n!!已达标,建议换上未达标车辆`;
|
const reason: ReasonBlock = {
|
||||||
|
lines: [
|
||||||
|
{ label: '客户日均', value: `${Math.round(vehicle.customerAvgDaily)} km` },
|
||||||
|
{ label: '年度完成率', value: `${yearRate}%` },
|
||||||
|
{ label: '考核剩余', value: `${vehicle.daysLeft} 天` },
|
||||||
|
{ label: '可为新车贡献', value: `约 ${fmtKmSimple(Math.round(canAddKm))} km` },
|
||||||
|
],
|
||||||
|
conclusion: '已达标,建议换上未达标车辆',
|
||||||
|
};
|
||||||
|
|
||||||
suggestions.push({
|
suggestions.push({
|
||||||
id: `qualified-${vehicle.plateNumber}`,
|
id: `qualified-${vehicle.plateNumber}`,
|
||||||
@@ -222,10 +240,11 @@ export function generateSuggestions(
|
|||||||
return a.priority === 'high' ? -1 : 1;
|
return a.priority === 'high' ? -1 : 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
// estimatedGain: count suggestions where at least one candidate canQualifyAfterSwap,
|
// estimatedGain uses strict definition: count suggestions that have at least
|
||||||
// plus rescue_hopeless suggestions (each rescued car can potentially qualify at a new customer)
|
// one candidate able to qualify after swap. The API layer recomputes this
|
||||||
|
// post permission-filtering, so keep both sides consistent.
|
||||||
const estimatedGain = filteredSuggestions.filter((s) =>
|
const estimatedGain = filteredSuggestions.filter((s) =>
|
||||||
s.candidates.some((c) => c.canQualifyAfterSwap) || s.type === 'rescue_hopeless',
|
s.candidates.some((c) => c.canQualifyAfterSwap),
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
const summary: SchedulingSummary = {
|
const summary: SchedulingSummary = {
|
||||||
@@ -233,6 +252,7 @@ export function generateSuggestions(
|
|||||||
hopelessCount: hopeless.length,
|
hopelessCount: hopeless.length,
|
||||||
suggestionCount: filteredSuggestions.length,
|
suggestionCount: filteredSuggestions.length,
|
||||||
estimatedGain,
|
estimatedGain,
|
||||||
|
recentInterventionCount: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
return { suggestions: filteredSuggestions, summary };
|
return { suggestions: filteredSuggestions, summary };
|
||||||
|
|||||||
34
src/server/routes/scheduling/db-schema.ts
Normal file
34
src/server/routes/scheduling/db-schema.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import pool from '../../db.js';
|
||||||
|
|
||||||
|
const CREATE_NOTIFICATIONS_TABLE = `
|
||||||
|
CREATE TABLE IF NOT EXISTS tab_scheduling_notifications (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
suggestion_id VARCHAR(128) NOT NULL,
|
||||||
|
current_plate VARCHAR(32) NOT NULL,
|
||||||
|
candidate_plate VARCHAR(32) NOT NULL,
|
||||||
|
operator_id VARCHAR(64),
|
||||||
|
operator_name VARCHAR(128),
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'sent',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
executed_at DATETIME NULL,
|
||||||
|
notes VARCHAR(500) NULL,
|
||||||
|
before_mileage INT NULL,
|
||||||
|
after_mileage INT NULL,
|
||||||
|
INDEX idx_suggestion_id (suggestion_id),
|
||||||
|
INDEX idx_current_plate (current_plate),
|
||||||
|
INDEX idx_candidate_plate (candidate_plate),
|
||||||
|
INDEX idx_status (status),
|
||||||
|
INDEX idx_created_at (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='智能调度干预/执行记录'
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function ensureSchedulingTables(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await pool.query(CREATE_NOTIFICATIONS_TABLE);
|
||||||
|
console.log('[scheduling] notifications table ready');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[scheduling] failed to ensure tables:', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,114 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
import pool from '../../db.js';
|
||||||
import type { AuthUser } from '../../auth/types.js';
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
import type { NotifyRequest } from './types.js';
|
import type {
|
||||||
|
NotifyRequest,
|
||||||
|
NotifyBatchRequest,
|
||||||
|
NotifyBatchResult,
|
||||||
|
NotificationRecord,
|
||||||
|
NotificationStatus,
|
||||||
|
UpdateNotificationRequest,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
// In-memory set of processed suggestion IDs
|
// ---------------------------------------------------------------------------
|
||||||
const processedSuggestions = new Set<string>();
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function isProcessed(suggestionId: string): boolean {
|
function rowToRecord(row: any): NotificationRecord {
|
||||||
return processedSuggestions.has(suggestionId);
|
return {
|
||||||
|
id: Number(row.id),
|
||||||
|
suggestionId: row.suggestion_id,
|
||||||
|
currentPlate: row.current_plate,
|
||||||
|
candidatePlate: row.candidate_plate,
|
||||||
|
operatorId: row.operator_id,
|
||||||
|
operatorName: row.operator_name,
|
||||||
|
status: row.status,
|
||||||
|
createdAt: row.created_at ? new Date(row.created_at).toISOString() : '',
|
||||||
|
updatedAt: row.updated_at ? new Date(row.updated_at).toISOString() : '',
|
||||||
|
executedAt: row.executed_at ? new Date(row.executed_at).toISOString() : null,
|
||||||
|
notes: row.notes,
|
||||||
|
beforeMileage: row.before_mileage != null ? Number(row.before_mileage) : null,
|
||||||
|
afterMileage: row.after_mileage != null ? Number(row.after_mileage) : null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count non-cancelled interventions created within the last 7 days.
|
||||||
|
*/
|
||||||
|
export async function fetchRecentInterventionCount(): Promise<number> {
|
||||||
|
const [rows] = (await pool.execute(
|
||||||
|
`SELECT COUNT(*) AS cnt FROM tab_scheduling_notifications
|
||||||
|
WHERE status != 'cancelled'
|
||||||
|
AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)`,
|
||||||
|
)) as [any[], unknown];
|
||||||
|
return rows.length > 0 ? Number(rows[0].cnt) || 0 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch notification status map for the currently-visible (suggestion, candidate) pairs.
|
||||||
|
* Key: `${suggestionId}::${candidatePlate}` → latest non-cancelled notification.
|
||||||
|
*/
|
||||||
|
export async function fetchActiveNotificationMap(): Promise<
|
||||||
|
Map<string, { id: number; status: NotificationStatus }>
|
||||||
|
> {
|
||||||
|
const [rows] = (await pool.execute(
|
||||||
|
`SELECT id, suggestion_id, candidate_plate, status, created_at
|
||||||
|
FROM tab_scheduling_notifications
|
||||||
|
WHERE status != 'cancelled'
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
)) as [any[], unknown];
|
||||||
|
|
||||||
|
const map = new Map<string, { id: number; status: NotificationStatus }>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = `${row.suggestion_id}::${row.candidate_plate}`;
|
||||||
|
if (!map.has(key)) {
|
||||||
|
map.set(key, { id: Number(row.id), status: row.status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertNotification(
|
||||||
|
req: NotifyRequest,
|
||||||
|
operator: { id: string | null; name: string | null },
|
||||||
|
): Promise<NotificationRecord | { skipped: true; existingPlate: string }> {
|
||||||
|
// Business rule: each current vehicle (suggestion) can have AT MOST ONE
|
||||||
|
// active intervention at a time. Any non-cancelled record for the same
|
||||||
|
// suggestion_id blocks further interventions until it is cancelled.
|
||||||
|
const [existing] = (await pool.execute(
|
||||||
|
`SELECT id, candidate_plate FROM tab_scheduling_notifications
|
||||||
|
WHERE suggestion_id = ? AND status != 'cancelled'
|
||||||
|
LIMIT 1`,
|
||||||
|
[req.suggestionId],
|
||||||
|
)) as [any[], unknown];
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
return { skipped: true, existingPlate: existing[0].candidate_plate as string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [result] = (await pool.execute(
|
||||||
|
`INSERT INTO tab_scheduling_notifications
|
||||||
|
(suggestion_id, current_plate, candidate_plate, operator_id, operator_name, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'sent')`,
|
||||||
|
[req.suggestionId, req.currentPlate, req.candidatePlate, operator.id, operator.name],
|
||||||
|
)) as [any, unknown];
|
||||||
|
|
||||||
|
const insertedId = Number(result.insertId);
|
||||||
|
const [rows] = (await pool.execute(
|
||||||
|
`SELECT * FROM tab_scheduling_notifications WHERE id = ?`,
|
||||||
|
[insertedId],
|
||||||
|
)) as [any[], unknown];
|
||||||
|
|
||||||
|
return rowToRecord(rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Routes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// POST /api/scheduling/notify — single notify
|
||||||
app.post('/', async (c) => {
|
app.post('/', async (c) => {
|
||||||
try {
|
try {
|
||||||
const body = await c.req.json<NotifyRequest>();
|
const body = await c.req.json<NotifyRequest>();
|
||||||
@@ -20,21 +118,163 @@ app.post('/', async (c) => {
|
|||||||
return c.json({ success: false, message: '缺少必要参数' }, 400);
|
return c.json({ success: false, message: '缺少必要参数' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (processedSuggestions.has(suggestionId)) {
|
const user = (c as any).get('user') as AuthUser | undefined;
|
||||||
return c.json({ success: false, message: '该建议已处理' }, 409);
|
const operator = {
|
||||||
|
id: user?.userId ?? null,
|
||||||
|
name: user?.userName ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await insertNotification(body, operator);
|
||||||
|
if ('skipped' in result) {
|
||||||
|
return c.json(
|
||||||
|
{ success: false, message: `此车已有干预(候选车 ${result.existingPlate}),请先解除` },
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[scheduling:notify] operator=${operator.name} suggestion=${suggestionId} current=${currentPlate} candidate=${candidatePlate}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
message: `干预已登记:${currentPlate} → ${candidatePlate}`,
|
||||||
|
record: result,
|
||||||
|
});
|
||||||
|
} catch (e: unknown) {
|
||||||
|
console.error('scheduling notify error:', e);
|
||||||
|
return c.json({ success: false, message: '登记干预失败' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/scheduling/notify/batch — bulk notify
|
||||||
|
app.post('/batch', async (c) => {
|
||||||
|
try {
|
||||||
|
const body = await c.req.json<NotifyBatchRequest>();
|
||||||
|
if (!Array.isArray(body.items) || body.items.length === 0) {
|
||||||
|
return c.json({ success: false, message: '缺少 items' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = (c as any).get('user') as AuthUser | undefined;
|
const user = (c as any).get('user') as AuthUser | undefined;
|
||||||
const operator = user?.userName || '未知';
|
const operator = {
|
||||||
|
id: user?.userId ?? null,
|
||||||
|
name: user?.userName ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
console.log(`[scheduling:notify] operator=${operator} suggestion=${suggestionId} current=${currentPlate} candidate=${candidatePlate}`);
|
const result: NotifyBatchResult = { success: 0, skipped: 0, failed: 0, records: [] };
|
||||||
|
for (const item of body.items) {
|
||||||
|
if (!item.suggestionId || !item.currentPlate || !item.candidatePlate) {
|
||||||
|
result.failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await insertNotification(item, operator);
|
||||||
|
if ('skipped' in r) result.skipped++;
|
||||||
|
else {
|
||||||
|
result.success++;
|
||||||
|
result.records.push(r);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
result.failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
processedSuggestions.add(suggestionId);
|
console.log(
|
||||||
|
`[scheduling:notify:batch] operator=${operator.name} total=${body.items.length} success=${result.success} skipped=${result.skipped} failed=${result.failed}`,
|
||||||
|
);
|
||||||
|
|
||||||
return c.json({ success: true, message: `替换通知已发送:${currentPlate} → ${candidatePlate}` });
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
message: `批量干预:成功 ${result.success},跳过 ${result.skipped},失败 ${result.failed}`,
|
||||||
|
result,
|
||||||
|
});
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
console.error('scheduling notify error:', e);
|
console.error('scheduling batch notify error:', e);
|
||||||
return c.json({ success: false, message: '发送通知失败' }, 500);
|
return c.json({ success: false, message: '批量干预失败' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/scheduling/notify — list all notifications (history)
|
||||||
|
app.get('/', async (c) => {
|
||||||
|
try {
|
||||||
|
const status = c.req.query('status');
|
||||||
|
const limit = Math.min(Number(c.req.query('limit')) || 200, 500);
|
||||||
|
|
||||||
|
const where: string[] = [];
|
||||||
|
const params: (string | number)[] = [];
|
||||||
|
if (status) {
|
||||||
|
where.push('status = ?');
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||||
|
params.push(limit);
|
||||||
|
|
||||||
|
const [rows] = (await pool.query(
|
||||||
|
`SELECT * FROM tab_scheduling_notifications
|
||||||
|
${whereSql}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
params,
|
||||||
|
)) as [any[], unknown];
|
||||||
|
|
||||||
|
return c.json({ records: rows.map(rowToRecord) });
|
||||||
|
} catch (e: unknown) {
|
||||||
|
console.error('scheduling notifications list error:', e);
|
||||||
|
return c.json({ records: [] }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/scheduling/notify/:id — update status (execute / cancel)
|
||||||
|
app.patch('/:id', async (c) => {
|
||||||
|
try {
|
||||||
|
const id = Number(c.req.param('id'));
|
||||||
|
if (!Number.isFinite(id) || id <= 0) {
|
||||||
|
return c.json({ success: false, message: 'id 无效' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await c.req.json<UpdateNotificationRequest>();
|
||||||
|
if (!body.status) {
|
||||||
|
return c.json({ success: false, message: '缺少 status' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validStatuses: NotificationStatus[] = ['sent', 'executed', 'cancelled'];
|
||||||
|
if (!validStatuses.includes(body.status)) {
|
||||||
|
return c.json({ success: false, message: 'status 不合法' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields: string[] = ['status = ?'];
|
||||||
|
const params: (string | number | null)[] = [body.status];
|
||||||
|
if (body.status === 'executed') {
|
||||||
|
fields.push('executed_at = CURRENT_TIMESTAMP');
|
||||||
|
}
|
||||||
|
if (body.notes !== undefined) {
|
||||||
|
fields.push('notes = ?');
|
||||||
|
params.push(body.notes);
|
||||||
|
}
|
||||||
|
if (body.afterMileage !== undefined) {
|
||||||
|
fields.push('after_mileage = ?');
|
||||||
|
params.push(body.afterMileage);
|
||||||
|
}
|
||||||
|
params.push(id);
|
||||||
|
|
||||||
|
await pool.execute(
|
||||||
|
`UPDATE tab_scheduling_notifications SET ${fields.join(', ')} WHERE id = ?`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [rows] = (await pool.execute(
|
||||||
|
`SELECT * FROM tab_scheduling_notifications WHERE id = ?`,
|
||||||
|
[id],
|
||||||
|
)) as [any[], unknown];
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return c.json({ success: false, message: '记录不存在' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ success: true, record: rowToRecord(rows[0]) });
|
||||||
|
} catch (e: unknown) {
|
||||||
|
console.error('scheduling notification update error:', e);
|
||||||
|
return c.json({ success: false, message: '更新失败' }, 500);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { fetchVehicleInfoMap } from '../mileage/vehicle-info.js';
|
|||||||
import { mapRegion } from '../vehicles.js';
|
import { mapRegion } from '../vehicles.js';
|
||||||
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
||||||
import { classifyVehicle, generateSuggestions } from './algorithm.js';
|
import { classifyVehicle, generateSuggestions } from './algorithm.js';
|
||||||
|
import { fetchActiveNotificationMap, fetchRecentInterventionCount } from './notify.js';
|
||||||
import type { EnrichedVehicle, InventoryVehicle, SchedulingResponse, SchedulingSummary } from './types.js';
|
import type { EnrichedVehicle, InventoryVehicle, SchedulingResponse, SchedulingSummary } from './types.js';
|
||||||
import type { AuthUser } from '../../auth/types.js';
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
|
|
||||||
@@ -113,12 +114,16 @@ app.get('/', async (c) => {
|
|||||||
// ---- Collect all plates for Query 6 ----
|
// ---- Collect all plates for Query 6 ----
|
||||||
const allPlates = assessmentRows.map((r: any) => r.plate_number as string);
|
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 customerAvgDailyMap = new Map<string, number>();
|
||||||
|
const customerAvgDaily7dMap = new Map<string, number>();
|
||||||
if (allPlates.length > 0) {
|
if (allPlates.length > 0) {
|
||||||
const placeholders = allPlates.map(() => '?').join(',');
|
const placeholders = allPlates.map(() => '?').join(',');
|
||||||
|
// Single query returning both windows per plate.
|
||||||
const [dailyRows] = await mileagePool.execute(
|
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
|
FROM v_vehicle_daily_stats
|
||||||
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
AND stat_date < CURDATE()
|
AND stat_date < CURDATE()
|
||||||
@@ -127,25 +132,30 @@ app.get('/', async (c) => {
|
|||||||
allPlates,
|
allPlates,
|
||||||
) as [any[], unknown];
|
) as [any[], unknown];
|
||||||
|
|
||||||
// Build plate → avg_daily map
|
const plateAvg30Map = new Map<string, number>();
|
||||||
const plateAvgMap = new Map<string, number>();
|
const plateAvg7Map = new Map<string, number>();
|
||||||
for (const row of dailyRows) {
|
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 customerPlates30 = new Map<string, number[]>();
|
||||||
const customerPlates = new Map<string, number[]>();
|
const customerPlates7 = new Map<string, number[]>();
|
||||||
for (const plate of allPlates) {
|
for (const plate of allPlates) {
|
||||||
const info = vehicleInfoMap.get(plate);
|
const info = vehicleInfoMap.get(plate);
|
||||||
const customer = info?.customer || '未知客户';
|
const customer = info?.customer || '未知客户';
|
||||||
if (!customerPlates.has(customer)) customerPlates.set(customer, []);
|
if (!customerPlates30.has(customer)) customerPlates30.set(customer, []);
|
||||||
const avg = plateAvgMap.get(plate);
|
if (!customerPlates7.has(customer)) customerPlates7.set(customer, []);
|
||||||
if (avg !== undefined) customerPlates.get(customer)!.push(avg);
|
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) {
|
for (const [customer, avgs] of customerPlates30) {
|
||||||
if (avgs.length > 0) {
|
if (avgs.length > 0) customerAvgDailyMap.set(customer, avgs.reduce((s, v) => s + v, 0) / avgs.length);
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,6 +217,7 @@ app.get('/', async (c) => {
|
|||||||
|
|
||||||
const customer = info?.customer || null;
|
const customer = info?.customer || null;
|
||||||
const customerAvgDaily = customerAvgDailyMap.get(customer || '未知客户') || 0;
|
const customerAvgDaily = customerAvgDailyMap.get(customer || '未知客户') || 0;
|
||||||
|
const customerAvgDaily7d = customerAvgDaily7dMap.get(customer || '未知客户') || 0;
|
||||||
const currentYearMileage = Number(row.current_year_mileage) || 0;
|
const currentYearMileage = Number(row.current_year_mileage) || 0;
|
||||||
const yearTarget = Number(row.current_year_mileage_task) || 0;
|
const yearTarget = Number(row.current_year_mileage_task) || 0;
|
||||||
const predictedYearEnd = currentYearMileage + customerAvgDaily * daysLeft;
|
const predictedYearEnd = currentYearMileage + customerAvgDaily * daysLeft;
|
||||||
@@ -232,6 +243,7 @@ app.get('/', async (c) => {
|
|||||||
department: info?.department || null,
|
department: info?.department || null,
|
||||||
manager: info?.manager || null,
|
manager: info?.manager || null,
|
||||||
customerAvgDaily,
|
customerAvgDaily,
|
||||||
|
customerAvgDaily7d,
|
||||||
predictedYearEnd,
|
predictedYearEnd,
|
||||||
daysLeft,
|
daysLeft,
|
||||||
classification,
|
classification,
|
||||||
@@ -275,6 +287,19 @@ app.get('/', async (c) => {
|
|||||||
// ---- Run algorithm ----
|
// ---- Run algorithm ----
|
||||||
const { suggestions, summary } = generateSuggestions(enrichedVehicles, inventoryVehicles);
|
const { suggestions, summary } = generateSuggestions(enrichedVehicles, inventoryVehicles);
|
||||||
|
|
||||||
|
// ---- Attach notification status to candidates ----
|
||||||
|
const notificationMap = await fetchActiveNotificationMap();
|
||||||
|
for (const s of suggestions) {
|
||||||
|
for (const c of s.candidates) {
|
||||||
|
const key = `${s.id}::${c.plateNumber}`;
|
||||||
|
const notif = notificationMap.get(key);
|
||||||
|
if (notif) {
|
||||||
|
c.notificationId = notif.id;
|
||||||
|
c.notificationStatus = notif.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Permission filtering & customer name masking ----
|
// ---- Permission filtering & customer name masking ----
|
||||||
const user = (c as any).get('user') as AuthUser | undefined;
|
const user = (c as any).get('user') as AuthUser | undefined;
|
||||||
|
|
||||||
@@ -317,6 +342,7 @@ app.get('/', async (c) => {
|
|||||||
// Recalculate summary based on permission-filtered results
|
// Recalculate summary based on permission-filtered results
|
||||||
const filteredQualified = masked.filter((s: any) => s.type === 'replace_qualified').length;
|
const filteredQualified = masked.filter((s: any) => s.type === 'replace_qualified').length;
|
||||||
const filteredHopeless = masked.filter((s: any) => s.type === 'rescue_hopeless').length;
|
const filteredHopeless = masked.filter((s: any) => s.type === 'rescue_hopeless').length;
|
||||||
|
const recentInterventionCount = await fetchRecentInterventionCount();
|
||||||
const filteredSummary: SchedulingSummary = {
|
const filteredSummary: SchedulingSummary = {
|
||||||
qualifiedCount: summary.qualifiedCount,
|
qualifiedCount: summary.qualifiedCount,
|
||||||
hopelessCount: summary.hopelessCount,
|
hopelessCount: summary.hopelessCount,
|
||||||
@@ -324,6 +350,7 @@ app.get('/', async (c) => {
|
|||||||
estimatedGain: masked.filter((s: any) =>
|
estimatedGain: masked.filter((s: any) =>
|
||||||
s.candidates?.some((c: any) => c.canQualifyAfterSwap)
|
s.candidates?.some((c: any) => c.canQualifyAfterSwap)
|
||||||
).length,
|
).length,
|
||||||
|
recentInterventionCount,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response: SchedulingResponse = {
|
const response: SchedulingResponse = {
|
||||||
@@ -337,7 +364,7 @@ app.get('/', async (c) => {
|
|||||||
console.error('scheduling suggestions error:', e);
|
console.error('scheduling suggestions error:', e);
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
summary: { qualifiedCount: 0, hopelessCount: 0, suggestionCount: 0, estimatedGain: 0 },
|
summary: { qualifiedCount: 0, hopelessCount: 0, suggestionCount: 0, estimatedGain: 0, recentInterventionCount: 0 },
|
||||||
suggestions: [],
|
suggestions: [],
|
||||||
targets: [],
|
targets: [],
|
||||||
} satisfies SchedulingResponse,
|
} satisfies SchedulingResponse,
|
||||||
|
|||||||
@@ -1,71 +1,23 @@
|
|||||||
export interface SchedulingVehicleInfo {
|
export type {
|
||||||
plateNumber: string;
|
SchedulingVehicleInfo,
|
||||||
targetId: number;
|
CandidateVehicle,
|
||||||
targetName: string;
|
SchedulingSuggestion,
|
||||||
vehicleType: string;
|
SchedulingSummary,
|
||||||
totalMileage: number;
|
SchedulingTargetOption,
|
||||||
currentYearMileage: number;
|
SchedulingResponse,
|
||||||
completionRate: number; // 本年完成率 currentYearMileage / yearTarget
|
NotifyRequest,
|
||||||
yearTarget: number;
|
NotifyBatchRequest,
|
||||||
region: string;
|
NotifyBatchResult,
|
||||||
province: string;
|
NotificationStatus,
|
||||||
customer: string | null;
|
NotificationRecord,
|
||||||
department: string | null;
|
UpdateNotificationRequest,
|
||||||
manager: string | null;
|
ReasonLine,
|
||||||
customerAvgDaily: number;
|
ReasonBlock,
|
||||||
predictedYearEnd: number;
|
} from '../../../shared/scheduling/types.js';
|
||||||
daysLeft: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CandidateVehicle {
|
// ---------------------------------------------------------------------------
|
||||||
plateNumber: string;
|
// Server-only types
|
||||||
targetId: number | null;
|
// ---------------------------------------------------------------------------
|
||||||
targetName: string | null;
|
|
||||||
vehicleType: string;
|
|
||||||
totalMileage: number;
|
|
||||||
completionRate: number;
|
|
||||||
yearTarget: number | null;
|
|
||||||
daysLeft: number;
|
|
||||||
region: string;
|
|
||||||
province: string;
|
|
||||||
mileageGap: number;
|
|
||||||
predictedAfterSwap: number;
|
|
||||||
canQualifyAfterSwap: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SchedulingSuggestion {
|
|
||||||
id: string;
|
|
||||||
priority: 'high' | 'medium';
|
|
||||||
type: 'replace_qualified' | 'rescue_hopeless';
|
|
||||||
currentVehicle: SchedulingVehicleInfo;
|
|
||||||
candidates: CandidateVehicle[];
|
|
||||||
reason: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SchedulingSummary {
|
|
||||||
qualifiedCount: number;
|
|
||||||
hopelessCount: number;
|
|
||||||
suggestionCount: number;
|
|
||||||
estimatedGain: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SchedulingTargetOption {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
vehicleCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SchedulingResponse {
|
|
||||||
summary: SchedulingSummary;
|
|
||||||
suggestions: SchedulingSuggestion[];
|
|
||||||
targets: SchedulingTargetOption[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NotifyRequest {
|
|
||||||
suggestionId: string;
|
|
||||||
currentPlate: string;
|
|
||||||
candidatePlate: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type VehicleClassification = 'qualified' | 'hopeless' | 'normal';
|
export type VehicleClassification = 'qualified' | 'hopeless' | 'normal';
|
||||||
|
|
||||||
@@ -87,6 +39,7 @@ export interface EnrichedVehicle {
|
|||||||
department: string | null;
|
department: string | null;
|
||||||
manager: string | null;
|
manager: string | null;
|
||||||
customerAvgDaily: number;
|
customerAvgDaily: number;
|
||||||
|
customerAvgDaily7d: number;
|
||||||
predictedYearEnd: number;
|
predictedYearEnd: number;
|
||||||
daysLeft: number;
|
daysLeft: number;
|
||||||
classification: VehicleClassification;
|
classification: VehicleClassification;
|
||||||
|
|||||||
123
src/shared/scheduling/types.ts
Normal file
123
src/shared/scheduling/types.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// Shared scheduling types — used by both client (modules/scheduling) and server
|
||||||
|
// (server/routes/scheduling). Keep server-only types (EnrichedVehicle etc.) in
|
||||||
|
// server/routes/scheduling/types.ts.
|
||||||
|
|
||||||
|
export interface SchedulingVehicleInfo {
|
||||||
|
plateNumber: string;
|
||||||
|
targetId: number;
|
||||||
|
targetName: string;
|
||||||
|
vehicleType: string;
|
||||||
|
totalMileage: number;
|
||||||
|
currentYearMileage: number;
|
||||||
|
completionRate: number;
|
||||||
|
yearTarget: number;
|
||||||
|
region: string;
|
||||||
|
province: string;
|
||||||
|
customer: string | null;
|
||||||
|
department: string | null;
|
||||||
|
manager: string | null;
|
||||||
|
customerAvgDaily: number;
|
||||||
|
customerAvgDaily7d: number;
|
||||||
|
predictedYearEnd: number;
|
||||||
|
daysLeft: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NotificationStatus = 'sent' | 'executed' | 'cancelled';
|
||||||
|
|
||||||
|
export interface CandidateVehicle {
|
||||||
|
plateNumber: string;
|
||||||
|
targetId: number | null;
|
||||||
|
targetName: string | null;
|
||||||
|
vehicleType: string;
|
||||||
|
totalMileage: number;
|
||||||
|
completionRate: number;
|
||||||
|
yearTarget: number | null;
|
||||||
|
daysLeft: number;
|
||||||
|
region: string;
|
||||||
|
province: string;
|
||||||
|
mileageGap: number;
|
||||||
|
predictedAfterSwap: number;
|
||||||
|
canQualifyAfterSwap: boolean;
|
||||||
|
isSameRegion: boolean;
|
||||||
|
notificationId: number | null;
|
||||||
|
notificationStatus: NotificationStatus | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationRecord {
|
||||||
|
id: number;
|
||||||
|
suggestionId: string;
|
||||||
|
currentPlate: string;
|
||||||
|
candidatePlate: string;
|
||||||
|
operatorId: string | null;
|
||||||
|
operatorName: string | null;
|
||||||
|
status: NotificationStatus;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
executedAt: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
beforeMileage: number | null;
|
||||||
|
afterMileage: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotifyBatchRequest {
|
||||||
|
items: NotifyRequest[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotifyBatchResult {
|
||||||
|
success: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
records: NotificationRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateNotificationRequest {
|
||||||
|
status: NotificationStatus;
|
||||||
|
notes?: string;
|
||||||
|
afterMileage?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReasonLine {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReasonBlock {
|
||||||
|
lines: ReasonLine[];
|
||||||
|
conclusion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchedulingSuggestion {
|
||||||
|
id: string;
|
||||||
|
priority: 'high' | 'medium';
|
||||||
|
type: 'replace_qualified' | 'rescue_hopeless';
|
||||||
|
currentVehicle: SchedulingVehicleInfo;
|
||||||
|
candidates: CandidateVehicle[];
|
||||||
|
reason: ReasonBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchedulingSummary {
|
||||||
|
qualifiedCount: number;
|
||||||
|
hopelessCount: number;
|
||||||
|
suggestionCount: number;
|
||||||
|
estimatedGain: number;
|
||||||
|
/** Count of interventions created within the last 7 days (excluding cancelled). */
|
||||||
|
recentInterventionCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchedulingTargetOption {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
vehicleCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchedulingResponse {
|
||||||
|
summary: SchedulingSummary;
|
||||||
|
suggestions: SchedulingSuggestion[];
|
||||||
|
targets: SchedulingTargetOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotifyRequest {
|
||||||
|
suggestionId: string;
|
||||||
|
currentPlate: string;
|
||||||
|
candidatePlate: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user