1616 lines
57 KiB
Markdown
1616 lines
57 KiB
Markdown
# 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<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**
|
||
|
||
```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<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**
|
||
|
||
```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<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**
|
||
|
||
```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<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**
|
||
|
||
```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<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">达标概率 < 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**
|
||
|
||
```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 (
|
||
<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**
|
||
|
||
```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<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**
|
||
|
||
```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<string, string> = {
|
||
'/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"
|
||
```
|