diff --git a/src/modules/scheduling/SchedulingModule.tsx b/src/modules/scheduling/SchedulingModule.tsx index 7ee6b87..94c444e 100644 --- a/src/modules/scheduling/SchedulingModule.tsx +++ b/src/modules/scheduling/SchedulingModule.tsx @@ -147,10 +147,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]; + // 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() { diff --git a/src/modules/scheduling/SuggestionDetail.tsx b/src/modules/scheduling/SuggestionDetail.tsx index 2d23d57..3349b4d 100644 --- a/src/modules/scheduling/SuggestionDetail.tsx +++ b/src/modules/scheduling/SuggestionDetail.tsx @@ -1,7 +1,7 @@ import { useState, useMemo } from 'react'; import { X, MapPin, AlertTriangle, CheckCircle, ArrowDown, ArrowUp, ArrowRight, ArrowUpDown, - TrendingUp, TrendingDown, Minus, + TrendingUp, TrendingDown, Minus, Lock, } from 'lucide-react'; import { motion } from 'motion/react'; import type { SchedulingSuggestion, CandidateVehicle } from './types'; @@ -36,6 +36,13 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce const v = s.currentVehicle; 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 const batchOptions = useMemo(() => { const set = new Set(); @@ -70,8 +77,9 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce sentPlates.has(c.plateNumber) || c.notificationStatus === 'sent' || c.notificationStatus === 'executed'; + const blockedByOther = !!activeIntervention && activeIntervention.plateNumber !== c.plateNumber; return ( -
+
{c.plateNumber} @@ -111,16 +119,22 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
- + {blockedByOther ? ( +
+ 该车已有其他干预,请先解除 +
+ ) : ( + + )}
); @@ -240,6 +254,15 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce {displayCount}/{s.candidates.length} 辆
+ {activeIntervention && ( +
+ + + 此车已干预替换为 {activeIntervention.plateNumber}。如需更换方案,请先在该候选车处解除干预。 + +
+ )} + {/* Filter + Sort controls */}
{/* Batch multi-select pills */} diff --git a/src/server/routes/scheduling/notify.ts b/src/server/routes/scheduling/notify.ts index 50e6e62..93075fd 100644 --- a/src/server/routes/scheduling/notify.ts +++ b/src/server/routes/scheduling/notify.ts @@ -61,16 +61,20 @@ export async function fetchActiveNotificationMap(): Promise< async function insertNotification( req: NotifyRequest, operator: { id: string | null; name: string | null }, -): Promise { - // Check if a non-cancelled notification already exists for this pair +): Promise { + // 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 FROM tab_scheduling_notifications - WHERE suggestion_id = ? AND candidate_plate = ? AND status != 'cancelled' + `SELECT id, candidate_plate FROM tab_scheduling_notifications + WHERE suggestion_id = ? AND status != 'cancelled' LIMIT 1`, - [req.suggestionId, req.candidatePlate], + [req.suggestionId], )) as [any[], unknown]; - if (existing.length > 0) return { skipped: true }; + if (existing.length > 0) { + return { skipped: true, existingPlate: existing[0].candidate_plate as string }; + } const [result] = (await pool.execute( `INSERT INTO tab_scheduling_notifications @@ -110,7 +114,10 @@ app.post('/', async (c) => { const result = await insertNotification(body, operator); if ('skipped' in result) { - return c.json({ success: false, message: '该建议已处理' }, 409); + return c.json( + { success: false, message: `此车已有干预(候选车 ${result.existingPlate}),请先解除` }, + 409, + ); } console.log(