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:
@@ -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 <table> set`)而不是裸关键字,避免把
|
||||
`UpdateNotification` 或日志里的 "update error" 误判为 SQL;小写 SQL 同样能被抓到。
|
||||
- **运行时建表已集中到 `server/db/schema/`**,并在 `DB_READ_ONLY=1` 时整体跳过(由架构测试守护,
|
||||
已实测不触碰数据库)。它仍由业务接口在首次调用时触发,而不是只由 `bootstrap.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");
|
||||
});
|
||||
|
||||
@@ -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<number> {
|
||||
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<number> {
|
||||
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<string, { id: number; status: NotificationStatus }>
|
||||
> {
|
||||
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<Map<string, { id: number; status: NotificationStatus }>> {
|
||||
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<NotificationRecord | { skipped: true; existingPlate: string }> {
|
||||
// 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<NotifyRequest>();
|
||||
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<NotificationRecord | { skipped: true; existingPlate: string }> {
|
||||
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<NotifyRequest>();
|
||||
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<NotifyBatchRequest>();
|
||||
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<NotifyBatchRequest>();
|
||||
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<UpdateNotificationRequest>();
|
||||
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<UpdateNotificationRequest>();
|
||||
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;
|
||||
|
||||
@@ -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<T = any>(sql: string, values?: any[]): Promise<[T, ...any[]]>;
|
||||
execute<T = any>(sql: string, values?: any[]): Promise<[T, ...any[]]>;
|
||||
}
|
||||
|
||||
/** 近 7 天未取消的干预条数。 */
|
||||
export async function countRecentInterventions(db: Database): Promise<number> {
|
||||
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<RowDataPacket[]> {
|
||||
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<RowDataPacket[]> {
|
||||
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<number> {
|
||||
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<NotificationDbRow | undefined> {
|
||||
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<NotificationDbRow[]> {
|
||||
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<void> {
|
||||
await db.execute(
|
||||
`UPDATE tab_scheduling_notifications SET ${fields.join(', ')} WHERE id = ?`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
/** 供 suggestions 视图使用的活跃状态映射所需的状态类型。 */
|
||||
export type { NotificationStatus };
|
||||
|
||||
/** 考核目标(用于年度目标里程)。 */
|
||||
export async function loadAssessmentTargets(db: Database): Promise<RowDataPacket[]> {
|
||||
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<RowDataPacket[]> {
|
||||
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<RowDataPacket[]> {
|
||||
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<RowDataPacket[]> {
|
||||
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<RowDataPacket[]> {
|
||||
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;
|
||||
}
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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<string, unknown> };
|
||||
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`);
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user