diff --git a/docs/superpowers/plans/2026-04-16-smart-scheduling.md b/docs/superpowers/plans/2026-04-16-smart-scheduling.md new file mode 100644 index 0000000..3d53614 --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-smart-scheduling.md @@ -0,0 +1,1615 @@ +# 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" +```