Compare commits

..

31 Commits

Author SHA1 Message Date
kkfluous
75f0aca5d1 fix(auth): require jumpToken for access, remove temporary bypass
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Previously: no jumpToken → direct access allowed (临时放行)
Now: no jumpToken → show "请从业务系统跳转访问" unauthorized page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:48:29 +08:00
kkfluous
8598aea445 feat(scheduling): restrict scheduling module to allowed users only
Only userId 1105261382487539712 and 1116631120763437056 can see the
scheduling tab. Other users see only assets + mileage modules.

- Add userId to frontend AuthState.user type
- App.tsx conditionally includes scheduling module based on user ID
- Backend already returns userId in auth exchange response

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:47:57 +08:00
kkfluous
25199b507c refactor(scheduling): simplify SwapPreview layout, remove verbose reason
- Remove type badge, reason section — too verbose
- Two clean white cards connected by arrow (swap diagram)
- Result section: predicted mileage, target, conclusion badge
- Tighter spacing, no redundant labels
- Professional tone, no childish wording

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:45:01 +08:00
kkfluous
6a3a5ba319 feat(scheduling): add full-screen SwapPreview for screenshot sharing
New SwapPreview component replaces direct "发送通知" button:
- Full-screen white background for clean screenshots
- Swap diagram: current vehicle → arrow → replacement vehicle
- Replacement reason section
- Post-swap prediction: predicted mileage, target, conclusion
- "发送替换通知" button at bottom
- Candidate button in detail modal changed to "查看替换方案 →"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:43:18 +08:00
kkfluous
0785c78382 fix(scheduling): only show candidates that can actually qualify after swap
replace_qualified (换下):
- Exclude already-qualified inventory (totalMileage >= yearTarget, gap=0)
- Only keep candidates where canQualifyAfterSwap=true
- Skip suggestions with no qualifiable candidates (e.g., too few days left)
- Reason text now shows customer's remaining capacity: "日均 318km × 53天 ≈ 1.7万km"

Before: showed 粤AGP9738 (缺口 0, already at target) — pointless
After: shows 粤AGQ5808 (缺口 1.7万, 换后 3.0万, 可达标) — meaningful

All replace_qualified candidates now guaranteed canQualifyAfterSwap=true.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:41:08 +08:00
kkfluous
afec75a1cc fix(scheduling): rescue candidates should be close-to-qualifying, not zero-mileage
For rescue_hopeless (换走) scenario, completely rethought candidate logic:

Before: showed biggest-gap candidates (0 mileage) → pointless, customer can't
  drive them to target
After: prioritize candidates where customer's remaining driving can push them
  over the target line (canQualifyAfterSwap), sorted by smallest gap first

Example: customer drives 178km/day × 57 days = ~1万km remaining.
- 粤AGR6869 (缺口 1990km) → 换后 3.8万, 可达标  (shown first)
- 浙FF58720 (缺口 6万km) → 换后 1万, 远不达标 (no longer shown first)

Also updated reason text to explain the math:
"该客户剩余57天还能跑约1万km,足以帮缺口小的车冲线"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:36:53 +08:00
kkfluous
1d1f8901aa fix(scheduling): exclude near-qualified vehicles from rescue candidates
For rescue_hopeless (换走) scenario, filter out inventory candidates
where totalMileage/yearTarget >= 80%. These are already near target
and swapping them in adds no value.

Instead, prioritize candidates with biggest mileage gaps — they benefit
most from accumulating any mileage, even at a low-mileage customer.

Before: showed 粤AGR6869 (93% done, 缺口 1990) as "可达标" — pointless
After:  shows 浙FF58720 (0% done, 缺口 60000) — genuinely needs mileage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:34:05 +08:00
kkfluous
81305be2df feat(scheduling): replace spinner with skeleton loading placeholders
- Full-page skeleton on initial load: card placeholders + list row placeholders
- List skeleton on refresh: 6 rows with pulse animation
- Skeleton blocks match actual layout (color bar, plate, badges, info line)
- Uses Tailwind animate-pulse for smooth loading effect

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:30:23 +08:00
kkfluous
64f47d5ad6 fix(scheduling): truncate long customer names, prevent list item wrap
- Customer name in list items: truncate with max-w-[40%]
- Daily km and completion rate: flex-shrink-0 to stay on same line

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:28:30 +08:00
kkfluous
9398688829 feat(scheduling): add department/manager filters, refine color palette
- Add department and manager fields to backend types and suggestions API
- Add department/manager to advanced filter panel
- Refine card colors: orange (换下) / blue (换走) / dark slate (全部)
- Selected card uses solid bg color, inactive uses gradient
- Batch pills use dark slate, confirm button uses dark slate
- Background changed to #F0F4F8 for subtle cool tone

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:28:04 +08:00
kkfluous
48fa3bc73f refactor(scheduling): rewrite terminology to match core business logic
Core story: 里程高的车换下来,里程少的车换上去。

- Summary cards: "里程高·需换下" / "里程低·需换走" / "替换建议"
- List tags: "换下" (amber) / "换走" (blue) with matching color bars
- Detail modal title: "里程高·换下此车" / "里程低·换走此车"
- Candidate section: explains WHY these vehicles are recommended
  - 换下: "以下车辆里程缺口大,换到该高里程客户处可加速达标"
  - 换走: "以下车辆里程已充足,可调给当前客户,将此车换走给高里程客户冲刺"
- Reason text: states current situation + clear action recommendation
  with specific numbers (已跑, 缺口, 日均, 完成率)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:23:35 +08:00
kkfluous
1a5a1c1514 feat(scheduling): add advanced filter panel matching prototype
- Filter icon in list header with active count badge
- Expandable filter panel: plate search, region select, vehicle type select, customer select
- FilterSelect component with search for long option lists
- Active filter tags shown as removable pills below header
- Temp/confirmed filter pattern (edit → confirm/cancel)
- Result count displayed when filters active

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:18:02 +08:00
kkfluous
73080a605d refactor(scheduling): polish overall color scheme and UX
- Modal header: unified dark slate-800 with directional icon (↓ rescue, ↑ release)
- Modal click-outside to close
- Candidate metrics: table-style with bg-slate-50 + dividers, more scannable
- Send button: dark slate instead of blue (avoids color overload)
- Reason section: warm amber accent
- Consistent font sizing and spacing throughout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:13:12 +08:00
kkfluous
6f7555a407 feat(scheduling): make summary cards clickable filters + refine color scheme
- Cards filter suggestions by type (已达标/无望达标/全部)
- Toggle: click active card again to reset to all
- Default: white bg + gray border; active: colored bg + ring
- Batch selector: dark pills instead of blue
- Refresh button moved into list header
- Reset type filter when switching batch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:12:02 +08:00
kkfluous
bcbeb64e28 fix(scheduling): use current year mileage for consistent data display
- Add currentYearMileage to SchedulingVehicleInfo (backend + frontend)
- Compute completionRate as currentYearMileage/yearTarget (year-based)
  instead of using overall completion_rate from DB
- Display "本年已跑" instead of "累计" in detail modal
- Fix reason text to show year completion rate

Before: 累计 4.6万, 考核 3.0万, 完成率 12.1% (mismatched periods)
After:  本年已跑 8.3万, 考核 3.0万, 完成率 275% (consistent year-based)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:08:29 +08:00
kkfluous
6ee811c937 refactor(scheduling): optimize UI for clarity and information density
- Summary cards: white bg + color border, remove icons, more compact
- SuggestionList: replace badge stacking with compact 2-line layout,
  use color bars for priority, fix completion rate format (0.16 → 16.4%)
- SuggestionDetail: bottom-sheet on mobile, compact inline metrics
  instead of grid cards, reduce vertical space per candidate
- Follows ui-ux-pro-max Data-Dense Dashboard guidelines

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:04:26 +08:00
kkfluous
495f4bf44f feat(scheduling): add 本年考核 field to candidate cards and rename 年度目标 to 本年考核
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:01:01 +08:00
kkfluous
ec3b079311 fix(scheduling): only suggest replacements for rented/operated vehicles
Filter enriched vehicles to only include rent_status = '租赁' or '自营'.
Inventory candidates already filtered by truck_rent_status = 0 (在库).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:52:16 +08:00
kkfluous
033af15814 fix(scheduling): include soft-deleted trucks and infer type from target name
411 of 451 assessment vehicles had is_deleted=1 in tab_truck, causing type
classification to fall back to "其他" and miss all inventory matches. Fix:
- Remove is_deleted=0 filter from truck type query (assessment vehicles need type info regardless)
- Add inferTypeFromTargetName() fallback deriving type from target name when truck record is missing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:47:00 +08:00
kkfluous
253cc2f2c0 fix(scheduling): fix vehicle type classification and algorithm candidate matching
- classifyVehicleType now parses dic_type.dic_name (e.g. "4.5吨冷链车") instead of raw model code
- Remove overly strict completionRate >= 0.8 filter for hopeless candidates
- Use vehicle's yearTarget as fallback when inventory has no assessment target
- Filter out suggestions with no candidates (not actionable)
- estimatedGain counts rescue_hopeless suggestions as potential gains

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:31:44 +08:00
kkfluous
db5ca2e686 feat(scheduling): wire up scheduling module in app navigation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:26:53 +08:00
kkfluous
2e82a30893 feat(scheduling): add SuggestionDetail modal with candidate comparison
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 20:25:58 +08:00
kkfluous
9c005bebc8 feat(scheduling): add SuggestionList component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 20:25:21 +08:00
kkfluous
82ee7f5480 feat(scheduling): add SchedulingModule main entry component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 20:25:13 +08:00
kkfluous
4169e04a9c feat(scheduling): add suggestions route with data aggregation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:23:56 +08:00
kkfluous
86d5bc8738 feat(scheduling): add notify route and wire up scheduling router
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 20:23:29 +08:00
kkfluous
460c9906e1 feat(scheduling): add algorithm pure functions and export mapRegion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:21:50 +08:00
kkfluous
569b5ea349 feat(scheduling): add frontend types and API client
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 20:20:23 +08:00
kkfluous
ebe46c6f73 feat(scheduling): add backend type definitions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 20:20:18 +08:00
kkfluous
32b297c731 docs: 智能调度模块实现计划
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:17:08 +08:00
kkfluous
9bf9bdd8ff docs: 智能调度模块设计规格
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:11:51 +08:00
29 changed files with 5560 additions and 12 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -5,10 +5,10 @@ services:
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0 image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
network_mode: host network_mode: host
environment: environment:
DB_HOST: "192.168.130.111" DB_HOST: "47.101.148.99"
DB_PORT: "3306" DB_PORT: "3306"
DB_USER: "linsset_01" DB_USER: "root"
DB_PASSWORD: "LN3456#&" DB_PASSWORD: "LN#Passw0rd@2026"
DB_NAME: "lingniu_prod" DB_NAME: "lingniu_prod"
SERVER_PORT: "8111" SERVER_PORT: "8111"
EXTERNAL_API_BASE: "https://lnh2e.com" EXTERNAL_API_BASE: "https://lnh2e.com"

BIN
docs/.DS_Store vendored Normal file

Binary file not shown.

BIN
docs/superpowers/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,753 @@
# Three Operations Modules 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:** Add department, region, and customer operations statistics sections to the dashboard, ported from lnoneos prototype with real MySQL data.
**Architecture:** All 3 new API endpoints aggregate from the existing `getVehicles()` cache (no new DB queries). A new macro-region mapping function converts province/city to 华东/华南/etc. The frontend adds 3 new collapsible sections below the existing asset summary table, each with desktop table + mobile card views. The existing vehicle list modal is extended with new filter params (manager, customer, isColdChain, isTrailer).
**Tech Stack:** Hono (backend), React + Tailwind CSS + Motion (frontend), TypeScript throughout.
**Reference:** lnoneos prototype at `/Users/kkfluous/Projects/ai-coding/lnoneos/src/App.tsx`
---
### Task 1: Backend — Macro-region mapping + vehicle type classification helpers
**Files:**
- Modify: `src/server/routes/vehicles.ts` (add functions after line ~111)
- [ ] **Step 1: Add macro-region mapping function**
Add after `mapInventoryRegion` (line 111) in `src/server/routes/vehicles.ts`:
```typescript
// Macro-region mapping: province/city -> 华东/华南/华北/华中/西南/西北/其他
function mapMacroRegion(province: string | null, city: string | null): string {
const prov = (province || '').trim();
const c = (city || '').trim();
const loc = prov + c;
// 华东: 上海/江苏/浙江/安徽/福建/江西/山东
if (/上海|江苏|浙江|安徽|福建|江西|山东|南京|杭州|合肥|济南|青岛|苏州|宁波|厦门|嘉兴|无锡/.test(loc)) return '华东';
// 华南: 广东/广西/海南
if (/广东|广西|海南|广州|深圳|佛山|东莞|珠海|惠州|中山|南宁/.test(loc)) return '华南';
// 华北: 北京/天津/河北/山西/内蒙古
if (/北京|天津|河北|山西|内蒙古|石家庄|太原|呼和浩特/.test(loc)) return '华北';
// 华中: 河南/湖北/湖南
if (/河南|湖北|湖南|郑州|武汉|长沙/.test(loc)) return '华中';
// 西南: 重庆/四川/贵州/云南/西藏
if (/重庆|四川|贵州|云南|西藏|成都|昆明|贵阳/.test(loc)) return '西南';
// 西北: 陕西/甘肃/青海/宁夏/新疆
if (/陕西|甘肃|青海|宁夏|新疆|西安|兰州|乌鲁木齐/.test(loc)) return '西北';
return '其他';
}
// Vehicle type classification for per-type counts
type VehicleTypeCounts = { t4_5: number; t4_5c: number; t18: number; t49: number; trailer: number; other: number; total: number };
function classifyVehicleType(v: Vehicle): keyof Omit<VehicleTypeCounts, 'total'> {
if (v.type === '4.5T' && !v.model.includes('冷链')) return 't4_5';
if (v.type === '4.5T' && v.model.includes('冷链')) return 't4_5c';
if (v.type === '18T') return 't18';
if (v.type === '49T') return 't49';
if (v.type === '挂车' || v.model.includes('挂车')) return 'trailer';
return 'other';
}
function countByType(vehicles: Vehicle[]): VehicleTypeCounts {
const counts: VehicleTypeCounts = { t4_5: 0, t4_5c: 0, t18: 0, t49: 0, trailer: 0, other: 0, total: 0 };
for (const v of vehicles) {
counts[classifyVehicleType(v)]++;
counts.total++;
}
return counts;
}
```
- [ ] **Step 2: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: no errors
- [ ] **Step 3: Commit**
```bash
git add src/server/routes/vehicles.ts
git commit -m "feat: add macro-region mapping and vehicle type classification helpers"
```
---
### Task 2: Backend — Three new API endpoints
**Files:**
- Modify: `src/server/routes/vehicles.ts` (add endpoints before the `/list` endpoint)
- [ ] **Step 1: Add `/dept-stats` endpoint**
Add before the `VEHICLE_TYPE_FILTERS` const (which is before `/list`) in `src/server/routes/vehicles.ts`:
```typescript
// GET /api/vehicles/dept-stats
app.get('/dept-stats', async (c) => {
const vehicles = await getVehicles();
// Only count operating vehicles for department stats (those with a customerManager)
const withManager = vehicles.filter((v) => v.customerManager);
const deptMap = new Map<string, Map<string, Vehicle[]>>();
for (const v of withManager) {
const dept = v.departmentName || '未分配';
const mgr = v.customerManager!;
if (!deptMap.has(dept)) deptMap.set(dept, new Map());
const mgrMap = deptMap.get(dept)!;
if (!mgrMap.has(mgr)) mgrMap.set(mgr, []);
mgrMap.get(mgr)!.push(v);
}
const result = Array.from(deptMap.entries()).map(([department, mgrMap]) => {
const allDeptVehicles = Array.from(mgrMap.values()).flat();
const managers = Array.from(mgrMap.entries())
.map(([manager, mvs]) => ({
manager,
department,
...countByType(mvs),
}))
.sort((a, b) => b.total - a.total);
return {
department,
totalAssets: allDeptVehicles.length,
operatingCount: allDeptVehicles.filter((v) => v.status === 'Operating').length,
idleCount: allDeptVehicles.filter((v) => v.status !== 'Operating').length,
managers,
};
}).sort((a, b) => b.totalAssets - a.totalAssets);
return c.json(result);
});
```
- [ ] **Step 2: Add `/region-stats` endpoint**
```typescript
// GET /api/vehicles/region-stats
app.get('/region-stats', async (c) => {
const vehicles = await getVehicles();
const operating = vehicles.filter((v) => v.status === 'Operating');
const regionMap = new Map<string, Vehicle[]>();
for (const v of operating) {
const region = mapMacroRegion(v.province, v.city);
if (!regionMap.has(region)) regionMap.set(region, []);
regionMap.get(region)!.push(v);
}
const regionOrder = ['华东', '华南', '华北', '华中', '西南', '西北', '其他'];
const result = regionOrder
.filter((r) => regionMap.has(r))
.map((region) => {
const rv = regionMap.get(region)!;
const customers = Array.from(new Set(rv.map((v) => v.customerName).filter(Boolean))) as string[];
const typeBreakdown = ['4.5T', '18T', '49T'].map((type) => {
const typeVehicles = rv.filter((v) => v.type === type);
return {
type,
total: typeVehicles.length,
operating: typeVehicles.filter((v) => v.status === 'Operating').length,
inventory: typeVehicles.filter((v) => v.status === 'Inventory').length,
customers: Array.from(new Set(typeVehicles.map((v) => v.customerName).filter(Boolean))) as string[],
};
}).filter((t) => t.total > 0);
return {
region,
totalAssets: rv.length,
operatingCount: rv.filter((v) => v.status === 'Operating').length,
inventoryCount: rv.filter((v) => v.status === 'Inventory').length,
customers,
typeBreakdown,
};
});
return c.json(result);
});
```
- [ ] **Step 3: Add `/customer-stats` endpoint**
```typescript
// GET /api/vehicles/customer-stats
app.get('/customer-stats', async (c) => {
const vehicles = await getVehicles();
const operating = vehicles.filter((v) => v.status === 'Operating' && v.customerName);
const custMap = new Map<string, Vehicle[]>();
for (const v of operating) {
const cust = v.customerName!;
if (!custMap.has(cust)) custMap.set(cust, []);
custMap.get(cust)!.push(v);
}
const result = Array.from(custMap.entries())
.map(([customer, cvs]) => {
const first = cvs[0];
return {
customer,
manager: first.customerManager || '',
brand: first.brandLabel || '',
department: first.departmentName || '',
region: mapMacroRegion(first.province, first.city),
city: first.city || '',
...countByType(cvs),
};
})
.sort((a, b) => b.total - a.total);
return c.json(result);
});
```
- [ ] **Step 4: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: no errors
- [ ] **Step 5: Commit**
```bash
git add src/server/routes/vehicles.ts
git commit -m "feat: add dept-stats, region-stats, customer-stats API endpoints"
```
---
### Task 3: Backend — Extend `/list` with new filter params
**Files:**
- Modify: `src/server/routes/vehicles.ts` (the `/list` endpoint)
- [ ] **Step 1: Add manager, customer, isColdChain, isTrailer filters**
In the `/list` endpoint, after the existing `category` filter block, add:
```typescript
const { batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer } = c.req.query();
```
(Replace the existing destructure line.)
Then after the `if (category)` block, add:
```typescript
if (manager) {
filtered = filtered.filter((v) => v.customerManager === manager);
}
if (customer) {
filtered = filtered.filter((v) => v.customerName === customer);
}
if (isColdChain !== undefined) {
const wantCold = isColdChain === 'true';
filtered = filtered.filter((v) => wantCold ? v.model.includes('冷链') : !v.model.includes('冷链'));
}
if (isTrailer !== undefined) {
const wantTrailer = isTrailer === 'true';
filtered = filtered.filter((v) => wantTrailer ? (v.type === '挂车' || v.model.includes('挂车')) : !(v.type === '挂车' || v.model.includes('挂车')));
}
```
- [ ] **Step 2: Verify TypeScript compiles and build passes**
Run: `npx tsc --noEmit && npx vite build`
Expected: no errors, successful build
- [ ] **Step 3: Commit**
```bash
git add src/server/routes/vehicles.ts
git commit -m "feat: extend /list endpoint with manager, customer, coldchain, trailer filters"
```
---
### Task 4: Frontend — Types and API client
**Files:**
- Modify: `src/types.ts`
- Modify: `src/api.ts`
- [ ] **Step 1: Add new interfaces to `src/types.ts`**
Append at end of file:
```typescript
export interface ManagerStats {
manager: string;
department: string;
t4_5: number;
t4_5c: number;
t18: number;
t49: number;
trailer: number;
other: number;
total: number;
}
export interface DeptGroup {
department: string;
totalAssets: number;
operatingCount: number;
idleCount: number;
managers: ManagerStats[];
}
export interface RegionGroup {
region: string;
totalAssets: number;
operatingCount: number;
inventoryCount: number;
customers: string[];
typeBreakdown: { type: string; total: number; operating: number; inventory: number; customers: string[] }[];
}
export interface CustomerStats {
customer: string;
manager: string;
brand: string;
department: string;
region: string;
city: string;
t4_5: number;
t4_5c: number;
t18: number;
t49: number;
trailer: number;
other: number;
total: number;
}
```
- [ ] **Step 2: Add API functions to `src/api.ts`**
Add imports at top:
```typescript
import type {
SummaryData,
TypeSummary,
VehicleListItem,
DeptGroup,
RegionGroup,
CustomerStats,
} from './types';
```
Add after `fetchVehicleList`:
```typescript
export async function fetchDeptStats(): Promise<DeptGroup[]> {
return fetchJson<DeptGroup[]>(`${BASE}/dept-stats`);
}
export async function fetchRegionStats(): Promise<RegionGroup[]> {
return fetchJson<RegionGroup[]>(`${BASE}/region-stats`);
}
export async function fetchCustomerStats(): Promise<CustomerStats[]> {
return fetchJson<CustomerStats[]>(`${BASE}/customer-stats`);
}
```
Also update `fetchVehicleList` params type to include new filters:
```typescript
export async function fetchVehicleList(params: {
batch?: string;
model?: string;
location?: string;
status?: string;
category?: string;
vehicleType?: string;
manager?: string;
customer?: string;
isColdChain?: string;
isTrailer?: string;
}): Promise<VehicleListItem[]> {
const query = new URLSearchParams();
if (params.batch) query.set('batch', params.batch);
if (params.model) query.set('model', params.model);
if (params.location) query.set('location', params.location);
if (params.status) query.set('status', params.status);
if (params.category) query.set('category', params.category);
if (params.vehicleType) query.set('vehicleType', params.vehicleType);
if (params.manager) query.set('manager', params.manager);
if (params.customer) query.set('customer', params.customer);
if (params.isColdChain) query.set('isColdChain', params.isColdChain);
if (params.isTrailer) query.set('isTrailer', params.isTrailer);
return fetchJson<VehicleListItem[]>(`${BASE}/list?${query.toString()}`);
}
```
- [ ] **Step 3: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: no errors
- [ ] **Step 4: Commit**
```bash
git add src/types.ts src/api.ts
git commit -m "feat: add frontend types and API client for dept/region/customer stats"
```
---
### Task 5: Frontend — Extend App.tsx state, data loading, imports, and showPlateNumbers
**Files:**
- Modify: `src/App.tsx`
- [ ] **Step 1: Update imports**
Replace the existing import lines at top of `src/App.tsx`:
```typescript
import {
Truck,
Warehouse,
Activity,
PlusCircle,
MinusCircle,
History,
ChevronDown,
ChevronRight,
Info,
Loader2,
Search,
Filter,
ArrowRightLeft,
} from 'lucide-react';
```
Update type imports:
```typescript
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats } from './types';
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats } from './api';
```
- [ ] **Step 2: Add new state variables**
After the existing state declarations (after `const [modalLoading, setModalLoading] = useState(false);`), add:
```typescript
// Dept/Region/Customer data
const [deptData, setDeptData] = useState<DeptGroup[]>([]);
const [regionData, setRegionData] = useState<RegionGroup[]>([]);
const [customerData, setCustomerData] = useState<CustomerStats[]>([]);
// Dept section state
const [deptViewMode, setDeptViewMode] = useState<'department' | 'manager'>('department');
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set());
const [expandedManagerDetails, setExpandedManagerDetails] = useState<Set<string>>(new Set());
const [selectedManager, setSelectedManager] = useState<string>('All');
// Region section state
const [expandedRegions, setExpandedRegions] = useState<Set<string>>(new Set());
const [regionFilters, setRegionFilters] = useState({ region: '', city: '', customer: '' });
const [isRegionFilterOpen, setIsRegionFilterOpen] = useState(false);
// Customer section state
const [expandedCustomers, setExpandedCustomers] = useState<Set<string>>(new Set());
const [customerFilters, setCustomerFilters] = useState({ customer: '', brand: '', department: '', manager: '', region: '' });
const [isCustomerFilterOpen, setIsCustomerFilterOpen] = useState(false);
```
- [ ] **Step 3: Update loadData to fetch all 3 new endpoints**
Update the `loadData` callback:
```typescript
const loadData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const [s, byType, dept, region, cust] = await Promise.all([
fetchSummary(),
fetchByType(),
fetchDeptStats(),
fetchRegionStats(),
fetchCustomerStats(),
]);
setSummary(s);
setProcessedData(byType);
setDeptData(dept);
setRegionData(region);
setCustomerData(cust);
setLastUpdate(new Date().toLocaleString('zh-CN'));
} catch (e) {
setError(e instanceof Error ? e.message : '数据加载失败');
} finally {
setLoading(false);
}
}, []);
```
- [ ] **Step 4: Extend showPlateNumbers type**
Update the showPlateNumbers state type:
```typescript
const [showPlateNumbers, setShowPlateNumbers] = useState<{
batch: string;
model: string;
location: string;
category?: 'Inventory' | 'Pending' | 'Delivered' | 'Returned' | 'Replaced' | 'Operating';
vehicleType?: string;
manager?: string;
customer?: string;
isColdChain?: boolean;
isTrailer?: boolean;
} | null>(null);
```
- [ ] **Step 5: Update modal loading to pass new filter params**
In the `useEffect` for modal loading, update the params block (the "Normal vehicle list" section):
```typescript
// Normal vehicle list
setModalWeeklyDetail([]);
const params: Record<string, string> = {};
if (showPlateNumbers.vehicleType) params.vehicleType = showPlateNumbers.vehicleType;
if (showPlateNumbers.batch !== 'All') params.batch = showPlateNumbers.batch;
if (showPlateNumbers.model !== 'All') params.model = showPlateNumbers.model;
if (showPlateNumbers.location !== 'All') params.location = showPlateNumbers.location;
if (cat === 'Inventory') params.status = 'Inventory';
if (cat === 'Operating') params.category = 'Operating';
if (showPlateNumbers.manager) params.manager = showPlateNumbers.manager;
if (showPlateNumbers.customer) params.customer = showPlateNumbers.customer;
if (showPlateNumbers.isColdChain !== undefined) params.isColdChain = String(showPlateNumbers.isColdChain);
if (showPlateNumbers.isTrailer !== undefined) params.isTrailer = String(showPlateNumbers.isTrailer);
```
- [ ] **Step 6: Add toggle helpers and derived data**
After the existing `toggleModel` function, add:
```typescript
const toggleDept = (dept: string) => {
const newSet = new Set(expandedDepts);
if (newSet.has(dept)) newSet.delete(dept);
else newSet.add(dept);
setExpandedDepts(newSet);
};
const toggleManagerDetails = (manager: string) => {
const newSet = new Set(expandedManagerDetails);
if (newSet.has(manager)) newSet.delete(manager);
else newSet.add(manager);
setExpandedManagerDetails(newSet);
};
const toggleRegion = (region: string) => {
const newSet = new Set(expandedRegions);
if (newSet.has(region)) newSet.delete(region);
else newSet.add(region);
setExpandedRegions(newSet);
};
const toggleCustomer = (customer: string) => {
const newSet = new Set(expandedCustomers);
if (newSet.has(customer)) newSet.delete(customer);
else newSet.add(customer);
setExpandedCustomers(newSet);
};
// Derived data for dept section
const allManagersList = deptData.flatMap((d) => d.managers.map((m) => m.manager)).filter((v, i, a) => a.indexOf(v) === i).sort();
const managerStats = deptData
.flatMap((d) => d.managers)
.filter((m) => selectedManager === 'All' || m.manager === selectedManager)
.sort((a, b) => b.total - a.total);
// Derived data for customer section
const filteredCustomerStats = customerData.filter((s) => {
const mc = !customerFilters.customer || s.customer.toLowerCase().includes(customerFilters.customer.toLowerCase());
const mb = !customerFilters.brand || s.brand === customerFilters.brand;
const md = !customerFilters.department || s.department === customerFilters.department;
const mm = !customerFilters.manager || s.manager.toLowerCase().includes(customerFilters.manager.toLowerCase());
const mr = !customerFilters.region || s.region === customerFilters.region;
return mc && mb && md && mm && mr;
});
const uniqueBrands = Array.from(new Set(customerData.map((s) => s.brand).filter(Boolean)));
const uniqueDepts = Array.from(new Set(customerData.map((s) => s.department).filter(Boolean)));
const uniqueRegions = Array.from(new Set(customerData.map((s) => s.region)));
const uniqueCities = Array.from(new Set(customerData.map((s) => s.city).filter(Boolean)));
// Derived data for region section
const filteredRegionData = regionData.filter((r) => !regionFilters.region || r.region === regionFilters.region);
```
- [ ] **Step 7: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: no errors
- [ ] **Step 8: Commit**
```bash
git add src/App.tsx
git commit -m "feat: add state, data loading, and helpers for 3 new modules"
```
---
### Task 6: Frontend — Department Operations UI
**Files:**
- Modify: `src/App.tsx` (add section after the asset summary table's closing `</div>`, before the Plate Number Modal)
- [ ] **Step 1: Add the department operations section**
Insert the department operations section JSX. Reference: lnoneos lines 1362-1880. This section goes right after the closing `</div>` of the asset summary table (`bg-white rounded-sm border...`) and before `{/* Plate Number Modal */}`.
The section includes:
1. Header with title "部门运营统计"
2. Dark summary bar (总资产/运营中/闲置中 — skip 平均出勤)
3. Toggle buttons (按部门 / 按业务员) + manager filter dropdown
4. Desktop table view (`hidden lg:block`)
- Department mode: department rows expandable to show manager cards with 6 vehicle type cells
- Manager mode: flat manager rows expandable to show 6 vehicle type cells
5. Mobile card view (`lg:hidden`)
Port the JSX from lnoneos lines 1362-1880, replacing:
- `MOCK_DEPT_STATS``deptData`
- `DEPT_TOTALS.total``deptData.reduce((s, d) => s + d.totalAssets, 0)`
- `allManagersList` / `managerStats` / `deptViewMode` / `expandedDepts` / `expandedManagerDetails` / `selectedManager` → already defined in Task 5
- `setShowPlateNumbers` calls: keep the same structure but remove `source` field (not needed in ln-bi)
- Remove `ArrowRightLeft` icon usage in toggle buttons — replace with simple text button
- All `rounded-2xl``rounded-sm` to match ln-bi style
- All `shadow-sm` stay as is
The `setShowPlateNumbers` calls from lnoneos use `manager`, `type`, `isColdChain`, `isTrailer` fields which we added to the state type in Task 5.
- [ ] **Step 2: Verify TypeScript compiles and build passes**
Run: `npx tsc --noEmit && npx vite build`
Expected: no errors, successful build
- [ ] **Step 3: Commit**
```bash
git add src/App.tsx
git commit -m "feat: add department operations statistics section"
```
---
### Task 7: Frontend — Region Operations UI
**Files:**
- Modify: `src/App.tsx` (add section after department section, before Plate Number Modal)
- [ ] **Step 1: Add the region operations section**
Reference: lnoneos lines 1882-2174. Insert after the department section.
The section includes:
1. Slate-themed header with "区域运营统计" + filter button
2. Filter popover (客户搜索 / 区域下拉 / 城市下拉)
3. Desktop table: expandable region rows → vehicle type sub-rows
4. Mobile cards: expandable region cards with type breakdown
Port the JSX from lnoneos, replacing:
- `MOCK_CUSTOMER_STATS` region-based filtering → use `filteredRegionData` (from Task 5)
- Region stats aggregation in lnoneos used mock data with `Math.floor(totalAssets * 0.8)` for operating — use real `r.operatingCount` and `r.inventoryCount`
- Type breakdown: use `r.typeBreakdown` array from API
- `uniqueRegions` / `uniqueCities` → already defined in Task 5
- `setShowPlateNumbers` calls: use `vehicleType` field for type filtering instead of lnoneos's `type` field
- `rounded-2xl``rounded-sm`
- Filter popover for cities: derive from `regionData` (all unique cities from customers)
Note: The region filter's city dropdown needs city data. Add to Task 5's derived data if not already there. The regionData from API contains customer names but not cities. For the city filter, we can derive from customerData filtered by region.
- [ ] **Step 2: Verify TypeScript compiles and build passes**
Run: `npx tsc --noEmit && npx vite build`
Expected: no errors, successful build
- [ ] **Step 3: Commit**
```bash
git add src/App.tsx
git commit -m "feat: add region operations statistics section"
```
---
### Task 8: Frontend — Customer Operations UI
**Files:**
- Modify: `src/App.tsx` (add section after region section, before Plate Number Modal)
- [ ] **Step 1: Add the customer operations section**
Reference: lnoneos lines 2176-2496. Insert after the region section.
The section includes:
1. Emerald-themed header with "客户运营统计" + filter button
2. Filter popover (客户名搜索 / 业务员搜索 / 品牌下拉 / 部门下拉 / 区域下拉)
3. Desktop table: customer rows with 6 vehicle type columns + total, expandable detail cards
4. Mobile cards: customer cards with expandable vehicle type grid
Port the JSX from lnoneos, replacing:
- `MOCK_CUSTOMER_STATS``filteredCustomerStats` (from Task 5)
- `DEPT_TOTALS.total``customerData.reduce((s, c) => s + c.total, 0)` for asset ratio
- `setShowPlateNumbers` calls: use `vehicleType` + `customer` fields
- `uniqueBrands` / `uniqueDepts` / `uniqueRegions` → already defined in Task 5
- `rounded-2xl``rounded-sm`
- [ ] **Step 2: Verify TypeScript compiles and build passes**
Run: `npx tsc --noEmit && npx vite build`
Expected: no errors, successful build
- [ ] **Step 3: Commit**
```bash
git add src/App.tsx
git commit -m "feat: add customer operations statistics section"
```
---
### Task 9: Final verification and build
**Files:** All modified files
- [ ] **Step 1: Full TypeScript check**
Run: `npx tsc --noEmit`
Expected: no errors
- [ ] **Step 2: Production build**
Run: `npx vite build`
Expected: successful build with no warnings
- [ ] **Step 3: Verify all sections render**
Run: `npm run dev` and check:
- Department section loads with real data
- Region section loads with real data
- Customer section loads with real data
- Filter popovers work
- Expand/collapse works
- Click-through to plate number modal works
- [ ] **Step 4: Final commit if any fixes needed**
```bash
git add -A
git commit -m "fix: address any issues from final review"
```

View File

@@ -0,0 +1,926 @@
# Mileage Backend Refactor 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:** Refactor `src/server/routes/mileage.ts` (569 lines) into well-typed, modular files with clear responsibilities, eliminating duplicate logic and `as any` casts.
**Architecture:** Split the monolithic route file into: shared types, a reusable vehicle-info query module, a monitoring cache module, and focused route handlers. The API contract (request/response shapes) stays identical — this is a pure internal refactor with zero frontend changes.
**Tech Stack:** Hono, mysql2/promise, TypeScript strict types
---
## File Structure
| File | Responsibility |
|------|---------------|
| `src/server/routes/mileage/types.ts` | All interfaces for mileage domain (cache, vehicles, filters, API responses) |
| `src/server/routes/mileage/vehicle-info.ts` | Shared SQL + helper to build plate→info Map from `lingniu_prod` |
| `src/server/routes/mileage/cache.ts` | Monitoring cache: refresh logic, data merging, filter precomputation, target mapping |
| `src/server/routes/mileage/monitoring.ts` | `GET /monitoring` route handler |
| `src/server/routes/mileage/targets.ts` | `GET /targets`, `GET /target/:id/vehicles` route handlers |
| `src/server/routes/mileage/trend.ts` | `GET /trend` route handler |
| `src/server/routes/mileage/index.ts` | Hono app assembly: imports routes, starts cache timer, exports app |
After refactor, delete: `src/server/routes/mileage.ts` (the old monolith).
## Constraints
- **Zero API changes** — all request params and response JSON shapes must remain identical
- **Zero frontend changes** — `src/modules/mileage/api.ts` and `types.ts` stay untouched
- **Preserve all existing behavior** including cache refresh interval, date queries, filter logic
---
### Task 1: Create shared types
**Files:**
- Create: `src/server/routes/mileage/types.ts`
- [ ] **Step 1: Create the types file**
```typescript
// src/server/routes/mileage/types.ts
/** 缓存中的单辆车数据 */
export interface CachedVehicle {
plate: string;
vin: string;
dailyKm: number;
totalKm: number | null;
source: string;
isOnline: boolean;
isDataSynced: boolean;
customer: string | null;
department: string | null;
manager: string | null;
rentStatus: string | null;
entity: string | null;
project: string | null;
yesterdayKm: number;
}
/** 车牌前缀统计 */
export interface PlatePrefix {
prefix: string;
count: number;
}
/** 筛选选项(前端下拉) */
export interface MonitoringFilters {
departments: string[];
customers: string[];
plates: string[];
projects: string[];
entities: string[];
rentStatuses: string[];
platePrefixes: PlatePrefix[];
targetNames: string[];
}
/** 监控缓存 */
export interface MonitoringCache {
vehicles: CachedVehicle[];
stats: { totalToday: number; totalAll: number; vehicleCount: number };
filters: MonitoringFilters;
targetPlatesMap: Map<string, Set<string>>;
updatedAt: string;
}
/** /monitoring 响应中的统计 */
export interface MonitoringStats {
totalToday: number;
totalAll: number;
vehicleCount: number;
yesterdayTotal: number;
}
/** /monitoring 完整响应 */
export interface MonitoringResponse {
vehicles: CachedVehicle[];
stats: MonitoringStats;
filters: MonitoringFilters;
total: number;
page: number;
totalPages: number;
updatedAt: string;
}
/** 车辆关联信息(从 lingniu_prod 查出的原始行) */
export interface VehicleInfoRow {
plate: string;
customer: string | null;
department: string | null;
manager: string | null;
rent_status: string | null;
entity: string | null;
project: string | null;
}
```
- [ ] **Step 2: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: no errors (new file has no imports/consumers yet)
- [ ] **Step 3: Commit**
```bash
git add src/server/routes/mileage/types.ts
git commit -m "refactor: extract mileage shared types"
```
---
### Task 2: Extract vehicle-info query module
**Files:**
- Create: `src/server/routes/mileage/vehicle-info.ts`
- [ ] **Step 1: Create the vehicle-info module**
This extracts the `VEHICLE_INFO_SQL` constant and a helper function to build the info Map. Both the cache builder and the `/target/:id/vehicles` route reuse this.
```typescript
// src/server/routes/mileage/vehicle-info.ts
import pool from '../../db.js';
import type { VehicleInfoRow } from './types.js';
/** 车辆关联信息 SQL客户名、部门、经理、租赁状态、主体、项目 */
export const VEHICLE_INFO_SQL = `SELECT
truck.plate_number AS plate,
cus.customer_name AS customer,
dep.dep_name AS department,
u.user_name AS manager,
dic_status.dic_name AS rent_status,
org_truck.org_name AS entity,
c.project_name AS project
FROM tab_truck truck
LEFT JOIN tab_truck_status_info si ON si.truck_id = truck.id AND si.is_deleted = 0
LEFT JOIN tab_contract c ON c.id = si.contract_id AND c.is_deleted = 0
LEFT JOIN tab_customer cus ON cus.id = c.customer_id AND cus.is_deleted = 0
LEFT JOIN tab_user u ON u.id = c.bd AND u.is_deleted = 0
LEFT JOIN tab_department dep ON dep.id = u.dep_id AND dep.is_deleted = 0
LEFT JOIN tab_dic dic_status ON dic_status.parent_code = 'dic_truck_rent_status'
AND dic_status.dic_code = truck.truck_rent_status AND dic_status.is_deleted = 0
LEFT JOIN tab_org org_truck ON org_truck.id = truck.org_id AND org_truck.is_deleted = 0
WHERE truck.is_deleted = 0 AND truck.is_operation = 1`;
/** 查询所有车辆关联信息,返回 plate→info 的 Map */
export async function fetchVehicleInfoMap(): Promise<Map<string, VehicleInfoRow>> {
const [rows] = await pool.execute(VEHICLE_INFO_SQL) as [VehicleInfoRow[], unknown];
const map = new Map<string, VehicleInfoRow>();
for (const row of rows) {
map.set(row.plate, row);
}
return map;
}
/** 查询指定车牌的关联信息 */
export async function fetchVehicleInfoByPlates(plates: string[]): Promise<Map<string, VehicleInfoRow>> {
if (plates.length === 0) return new Map();
const [rows] = await pool.execute(
`${VEHICLE_INFO_SQL} AND truck.plate_number IN (${plates.map(() => '?').join(',')})`,
plates
) as [VehicleInfoRow[], unknown];
const map = new Map<string, VehicleInfoRow>();
for (const row of rows) {
map.set(row.plate, row);
}
return map;
}
```
- [ ] **Step 2: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: no errors
- [ ] **Step 3: Commit**
```bash
git add src/server/routes/mileage/vehicle-info.ts
git commit -m "refactor: extract vehicle-info query module"
```
---
### Task 3: Extract monitoring cache module
**Files:**
- Create: `src/server/routes/mileage/cache.ts`
- [ ] **Step 1: Create the cache module**
This contains the cache singleton, refresh logic, and the `queryDateMileage` function. Both used to live in the monolith.
```typescript
// src/server/routes/mileage/cache.ts
import pool from '../../db.js';
import mileagePool from '../../mileage-db.js';
import { fetchVehicleInfoMap } from './vehicle-info.js';
import type { CachedVehicle, MonitoringCache, MonitoringFilters, PlatePrefix } from './types.js';
let monitoringCache: MonitoringCache | null = null;
export function getCache(): MonitoringCache | null {
return monitoringCache;
}
/** 部门排序顺序 */
const DEPT_ORDER = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
function sortDepartments(departments: string[]): string[] {
return departments.sort((a, b) => {
const ai = DEPT_ORDER.findIndex(d => a.includes(d));
const bi = DEPT_ORDER.findIndex(d => b.includes(d));
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
});
}
/** 从车辆列表计算筛选选项 */
function buildFilters(vehicles: CachedVehicle[], targetNames: string[]): MonitoringFilters {
const departments = sortDepartments(
Array.from(new Set(vehicles.map(v => v.department).filter((d): d is string => d !== null)))
);
const customers = Array.from(new Set(vehicles.map(v => v.customer).filter((c): c is string => c !== null)));
const plates = vehicles.map(v => v.plate);
const projects = Array.from(new Set(vehicles.map(v => v.project).filter((p): p is string => p !== null)));
const entities = Array.from(new Set(vehicles.map(v => v.entity).filter((e): e is string => e !== null)));
const rentStatuses = Array.from(new Set(vehicles.map(v => v.rentStatus).filter((r): r is string => r !== null)));
const prefixCount = new Map<string, number>();
for (const v of vehicles) {
const p = v.plate.charAt(0);
prefixCount.set(p, (prefixCount.get(p) || 0) + 1);
}
const platePrefixes: PlatePrefix[] = Array.from(prefixCount.entries())
.map(([prefix, count]) => ({ prefix, count }))
.sort((a, b) => b.count - a.count);
return { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames };
}
/** 将里程原始行 + 车辆信息合并为 CachedVehicle 列表 */
function mergeVehicles(
mileageRows: { plate: string; vin: string; daily_km: string; total_km: string | null; source: string }[],
infoMap: Map<string, { customer: string | null; department: string | null; manager: string | null; rent_status: string | null; entity: string | null; project: string | null }>,
yesterdayMap: Map<string, number>,
): CachedVehicle[] {
// 去重:同一 plate 取 daily_km 最大的
const mileageMap = new Map<string, typeof mileageRows[0]>();
for (const row of mileageRows) {
const existing = mileageMap.get(row.plate);
if (!existing || Number(row.daily_km) > Number(existing.daily_km)) {
mileageMap.set(row.plate, row);
}
}
return Array.from(mileageMap.values()).map(m => {
const info = infoMap.get(m.plate);
const dailyKm = Number(m.daily_km) || 0;
const source = m.source || 'NONE';
return {
plate: m.plate,
vin: m.vin,
dailyKm,
totalKm: m.total_km !== null ? Number(m.total_km) : null,
source,
isOnline: source !== 'NONE' && dailyKm > 0,
isDataSynced: source !== 'NONE',
customer: info?.customer || null,
department: info?.department || null,
manager: info?.manager || null,
rentStatus: info?.rent_status || null,
entity: info?.entity || null,
project: info?.project || null,
yesterdayKm: yesterdayMap.get(m.plate) || 0,
};
});
}
/** 刷新监控缓存(从两个数据库并行查询) */
export async function refreshMonitoringCache(): Promise<void> {
try {
console.log('[mileage] refreshing monitoring cache...');
const start = Date.now();
const [mileageRows, yesterdayMap, infoMap, targetRows] = await Promise.all([
// 最新日期的里程数据
(async () => {
const [dateRows] = await mileagePool.execute(
'SELECT MAX(stat_date) as latest FROM v_vehicle_daily_stats'
) as [{ latest: string | null }[], unknown];
const latestDate = dateRows[0]?.latest;
if (!latestDate) return [];
const [rows] = await mileagePool.execute(
'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?',
[latestDate]
) as [any[], unknown];
return rows;
})(),
// 昨日里程(用于环比)
(async () => {
const [rows] = await mileagePool.execute(
`SELECT plate, daily_km FROM v_vehicle_daily_stats
WHERE stat_date = DATE_SUB((SELECT MAX(stat_date) FROM v_vehicle_daily_stats), INTERVAL 1 DAY)`
) as [any[], unknown];
const map = new Map<string, number>();
for (const r of rows) {
const km = Number(r.daily_km) || 0;
const existing = map.get(r.plate) || 0;
if (km > existing) map.set(r.plate, km);
}
return map;
})(),
// 车辆关联信息
fetchVehicleInfoMap(),
// 考核批次→车牌映射
pool.execute(
`SELECT t.id, t.target_name, v.plate_number
FROM tab_mileage_assessment_target t
JOIN tab_mileage_assessment_vehicle v ON v.target_id = t.id AND v.is_deleted = 0
WHERE t.is_deleted = 0`
).then(([rows]) => rows as { id: number; target_name: string; plate_number: string }[]),
]);
// 构建批次映射
const targetPlatesMap = new Map<string, Set<string>>();
for (const r of targetRows) {
const set = targetPlatesMap.get(r.target_name) || new Set();
set.add(r.plate_number);
targetPlatesMap.set(r.target_name, set);
}
const targetNames = Array.from(targetPlatesMap.keys());
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap);
const totalToday = vehicles.reduce((sum, v) => sum + v.dailyKm, 0);
const totalAll = vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0);
monitoringCache = {
vehicles,
stats: { totalToday, totalAll, vehicleCount: vehicles.length },
filters: buildFilters(vehicles, targetNames),
targetPlatesMap,
updatedAt: new Date().toISOString(),
};
console.log(`[mileage] cache refreshed: ${vehicles.length} vehicles in ${Date.now() - start}ms`);
} catch (e: unknown) {
console.error('[mileage] cache refresh error:', e);
}
}
/** 查询指定日期的里程数据(不使用缓存) */
export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]> {
const [mileageRows, yesterdayRows, infoMap] = await Promise.all([
mileagePool.execute(
'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?',
[dateStr]
).then(([r]) => r as any[]),
mileagePool.execute(
'SELECT plate, daily_km FROM v_vehicle_daily_stats WHERE stat_date = DATE_SUB(?, INTERVAL 1 DAY)',
[dateStr]
).then(([r]) => r as any[]),
fetchVehicleInfoMap(),
]);
const yesterdayMap = new Map<string, number>();
for (const r of yesterdayRows) {
const km = Number(r.daily_km) || 0;
const existing = yesterdayMap.get(r.plate) || 0;
if (km > existing) yesterdayMap.set(r.plate, km);
}
return mergeVehicles(mileageRows, infoMap, yesterdayMap);
}
/** 构建指定日期数据的筛选选项 */
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {
return buildFilters(vehicles, monitoringCache?.filters.targetNames || []);
}
```
- [ ] **Step 2: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: no errors
- [ ] **Step 3: Commit**
```bash
git add src/server/routes/mileage/cache.ts
git commit -m "refactor: extract monitoring cache module"
```
---
### Task 4: Create monitoring route handler
**Files:**
- Create: `src/server/routes/mileage/monitoring.ts`
- [ ] **Step 1: Create the monitoring route**
```typescript
// src/server/routes/mileage/monitoring.ts
import { Hono } from 'hono';
import { getCache, queryDateMileage, buildDateFilters } from './cache.js';
import type { CachedVehicle, MonitoringFilters, MonitoringResponse } from './types.js';
const app = new Hono();
const EMPTY_RESPONSE: MonitoringResponse = {
vehicles: [],
stats: { totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 },
filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [] },
total: 0,
page: 1,
totalPages: 1,
updatedAt: new Date().toISOString(),
};
/** 应用筛选条件 */
function applyFilters(vehicles: CachedVehicle[], params: {
search: string; dept: string; customer: string; project: string;
entity: string; rentStatus: string; plate: string; platePrefix: string;
targetName: string; mileageMin: string; mileageMax: string;
}): CachedVehicle[] {
let result = vehicles;
if (params.search) {
const q = params.search.toLowerCase();
result = result.filter(v =>
v.plate.toLowerCase().includes(q) ||
(v.customer || '').toLowerCase().includes(q) ||
(v.project || '').toLowerCase().includes(q)
);
}
if (params.dept) result = result.filter(v => params.dept === '__EMPTY__' ? !v.department : v.department === params.dept);
if (params.customer) result = result.filter(v => params.customer === '__EMPTY__' ? !v.customer : v.customer === params.customer);
if (params.project) result = result.filter(v => v.project === params.project);
if (params.entity) result = result.filter(v => v.entity === params.entity);
if (params.rentStatus) result = result.filter(v => v.rentStatus === params.rentStatus);
if (params.plate) result = result.filter(v => v.plate === params.plate);
if (params.platePrefix) result = result.filter(v => v.plate.startsWith(params.platePrefix));
if (params.targetName) {
const cache = getCache();
const tPlates = cache?.targetPlatesMap.get(params.targetName);
result = tPlates ? result.filter(v => tPlates.has(v.plate)) : [];
}
if (params.mileageMin) result = result.filter(v => v.dailyKm >= Number(params.mileageMin));
if (params.mileageMax) result = result.filter(v => v.dailyKm <= Number(params.mileageMax));
return result;
}
app.get('/', async (c) => {
const sortBy = c.req.query('sortBy') || 'today';
const sortOrder = c.req.query('sortOrder') || 'desc';
const limit = Number(c.req.query('limit')) || 50;
const page = Number(c.req.query('page')) || 1;
const date = c.req.query('date') || '';
const filterParams = {
search: c.req.query('search') || '',
dept: c.req.query('dept') || '',
customer: c.req.query('customer') || '',
project: c.req.query('project') || '',
entity: c.req.query('entity') || '',
rentStatus: c.req.query('rentStatus') || '',
plate: c.req.query('plate') || '',
platePrefix: c.req.query('platePrefix') || '',
targetName: c.req.query('targetName') || '',
mileageMin: c.req.query('mileageMin') || '',
mileageMax: c.req.query('mileageMax') || '',
};
// 获取数据源
let allVehicles: CachedVehicle[];
let filters: MonitoringFilters;
if (date) {
try {
allVehicles = await queryDateMileage(date);
filters = buildDateFilters(allVehicles);
} catch (e: unknown) {
console.error('monitoring date query error:', e);
return c.json(EMPTY_RESPONSE, 500);
}
} else {
const cache = getCache();
if (!cache) return c.json(EMPTY_RESPONSE);
allVehicles = cache.vehicles;
filters = cache.filters;
}
// 筛选
const filtered = applyFilters(allVehicles, filterParams);
// 统计
const stats = {
totalToday: filtered.reduce((sum, v) => sum + v.dailyKm, 0),
totalAll: filtered.reduce((sum, v) => sum + (v.totalKm || 0), 0),
vehicleCount: filtered.length,
yesterdayTotal: filtered.reduce((sum, v) => sum + v.yesterdayKm, 0),
};
// 排序
const sorted = [...filtered].sort((a, b) => {
const valA = sortBy === 'today' ? a.dailyKm : (a.totalKm || 0);
const valB = sortBy === 'today' ? b.dailyKm : (b.totalKm || 0);
return sortOrder === 'desc' ? valB - valA : valA - valB;
});
// 分页
const offset = (page - 1) * limit;
const paged = sorted.slice(offset, offset + limit);
const total = filtered.length;
return c.json({
vehicles: paged,
stats,
filters,
total,
page,
totalPages: Math.ceil(total / limit),
updatedAt: date || getCache()?.updatedAt || new Date().toISOString(),
});
});
export default app;
```
- [ ] **Step 2: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: no errors
- [ ] **Step 3: Commit**
```bash
git add src/server/routes/mileage/monitoring.ts
git commit -m "refactor: create monitoring route handler"
```
---
### Task 5: Create targets route handler
**Files:**
- Create: `src/server/routes/mileage/targets.ts`
- [ ] **Step 1: Create the targets route**
```typescript
// src/server/routes/mileage/targets.ts
import { Hono } from 'hono';
import pool from '../../db.js';
import mileagePool from '../../mileage-db.js';
import { getCache } from './cache.js';
import { fetchVehicleInfoByPlates } from './vehicle-info.js';
const app = new Hono();
// GET /targets — 考核项目列表 + 汇总
app.get('/', async (c) => {
try {
const [targets] = await pool.execute(
'SELECT * FROM tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id'
) as [any[], unknown];
const [vehicleStats] = await pool.execute(`
SELECT
target_id, COUNT(*) as total,
SUM(today_mileage) as today_total,
SUM(current_mileage) as cumulative_total,
AVG(current_year_completion_rate) as avg_completion,
SUM(CASE WHEN is_qualified = 1 THEN 1 ELSE 0 END) as qualified_count,
SUM(CASE WHEN current_year_is_qualified = 1 THEN 1 ELSE 0 END) as year_qualified_count,
SUM(CASE WHEN current_year_completion_rate >= 0.5 THEN 1 ELSE 0 END) as half_qualified_count,
SUM(current_year_mileage_task) as current_year_target,
SUM(current_year_mileage) as current_year_completed,
MAX(current_year_assessment_end_date) as year_end_date
FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0
GROUP BY target_id
`) as [any[], unknown];
const statsMap = new Map<number, any>();
for (const s of vehicleStats) statsMap.set(s.target_id, s);
const [periodRows] = await pool.execute(`
SELECT target_id,
DATE_FORMAT(assessment_start_date, '%Y-%m-%d') as start_date,
DATE_FORMAT(assessment_end_date, '%Y-%m-%d') as end_date,
COUNT(*) as cnt
FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0
GROUP BY target_id, assessment_start_date, assessment_end_date
ORDER BY target_id, assessment_start_date
`) as [any[], unknown];
const periodsMap = new Map<number, string[]>();
for (const p of periodRows) {
const list = periodsMap.get(p.target_id) || [];
list.push(`${p.start_date} ~ ${p.end_date} (${p.cnt}台)`);
periodsMap.set(p.target_id, list);
}
// 使用监控缓存里程数据(与里程看板一致)
const cache = getCache();
const cacheVehicleMap = new Map<string, number>();
if (cache) {
for (const v of cache.vehicles) {
cacheVehicleMap.set(v.plate, Math.max(0, v.dailyKm || 0));
}
}
const [targetVehicleRows] = await pool.execute(
'SELECT target_id, plate_number FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0'
) as [{ target_id: number; plate_number: string }[], unknown];
const targetIdPlatesMap = new Map<number, string[]>();
for (const r of targetVehicleRows) {
const list = targetIdPlatesMap.get(r.target_id) || [];
list.push(r.plate_number);
targetIdPlatesMap.set(r.target_id, list);
}
const now = new Date();
const result = targets.map((t: any) => {
const s = statsMap.get(t.id) || {};
const currentYearTarget = Number(s.current_year_target) || 0;
const currentYearCompleted = Number(s.current_year_completed) || 0;
const remaining = Math.max(0, currentYearTarget - currentYearCompleted);
const yearEnd = s.year_end_date ? new Date(s.year_end_date) : now;
const daysLeft = Math.max(1, Math.ceil((yearEnd.getTime() - now.getTime()) / 86400000));
const dailyTarget = remaining / daysLeft;
const periods = periodsMap.get(t.id) || [];
if (periods.length === 0) {
const startDate = t.default_start_date ? new Date(t.default_start_date).toISOString().split('T')[0] : '';
const endDate = t.default_end_date ? new Date(t.default_end_date).toISOString().split('T')[0] : '';
if (startDate || endDate) periods.push(`${startDate} ~ ${endDate}`);
}
return {
id: t.id,
targetName: t.target_name,
vehicleCount: Number(s.total) || t.vehicle_count,
totalMileagePerVehicle: Number(t.total_mileage_per_vehicle),
annualMileagePerVehicle: Number(t.annual_mileage_per_vehicle),
assessmentYears: t.assessment_years,
periods,
todayTotal: (targetIdPlatesMap.get(t.id) || []).reduce((sum, plate) => sum + (cacheVehicleMap.get(plate) || 0), 0),
cumulativeTotal: Number(s.cumulative_total) || 0,
avgCompletion: (Number(s.avg_completion) || 0) * 100,
qualifiedCount: Number(s.qualified_count) || 0,
yearQualifiedCount: Number(s.year_qualified_count) || 0,
halfQualifiedCount: Number(s.half_qualified_count) || 0,
currentYearTarget,
currentYearCompleted,
remaining,
daysLeft,
dailyTarget: Math.round(dailyTarget * 10) / 10,
};
});
return c.json(result);
} catch (e: unknown) {
console.error('targets error:', e);
return c.json([], 500);
}
});
// GET /target/:id/vehicles — 某项目的车辆明细
app.get('/:id/vehicles', async (c) => {
const targetId = c.req.param('id');
const date = c.req.query('date') || '';
try {
const [rows] = await pool.execute(
`SELECT plate_number, today_mileage, vehicle_total_mileage,
completion_rate, is_qualified, current_year_is_qualified,
daily_required_mileage
FROM tab_mileage_assessment_vehicle
WHERE target_id = ? AND is_deleted = 0
ORDER BY today_mileage DESC`,
[targetId]
) as [any[], unknown];
const plates: string[] = rows.map((r: any) => r.plate_number);
const infoMap = await fetchVehicleInfoByPlates(plates);
// 指定日期时,从里程库查该日里程
const dateMileageMap = new Map<string, { dailyKm: number; totalKm: number | null; isOnline: boolean }>();
if (date && plates.length > 0) {
const [mileageRows] = await mileagePool.execute(
`SELECT plate, daily_km, total_km, source FROM v_vehicle_daily_stats
WHERE stat_date = ? AND plate IN (${plates.map(() => '?').join(',')})`,
[date, ...plates]
) as [any[], unknown];
for (const m of mileageRows) {
const existing = dateMileageMap.get(m.plate);
const dailyKm = Number(m.daily_km) || 0;
if (!existing || dailyKm > existing.dailyKm) {
const source = m.source || 'NONE';
dateMileageMap.set(m.plate, {
dailyKm,
totalKm: m.total_km !== null ? Number(m.total_km) : null,
isOnline: source !== 'NONE' && dailyKm > 0,
});
}
}
}
const result = rows.map((r: any) => {
const info = infoMap.get(r.plate_number);
const dateMileage = date ? dateMileageMap.get(r.plate_number) : null;
return {
plateNumber: r.plate_number,
todayMileage: dateMileage ? dateMileage.dailyKm : (Number(r.today_mileage) || 0),
totalMileage: dateMileage?.totalKm ?? (Number(r.vehicle_total_mileage) || 0),
completionRate: Number(r.completion_rate) || 0,
isQualified: r.is_qualified === 1,
currentYearIsQualified: r.current_year_is_qualified === 1,
dailyRequiredMileage: Number(r.daily_required_mileage) || 0,
rentStatus: info?.rent_status || null,
department: info?.department || null,
customer: info?.customer || null,
isOnline: dateMileage ? dateMileage.isOnline : true,
};
});
return c.json(result);
} catch (e: unknown) {
console.error('target vehicles error:', e);
return c.json([], 500);
}
});
export default app;
```
- [ ] **Step 2: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: no errors
- [ ] **Step 3: Commit**
```bash
git add src/server/routes/mileage/targets.ts
git commit -m "refactor: create targets route handler"
```
---
### Task 6: Create trend route handler
**Files:**
- Create: `src/server/routes/mileage/trend.ts`
- [ ] **Step 1: Create the trend route**
```typescript
// src/server/routes/mileage/trend.ts
import { Hono } from 'hono';
import pool from '../../db.js';
import mileagePool from '../../mileage-db.js';
const app = new Hono();
app.get('/', async (c) => {
const targetId = c.req.query('targetId');
const days = Number(c.req.query('days')) || 7;
try {
let plates: string[] = [];
if (targetId) {
const [vehicleRows] = await pool.execute(
'SELECT plate_number FROM tab_mileage_assessment_vehicle WHERE target_id = ? AND is_deleted = 0',
[targetId]
) as [{ plate_number: string }[], unknown];
plates = vehicleRows.map(r => r.plate_number);
if (plates.length === 0) return c.json([]);
}
let sql = `
SELECT DATE_FORMAT(stat_date, '%m-%d') as date, SUM(daily_km) as mileage
FROM v_vehicle_daily_stats
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) AND stat_date < CURDATE()
`;
const params: (string | number)[] = [days];
if (plates.length > 0) {
sql += ` AND plate IN (${plates.map(() => '?').join(',')})`;
params.push(...plates);
}
sql += ' GROUP BY stat_date ORDER BY stat_date';
const [rows] = await mileagePool.execute(sql, params) as [any[], unknown];
return c.json(rows.map((r: any) => ({
date: r.date,
mileage: Math.round(Number(r.mileage) || 0),
})));
} catch (e: unknown) {
console.error('trend error:', e);
return c.json([], 500);
}
});
export default app;
```
- [ ] **Step 2: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: no errors
- [ ] **Step 3: Commit**
```bash
git add src/server/routes/mileage/trend.ts
git commit -m "refactor: create trend route handler"
```
---
### Task 7: Assemble new index and swap in
**Files:**
- Create: `src/server/routes/mileage/index.ts`
- Delete: `src/server/routes/mileage.ts`
- [ ] **Step 1: Create the new index**
```typescript
// src/server/routes/mileage/index.ts
import { Hono } from 'hono';
import { refreshMonitoringCache } from './cache.js';
import monitoringRouter from './monitoring.js';
import targetsRouter from './targets.js';
import trendRouter from './trend.js';
const app = new Hono();
app.route('/monitoring', monitoringRouter);
app.route('/targets', targetsRouter);
app.route('/target', targetsRouter);
app.route('/trend', trendRouter);
// 启动时立即刷新缓存,之后每分钟刷新
refreshMonitoringCache();
setInterval(refreshMonitoringCache, 60 * 1000);
export default app;
```
- [ ] **Step 2: Delete the old monolith**
```bash
rm src/server/routes/mileage.ts
```
- [ ] **Step 3: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: no errors
- [ ] **Step 4: Verify the server starts and API works**
Run: `npm run dev` and test:
- `curl http://localhost:3001/api/mileage/monitoring?limit=2` — should return vehicles
- `curl http://localhost:3001/api/mileage/targets` — should return target list
- `curl http://localhost:3001/api/mileage/trend?days=7` — should return trend data
- [ ] **Step 5: Commit**
```bash
git add src/server/routes/mileage/ && git add -u src/server/routes/mileage.ts
git commit -m "refactor: replace mileage monolith with modular route files"
```
---
### Task 8: Fix the stale comment and final cleanup
**Files:**
- Modify: `src/server/routes/mileage/cache.ts`
- [ ] **Step 1: Verify no leftover references to old file**
Run: `grep -r "routes/mileage.js" src/` — should only find `src/server/index.ts` which imports `./routes/mileage.js`. Since we moved to `mileage/index.ts`, the import path `./routes/mileage.js` resolves to `./routes/mileage/index.js` automatically. No change needed.
- [ ] **Step 2: Verify full build**
Run: `npx tsc --noEmit && npm run build`
Expected: no errors
- [ ] **Step 3: Final commit**
```bash
git commit --allow-empty -m "refactor: mileage backend refactor complete — verified build"
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,153 @@
# 三大运营统计模块设计
从 lnoneos 原型迁移到 ln-bi 生产项目,使用真实 MySQL 数据。
## 架构决策
- **数据源**:复用现有 `getVehicles()` 缓存(~1000 辆,内存聚合无性能问题)
- **跳过**出勤率、日均里程无数据源QR Code
- **新增图标**`Search`, `Filter`, `ArrowRightLeft` (lucide-react已安装)
## 模块 1部门运营统计
### 后端 API
**`GET /api/vehicles/dept-stats`** — 返回 `DeptGroup[]`
聚合逻辑:按 `Vehicle.departmentName` 分组,每个部门下按 `Vehicle.customerManager` 分组。每个业务员统计车型分布:
| 车型类别 | 过滤条件 |
|---|---|
| t4_5 | type=4.5T 且 model 不含"冷链" |
| t4_5c | type=4.5T 且 model 含"冷链" |
| t18 | type=18T |
| t49 | type=49T |
| trailer | model 含"挂车" |
| other | 以上都不是 |
部门级别额外字段:`totalAssets`(运营中的)、`operatingCount`status=Operating`idleCount`status=Inventory 或 Abnormal
### 后端:扩展 `/api/vehicles/list`
新增查询参数:
- `manager` — 按客户经理筛选
- `customer` — 按客户名称筛选
- `isColdChain` — true/false筛选冷链/非冷链
- `isTrailer` — true/false筛选挂车/非挂车
### 前端类型
```typescript
interface ManagerStats {
manager: string;
department: string;
t4_5: number;
t4_5c: number;
t18: number;
t49: number;
trailer: number;
other: number;
total: number;
}
interface DeptGroup {
department: string;
totalAssets: number;
operatingCount: number;
idleCount: number;
managers: ManagerStats[];
}
```
### 前端 UI
参照 lnoneos 1362-1880 行:
- 顶部深色汇总条(总资产/运营中/闲置中,跳过平均出勤)
- 按部门/按业务员切换
- 桌面表格 + 移动端卡片
- 展开部门显示业务员卡片,展开业务员显示 6 个车型格子(可点击下钻到车牌列表)
## 模块 2区域运营统计
### 后端 API
**`GET /api/vehicles/region-stats`** — 返回 `RegionGroup[]`
新增大区映射函数province/city → 华东/华南/华北/华中/西南/西北/其他)。按大区分组,每个区域下统计:
- 按车型4.5T/18T/49T的资产/运营/库存数
- 列出区域内的客户列表
```typescript
interface RegionGroup {
region: string; // 华东、华南等
totalAssets: number;
operatingCount: number;
inventoryCount: number;
customers: string[];
typeBreakdown: { type: string; total: number; operating: number; inventory: number; customers: string[] }[];
}
```
### 前端 UI
参照 lnoneos 1882-2174 行:
- 筛选弹出框(客户搜索/区域/城市下拉)
- 可展开区域行,展开后显示车型子行
- 桌面表格 + 移动端卡片
## 模块 3客户运营统计
### 后端 API
**`GET /api/vehicles/customer-stats`** — 返回 `CustomerStats[]`
`Vehicle.customerName` 分组(只统计 status=Operating 的车辆),每个客户统计:
- 关联业务员customerManager、品牌brandLabel、部门departmentName
- 大区(从 province/city 映射)、城市
- 6 个车型分列计数 + 合计
```typescript
interface CustomerStats {
customer: string;
manager: string;
brand: string;
department: string;
region: string;
city: string;
t4_5: number;
t4_5c: number;
t18: number;
t49: number;
trailer: number;
other: number;
total: number;
}
```
### 前端 UI
参照 lnoneos 2176-2496 行:
- 筛选弹出框(客户名/业务员搜索,品牌/部门/区域下拉)
- 翡翠绿色主题表头
- 客户表格,各车型列可点击下钻
- 展开后显示 4 个详情卡片(客户详情/主要车型/运营状态/资产占比)
- 桌面表格 + 移动端卡片
## 文件变更清单
| 文件 | 变更 |
|---|---|
| `src/server/routes/vehicles.ts` | 新增 3 个 API 端点 + 扩展 `/list` 的过滤参数 + 大区映射函数 |
| `src/types.ts` | 新增 `DeptGroup`, `ManagerStats`, `CustomerStats`, `RegionGroup` 接口 |
| `src/server/types.ts` | 同步新增后端类型 |
| `src/api.ts` | 新增 `fetchDeptStats`, `fetchRegionStats`, `fetchCustomerStats` |
| `src/App.tsx` | 新增 3 个 section + 相关 state/toggle/filter 逻辑 + 扩展 showPlateNumbers 类型 |
## 实现顺序
1. 后端:大区映射 + 3 个 API + 扩展 list 过滤
2. 前端类型 + API 客户端
3. 部门运营统计 UI
4. 区域运营统计 UI
5. 客户运营统计 UI
6. 验证构建通过

View File

@@ -0,0 +1,224 @@
# 智能调度模块设计
基于里程考核数据,通过贪心优先级匹配算法,生成车辆替换建议,帮助调度员优化车队里程分布,最大化达标车辆数。
## 业务背景
公司有多批次考核车辆40台普货、190台冷藏车等每批次有年度里程考核目标。车辆租赁给不同客户客户实际使用强度差异大。考核的是**车辆本身的里程**,因此需要通过替换车辆来均衡里程:
- 高里程客户的已达标车换下来,换上里程缺口大的车(让新车追赶)
- 低里程客户的无望达标车换下来给高里程客户(抢救),给低里程客户换上已达标的车
## 核心算法
### 车辆分类
`tab_mileage_assessment_vehicle` 获取所有考核车辆,按客户聚合计算**客户日均里程**(客户下所有车辆近 30 天日均里程的平均值),然后对每辆车计算:
```
预测年终里程 = 当前累计里程 + 客户日均里程 × 剩余天数
达标概率 = 预测年终里程 / 年度目标里程
```
分为三类:
| 类型 | 条件 | 含义 |
|------|------|------|
| qualified | `currentYearIsQualified = true` 或 达标概率 ≥ 120% | 已完成或铁定完成 |
| hopeless | 达标概率 < 60% | 按当前客户使用强度,年底肯定完不成 |
| normal | 60% ≤ 达标概率 < 120% | 有希望但不确定,暂不干预 |
### 替换建议生成
**场景 Areplace_qualified高里程客户的已达标车辆**
- 目标:把已达标的车换下来,换上里程缺口大的库存车
- 候选池库存车rent_status='在库'+ 同车型 + 同区域
- 排序:优先选剩余缺口最大但换后仍可达标的车
- 校验:`候选车当前累计 + 客户日均 × 剩余天数 ≥ 年度目标` 才推荐
**场景 Brescue_hopeless低里程客户的无望达标车辆**
- 目标:把无望车换给高里程客户抢救,给低里程客户换上已达标/库存车
- 候选池:库存中已达标或将达标的同车型同区域车辆
- 排序:优先选已达标且里程最高的车(对低里程客户无影响)
### 车型匹配规则
| 源车型 | 可替换为 | 说明 |
|--------|---------|------|
| 4.5T冷链 | 4.5T冷链、4.5T普货 | 冷链不开空调可当普货用 |
| 4.5T普货 | 4.5T普货 | 不能反向替换冷链 |
| 18T | 18T | 同型号互换 |
| 49T | 49T | 同型号互换 |
| 挂车 | 挂车 | 同型号互换 |
### 区域匹配规则
复用已有 `mapRegion()` 函数,将 province/city 映射到大区(嘉兴/广东/北京/新疆/其他)。同一大区内可替换,跨大区不推荐。
### 优先级排序
干预清单排序:
1. **hopeless + 有可行替换方案** → priority: high最紧急还能抢救
2. **qualified + 高里程客户 + 有库存可换** → priority: medium释放达标车让新车追赶
## 后端 API
### GET /api/scheduling/suggestions
获取调度建议列表。每次请求实时计算(不使用定时缓存),因为用户操作后需要立即看到最新结果。
**请求参数**
| 参数 | 类型 | 说明 |
|------|------|------|
| targetId | number (可选) | 按批次筛选,不传则全部 |
**响应**
```typescript
{
summary: {
qualifiedCount: number; // 已达标车辆数
hopelessCount: number; // 无望达标车辆数
suggestionCount: number; // 可干预建议数
estimatedGain: number; // 预计干预后可新增达标数
};
suggestions: SchedulingSuggestion[];
targets: { id: number; name: string; vehicleCount: number }[];
}
```
`SchedulingSuggestion` 结构:
```typescript
{
id: string; // 建议唯一ID如 "s-{plate}-{timestamp}"
priority: 'high' | 'medium';
type: 'replace_qualified' | 'rescue_hopeless';
currentVehicle: {
plateNumber: string;
targetId: number;
targetName: string; // 所属批次
vehicleType: string; // "4.5T冷链" / "18T" 等
totalMileage: number;
completionRate: number; // 0-1
yearTarget: number; // 年度目标里程
region: string; // 大区(嘉兴/广东等)
province: string; // 原始省份
customer: string;
customerAvgDaily: number; // 客户日均里程
predictedYearEnd: number; // 预测年终里程
daysLeft: number;
};
candidates: {
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;
}[];
reason: string; // 建议原因文案
}
```
### POST /api/scheduling/notify
发送替换通知。成功后前端立即重新拉取 suggestions。
**请求体**
```typescript
{
suggestionId: string;
currentPlate: string;
candidatePlate: string;
}
```
操作人从 JWT auth 中获取。
**响应**`{ success: boolean; message: string }`
**行为**:调用外部回调接口发送通知(具体回调 URL 后续配置)。成功后在本地记录已操作状态,后续 GET suggestions 时排除已操作的建议。
### 数据查询流程
后端一次请求聚合以下数据:
1. 所有考核车辆 — `tab_mileage_assessment_vehicle`(里程进度、达标状态)
2. 所有考核目标 — `tab_mileage_assessment_target`(批次名称、年度目标)
3. 库存车辆 — `tab_truck WHERE truck_rent_status = 0`(在库)+ 同表获取车型
4. 车辆实时位置 — `tab_truck_remote_sync_realtime_info`province, city
5. 合同/客户信息 — 复用 `vehicle-info.ts` 已有的 JOIN 查询
6. 客户日均里程 — 按客户聚合 `v_vehicle_daily_stats` 近 30 天均值
## 前端结构
### 文件组织
```
src/modules/scheduling/
├── SchedulingModule.tsx // 主入口,状态管理和数据加载
├── SuggestionList.tsx // 干预建议清单列表
├── SuggestionDetail.tsx // 单条建议展开详情(含替换车辆对比)
├── api.ts // fetchSuggestions(), sendNotify()
└── types.ts // SchedulingSuggestion 等类型定义
```
后端:
```
src/server/routes/scheduling/
├── index.ts // 路由注册
├── suggestions.ts // GET /suggestions 算法核心
└── notify.ts // POST /notify 回调通知
```
### 页面层级
```
智能调度 Tab
├── 顶部:批次选择器(复用里程统计的批次 tabs默认"全部"
├── 统计卡片区3 个)
│ ├── 已达标车辆数(绿色)
│ ├── 无望达标车辆数(红色)
│ └── 可干预建议数 + 预计可新增达标数(蓝色)
├── 干预建议清单(主列表,按优先级排序)
│ ├── 每条:车牌、批次、客户、客户日均、完成率、区域、类型标签(已达标/无望)
│ └── 点击 → 展开干预详情
└── 干预详情(弹窗)
├── 当前车辆信息卡片
├── 推荐替换车辆列表(最多 5 辆)
│ └── 每辆显示对比:替换前后的区域、车型、里程、预测达标
├── 建议原因说明
└── 「发送替换通知」按钮 → notify 接口 → 成功后刷新列表
```
### UI 设计要求
- 以原型 `SmartSchedulingView` 组件为基础风格
- 使用 ui-ux-pro-max 优化视觉质量
- 适配移动端(竖屏卡片流)和 Web 端landscape 横屏大表格)
- 干预详情弹窗需截图友好:完整卡片布局、替换前后对比一屏可见、关键数据醒目
- 统计卡片区保持与原型一致的三列 grid 布局
- 批次选择器横向滚动 pill 按钮样式
### 技术栈
复用项目已有React 19 + Tailwind CSS + motion/react动画+ recharts图表+ lucide-react图标
## 约束与边界
- 替换仅为建议,不直接操作数据库修改车辆归属
- 不能推荐已租赁给其他客户的车辆,只从库存(在库)中推荐
- 跨批次可替换,但车型必须匹配(含冷链→普货单向规则)
- 同大区内替换,不跨大区
- notify 操作后数据立即更新(不使用定时缓存)
- 客户名称展示需使用已有的脱敏/Blur 组件

View File

@@ -0,0 +1,178 @@
沪A00113F
沪A00220F
沪A00333F
沪A00607F
沪A01056F
沪A01311F
沪A01775F
沪A01813F
沪A01855F
沪A02303F
沪A02311F
沪A02326F
沪A02361F
沪A02720F
沪A03086F
沪A03397F
沪A03565F
沪A03620F
沪A03659F
沪A03801F
沪A03870F
沪A05035F
沪A05113F
沪A05223F
沪A05501F
沪A05675F
沪A05697F
沪A05830F
沪A06335F
沪A06599F
沪A06695F
沪A07006F
沪A07153F
沪A07806F
沪A08037F
沪A08150F
沪A08315F
沪A08598F
沪A08786F
沪A09100F
沪A09251F
沪A09276F
沪A09303F
沪A09313F
沪A09322F
沪A09689F
沪A30010F
沪A30399F
沪A31031F
沪A31211F
沪A31281F
沪A31308F
沪A31381F
沪A31613F
沪A32269F
沪A33216F
沪A35236F
沪A35798F
沪A35879F
沪A35898F
沪A36133F
沪A36169F
沪A36569F
沪A36980F
沪A37785F
沪A38795F
沪A39287F
沪A39289F
沪A39585F
沪A39608F
沪A39626F
沪A39815F
沪A39835F
沪A39912F
沪A50026F
沪A50069F
沪A50309F
沪A51580F
沪A51612F
沪A51677F
沪A51893F
沪A52331F
沪A52511F
沪A53309F
沪A53322F
沪A53506F
沪A53960F
沪A55179F
沪A55297F
沪A55339F
沪A55666F
沪A55695F
沪A56122F
沪A56701F
沪A56959F
沪A56988F
沪A57139F
沪A57167F
沪A57198F
沪A57838F
沪A57850F
沪A57895F
沪A58087F
沪A58159F
沪A58185F
沪A58307F
沪A58533F
沪A58538F
沪A58593F
沪A58922F
沪A59095F
沪A59510F
沪A59613F
沪A59682F
沪A59799F
沪A59932F
沪A60339F
沪A60691F
沪A60820F
沪A61187F
沪A61193F
沪A61312F
沪A61559F
沪A61600F
沪A61711F
沪A61738F
沪A62322F
沪A62772F
沪A62928F
沪A63013F
沪A63305F
沪A63522F
沪A63660F
沪A63697F
沪A65036F
沪A65181F
沪A65522F
沪A65995F
沪A66216F
沪A66256F
沪A66329F
沪A66593F
沪A66710F
沪A66921F
沪A67018F
沪A67033F
沪A67872F
沪A68115F
沪A68139F
沪A68332F
沪A68613F
沪A68658F
沪A68752F
沪A69311F
沪A69826F
沪A69997F
沪A85021F
沪A89315F
沪A89385F
沪A89662F
浙F00885F
浙F08889F
浙F09898F
粤A00255F
粤A02683F
粤A02956F
粤A03502F
粤A03532F
粤A03569F
粤A05106F
粤A05391F
粤A05428F
粤A05839F
粤A05985F
粤A05995F
粤A06569F
粤A06931F
粤A06932F

60
scripts-tmp/find_extra.ts Normal file
View File

@@ -0,0 +1,60 @@
import mysql from 'mysql2/promise';
import fs from 'node:fs';
const pool = mysql.createPool({
host: 'rm-uf65w5v2r77n674x2.mysql.rds.aliyuncs.com',
port: 3306,
user: 'root',
password: 'LN#Passw0rd@2026',
database: 'lingniu_prod',
connectTimeout: 15000, ssl: { rejectUnauthorized: false },
});
async function main() {
const excelPlates = new Set(
fs.readFileSync('/Users/kkfluous/Projects/ai-coding/ln-bi/scripts-tmp/excel_plates.txt', 'utf8').trim().split('\n').map((s) => s.trim())
);
console.log('excel plates:', excelPlates.size);
// 按 dept-stats 逻辑查金可鹏 18T Operating
const [rows] = await pool.query<any[]>(`
SELECT truck.plate_number AS plate,
dic_type.dic_name AS type_label,
dic_status.dic_name AS status_label,
cus.customer_name AS customer,
org_truck.org_name AS subject_org
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
LEFT JOIN tab_dic dic_status ON dic_status.parent_code='dic_truck_rent_status' AND dic_status.dic_code=truck.truck_rent_status AND dic_status.is_deleted=0
LEFT JOIN tab_truck_status_info si ON si.truck_id=truck.id AND si.is_deleted=0
LEFT JOIN tab_contract c ON c.id=si.contract_id AND c.is_deleted=0
LEFT JOIN tab_customer cus ON cus.id=c.customer_id AND cus.is_deleted=0
LEFT JOIN tab_org org_truck ON org_truck.id=truck.org_id AND org_truck.is_deleted=0
LEFT JOIN tab_user u ON u.id=c.bd AND u.is_deleted=0
WHERE truck.is_deleted=0 AND truck.is_operation=1
AND u.user_name='金可鹏'
AND dic_type.dic_name LIKE '%18吨%'
AND dic_status.dic_name IN ('租赁','自营','挂靠')
ORDER BY truck.plate_number
`);
console.log('DB 金可鹏 18T operating:', rows.length);
const dbPlates = new Set((rows as any[]).map((r) => (r.plate || '').trim()));
const extra = [...dbPlates].filter((p) => !excelPlates.has(p)).sort();
const missing = [...excelPlates].filter((p) => !dbPlates.has(p)).sort();
console.log('\n=== DB 有但 Excel 没有(多出来的) ===');
console.log('数量:', extra.length);
for (const p of extra) {
const r = (rows as any[]).find((x) => x.plate === p);
console.log(' ', p, '|', r?.type_label, '|', r?.customer, '|', r?.subject_org);
}
console.log('\n=== Excel 有但 DB 没有 ===');
console.log('数量:', missing.length);
for (const p of missing) console.log(' ', p);
await pool.end();
}
main().catch((e) => { console.error(e); process.exit(1); });

View File

@@ -1,18 +1,36 @@
import { Truck, Route } from 'lucide-react'; import { useMemo } from 'react';
import { Truck, Route, Activity } from 'lucide-react';
import { Shell, type ModuleConfig } from './components/Shell'; import { Shell, type ModuleConfig } from './components/Shell';
import AssetsModule from './modules/assets/AssetsModule'; import AssetsModule from './modules/assets/AssetsModule';
import MileageModule from './modules/mileage/MileageModule'; import MileageModule from './modules/mileage/MileageModule';
import SchedulingModule from './modules/scheduling/SchedulingModule';
import AuthProvider from './auth/AuthProvider'; import AuthProvider from './auth/AuthProvider';
import { useAuth } from './auth/useAuth'; import { useAuth } from './auth/useAuth';
import UnauthorizedPage from './auth/UnauthorizedPage'; import UnauthorizedPage from './auth/UnauthorizedPage';
const MODULES: ModuleConfig[] = [ const SCHEDULING_ALLOWED_USERS = new Set([
'1105261382487539712',
'1116631120763437056',
]);
const BASE_MODULES: ModuleConfig[] = [
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule }, { id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule }, { id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
]; ];
const SCHEDULING_MODULE: ModuleConfig = {
id: 'scheduling', label: '智能调度', icon: Activity, component: SchedulingModule,
};
function AuthGate() { function AuthGate() {
const { isLoading, isAuthenticated, error } = useAuth(); const { isLoading, isAuthenticated, error, user } = useAuth();
const modules = useMemo(() => {
if (user?.userId && SCHEDULING_ALLOWED_USERS.has(user.userId)) {
return [...BASE_MODULES, SCHEDULING_MODULE];
}
return BASE_MODULES;
}, [user?.userId]);
if (isLoading) { if (isLoading) {
return ( return (
@@ -29,7 +47,7 @@ function AuthGate() {
return <UnauthorizedPage message={error || undefined} />; return <UnauthorizedPage message={error || undefined} />;
} }
return <Shell modules={MODULES} />; return <Shell modules={modules} />;
} }
export default function App() { export default function App() {

View File

@@ -65,7 +65,7 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
const jumpToken = params.get('jumpToken'); const jumpToken = params.get('jumpToken');
if (!jumpToken) { if (!jumpToken) {
setState({ isLoading: false, isAuthenticated: false, user: null, error: '未提供跳转令牌' }); setState({ isLoading: false, isAuthenticated: false, user: null, error: '请从业务系统跳转访问' });
return; return;
} }

View File

@@ -3,7 +3,7 @@ import { createContext, useContext } from 'react';
export interface AuthState { export interface AuthState {
isLoading: boolean; isLoading: boolean;
isAuthenticated: boolean; isAuthenticated: boolean;
user: { userName: string; permissionLevel: string; depName: string } | null; user: { userId: string; userName: string; permissionLevel: string; depName: string } | null;
error: string | null; error: string | null;
} }

View File

@@ -14,6 +14,7 @@ const PATH_MAP: Record<string, string> = {
'/vehicle': 'assets', '/vehicle': 'assets',
'/assets': 'assets', '/assets': 'assets',
'/mileage': 'mileage', '/mileage': 'mileage',
'/scheduling': 'scheduling',
}; };
function getInitialModule(modules: ModuleConfig[]): string { function getInitialModule(modules: ModuleConfig[]): string {

View File

@@ -11,6 +11,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import type { TargetSummary, TargetVehicle, TrendPoint } from './types'; import type { TargetSummary, TargetVehicle, TrendPoint } from './types';
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api'; import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
import Blur from '../../components/Blur';
function getDefaultDate(): string { function getDefaultDate(): string {
const now = new Date(); const now = new Date();
@@ -344,7 +345,7 @@ export default function StatisticsView() {
{(targetVehiclesMap[target.id] || []).slice(0, 5).map(tv => ( {(targetVehiclesMap[target.id] || []).slice(0, 5).map(tv => (
<div key={tv.plateNumber} className="bg-slate-50/50/50 px-2 py-1.5 rounded-lg flex items-center justify-between"> <div key={tv.plateNumber} className="bg-slate-50/50/50 px-2 py-1.5 rounded-lg flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[10px] font-mono font-bold text-slate-700">{tv.plateNumber}</span> <span className="text-[10px] font-mono font-bold text-slate-700"><Blur>{tv.plateNumber}</Blur></span>
<span className="text-[7px] px-1 rounded bg-green-100 text-green-600 font-bold"> <span className="text-[7px] px-1 rounded bg-green-100 text-green-600 font-bold">
线 线
</span> </span>
@@ -561,7 +562,7 @@ export default function StatisticsView() {
</div> </div>
<div className="overflow-hidden flex-1"> <div className="overflow-hidden flex-1">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-xs font-black text-slate-900 font-mono">{tv.plateNumber}</span> <span className="text-xs font-black text-slate-900 font-mono"><Blur>{tv.plateNumber}</Blur></span>
<span className={`text-[8px] px-1 rounded ${tv.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}> <span className={`text-[8px] px-1 rounded ${tv.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
{tv.isOnline ? '在线' : '离线'} {tv.isOnline ? '在线' : '离线'}
</span> </span>

View File

@@ -0,0 +1,404 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Filter, RotateCcw, X, Search, ChevronDown } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { fetchSuggestions } from './api';
import type { SchedulingResponse, SchedulingSuggestion } from './types';
import SuggestionList from './SuggestionList';
import SuggestionDetail from './SuggestionDetail';
type TypeFilter = 'all' | 'qualified' | 'hopeless';
interface AdvancedFilters {
plateSearch: string;
region: string;
vehicleType: string;
customer: string;
department: string;
manager: string;
}
const EMPTY_FILTERS: AdvancedFilters = { plateSearch: '', region: '', vehicleType: '', customer: '', department: '', manager: '' };
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}`;
}
function hasActiveFilters(f: AdvancedFilters): boolean {
return f.plateSearch !== '' || f.region !== '' || f.vehicleType !== '' || f.customer !== '';
}
function FilterSelect({ label, options, value, onChange, placeholder }: {
label: string; options: string[]; value: string; onChange: (v: string) => void; placeholder: string;
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const ref = useRef<HTMLDivElement>(null);
const filtered = options.filter(o => o.toLowerCase().includes(search.toLowerCase()));
useEffect(() => {
const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
return (
<div ref={ref} className="space-y-1">
<label className="text-[10px] text-slate-400 uppercase font-bold">{label}</label>
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between bg-slate-50 rounded-lg px-3 py-2 text-xs text-left cursor-pointer hover:bg-slate-100 transition-colors"
>
<span className={value ? 'text-slate-800 font-medium' : 'text-slate-400'}>{value || placeholder}</span>
<ChevronDown size={14} className={`text-slate-400 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="bg-white border border-slate-200 rounded-lg shadow-lg max-h-48 overflow-hidden z-10 relative">
{options.length > 5 && (
<div className="p-1.5 border-b border-slate-100">
<div className="relative">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-slate-400" />
<input type="text" value={search} onChange={e => setSearch(e.target.value)} placeholder="搜索..." autoFocus
className="w-full pl-7 pr-2 py-1.5 text-xs bg-slate-50 rounded border-none outline-none" />
</div>
</div>
)}
<div className="overflow-y-auto max-h-36">
<button onClick={() => { onChange(''); setOpen(false); setSearch(''); }}
className={`w-full text-left px-3 py-2 text-xs hover:bg-slate-50 cursor-pointer ${!value ? 'text-blue-600 font-bold' : 'text-slate-400'}`}></button>
{filtered.map(opt => (
<button key={opt} onClick={() => { onChange(opt); setOpen(false); setSearch(''); }}
className={`w-full text-left px-3 py-2 text-xs hover:bg-slate-50 cursor-pointer ${value === opt ? 'text-blue-600 font-bold bg-blue-50' : 'text-slate-700'}`}>{opt}</button>
))}
</div>
</div>
)}
</div>
);
}
/** Skeleton pulse block */
function Sk({ className }: { className?: string }) {
return <div className={`animate-pulse bg-slate-200/70 rounded ${className ?? ''}`} />;
}
function SkeletonPage() {
return (
<div className="min-h-screen bg-[#F0F4F8] font-sans p-3 md:p-6">
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 md:pb-0">
{/* Cards skeleton */}
<div className="grid grid-cols-3 gap-2.5">
{[0, 1, 2].map(i => (
<div key={i} className="p-4 rounded-2xl bg-white border border-slate-100 space-y-2.5">
<Sk className="h-3 w-16" />
<Sk className="h-7 w-12" />
<Sk className="h-2.5 w-24" />
</div>
))}
</div>
{/* List card skeleton */}
<div className="bg-white rounded-2xl border border-slate-200/60 overflow-hidden">
{/* Header */}
<div className="px-4 py-3 border-b border-slate-100 space-y-3">
<div className="flex items-center justify-between">
<Sk className="h-4 w-28" />
<div className="flex gap-2"><Sk className="h-6 w-6 rounded-lg" /><Sk className="h-6 w-6 rounded-lg" /></div>
</div>
<div className="flex gap-2">
{[0, 1, 2, 3].map(i => <Sk key={i} className="h-7 w-20 rounded-full" />)}
</div>
</div>
{/* Rows */}
<div className="divide-y divide-slate-50">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="px-4 py-3 flex items-center gap-3">
<Sk className="w-1 h-10 rounded-full" />
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<Sk className="h-3.5 w-20" />
<Sk className="h-3 w-10 rounded-full" />
<Sk className="h-3 w-14" />
</div>
<div className="flex items-center gap-3">
<Sk className="h-2.5 w-28" />
<Sk className="h-2.5 w-16" />
<Sk className="h-2.5 w-14" />
</div>
</div>
<Sk className="h-4 w-8" />
</div>
))}
</div>
</div>
</div>
</div>
);
}
export default function SchedulingModule() {
const [data, setData] = useState<SchedulingResponse | null>(null);
const [loading, setLoading] = useState(false);
const [selectedTargetId, setSelectedTargetId] = useState<number | undefined>(undefined);
const [selectedSuggestion, setSelectedSuggestion] = useState<SchedulingSuggestion | null>(null);
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
const [showFilter, setShowFilter] = useState(false);
const [filters, setFilters] = useState<AdvancedFilters>(EMPTY_FILTERS);
const [tempFilters, setTempFilters] = useState<AdvancedFilters>(EMPTY_FILTERS);
const loadData = useCallback(async () => {
setLoading(true);
try { setData(await fetchSuggestions(selectedTargetId)); } finally { setLoading(false); }
}, [selectedTargetId]);
useEffect(() => { loadData(); }, [loadData]);
const handleNotifySuccess = useCallback(() => { loadData(); }, [loadData]);
const filterOptions = useMemo(() => {
if (!data) return { regions: [], vehicleTypes: [], customers: [], departments: [], managers: [] };
const r = new Set<string>(), t = new Set<string>(), c = new Set<string>(), d = new Set<string>(), m = new Set<string>();
for (const s of data.suggestions) {
const v = s.currentVehicle;
if (v.region) r.add(v.region);
if (v.vehicleType) t.add(v.vehicleType);
if (v.customer) c.add(v.customer);
if (v.department) d.add(v.department);
if (v.manager) m.add(v.manager);
}
return { regions: [...r].sort(), vehicleTypes: [...t].sort(), customers: [...c].sort(), departments: [...d].sort(), managers: [...m].sort() };
}, [data]);
const filteredSuggestions = useMemo(() => {
if (!data) return [];
let list = data.suggestions;
if (typeFilter === 'qualified') list = list.filter(s => s.type === 'replace_qualified');
if (typeFilter === 'hopeless') list = list.filter(s => s.type === 'rescue_hopeless');
if (filters.plateSearch) { const q = filters.plateSearch.toLowerCase(); list = list.filter(s => s.currentVehicle.plateNumber.toLowerCase().includes(q)); }
if (filters.region) list = list.filter(s => s.currentVehicle.region === filters.region);
if (filters.vehicleType) list = list.filter(s => s.currentVehicle.vehicleType === filters.vehicleType);
if (filters.customer) list = list.filter(s => s.currentVehicle.customer === filters.customer);
if (filters.department) list = list.filter(s => s.currentVehicle.department === filters.department);
if (filters.manager) list = list.filter(s => s.currentVehicle.manager === filters.manager);
return list;
}, [data, typeFilter, filters]);
const summary = data?.summary;
const activeFilterCount = [filters.plateSearch, filters.region, filters.vehicleType, filters.customer, filters.department, filters.manager].filter(Boolean).length;
// Initial load — full page skeleton
if (loading && !data) return <SkeletonPage />;
return (
<div className="min-h-screen bg-[#F0F4F8] text-slate-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">
{/* ===== Summary Cards ===== */}
<div className="grid grid-cols-3 gap-2.5">
{/* 里程高·换下 — warm orange */}
<button
onClick={() => setTypeFilter(typeFilter === 'qualified' ? 'all' : 'qualified')}
className={`p-3.5 rounded-2xl text-left transition-all cursor-pointer ${
typeFilter === 'qualified'
? 'bg-orange-500 text-white shadow-lg shadow-orange-500/25'
: 'bg-gradient-to-br from-orange-50 to-amber-50 border border-orange-200/60'
}`}
>
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'qualified' ? 'text-orange-100' : 'text-orange-600'}`}>
·
</div>
<div className={`text-2xl font-black ${typeFilter === 'qualified' ? 'text-white' : 'text-orange-700'}`}>
{loading && !data ? '-' : summary?.qualifiedCount ?? 0}
<span className={`text-[10px] font-normal ml-1 ${typeFilter === 'qualified' ? 'text-orange-200' : 'text-orange-400'}`}></span>
</div>
<div className={`text-[9px] mt-0.5 ${typeFilter === 'qualified' ? 'text-orange-200' : 'text-orange-400'}`}>
</div>
</button>
{/* 里程低·换走 — cool blue */}
<button
onClick={() => setTypeFilter(typeFilter === 'hopeless' ? 'all' : 'hopeless')}
className={`p-3.5 rounded-2xl text-left transition-all cursor-pointer ${
typeFilter === 'hopeless'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/25'
: 'bg-gradient-to-br from-blue-50 to-indigo-50 border border-blue-200/60'
}`}
>
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'hopeless' ? 'text-blue-100' : 'text-blue-600'}`}>
·
</div>
<div className={`text-2xl font-black ${typeFilter === 'hopeless' ? 'text-white' : 'text-blue-700'}`}>
{loading && !data ? '-' : summary?.hopelessCount ?? 0}
<span className={`text-[10px] font-normal ml-1 ${typeFilter === 'hopeless' ? 'text-blue-200' : 'text-blue-400'}`}></span>
</div>
<div className={`text-[9px] mt-0.5 ${typeFilter === 'hopeless' ? 'text-blue-200' : 'text-blue-400'}`}>
</div>
</button>
{/* 替换建议 — neutral dark */}
<button
onClick={() => setTypeFilter('all')}
className={`p-3.5 rounded-2xl text-left transition-all cursor-pointer ${
typeFilter === 'all'
? 'bg-slate-800 text-white shadow-lg shadow-slate-800/25'
: 'bg-gradient-to-br from-slate-50 to-slate-100 border border-slate-200/60'
}`}
>
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'all' ? 'text-slate-300' : 'text-slate-500'}`}>
</div>
<div className={`text-2xl font-black ${typeFilter === 'all' ? 'text-white' : 'text-slate-800'}`}>
{loading && !data ? '-' : summary?.suggestionCount ?? 0}
<span className={`text-[10px] font-normal ml-1 ${typeFilter === 'all' ? 'text-slate-400' : 'text-slate-400'}`}></span>
</div>
<div className={`text-[9px] mt-0.5 ${typeFilter === 'all' ? 'text-slate-400' : 'text-slate-400'}`}>
+{summary?.estimatedGain ?? 0}
</div>
</button>
</div>
{/* ===== List Card ===== */}
<div className="bg-white rounded-2xl border border-slate-200/60 shadow-sm overflow-hidden">
{/* Header */}
<div className="px-4 py-3 border-b border-slate-100">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-bold text-slate-900"></h3>
<div className="flex items-center gap-1">
<button onClick={loadData} disabled={loading}
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer">
<RotateCcw size={15} className={loading ? 'animate-spin' : ''} />
</button>
<button
onClick={() => { setShowFilter(!showFilter); setTempFilters(filters); }}
className={`relative p-1.5 transition-colors rounded-lg cursor-pointer ${
showFilter || activeFilterCount > 0 ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-50'
}`}
>
<Filter size={15} />
{activeFilterCount > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-blue-600 text-white text-[8px] font-bold rounded-full flex items-center justify-center">{activeFilterCount}</span>
)}
</button>
</div>
</div>
<div className="flex gap-2 overflow-x-auto no-scrollbar">
<button
onClick={() => { setSelectedTargetId(undefined); setTypeFilter('all'); }}
className={`px-4 py-1.5 rounded-full text-[11px] font-bold whitespace-nowrap transition-all cursor-pointer ${
selectedTargetId === undefined ? 'bg-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
</button>
{data?.targets.map(t => (
<button key={t.id}
onClick={() => { setSelectedTargetId(t.id); setTypeFilter('all'); }}
className={`px-4 py-1.5 rounded-full text-[11px] font-bold whitespace-nowrap transition-all cursor-pointer ${
selectedTargetId === t.id ? 'bg-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
{shortTargetName(t.name)}
</button>
))}
</div>
</div>
{/* Filter Panel */}
<AnimatePresence>
{showFilter && (
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} exit={{ height: 0, opacity: 0 }} className="overflow-hidden border-b border-slate-100">
<div className="px-4 py-4 bg-slate-50/60 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-700"></span>
{hasActiveFilters(tempFilters) && (
<button onClick={() => setTempFilters(EMPTY_FILTERS)} className="text-[10px] text-slate-400 hover:text-slate-600 cursor-pointer"></button>
)}
</div>
<div className="space-y-1">
<label className="text-[10px] text-slate-400 uppercase font-bold"></label>
<div className="relative">
<Search size={12} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input type="text" value={tempFilters.plateSearch} onChange={e => setTempFilters(prev => ({ ...prev, plateSearch: e.target.value }))}
placeholder="搜索车牌号..." className="w-full pl-8 pr-3 py-2 bg-white rounded-lg text-xs border border-slate-200 outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400 transition-all" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<FilterSelect label="运营区域" options={filterOptions.regions} value={tempFilters.region} onChange={v => setTempFilters(prev => ({ ...prev, region: v }))} placeholder="全部区域" />
<FilterSelect label="车辆类型" options={filterOptions.vehicleTypes} value={tempFilters.vehicleType} onChange={v => setTempFilters(prev => ({ ...prev, vehicleType: v }))} placeholder="全部类型" />
</div>
<div className="grid grid-cols-2 gap-3">
<FilterSelect label="业务部门" options={filterOptions.departments} value={tempFilters.department} onChange={v => setTempFilters(prev => ({ ...prev, department: v }))} placeholder="全部部门" />
<FilterSelect label="业务负责人" options={filterOptions.managers} value={tempFilters.manager} onChange={v => setTempFilters(prev => ({ ...prev, manager: v }))} placeholder="全部负责人" />
</div>
<FilterSelect label="客户" options={filterOptions.customers} value={tempFilters.customer} onChange={v => setTempFilters(prev => ({ ...prev, customer: v }))} placeholder="全部客户" />
<div className="flex gap-2 pt-1">
<button onClick={() => setShowFilter(false)} className="flex-1 py-2 text-xs font-bold text-slate-500 bg-white border border-slate-200 rounded-lg cursor-pointer hover:bg-slate-50 transition-colors"></button>
<button onClick={() => { setFilters(tempFilters); setShowFilter(false); }} className="flex-1 py-2 text-xs font-bold text-white bg-slate-800 rounded-lg cursor-pointer hover:bg-slate-900 transition-colors shadow-sm"></button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Active filter tags */}
{activeFilterCount > 0 && !showFilter && (
<div className="px-4 py-2 border-b border-slate-100 flex items-center gap-2 flex-wrap">
<span className="text-[10px] text-slate-400">:</span>
{filters.plateSearch && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1"> "{filters.plateSearch}" <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, plateSearch: '' }))} /></span>}
{filters.region && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.region} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, region: '' }))} /></span>}
{filters.vehicleType && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.vehicleType} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, vehicleType: '' }))} /></span>}
{filters.department && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.department} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, department: '' }))} /></span>}
{filters.manager && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.manager} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, manager: '' }))} /></span>}
{filters.customer && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.customer} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, customer: '' }))} /></span>}
<button onClick={() => setFilters(EMPTY_FILTERS)} className="text-[10px] text-slate-400 hover:text-slate-600 cursor-pointer"></button>
</div>
)}
{(activeFilterCount > 0 || typeFilter !== 'all') && (
<div className="px-4 py-1.5 border-b border-slate-50 text-[10px] text-slate-400"> {filteredSuggestions.length} </div>
)}
{loading ? (
/* List skeleton while refreshing */
<div className="divide-y divide-slate-50">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="px-4 py-3 flex items-center gap-3">
<Sk className="w-1 h-10 rounded-full" />
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<Sk className="h-3.5 w-20" />
<Sk className="h-3 w-10 rounded-full" />
<Sk className="h-3 w-14" />
</div>
<div className="flex items-center gap-3">
<Sk className="h-2.5 w-28" />
<Sk className="h-2.5 w-16" />
<Sk className="h-2.5 w-14" />
</div>
</div>
<Sk className="h-4 w-8" />
</div>
))}
</div>
) : (
<SuggestionList suggestions={filteredSuggestions} onSelect={setSelectedSuggestion} />
)}
</div>
{selectedSuggestion && (
<SuggestionDetail suggestion={selectedSuggestion} onClose={() => setSelectedSuggestion(null)} onNotifySuccess={handleNotifySuccess} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,203 @@
import { useState } from 'react';
import {
X, MapPin, AlertTriangle, CheckCircle, ArrowDown, ArrowUp, ArrowRight,
} from 'lucide-react';
import { motion } from 'motion/react';
import type { SchedulingSuggestion, CandidateVehicle } from './types';
import Blur from '../../components/Blur';
import SwapPreview from './SwapPreview';
interface Props {
suggestion: SchedulingSuggestion;
onClose: () => void;
onNotifySuccess: () => void;
}
function fmtKm(value: number): string {
if (value >= 10000) return (value / 10000).toFixed(1) + '万';
return value.toLocaleString();
}
function fmtRate(rate: number): string {
return (rate * 100).toFixed(1) + '%';
}
export default function SuggestionDetail({ suggestion: s, onClose, onNotifySuccess }: Props) {
const [previewCandidate, setPreviewCandidate] = useState<CandidateVehicle | null>(null);
const [sentPlates, setSentPlates] = useState<Set<string>>(new Set());
const v = s.currentVehicle;
const isRescue = s.type === 'rescue_hopeless';
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-end sm:items-center justify-center" onClick={onClose}>
<motion.div
initial={{ y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
onClick={e => e.stopPropagation()}
className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:max-w-lg overflow-hidden flex flex-col max-h-[92vh] sm:max-h-[85vh] sm:mx-4"
>
{/* Header — unified dark slate */}
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between flex-shrink-0">
<div className="flex items-center gap-2">
{isRescue
? <ArrowDown size={14} className="text-blue-300" />
: <ArrowUp size={14} className="text-amber-300" />
}
<span className="text-white font-bold text-sm">
{isRescue ? '里程低·换走此车' : '里程高·换下此车'}
</span>
</div>
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors p-1 cursor-pointer">
<X size={18} />
</button>
</div>
{/* Body */}
<div className="overflow-y-auto flex-1 no-scrollbar">
{/* Current Vehicle */}
<div className="px-4 py-3 bg-slate-50 border-b border-slate-100">
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2">
<span className="text-base font-black text-slate-900 font-mono"><Blur>{v.plateNumber}</Blur></span>
<span className="text-[9px] px-1.5 py-0.5 rounded bg-white text-slate-500 font-bold border border-slate-200">{v.vehicleType}</span>
</div>
<span className={`text-lg font-black tabular-nums ${v.completionRate >= 1 ? 'text-emerald-600' : v.completionRate >= 0.5 ? 'text-amber-600' : 'text-rose-600'}`}>
{fmtRate(v.completionRate)}
</span>
</div>
<div className="text-[10px] text-slate-500 space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-slate-400">{v.targetName}</span>
<span className="text-slate-200">|</span>
<span> <b className="text-slate-700">{fmtKm(v.currentYearMileage)}</b></span>
<span> <b className="text-slate-700">{fmtKm(v.yearTarget)}</b> km</span>
<span className="flex items-center gap-0.5"><MapPin size={9} /> {v.region}</span>
</div>
<div className="flex items-center gap-2">
<span> <b className="text-slate-700"><Blur>{v.customer || '-'}</Blur></b></span>
<span> <b className="text-slate-700">{Math.round(v.customerAvgDaily)}</b> km</span>
</div>
</div>
</div>
{/* Reason */}
<div className="px-4 py-2 text-[11px] text-slate-500 leading-relaxed border-b border-slate-100 bg-amber-50/50">
<span className="text-amber-700 font-bold"></span>
<span className="text-slate-600">{s.reason}</span>
</div>
{/* Candidates */}
<div className="px-4 py-3">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-bold text-slate-700">
{isRescue ? '从库存调入替换' : '换上以下里程少的车'}
</span>
<span className="text-[10px] text-slate-400">{s.candidates.length} </span>
</div>
<div className="text-[10px] text-slate-400 mb-2.5">
{isRescue
? '以下车辆快达标,换到当前客户处利用剩余天数即可冲线'
: '以下车辆里程缺口大,换到该高里程客户处可加速达标'
}
</div>
<div className="space-y-2">
{s.candidates.map(c => {
const sent = sentPlates.has(c.plateNumber);
return (
<div key={c.plateNumber} className="rounded-xl border border-slate-200 overflow-hidden bg-white">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{c.plateNumber}</Blur></span>
<span className="text-[9px] text-slate-400">{c.vehicleType}</span>
<span className="text-[9px] text-slate-300">{c.targetName || '库存'}</span>
</div>
{c.canQualifyAfterSwap ? (
<span className="text-[9px] font-bold text-emerald-600 flex items-center gap-0.5 bg-emerald-50 px-1.5 py-0.5 rounded">
<CheckCircle size={10} />
</span>
) : (
<span className="text-[9px] font-bold text-amber-500 flex items-center gap-0.5 bg-amber-50 px-1.5 py-0.5 rounded">
<AlertTriangle size={10} />
</span>
)}
</div>
{/* Metrics — compact table style */}
<div className="px-3 pb-2">
<div className="flex text-[10px] bg-slate-50 rounded-lg overflow-hidden divide-x divide-slate-200">
<div className="flex-1 py-1.5 px-2 text-center">
<div className="text-slate-400"></div>
<div className="font-bold text-slate-700">{fmtKm(c.totalMileage)}</div>
</div>
<div className="flex-1 py-1.5 px-2 text-center">
<div className="text-blue-400"></div>
<div className="font-bold text-blue-700">{c.yearTarget ? fmtKm(c.yearTarget) : '-'}</div>
</div>
<div className="flex-1 py-1.5 px-2 text-center">
<div className="text-rose-400"></div>
<div className="font-bold text-rose-600">{fmtKm(c.mileageGap)}</div>
</div>
<div className="flex-1 py-1.5 px-2 text-center">
<div className="text-slate-400"></div>
<div className="font-bold text-slate-700">{c.region}</div>
</div>
<div className="flex-1 py-1.5 px-2 text-center">
<div className="text-slate-400"></div>
<div className={`font-bold ${c.canQualifyAfterSwap ? 'text-emerald-600' : 'text-amber-600'}`}>{fmtKm(c.predictedAfterSwap)}</div>
</div>
</div>
</div>
{/* Action */}
<div className="px-3 pb-2.5">
<button
onClick={() => setPreviewCandidate(c)}
disabled={sent}
className={`w-full flex items-center justify-center gap-1.5 text-[11px] font-bold py-2 rounded-lg transition-all cursor-pointer ${
sent
? 'bg-emerald-50 text-emerald-600'
: 'bg-slate-800 hover:bg-slate-900 text-white active:scale-[0.98] shadow-sm'
}`}
>
{sent ? <><CheckCircle size={12} /> </> : <> <ArrowRight size={12} /></>}
</button>
</div>
</div>
);
})}
</div>
</div>
</div>
{/* Footer */}
<div className="border-t border-slate-100 px-4 py-2.5 flex-shrink-0">
<button
onClick={onClose}
className="w-full py-2 text-xs font-bold text-slate-500 bg-slate-50 hover:bg-slate-100 rounded-lg transition-colors cursor-pointer"
>
</button>
</div>
</motion.div>
{/* Full-screen swap preview */}
{previewCandidate && (
<SwapPreview
suggestion={s}
candidate={previewCandidate}
onClose={() => setPreviewCandidate(null)}
onSuccess={() => {
setSentPlates(prev => new Set(prev).add(previewCandidate.plateNumber));
setPreviewCandidate(null);
onNotifySuccess();
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { ArrowRightLeft, ChevronRight } from 'lucide-react';
import { motion } from 'motion/react';
import type { SchedulingSuggestion } from './types';
import Blur from '../../components/Blur';
interface Props {
suggestions: SchedulingSuggestion[];
onSelect: (s: SchedulingSuggestion) => void;
}
function fmtRate(rate: number): string {
return (rate * 100).toFixed(1) + '%';
}
export default function SuggestionList({ suggestions, onSelect }: Props) {
if (suggestions.length === 0) {
return (
<div className="py-16 text-center">
<ArrowRightLeft className="w-8 h-8 text-slate-200 mx-auto mb-2" />
<p className="text-sm text-slate-400"></p>
</div>
);
}
return (
<div className="divide-y divide-slate-50">
{suggestions.map((s, idx) => {
const isRescue = s.type === 'rescue_hopeless';
const v = s.currentVehicle;
return (
<motion.div
key={s.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: Math.min(idx * 0.02, 0.3) }}
className="px-4 py-3 hover:bg-slate-50/60 cursor-pointer transition-colors active:bg-slate-100 flex items-center gap-3"
onClick={() => onSelect(s)}
>
{/* Color bar */}
<div className={`w-1 h-10 rounded-full flex-shrink-0 ${isRescue ? 'bg-blue-400' : 'bg-amber-400'}`} />
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-black text-slate-900 font-mono">
<Blur>{v.plateNumber}</Blur>
</span>
<span className={`text-[9px] px-1.5 py-px rounded font-bold ${
isRescue ? 'bg-blue-50 text-blue-600' : 'bg-amber-50 text-amber-600'
}`}>
{isRescue ? '里程低·换走' : '里程高·换下'}
</span>
<span className="text-[9px] text-slate-400">{v.vehicleType}</span>
<span className="text-[9px] text-slate-300">·</span>
<span className="text-[9px] text-slate-400">{v.region}</span>
</div>
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-slate-400 overflow-hidden">
<span className="truncate max-w-[40%] flex-shrink"><Blur>{v.customer || '-'}</Blur></span>
<span className="flex-shrink-0"> <span className="text-slate-600 font-medium">{Math.round(v.customerAvgDaily)}</span> km</span>
<span className="flex-shrink-0"> <span className={`font-medium ${v.completionRate >= 1 ? 'text-emerald-600' : v.completionRate >= 0.5 ? 'text-amber-600' : 'text-rose-500'}`}>{fmtRate(v.completionRate)}</span></span>
</div>
</div>
{/* Right */}
<div className="flex items-center gap-1 flex-shrink-0">
<span className="text-xs font-bold text-blue-600">{s.candidates.length}</span>
<span className="text-[9px] text-slate-400"></span>
<ChevronRight size={14} className="text-slate-300" />
</div>
</motion.div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,132 @@
import { useState } from 'react';
import { ArrowDownUp, CheckCircle, Send, X } from 'lucide-react';
import { sendNotify } from './api';
import type { SchedulingSuggestion, CandidateVehicle } from './types';
import Blur from '../../components/Blur';
interface Props {
suggestion: SchedulingSuggestion;
candidate: CandidateVehicle;
onClose: () => void;
onSuccess: () => void;
}
function fmtKm(value: number): string {
if (value >= 10000) return (value / 10000).toFixed(1) + '万';
return value.toLocaleString();
}
function fmtRate(rate: number): string {
return (rate * 100).toFixed(1) + '%';
}
export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSuccess }: Props) {
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const v = s.currentVehicle;
const handleSend = async () => {
if (sending || sent) return;
setSending(true);
try {
const result = await sendNotify({ suggestionId: s.id, currentPlate: v.plateNumber, candidatePlate: c.plateNumber });
if (result.success) { setSent(true); onSuccess(); } else { alert(result.message || '发送失败'); }
} catch { alert('网络错误'); } finally { setSending(false); }
};
return (
<div className="fixed inset-0 z-[80] bg-[#F0F4F8] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-5 py-3 bg-white border-b border-slate-200 flex-shrink-0">
<span className="text-sm font-bold text-slate-800"></span>
<button onClick={onClose} className="p-1 text-slate-400 hover:text-slate-600 cursor-pointer"><X size={20} /></button>
</div>
{/* Content */}
<div className="flex-1 overflow-auto px-5 py-5">
<div className="max-w-sm mx-auto space-y-4">
{/* === Swap Cards === */}
<div className="relative">
{/* Current vehicle */}
<div className="bg-white rounded-2xl p-4 border border-slate-200 shadow-sm">
<div className="flex items-start justify-between">
<div>
<div className="text-lg font-black text-slate-900 font-mono"><Blur>{v.plateNumber}</Blur></div>
<div className="text-[10px] text-slate-400 mt-0.5">{v.vehicleType} · {v.targetName}</div>
</div>
<div className="text-right">
<div className="text-base font-black text-slate-800">{fmtKm(v.currentYearMileage)}<span className="text-[9px] text-slate-400 ml-0.5">km</span></div>
<div className="text-[10px] text-slate-400"> {fmtKm(v.yearTarget)} km</div>
</div>
</div>
<div className="flex items-center gap-3 mt-2.5 text-[10px] text-slate-500">
<span><Blur>{v.customer || '-'}</Blur></span>
<span> <b className="text-slate-700">{Math.round(v.customerAvgDaily)}</b></span>
<span> <b className={v.completionRate >= 1 ? 'text-emerald-600' : 'text-rose-500'}>{fmtRate(v.completionRate)}</b></span>
</div>
</div>
{/* Arrow bridge */}
<div className="flex justify-center -my-3 relative z-10">
<div className="w-10 h-10 rounded-full bg-slate-800 flex items-center justify-center shadow-lg">
<ArrowDownUp size={16} className="text-white" />
</div>
</div>
{/* Replacement vehicle */}
<div className="bg-white rounded-2xl p-4 border border-emerald-300 shadow-sm">
<div className="flex items-start justify-between">
<div>
<div className="text-lg font-black text-slate-900 font-mono"><Blur>{c.plateNumber}</Blur></div>
<div className="text-[10px] text-slate-400 mt-0.5">{c.vehicleType} · {c.targetName || '库存'} · {c.region}</div>
</div>
<div className="text-right">
<div className="text-base font-black text-slate-800">{fmtKm(c.totalMileage)}<span className="text-[9px] text-slate-400 ml-0.5">km</span></div>
<div className="text-[10px] text-slate-400"> {c.yearTarget ? fmtKm(c.yearTarget) : '-'} km</div>
</div>
</div>
<div className="flex items-center gap-3 mt-2.5 text-[10px] text-slate-500">
<span> <b className="text-rose-500">{fmtKm(c.mileageGap)}</b></span>
</div>
</div>
</div>
{/* === Result === */}
<div className="bg-white rounded-2xl p-4 border border-slate-200 shadow-sm">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-3"></div>
<div className="flex items-end gap-6">
<div>
<div className="text-[9px] text-slate-400 mb-0.5"></div>
<div className="text-xl font-black text-slate-800">{fmtKm(c.predictedAfterSwap)} <span className="text-[10px] font-normal text-slate-400">km</span></div>
</div>
<div>
<div className="text-[9px] text-slate-400 mb-0.5"></div>
<div className="text-xl font-black text-slate-800">{c.yearTarget ? fmtKm(c.yearTarget) : '-'} <span className="text-[10px] font-normal text-slate-400">km</span></div>
</div>
<div className={`text-sm font-black px-3 py-1 rounded-lg ${c.canQualifyAfterSwap ? 'bg-emerald-50 text-emerald-600' : 'bg-amber-50 text-amber-600'}`}>
{c.canQualifyAfterSwap ? '可达标' : '需关注'}
</div>
</div>
</div>
</div>
</div>
{/* Bottom */}
<div className="px-5 pb-6 pt-2 flex-shrink-0 bg-[#F0F4F8]">
<div className="max-w-sm mx-auto">
<button
onClick={handleSend}
disabled={sending || sent}
className={`w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-bold transition-all cursor-pointer ${
sent ? 'bg-emerald-100 text-emerald-600' : 'bg-slate-800 hover:bg-slate-900 text-white active:scale-[0.98] shadow-lg'
}`}
>
{sent ? <><CheckCircle size={16} /> </> : <><Send size={16} /> </>}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
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),
});
}

View File

@@ -0,0 +1,61 @@
export interface SchedulingVehicleInfo {
plateNumber: string;
targetId: number;
targetName: string;
vehicleType: string;
totalMileage: number;
currentYearMileage: number;
completionRate: number;
yearTarget: number;
region: string;
province: string;
customer: string | null;
department: string | null;
manager: 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[];
}

View File

@@ -5,6 +5,7 @@ import { cors } from 'hono/cors';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import vehiclesRouter from './routes/vehicles.js'; import vehiclesRouter from './routes/vehicles.js';
import mileageRouter from './routes/mileage/index.js'; import mileageRouter from './routes/mileage/index.js';
import schedulingRouter from './routes/scheduling/index.js';
import authRouter from './auth/login.js'; import authRouter from './auth/login.js';
import { authMiddleware } from './auth/middleware.js'; import { authMiddleware } from './auth/middleware.js';
@@ -22,6 +23,7 @@ app.use('/api/*', authMiddleware);
app.route('/api/vehicles', vehiclesRouter); app.route('/api/vehicles', vehiclesRouter);
app.route('/api/mileage', mileageRouter); app.route('/api/mileage', mileageRouter);
app.route('/api/scheduling', schedulingRouter);
app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() })); app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() }));

View File

@@ -0,0 +1,235 @@
import type {
EnrichedVehicle, InventoryVehicle, SchedulingSuggestion,
CandidateVehicle, VehicleClassification, SchedulingSummary,
} from './types.js';
function fmtKmSimple(v: number): string {
if (v >= 10000) return (v / 10000).toFixed(1) + '万';
return Math.round(v).toLocaleString();
}
// ---------------------------------------------------------------------------
// 1. Vehicle type compatibility
// ---------------------------------------------------------------------------
export function isTypeCompatible(sourceType: string, candidateType: string): boolean {
if (sourceType === candidateType) return true;
// Cold-chain 4.5T can replace plain-cargo 4.5T
if (candidateType === '4.5T冷链' && (sourceType === '4.5T冷链' || sourceType === '4.5T普货')) return true;
return false;
}
// ---------------------------------------------------------------------------
// 2. Vehicle classification
// ---------------------------------------------------------------------------
export function classifyVehicle(
currentYearIsQualified: boolean,
predictedYearEnd: number,
yearTarget: number,
): VehicleClassification {
if (currentYearIsQualified || predictedYearEnd / yearTarget >= 1.2) return 'qualified';
if (predictedYearEnd / yearTarget < 0.6) return 'hopeless';
return 'normal';
}
// ---------------------------------------------------------------------------
// 3. Helper convert EnrichedVehicle to SchedulingVehicleInfo shape
// ---------------------------------------------------------------------------
import type { SchedulingVehicleInfo } from './types.js';
export function toVehicleInfo(v: EnrichedVehicle): SchedulingVehicleInfo {
// Use current year completion rate instead of overall
const yearCompletionRate = v.yearTarget > 0 ? v.currentYearMileage / v.yearTarget : 0;
return {
plateNumber: v.plateNumber,
targetId: v.targetId,
targetName: v.targetName,
vehicleType: v.vehicleType,
totalMileage: v.totalMileage,
currentYearMileage: v.currentYearMileage,
completionRate: yearCompletionRate,
yearTarget: v.yearTarget,
region: v.region,
province: v.province,
customer: v.customer,
department: v.department,
manager: v.manager,
customerAvgDaily: v.customerAvgDaily,
predictedYearEnd: v.predictedYearEnd,
daysLeft: v.daysLeft,
};
}
// ---------------------------------------------------------------------------
// 4. Main algorithm generate scheduling suggestions
// ---------------------------------------------------------------------------
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[] = [];
// --- rescue_hopeless (high priority) ---
// Take the hopeless car away → give to high-mileage customer to sprint.
// Replace with an inventory car that is CLOSE to qualifying — the low-mileage
// customer's remaining driving days can push it over the finish line.
//
// Key insight: pick candidates where
// candidate.totalMileage + customer.avgDaily × daysLeft >= yearTarget
// i.e., the customer's daily driving is enough to finish the candidate's target.
// Among those, prefer the one with the smallest gap (easiest to finish).
// Exclude already-qualified (>= 100%) — no value in swapping those.
for (const vehicle of hopeless) {
const customerCanAdd = vehicle.customerAvgDaily * vehicle.daysLeft;
const candidates: CandidateVehicle[] = inventoryVehicles
.filter((inv) => {
if (!isTypeCompatible(vehicle.vehicleType, inv.vehicleType)) return false;
if (inv.region !== vehicle.region) return false;
// Exclude already fully qualified
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
if (effectiveTarget > 0 && inv.totalMileage >= effectiveTarget) return false;
return true;
})
.map((inv) => {
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
const mileageGap = Math.max(0, effectiveTarget - inv.totalMileage);
const predictedAfterSwap = inv.totalMileage + customerCanAdd;
const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget;
return {
plateNumber: inv.plateNumber,
targetId: inv.targetId,
targetName: inv.targetName,
vehicleType: inv.vehicleType,
totalMileage: inv.totalMileage,
completionRate: inv.completionRate,
yearTarget: inv.yearTarget ?? vehicle.yearTarget,
region: inv.region,
province: inv.province,
mileageGap,
predictedAfterSwap,
canQualifyAfterSwap,
};
})
.sort((a, b) => {
// 1. Prefer "can qualify after swap" first
if (a.canQualifyAfterSwap !== b.canQualifyAfterSwap)
return a.canQualifyAfterSwap ? -1 : 1;
// 2. Among qualifiable: smallest gap first (easiest to finish)
// Among non-qualifiable: smallest gap first (closest to target)
return a.mileageGap - b.mileageGap;
})
.slice(0, 5);
const yearRate = vehicle.yearTarget > 0 ? Math.round((vehicle.currentYearMileage / vehicle.yearTarget) * 100) : 0;
const gap = Math.max(0, vehicle.yearTarget - vehicle.currentYearMileage);
const canAddKm = Math.round(customerCanAdd);
const reason = `该车在客户「${vehicle.customer}」处日均仅 ${Math.round(vehicle.customerAvgDaily)} km完成率 ${yearRate}%,还差 ${fmtKmSimple(gap)} km年底无法达标。`
+ `\n建议将此车换走给高里程客户冲刺换上一辆快达标的车——该客户剩余 ${vehicle.daysLeft} 天还能跑约 ${fmtKmSimple(canAddKm)} km足以帮缺口小的车冲线。`;
suggestions.push({
id: `hopeless-${vehicle.plateNumber}`,
priority: 'high',
type: 'rescue_hopeless',
currentVehicle: toVehicleInfo(vehicle),
candidates,
reason,
});
}
// --- replace_qualified (medium priority) ---
// Swap out the qualified car, swap in a car that NEEDS mileage.
// The high-mileage customer will drive it hard → helps it reach target.
// Exclude candidates already at target (gap <= 0) — swapping those in is pointless.
for (const vehicle of qualified) {
if (vehicle.customerAvgDaily <= vehicle.dailyRequiredMileage) continue;
const candidates: CandidateVehicle[] = inventoryVehicles
.filter((inv) => {
if (!isTypeCompatible(vehicle.vehicleType, inv.vehicleType)) return false;
if (inv.region !== vehicle.region) return false;
// Must still need mileage — exclude already-qualified inventory
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
if (effectiveTarget > 0 && inv.totalMileage >= effectiveTarget) return false;
return true;
})
.map((inv) => {
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
const mileageGap = Math.max(0, effectiveTarget - inv.totalMileage);
const predictedAfterSwap =
inv.totalMileage + vehicle.customerAvgDaily * vehicle.daysLeft;
const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget;
return {
plateNumber: inv.plateNumber,
targetId: inv.targetId,
targetName: inv.targetName,
vehicleType: inv.vehicleType,
totalMileage: inv.totalMileage,
completionRate: inv.completionRate,
yearTarget: inv.yearTarget ?? vehicle.yearTarget,
region: inv.region,
province: inv.province,
mileageGap,
predictedAfterSwap,
canQualifyAfterSwap,
};
})
.sort((a, b) => {
// 1. canQualifyAfterSwap first
if (a.canQualifyAfterSwap !== b.canQualifyAfterSwap)
return a.canQualifyAfterSwap ? -1 : 1;
// 2. Among qualifiable: biggest gap first (most value from the swap)
return b.mileageGap - a.mileageGap;
})
// Only keep candidates that can actually qualify at this customer
.filter(c => c.canQualifyAfterSwap)
.slice(0, 5);
// Skip if no candidate can reach target — swap would be pointless
if (candidates.length === 0) continue;
const yearRate = vehicle.yearTarget > 0 ? Math.round((vehicle.currentYearMileage / vehicle.yearTarget) * 100) : 0;
const canAddKm = vehicle.customerAvgDaily * vehicle.daysLeft;
const reason = `该车在客户「${vehicle.customer}」处已达标(完成率 ${yearRate}%),客户日均 ${Math.round(vehicle.customerAvgDaily)} km × ${vehicle.daysLeft} 天 ≈ ${fmtKmSimple(canAddKm)} km。`
+ `\n建议换上里程未达标的车利用该客户的高日均帮新车快速冲线。`;
suggestions.push({
id: `qualified-${vehicle.plateNumber}`,
priority: 'medium',
type: 'replace_qualified',
currentVehicle: toVehicleInfo(vehicle),
candidates,
reason,
});
}
// Remove suggestions with no candidates
const filteredSuggestions = suggestions.filter((s) => s.candidates.length > 0);
// Sort: high priority first
filteredSuggestions.sort((a, b) => {
if (a.priority === b.priority) return 0;
return a.priority === 'high' ? -1 : 1;
});
// estimatedGain: count suggestions where at least one candidate canQualifyAfterSwap,
// plus rescue_hopeless suggestions (each rescued car can potentially qualify at a new customer)
const estimatedGain = filteredSuggestions.filter((s) =>
s.candidates.some((c) => c.canQualifyAfterSwap) || s.type === 'rescue_hopeless',
).length;
const summary: SchedulingSummary = {
qualifiedCount: qualified.length,
hopelessCount: hopeless.length,
suggestionCount: filteredSuggestions.length,
estimatedGain,
};
return { suggestions: filteredSuggestions, summary };
}

View File

@@ -0,0 +1,10 @@
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;

View File

@@ -0,0 +1,41 @@
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
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 || '未知';
console.log(`[scheduling:notify] operator=${operator} suggestion=${suggestionId} current=${currentPlate} candidate=${candidatePlate}`);
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;

View File

@@ -0,0 +1,328 @@
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';
// ---------------------------------------------------------------------------
// Helper: vehicle type classification
// ---------------------------------------------------------------------------
/**
* Infer vehicle type from target name when truck table has no match.
* e.g. "交投190辆4.5T冷链车" → "4.5T冷链", "羚牛100辆18T" → "18T"
*/
function inferTypeFromTargetName(targetName: string): string {
const t = targetName || '';
if (t.includes('冷链')) return '4.5T冷链';
if (t.includes('普货') || (t.includes('4.5') && !t.includes('冷链'))) return '4.5T普货';
if (t.includes('18T') || t.includes('18t')) return '18T';
if (t.includes('49') || t.includes('牵引')) return '49T';
if (t.includes('挂车')) return '挂车';
return '其他';
}
/**
* Classify vehicle type from dic_type.dic_name (e.g. "4.5吨冷链车", "4.5吨货车", "18吨双飞翼货车").
* The typeName is the full label from the dictionary, modelRaw is the numeric dic_code.
*/
function classifyVehicleType(typeName: string, _modelRaw: string): string {
const t = (typeName || '').trim();
if (t.includes('4.5') && t.includes('冷链')) return '4.5T冷链';
if (t.includes('4.5')) return '4.5T普货';
if (t.includes('18')) return '18T';
if (t.includes('49') || t.includes('牵引')) return '49T';
if (t.includes('挂车')) return '挂车';
return t || '其他';
}
// ---------------------------------------------------------------------------
// Route
// ---------------------------------------------------------------------------
const app = new Hono();
app.get('/', async (c) => {
try {
const targetIdParam = c.req.query('targetId');
const filterTargetId = targetIdParam ? Number(targetIdParam) : null;
// ---- Query 1: 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, { targetName: string; annualMileage: number }>();
for (const t of targets) {
targetMap.set(t.id, {
targetName: t.target_name,
annualMileage: Number(t.annual_mileage_per_vehicle) || 0,
});
}
// ---- Query 2: 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];
// ---- Query 3: Vehicle info (customer, dept, manager) ----
const vehicleInfoMap = await fetchVehicleInfoMap();
// ---- Query 4: Vehicle types from tab_truck ----
// Include soft-deleted trucks: many assessment vehicles have is_deleted=1 in tab_truck
// but are still active in the assessment. We need their type info.
const [truckTypeRows] = 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_operation = 1
`) as [any[], unknown];
const truckTypeMap = new Map<string, { typeName: string; modelRaw: string }>();
for (const row of truckTypeRows) {
truckTypeMap.set(row.plate_number, {
typeName: row.type_name || '',
modelRaw: row.model_raw || '',
});
}
// ---- Query 5: Real-time location ----
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 || '',
});
}
// ---- Collect all plates for Query 6 ----
const allPlates = assessmentRows.map((r: any) => r.plate_number as string);
// ---- Query 6: Customer daily avg (from mileage DB) ----
const customerAvgDailyMap = new Map<string, number>();
if (allPlates.length > 0) {
const placeholders = allPlates.map(() => '?').join(',');
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 (${placeholders})
GROUP BY plate`,
allPlates,
) as [any[], unknown];
// Build plate → avg_daily map
const plateAvgMap = new Map<string, number>();
for (const row of dailyRows) {
plateAvgMap.set(row.plate, Number(row.avg_daily) || 0);
}
// Aggregate per customer: average of all plates belonging to each customer
const customerPlates = new Map<string, number[]>();
for (const plate of allPlates) {
const info = vehicleInfoMap.get(plate);
const customer = info?.customer || '未知客户';
if (!customerPlates.has(customer)) customerPlates.set(customer, []);
const avg = plateAvgMap.get(plate);
if (avg !== undefined) customerPlates.get(customer)!.push(avg);
}
for (const [customer, avgs] of customerPlates) {
if (avgs.length > 0) {
customerAvgDailyMap.set(customer, avgs.reduce((s, v) => s + v, 0) / avgs.length);
}
}
}
// ---- Query 7: Inventory vehicles (rent_status = 0) ----
const [inventoryTruckRows] = 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];
// ---- Build assessment vehicle lookup for inventory cross-reference ----
const assessmentByPlate = new Map<string, any>();
for (const row of assessmentRows) {
assessmentByPlate.set(row.plate_number, row);
}
// ---- Enrich assessment vehicles ----
const now = new Date();
const yearEnd = new Date(now.getFullYear(), 11, 31); // Dec 31
const enrichedVehicles: EnrichedVehicle[] = [];
for (const row of assessmentRows) {
const targetId = row.target_id as number;
if (filterTargetId !== null && targetId !== filterTargetId) continue;
const target = targetMap.get(targetId);
if (!target) continue;
const plate = row.plate_number as string;
const info = vehicleInfoMap.get(plate);
// Only include vehicles that are actively rented/operated (租赁 or 自营)
const rentStatus = info?.rent_status || '';
if (rentStatus !== '租赁' && rentStatus !== '自营') continue;
const loc = locationMap.get(plate);
const truckType = truckTypeMap.get(plate);
const province = loc?.province || '';
const city = loc?.city || '';
const region = mapRegion(province, city);
// Determine vehicle type: prefer truck table, fallback to target name
let vehicleType = '其他';
if (truckType) {
vehicleType = classifyVehicleType(truckType.typeName, truckType.modelRaw);
} else {
// Fallback: infer from target name (e.g. "交投190辆4.5T冷链车" → "4.5T冷链")
vehicleType = inferTypeFromTargetName(target.targetName);
}
const endDate = row.current_year_assessment_end_date
? new Date(row.current_year_assessment_end_date)
: yearEnd;
const daysLeft = Math.max(1, Math.ceil((endDate.getTime() - now.getTime()) / 86400000));
const customer = info?.customer || null;
const customerAvgDaily = customerAvgDailyMap.get(customer || '未知客户') || 0;
const currentYearMileage = Number(row.current_year_mileage) || 0;
const yearTarget = Number(row.current_year_mileage_task) || 0;
const predictedYearEnd = currentYearMileage + customerAvgDaily * daysLeft;
const currentYearIsQualified = row.current_year_is_qualified === 1;
const classification = classifyVehicle(currentYearIsQualified, predictedYearEnd, yearTarget);
enrichedVehicles.push({
plateNumber: plate,
targetId,
targetName: target.targetName,
vehicleType,
totalMileage: Number(row.vehicle_total_mileage) || 0,
currentYearMileage,
completionRate: Number(row.completion_rate) || 0,
yearTarget,
isQualified: row.is_qualified === 1,
currentYearIsQualified,
dailyRequiredMileage: Number(row.daily_required_mileage) || 0,
region,
province,
customer,
department: info?.department || null,
manager: info?.manager || null,
customerAvgDaily,
predictedYearEnd,
daysLeft,
classification,
});
}
// ---- Build inventory vehicles ----
const inventoryVehicles: InventoryVehicle[] = [];
for (const row of inventoryTruckRows) {
const plate = row.plate_number as string;
const loc = locationMap.get(plate);
const province = loc?.province || '';
const city = loc?.city || '';
const region = mapRegion(province, city);
const vehicleType = classifyVehicleType(row.type_name || '', row.model_raw || '');
// Cross-reference with assessment data
const assessment = assessmentByPlate.get(plate);
inventoryVehicles.push({
plateNumber: plate,
vehicleType,
region,
province,
totalMileage: assessment ? Number(assessment.vehicle_total_mileage) || 0 : 0,
targetId: assessment ? (assessment.target_id as number) : null,
targetName: assessment ? (targetMap.get(assessment.target_id)?.targetName ?? null) : null,
yearTarget: assessment ? Number(assessment.current_year_mileage_task) || null : null,
completionRate: assessment ? Number(assessment.completion_rate) || 0 : 0,
});
}
// ---- Run algorithm ----
const { suggestions, summary } = generateSuggestions(enrichedVehicles, inventoryVehicles);
// ---- Permission filtering & customer name masking ----
const user = (c as any).get('user') as AuthUser | undefined;
// Attach department/manager info so filterByPermission can work
const suggestionsWithPermFields = suggestions.map((s) => {
const info = vehicleInfoMap.get(s.currentVehicle.plateNumber);
return {
...s,
department: info?.department || null,
departmentName: info?.department || null,
managerId: info?.manager_id || null,
};
});
const filtered = user
? filterByPermission(suggestionsWithPermFields, user)
: suggestionsWithPermFields;
// Mask customer names in suggestions
const masked = maskCustomerNames(
filtered.map((s) => {
// Strip permission-filtering fields from response
const { department, departmentName, managerId, ...rest } = s;
return rest;
}),
);
// ---- Build target options list for filter UI ----
const targetVehicleCounts = new Map<number, number>();
for (const v of enrichedVehicles) {
targetVehicleCounts.set(v.targetId, (targetVehicleCounts.get(v.targetId) || 0) + 1);
}
const targetOptions = targets.map((t: any) => ({
id: t.id as number,
name: t.target_name as string,
vehicleCount: targetVehicleCounts.get(t.id) || 0,
}));
const response: SchedulingResponse = {
summary,
suggestions: masked,
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: [],
} satisfies SchedulingResponse,
500,
);
}
});
export default app;

View File

@@ -0,0 +1,104 @@
export interface SchedulingVehicleInfo {
plateNumber: string;
targetId: number;
targetName: string;
vehicleType: string;
totalMileage: number;
currentYearMileage: number;
completionRate: number; // 本年完成率 currentYearMileage / yearTarget
yearTarget: number;
region: string;
province: string;
customer: string | null;
department: string | null;
manager: 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;
}
export type VehicleClassification = 'qualified' | 'hopeless' | 'normal';
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;
department: string | null;
manager: string | null;
customerAvgDaily: number;
predictedYearEnd: number;
daysLeft: number;
classification: VehicleClassification;
}
export interface InventoryVehicle {
plateNumber: string;
vehicleType: string;
region: string;
province: string;
totalMileage: number;
targetId: number | null;
targetName: string | null;
yearTarget: number | null;
completionRate: number;
}

View File

@@ -91,7 +91,7 @@ WHERE truck.is_deleted = 0
const REGIONS = ['嘉兴', '广东', '北京', '新疆', '其他'] as const; const REGIONS = ['嘉兴', '广东', '北京', '新疆', '其他'] as const;
const INVENTORY_REGIONS = ['江浙沪', '广东', '新疆', '其它'] as const; const INVENTORY_REGIONS = ['江浙沪', '广东', '新疆', '其它'] as const;
function mapRegion(province: string | null, city: string | null): string { export function mapRegion(province: string | null, city: string | null): string {
if (!province && !city) return '其他'; if (!province && !city) return '其他';
const loc = (city || province || '').trim(); const loc = (city || province || '').trim();
if (loc.includes('嘉兴') || loc.includes('浙江') || loc.includes('上海') || loc.includes('江苏')) return '嘉兴'; if (loc.includes('嘉兴') || loc.includes('浙江') || loc.includes('上海') || loc.includes('江苏')) return '嘉兴';