diff --git a/src/modules/scheduling/SchedulingModule.tsx b/src/modules/scheduling/SchedulingModule.tsx index 8763814..8fc0d0e 100644 --- a/src/modules/scheduling/SchedulingModule.tsx +++ b/src/modules/scheduling/SchedulingModule.tsx @@ -1,10 +1,11 @@ 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 { fetchSuggestions } from './api'; -import type { SchedulingResponse, SchedulingSuggestion } from './types'; +import { fetchSuggestions, sendNotifyBatch } from './api'; +import type { SchedulingResponse, SchedulingSuggestion, CandidateVehicle } from './types'; import SuggestionList from './SuggestionList'; import SuggestionDetail from './SuggestionDetail'; +import Blur from '../../components/Blur'; 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() { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); @@ -152,6 +160,11 @@ export default function SchedulingModule() { const [showFilter, setShowFilter] = useState(false); const [filters, setFilters] = useState(EMPTY_FILTERS); const [tempFilters, setTempFilters] = useState(EMPTY_FILTERS); + const [selectMode, setSelectMode] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [showBatchConfirm, setShowBatchConfirm] = useState(false); + const [batchInFlight, setBatchInFlight] = useState(false); + const [batchResultMsg, setBatchResultMsg] = useState(null); const loadData = useCallback(async () => { setLoading(true); @@ -168,6 +181,55 @@ export default function SchedulingModule() { if (!stillExists) setSelectedSuggestion(null); }, [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(() => { if (!data) return { regions: [], vehicleTypes: [], customers: [], departments: [], managers: [] }; const r = new Set(), t = new Set(), c = new Set(), d = new Set(), m = new Set(); @@ -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"> + + + + + )} + + + {/* Batch confirmation modal */} + {showBatchConfirm && ( +
!batchInFlight && setShowBatchConfirm(false)}> + 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" + > +
+ 确认批量通知 + +
+
+

+ 将发送 {batchItems.length} 条替换通知,已排除无可用候选车的建议。 +

+
+ {batchItems.map(({ suggestion, candidate }) => ( +
+
+ {suggestion.currentVehicle.plateNumber} + + {candidate.plateNumber} +
+ {candidate.canQualifyAfterSwap ? ( + 可达标 + ) : ( + 需关注 + )} +
+ ))} +
+ {batchResultMsg && ( +

{batchResultMsg}

+ )} +
+
+ + +
+
+
+ )} ); diff --git a/src/modules/scheduling/SuggestionDetail.tsx b/src/modules/scheduling/SuggestionDetail.tsx index 2033a5f..5aadcc3 100644 --- a/src/modules/scheduling/SuggestionDetail.tsx +++ b/src/modules/scheduling/SuggestionDetail.tsx @@ -65,7 +65,10 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce }; const renderCandidate = (c: CandidateVehicle) => { - const sent = sentPlates.has(c.plateNumber); + const sent = + sentPlates.has(c.plateNumber) || + c.notificationStatus === 'sent' || + c.notificationStatus === 'executed'; return (
diff --git a/src/modules/scheduling/SuggestionList.tsx b/src/modules/scheduling/SuggestionList.tsx index 2871236..3468b84 100644 --- a/src/modules/scheduling/SuggestionList.tsx +++ b/src/modules/scheduling/SuggestionList.tsx @@ -1,5 +1,5 @@ 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 type { SchedulingSuggestion } from './types'; import Blur from '../../components/Blur'; @@ -7,6 +7,13 @@ import Blur from '../../components/Blur'; interface Props { suggestions: SchedulingSuggestion[]; onSelect: (s: SchedulingSuggestion) => void; + selectMode?: boolean; + selectedIds?: Set; + 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 { @@ -16,7 +23,7 @@ function fmtRate(rate: number): string { type SortKey = 'default' | 'avgDaily' | 'completion'; 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('default'); const [sortDir, setSortDir] = useState('desc'); @@ -73,6 +80,17 @@ export default function SuggestionList({ suggestions, onSelect }: Props) { {sorted.map((s, idx) => { const isRescue = s.type === 'rescue_hopeless'; 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 ( onSelect(s)} + className={`px-4 py-3 hover:bg-slate-50/60 transition-colors flex items-center gap-3 ${ + canSelect || !selectMode ? 'cursor-pointer active:bg-slate-100' : 'cursor-default opacity-60' + } ${isSelected ? 'bg-blue-50/60' : ''}`} + onClick={handleClick} > + {/* Checkbox (select mode) */} + {selectMode && ( +
+ {isSelected && } +
+ )} + {/* Color bar */}
@@ -96,6 +131,11 @@ export default function SuggestionList({ suggestions, onSelect }: Props) { {v.vehicleType} · {v.region} + {notified && ( + + 已通知 + + )}
年度考核 @@ -115,10 +155,12 @@ export default function SuggestionList({ suggestions, onSelect }: Props) {
{/* Right */} -
- 干预 - -
+ {!selectMode && ( +
+ 干预 + +
+ )} ); })} diff --git a/src/modules/scheduling/api.ts b/src/modules/scheduling/api.ts index f67ed2e..366e535 100644 --- a/src/modules/scheduling/api.ts +++ b/src/modules/scheduling/api.ts @@ -1,5 +1,13 @@ 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'; @@ -10,14 +18,44 @@ export async function fetchSuggestions(targetId?: number): Promise(`${BASE}/suggestions${qs ? `?${qs}` : ''}`); } -export async function sendNotify(body: { - suggestionId: string; - currentPlate: string; - candidatePlate: string; -}): Promise<{ success: boolean; message: string }> { - return fetchJson<{ success: boolean; message: string }>(`${BASE}/notify`, { +export async function sendNotify( + body: NotifyRequest, +): Promise<{ success: boolean; message: string; record?: NotificationRecord }> { + return fetchJson(`${BASE}/notify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, 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), + }); +} diff --git a/src/modules/scheduling/types.ts b/src/modules/scheduling/types.ts index 20b8d35..3778eea 100644 --- a/src/modules/scheduling/types.ts +++ b/src/modules/scheduling/types.ts @@ -6,6 +6,11 @@ export type { SchedulingTargetOption, SchedulingResponse, NotifyRequest, + NotifyBatchRequest, + NotifyBatchResult, + NotificationStatus, + NotificationRecord, + UpdateNotificationRequest, ReasonLine, ReasonBlock, } from '../../shared/scheduling/types'; diff --git a/src/server/index.ts b/src/server/index.ts index fdd9411..68222d6 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -6,6 +6,7 @@ import dotenv from 'dotenv'; import vehiclesRouter from './routes/vehicles.js'; import mileageRouter from './routes/mileage/index.js'; import schedulingRouter from './routes/scheduling/index.js'; +import { ensureSchedulingTables } from './routes/scheduling/db-schema.js'; import authRouter from './auth/login.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; console.log(`Server starting on port ${port}...`); +ensureSchedulingTables().catch(e => console.error('scheduling bootstrap error:', e)); serve({ fetch: app.fetch, port }, () => { console.log(`Server running at http://localhost:${port}`); }); diff --git a/src/server/routes/scheduling/algorithm.ts b/src/server/routes/scheduling/algorithm.ts index c70c027..1a19dc5 100644 --- a/src/server/routes/scheduling/algorithm.ts +++ b/src/server/routes/scheduling/algorithm.ts @@ -119,6 +119,8 @@ export function generateSuggestions( predictedAfterSwap, canQualifyAfterSwap, isSameRegion: inv.region === vehicle.region, + notificationId: null, + notificationStatus: null, }; }) .sort((a, b) => { @@ -188,6 +190,8 @@ export function generateSuggestions( predictedAfterSwap, canQualifyAfterSwap, isSameRegion: inv.region === vehicle.region, + notificationId: null, + notificationStatus: null, }; }) // Only keep candidates that can actually qualify at this customer — diff --git a/src/server/routes/scheduling/db-schema.ts b/src/server/routes/scheduling/db-schema.ts new file mode 100644 index 0000000..e92c19f --- /dev/null +++ b/src/server/routes/scheduling/db-schema.ts @@ -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 { + 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; + } +} diff --git a/src/server/routes/scheduling/notify.ts b/src/server/routes/scheduling/notify.ts index 49cbfd0..6b74641 100644 --- a/src/server/routes/scheduling/notify.ts +++ b/src/server/routes/scheduling/notify.ts @@ -1,16 +1,98 @@ import { Hono } from 'hono'; +import pool from '../../db.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(); -// In-memory set of processed suggestion IDs -const processedSuggestions = new Set(); +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- -export function isProcessed(suggestionId: string): boolean { - return processedSuggestions.has(suggestionId); +function rowToRecord(row: any): NotificationRecord { + 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 +> { + 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(); + 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 { + // 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) => { try { const body = await c.req.json(); @@ -20,22 +102,161 @@ app.post('/', async (c) => { 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); } - const user = (c as any).get('user') as AuthUser | undefined; - const operator = user?.userName || '未知'; + console.log( + `[scheduling:notify] operator=${operator.name} suggestion=${suggestionId} current=${currentPlate} candidate=${candidatePlate}`, + ); - console.log(`[scheduling:notify] operator=${operator} suggestion=${suggestionId} current=${currentPlate} candidate=${candidatePlate}`); - - processedSuggestions.add(suggestionId); - - return c.json({ success: true, message: `替换通知已发送:${currentPlate} → ${candidatePlate}` }); + return c.json({ + success: true, + message: `替换通知已发送:${currentPlate} → ${candidatePlate}`, + record: result, + }); } catch (e: unknown) { console.error('scheduling notify error:', e); 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(); + 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(); + 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; diff --git a/src/server/routes/scheduling/suggestions.ts b/src/server/routes/scheduling/suggestions.ts index 5c9f892..90f93c4 100644 --- a/src/server/routes/scheduling/suggestions.ts +++ b/src/server/routes/scheduling/suggestions.ts @@ -5,6 +5,7 @@ import { fetchVehicleInfoMap } from '../mileage/vehicle-info.js'; import { mapRegion } from '../vehicles.js'; import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js'; import { classifyVehicle, generateSuggestions } from './algorithm.js'; +import { fetchActiveNotificationMap } from './notify.js'; import type { EnrichedVehicle, InventoryVehicle, SchedulingResponse, SchedulingSummary } from './types.js'; import type { AuthUser } from '../../auth/types.js'; @@ -275,6 +276,19 @@ app.get('/', async (c) => { // ---- Run algorithm ---- 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 ---- const user = (c as any).get('user') as AuthUser | undefined; diff --git a/src/server/routes/scheduling/types.ts b/src/server/routes/scheduling/types.ts index b817f5a..9e5bdae 100644 --- a/src/server/routes/scheduling/types.ts +++ b/src/server/routes/scheduling/types.ts @@ -6,6 +6,11 @@ export type { SchedulingTargetOption, SchedulingResponse, NotifyRequest, + NotifyBatchRequest, + NotifyBatchResult, + NotificationStatus, + NotificationRecord, + UpdateNotificationRequest, ReasonLine, ReasonBlock, } from '../../../shared/scheduling/types.js'; diff --git a/src/shared/scheduling/types.ts b/src/shared/scheduling/types.ts index 676fa99..9417aac 100644 --- a/src/shared/scheduling/types.ts +++ b/src/shared/scheduling/types.ts @@ -21,6 +21,8 @@ export interface SchedulingVehicleInfo { daysLeft: number; } +export type NotificationStatus = 'sent' | 'executed' | 'cancelled'; + export interface CandidateVehicle { plateNumber: string; targetId: number | null; @@ -36,6 +38,41 @@ export interface CandidateVehicle { predictedAfterSwap: number; canQualifyAfterSwap: 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 {