- 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>
927 lines
31 KiB
Markdown
927 lines
31 KiB
Markdown
# 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"
|
|
```
|