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 {
|
function pickBestCandidate(s: SchedulingSuggestion): CandidateVehicle | null {
|
||||||
// Prefer a candidate that can qualify and isn't already notified
|
// Business rule: at most one active intervention per suggestion. If ANY
|
||||||
const available = s.candidates.filter(c => !c.notificationStatus || c.notificationStatus === 'cancelled');
|
// candidate is already intervened, skip the whole suggestion in batch flow.
|
||||||
if (available.length === 0) return null;
|
const hasActive = s.candidates.some(
|
||||||
return available.find(c => c.canQualifyAfterSwap) ?? available[0];
|
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() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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,
|
||||||
TrendingUp, TrendingDown, Minus,
|
TrendingUp, TrendingDown, Minus, 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';
|
||||||
@@ -36,6 +36,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>();
|
||||||
@@ -70,8 +77,9 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
|
|||||||
sentPlates.has(c.plateNumber) ||
|
sentPlates.has(c.plateNumber) ||
|
||||||
c.notificationStatus === 'sent' ||
|
c.notificationStatus === 'sent' ||
|
||||||
c.notificationStatus === 'executed';
|
c.notificationStatus === 'executed';
|
||||||
|
const blockedByOther = !!activeIntervention && activeIntervention.plateNumber !== c.plateNumber;
|
||||||
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 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 justify-between px-3 py-2">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<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-xs font-black text-slate-900 font-mono"><Blur>{c.plateNumber}</Blur></span>
|
||||||
@@ -111,6 +119,11 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-3 pb-2.5">
|
<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
|
<button
|
||||||
onClick={() => setPreviewCandidate(c)}
|
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 ${
|
className={`w-full flex items-center justify-center gap-1.5 text-[11px] font-bold py-2 rounded-lg transition-all cursor-pointer ${
|
||||||
@@ -121,6 +134,7 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
|
|||||||
>
|
>
|
||||||
{sent ? <><CheckCircle size={12} /> 已干预 · 查看 <ArrowRight size={12} /></> : <>查看替换方案 <ArrowRight size={12} /></>}
|
{sent ? <><CheckCircle size={12} /> 已干预 · 查看 <ArrowRight size={12} /></> : <>查看替换方案 <ArrowRight size={12} /></>}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<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 */}
|
||||||
|
|||||||
@@ -61,16 +61,20 @@ export async function fetchActiveNotificationMap(): Promise<
|
|||||||
async function insertNotification(
|
async function insertNotification(
|
||||||
req: NotifyRequest,
|
req: NotifyRequest,
|
||||||
operator: { id: string | null; name: string | null },
|
operator: { id: string | null; name: string | null },
|
||||||
): Promise<NotificationRecord | { skipped: true }> {
|
): Promise<NotificationRecord | { skipped: true; existingPlate: string }> {
|
||||||
// Check if a non-cancelled notification already exists for this pair
|
// 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(
|
const [existing] = (await pool.execute(
|
||||||
`SELECT id FROM tab_scheduling_notifications
|
`SELECT id, candidate_plate FROM tab_scheduling_notifications
|
||||||
WHERE suggestion_id = ? AND candidate_plate = ? AND status != 'cancelled'
|
WHERE suggestion_id = ? AND status != 'cancelled'
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[req.suggestionId, req.candidatePlate],
|
[req.suggestionId],
|
||||||
)) as [any[], unknown];
|
)) 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(
|
const [result] = (await pool.execute(
|
||||||
`INSERT INTO tab_scheduling_notifications
|
`INSERT INTO tab_scheduling_notifications
|
||||||
@@ -110,7 +114,10 @@ app.post('/', async (c) => {
|
|||||||
|
|
||||||
const result = await insertNotification(body, operator);
|
const result = await insertNotification(body, operator);
|
||||||
if ('skipped' in result) {
|
if ('skipped' in result) {
|
||||||
return c.json({ success: false, message: '该建议已处理' }, 409);
|
return c.json(
|
||||||
|
{ success: false, message: `此车已有干预(候选车 ${result.existingPlate}),请先解除` },
|
||||||
|
409,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
Reference in New Issue
Block a user