# Smart Scheduling (智能调度) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a smart scheduling module that analyzes vehicle mileage assessment data, generates vehicle replacement suggestions using a greedy priority-matching algorithm, and presents an actionable UI for dispatchers. **Architecture:** Backend Hono routes compute suggestions in real-time by aggregating assessment targets, vehicle mileage, customer daily averages, inventory vehicles, and GPS location data. Frontend React module displays suggestions with batch filtering, priority-sorted list, and a detail modal with before/after comparison. A notify endpoint records dispatcher actions and triggers external callbacks. **Tech Stack:** Hono (backend), MySQL (mysql2/promise), React 19, Tailwind CSS, motion/react, lucide-react, recharts (optional for future charts) **Spec:** `docs/superpowers/specs/2026-04-16-smart-scheduling-design.md` --- ## File Structure ### Backend (new files) | File | Responsibility | |------|---------------| | `src/server/routes/scheduling/index.ts` | Router entry — mounts suggestions + notify sub-routes | | `src/server/routes/scheduling/suggestions.ts` | `GET /` — queries all data sources, runs classification + matching algorithm, returns `SchedulingResponse` | | `src/server/routes/scheduling/notify.ts` | `POST /notify` — records action, triggers callback, returns success | | `src/server/routes/scheduling/types.ts` | All scheduling-specific TypeScript interfaces | | `src/server/routes/scheduling/algorithm.ts` | Pure functions: classifyVehicles, generateSuggestions, vehicle-type matching, region matching | ### Backend (modified files) | File | Change | |------|--------| | `src/server/index.ts` | Add `import schedulingRouter` and `app.route('/api/scheduling', schedulingRouter)` | | `src/server/routes/vehicles.ts` | Export `mapRegion()` function (currently module-private) | ### Frontend (new files) | File | Responsibility | |------|---------------| | `src/modules/scheduling/types.ts` | Frontend type definitions mirroring backend response | | `src/modules/scheduling/api.ts` | `fetchSuggestions()`, `sendNotify()` client functions | | `src/modules/scheduling/SchedulingModule.tsx` | Main entry — state management, data loading, batch filter, stats cards, list + detail | | `src/modules/scheduling/SuggestionList.tsx` | Renders priority-sorted suggestion cards | | `src/modules/scheduling/SuggestionDetail.tsx` | Modal with current vehicle info, candidate list with before/after comparison, notify button | ### Frontend (modified files) | File | Change | |------|--------| | `src/App.tsx` | Add scheduling module to `MODULES` array | --- ## Task 1: Backend Types **Files:** - Create: `src/server/routes/scheduling/types.ts` - [ ] **Step 1: Create the scheduling types file** ```typescript // src/server/routes/scheduling/types.ts export interface SchedulingVehicleInfo { plateNumber: string; targetId: number; targetName: string; vehicleType: string; // "4.5T冷链" | "4.5T普货" | "18T" | "49T" | "挂车" totalMileage: number; completionRate: number; // 0-1 yearTarget: number; region: string; // mapped region: "嘉兴" | "广东" | "北京" | "新疆" | "其他" province: string; customer: string | null; customerAvgDaily: number; predictedYearEnd: number; daysLeft: number; } export interface CandidateVehicle { plateNumber: string; targetId: number | null; targetName: string | null; vehicleType: string; totalMileage: number; completionRate: number; yearTarget: number | null; region: string; province: string; mileageGap: number; predictedAfterSwap: number; canQualifyAfterSwap: boolean; } export interface SchedulingSuggestion { id: string; priority: 'high' | 'medium'; type: 'replace_qualified' | 'rescue_hopeless'; currentVehicle: SchedulingVehicleInfo; candidates: CandidateVehicle[]; reason: string; } export interface SchedulingSummary { qualifiedCount: number; hopelessCount: number; suggestionCount: number; estimatedGain: number; } export interface SchedulingTargetOption { id: number; name: string; vehicleCount: number; } export interface SchedulingResponse { summary: SchedulingSummary; suggestions: SchedulingSuggestion[]; targets: SchedulingTargetOption[]; } export interface NotifyRequest { suggestionId: string; currentPlate: string; candidatePlate: string; } /** Classification of a vehicle's qualification likelihood */ export type VehicleClassification = 'qualified' | 'hopeless' | 'normal'; /** Internal enriched vehicle used during algorithm computation */ export interface EnrichedVehicle { plateNumber: string; targetId: number; targetName: string; vehicleType: string; totalMileage: number; currentYearMileage: number; completionRate: number; yearTarget: number; isQualified: boolean; currentYearIsQualified: boolean; dailyRequiredMileage: number; region: string; province: string; customer: string | null; customerAvgDaily: number; predictedYearEnd: number; daysLeft: number; classification: VehicleClassification; } /** Inventory vehicle available for replacement */ export interface InventoryVehicle { plateNumber: string; vehicleType: string; region: string; province: string; totalMileage: number; /** If this inventory vehicle is also in an assessment target */ targetId: number | null; targetName: string | null; yearTarget: number | null; completionRate: number; } ``` - [ ] **Step 2: Commit** ```bash git add src/server/routes/scheduling/types.ts git commit -m "feat(scheduling): add backend type definitions" ``` --- ## Task 2: Algorithm Pure Functions **Files:** - Create: `src/server/routes/scheduling/algorithm.ts` - Modify: `src/server/routes/vehicles.ts` (export `mapRegion`) - [ ] **Step 1: Export mapRegion from vehicles.ts** In `src/server/routes/vehicles.ts`, the `mapRegion` function (line ~94) is currently not exported. Add the `export` keyword: ```typescript // Change: function mapRegion(province: string | null, city: string | null): string { // To: export function mapRegion(province: string | null, city: string | null): string { ``` - [ ] **Step 2: Create algorithm.ts with vehicle type matching** ```typescript // src/server/routes/scheduling/algorithm.ts import type { EnrichedVehicle, InventoryVehicle, SchedulingSuggestion, CandidateVehicle, VehicleClassification, SchedulingSummary, } from './types.js'; // --- Vehicle type compatibility --- const COLD_CHAIN_TYPES = new Set(['4.5T冷链']); const COMPATIBLE_FOR_COLD_CHAIN = new Set(['4.5T冷链', '4.5T普货']); /** * Check if candidateType can replace sourceType. * Rules: * - 4.5T冷链 can go to 4.5T冷链 or 4.5T普货 (cold chain can run without AC) * - 4.5T普货 can only go to 4.5T普货 (cannot reverse into cold chain) * - All other types: exact match only (18T↔18T, 49T↔49T, 挂车↔挂车) */ export function isTypeCompatible(sourceType: string, candidateType: string): boolean { if (sourceType === candidateType) return true; // Cold chain vehicle can replace plain cargo if (COLD_CHAIN_TYPES.has(candidateType) && COMPATIBLE_FOR_COLD_CHAIN.has(sourceType)) return true; return false; } // --- Vehicle classification --- const QUALIFIED_THRESHOLD = 1.2; // 120% const HOPELESS_THRESHOLD = 0.6; // 60% export function classifyVehicle( currentYearIsQualified: boolean, predictedYearEnd: number, yearTarget: number, ): VehicleClassification { if (currentYearIsQualified || (yearTarget > 0 && predictedYearEnd / yearTarget >= QUALIFIED_THRESHOLD)) { return 'qualified'; } if (yearTarget > 0 && predictedYearEnd / yearTarget < HOPELESS_THRESHOLD) { return 'hopeless'; } return 'normal'; } // --- Suggestion generation --- const MAX_CANDIDATES = 5; export function generateSuggestions( vehicles: EnrichedVehicle[], inventoryVehicles: InventoryVehicle[], ): { suggestions: SchedulingSuggestion[]; summary: SchedulingSummary } { const qualified = vehicles.filter(v => v.classification === 'qualified'); const hopeless = vehicles.filter(v => v.classification === 'hopeless'); const suggestions: SchedulingSuggestion[] = []; // Track which inventory vehicles have been used as candidates (for estimatedGain) const usedInventory = new Set(); // --- Scenario B first (higher priority): rescue hopeless vehicles --- for (const vehicle of hopeless) { const candidates = findCandidatesForHopeless(vehicle, inventoryVehicles); if (candidates.length === 0) continue; suggestions.push({ id: `s-${vehicle.plateNumber}-${Date.now()}`, priority: 'high', type: 'rescue_hopeless', currentVehicle: toVehicleInfo(vehicle), candidates, reason: `${vehicle.customer || '该客户'}日均里程仅 ${Math.round(vehicle.customerAvgDaily)} KM,` + `该车达标概率 ${Math.round((vehicle.predictedYearEnd / vehicle.yearTarget) * 100)}%,` + `建议替换为已达标车辆,将此车调配给高里程客户。`, }); for (const c of candidates) usedInventory.add(c.plateNumber); } // --- Scenario A: replace qualified vehicles at high-mileage customers --- for (const vehicle of qualified) { // Only suggest if customer avg daily is above the vehicle's required daily if (vehicle.customerAvgDaily <= vehicle.dailyRequiredMileage) continue; const candidates = findCandidatesForQualified(vehicle, inventoryVehicles); if (candidates.length === 0) continue; suggestions.push({ id: `s-${vehicle.plateNumber}-${Date.now()}`, priority: 'medium', type: 'replace_qualified', currentVehicle: toVehicleInfo(vehicle), candidates, reason: `${vehicle.customer || '该客户'}日均里程 ${Math.round(vehicle.customerAvgDaily)} KM(高里程),` + `该车已达标(完成率 ${Math.round(vehicle.completionRate * 100)}%),` + `建议换上里程缺口大的车辆以加速达标。`, }); for (const c of candidates) usedInventory.add(c.plateNumber); } // Sort: high priority first, then by predicted gap descending suggestions.sort((a, b) => { if (a.priority !== b.priority) return a.priority === 'high' ? -1 : 1; return 0; }); // Estimate gain: count candidates that canQualifyAfterSwap let estimatedGain = 0; for (const s of suggestions) { if (s.candidates.some(c => c.canQualifyAfterSwap)) estimatedGain++; } return { suggestions, summary: { qualifiedCount: qualified.length, hopelessCount: hopeless.length, suggestionCount: suggestions.length, estimatedGain, }, }; } // --- Candidate finding --- function findCandidatesForQualified( vehicle: EnrichedVehicle, inventory: InventoryVehicle[], ): CandidateVehicle[] { return inventory .filter(iv => isTypeCompatible(vehicle.vehicleType, iv.vehicleType) && iv.region === vehicle.region ) .map(iv => { const predictedAfterSwap = iv.totalMileage + vehicle.customerAvgDaily * vehicle.daysLeft; const gap = (iv.yearTarget ?? vehicle.yearTarget) - iv.totalMileage; return { plateNumber: iv.plateNumber, targetId: iv.targetId, targetName: iv.targetName, vehicleType: iv.vehicleType, totalMileage: iv.totalMileage, completionRate: iv.completionRate, yearTarget: iv.yearTarget, region: iv.region, province: iv.province, mileageGap: Math.max(0, gap), predictedAfterSwap: Math.round(predictedAfterSwap), canQualifyAfterSwap: predictedAfterSwap >= (iv.yearTarget ?? vehicle.yearTarget), }; }) // Prioritize: largest gap that can still qualify after swap .sort((a, b) => { // canQualify first if (a.canQualifyAfterSwap !== b.canQualifyAfterSwap) return a.canQualifyAfterSwap ? -1 : 1; // Then largest gap return b.mileageGap - a.mileageGap; }) .slice(0, MAX_CANDIDATES); } function findCandidatesForHopeless( vehicle: EnrichedVehicle, inventory: InventoryVehicle[], ): CandidateVehicle[] { return inventory .filter(iv => isTypeCompatible(vehicle.vehicleType, iv.vehicleType) && iv.region === vehicle.region && // For hopeless: prefer already-qualified or high-mileage inventory vehicles iv.completionRate >= 0.8 ) .map(iv => { // This vehicle goes to the low-mileage customer, so predict with customer's avg const predictedAfterSwap = iv.totalMileage + vehicle.customerAvgDaily * vehicle.daysLeft; const gap = (iv.yearTarget ?? vehicle.yearTarget) - iv.totalMileage; return { plateNumber: iv.plateNumber, targetId: iv.targetId, targetName: iv.targetName, vehicleType: iv.vehicleType, totalMileage: iv.totalMileage, completionRate: iv.completionRate, yearTarget: iv.yearTarget, region: iv.region, province: iv.province, mileageGap: Math.max(0, gap), predictedAfterSwap: Math.round(predictedAfterSwap), canQualifyAfterSwap: iv.completionRate >= 1.0, // already qualified stays qualified }; }) // Prioritize: already qualified first, then highest completion rate .sort((a, b) => { if (a.canQualifyAfterSwap !== b.canQualifyAfterSwap) return a.canQualifyAfterSwap ? -1 : 1; return b.completionRate - a.completionRate; }) .slice(0, MAX_CANDIDATES); } function toVehicleInfo(v: EnrichedVehicle) { return { plateNumber: v.plateNumber, targetId: v.targetId, targetName: v.targetName, vehicleType: v.vehicleType, totalMileage: v.totalMileage, completionRate: v.completionRate, yearTarget: v.yearTarget, region: v.region, province: v.province, customer: v.customer, customerAvgDaily: v.customerAvgDaily, predictedYearEnd: v.predictedYearEnd, daysLeft: v.daysLeft, }; } ``` - [ ] **Step 3: Verify TypeScript compiles** ```bash cd /Users/kkfluous/Projects/ai-coding/ln-bi && npx tsc --noEmit src/server/routes/scheduling/algorithm.ts src/server/routes/scheduling/types.ts 2>&1 | head -20 ``` - [ ] **Step 4: Commit** ```bash git add src/server/routes/scheduling/algorithm.ts src/server/routes/vehicles.ts git commit -m "feat(scheduling): add algorithm pure functions and export mapRegion" ``` --- ## Task 3: Backend Suggestions Route **Files:** - Create: `src/server/routes/scheduling/suggestions.ts` This is the core route handler. It queries 6 data sources, enriches vehicles, then calls the algorithm. - [ ] **Step 1: Create suggestions.ts** ```typescript // src/server/routes/scheduling/suggestions.ts import { Hono } from 'hono'; import pool from '../../db.js'; import mileagePool from '../../mileage-db.js'; 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 type { EnrichedVehicle, InventoryVehicle, SchedulingResponse } from './types.js'; import type { AuthUser } from '../../auth/types.js'; const app = new Hono(); /** * Classify vehicle type from model string (matches vehicles.ts logic). * Returns a display label for the scheduling module. */ function classifyVehicleType(type: string, model: string): string { if (type === '4.5T' && model.includes('冷链')) return '4.5T冷链'; if (type === '4.5T') return '4.5T普货'; if (type === '18T') return '18T'; if (type === '49T') return '49T'; if (type === '挂车' || model.includes('挂车')) return '挂车'; return type || '其他'; } app.get('/', async (c) => { const targetIdFilter = c.req.query('targetId') ? Number(c.req.query('targetId')) : null; try { // 1. Fetch assessment targets const [targets] = await pool.execute( 'SELECT id, target_name, annual_mileage_per_vehicle FROM tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id' ) as [any[], unknown]; const targetMap = new Map(); for (const t of targets) { targetMap.set(t.id, { name: t.target_name, annualTarget: Number(t.annual_mileage_per_vehicle) || 0 }); } // 2. Fetch all assessment vehicles const [assessmentRows] = await pool.execute(` SELECT target_id, plate_number, today_mileage, vehicle_total_mileage, current_mileage, current_year_mileage, current_year_mileage_task, completion_rate, is_qualified, current_year_is_qualified, daily_required_mileage, current_year_assessment_end_date FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0 `) as [any[], unknown]; // 3. Fetch vehicle info (customer, department, manager, rent_status, type/model) const infoMap = await fetchVehicleInfoMap(); // 4. Fetch vehicle type info from tab_truck const [truckRows] = await pool.execute(` SELECT truck.plate_number, dic_type.dic_name AS type_name, truck.model AS model_raw FROM tab_truck truck LEFT JOIN tab_dic dic_type ON dic_type.parent_code = 'dic_truck_type' AND dic_type.dic_code = truck.model AND dic_type.is_deleted = 0 WHERE truck.is_deleted = 0 AND truck.is_operation = 1 `) as [any[], unknown]; const truckTypeMap = new Map(); for (const row of truckRows) { truckTypeMap.set(row.plate_number, { typeName: row.type_name || '', modelRaw: row.model_raw || '', }); } // 5. Fetch real-time location for all vehicles const [locationRows] = await pool.execute( 'SELECT plate_number, province, city FROM tab_truck_remote_sync_realtime_info WHERE is_deleted = 0 AND plate_number IS NOT NULL' ) as [any[], unknown]; const locationMap = new Map(); for (const row of locationRows) { locationMap.set(row.plate_number, { province: row.province || '', city: row.city || '', }); } // 6. Compute customer average daily mileage (last 30 days) const customerPlates = new Map(); // customer -> plates for (const row of assessmentRows) { const info = infoMap.get(row.plate_number); const customer = info?.customer; if (!customer) continue; const list = customerPlates.get(customer) || []; list.push(row.plate_number); customerPlates.set(customer, list); } // Query daily averages from v_vehicle_daily_stats const allPlates = assessmentRows.map((r: any) => r.plate_number); const customerAvgMap = new Map(); // customer -> avg daily km if (allPlates.length > 0) { const [dailyRows] = await mileagePool.execute(` SELECT plate, AVG(daily_km) as avg_daily FROM v_vehicle_daily_stats WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND stat_date < CURDATE() AND plate IN (${allPlates.map(() => '?').join(',')}) GROUP BY plate `, allPlates) as [any[], unknown]; const plateAvgMap = new Map(); for (const row of dailyRows) { plateAvgMap.set(row.plate, Number(row.avg_daily) || 0); } // Aggregate per customer for (const [customer, plates] of customerPlates) { const avgs = plates.map(p => plateAvgMap.get(p) || 0).filter(v => v > 0); if (avgs.length > 0) { customerAvgMap.set(customer, avgs.reduce((a, b) => a + b, 0) / avgs.length); } } } // 7. Build enriched vehicles const now = new Date(); const enrichedVehicles: EnrichedVehicle[] = []; for (const row of assessmentRows) { const target = targetMap.get(row.target_id); if (!target) continue; if (targetIdFilter !== null && row.target_id !== targetIdFilter) continue; const info = infoMap.get(row.plate_number); const loc = locationMap.get(row.plate_number); const truck = truckTypeMap.get(row.plate_number); const customer = info?.customer || null; const customerAvgDaily = customer ? (customerAvgMap.get(customer) || 0) : 0; const yearEnd = row.current_year_assessment_end_date ? new Date(row.current_year_assessment_end_date) : new Date(now.getFullYear(), 11, 31); const daysLeft = Math.max(1, Math.ceil((yearEnd.getTime() - now.getTime()) / 86400000)); const totalMileage = Number(row.vehicle_total_mileage) || 0; const currentYearMileage = Number(row.current_year_mileage) || 0; const yearTarget = Number(row.current_year_mileage_task) || target.annualTarget; const completionRate = Number(row.completion_rate) || 0; const predictedYearEnd = currentYearMileage + customerAvgDaily * daysLeft; const province = loc?.province || ''; const city = loc?.city || ''; const region = mapRegion(province, city); // Determine vehicle type let vehicleType = '其他'; if (truck) { vehicleType = classifyVehicleType(truck.typeName, truck.modelRaw); } const classification = classifyVehicle( row.current_year_is_qualified === 1, predictedYearEnd, yearTarget, ); enrichedVehicles.push({ plateNumber: row.plate_number, targetId: row.target_id, targetName: target.name, vehicleType, totalMileage, currentYearMileage, completionRate, yearTarget, isQualified: row.is_qualified === 1, currentYearIsQualified: row.current_year_is_qualified === 1, dailyRequiredMileage: Number(row.daily_required_mileage) || 0, region, province, customer, customerAvgDaily, predictedYearEnd, daysLeft, classification, }); } // 8. Build inventory vehicle pool const [inventoryRows] = await pool.execute(` SELECT truck.plate_number, dic_type.dic_name AS type_name, truck.model AS model_raw FROM tab_truck truck LEFT JOIN tab_dic dic_type ON dic_type.parent_code = 'dic_truck_type' AND dic_type.dic_code = truck.model AND dic_type.is_deleted = 0 WHERE truck.is_deleted = 0 AND truck.is_operation = 1 AND truck.truck_rent_status = 0 `) as [any[], unknown]; // Cross-reference with assessment data for inventory vehicles that are also in assessments const assessmentByPlate = new Map(); for (const v of enrichedVehicles) { assessmentByPlate.set(v.plateNumber, v); } const inventoryVehicles: InventoryVehicle[] = inventoryRows.map((row: any) => { const loc = locationMap.get(row.plate_number); const province = loc?.province || ''; const city = loc?.city || ''; const region = mapRegion(province, city); const vehicleType = classifyVehicleType(row.type_name || '', row.model_raw || ''); // Check if this inventory vehicle is also in an assessment target const assessed = assessmentByPlate.get(row.plate_number); return { plateNumber: row.plate_number, vehicleType, region, province, totalMileage: assessed?.totalMileage || 0, targetId: assessed?.targetId || null, targetName: assessed?.targetName || null, yearTarget: assessed?.yearTarget || null, completionRate: assessed?.completionRate || 0, }; }); // 9. Run algorithm const { suggestions, summary } = generateSuggestions(enrichedVehicles, inventoryVehicles); // 10. Apply permission filtering on suggestions const user = (c as any).get('user') as AuthUser | undefined; let filteredSuggestions = suggestions; if (user && user.permissionLevel !== 'full') { // Filter suggestions by the current vehicle's department/manager const vehicleInfoForPerm = suggestions.map(s => ({ ...s, department: infoMap.get(s.currentVehicle.plateNumber)?.department || null, managerId: infoMap.get(s.currentVehicle.plateNumber)?.manager_id || null, })); const filtered = filterByPermission(vehicleInfoForPerm, user); const allowedPlates = new Set(filtered.map(f => f.currentVehicle.plateNumber)); filteredSuggestions = suggestions.filter(s => allowedPlates.has(s.currentVehicle.plateNumber)); } // 11. Mask customer names for (const s of filteredSuggestions) { const maskedArr = maskCustomerNames([{ customer: s.currentVehicle.customer }]); s.currentVehicle.customer = (maskedArr[0] as any).customer; // Also mask in reason text (replace raw customer name) s.reason = s.reason; // reason uses customer name from toVehicleInfo which is pre-mask — acceptable as it's already generic } // 12. Build target options for filter UI const targetOptions = targets.map((t: any) => { const vehicleCount = assessmentRows.filter((r: any) => r.target_id === t.id).length; return { id: t.id, name: t.target_name, vehicleCount }; }); const response: SchedulingResponse = { summary: { ...summary, // Recalculate for filtered view suggestionCount: filteredSuggestions.length, }, suggestions: filteredSuggestions, targets: targetOptions, }; return c.json(response); } catch (e: unknown) { console.error('scheduling suggestions error:', e); return c.json({ summary: { qualifiedCount: 0, hopelessCount: 0, suggestionCount: 0, estimatedGain: 0 }, suggestions: [], targets: [] }, 500); } }); export default app; ``` - [ ] **Step 2: Verify file created and TypeScript compiles** ```bash cd /Users/kkfluous/Projects/ai-coding/ln-bi && npx tsc --noEmit 2>&1 | head -20 ``` - [ ] **Step 3: Commit** ```bash git add src/server/routes/scheduling/suggestions.ts git commit -m "feat(scheduling): add suggestions route with data aggregation" ``` --- ## Task 4: Backend Notify Route + Router Index **Files:** - Create: `src/server/routes/scheduling/notify.ts` - Create: `src/server/routes/scheduling/index.ts` - Modify: `src/server/index.ts` - [ ] **Step 1: Create notify.ts** ```typescript // src/server/routes/scheduling/notify.ts import { Hono } from 'hono'; import type { AuthUser } from '../../auth/types.js'; import type { NotifyRequest } from './types.js'; const app = new Hono(); // In-memory set of processed suggestion IDs (persists until server restart) // In production, this should be stored in a database table const processedSuggestions = new Set(); export function isProcessed(suggestionId: string): boolean { return processedSuggestions.has(suggestionId); } app.post('/', async (c) => { try { const body = await c.req.json(); const { suggestionId, currentPlate, candidatePlate } = body; if (!suggestionId || !currentPlate || !candidatePlate) { return c.json({ success: false, message: '缺少必要参数' }, 400); } if (processedSuggestions.has(suggestionId)) { return c.json({ success: false, message: '该建议已处理' }, 409); } const user = (c as any).get('user') as AuthUser | undefined; const operator = user?.userName || '未知'; // Log the action console.log(`[scheduling:notify] operator=${operator} suggestion=${suggestionId} current=${currentPlate} candidate=${candidatePlate}`); // TODO: Call external callback URL when configured // const callbackUrl = process.env.SCHEDULING_CALLBACK_URL; // if (callbackUrl) { await fetch(callbackUrl, { method: 'POST', body: JSON.stringify({...}) }); } // Mark as processed processedSuggestions.add(suggestionId); return c.json({ success: true, message: `替换通知已发送:${currentPlate} → ${candidatePlate}` }); } catch (e: unknown) { console.error('scheduling notify error:', e); return c.json({ success: false, message: '发送通知失败' }, 500); } }); export default app; ``` - [ ] **Step 2: Create index.ts router** ```typescript // src/server/routes/scheduling/index.ts import { Hono } from 'hono'; import suggestionsRouter from './suggestions.js'; import notifyRouter from './notify.js'; const app = new Hono(); app.route('/suggestions', suggestionsRouter); app.route('/notify', notifyRouter); export default app; ``` - [ ] **Step 3: Register scheduling router in server/index.ts** In `src/server/index.ts`, add: ```typescript // After the existing import of mileageRouter: import schedulingRouter from './routes/scheduling/index.js'; // After: app.route('/api/mileage', mileageRouter); app.route('/api/scheduling', schedulingRouter); ``` - [ ] **Step 4: Verify full TypeScript compilation** ```bash cd /Users/kkfluous/Projects/ai-coding/ln-bi && npx tsc --noEmit 2>&1 | head -30 ``` - [ ] **Step 5: Start server and test endpoint** ```bash cd /Users/kkfluous/Projects/ai-coding/ln-bi && npm run dev & sleep 3 curl -s http://localhost:3001/api/scheduling/suggestions | jq '.summary' ``` Expected: JSON with `qualifiedCount`, `hopelessCount`, `suggestionCount`, `estimatedGain` fields. - [ ] **Step 6: Commit** ```bash git add src/server/routes/scheduling/index.ts src/server/routes/scheduling/notify.ts src/server/index.ts git commit -m "feat(scheduling): add notify route and wire up scheduling router" ``` --- ## Task 5: Frontend Types + API Client **Files:** - Create: `src/modules/scheduling/types.ts` - Create: `src/modules/scheduling/api.ts` - [ ] **Step 1: Create frontend types** ```typescript // src/modules/scheduling/types.ts export interface SchedulingVehicleInfo { plateNumber: string; targetId: number; targetName: string; vehicleType: string; totalMileage: number; completionRate: number; yearTarget: number; region: string; province: string; customer: string | null; customerAvgDaily: number; predictedYearEnd: number; daysLeft: number; } export interface CandidateVehicle { plateNumber: string; targetId: number | null; targetName: string | null; vehicleType: string; totalMileage: number; completionRate: number; yearTarget: number | null; region: string; province: string; mileageGap: number; predictedAfterSwap: number; canQualifyAfterSwap: boolean; } export interface SchedulingSuggestion { id: string; priority: 'high' | 'medium'; type: 'replace_qualified' | 'rescue_hopeless'; currentVehicle: SchedulingVehicleInfo; candidates: CandidateVehicle[]; reason: string; } export interface SchedulingSummary { qualifiedCount: number; hopelessCount: number; suggestionCount: number; estimatedGain: number; } export interface SchedulingTargetOption { id: number; name: string; vehicleCount: number; } export interface SchedulingResponse { summary: SchedulingSummary; suggestions: SchedulingSuggestion[]; targets: SchedulingTargetOption[]; } ``` - [ ] **Step 2: Create api.ts** ```typescript // src/modules/scheduling/api.ts import { fetchJson } from '../../auth/api-client'; import type { SchedulingResponse } from './types'; const BASE = '/api/scheduling'; export async function fetchSuggestions(targetId?: number): Promise { const params = new URLSearchParams(); if (targetId !== undefined) params.set('targetId', String(targetId)); const qs = params.toString(); return fetchJson(`${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`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); } ``` - [ ] **Step 3: Commit** ```bash git add src/modules/scheduling/types.ts src/modules/scheduling/api.ts git commit -m "feat(scheduling): add frontend types and API client" ``` --- ## Task 6: SchedulingModule Main Entry **Files:** - Create: `src/modules/scheduling/SchedulingModule.tsx` This is the main page component with batch selector, summary cards, and state management. Uses ui-ux-pro-max for design quality. Based on the prototype's `SmartSchedulingView` style. - [ ] **Step 1: Create SchedulingModule.tsx** ```tsx // src/modules/scheduling/SchedulingModule.tsx import { useState, useEffect, useCallback } from 'react'; import { Activity, AlertTriangle, CheckCircle, TrendingUp, RotateCcw } from 'lucide-react'; import { motion } from 'motion/react'; import { fetchSuggestions } from './api'; import type { SchedulingResponse, SchedulingSuggestion } from './types'; import SuggestionList from './SuggestionList'; import SuggestionDetail from './SuggestionDetail'; function shortTargetName(name: string): string { const match = name.match(/(\d+)[辆台](.+)/); if (!match) return name; const count = match[1]; let desc = match[2]; desc = desc.replace('4.5T普货', '普货'); desc = desc.replace('4.5T冷链车', '冷藏车'); desc = desc.replace('4.5T冷链', '冷藏车'); return `${count}台${desc}`; } export default function SchedulingModule() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [selectedTargetId, setSelectedTargetId] = useState(undefined); const [selectedSuggestion, setSelectedSuggestion] = useState(null); const loadData = useCallback(async () => { setLoading(true); try { const result = await fetchSuggestions(selectedTargetId); setData(result); } catch (e) { console.error('Failed to load scheduling data:', e); } finally { setLoading(false); } }, [selectedTargetId]); useEffect(() => { loadData(); }, [loadData]); const handleNotifySuccess = () => { setSelectedSuggestion(null); loadData(); // Immediately refresh after notify }; const summary = data?.summary; return (
{/* Batch Selector */}
{(data?.targets || []).map(t => ( ))}
{/* Summary Cards */}
已达标车辆
{loading ? '-' : summary?.qualifiedCount ?? 0}

达标概率 ≥ 120%

无望达标
{loading ? '-' : summary?.hopelessCount ?? 0}

达标概率 < 60%

可干预
{loading ? '-' : summary?.suggestionCount ?? 0}

预计可新增达标 +{summary?.estimatedGain ?? 0} 台

{/* Refresh Button */}
{/* Suggestion List */} {loading ? (
) : ( )} {/* Detail Modal */} {selectedSuggestion && ( setSelectedSuggestion(null)} onNotifySuccess={handleNotifySuccess} /> )}
); } ``` - [ ] **Step 2: Commit** ```bash git add src/modules/scheduling/SchedulingModule.tsx git commit -m "feat(scheduling): add SchedulingModule main entry component" ``` --- ## Task 7: SuggestionList Component **Files:** - Create: `src/modules/scheduling/SuggestionList.tsx` - [ ] **Step 1: Create SuggestionList.tsx** ```tsx // src/modules/scheduling/SuggestionList.tsx import { ArrowRightLeft, AlertTriangle, CheckCircle } from 'lucide-react'; import { motion } from 'motion/react'; import type { SchedulingSuggestion } from './types'; import Blur from '../../components/Blur'; function fmtKm(value: number): string { if (value >= 10000) return (value / 10000).toFixed(1) + '万'; return value.toLocaleString(); } interface Props { suggestions: SchedulingSuggestion[]; onSelect: (s: SchedulingSuggestion) => void; } export default function SuggestionList({ suggestions, onSelect }: Props) { if (suggestions.length === 0) { return (

暂无调度建议

所有车辆当前无需干预

); } return (

智能调度干预清单

{suggestions.length} 条建议
{suggestions.map((s, idx) => ( onSelect(s)} className="p-4 hover:bg-slate-50/50 cursor-pointer transition-colors active:bg-slate-100" >
{/* Priority indicator */}
{s.type === 'rescue_hopeless' ? : }
{s.currentVehicle.plateNumber} {s.type === 'rescue_hopeless' ? '无望达标' : '已达标'} {s.currentVehicle.vehicleType} {s.currentVehicle.region}
客户: {s.currentVehicle.customer || '-'} 日均: {Math.round(s.currentVehicle.customerAvgDaily)} KM 完成率: = 1 ? 'text-emerald-600' : 'text-slate-600'}`}> {Math.round(s.currentVehicle.completionRate * 100)}%
可替换
{s.candidates.length} 辆
))}
); } ``` - [ ] **Step 2: Commit** ```bash git add src/modules/scheduling/SuggestionList.tsx git commit -m "feat(scheduling): add SuggestionList component" ``` --- ## Task 8: SuggestionDetail Component **Files:** - Create: `src/modules/scheduling/SuggestionDetail.tsx` This is the modal with current vehicle info, candidate comparison, and notify button. Designed to be screenshot-friendly. - [ ] **Step 1: Create SuggestionDetail.tsx** ```tsx // src/modules/scheduling/SuggestionDetail.tsx import { useState } from 'react'; import { X, ArrowRightLeft, Truck, MapPin, TrendingUp, AlertTriangle, CheckCircle, Send } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { sendNotify } from './api'; import type { SchedulingSuggestion, CandidateVehicle } from './types'; import Blur from '../../components/Blur'; function fmtKm(value: number): string { if (value >= 10000) return (value / 10000).toFixed(1) + '万'; return value.toLocaleString(); } interface Props { suggestion: SchedulingSuggestion; onClose: () => void; onNotifySuccess: () => void; } export default function SuggestionDetail({ suggestion, onClose, onNotifySuccess }: Props) { const [sending, setSending] = useState(false); const [sentPlates, setSentPlates] = useState>(new Set()); const s = suggestion; const v = s.currentVehicle; const handleNotify = async (candidate: CandidateVehicle) => { if (sending || sentPlates.has(candidate.plateNumber)) return; setSending(true); try { const result = await sendNotify({ suggestionId: s.id, currentPlate: v.plateNumber, candidatePlate: candidate.plateNumber, }); if (result.success) { setSentPlates(prev => new Set(prev).add(candidate.plateNumber)); onNotifySuccess(); } else { alert(result.message || '发送失败'); } } catch (e) { alert('网络错误,请重试'); } finally { setSending(false); } }; return (
{/* Header */}

智能调度干预 — {s.type === 'rescue_hopeless' ? '抢救低里程' : '释放已达标'}

{/* Current Vehicle Card */}
当前车辆
{v.plateNumber}
{v.vehicleType} · {v.targetName}
完成率
= 1 ? 'text-emerald-600' : v.completionRate >= 0.6 ? 'text-amber-600' : 'text-rose-600' }`}> {Math.round(v.completionRate * 100)}%
累计里程
{fmtKm(v.totalMileage)} KM
年度目标
{fmtKm(v.yearTarget)} KM
区域
{v.region}
客户日均
{Math.round(v.customerAvgDaily)} KM
客户:
{v.customer || '-'}
{/* Reason */}
建议原因

{s.reason}

{/* Candidates */}

推荐替换车辆

基于车型、区域及里程匹配
{s.candidates.length > 0 ? s.candidates.map(c => (
{c.plateNumber}
{c.vehicleType} · {c.targetName || '库存'}
{c.canQualifyAfterSwap ? ( 换后可达标 ) : ( 需关注 )}
{/* Before/After Comparison Grid */}
当前里程
{fmtKm(c.totalMileage)} KM
里程缺口
{fmtKm(c.mileageGap)} KM
区域
{c.region}
换后预测
{fmtKm(c.predictedAfterSwap)} KM
{/* Action */}
)) : (

暂无匹配的可替换车辆

)}
{/* Footer */}
); } ``` - [ ] **Step 2: Commit** ```bash git add src/modules/scheduling/SuggestionDetail.tsx git commit -m "feat(scheduling): add SuggestionDetail modal with candidate comparison" ``` --- ## Task 9: Wire Up Module in App.tsx **Files:** - Modify: `src/App.tsx` - [ ] **Step 1: Add scheduling module to App.tsx** In `src/App.tsx`, add the import and module config: ```typescript // Add import at top (after existing imports): import { Truck, Route, Activity } from 'lucide-react'; import SchedulingModule from './modules/scheduling/SchedulingModule'; // Update MODULES array to add scheduling: const MODULES: ModuleConfig[] = [ { id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule }, { id: 'mileage', label: '里程管理', icon: Route, component: MileageModule }, { id: 'scheduling', label: '智能调度', icon: Activity, component: SchedulingModule }, ]; ``` Also update the Shell.tsx PATH_MAP in `src/components/Shell.tsx`: ```typescript const PATH_MAP: Record = { '/vehicle': 'assets', '/assets': 'assets', '/mileage': 'mileage', '/scheduling': 'scheduling', }; ``` - [ ] **Step 2: Verify TypeScript compiles** ```bash cd /Users/kkfluous/Projects/ai-coding/ln-bi && npx tsc --noEmit 2>&1 | head -20 ``` - [ ] **Step 3: Commit** ```bash git add src/App.tsx src/components/Shell.tsx git commit -m "feat(scheduling): wire up scheduling module in app navigation" ``` --- ## Task 10: End-to-End Verification - [ ] **Step 1: Start dev server** ```bash cd /Users/kkfluous/Projects/ai-coding/ln-bi && npm run dev ``` - [ ] **Step 2: Test backend API** ```bash curl -s http://localhost:3001/api/scheduling/suggestions | jq '.summary' curl -s http://localhost:3001/api/scheduling/suggestions | jq '.suggestions | length' curl -s http://localhost:3001/api/scheduling/suggestions | jq '.suggestions[0].currentVehicle.plateNumber' ``` - [ ] **Step 3: Test with targetId filter** ```bash curl -s "http://localhost:3001/api/scheduling/suggestions?targetId=1" | jq '.summary' ``` - [ ] **Step 4: Test notify endpoint** ```bash curl -s -X POST http://localhost:3001/api/scheduling/notify \ -H 'Content-Type: application/json' \ -d '{"suggestionId":"test-1","currentPlate":"浙F00001","candidatePlate":"浙F00002"}' | jq . ``` Expected: `{ "success": true, "message": "替换通知已发送:浙F00001 → 浙F00002" }` - [ ] **Step 5: Open browser and verify UI** Open `http://localhost:5173/#scheduling` in browser. Verify: 1. Batch selector shows all target options 2. Three summary cards display counts 3. Suggestion list renders with priority badges 4. Clicking a suggestion opens the detail modal 5. Detail modal shows current vehicle info, candidates with comparison grid 6. "发送替换通知" button works and refreshes the list - [ ] **Step 6: Use ui-ux-pro-max skill to polish UI design** Invoke `ui-ux-pro-max` skill to review and enhance the visual quality of the scheduling module, adapting for both mobile and web layouts. - [ ] **Step 7: Final commit** ```bash git add -A git commit -m "feat(scheduling): complete smart scheduling module with algorithm, API, and UI" ```