feat(scheduling): persist notifications, batch notify flow, dedup protection
- Add tab_scheduling_notifications table with bootstrap via ensureSchedulingTables() - Notify endpoint rewritten: dedup by (suggestion_id, candidate_plate), history list, PATCH /:id for execute/cancel lifecycle - Batch notify endpoint returns success/skipped/failed counts - Suggestions response now carries notificationId + notificationStatus per candidate (joined from active-notification map) - UI: select mode with checkboxes, floating action bar, confirmation modal listing each swap; already-notified items are dimmed and skipped - Detail view badges show sent/executed state, preventing duplicate notify Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
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 } 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 Blur from '../../components/Blur';
|
||||||
|
|
||||||
type TypeFilter = 'all' | 'qualified' | 'hopeless';
|
type TypeFilter = 'all' | 'qualified' | 'hopeless';
|
||||||
|
|
||||||
@@ -143,6 +144,13 @@ function SkeletonPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickBestCandidate(s: SchedulingSuggestion): CandidateVehicle | null {
|
||||||
|
// Prefer a candidate that can qualify and isn't already notified
|
||||||
|
const available = s.candidates.filter(c => !c.notificationStatus || c.notificationStatus === 'cancelled');
|
||||||
|
if (available.length === 0) return null;
|
||||||
|
return available.find(c => c.canQualifyAfterSwap) ?? available[0];
|
||||||
|
}
|
||||||
|
|
||||||
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 +160,11 @@ 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 loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -168,6 +181,55 @@ export default function SchedulingModule() {
|
|||||||
if (!stillExists) setSelectedSuggestion(null);
|
if (!stillExists) setSelectedSuggestion(null);
|
||||||
}, [data, selectedSuggestion]);
|
}, [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>();
|
||||||
@@ -284,6 +346,18 @@ 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={() => {
|
||||||
|
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 ${
|
||||||
@@ -398,13 +472,115 @@ 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} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -65,7 +65,10 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderCandidate = (c: CandidateVehicle) => {
|
const renderCandidate = (c: CandidateVehicle) => {
|
||||||
const sent = sentPlates.has(c.plateNumber);
|
const sent =
|
||||||
|
sentPlates.has(c.plateNumber) ||
|
||||||
|
c.notificationStatus === 'sent' ||
|
||||||
|
c.notificationStatus === 'executed';
|
||||||
return (
|
return (
|
||||||
<div key={c.plateNumber} className="rounded-xl border border-slate-200 overflow-hidden bg-white">
|
<div key={c.plateNumber} className="rounded-xl border border-slate-200 overflow-hidden bg-white">
|
||||||
<div className="flex items-center justify-between px-3 py-2">
|
<div className="flex items-center justify-between px-3 py-2">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -115,10 +155,12 @@ export default function SuggestionList({ suggestions, onSelect }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right */}
|
{/* Right */}
|
||||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
{!selectMode && (
|
||||||
<span className="text-[9px] text-slate-400">干预</span>
|
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||||
<ChevronRight size={14} className="text-slate-300" />
|
<span className="text-[9px] text-slate-400">干预</span>
|
||||||
</div>
|
<ChevronRight size={14} className="text-slate-300" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ export type {
|
|||||||
SchedulingTargetOption,
|
SchedulingTargetOption,
|
||||||
SchedulingResponse,
|
SchedulingResponse,
|
||||||
NotifyRequest,
|
NotifyRequest,
|
||||||
|
NotifyBatchRequest,
|
||||||
|
NotifyBatchResult,
|
||||||
|
NotificationStatus,
|
||||||
|
NotificationRecord,
|
||||||
|
UpdateNotificationRequest,
|
||||||
ReasonLine,
|
ReasonLine,
|
||||||
ReasonBlock,
|
ReasonBlock,
|
||||||
} from '../../shared/scheduling/types';
|
} from '../../shared/scheduling/types';
|
||||||
|
|||||||
@@ -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}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ export function generateSuggestions(
|
|||||||
predictedAfterSwap,
|
predictedAfterSwap,
|
||||||
canQualifyAfterSwap,
|
canQualifyAfterSwap,
|
||||||
isSameRegion: inv.region === vehicle.region,
|
isSameRegion: inv.region === vehicle.region,
|
||||||
|
notificationId: null,
|
||||||
|
notificationStatus: null,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
@@ -188,6 +190,8 @@ export function generateSuggestions(
|
|||||||
predictedAfterSwap,
|
predictedAfterSwap,
|
||||||
canQualifyAfterSwap,
|
canQualifyAfterSwap,
|
||||||
isSameRegion: inv.region === vehicle.region,
|
isSameRegion: inv.region === vehicle.region,
|
||||||
|
notificationId: null,
|
||||||
|
notificationStatus: null,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
// Only keep candidates that can actually qualify at this customer —
|
// Only keep candidates that can actually qualify at this customer —
|
||||||
|
|||||||
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,98 @@
|
|||||||
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 }> {
|
||||||
|
// Check if a non-cancelled notification already exists for this pair
|
||||||
|
const [existing] = (await pool.execute(
|
||||||
|
`SELECT id FROM tab_scheduling_notifications
|
||||||
|
WHERE suggestion_id = ? AND candidate_plate = ? AND status != 'cancelled'
|
||||||
|
LIMIT 1`,
|
||||||
|
[req.suggestionId, req.candidatePlate],
|
||||||
|
)) as [any[], unknown];
|
||||||
|
|
||||||
|
if (existing.length > 0) return { skipped: true };
|
||||||
|
|
||||||
|
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,22 +102,161 @@ 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;
|
||||||
|
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: '该建议已处理' }, 409);
|
return c.json({ success: false, message: '该建议已处理' }, 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = (c as any).get('user') as AuthUser | undefined;
|
console.log(
|
||||||
const operator = user?.userName || '未知';
|
`[scheduling:notify] operator=${operator.name} suggestion=${suggestionId} current=${currentPlate} candidate=${candidatePlate}`,
|
||||||
|
);
|
||||||
|
|
||||||
console.log(`[scheduling:notify] operator=${operator} suggestion=${suggestionId} current=${currentPlate} candidate=${candidatePlate}`);
|
return c.json({
|
||||||
|
success: true,
|
||||||
processedSuggestions.add(suggestionId);
|
message: `替换通知已发送:${currentPlate} → ${candidatePlate}`,
|
||||||
|
record: result,
|
||||||
return c.json({ success: true, message: `替换通知已发送:${currentPlate} → ${candidatePlate}` });
|
});
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
console.error('scheduling notify error:', e);
|
console.error('scheduling notify error:', e);
|
||||||
return c.json({ success: false, message: '发送通知失败' }, 500);
|
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 operator = {
|
||||||
|
id: user?.userId ?? null,
|
||||||
|
name: user?.userName ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: `批量通知:成功 ${result.success},跳过 ${result.skipped},失败 ${result.failed}`,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
} catch (e: unknown) {
|
||||||
|
console.error('scheduling batch notify error:', e);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -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 } 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';
|
||||||
|
|
||||||
@@ -275,6 +276,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;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ export type {
|
|||||||
SchedulingTargetOption,
|
SchedulingTargetOption,
|
||||||
SchedulingResponse,
|
SchedulingResponse,
|
||||||
NotifyRequest,
|
NotifyRequest,
|
||||||
|
NotifyBatchRequest,
|
||||||
|
NotifyBatchResult,
|
||||||
|
NotificationStatus,
|
||||||
|
NotificationRecord,
|
||||||
|
UpdateNotificationRequest,
|
||||||
ReasonLine,
|
ReasonLine,
|
||||||
ReasonBlock,
|
ReasonBlock,
|
||||||
} from '../../../shared/scheduling/types.js';
|
} from '../../../shared/scheduling/types.js';
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export interface SchedulingVehicleInfo {
|
|||||||
daysLeft: number;
|
daysLeft: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NotificationStatus = 'sent' | 'executed' | 'cancelled';
|
||||||
|
|
||||||
export interface CandidateVehicle {
|
export interface CandidateVehicle {
|
||||||
plateNumber: string;
|
plateNumber: string;
|
||||||
targetId: number | null;
|
targetId: number | null;
|
||||||
@@ -36,6 +38,41 @@ export interface CandidateVehicle {
|
|||||||
predictedAfterSwap: number;
|
predictedAfterSwap: number;
|
||||||
canQualifyAfterSwap: boolean;
|
canQualifyAfterSwap: boolean;
|
||||||
isSameRegion: 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 {
|
export interface ReasonLine {
|
||||||
|
|||||||
Reference in New Issue
Block a user