From 25e53b8f66471404ae1d00f9675712142386ba69 Mon Sep 17 00:00:00 2001 From: dsh-agent Date: Fri, 11 Sep 2026 10:57:29 +0800 Subject: [PATCH] =?UTF-8?q?refactor(stage16):=20=E9=87=8C=E7=A8=8B?= =?UTF-8?q?=E5=9F=9F=E7=A7=BB=E5=85=A5=20repository=EF=BC=8C=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E5=88=86=E5=B1=82=E5=85=A8=E9=83=A8=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动 - 新增 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)以及依赖里程的 调度、车辆热力图契约测试全部通过。 --- docs/ARCHITECTURE.md | 11 +- src/architecture.test.ts | 1 + src/server/routes/mileage/cache.ts | 8 +- .../routes/mileage/daily-report-service.ts | 26 +- .../routes/mileage/daily-report-store.ts | 48 +-- src/server/routes/mileage/repository.ts | 322 ++++++++++++++++++ src/server/routes/mileage/targets.ts | 113 +----- src/server/routes/mileage/trend.ts | 6 +- src/server/routes/mileage/vehicle-info.ts | 75 +--- 9 files changed, 369 insertions(+), 241 deletions(-) create mode 100644 src/server/routes/mileage/repository.ts 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> { - const [rows] = await pool.execute(VEHICLE_INFO_SQL) as [VehicleInfoRow[], unknown]; + const rows = await loadVehicleInfoMapRows(pool) as unknown as VehicleInfoRow[]; const map = new Map(); for (const row of rows) { map.set(row.plate, row); @@ -81,10 +15,7 @@ export async function fetchVehicleInfoMap(): Promise /** 查询指定车牌的关联信息 */ export async function fetchVehicleInfoByPlates(plates: string[]): Promise> { 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(); for (const row of rows) { map.set(row.plate, row);