refactor(stage12): 调度域拆出 repository,跨域数据源改为显式注入

改动
- 新增 scheduling/repository.ts:通知/干预与建议列表的全部 SQL(13 条)。
- notify.ts / suggestions.ts 改为 register*(app, deps) 可注入,保留默认导出与 create*Router 工厂。
- suggestions.ts 的两处跨域依赖(里程车辆信息、OneOS 里程)改为依赖注入:
  既让本域可独立测试,也让"建议依赖里程数据"在类型上可见,而不是藏在 import 里。
- 跨域引用修正:mapRegion 原本从 '../vehicles/routes.js' 引入(为一个纯函数把整个
  车辆路由拖进依赖图),改为直接从 '../vehicles/model.js' 引入。

契约测试(新增 10 个用例)
- 逐条锁定 SQL 与参数顺序:干预登记的三步(查重 → INSERT → 回读)、409 阻断、
  批量循环、历史列表的状态过滤与 limit 上限(500/默认 200)、状态更新的 UPDATE 形状、
  400/404 分支、活跃映射与近 7 天计数、建议列表五条基础查询的内容与顺序。
- 架构守护升级:不再只看 routes.ts,而是要求该域**除 repository.ts 外的任何非测试文件
  都不得含 SQL**;匹配用"语句形状"正则(如 update <table> set)而非裸关键字,
  避免把 UpdateNotification 或日志 "update error" 误判。已验证 6/6 个 repository 被识别、
  其余文件零误判。

等价性验证
- 把 notify.ts / suggestions.ts 改造前的实现从 git 取出,与改造后跑同一批请求
  (9 个场景,含建议列表整条链路 8 次查询),对比落库 SQL、参数、HTTP 状态与响应体:
  完全一致。
- 期间修正了两处**验证工具自身**的缺陷(旧文件误引用新 notify;跨域默认实现的调用
  被记到另一侧),修正后结论可信。

lint / test(191) / build 全绿,可达性 0 未引用文件。
This commit is contained in:
dsh-agent
2026-09-11 10:44:52 +08:00
parent 523f7b312d
commit 5196db5df7
6 changed files with 656 additions and 257 deletions
+44 -40
View File
@@ -1,11 +1,19 @@
import { Hono } from 'hono';
import pool from '../../db/mysql.js';
import mysqlPool from '../../db/mysql.js';
import { fetchVehicleInfoMap } from '../mileage/vehicle-info.js';
import { fetchOneOsMileageDates } from '../mileage/oneos-api.js';
import { mapRegion } from '../vehicles/routes.js';
import { mapRegion } from '../vehicles/model.js';
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
import { classifyVehicle, generateSuggestions } from './algorithm.js';
import { fetchActiveNotificationMap, fetchRecentInterventionCount } from './notify.js';
import {
loadAssessmentTargets,
loadAssessmentVehicles,
loadInventoryVehicles,
loadRealtimeLocations,
loadVehicleTypes,
type Database,
} from './repository.js';
import type { EnrichedVehicle, InventoryVehicle, SchedulingResponse, SchedulingSummary } from './types.js';
import type { AuthUser } from '../../auth/types.js';
@@ -64,17 +72,28 @@ function recentCompletedDates(count: number): string[] {
// Route
// ---------------------------------------------------------------------------
const app = new Hono();
export interface SuggestionsDependencies {
db: Database;
/**
* 跨域数据源(里程域的车辆信息与 OneOS 里程)。
* 显式作为依赖注入:既让本域可独立测试,也让"建议依赖里程数据"这件事在类型上可见。
*/
loadVehicleInfoMap?: typeof fetchVehicleInfoMap;
loadOneOsMileageDates?: typeof fetchOneOsMileageDates;
}
app.get('/', async (c) => {
export function registerSuggestionsRoutes(app: Hono, deps: SuggestionsDependencies): void {
const { db } = deps;
const loadVehicleInfoMap = deps.loadVehicleInfoMap ?? fetchVehicleInfoMap;
const loadOneOsMileageDates = deps.loadOneOsMileageDates ?? fetchOneOsMileageDates;
app.get('/', async (c) => {
try {
const targetIdParam = c.req.query('targetId');
const filterTargetId = targetIdParam ? Number(targetIdParam) : null;
// ---- Query 1: Assessment targets ----
const [targets] = await pool.execute(
'SELECT id, target_name, annual_mileage_per_vehicle FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id',
) as [any[], unknown];
const targets = await loadAssessmentTargets(db);
const targetMap = new Map<number, { targetName: string; annualMileage: number }>();
for (const t of targets) {
@@ -85,26 +104,13 @@ app.get('/', async (c) => {
}
// ---- Query 2: Assessment vehicles ----
const [assessmentRows] = await pool.execute(`
SELECT target_id, plate_number, today_mileage, vehicle_total_mileage,
current_mileage, current_year_mileage, current_year_mileage_task,
completion_rate, is_qualified, current_year_is_qualified,
daily_required_mileage, current_year_assessment_end_date
FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0
`) as [any[], unknown];
const assessmentRows = await loadAssessmentVehicles(db);
// ---- Query 3: Vehicle info (customer, dept, manager) ----
const vehicleInfoMap = await fetchVehicleInfoMap();
const vehicleInfoMap = await loadVehicleInfoMap();
// ---- Query 4: Vehicle types from vehicle_info ----
const [truckTypeRows] = await pool.execute(`
SELECT vi.plate_number, vm.model AS type_name, vm.vehicle_type AS model_raw
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'
WHERE vi.del_flag = '0'
AND COALESCE(vs.operation_status, '') <> '5'
`) as [any[], unknown];
const truckTypeRows = await loadVehicleTypes(db);
const truckTypeMap = new Map<string, { typeName: string; modelRaw: string }>();
for (const row of truckTypeRows) {
@@ -115,11 +121,7 @@ app.get('/', async (c) => {
}
// ---- Query 5: Real-time location ----
const [locationRows] = await pool.execute(`
SELECT plate_number, province, city
FROM tab_truck_remote_sync_realtime_info
WHERE is_deleted = 0 AND plate_number IS NOT NULL
`) as [any[], unknown];
const locationRows = await loadRealtimeLocations(db);
const locationMap = new Map<string, { province: string; city: string }>();
for (const row of locationRows) {
@@ -139,7 +141,7 @@ app.get('/', async (c) => {
const dates = recentCompletedDates(30);
const sevenDayDates = new Set(dates.slice(-7));
const allowedPlates = new Set(allPlates);
const rowsByDate = await fetchOneOsMileageDates(dates);
const rowsByDate = await loadOneOsMileageDates(dates);
const aggregates = new Map<string, { sum30: number; count30: number; sum7: number; count7: number }>();
for (const [date, rows] of rowsByDate) {
for (const row of rows) {
@@ -182,15 +184,7 @@ app.get('/', async (c) => {
}
// ---- Query 7: Inventory vehicles (rent_status = 0) ----
const [inventoryTruckRows] = await pool.execute(`
SELECT vi.plate_number, vm.model AS type_name, vm.vehicle_type AS model_raw
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'
WHERE vi.del_flag = '0'
AND COALESCE(vs.operation_status, '') IN ('3','4')
AND COALESCE(vs.vehicle_status, '') <> '4'
`) as [any[], unknown];
const inventoryTruckRows = await loadInventoryVehicles(db);
// ---- Build assessment vehicle lookup for inventory cross-reference ----
const assessmentByPlate = new Map<string, any>();
@@ -311,7 +305,7 @@ app.get('/', async (c) => {
const { suggestions, summary } = generateSuggestions(enrichedVehicles, inventoryVehicles);
// ---- Attach notification status to candidates ----
const notificationMap = await fetchActiveNotificationMap();
const notificationMap = await fetchActiveNotificationMap(db);
for (const s of suggestions) {
for (const c of s.candidates) {
const key = `${s.id}::${c.plateNumber}`;
@@ -365,7 +359,7 @@ app.get('/', async (c) => {
// Recalculate summary based on permission-filtered results
const filteredQualified = masked.filter((s: any) => s.type === 'replace_qualified').length;
const filteredHopeless = masked.filter((s: any) => s.type === 'rescue_hopeless').length;
const recentInterventionCount = await fetchRecentInterventionCount();
const recentInterventionCount = await fetchRecentInterventionCount(db);
const filteredSummary: SchedulingSummary = {
qualifiedCount: filteredQualified,
hopelessCount: filteredHopeless,
@@ -396,4 +390,14 @@ app.get('/', async (c) => {
}
});
}
/** 生产用路由器:绑定真实连接池。 */
export function createSuggestionsRouter(): Hono {
const app = new Hono();
registerSuggestionsRoutes(app, { db: mysqlPool });
return app;
}
const app = createSuggestionsRouter();
export default app;