Files
ln-bi/docs/superpowers/plans/2026-04-02-mileage-backend-refactor.md
kkfluous 253cc2f2c0 fix(scheduling): fix vehicle type classification and algorithm candidate matching
- classifyVehicleType now parses dic_type.dic_name (e.g. "4.5吨冷链车") instead of raw model code
- Remove overly strict completionRate >= 0.8 filter for hopeless candidates
- Use vehicle's yearTarget as fallback when inventory has no assessment target
- Filter out suggestions with no candidates (not actionable)
- estimatedGain counts rescue_hopeless suggestions as potential gains

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:31:44 +08:00

927 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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"
```