Files
ln-bi/docs/superpowers/plans/2026-04-16-smart-scheduling.md
kkfluous 32b297c731 docs: 智能调度模块实现计划
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:17:08 +08:00

57 KiB
Raw Blame History

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

// 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
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:

// 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
// 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<string>();

  // --- 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
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
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
// 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<number, { name: string; annualTarget: number }>();
    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<string, { typeName: string; modelRaw: string }>();
    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<string, { province: string; city: string }>();
    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<string, string[]>(); // 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<string, number>(); // 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<string, number>();
      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<string, EnrichedVehicle>();
    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
cd /Users/kkfluous/Projects/ai-coding/ln-bi && npx tsc --noEmit 2>&1 | head -20
  • Step 3: Commit
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

// 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<string>();

export function isProcessed(suggestionId: string): boolean {
  return processedSuggestions.has(suggestionId);
}

app.post('/', async (c) => {
  try {
    const body = await c.req.json<NotifyRequest>();
    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
// 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:

// 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
cd /Users/kkfluous/Projects/ai-coding/ln-bi && npx tsc --noEmit 2>&1 | head -30
  • Step 5: Start server and test endpoint
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
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

// 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
// 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<SchedulingResponse> {
  const params = new URLSearchParams();
  if (targetId !== undefined) params.set('targetId', String(targetId));
  const qs = params.toString();
  return fetchJson<SchedulingResponse>(`${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
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
// 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<SchedulingResponse | null>(null);
  const [loading, setLoading] = useState(true);
  const [selectedTargetId, setSelectedTargetId] = useState<number | undefined>(undefined);
  const [selectedSuggestion, setSelectedSuggestion] = useState<SchedulingSuggestion | null>(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 (
    <div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6" style={{ overflowX: 'clip' }}>
      <div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 md:pb-0">

        {/* Batch Selector */}
        <div className="bg-white p-2 rounded-2xl shadow-sm border border-slate-100 flex gap-1 overflow-x-auto no-scrollbar flex-shrink-0">
          <button
            onClick={() => setSelectedTargetId(undefined)}
            className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
              selectedTargetId === undefined
                ? 'bg-blue-600 text-white shadow-md shadow-blue-200'
                : 'bg-slate-50 text-slate-500 hover:bg-slate-100'
            }`}
          >
            全部批次
          </button>
          {(data?.targets || []).map(t => (
            <button
              key={t.id}
              onClick={() => setSelectedTargetId(t.id)}
              className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
                selectedTargetId === t.id
                  ? 'bg-blue-600 text-white shadow-md shadow-blue-200'
                  : 'bg-slate-50 text-slate-500 hover:bg-slate-100'
              }`}
            >
              {shortTargetName(t.name)}
            </button>
          ))}
        </div>

        {/* Summary Cards */}
        <div className="grid grid-cols-3 gap-3">
          <div className="bg-emerald-50 border border-emerald-100 p-4 rounded-2xl">
            <div className="flex items-center gap-1.5 mb-1">
              <CheckCircle size={12} className="text-emerald-500" />
              <span className="text-[10px] font-bold text-emerald-600 uppercase">已达标车辆</span>
            </div>
            <div className="text-2xl font-black text-emerald-700">
              {loading ? '-' : summary?.qualifiedCount ?? 0}
              <span className="text-xs font-normal ml-1"></span>
            </div>
            <p className="text-[10px] text-emerald-500 mt-1">达标概率  120%</p>
          </div>
          <div className="bg-rose-50 border border-rose-100 p-4 rounded-2xl">
            <div className="flex items-center gap-1.5 mb-1">
              <AlertTriangle size={12} className="text-rose-500" />
              <span className="text-[10px] font-bold text-rose-600 uppercase">无望达标</span>
            </div>
            <div className="text-2xl font-black text-rose-700">
              {loading ? '-' : summary?.hopelessCount ?? 0}
              <span className="text-xs font-normal ml-1"></span>
            </div>
            <p className="text-[10px] text-rose-500 mt-1">达标概率 &lt; 60%</p>
          </div>
          <div className="bg-blue-50 border border-blue-100 p-4 rounded-2xl">
            <div className="flex items-center gap-1.5 mb-1">
              <TrendingUp size={12} className="text-blue-500" />
              <span className="text-[10px] font-bold text-blue-600 uppercase">可干预</span>
            </div>
            <div className="text-2xl font-black text-blue-700">
              {loading ? '-' : summary?.suggestionCount ?? 0}
              <span className="text-xs font-normal ml-1"></span>
            </div>
            <p className="text-[10px] text-blue-500 mt-1">
              预计可新增达标 +{summary?.estimatedGain ?? 0} 
            </p>
          </div>
        </div>

        {/* Refresh Button */}
        <div className="flex justify-end">
          <button
            onClick={loadData}
            disabled={loading}
            className="flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold text-slate-400 hover:text-blue-600 bg-white border border-slate-100 rounded-lg transition-colors disabled:opacity-50"
          >
            <RotateCcw size={12} className={loading ? 'animate-spin' : ''} />
            刷新
          </button>
        </div>

        {/* Suggestion List */}
        {loading ? (
          <div className="flex items-center justify-center py-16">
            <div className="w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
          </div>
        ) : (
          <SuggestionList
            suggestions={data?.suggestions || []}
            onSelect={setSelectedSuggestion}
          />
        )}

        {/* Detail Modal */}
        {selectedSuggestion && (
          <SuggestionDetail
            suggestion={selectedSuggestion}
            onClose={() => setSelectedSuggestion(null)}
            onNotifySuccess={handleNotifySuccess}
          />
        )}
      </div>
    </div>
  );
}
  • Step 2: Commit
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

// 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 (
      <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-12 text-center">
        <div className="w-12 h-12 bg-slate-50 rounded-full flex items-center justify-center mx-auto mb-3">
          <ArrowRightLeft size={20} className="text-slate-300" />
        </div>
        <p className="text-sm text-slate-400 font-bold">暂无调度建议</p>
        <p className="text-[10px] text-slate-300 mt-1">所有车辆当前无需干预</p>
      </div>
    );
  }

  return (
    <div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
      <div className="p-4 border-b border-slate-50 flex items-center gap-2">
        <div className="w-1 h-4 bg-blue-600 rounded-full" />
        <h3 className="text-sm font-bold text-slate-800">智能调度干预清单</h3>
        <span className="text-[10px] text-slate-400 ml-auto">{suggestions.length} 条建议</span>
      </div>

      <div className="divide-y divide-slate-50">
        {suggestions.map((s, idx) => (
          <motion.div
            key={s.id}
            initial={{ opacity: 0, y: 10 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ delay: idx * 0.03 }}
            onClick={() => onSelect(s)}
            className="p-4 hover:bg-slate-50/50 cursor-pointer transition-colors active:bg-slate-100"
          >
            <div className="flex items-center justify-between">
              <div className="flex items-center gap-3 flex-1 min-w-0">
                {/* Priority indicator */}
                <div className={`w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0 ${
                  s.priority === 'high'
                    ? 'bg-rose-50 text-rose-500'
                    : 'bg-amber-50 text-amber-500'
                }`}>
                  {s.type === 'rescue_hopeless'
                    ? <AlertTriangle size={16} />
                    : <CheckCircle size={16} />
                  }
                </div>

                <div className="flex-1 min-w-0">
                  <div className="flex items-center gap-2 flex-wrap">
                    <span className="text-xs font-black text-slate-900 font-mono">
                      <Blur>{s.currentVehicle.plateNumber}</Blur>
                    </span>
                    <span className={`text-[8px] px-1.5 py-0.5 rounded-full font-bold ${
                      s.type === 'rescue_hopeless'
                        ? 'bg-rose-50 text-rose-600'
                        : 'bg-emerald-50 text-emerald-600'
                    }`}>
                      {s.type === 'rescue_hopeless' ? '无望达标' : '已达标'}
                    </span>
                    <span className="text-[8px] px-1.5 py-0.5 rounded-full bg-slate-50 text-slate-500 font-bold">
                      {s.currentVehicle.vehicleType}
                    </span>
                    <span className="text-[8px] px-1.5 py-0.5 rounded-full bg-slate-50 text-slate-500 font-bold">
                      {s.currentVehicle.region}
                    </span>
                  </div>
                  <div className="flex items-center gap-3 mt-1 text-[10px] text-slate-400">
                    <span>
                      客户: <Blur><span className="text-slate-600 font-bold">{s.currentVehicle.customer || '-'}</span></Blur>
                    </span>
                    <span>
                      日均: <span className="text-slate-600 font-bold">{Math.round(s.currentVehicle.customerAvgDaily)} KM</span>
                    </span>
                    <span>
                      完成率: <span className={`font-bold ${s.currentVehicle.completionRate >= 1 ? 'text-emerald-600' : 'text-slate-600'}`}>
                        {Math.round(s.currentVehicle.completionRate * 100)}%
                      </span>
                    </span>
                  </div>
                </div>
              </div>

              <div className="flex-shrink-0 ml-2 text-right">
                <div className="text-[10px] text-slate-400">可替换</div>
                <div className="text-sm font-black text-blue-600">{s.candidates.length} </div>
              </div>
            </div>
          </motion.div>
        ))}
      </div>
    </div>
  );
}
  • Step 2: Commit
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
// 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<Set<string>>(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 (
    <div className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[60] flex items-center justify-center p-4">
      <motion.div
        initial={{ scale: 0.9, opacity: 0, y: 20 }}
        animate={{ scale: 1, opacity: 1, y: 0 }}
        exit={{ scale: 0.9, opacity: 0, y: 20 }}
        className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden flex flex-col max-h-[90vh]"
      >
        {/* Header */}
        <div className={`p-4 border-b flex justify-between items-center text-white ${
          s.type === 'rescue_hopeless' ? 'bg-rose-600' : 'bg-amber-600'
        }`}>
          <div className="flex items-center gap-2">
            <ArrowRightLeft size={18} />
            <h3 className="font-bold text-sm">
              智能调度干预  {s.type === 'rescue_hopeless' ? '抢救低里程' : '释放已达标'}
            </h3>
          </div>
          <button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full transition-colors">
            <X size={20} />
          </button>
        </div>

        <div className="p-5 overflow-y-auto space-y-5">
          {/* Current Vehicle Card */}
          <div className={`rounded-xl p-4 border ${
            s.type === 'rescue_hopeless' ? 'bg-rose-50 border-rose-100' : 'bg-amber-50 border-amber-100'
          }`}>
            <div className="flex justify-between items-start mb-3">
              <div>
                <div className={`text-[10px] font-bold uppercase mb-1 ${
                  s.type === 'rescue_hopeless' ? 'text-rose-600' : 'text-amber-600'
                }`}>
                  当前车辆
                </div>
                <div className="text-lg font-black text-slate-900">
                  <Blur>{v.plateNumber}</Blur>
                </div>
                <div className="text-xs text-slate-500">{v.vehicleType} · {v.targetName}</div>
              </div>
              <div className="text-right">
                <div className="text-[10px] font-bold text-slate-400 uppercase mb-1">完成率</div>
                <div className={`text-xl font-black ${
                  v.completionRate >= 1 ? 'text-emerald-600' : v.completionRate >= 0.6 ? 'text-amber-600' : 'text-rose-600'
                }`}>
                  {Math.round(v.completionRate * 100)}%
                </div>
              </div>
            </div>
            <div className="grid grid-cols-2 sm:grid-cols-4 gap-3 pt-3 border-t border-slate-200/30">
              <div>
                <div className="text-[9px] text-slate-400 uppercase">累计里程</div>
                <div className="text-xs font-bold text-slate-700">{fmtKm(v.totalMileage)} KM</div>
              </div>
              <div>
                <div className="text-[9px] text-slate-400 uppercase">年度目标</div>
                <div className="text-xs font-bold text-slate-700">{fmtKm(v.yearTarget)} KM</div>
              </div>
              <div>
                <div className="text-[9px] text-slate-400 uppercase">区域</div>
                <div className="text-xs font-bold text-slate-700 flex items-center gap-1">
                  <MapPin size={10} /> {v.region}
                </div>
              </div>
              <div>
                <div className="text-[9px] text-slate-400 uppercase">客户日均</div>
                <div className="text-xs font-bold text-slate-700">{Math.round(v.customerAvgDaily)} KM</div>
              </div>
            </div>
            <div className="mt-3 flex items-center gap-1">
              <div className="text-[9px] text-slate-400 uppercase">客户:</div>
              <div className="text-xs font-bold text-slate-700">
                <Blur>{v.customer || '-'}</Blur>
              </div>
            </div>
          </div>

          {/* Reason */}
          <div className="bg-blue-50 border border-blue-100 rounded-xl p-3">
            <div className="text-[10px] font-bold text-blue-600 mb-1">建议原因</div>
            <p className="text-xs text-blue-800 leading-relaxed">{s.reason}</p>
          </div>

          {/* Candidates */}
          <div>
            <div className="flex items-center justify-between mb-3">
              <h4 className="text-sm font-bold text-slate-900 flex items-center gap-2">
                <Truck size={16} className="text-blue-500" />
                推荐替换车辆
              </h4>
              <span className="text-[10px] text-slate-400">基于车型、区域及里程匹配</span>
            </div>

            <div className="space-y-3">
              {s.candidates.length > 0 ? s.candidates.map(c => (
                <div key={c.plateNumber} className="bg-white border border-slate-100 rounded-xl p-4 hover:border-blue-200 hover:shadow-md transition-all">
                  <div className="flex justify-between items-start">
                    <div className="flex items-center gap-3">
                      <div className="w-10 h-10 bg-blue-50 rounded-full flex items-center justify-center text-blue-500">
                        <Truck size={18} />
                      </div>
                      <div>
                        <div className="text-sm font-black text-slate-900 font-mono">
                          <Blur>{c.plateNumber}</Blur>
                        </div>
                        <div className="text-[10px] text-slate-400">
                          {c.vehicleType} · {c.targetName || '库存'}
                        </div>
                      </div>
                    </div>
                    <div>
                      {c.canQualifyAfterSwap ? (
                        <span className="flex items-center gap-1 text-[9px] font-bold text-emerald-600 bg-emerald-50 px-2 py-1 rounded-full">
                          <CheckCircle size={10} /> 换后可达标
                        </span>
                      ) : (
                        <span className="flex items-center gap-1 text-[9px] font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-full">
                          <AlertTriangle size={10} /> 需关注
                        </span>
                      )}
                    </div>
                  </div>

                  {/* Before/After Comparison Grid */}
                  <div className="mt-3 grid grid-cols-2 sm:grid-cols-4 gap-3">
                    <div>
                      <div className="text-[9px] text-slate-400 uppercase">当前里程</div>
                      <div className="text-xs font-bold text-slate-700">{fmtKm(c.totalMileage)} KM</div>
                    </div>
                    <div>
                      <div className="text-[9px] text-slate-400 uppercase">里程缺口</div>
                      <div className="text-xs font-bold text-rose-600">{fmtKm(c.mileageGap)} KM</div>
                    </div>
                    <div>
                      <div className="text-[9px] text-slate-400 uppercase">区域</div>
                      <div className="text-xs font-bold text-slate-700 flex items-center gap-1">
                        <MapPin size={10} /> {c.region}
                      </div>
                    </div>
                    <div>
                      <div className="text-[9px] text-slate-400 uppercase">换后预测</div>
                      <div className={`text-xs font-bold ${c.canQualifyAfterSwap ? 'text-emerald-600' : 'text-amber-600'}`}>
                        {fmtKm(c.predictedAfterSwap)} KM
                      </div>
                    </div>
                  </div>

                  {/* Action */}
                  <div className="mt-3 flex justify-end">
                    <button
                      onClick={() => handleNotify(c)}
                      disabled={sending || sentPlates.has(c.plateNumber)}
                      className={`flex items-center gap-1.5 text-[10px] font-bold px-4 py-2 rounded-lg transition-all active:scale-95 ${
                        sentPlates.has(c.plateNumber)
                          ? 'bg-slate-100 text-slate-400 cursor-not-allowed'
                          : 'bg-blue-600 text-white hover:bg-blue-700 shadow-sm shadow-blue-200'
                      }`}
                    >
                      <Send size={12} />
                      {sentPlates.has(c.plateNumber) ? '已发送' : '发送替换通知'}
                    </button>
                  </div>
                </div>
              )) : (
                <div className="text-center py-8 bg-slate-50 rounded-xl border border-dashed border-slate-200">
                  <p className="text-xs text-slate-400">暂无匹配的可替换车辆</p>
                </div>
              )}
            </div>
          </div>
        </div>

        {/* Footer */}
        <div className="p-4 bg-slate-50 border-t border-slate-100 flex justify-end">
          <button
            onClick={onClose}
            className="px-6 py-2 text-xs font-bold text-slate-500 hover:text-slate-700 bg-white border border-slate-200 rounded-lg transition-colors"
          >
            关闭
          </button>
        </div>
      </motion.div>
    </div>
  );
}
  • Step 2: Commit
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:

// 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:

const PATH_MAP: Record<string, string> = {
  '/vehicle': 'assets',
  '/assets': 'assets',
  '/mileage': 'mileage',
  '/scheduling': 'scheduling',
};
  • Step 2: Verify TypeScript compiles
cd /Users/kkfluous/Projects/ai-coding/ln-bi && npx tsc --noEmit 2>&1 | head -20
  • Step 3: Commit
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
cd /Users/kkfluous/Projects/ai-coding/ln-bi && npm run dev
  • Step 2: Test backend API
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
curl -s "http://localhost:3001/api/scheduling/suggestions?targetId=1" | jq '.summary'
  • Step 4: Test notify endpoint
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
git add -A
git commit -m "feat(scheduling): complete smart scheduling module with algorithm, API, and UI"