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>
This commit is contained in:
BIN
docs/superpowers/.DS_Store
vendored
Normal file
BIN
docs/superpowers/.DS_Store
vendored
Normal file
Binary file not shown.
753
docs/superpowers/plans/2026-03-27-three-modules.md
Normal file
753
docs/superpowers/plans/2026-03-27-three-modules.md
Normal 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"
|
||||
```
|
||||
926
docs/superpowers/plans/2026-04-02-mileage-backend-refactor.md
Normal file
926
docs/superpowers/plans/2026-04-02-mileage-backend-refactor.md
Normal 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"
|
||||
```
|
||||
153
docs/superpowers/specs/2026-03-27-three-modules-design.md
Normal file
153
docs/superpowers/specs/2026-03-27-three-modules-design.md
Normal 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. 验证构建通过
|
||||
Reference in New Issue
Block a user