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 有契约测试 |
| `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 <table> set`)而不是裸关键字,避免把
`UpdateNotification` 或日志里的 "update error" 误判为 SQL;小写 SQL 同样能被抓到。
- **运行时建表已集中到 `server/db/schema/`**,并在 `DB_READ_ONLY=1` 时整体跳过(由架构测试守护,
+1
View File
@@ -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 也依然能被抓到。
+2 -6
View File
@@ -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<TargetRow[]> {
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<void> {
@@ -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<AssessmentRow[]> {
const [rows] = await pool.query<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
`);
const rows = await loadReportAssessmentRows(pool) as unknown as AssessmentRow[];
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 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<DailyMileageReport | null> {
await ensureDailyReportTable();
const [rows] = await pool.query<RowDataPacket[]>(
`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<boolean> {
await ensureDailyReportTable();
const [rows] = await pool.query<RowDataPacket[]>(
`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<ResultSetHeader>(
`${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<boolean> {
await ensureDailyReportTable();
const [result] = await pool.query<ResultSetHeader>(
`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<MileageReportHistoryResponse> {
await ensureDailyReportTable();
const safeLimit = Math.min(366, Math.max(1, limit));
const [rows] = await pool.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],
);
const rows = await listDailyReportHistoryRows(pool, { safeLimit });
const items: MileageReportHistoryItem[] = rows.map(row => ({
reportDate: row.report_date,
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 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<number, any>();
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<number, any>();
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<number, any[]>();
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<string, string[]>();
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<number, string[]>();
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<number, string[]>();
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);
+2 -4
View File
@@ -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([]);
}
+3 -72
View File
@@ -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<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>();
for (const row of rows) {
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>> {
if (plates.length === 0) return new Map();
const [rows] = await pool.execute(
`${VEHICLE_INFO_SQL} AND vi.plate_number IN (${plates.map(() => '?').join(',')})`,
plates
) as [VehicleInfoRow[], unknown];
const rows = await loadVehicleInfoByPlates(pool, { plates }) as unknown as VehicleInfoRow[];
const map = new Map<string, VehicleInfoRow>();
for (const row of rows) {
map.set(row.plate, row);