From 5196db5df77714a7cdc094c99f09873f599db9be Mon Sep 17 00:00:00 2001 From: dsh-agent Date: Fri, 11 Sep 2026 10:44:52 +0800 Subject: [PATCH] =?UTF-8?q?refactor(stage12):=20=E8=B0=83=E5=BA=A6?= =?UTF-8?q?=E5=9F=9F=E6=8B=86=E5=87=BA=20repository=EF=BC=8C=E8=B7=A8?= =?UTF-8?q?=E5=9F=9F=E6=95=B0=E6=8D=AE=E6=BA=90=E6=94=B9=E4=B8=BA=E6=98=BE?= =?UTF-8?q?=E5=BC=8F=E6=B3=A8=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动 - 新增 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 set)而非裸关键字, 避免把 UpdateNotification 或日志 "update error" 误判。已验证 6/6 个 repository 被识别、 其余文件零误判。 等价性验证 - 把 notify.ts / suggestions.ts 改造前的实现从 git 取出,与改造后跑同一批请求 (9 个场景,含建议列表整条链路 8 次查询),对比落库 SQL、参数、HTTP 状态与响应体: 完全一致。 - 期间修正了两处**验证工具自身**的缺陷(旧文件误引用新 notify;跨域默认实现的调用 被记到另一侧),修正后结论可信。 lint / test(191) / build 全绿,可达性 0 未引用文件。 --- docs/ARCHITECTURE.md | 17 +- src/architecture.test.ts | 28 +- src/server/routes/scheduling/notify.ts | 369 +++++++++----------- src/server/routes/scheduling/repository.ts | 176 ++++++++++ src/server/routes/scheduling/routes.test.ts | 239 +++++++++++++ src/server/routes/scheduling/suggestions.ts | 84 ++--- 6 files changed, 656 insertions(+), 257 deletions(-) create mode 100644 src/server/routes/scheduling/repository.ts create mode 100644 src/server/routes/scheduling/routes.test.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 674a7cc..a928eb3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -96,13 +96,13 @@ server/ | `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 仍在各路由文件内** | | `energy/` | `index.ts`(聚合)+ `hydrogen-bi-v2.ts` `hydrogen-station-board.ts` `electric.ts` `etc.ts` + `query-model.ts` `cache.ts` `constants.ts` | 路由 / 模型已分开,**SQL 仍在路由内** | -| `scheduling/` | `index.ts`(聚合)+ `suggestions.ts` `notify.ts` + `algorithm.ts` `notification-model.ts` | 同上 | +| `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`) -由架构测试守护:`routes.ts` 不得出现 SQL,且必须存在 `repository.ts`。 -其余域(`energy` / `mileage` / `scheduling`)尚未拆出 repository——拆分时**不要改变 SQL 与参数顺序**, +已完整分层的六个域(`vehicles` / `ele` / `feedback` / `vehicle-heatmap` / `hydrogen-heatmap` / `scheduling`) +由架构测试守护:必须存在 `repository.ts`,且该域**其他任何非测试文件都不得含 SQL**。 +其余域(`energy` / `mileage`)尚未拆出 repository——拆分时**不要改变 SQL 与参数顺序**, 请按 `ele/routes.test.ts` 的配方先补契约测试,并用"改造前后同一批请求对比落库 SQL 与响应体"做等价性验证。 @@ -139,10 +139,11 @@ cors → read-only → /api/auth(公开) → authMiddleware → 各业务域 诚实记录,避免后来者以为已经做完: -- **后端仍有 3 个域没拆出 repository**:`energy` / `mileage` / `scheduling` 的 SQL 仍在各自的 - 路由文件里(形状见上表)。已完成的有 5 个域,可作为模板:路由只做校验与组装, - SQL 进 `repository.ts`,纯逻辑进 `model.ts`,并用 mock pool 的契约测试锁定 SQL 与参数。 - 注意 `vehicle-heatmap` 的 SQL 是小写、`hydrogen-heatmap` 是大小写混排,正则检查要忽略大小写。 +- **后端仍有 2 个域没拆出 repository**:`energy` / `mileage` 的 SQL 仍在各自的路由文件里 + (形状见上表)。已完成的有 6 个域,可作为模板:路由只做校验与组装,SQL 进 `repository.ts`, + 纯逻辑进 `model.ts`,并用 mock pool 的契约测试锁定 SQL 与参数。 + 守卫用的是"语句形状"正则(如 `update
set`)而不是裸关键字,避免把 + `UpdateNotification` 或日志里的 "update error" 误判为 SQL;小写 SQL 同样能被抓到。 - **运行时建表已集中到 `server/db/schema/`**,并在 `DB_READ_ONLY=1` 时整体跳过(由架构测试守护, 已实测不触碰数据库)。它仍由业务接口在首次调用时触发,而不是只由 `bootstrap.ts` 调用—— 要彻底改成显式迁移,需要先建立数据库变更脚本流程,避免"代码里偷偷建表"。 diff --git a/src/architecture.test.ts b/src/architecture.test.ts index 269a0dd..99433ee 100644 --- a/src/architecture.test.ts +++ b/src/architecture.test.ts @@ -48,6 +48,11 @@ function targetPath(file: string, spec: string): string | null { return path.relative(srcDir, path.resolve(path.dirname(file), spec)); } +/** 去掉注释后再做 SQL 关键字匹配:注释里的 "update status" 之类不应触发守卫。 */ +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|[^:])\/\/[^\n]*/g, "$1 "); +} + function filesUnder(prefix: string): string[] { return allFiles.filter((f) => rel(f).startsWith(prefix)); } @@ -164,7 +169,7 @@ test("运行时建表必须尊重只读模式", () => { assert.deepEqual(offenders, [], "每个 schema 模块都要在 DB_READ_ONLY=1 时跳过 DDL"); }); -test("已完整分层的域:routes.ts 不含 SQL,且存在 repository.ts", () => { +test("已完整分层的域:repository.ts 存在,且 SQL 只允许出现在那里", () => { // 这些域已完成 routes / repository / model 拆分;其余域仍把 SQL 放在路由里 // (见 docs/ARCHITECTURE.md 的"后端业务域形状"表)。 const layered = [ @@ -173,14 +178,25 @@ test("已完整分层的域:routes.ts 不含 SQL,且存在 repository.ts", ( "server/routes/feedback", "server/routes/vehicle-heatmap", "server/routes/hydrogen-heatmap", + "server/routes/scheduling", ]; + // 用"语句形状"而不是裸关键字:UpdateNotification 或 "update error" 这类标识符/日志 + // 不应误判,而小写 SQL 也依然能被抓到。 + const SQL = /\bselect\b[\s\S]{0,400}?\bfrom\b|\binsert\s+(into|ignore)\b|\bupdate\s+[\w.`]+\s+set\b|\bdelete\s+from\b|\bcreate\s+table\b|\balter\s+table\b/i; const offenders: string[] = []; for (const dir of layered) { - if (!existsSync(path.join(srcDir, dir, "repository.ts"))) offenders.push(`${dir}/repository.ts 缺失`); - const routes = path.join(srcDir, dir, "routes.ts"); - if (existsSync(routes) && /\b(select|insert into|insert ignore|update |delete from|create table|alter table)\b/i.test(readFileSync(routes, "utf8"))) { - offenders.push(`${dir}/routes.ts 仍含 SQL`); + const abs = path.join(srcDir, dir); + if (!existsSync(path.join(abs, "repository.ts"))) { + offenders.push(`${dir}/repository.ts 缺失`); + continue; + } + for (const file of readdirSync(abs)) { + if (!file.endsWith(".ts")) continue; + if (file === "repository.ts" || file.endsWith(".test.ts")) continue; + if (SQL.test(stripComments(readFileSync(path.join(abs, file), "utf8")))) { + offenders.push(`${dir}/${file} 仍含 SQL`); + } } } - assert.deepEqual(offenders, [], "SQL 必须留在 repository.ts"); + assert.deepEqual(offenders, [], "SQL 必须留在各域的 repository.ts"); }); diff --git a/src/server/routes/scheduling/notify.ts b/src/server/routes/scheduling/notify.ts index 6092c4c..79b2455 100644 --- a/src/server/routes/scheduling/notify.ts +++ b/src/server/routes/scheduling/notify.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import pool from '../../db/mysql.js'; +import mysqlPool from '../../db/mysql.js'; import type { AuthUser } from '../../auth/types.js'; import type { NotifyRequest, @@ -18,233 +18,196 @@ import { type ActiveNotificationRow, type NotificationDbRow, } from './notification-model.js'; +import { + countRecentInterventions, + findBlockingNotification, + findNotificationById, + insertNotification, + listActiveNotifications, + listNotifications, + updateNotification, + type Database, +} from './repository.js'; -const app = new Hono(); +export interface NotifyDependencies { + db: Database; +} -/** - * Count non-cancelled interventions created within the last 7 days. - */ -export async function fetchRecentInterventionCount(): Promise { - const [rows] = (await pool.execute( - `SELECT COUNT(*) AS cnt FROM tab_scheduling_notifications - WHERE status != 'cancelled' - AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)`, - )) as [any[], unknown]; - return rows.length > 0 ? Number(rows[0].cnt) || 0 : 0; +/** 近 7 天未取消的干预条数(供建议列表使用)。 */ +export async function fetchRecentInterventionCount(db: Database): Promise { + return countRecentInterventions(db); } /** - * Fetch notification status map for the currently-visible (suggestion, candidate) pairs. - * Key: `${suggestionId}::${candidatePlate}` → latest non-cancelled notification. + * 当前可见 (suggestion, candidate) 组合的通知状态映射。 + * Key: `${suggestionId}::${candidatePlate}` → 最近一条未取消通知。 */ -export async function fetchActiveNotificationMap(): Promise< - Map -> { - const [rows] = (await pool.execute( - `SELECT id, suggestion_id, candidate_plate, status, created_at - FROM tab_scheduling_notifications - WHERE status != 'cancelled' - ORDER BY created_at DESC`, - )) as [any[], unknown]; - - return buildActiveNotificationMap(rows as ActiveNotificationRow[]); +export async function fetchActiveNotificationMap( + db: Database, +): Promise> { + const rows = await listActiveNotifications(db); + return buildActiveNotificationMap(rows as unknown as ActiveNotificationRow[]); } -async function insertNotification( - req: NotifyRequest, - operator: { id: string | null; name: string | null }, -): Promise { - // Business rule: each current vehicle (suggestion) can have AT MOST ONE - // active intervention at a time. Any non-cancelled record for the same - // suggestion_id blocks further interventions until it is cancelled. - const [existing] = (await pool.execute( - `SELECT id, candidate_plate FROM tab_scheduling_notifications - WHERE suggestion_id = ? AND status != 'cancelled' - LIMIT 1`, - [req.suggestionId], - )) as [any[], unknown]; +export function registerNotifyRoutes(app: Hono, deps: NotifyDependencies): void { + const { db } = deps; - if (existing.length > 0) { - return { skipped: true, existingPlate: existing[0].candidate_plate as string }; - } - - const [result] = (await pool.execute( - `INSERT INTO tab_scheduling_notifications - (suggestion_id, current_plate, candidate_plate, operator_id, operator_name, status) - VALUES (?, ?, ?, ?, ?, 'sent')`, - [req.suggestionId, req.currentPlate, req.candidatePlate, operator.id, operator.name], - )) as [any, unknown]; - - const insertedId = Number(result.insertId); - const [rows] = (await pool.execute( - `SELECT * FROM tab_scheduling_notifications WHERE id = ?`, - [insertedId], - )) as [any[], unknown]; - - return rowToNotificationRecord(rows[0] as NotificationDbRow); -} - -// --------------------------------------------------------------------------- -// Routes -// --------------------------------------------------------------------------- - -// POST /api/scheduling/notify — single notify -app.post('/', async (c) => { - try { - const body = await c.req.json(); - const { suggestionId, currentPlate, candidatePlate } = body; - - if (!hasRequiredNotifyFields(body)) { - return c.json({ success: false, message: '缺少必要参数' }, 400); + /** + * 登记一条干预。 + * 业务规则:同一 suggestion 同一时刻最多一条生效干预,已有未取消记录则跳过。 + */ + async function insertOne( + req: NotifyRequest, + operator: { id: string | null; name: string | null }, + ): Promise { + const existing = await findBlockingNotification(db, req.suggestionId); + if (existing.length > 0) { + return { skipped: true, existingPlate: existing[0].candidate_plate as string }; } - const user = (c as any).get('user') as AuthUser | undefined; - const operator = { - id: user?.userId ?? null, - name: user?.userName ?? null, - }; + const insertedId = await insertNotification(db, { + suggestionId: req.suggestionId, + currentPlate: req.currentPlate, + candidatePlate: req.candidatePlate, + operatorId: operator.id, + operatorName: operator.name, + }); + const row = await findNotificationById(db, insertedId); + return rowToNotificationRecord(row as NotificationDbRow); + } - const result = await insertNotification(body, operator); - if ('skipped' in result) { - return c.json( - { success: false, message: `此车已有干预(候选车 ${result.existingPlate}),请先解除` }, - 409, + // POST /api/scheduling/notify — single notify + app.post('/', async (c) => { + try { + const body = await c.req.json(); + const { suggestionId, currentPlate, candidatePlate } = body; + + if (!hasRequiredNotifyFields(body)) { + return c.json({ success: false, message: '缺少必要参数' }, 400); + } + + const user = (c as any).get('user') as AuthUser | undefined; + const operator = { id: user?.userId ?? null, name: user?.userName ?? null }; + + const result = await insertOne(body, operator); + if ('skipped' in result) { + return c.json( + { success: false, message: `此车已有干预(候选车 ${result.existingPlate}),请先解除` }, + 409, + ); + } + + console.log( + `[scheduling:notify] operator=${operator.name} suggestion=${suggestionId} current=${currentPlate} candidate=${candidatePlate}`, ); + + return c.json({ + success: true, + message: `干预已登记:${currentPlate} → ${candidatePlate}`, + record: result, + }); + } catch (e: unknown) { + console.error('scheduling notify error:', e); + return c.json({ success: false, message: '登记干预失败' }, 500); } + }); - console.log( - `[scheduling:notify] operator=${operator.name} suggestion=${suggestionId} current=${currentPlate} candidate=${candidatePlate}`, - ); - - return c.json({ - success: true, - message: `干预已登记:${currentPlate} → ${candidatePlate}`, - record: result, - }); - } catch (e: unknown) { - console.error('scheduling notify error:', e); - return c.json({ success: false, message: '登记干预失败' }, 500); - } -}); - -// POST /api/scheduling/notify/batch — bulk notify -app.post('/batch', async (c) => { - try { - const body = await c.req.json(); - if (!Array.isArray(body.items) || body.items.length === 0) { - return c.json({ success: false, message: '缺少 items' }, 400); - } - - const user = (c as any).get('user') as AuthUser | undefined; - const operator = { - id: user?.userId ?? null, - name: user?.userName ?? null, - }; - - const result: NotifyBatchResult = { success: 0, skipped: 0, failed: 0, records: [] }; - for (const item of body.items) { - if (!hasRequiredNotifyFields(item)) { - result.failed++; - continue; + // POST /api/scheduling/notify/batch — bulk notify + app.post('/batch', async (c) => { + try { + const body = await c.req.json(); + if (!Array.isArray(body.items) || body.items.length === 0) { + return c.json({ success: false, message: '缺少 items' }, 400); } - try { - const r = await insertNotification(item, operator); - if ('skipped' in r) result.skipped++; - else { - result.success++; - result.records.push(r); + + const user = (c as any).get('user') as AuthUser | undefined; + const operator = { id: user?.userId ?? null, name: user?.userName ?? null }; + + const result: NotifyBatchResult = { success: 0, skipped: 0, failed: 0, records: [] }; + for (const item of body.items) { + if (!hasRequiredNotifyFields(item)) { + result.failed++; + continue; + } + try { + const r = await insertOne(item, operator); + if ('skipped' in r) result.skipped++; + else { + result.success++; + result.records.push(r); + } + } catch { + result.failed++; } - } catch { - result.failed++; } + + console.log( + `[scheduling:notify:batch] operator=${operator.name} total=${body.items.length} success=${result.success} skipped=${result.skipped} failed=${result.failed}`, + ); + + return c.json({ + success: true, + message: `批量干预:成功 ${result.success},跳过 ${result.skipped},失败 ${result.failed}`, + result, + }); + } catch (e: unknown) { + console.error('scheduling batch notify error:', e); + return c.json({ success: false, message: '批量干预失败' }, 500); } + }); - console.log( - `[scheduling:notify:batch] operator=${operator.name} total=${body.items.length} success=${result.success} skipped=${result.skipped} failed=${result.failed}`, - ); - - return c.json({ - success: true, - message: `批量干预:成功 ${result.success},跳过 ${result.skipped},失败 ${result.failed}`, - result, - }); - } catch (e: unknown) { - console.error('scheduling batch notify error:', e); - return c.json({ success: false, message: '批量干预失败' }, 500); - } -}); - -// GET /api/scheduling/notify — list all notifications (history) -app.get('/', async (c) => { - try { - const status = c.req.query('status'); - const limit = Math.min(Number(c.req.query('limit')) || 200, 500); - - const where: string[] = []; - const params: (string | number)[] = []; - if (status) { - where.push('status = ?'); - params.push(status); + // GET /api/scheduling/notify — list all notifications (history) + app.get('/', async (c) => { + try { + const rawStatus = c.req.query('status'); + const limit = Math.min(Number(c.req.query('limit')) || 200, 500); + const rows = await listNotifications(db, { status: rawStatus || null, limit }); + return c.json({ records: rows.map(rowToNotificationRecord) }); + } catch (e: unknown) { + console.error('scheduling notifications list error:', e); + return c.json({ records: [] }, 500); } - const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : ''; - params.push(limit); + }); - const [rows] = (await pool.query( - `SELECT * FROM tab_scheduling_notifications - ${whereSql} - ORDER BY created_at DESC - LIMIT ?`, - params, - )) as [any[], unknown]; + // PATCH /api/scheduling/notify/:id — update status (execute / cancel) + app.patch('/:id', async (c) => { + try { + const id = Number(c.req.param('id')); + if (!Number.isFinite(id) || id <= 0) { + return c.json({ success: false, message: 'id 无效' }, 400); + } - return c.json({ records: (rows as NotificationDbRow[]).map(rowToNotificationRecord) }); - } catch (e: unknown) { - console.error('scheduling notifications list error:', e); - return c.json({ records: [] }, 500); - } -}); + const body = await c.req.json(); + if (!body.status) { + return c.json({ success: false, message: '缺少 status' }, 400); + } -// PATCH /api/scheduling/notify/:id — update status (execute / cancel) -app.patch('/:id', async (c) => { - try { - const id = Number(c.req.param('id')); - if (!Number.isFinite(id) || id <= 0) { - return c.json({ success: false, message: 'id 无效' }, 400); + if (!isNotificationStatus(body.status)) { + return c.json({ success: false, message: 'status 不合法' }, 400); + } + + const { fields, params } = buildNotificationUpdate(body, id); + await updateNotification(db, fields, params); + + const row = await findNotificationById(db, id); + if (!row) { + return c.json({ success: false, message: '记录不存在' }, 404); + } + + return c.json({ success: true, record: rowToNotificationRecord(row) }); + } catch (e: unknown) { + console.error('scheduling notification update error:', e); + return c.json({ success: false, message: '更新失败' }, 500); } + }); +} - const body = await c.req.json(); - if (!body.status) { - return c.json({ success: false, message: '缺少 status' }, 400); - } - - if (!isNotificationStatus(body.status)) { - return c.json({ success: false, message: 'status 不合法' }, 400); - } - - const { fields, params } = buildNotificationUpdate(body, id); - - await pool.execute( - `UPDATE tab_scheduling_notifications SET ${fields.join(', ')} WHERE id = ?`, - params, - ); - - const [rows] = (await pool.execute( - `SELECT * FROM tab_scheduling_notifications WHERE id = ?`, - [id], - )) as [any[], unknown]; - - if (rows.length === 0) { - return c.json({ success: false, message: '记录不存在' }, 404); - } - - return c.json({ - success: true, - record: rowToNotificationRecord(rows[0] as NotificationDbRow), - }); - } catch (e: unknown) { - console.error('scheduling notification update error:', e); - return c.json({ success: false, message: '更新失败' }, 500); - } -}); +/** 生产用路由器:绑定真实连接池。 */ +export function createNotifyRouter(): Hono { + const app = new Hono(); + registerNotifyRoutes(app, { db: mysqlPool }); + return app; +} +const app = createNotifyRouter(); export default app; diff --git a/src/server/routes/scheduling/repository.ts b/src/server/routes/scheduling/repository.ts new file mode 100644 index 0000000..c6cc07d --- /dev/null +++ b/src/server/routes/scheduling/repository.ts @@ -0,0 +1,176 @@ +import type { RowDataPacket } from 'mysql2'; +import type { NotificationStatus } from './types.js'; +import type { NotificationDbRow } from './notification-model.js'; + +/** + * 智能调度的数据访问。 + * + * 通知/干预记录的全部 SQL 集中在此;字段映射与更新字段拼装仍在 + * notification-model.ts(纯函数,已有单测)。SQL 文本与参数顺序由 + * notify-routes.test.ts 的契约测试锁定。 + */ + +/** MySQL 连接:只声明用到的能力。 */ +export interface Database { + query(sql: string, values?: any[]): Promise<[T, ...any[]]>; + execute(sql: string, values?: any[]): Promise<[T, ...any[]]>; +} + +/** 近 7 天未取消的干预条数。 */ +export async function countRecentInterventions(db: Database): Promise { + const [rows] = (await db.execute( + `SELECT COUNT(*) AS cnt FROM tab_scheduling_notifications + WHERE status != 'cancelled' + AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)`, + )) as [any[], unknown]; + return rows.length > 0 ? Number(rows[0].cnt) || 0 : 0; +} + +/** 全部未取消的通知(按创建时间倒序)。 */ +export async function listActiveNotifications(db: Database): Promise { + const [rows] = (await db.execute( + `SELECT id, suggestion_id, candidate_plate, status, created_at + FROM tab_scheduling_notifications + WHERE status != 'cancelled' + ORDER BY created_at DESC`, + )) as [RowDataPacket[], unknown]; + return rows; +} + +/** + * 同一 suggestion 是否已有生效干预(每个当前车辆同一时刻只允许一条)。 + */ +export async function findBlockingNotification( + db: Database, + suggestionId: string, +): Promise { + const [rows] = (await db.execute( + `SELECT id, candidate_plate FROM tab_scheduling_notifications + WHERE suggestion_id = ? AND status != 'cancelled' + LIMIT 1`, + [suggestionId], + )) as [RowDataPacket[], unknown]; + return rows; +} + +export interface NewNotification { + suggestionId: string; + currentPlate: string; + candidatePlate: string; + operatorId: string | null; + operatorName: string | null; +} + +export async function insertNotification(db: Database, row: NewNotification): Promise { + const [result] = (await db.execute( + `INSERT INTO tab_scheduling_notifications + (suggestion_id, current_plate, candidate_plate, operator_id, operator_name, status) + VALUES (?, ?, ?, ?, ?, 'sent')`, + [row.suggestionId, row.currentPlate, row.candidatePlate, row.operatorId, row.operatorName], + )) as [any, unknown]; + return Number(result.insertId); +} + +export async function findNotificationById(db: Database, id: number): Promise { + const [rows] = (await db.execute( + `SELECT * FROM tab_scheduling_notifications WHERE id = ?`, + [id], + )) as [NotificationDbRow[], unknown]; + return rows[0]; +} + +/** 历史列表:可选状态过滤 + 条数上限。 */ +export async function listNotifications( + db: Database, + options: { status: string | null; limit: number }, +): Promise { + const where: string[] = []; + const params: (string | number)[] = []; + if (options.status) { + where.push('status = ?'); + params.push(options.status); + } + const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : ''; + params.push(options.limit); + + const [rows] = (await db.query( + `SELECT * FROM tab_scheduling_notifications + ${whereSql} + ORDER BY created_at DESC + LIMIT ?`, + params, + )) as [NotificationDbRow[], unknown]; + return rows; +} + +/** 按 buildNotificationUpdate 产出的片段更新记录。 */ +export async function updateNotification( + db: Database, + fields: string[], + params: (string | number | null)[], +): Promise { + await db.execute( + `UPDATE tab_scheduling_notifications SET ${fields.join(', ')} WHERE id = ?`, + params, + ); +} + +/** 供 suggestions 视图使用的活跃状态映射所需的状态类型。 */ +export type { NotificationStatus }; + +/** 考核目标(用于年度目标里程)。 */ +export async function loadAssessmentTargets(db: Database): Promise { + const [rows] = (await db.execute( + 'SELECT id, target_name, annual_mileage_per_vehicle FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id', + )) as [RowDataPacket[], unknown]; + return rows; +} + + +/** 考核车辆明细。 */ +export async function loadAssessmentVehicles(db: Database): Promise { + const [rows] = (await db.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 [RowDataPacket[], unknown]; + return rows; +} + + +/** 在运营车辆的类型(vehicle_info × vehicle_model)。 */ +export async function loadVehicleTypes(db: Database): Promise { + const [rows] = (await db.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 [RowDataPacket[], unknown]; + return rows; +} + + +/** 实时车辆省份/城市。 */ +export async function loadRealtimeLocations(db: Database): Promise { + const [rows] = (await db.execute(` + SELECT plate_number, province, city + FROM tab_truck_remote_sync_realtime_info + WHERE is_deleted = 0 AND plate_number IS NOT NULL`)) as [RowDataPacket[], unknown]; + return rows; +} + + +/** 库存车辆(operation_status 3/4 且非停用)。 */ +export async function loadInventoryVehicles(db: Database): Promise { + const [rows] = (await db.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 [RowDataPacket[], unknown]; + return rows; +} diff --git a/src/server/routes/scheduling/routes.test.ts b/src/server/routes/scheduling/routes.test.ts new file mode 100644 index 0000000..39f5725 --- /dev/null +++ b/src/server/routes/scheduling/routes.test.ts @@ -0,0 +1,239 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import test from "node:test"; +import { Hono } from "hono"; +import type { AuthUser } from "../../auth/types.js"; +import { registerNotifyRoutes, fetchActiveNotificationMap, fetchRecentInterventionCount } from "./notify.js"; +import { registerSuggestionsRoutes } from "./suggestions.js"; +import type { Database } from "./repository.js"; + +interface Call { + via: "query" | "execute"; + sql: string; + params: unknown[]; +} + +const normalize = (sql: string) => sql.replace(/\s+/g, " ").trim(); + +/** 依次弹出预设结果;未预设则返回空数组。 */ +function createMockDb(resultSets: unknown[] = []): { db: Database; calls: Call[] } { + const calls: Call[] = []; + const queue = [...resultSets]; + const mk = (via: "query" | "execute") => async (sql: string, params?: any[]) => { + calls.push({ via, sql: normalize(sql), params: (params ?? []) as unknown[] }); + return [queue.shift() ?? [], []] as never; + }; + return { db: { query: mk("query"), execute: mk("execute") }, calls }; +} + +const USER: AuthUser = { + userId: "u-1", userName: "张三", loginName: "zhangsan", depCode: "", depName: "", + permissionLevel: "full", roles: ["BI-SCHEDULE-OPT"], +}; + +function appWith(register: (app: any, deps: any) => void, db: Database, extra: Record = {}) { + const app = new Hono<{ Variables: { user: AuthUser } }>(); + app.use("*", async (c, next) => { + c.set("user", USER); + await next(); + }); + register(app, { db, ...extra }); + return app; +} + +/** 建议列表依赖的跨域数据源:测试中替成空实现,避免触达真实库/外部 API。 */ +const STUB_DEPS = { + loadVehicleInfoMap: async () => new Map(), + loadOneOsMileageDates: async () => new Map(), +}; + +const NOTIFICATION_ROW = { + id: 5, + suggestion_id: "sug-1", + current_plate: "粤A1", + candidate_plate: "粤B2", + operator_id: "u-1", + operator_name: "张三", + status: "sent", + created_at: "2026-07-01 10:00:00", + updated_at: "2026-07-01 10:00:00", + executed_at: null, + notes: null, + before_mileage: null, + after_mileage: null, +}; + +test("干预登记:先查是否已有生效记录,再 INSERT 并回读", async () => { + const { db, calls } = createMockDb([[], { insertId: 5 }, [NOTIFICATION_ROW]]); + const app = appWith(registerNotifyRoutes, db); + + const res = await app.request("/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ suggestionId: "sug-1", currentPlate: "粤A1", candidatePlate: "粤B2" }), + }); + + assert.equal(res.status, 200); + const payload = await res.json() as { success: boolean; record: Record }; + assert.equal(payload.success, true); + assert.equal(payload.record.candidatePlate, "粤B2"); + + assert.equal(calls.length, 3); + assert.equal( + calls[0].sql, + "SELECT id, candidate_plate FROM tab_scheduling_notifications WHERE suggestion_id = ? AND status != 'cancelled' LIMIT 1", + ); + assert.deepEqual(calls[0].params, ["sug-1"]); + assert.equal(calls[0].via, "execute"); + assert.equal( + calls[1].sql, + "INSERT INTO tab_scheduling_notifications (suggestion_id, current_plate, candidate_plate, operator_id, operator_name, status) VALUES (?, ?, ?, ?, ?, 'sent')", + ); + assert.deepEqual(calls[1].params, ["sug-1", "粤A1", "粤B2", "u-1", "张三"]); + assert.equal(calls[2].sql, "SELECT * FROM tab_scheduling_notifications WHERE id = ?"); + assert.deepEqual(calls[2].params, [5]); +}); + +test("干预登记:已有生效记录返回 409 且不写库", async () => { + const { db, calls } = createMockDb([[{ id: 9, candidate_plate: "粤C3" }]]); + const app = appWith(registerNotifyRoutes, db); + + const res = await app.request("/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ suggestionId: "sug-1", currentPlate: "粤A1", candidatePlate: "粤B2" }), + }); + + assert.equal(res.status, 409); + assert.equal(calls.length, 1, "被阻断后不应再 INSERT"); + const payload = await res.json() as { message: string }; + assert.equal(payload.message.includes("粤C3"), true); +}); + +test("干预登记:缺少必要参数返回 400 且不查库", async () => { + const { db, calls } = createMockDb(); + const app = appWith(registerNotifyRoutes, db); + const res = await app.request("/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ suggestionId: "sug-1" }), + }); + assert.equal(res.status, 400); + assert.equal(calls.length, 0); +}); + +test("历史列表:状态过滤与条数上限,SQL 与参数顺序锁定", async () => { + const { db, calls } = createMockDb([[]]); + const app = appWith(registerNotifyRoutes, db); + + await app.request("/?status=sent&limit=9999"); + assert.equal(calls.length, 1); + assert.equal(calls[0].via, "query", "列表沿用 query(非 execute)"); + assert.equal( + calls[0].sql, + "SELECT * FROM tab_scheduling_notifications WHERE status = ? ORDER BY created_at DESC LIMIT ?", + ); + assert.deepEqual(calls[0].params, ["sent", 500], "limit 上限 500"); + + calls.length = 0; + await app.request("/"); + assert.equal(calls[0].sql, "SELECT * FROM tab_scheduling_notifications ORDER BY created_at DESC LIMIT ?"); + assert.deepEqual(calls[0].params, [200], "无过滤时默认 200"); +}); + +test("状态更新:UPDATE 字段顺序与回读 SQL 保持", async () => { + const { db, calls } = createMockDb([{}, [NOTIFICATION_ROW]]); + const app = appWith(registerNotifyRoutes, db); + + const res = await app.request("/5", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "executed" }), + }); + + assert.equal(res.status, 200); + assert.equal(calls[0].via, "execute"); + assert.equal(calls[0].sql.startsWith("UPDATE tab_scheduling_notifications SET "), true); + assert.equal(calls[0].sql.endsWith("WHERE id = ?"), true); + assert.equal(calls[0].params[calls[0].params.length - 1], 5); + assert.equal(calls[1].sql, "SELECT * FROM tab_scheduling_notifications WHERE id = ?"); +}); + +test("状态更新:非法 id / 缺 status / 非法 status 一律 400 且不查库", async () => { + const { db, calls } = createMockDb(); + const app = appWith(registerNotifyRoutes, db); + const cases: Array<[string, unknown]> = [ + ["/0", { status: "executed" }], + ["/5", {}], + ["/5", { status: "nope" }], + ]; + for (const [url, body] of cases) { + const res = await app.request(url, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + assert.equal(res.status, 400, `${url} ${JSON.stringify(body)}`); + } + assert.equal(calls.length, 0); +}); + +test("状态更新:记录不存在返回 404", async () => { + const { db } = createMockDb([{}, []]); + const app = appWith(registerNotifyRoutes, db); + const res = await app.request("/5", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "cancelled" }), + }); + assert.equal(res.status, 404); +}); + +test("活跃映射与近 7 天计数:SQL 与映射键保持", async () => { + const { db, calls } = createMockDb([ + [{ id: 1, suggestion_id: "sug-1", candidate_plate: "粤B2", status: "sent", created_at: "2026-07-01 10:00:00" }], + [{ cnt: "4" }], + ]); + + const map = await fetchActiveNotificationMap(db); + assert.equal(map.get("sug-1::粤B2")?.id, 1); + assert.equal( + calls[0].sql, + "SELECT id, suggestion_id, candidate_plate, status, created_at FROM tab_scheduling_notifications WHERE status != 'cancelled' ORDER BY created_at DESC", + ); + + assert.equal(await fetchRecentInterventionCount(db), 4); + assert.equal( + calls[1].sql, + "SELECT COUNT(*) AS cnt FROM tab_scheduling_notifications WHERE status != 'cancelled' AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)", + ); +}); + +test("建议列表:五条基础查询的 SQL 与顺序保持(不查外部 API 的路径)", async () => { + const { db, calls } = createMockDb([[], [], [], [], []]); + const app = appWith(registerSuggestionsRoutes, db, STUB_DEPS); + + // targetId 让响应可预测;OneOS 段在无车牌时不会触发 + const res = await app.request("/?targetId=1"); + assert.equal(res.status, 200); + + const sqls = calls.map(c => c.sql); + assert.equal(sqls[0], "SELECT id, target_name, annual_mileage_per_vehicle FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id"); + assert.equal(sqls[1].startsWith("SELECT target_id, plate_number, today_mileage, vehicle_total_mileage,"), true); + assert.equal(sqls[1].includes("FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0"), true); + assert.equal(sqls[2].includes("FROM vehicle_info vi LEFT JOIN vehicle_status vs"), true); + assert.equal(sqls[2].includes("COALESCE(vs.operation_status, '') <> '5'"), true); + assert.equal(sqls[3], "SELECT plate_number, province, city FROM tab_truck_remote_sync_realtime_info WHERE is_deleted = 0 AND plate_number IS NOT NULL"); + assert.equal(sqls[4].includes("COALESCE(vs.operation_status, '') IN ('3','4')"), true, "库存车辆条件保持"); +}); + +test("分层:调度域的路由文件不再直接书写 SQL", () => { + const dir = path.dirname(fileURLToPath(import.meta.url)); + for (const file of ["notify.ts", "suggestions.ts"]) { + const source = readFileSync(path.join(dir, file), "utf8"); + const offenders = source.match(/\b(SELECT|INSERT INTO|UPDATE |DELETE FROM)\b/g) ?? []; + assert.deepEqual(offenders, [], `${file} 的 SQL 必须留在 repository.ts`); + } +}); diff --git a/src/server/routes/scheduling/suggestions.ts b/src/server/routes/scheduling/suggestions.ts index 06afc14..e24991f 100644 --- a/src/server/routes/scheduling/suggestions.ts +++ b/src/server/routes/scheduling/suggestions.ts @@ -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(); 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(); 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(); 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(); 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(); @@ -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;