refactor(stage16): 里程域移入 repository,后端分层全部完成

改动
- 新增 mileage/repository.ts:18 个查询函数,覆盖
  vehicle-info(含搬走的 VEHICLE_INFO_SQL 共享常量)、trend、监控缓存、targets(8 条)、
  daily-report-store(5 条)、daily-report-service。
- 6 个含 SQL 的文件改为调用 repository;路由/服务层不再出现 SQL 文本。
- 日报写入的两个函数改为返回 affectedRows(调用方本来只用它),而不是行数组。
- 架构守护的"已完整分层"清单加入 mileage —— 至此八个域全部在守护范围内。

SQL 保持性验证
- 逐字比对:改造前 6 个文件里的 19 条 SQL 字面量,除"动态 INSERT 关键字"一处外,
  全部在 repository.ts 中原样出现。
- 那一处是 `${insert} INTO ${TABLE}`(insert 为 'REPLACE' | 'INSERT IGNORE'),
  改为 `${replace ? "REPLACE" : "INSERT IGNORE"}`;已渲染两个分支与原文本逐字比对,均为 identical。

验证
- 全量 lint / test(191) / build 全绿,可达性 0 未引用文件。
- 里程域的模型层测试(cache-model / oneos-model / daily-report-model)以及依赖里程的
  调度、车辆热力图契约测试全部通过。
This commit is contained in:
dsh-agent
2026-09-11 10:57:29 +08:00
parent 9750d35ba4
commit 25e53b8f66
9 changed files with 369 additions and 241 deletions
+5 -6
View File
@@ -94,13 +94,13 @@ server/
| `vehicles/` | `routes.ts` `repository.ts` `model.ts` `utils.ts` `types.ts` | ✅ 完整分层;SQL 有契约测试 | | `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/参数/响应体已做等价性验证 | | `ele/` | `routes.ts` `repository.ts` `model.ts`+ `model.test.ts` `routes.test.ts`) | ✅ 完整分层;改造前后 SQL/参数/响应体已做等价性验证 |
| `feedback/` | `routes.ts` `repository.ts` `oss.ts`+ `routes.test.ts` | ✅ 完整分层;同上 | | `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 | | `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)显式注入 | | `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` 片段与参数顺序已锁定 | | `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(定位点)两个库 | | `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**。 由架构测试守护:必须存在 `repository.ts`,且该域**其他任何非测试文件都不得含 SQL**。
其余域(`energy` / `mileage`)尚未拆出 repository——拆分时**不要改变 SQL 与参数顺序**, 其余域(`energy` / `mileage`)尚未拆出 repository——拆分时**不要改变 SQL 与参数顺序**,
请按 `ele/routes.test.ts` 的配方先补契约测试,并用"改造前后同一批请求对比落库 SQL 与响应体"做等价性验证。 请按 `ele/routes.test.ts` 的配方先补契约测试,并用"改造前后同一批请求对比落库 SQL 与响应体"做等价性验证。
@@ -139,10 +139,9 @@ cors → read-only → /api/auth(公开) → authMiddleware → 各业务域
诚实记录,避免后来者以为已经做完: 诚实记录,避免后来者以为已经做完:
- **后端仍有 1 个域没拆出 repository**`mileage` 的 SQL 仍分散在 `cache.ts` / - **后端八个域都已拆出 repository**SQL 只允许出现在各域的 `repository.ts`(以及共享片段模块
`daily-report-store.ts` / `targets.ts` / `monitoring.ts` / `trend.ts` / `vehicle-recent.ts` 等文件里。 `energy/constants.ts`)中,由架构测试守护。新增查询请沿用同一形状:路由只做校验与组装,
已完成的有 7 个域,可作为模板:路由只做校验与组装,SQL 进 `repository.ts` SQL 进 `repository.ts`纯逻辑进 `model.ts`,并用 mock pool 的契约测试锁定 SQL 与参数。
纯逻辑进 `model.ts`,并用 mock pool 的契约测试锁定 SQL 与参数。
守卫用的是"语句形状"正则(如 `update <table> set`)而不是裸关键字,避免把 守卫用的是"语句形状"正则(如 `update <table> set`)而不是裸关键字,避免把
`UpdateNotification` 或日志里的 "update error" 误判为 SQL;小写 SQL 同样能被抓到。 `UpdateNotification` 或日志里的 "update error" 误判为 SQL;小写 SQL 同样能被抓到。
- **运行时建表已集中到 `server/db/schema/`**,并在 `DB_READ_ONLY=1` 时整体跳过(由架构测试守护, - **运行时建表已集中到 `server/db/schema/`**,并在 `DB_READ_ONLY=1` 时整体跳过(由架构测试守护,
+1
View File
@@ -180,6 +180,7 @@ test("已完整分层的域:repository.ts 存在,且 SQL 只允许出现在
"server/routes/hydrogen-heatmap", "server/routes/hydrogen-heatmap",
"server/routes/scheduling", "server/routes/scheduling",
"server/routes/energy", "server/routes/energy",
"server/routes/mileage",
]; ];
// 用"语句形状"而不是裸关键字:UpdateNotification 或 "update error" 这类标识符/日志 // 用"语句形状"而不是裸关键字:UpdateNotification 或 "update error" 这类标识符/日志
// 不应误判,而小写 SQL 也依然能被抓到。 // 不应误判,而小写 SQL 也依然能被抓到。
+2 -6
View File
@@ -1,3 +1,4 @@
import { loadMonitoringTargetRows } from './repository.js';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
@@ -51,12 +52,7 @@ export interface RangeMileageResult {
} }
async function fetchTargetRows(): Promise<TargetRow[]> { async function fetchTargetRows(): Promise<TargetRow[]> {
return pool.execute( return loadMonitoringTargetRows(pool).then(([rows]) => rows as TargetRow[]);
`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[]);
} }
export async function refreshMonitoringCache(): Promise<void> { export async function refreshMonitoringCache(): Promise<void> {
@@ -1,3 +1,4 @@
import { loadReportAssessmentRows } from './repository.js';
import type { RowDataPacket } from 'mysql2'; import type { RowDataPacket } from 'mysql2';
import pool from '../../db/mysql.js'; import pool from '../../db/mysql.js';
import type { import type {
@@ -123,30 +124,7 @@ function shanghaiIsoNow(): string {
} }
async function fetchAssessmentRows(): Promise<AssessmentRow[]> { async function fetchAssessmentRows(): Promise<AssessmentRow[]> {
const [rows] = await pool.query<AssessmentRow[]>(` const rows = await loadReportAssessmentRows(pool) as unknown as AssessmentRow[];
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; return rows;
} }
+14 -34
View File
@@ -1,3 +1,10 @@
import {
dailyReportSnapshotExists,
insertDailyReportSnapshot,
listDailyReportHistoryRows,
loadDailyReportPayload,
updateDailyReportPayload,
} from './repository.js';
import type { ResultSetHeader, RowDataPacket } from 'mysql2'; import type { ResultSetHeader, RowDataPacket } from 'mysql2';
import pool from '../../db/mysql.js'; import pool from '../../db/mysql.js';
import { ensureDailyReportTable, DAILY_REPORT_TABLE as TABLE } from '../../db/schema/mileage-report.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<DailyMileageReport | null> { export async function getDailyReportSnapshot(reportDate: string): Promise<DailyMileageReport | null> {
await ensureDailyReportTable(); await ensureDailyReportTable();
const [rows] = await pool.query<RowDataPacket[]>( const rows = await loadDailyReportPayload(pool, { reportDate });
`SELECT report_payload FROM ${TABLE} WHERE report_date = ? LIMIT 1`,
[reportDate],
);
return parsePayload(rows[0]?.report_payload); return parsePayload(rows[0]?.report_payload);
} }
export async function hasDailyReportSnapshot(reportDate: string): Promise<boolean> { export async function hasDailyReportSnapshot(reportDate: string): Promise<boolean> {
await ensureDailyReportTable(); await ensureDailyReportTable();
const [rows] = await pool.query<RowDataPacket[]>( const rows = await dailyReportSnapshotExists(pool, { reportDate });
`SELECT 1 AS found FROM ${TABLE} WHERE report_date = ? LIMIT 1`,
[reportDate],
);
return rows.length > 0; return rows.length > 0;
} }
@@ -57,41 +58,20 @@ export async function saveDailyReportSnapshot(
archived.generatedAt.replace('T', ' ').replace(/Z$/, '').slice(0, 19), archived.generatedAt.replace('T', ' ').replace(/Z$/, '').slice(0, 19),
]; ];
const insert = options.replace ? 'REPLACE' : 'INSERT IGNORE'; const affected = await insertDailyReportSnapshot(pool, { replace: Boolean(options.replace), values });
const [result] = await pool.query<ResultSetHeader>( return affected > 0;
`${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;
} }
export async function updateDailyReportSnapshotPayload(report: DailyMileageReport): Promise<boolean> { export async function updateDailyReportSnapshotPayload(report: DailyMileageReport): Promise<boolean> {
await ensureDailyReportTable(); await ensureDailyReportTable();
const [result] = await pool.query<ResultSetHeader>( const affected = await updateDailyReportPayload(pool, { report: JSON.stringify(report), reportDate: report.reportDate });
`UPDATE ${TABLE} return affected > 0;
SET report_payload = CAST(? AS JSON)
WHERE report_date = ?`,
[JSON.stringify(report), report.reportDate],
);
return result.affectedRows > 0;
} }
export async function listDailyReportHistory(limit = 90): Promise<MileageReportHistoryResponse> { export async function listDailyReportHistory(limit = 90): Promise<MileageReportHistoryResponse> {
await ensureDailyReportTable(); await ensureDailyReportTable();
const safeLimit = Math.min(366, Math.max(1, limit)); const safeLimit = Math.min(366, Math.max(1, limit));
const [rows] = await pool.query<RowDataPacket[]>( const rows = await listDailyReportHistoryRows(pool, { safeLimit });
`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 items: MileageReportHistoryItem[] = rows.map(row => ({ const items: MileageReportHistoryItem[] = rows.map(row => ({
reportDate: row.report_date, reportDate: row.report_date,
status: row.status, status: row.status,
+322
View File
@@ -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<T = any>(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<RowDataPacket[]> {
const { plates } = args;
const [rows] = await db.query<RowDataPacket[]>(`${VEHICLE_INFO_SQL} AND vi.plate_number IN (${plates.map(() => '?').join(',')})`, plates);
return rows;
}
/** 考核目标下的车牌。 */
export async function loadTargetVehiclePlates(db: Database, args: { targetId: string }): Promise<RowDataPacket[]> {
const { targetId } = args;
const [rows] = await db.query<RowDataPacket[]>(`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<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(`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<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(`SELECT * FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id`, []);
return rows;
}
/** 各目标的车辆统计。 */
export async function loadAssessmentVehicleStats(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(`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<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(`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<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(`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<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(`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<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(`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<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(`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<RowDataPacket[]> {
const { targetId } = args;
const [rows] = await db.query<RowDataPacket[]>(`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<RowDataPacket[]> {
const { reportDate } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT report_payload FROM ${TABLE} WHERE report_date = ? LIMIT 1`, [reportDate]);
return rows;
}
/** 日报是否已归档。 */
export async function dailyReportSnapshotExists(db: Database, args: { reportDate: string }): Promise<RowDataPacket[]> {
const { reportDate } = args;
const [rows] = await db.query<RowDataPacket[]>(`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<number> {
const { replace, values } = args;
const [rows] = await db.query<any>(`${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<number> {
const { report, reportDate } = args;
const [rows] = await db.query<any>(`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<RowDataPacket[]> {
const { safeLimit } = args;
const [rows] = await db.query<RowDataPacket[]>(`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<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(`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<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(VEHICLE_INFO_SQL);
return rows;
}
+18 -95
View File
@@ -1,3 +1,13 @@
import {
loadAssessmentFirstYearStats,
loadAssessmentPeriodStats,
loadAssessmentTargetRows,
loadAssessmentVehicleStats,
loadAssessmentYearlyPeriodStats,
loadAssessmentYearlyStats,
loadTargetVehicleRows,
loadYearlyAssessments,
} from './repository.js';
import { Hono } from 'hono'; import { Hono } from 'hono';
import pool from '../../db/mysql.js'; import pool from '../../db/mysql.js';
import { getCache } from './cache.js'; import { getCache } from './cache.js';
@@ -9,73 +19,19 @@ const app = new Hono();
app.get('/', async (c) => { app.get('/', async (c) => {
try { try {
const [targets] = await pool.execute( const targets = await loadAssessmentTargetRows(pool);
'SELECT * FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id'
) as [any[], unknown];
const [vehicleStats] = await pool.execute(` const vehicleStats = await loadAssessmentVehicleStats(pool);
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 statsMap = new Map<number, any>(); const statsMap = new Map<number, any>();
for (const s of vehicleStats) statsMap.set(s.target_id, s); for (const s of vehicleStats) statsMap.set(s.target_id, s);
const [firstYearRows] = await pool.execute(` const firstYearRows = await loadAssessmentFirstYearStats(pool);
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 firstYearMap = new Map<number, any>(); const firstYearMap = new Map<number, any>();
for (const s of firstYearRows) firstYearMap.set(s.target_id, s); for (const s of firstYearRows) firstYearMap.set(s.target_id, s);
const [yearlyRows] = await pool.execute(` const yearlyRows = await loadAssessmentYearlyStats(pool);
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 yearlyMap = new Map<number, any[]>(); const yearlyMap = new Map<number, any[]>();
for (const row of yearlyRows) { for (const row of yearlyRows) {
@@ -84,22 +40,7 @@ app.get('/', async (c) => {
yearlyMap.set(row.target_id, list); yearlyMap.set(row.target_id, list);
} }
const [yearlyPeriodRows] = await pool.execute(` const yearlyPeriodRows = await loadAssessmentYearlyPeriodStats(pool);
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 yearlyPeriodsMap = new Map<string, string[]>(); const yearlyPeriodsMap = new Map<string, string[]>();
for (const row of yearlyPeriodRows) { for (const row of yearlyPeriodRows) {
@@ -109,15 +50,7 @@ app.get('/', async (c) => {
yearlyPeriodsMap.set(key, list); yearlyPeriodsMap.set(key, list);
} }
const [periodRows] = await pool.execute(` const periodRows = await loadAssessmentPeriodStats(pool);
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 periodsMap = new Map<number, string[]>(); const periodsMap = new Map<number, string[]>();
for (const p of periodRows) { for (const p of periodRows) {
@@ -134,9 +67,7 @@ app.get('/', async (c) => {
} }
} }
const [targetVehicleRows] = await pool.execute( const targetVehicleRows = await loadTargetVehicleRows(pool);
'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 targetIdPlatesMap = new Map<number, string[]>(); const targetIdPlatesMap = new Map<number, string[]>();
for (const r of targetVehicleRows) { for (const r of targetVehicleRows) {
@@ -241,15 +172,7 @@ app.get('/:id/vehicles', async (c) => {
const date = c.req.query('date') || ''; const date = c.req.query('date') || '';
try { try {
const [rows] = await pool.execute( const rows = await loadYearlyAssessments(pool, { targetId });
`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 plates: string[] = rows.map((r: any) => r.plate_number); const plates: string[] = rows.map((r: any) => r.plate_number);
const infoMap = await fetchVehicleInfoByPlates(plates); const infoMap = await fetchVehicleInfoByPlates(plates);
+2 -4
View File
@@ -1,3 +1,4 @@
import { loadTargetVehiclePlates } from './repository.js';
import { Hono } from 'hono'; import { Hono } from 'hono';
import pool from '../../db/mysql.js'; import pool from '../../db/mysql.js';
import { fetchOneOsMileageDates } from './oneos-api.js'; import { fetchOneOsMileageDates } from './oneos-api.js';
@@ -11,10 +12,7 @@ app.get('/', async (c) => {
try { try {
let plates: string[] = []; let plates: string[] = [];
if (targetId) { if (targetId) {
const [vehicleRows] = await pool.execute( const vehicleRows = await loadTargetVehiclePlates(pool, { targetId });
'SELECT plate_number FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE target_id = ? AND is_deleted = 0',
[targetId]
) as [{ plate_number: string }[], unknown];
plates = vehicleRows.map(r => r.plate_number); plates = vehicleRows.map(r => r.plate_number);
if (plates.length === 0) return c.json([]); if (plates.length === 0) return c.json([]);
} }
+3 -72
View File
@@ -1,76 +1,10 @@
import { loadVehicleInfoByPlates, loadVehicleInfoMapRows } from './repository.js';
import pool from '../../db/mysql.js'; import pool from '../../db/mysql.js';
import type { VehicleInfoRow } from './types.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 */ /** 查询所有车辆关联信息,返回 plate→info 的 Map */
export async function fetchVehicleInfoMap(): Promise<Map<string, VehicleInfoRow>> { export async function fetchVehicleInfoMap(): Promise<Map<string, VehicleInfoRow>> {
const [rows] = await pool.execute(VEHICLE_INFO_SQL) as [VehicleInfoRow[], unknown]; const rows = await loadVehicleInfoMapRows(pool) as unknown as VehicleInfoRow[];
const map = new Map<string, VehicleInfoRow>(); const map = new Map<string, VehicleInfoRow>();
for (const row of rows) { for (const row of rows) {
map.set(row.plate, row); map.set(row.plate, row);
@@ -81,10 +15,7 @@ export async function fetchVehicleInfoMap(): Promise<Map<string, VehicleInfoRow>
/** 查询指定车牌的关联信息 */ /** 查询指定车牌的关联信息 */
export async function fetchVehicleInfoByPlates(plates: string[]): Promise<Map<string, VehicleInfoRow>> { export async function fetchVehicleInfoByPlates(plates: string[]): Promise<Map<string, VehicleInfoRow>> {
if (plates.length === 0) return new Map(); if (plates.length === 0) return new Map();
const [rows] = await pool.execute( const rows = await loadVehicleInfoByPlates(pool, { plates }) as unknown as VehicleInfoRow[];
`${VEHICLE_INFO_SQL} AND vi.plate_number IN (${plates.map(() => '?').join(',')})`,
plates
) as [VehicleInfoRow[], unknown];
const map = new Map<string, VehicleInfoRow>(); const map = new Map<string, VehicleInfoRow>();
for (const row of rows) { for (const row of rows) {
map.set(row.plate, row); map.set(row.plate, row);