fix(scheduling): enforce one active intervention per current vehicle
Business rule: a running vehicle can hold AT MOST ONE active (sent|executed) intervention. Switching to a different candidate requires cancelling the prior one first. - Server: insertNotification dedup key changes from (suggestion_id, candidate_plate) to just suggestion_id; 409 response includes the blocking candidate plate - Detail modal: shows a banner naming the locked candidate; non-active candidates render a disabled "该车已有其他干预,请先解除" hint instead of the action button - Batch: pickBestCandidate returns null for any suggestion already holding an active intervention — the whole suggestion is excluded Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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<string>();
|
||||
@@ -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 (
|
||||
<div key={c.plateNumber} className="rounded-xl border border-slate-200 overflow-hidden bg-white">
|
||||
<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>
|
||||
@@ -111,16 +119,22 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
|
||||
</div>
|
||||
|
||||
<div className="px-3 pb-2.5">
|
||||
<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>
|
||||
{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>
|
||||
);
|
||||
@@ -240,6 +254,15 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
|
||||
<span className="text-[10px] text-slate-400">{displayCount}/{s.candidates.length} 辆</span>
|
||||
</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 */}
|
||||
<div className="flex items-center gap-2 mb-2.5 flex-wrap">
|
||||
{/* Batch multi-select pills */}
|
||||
|
||||
@@ -61,16 +61,20 @@ export async function fetchActiveNotificationMap(): Promise<
|
||||
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
|
||||
): 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 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(
|
||||
|
||||
Reference in New Issue
Block a user