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:
kkfluous
2026-04-17 09:19:43 +08:00
parent 210db7f8ff
commit 1b2ad68743
3 changed files with 56 additions and 23 deletions

View File

@@ -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() {

View File

@@ -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 */}

View File

@@ -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(