diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index c3857d4..22c2f88 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -94,13 +94,13 @@ server/
| `vehicles/` | `routes.ts` `repository.ts` `model.ts` `utils.ts` `types.ts` | ✅ 完整分层;SQL 有契约测试 |
| `ele/` | `routes.ts` `repository.ts` `model.ts`(+ `model.test.ts` `routes.test.ts`) | ✅ 完整分层;改造前后 SQL/参数/响应体已做等价性验证 |
| `feedback/` | `routes.ts` `repository.ts` `oss.ts`(+ `routes.test.ts`) | ✅ 完整分层;同上 |
-| `mileage/` | `index.ts`(聚合)+ `monitoring.ts` `targets.ts` `trend.ts` `daily-report.ts` `vehicle-recent.ts` + `*-model.ts` + `cache.ts` `oneos-api.ts` `daily-report-{service,store,scheduler}.ts` | 路由 / 模型 / 服务已分开,**SQL 仍在各路由文件内** |
+| `mileage/` | `index.ts`(聚合)+ `monitoring.ts` `targets.ts` `trend.ts` `daily-report.ts` `vehicle-recent.ts` + `repository.ts` + `*-model.ts` + `cache.ts` `oneos-api.ts` `daily-report-{service,store,scheduler}.ts` | ✅ 完整分层;车辆关联信息 SQL 与 18 个查询集中在此 |
| `energy/` | `index.ts`(聚合)+ `electric.ts` `etc.ts` `hydrogen-station-board.ts` `hydrogen-bi-v2.ts` + `repository.ts` + `query-model.ts` `cache.ts` `constants.ts` | ✅ 完整分层;WHERE 片段组装(`buildFilterClauses` / `buildGroupSelect` / `buildScopedWhere` / `resolvedWhere`)与共享 SQL 片段也都在 repository / constants |
| `scheduling/` | `index.ts`(聚合)+ `suggestions.ts` `notify.ts` `repository.ts` + `algorithm.ts` `notification-model.ts` | ✅ 完整分层;跨域数据源(里程车辆信息 / OneOS)显式注入 |
| `hydrogen-heatmap/` | `routes.ts` `repository.ts` `model.ts`(+ `routes.test.ts`) | ✅ 完整分层;`buildWhere` 片段与参数顺序已锁定 |
| `vehicle-heatmap/` | `routes.ts` `repository.ts` `model.ts`(+ `routes.test.ts`) | ✅ 完整分层;同时覆盖 MySQL(考核批次)与 PG(定位点)两个库 |
-已完整分层的七个域(`vehicles` / `ele` / `feedback` / `vehicle-heatmap` / `hydrogen-heatmap` / `scheduling` / `energy`)
+**全部八个域**(`vehicles` / `ele` / `feedback` / `vehicle-heatmap` / `hydrogen-heatmap` / `scheduling` / `energy` / `mileage`)
由架构测试守护:必须存在 `repository.ts`,且该域**其他任何非测试文件都不得含 SQL**。
其余域(`energy` / `mileage`)尚未拆出 repository——拆分时**不要改变 SQL 与参数顺序**,
请按 `ele/routes.test.ts` 的配方先补契约测试,并用"改造前后同一批请求对比落库 SQL 与响应体"做等价性验证。
@@ -139,10 +139,9 @@ cors → read-only → /api/auth(公开) → authMiddleware → 各业务域
诚实记录,避免后来者以为已经做完:
-- **后端仍有 1 个域没拆出 repository**:`mileage` 的 SQL 仍分散在 `cache.ts` /
- `daily-report-store.ts` / `targets.ts` / `monitoring.ts` / `trend.ts` / `vehicle-recent.ts` 等文件里。
- 已完成的有 7 个域,可作为模板:路由只做校验与组装,SQL 进 `repository.ts`,
- 纯逻辑进 `model.ts`,并用 mock pool 的契约测试锁定 SQL 与参数。
+- **后端八个域都已拆出 repository**,SQL 只允许出现在各域的 `repository.ts`(以及共享片段模块
+ `energy/constants.ts`)中,由架构测试守护。新增查询请沿用同一形状:路由只做校验与组装,
+ SQL 进 `repository.ts`,纯逻辑进 `model.ts`,并用 mock pool 的契约测试锁定 SQL 与参数。
守卫用的是"语句形状"正则(如 `update
set`)而不是裸关键字,避免把
`UpdateNotification` 或日志里的 "update error" 误判为 SQL;小写 SQL 同样能被抓到。
- **运行时建表已集中到 `server/db/schema/`**,并在 `DB_READ_ONLY=1` 时整体跳过(由架构测试守护,
diff --git a/src/architecture.test.ts b/src/architecture.test.ts
index ae540eb..6715ee0 100644
--- a/src/architecture.test.ts
+++ b/src/architecture.test.ts
@@ -180,6 +180,7 @@ test("已完整分层的域:repository.ts 存在,且 SQL 只允许出现在
"server/routes/hydrogen-heatmap",
"server/routes/scheduling",
"server/routes/energy",
+ "server/routes/mileage",
];
// 用"语句形状"而不是裸关键字:UpdateNotification 或 "update error" 这类标识符/日志
// 不应误判,而小写 SQL 也依然能被抓到。
diff --git a/src/server/routes/mileage/cache.ts b/src/server/routes/mileage/cache.ts
index 6f2e054..a54f227 100644
--- a/src/server/routes/mileage/cache.ts
+++ b/src/server/routes/mileage/cache.ts
@@ -1,3 +1,4 @@
+import { loadMonitoringTargetRows } from './repository.js';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
@@ -51,12 +52,7 @@ export interface RangeMileageResult {
}
async function fetchTargetRows(): Promise {
- return pool.execute(
- `SELECT t.id, t.target_name, v.plate_number
- FROM lingniu_prod.tab_mileage_assessment_target t
- JOIN lingniu_prod.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 TargetRow[]);
+ return loadMonitoringTargetRows(pool).then(([rows]) => rows as TargetRow[]);
}
export async function refreshMonitoringCache(): Promise {
diff --git a/src/server/routes/mileage/daily-report-service.ts b/src/server/routes/mileage/daily-report-service.ts
index 70a485b..05d7ef4 100644
--- a/src/server/routes/mileage/daily-report-service.ts
+++ b/src/server/routes/mileage/daily-report-service.ts
@@ -1,3 +1,4 @@
+import { loadReportAssessmentRows } from './repository.js';
import type { RowDataPacket } from 'mysql2';
import pool from '../../db/mysql.js';
import type {
@@ -123,30 +124,7 @@ function shanghaiIsoNow(): string {
}
async function fetchAssessmentRows(): Promise {
- const [rows] = await pool.query(`
- SELECT
- t.id AS target_id,
- t.target_name,
- t.annual_mileage_per_vehicle,
- v.id AS assessment_vehicle_id,
- v.plate_number,
- DATE_FORMAT(v.assessment_start_date, '%Y-%m-%d') AS assessment_start_date,
- DATE_FORMAT(v.assessment_end_date, '%Y-%m-%d') AS assessment_end_date,
- v.total_mileage_task,
- v.current_mileage,
- v.completion_rate,
- v.current_year_mileage_task,
- v.current_year_mileage,
- v.current_year_completion_rate,
- v.current_year_is_qualified,
- v.daily_required_mileage,
- DATE_FORMAT(v.current_year_assessment_end_date, '%Y-%m-%d') AS current_year_assessment_end_date
- FROM lingniu_prod.tab_mileage_assessment_target t
- JOIN lingniu_prod.tab_mileage_assessment_vehicle v
- ON v.target_id = t.id AND v.is_deleted = 0
- WHERE t.is_deleted = 0
- ORDER BY t.id, v.plate_number
- `);
+ const rows = await loadReportAssessmentRows(pool) as unknown as AssessmentRow[];
return rows;
}
diff --git a/src/server/routes/mileage/daily-report-store.ts b/src/server/routes/mileage/daily-report-store.ts
index a06b62b..b0990d8 100644
--- a/src/server/routes/mileage/daily-report-store.ts
+++ b/src/server/routes/mileage/daily-report-store.ts
@@ -1,3 +1,10 @@
+import {
+ dailyReportSnapshotExists,
+ insertDailyReportSnapshot,
+ listDailyReportHistoryRows,
+ loadDailyReportPayload,
+ updateDailyReportPayload,
+} from './repository.js';
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
import pool from '../../db/mysql.js';
import { ensureDailyReportTable, DAILY_REPORT_TABLE as TABLE } from '../../db/schema/mileage-report.js';
@@ -21,19 +28,13 @@ function parsePayload(value: unknown): DailyMileageReport | null {
export async function getDailyReportSnapshot(reportDate: string): Promise {
await ensureDailyReportTable();
- const [rows] = await pool.query(
- `SELECT report_payload FROM ${TABLE} WHERE report_date = ? LIMIT 1`,
- [reportDate],
- );
+ const rows = await loadDailyReportPayload(pool, { reportDate });
return parsePayload(rows[0]?.report_payload);
}
export async function hasDailyReportSnapshot(reportDate: string): Promise {
await ensureDailyReportTable();
- const [rows] = await pool.query(
- `SELECT 1 AS found FROM ${TABLE} WHERE report_date = ? LIMIT 1`,
- [reportDate],
- );
+ const rows = await dailyReportSnapshotExists(pool, { reportDate });
return rows.length > 0;
}
@@ -57,41 +58,20 @@ export async function saveDailyReportSnapshot(
archived.generatedAt.replace('T', ' ').replace(/Z$/, '').slice(0, 19),
];
- const insert = options.replace ? 'REPLACE' : 'INSERT IGNORE';
- const [result] = await pool.query(
- `${insert} INTO ${TABLE}
- (report_date, status, source, vehicle_count, operating_count, inventory_count,
- daily_mileage, day_over_day_rate, report_payload, generated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?)`,
- values,
- );
- return result.affectedRows > 0;
+ const affected = await insertDailyReportSnapshot(pool, { replace: Boolean(options.replace), values });
+ return affected > 0;
}
export async function updateDailyReportSnapshotPayload(report: DailyMileageReport): Promise {
await ensureDailyReportTable();
- const [result] = await pool.query(
- `UPDATE ${TABLE}
- SET report_payload = CAST(? AS JSON)
- WHERE report_date = ?`,
- [JSON.stringify(report), report.reportDate],
- );
- return result.affectedRows > 0;
+ const affected = await updateDailyReportPayload(pool, { report: JSON.stringify(report), reportDate: report.reportDate });
+ return affected > 0;
}
export async function listDailyReportHistory(limit = 90): Promise {
await ensureDailyReportTable();
const safeLimit = Math.min(366, Math.max(1, limit));
- const [rows] = await pool.query(
- `SELECT DATE_FORMAT(report_date, '%Y-%m-%d') AS report_date,
- status, source, vehicle_count, operating_count, inventory_count,
- daily_mileage, day_over_day_rate,
- DATE_FORMAT(generated_at, '%Y-%m-%dT%H:%i:%s+08:00') AS generated_at
- FROM ${TABLE}
- ORDER BY report_date DESC
- LIMIT ?`,
- [safeLimit],
- );
+ const rows = await listDailyReportHistoryRows(pool, { safeLimit });
const items: MileageReportHistoryItem[] = rows.map(row => ({
reportDate: row.report_date,
status: row.status,
diff --git a/src/server/routes/mileage/repository.ts b/src/server/routes/mileage/repository.ts
new file mode 100644
index 0000000..484a4db
--- /dev/null
+++ b/src/server/routes/mileage/repository.ts
@@ -0,0 +1,322 @@
+import type { RowDataPacket } from 'mysql2';
+import { DAILY_REPORT_TABLE as TABLE } from '../../db/schema/mileage-report.js';
+
+/**
+ * 里程域的数据访问。
+ * SQL 集中在此;缓存/口径计算仍在 cache.ts、daily-report-* 等模块里。
+ */
+
+/** 只需要 query 能力的连接。 */
+export interface Database {
+ query(sql: string, values?: any[]): Promise<[T, ...any[]]>;
+}
+
+// ---------------------------------------------------------------------------
+// 里程域
+// ---------------------------------------------------------------------------
+
+/** 车辆关联信息(客户 / 部门 / 经理 / 租赁状态 / 主体 / 项目 / 品牌)。 */
+export const VEHICLE_INFO_SQL = `SELECT
+ vi.plate_number AS plate,
+ vi.vin AS vin,
+ COALESCE(c.customer_name, vor.customer_name, ci.customer_name) AS customer,
+ COALESCE(c.business_department_name, vor.business_dept) AS department,
+ COALESCE(c.business_manager_name, vor.business_manager) AS manager,
+ CAST(COALESCE(c.business_manager_id, vi.business_id) AS CHAR) AS manager_id,
+ CASE vs.operation_status
+ WHEN '1' THEN '租赁'
+ WHEN '2' THEN '自营'
+ WHEN '3' THEN '可运营'
+ WHEN '4' THEN '待运营'
+ WHEN '5' THEN '退出运营'
+ ELSE vs.operation_status
+ END AS rent_status,
+ NULLIF(vi.registered_ownership, '') AS entity,
+ COALESCE(c.project_name, vor.project_name) AS project,
+ COALESCE(vi_operation_city.NAME, NULLIF(vi.operation_city, '')) AS inventory_location,
+ CASE vm.brand
+ WHEN 'hyundai' THEN CASE WHEN vm.model LIKE '%帕力安%' OR vm.model LIKE '%冷链%' OR vm.model LIKE '%双飞翼%' THEN '帕力安牌' ELSE '现代' END
+ WHEN 'yuejin' THEN '跃进'
+ WHEN 'feichi' THEN '飞驰'
+ WHEN 'sulong' THEN '苏龙'
+ WHEN 'higer' THEN '海格'
+ WHEN 'dongfeng' THEN '东风'
+ WHEN 'yutong' THEN '宇通'
+ WHEN 'chufeng' THEN '楚风'
+ WHEN 'tonghua' THEN '通华'
+ WHEN 'maxus' THEN '大通'
+ WHEN 'mingwei' THEN '明威'
+ WHEN 'wanfeng' THEN '万风'
+ WHEN 'shujie' THEN '舒捷'
+ WHEN 'denza' THEN '腾势'
+ WHEN 'hongyan' THEN '红岩'
+ WHEN 'yuanchang brand' THEN '远程牌'
+ WHEN 'others' THEN '其他'
+ ELSE vm.brand
+ END AS brand_label
+FROM vehicle_info vi
+LEFT JOIN vehicle_status vs
+ ON vs.vehicle_id = vi.id
+ AND vs.del_flag = 0
+LEFT JOIN vehicle_model vm
+ ON vm.id = vi.vehicle_model_id
+ AND vm.del_flag = '0'
+LEFT JOIN common_district vi_operation_city
+ ON vi_operation_city.CODE = vi.operation_city COLLATE utf8mb4_unicode_ci
+ AND vi_operation_city.STATUS = 'VALID'
+LEFT JOIN vehicle_lease_order_record vor
+ ON vor.vehicle_id = vi.id
+ AND vor.del_flag = '0'
+ AND vor.id = (
+ SELECT MAX(vor2.id)
+ FROM vehicle_lease_order_record vor2
+ WHERE vor2.vehicle_id = vi.id
+ AND vor2.del_flag = '0'
+ )
+LEFT JOIN vehicle_lease_contract_info c
+ ON c.order_id = vor.contract_id
+ AND c.del_flag = '0'
+LEFT JOIN customer_info ci
+ ON ci.id = vi.customer_id
+ AND ci.del_flag = '0'
+WHERE vi.del_flag = '0'
+ AND COALESCE(vs.operation_status, '') <> '5'`;
+
+
+/** 按车牌批量查询车辆关联信息。 */
+export async function loadVehicleInfoByPlates(db: Database, args: { plates: string[] }): Promise {
+ const { plates } = args;
+ const [rows] = await db.query(`${VEHICLE_INFO_SQL} AND vi.plate_number IN (${plates.map(() => '?').join(',')})`, plates);
+ return rows;
+}
+
+
+/** 考核目标下的车牌。 */
+export async function loadTargetVehiclePlates(db: Database, args: { targetId: string }): Promise {
+ const { targetId } = args;
+ const [rows] = await db.query(`SELECT plate_number FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE target_id = ? AND is_deleted = 0`, [targetId]);
+ return rows;
+}
+
+
+/** 监控缓存的考核目标 × 车牌(全量)。 */
+export async function loadMonitoringTargetRows(db: Database): Promise {
+ const [rows] = await db.query(`SELECT t.id, t.target_name, v.plate_number
+ FROM lingniu_prod.tab_mileage_assessment_target t
+ JOIN lingniu_prod.tab_mileage_assessment_vehicle v ON v.target_id = t.id AND v.is_deleted = 0
+ WHERE t.is_deleted = 0`, []);
+ return rows;
+}
+
+
+/** 考核目标列表。 */
+export async function loadAssessmentTargetRows(db: Database): Promise {
+ const [rows] = await db.query(`SELECT * FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id`, []);
+ return rows;
+}
+
+
+/** 各目标的车辆统计。 */
+export async function loadAssessmentVehicleStats(db: Database): Promise {
+ const [rows] = await db.query(`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 lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0
+ GROUP BY target_id`, []);
+ return rows;
+}
+
+
+/** 首年目标统计。 */
+export async function loadAssessmentFirstYearStats(db: Database): Promise {
+ const [rows] = await db.query(`SELECT
+ v.target_id,
+ COUNT(*) as first_year_total,
+ SUM(t.annual_mileage_per_vehicle) as first_year_target,
+ SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle)) as first_year_completed,
+ SUM(GREATEST(t.annual_mileage_per_vehicle - v.current_mileage, 0)) as first_year_remaining,
+ SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle)) / NULLIF(SUM(t.annual_mileage_per_vehicle), 0) as first_year_completion_rate,
+ SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle THEN 1 ELSE 0 END) as first_year_qualified_count,
+ SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * 0.5 THEN 1 ELSE 0 END) as first_year_half_qualified_count,
+ DATE_FORMAT(MIN(v.assessment_start_date), '%Y-%m-%d') as first_year_start_date,
+ DATE_FORMAT(MAX(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL 1 YEAR), INTERVAL 1 DAY)), '%Y-%m-%d') as first_year_end_date
+ FROM lingniu_prod.tab_mileage_assessment_vehicle v
+ JOIN lingniu_prod.tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0
+ WHERE v.is_deleted = 0
+ GROUP BY v.target_id`, []);
+ return rows;
+}
+
+
+/** 年度考核统计。 */
+export async function loadAssessmentYearlyStats(db: Database): Promise {
+ const [rows] = await db.query(`SELECT
+ v.target_id,
+ y.year_number,
+ COUNT(*) as vehicle_count,
+ SUM(t.annual_mileage_per_vehicle * y.year_number) as target_mileage,
+ SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle * y.year_number)) as completed_mileage,
+ SUM(GREATEST(t.annual_mileage_per_vehicle * y.year_number - v.current_mileage, 0)) as remaining_mileage,
+ SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle * y.year_number))
+ / NULLIF(SUM(t.annual_mileage_per_vehicle * y.year_number), 0) as completion_rate,
+ SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * y.year_number THEN 1 ELSE 0 END) as qualified_count,
+ SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * y.year_number * 0.5 THEN 1 ELSE 0 END) as half_qualified_count,
+ DATE_FORMAT(MIN(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number - 1 YEAR)), '%Y-%m-%d') as start_date,
+ DATE_FORMAT(MAX(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number YEAR), INTERVAL 1 DAY)), '%Y-%m-%d') as end_date
+ FROM lingniu_prod.tab_mileage_assessment_vehicle v
+ JOIN lingniu_prod.tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0
+ JOIN (
+ SELECT 1 as year_number UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
+ ) y ON y.year_number <= LEAST(t.assessment_years, v.current_year_number)
+ WHERE v.is_deleted = 0
+ GROUP BY v.target_id, y.year_number
+ ORDER BY v.target_id, y.year_number`, []);
+ return rows;
+}
+
+
+/** 年度分期统计。 */
+export async function loadAssessmentYearlyPeriodStats(db: Database): Promise {
+ const [rows] = await db.query(`SELECT
+ v.target_id,
+ y.year_number,
+ DATE_FORMAT(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number - 1 YEAR), '%Y-%m-%d') as start_date,
+ DATE_FORMAT(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number YEAR), INTERVAL 1 DAY), '%Y-%m-%d') as end_date,
+ COUNT(*) as cnt
+ FROM lingniu_prod.tab_mileage_assessment_vehicle v
+ JOIN lingniu_prod.tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0
+ JOIN (
+ SELECT 1 as year_number UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
+ ) y ON y.year_number <= LEAST(t.assessment_years, v.current_year_number)
+ WHERE v.is_deleted = 0
+ GROUP BY v.target_id, y.year_number, v.assessment_start_date
+ ORDER BY v.target_id, y.year_number, v.assessment_start_date`, []);
+ return rows;
+}
+
+
+/** 分期统计。 */
+export async function loadAssessmentPeriodStats(db: Database): Promise {
+ const [rows] = await db.query(`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 lingniu_prod.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`, []);
+ return rows;
+}
+
+
+/** 考核车辆明细(全量)。 */
+export async function loadTargetVehicleRows(db: Database): Promise {
+ const [rows] = await db.query(`SELECT target_id, plate_number FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0`, []);
+ return rows;
+}
+
+
+/** 单目标年度考核车辆。 */
+export async function loadYearlyAssessments(db: Database, args: { targetId: string }): Promise {
+ const { targetId } = args;
+ const [rows] = await db.query(`SELECT plate_number, today_mileage, vehicle_total_mileage,
+ completion_rate, is_qualified, current_year_is_qualified,
+ daily_required_mileage
+ FROM lingniu_prod.tab_mileage_assessment_vehicle
+ WHERE target_id = ? AND is_deleted = 0
+ ORDER BY today_mileage DESC`, [targetId]);
+ return rows;
+}
+
+
+/** 读取日报归档负载。 */
+export async function loadDailyReportPayload(db: Database, args: { reportDate: string }): Promise {
+ const { reportDate } = args;
+ const [rows] = await db.query(`SELECT report_payload FROM ${TABLE} WHERE report_date = ? LIMIT 1`, [reportDate]);
+ return rows;
+}
+
+
+/** 日报是否已归档。 */
+export async function dailyReportSnapshotExists(db: Database, args: { reportDate: string }): Promise {
+ const { reportDate } = args;
+ const [rows] = await db.query(`SELECT 1 AS found FROM ${TABLE} WHERE report_date = ? LIMIT 1`, [reportDate]);
+ return rows;
+}
+
+
+/** 写入日报归档(REPLACE 或 INSERT IGNORE)。 */
+export async function insertDailyReportSnapshot(db: Database, args: { replace: boolean; values: unknown[] }): Promise {
+ const { replace, values } = args;
+ const [rows] = await db.query(`${replace ? "REPLACE" : "INSERT IGNORE"} INTO ${TABLE}
+ (report_date, status, source, vehicle_count, operating_count, inventory_count,
+ daily_mileage, day_over_day_rate, report_payload, generated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?)`, values);
+ return rows;
+}
+
+
+/** 更新日报负载。 */
+export async function updateDailyReportPayload(db: Database, args: { report: unknown; reportDate: string }): Promise {
+ const { report, reportDate } = args;
+ const [rows] = await db.query(`UPDATE ${TABLE}
+ SET report_payload = CAST(? AS JSON)
+ WHERE report_date = ?`, [JSON.stringify(report), reportDate]);
+ return rows;
+}
+
+
+/** 日报归档历史。 */
+export async function listDailyReportHistoryRows(db: Database, args: { safeLimit: number }): Promise {
+ const { safeLimit } = args;
+ const [rows] = await db.query(`SELECT DATE_FORMAT(report_date, '%Y-%m-%d') AS report_date,
+ status, source, vehicle_count, operating_count, inventory_count,
+ daily_mileage, day_over_day_rate,
+ DATE_FORMAT(generated_at, '%Y-%m-%dT%H:%i:%s+08:00') AS generated_at
+ FROM ${TABLE}
+ ORDER BY report_date DESC
+ LIMIT ?`, [safeLimit]);
+ return rows;
+}
+
+
+/** 日报所需的考核车辆行。 */
+export async function loadReportAssessmentRows(db: Database): Promise {
+ const [rows] = await db.query(`SELECT
+ t.id AS target_id,
+ t.target_name,
+ t.annual_mileage_per_vehicle,
+ v.id AS assessment_vehicle_id,
+ v.plate_number,
+ DATE_FORMAT(v.assessment_start_date, '%Y-%m-%d') AS assessment_start_date,
+ DATE_FORMAT(v.assessment_end_date, '%Y-%m-%d') AS assessment_end_date,
+ v.total_mileage_task,
+ v.current_mileage,
+ v.completion_rate,
+ v.current_year_mileage_task,
+ v.current_year_mileage,
+ v.current_year_completion_rate,
+ v.current_year_is_qualified,
+ v.daily_required_mileage,
+ DATE_FORMAT(v.current_year_assessment_end_date, '%Y-%m-%d') AS current_year_assessment_end_date
+ FROM lingniu_prod.tab_mileage_assessment_target t
+ JOIN lingniu_prod.tab_mileage_assessment_vehicle v
+ ON v.target_id = t.id AND v.is_deleted = 0
+ WHERE t.is_deleted = 0
+ ORDER BY t.id, v.plate_number`, []);
+ return rows;
+}
+
+
+/** 全量车辆关联信息。 */
+export async function loadVehicleInfoMapRows(db: Database): Promise {
+ const [rows] = await db.query(VEHICLE_INFO_SQL);
+ return rows;
+}
diff --git a/src/server/routes/mileage/targets.ts b/src/server/routes/mileage/targets.ts
index a1aadb1..5c8f223 100644
--- a/src/server/routes/mileage/targets.ts
+++ b/src/server/routes/mileage/targets.ts
@@ -1,3 +1,13 @@
+import {
+ loadAssessmentFirstYearStats,
+ loadAssessmentPeriodStats,
+ loadAssessmentTargetRows,
+ loadAssessmentVehicleStats,
+ loadAssessmentYearlyPeriodStats,
+ loadAssessmentYearlyStats,
+ loadTargetVehicleRows,
+ loadYearlyAssessments,
+} from './repository.js';
import { Hono } from 'hono';
import pool from '../../db/mysql.js';
import { getCache } from './cache.js';
@@ -9,73 +19,19 @@ const app = new Hono();
app.get('/', async (c) => {
try {
- const [targets] = await pool.execute(
- 'SELECT * FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id'
- ) as [any[], unknown];
+ const targets = await loadAssessmentTargetRows(pool);
- 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 lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0
- GROUP BY target_id
- `) as [any[], unknown];
+ const vehicleStats = await loadAssessmentVehicleStats(pool);
const statsMap = new Map();
for (const s of vehicleStats) statsMap.set(s.target_id, s);
- const [firstYearRows] = await pool.execute(`
- SELECT
- v.target_id,
- COUNT(*) as first_year_total,
- SUM(t.annual_mileage_per_vehicle) as first_year_target,
- SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle)) as first_year_completed,
- SUM(GREATEST(t.annual_mileage_per_vehicle - v.current_mileage, 0)) as first_year_remaining,
- SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle)) / NULLIF(SUM(t.annual_mileage_per_vehicle), 0) as first_year_completion_rate,
- SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle THEN 1 ELSE 0 END) as first_year_qualified_count,
- SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * 0.5 THEN 1 ELSE 0 END) as first_year_half_qualified_count,
- DATE_FORMAT(MIN(v.assessment_start_date), '%Y-%m-%d') as first_year_start_date,
- DATE_FORMAT(MAX(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL 1 YEAR), INTERVAL 1 DAY)), '%Y-%m-%d') as first_year_end_date
- FROM lingniu_prod.tab_mileage_assessment_vehicle v
- JOIN lingniu_prod.tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0
- WHERE v.is_deleted = 0
- GROUP BY v.target_id
- `) as [any[], unknown];
+ const firstYearRows = await loadAssessmentFirstYearStats(pool);
const firstYearMap = new Map();
for (const s of firstYearRows) firstYearMap.set(s.target_id, s);
- const [yearlyRows] = await pool.execute(`
- SELECT
- v.target_id,
- y.year_number,
- COUNT(*) as vehicle_count,
- SUM(t.annual_mileage_per_vehicle * y.year_number) as target_mileage,
- SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle * y.year_number)) as completed_mileage,
- SUM(GREATEST(t.annual_mileage_per_vehicle * y.year_number - v.current_mileage, 0)) as remaining_mileage,
- SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle * y.year_number))
- / NULLIF(SUM(t.annual_mileage_per_vehicle * y.year_number), 0) as completion_rate,
- SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * y.year_number THEN 1 ELSE 0 END) as qualified_count,
- SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * y.year_number * 0.5 THEN 1 ELSE 0 END) as half_qualified_count,
- DATE_FORMAT(MIN(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number - 1 YEAR)), '%Y-%m-%d') as start_date,
- DATE_FORMAT(MAX(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number YEAR), INTERVAL 1 DAY)), '%Y-%m-%d') as end_date
- FROM lingniu_prod.tab_mileage_assessment_vehicle v
- JOIN lingniu_prod.tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0
- JOIN (
- SELECT 1 as year_number UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
- ) y ON y.year_number <= LEAST(t.assessment_years, v.current_year_number)
- WHERE v.is_deleted = 0
- GROUP BY v.target_id, y.year_number
- ORDER BY v.target_id, y.year_number
- `) as [any[], unknown];
+ const yearlyRows = await loadAssessmentYearlyStats(pool);
const yearlyMap = new Map();
for (const row of yearlyRows) {
@@ -84,22 +40,7 @@ app.get('/', async (c) => {
yearlyMap.set(row.target_id, list);
}
- const [yearlyPeriodRows] = await pool.execute(`
- SELECT
- v.target_id,
- y.year_number,
- DATE_FORMAT(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number - 1 YEAR), '%Y-%m-%d') as start_date,
- DATE_FORMAT(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number YEAR), INTERVAL 1 DAY), '%Y-%m-%d') as end_date,
- COUNT(*) as cnt
- FROM lingniu_prod.tab_mileage_assessment_vehicle v
- JOIN lingniu_prod.tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0
- JOIN (
- SELECT 1 as year_number UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
- ) y ON y.year_number <= LEAST(t.assessment_years, v.current_year_number)
- WHERE v.is_deleted = 0
- GROUP BY v.target_id, y.year_number, v.assessment_start_date
- ORDER BY v.target_id, y.year_number, v.assessment_start_date
- `) as [any[], unknown];
+ const yearlyPeriodRows = await loadAssessmentYearlyPeriodStats(pool);
const yearlyPeriodsMap = new Map();
for (const row of yearlyPeriodRows) {
@@ -109,15 +50,7 @@ app.get('/', async (c) => {
yearlyPeriodsMap.set(key, list);
}
- 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 lingniu_prod.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 periodRows = await loadAssessmentPeriodStats(pool);
const periodsMap = new Map();
for (const p of periodRows) {
@@ -134,9 +67,7 @@ app.get('/', async (c) => {
}
}
- const [targetVehicleRows] = await pool.execute(
- 'SELECT target_id, plate_number FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0'
- ) as [{ target_id: number; plate_number: string }[], unknown];
+ const targetVehicleRows = await loadTargetVehicleRows(pool);
const targetIdPlatesMap = new Map();
for (const r of targetVehicleRows) {
@@ -241,15 +172,7 @@ app.get('/:id/vehicles', async (c) => {
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 lingniu_prod.tab_mileage_assessment_vehicle
- WHERE target_id = ? AND is_deleted = 0
- ORDER BY today_mileage DESC`,
- [targetId]
- ) as [any[], unknown];
+ const rows = await loadYearlyAssessments(pool, { targetId });
const plates: string[] = rows.map((r: any) => r.plate_number);
const infoMap = await fetchVehicleInfoByPlates(plates);
diff --git a/src/server/routes/mileage/trend.ts b/src/server/routes/mileage/trend.ts
index 9e74aec..edd8e84 100644
--- a/src/server/routes/mileage/trend.ts
+++ b/src/server/routes/mileage/trend.ts
@@ -1,3 +1,4 @@
+import { loadTargetVehiclePlates } from './repository.js';
import { Hono } from 'hono';
import pool from '../../db/mysql.js';
import { fetchOneOsMileageDates } from './oneos-api.js';
@@ -11,10 +12,7 @@ app.get('/', async (c) => {
try {
let plates: string[] = [];
if (targetId) {
- const [vehicleRows] = await pool.execute(
- 'SELECT plate_number FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE target_id = ? AND is_deleted = 0',
- [targetId]
- ) as [{ plate_number: string }[], unknown];
+ const vehicleRows = await loadTargetVehiclePlates(pool, { targetId });
plates = vehicleRows.map(r => r.plate_number);
if (plates.length === 0) return c.json([]);
}
diff --git a/src/server/routes/mileage/vehicle-info.ts b/src/server/routes/mileage/vehicle-info.ts
index 458d94c..8145ed1 100644
--- a/src/server/routes/mileage/vehicle-info.ts
+++ b/src/server/routes/mileage/vehicle-info.ts
@@ -1,76 +1,10 @@
+import { loadVehicleInfoByPlates, loadVehicleInfoMapRows } from './repository.js';
import pool from '../../db/mysql.js';
import type { VehicleInfoRow } from './types.js';
-/** 车辆关联信息 SQL(客户名、部门、经理、租赁状态、主体、项目、品牌) */
-export const VEHICLE_INFO_SQL = `SELECT
- vi.plate_number AS plate,
- vi.vin AS vin,
- COALESCE(c.customer_name, vor.customer_name, ci.customer_name) AS customer,
- COALESCE(c.business_department_name, vor.business_dept) AS department,
- COALESCE(c.business_manager_name, vor.business_manager) AS manager,
- CAST(COALESCE(c.business_manager_id, vi.business_id) AS CHAR) AS manager_id,
- CASE vs.operation_status
- WHEN '1' THEN '租赁'
- WHEN '2' THEN '自营'
- WHEN '3' THEN '可运营'
- WHEN '4' THEN '待运营'
- WHEN '5' THEN '退出运营'
- ELSE vs.operation_status
- END AS rent_status,
- NULLIF(vi.registered_ownership, '') AS entity,
- COALESCE(c.project_name, vor.project_name) AS project,
- COALESCE(vi_operation_city.NAME, NULLIF(vi.operation_city, '')) AS inventory_location,
- CASE vm.brand
- WHEN 'hyundai' THEN CASE WHEN vm.model LIKE '%帕力安%' OR vm.model LIKE '%冷链%' OR vm.model LIKE '%双飞翼%' THEN '帕力安牌' ELSE '现代' END
- WHEN 'yuejin' THEN '跃进'
- WHEN 'feichi' THEN '飞驰'
- WHEN 'sulong' THEN '苏龙'
- WHEN 'higer' THEN '海格'
- WHEN 'dongfeng' THEN '东风'
- WHEN 'yutong' THEN '宇通'
- WHEN 'chufeng' THEN '楚风'
- WHEN 'tonghua' THEN '通华'
- WHEN 'maxus' THEN '大通'
- WHEN 'mingwei' THEN '明威'
- WHEN 'wanfeng' THEN '万风'
- WHEN 'shujie' THEN '舒捷'
- WHEN 'denza' THEN '腾势'
- WHEN 'hongyan' THEN '红岩'
- WHEN 'yuanchang brand' THEN '远程牌'
- WHEN 'others' THEN '其他'
- ELSE vm.brand
- END AS brand_label
-FROM vehicle_info vi
-LEFT JOIN vehicle_status vs
- ON vs.vehicle_id = vi.id
- AND vs.del_flag = 0
-LEFT JOIN vehicle_model vm
- ON vm.id = vi.vehicle_model_id
- AND vm.del_flag = '0'
-LEFT JOIN common_district vi_operation_city
- ON vi_operation_city.CODE = vi.operation_city COLLATE utf8mb4_unicode_ci
- AND vi_operation_city.STATUS = 'VALID'
-LEFT JOIN vehicle_lease_order_record vor
- ON vor.vehicle_id = vi.id
- AND vor.del_flag = '0'
- AND vor.id = (
- SELECT MAX(vor2.id)
- FROM vehicle_lease_order_record vor2
- WHERE vor2.vehicle_id = vi.id
- AND vor2.del_flag = '0'
- )
-LEFT JOIN vehicle_lease_contract_info c
- ON c.order_id = vor.contract_id
- AND c.del_flag = '0'
-LEFT JOIN customer_info ci
- ON ci.id = vi.customer_id
- AND ci.del_flag = '0'
-WHERE vi.del_flag = '0'
- AND COALESCE(vs.operation_status, '') <> '5'`;
-
/** 查询所有车辆关联信息,返回 plate→info 的 Map */
export async function fetchVehicleInfoMap(): Promise