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`) | ✅ 完整分层;同上 |
|
| `feedback/` | `routes.ts` `repository.ts` `oss.ts`(+ `routes.test.ts`) | ✅ 完整分层;同上 |
|
||||||
| `mileage/` | `index.ts`(聚合)+ `monitoring.ts` `targets.ts` `trend.ts` `daily-report.ts` `vehicle-recent.ts` + `*-model.ts` + `cache.ts` `oneos-api.ts` `daily-report-{service,store,scheduler}.ts` | 路由 / 模型 / 服务已分开,**SQL 仍在各路由文件内** |
|
| `mileage/` | `index.ts`(聚合)+ `monitoring.ts` `targets.ts` `trend.ts` `daily-report.ts` `vehicle-recent.ts` + `*-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 仍在路由内** |
|
| `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` 片段与参数顺序已锁定 |
|
| `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(定位点)两个库 |
|
| `vehicle-heatmap/` | `routes.ts` `repository.ts` `model.ts`(+ `routes.test.ts`) | ✅ 完整分层;同时覆盖 MySQL(考核批次)与 PG(定位点)两个库 |
|
||||||
|
|
||||||
已完整分层的五个域(`vehicles` / `ele` / `feedback` / `vehicle-heatmap` / `hydrogen-heatmap`)
|
已完整分层的六个域(`vehicles` / `ele` / `feedback` / `vehicle-heatmap` / `hydrogen-heatmap` / `scheduling`)
|
||||||
由架构测试守护:`routes.ts` 不得出现 SQL,且必须存在 `repository.ts`。
|
由架构测试守护:必须存在 `repository.ts`,且该域**其他任何非测试文件都不得含 SQL**。
|
||||||
其余域(`energy` / `mileage` / `scheduling`)尚未拆出 repository——拆分时**不要改变 SQL 与参数顺序**,
|
其余域(`energy` / `mileage`)尚未拆出 repository——拆分时**不要改变 SQL 与参数顺序**,
|
||||||
请按 `ele/routes.test.ts` 的配方先补契约测试,并用"改造前后同一批请求对比落库 SQL 与响应体"做等价性验证。
|
请按 `ele/routes.test.ts` 的配方先补契约测试,并用"改造前后同一批请求对比落库 SQL 与响应体"做等价性验证。
|
||||||
|
|
||||||
|
|
||||||
@@ -139,10 +139,11 @@ cors → read-only → /api/auth(公开) → authMiddleware → 各业务域
|
|||||||
|
|
||||||
诚实记录,避免后来者以为已经做完:
|
诚实记录,避免后来者以为已经做完:
|
||||||
|
|
||||||
- **后端仍有 3 个域没拆出 repository**:`energy` / `mileage` / `scheduling` 的 SQL 仍在各自的
|
- **后端仍有 2 个域没拆出 repository**:`energy` / `mileage` 的 SQL 仍在各自的路由文件里
|
||||||
路由文件里(形状见上表)。已完成的有 5 个域,可作为模板:路由只做校验与组装,
|
(形状见上表)。已完成的有 6 个域,可作为模板:路由只做校验与组装,SQL 进 `repository.ts`,
|
||||||
SQL 进 `repository.ts`,纯逻辑进 `model.ts`,并用 mock pool 的契约测试锁定 SQL 与参数。
|
纯逻辑进 `model.ts`,并用 mock pool 的契约测试锁定 SQL 与参数。
|
||||||
注意 `vehicle-heatmap` 的 SQL 是小写、`hydrogen-heatmap` 是大小写混排,正则检查要忽略大小写。
|
守卫用的是"语句形状"正则(如 `update <table> set`)而不是裸关键字,避免把
|
||||||
|
`UpdateNotification` 或日志里的 "update error" 误判为 SQL;小写 SQL 同样能被抓到。
|
||||||
- **运行时建表已集中到 `server/db/schema/`**,并在 `DB_READ_ONLY=1` 时整体跳过(由架构测试守护,
|
- **运行时建表已集中到 `server/db/schema/`**,并在 `DB_READ_ONLY=1` 时整体跳过(由架构测试守护,
|
||||||
已实测不触碰数据库)。它仍由业务接口在首次调用时触发,而不是只由 `bootstrap.ts` 调用——
|
已实测不触碰数据库)。它仍由业务接口在首次调用时触发,而不是只由 `bootstrap.ts` 调用——
|
||||||
要彻底改成显式迁移,需要先建立数据库变更脚本流程,避免"代码里偷偷建表"。
|
要彻底改成显式迁移,需要先建立数据库变更脚本流程,避免"代码里偷偷建表"。
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ function targetPath(file: string, spec: string): string | null {
|
|||||||
return path.relative(srcDir, path.resolve(path.dirname(file), spec));
|
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[] {
|
function filesUnder(prefix: string): string[] {
|
||||||
return allFiles.filter((f) => rel(f).startsWith(prefix));
|
return allFiles.filter((f) => rel(f).startsWith(prefix));
|
||||||
}
|
}
|
||||||
@@ -164,7 +169,7 @@ test("运行时建表必须尊重只读模式", () => {
|
|||||||
assert.deepEqual(offenders, [], "每个 schema 模块都要在 DB_READ_ONLY=1 时跳过 DDL");
|
assert.deepEqual(offenders, [], "每个 schema 模块都要在 DB_READ_ONLY=1 时跳过 DDL");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("已完整分层的域:routes.ts 不含 SQL,且存在 repository.ts", () => {
|
test("已完整分层的域:repository.ts 存在,且 SQL 只允许出现在那里", () => {
|
||||||
// 这些域已完成 routes / repository / model 拆分;其余域仍把 SQL 放在路由里
|
// 这些域已完成 routes / repository / model 拆分;其余域仍把 SQL 放在路由里
|
||||||
// (见 docs/ARCHITECTURE.md 的"后端业务域形状"表)。
|
// (见 docs/ARCHITECTURE.md 的"后端业务域形状"表)。
|
||||||
const layered = [
|
const layered = [
|
||||||
@@ -173,14 +178,25 @@ test("已完整分层的域:routes.ts 不含 SQL,且存在 repository.ts", (
|
|||||||
"server/routes/feedback",
|
"server/routes/feedback",
|
||||||
"server/routes/vehicle-heatmap",
|
"server/routes/vehicle-heatmap",
|
||||||
"server/routes/hydrogen-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[] = [];
|
const offenders: string[] = [];
|
||||||
for (const dir of layered) {
|
for (const dir of layered) {
|
||||||
if (!existsSync(path.join(srcDir, dir, "repository.ts"))) offenders.push(`${dir}/repository.ts 缺失`);
|
const abs = path.join(srcDir, dir);
|
||||||
const routes = path.join(srcDir, dir, "routes.ts");
|
if (!existsSync(path.join(abs, "repository.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}/repository.ts 缺失`);
|
||||||
offenders.push(`${dir}/routes.ts 仍含 SQL`);
|
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 { Hono } from 'hono';
|
||||||
import pool from '../../db/mysql.js';
|
import mysqlPool from '../../db/mysql.js';
|
||||||
import type { AuthUser } from '../../auth/types.js';
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
import type {
|
import type {
|
||||||
NotifyRequest,
|
NotifyRequest,
|
||||||
@@ -18,78 +18,66 @@ import {
|
|||||||
type ActiveNotificationRow,
|
type ActiveNotificationRow,
|
||||||
type NotificationDbRow,
|
type NotificationDbRow,
|
||||||
} from './notification-model.js';
|
} 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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/** 近 7 天未取消的干预条数(供建议列表使用)。 */
|
||||||
* Count non-cancelled interventions created within the last 7 days.
|
export async function fetchRecentInterventionCount(db: Database): Promise<number> {
|
||||||
*/
|
return countRecentInterventions(db);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch notification status map for the currently-visible (suggestion, candidate) pairs.
|
* 当前可见 (suggestion, candidate) 组合的通知状态映射。
|
||||||
* Key: `${suggestionId}::${candidatePlate}` → latest non-cancelled notification.
|
* Key: `${suggestionId}::${candidatePlate}` → 最近一条未取消通知。
|
||||||
*/
|
*/
|
||||||
export async function fetchActiveNotificationMap(): Promise<
|
export async function fetchActiveNotificationMap(
|
||||||
Map<string, { id: number; status: NotificationStatus }>
|
db: Database,
|
||||||
> {
|
): Promise<Map<string, { id: number; status: NotificationStatus }>> {
|
||||||
const [rows] = (await pool.execute(
|
const rows = await listActiveNotifications(db);
|
||||||
`SELECT id, suggestion_id, candidate_plate, status, created_at
|
return buildActiveNotificationMap(rows as unknown as ActiveNotificationRow[]);
|
||||||
FROM tab_scheduling_notifications
|
|
||||||
WHERE status != 'cancelled'
|
|
||||||
ORDER BY created_at DESC`,
|
|
||||||
)) as [any[], unknown];
|
|
||||||
|
|
||||||
return buildActiveNotificationMap(rows as ActiveNotificationRow[]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function insertNotification(
|
export function registerNotifyRoutes(app: Hono, deps: NotifyDependencies): void {
|
||||||
|
const { db } = deps;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登记一条干预。
|
||||||
|
* 业务规则:同一 suggestion 同一时刻最多一条生效干预,已有未取消记录则跳过。
|
||||||
|
*/
|
||||||
|
async function insertOne(
|
||||||
req: NotifyRequest,
|
req: NotifyRequest,
|
||||||
operator: { id: string | null; name: string | null },
|
operator: { id: string | null; name: string | null },
|
||||||
): Promise<NotificationRecord | { skipped: true; existingPlate: string }> {
|
): Promise<NotificationRecord | { skipped: true; existingPlate: string }> {
|
||||||
// Business rule: each current vehicle (suggestion) can have AT MOST ONE
|
const existing = await findBlockingNotification(db, req.suggestionId);
|
||||||
// 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];
|
|
||||||
|
|
||||||
if (existing.length > 0) {
|
if (existing.length > 0) {
|
||||||
return { skipped: true, existingPlate: existing[0].candidate_plate as string };
|
return { skipped: true, existingPlate: existing[0].candidate_plate as string };
|
||||||
}
|
}
|
||||||
|
|
||||||
const [result] = (await pool.execute(
|
const insertedId = await insertNotification(db, {
|
||||||
`INSERT INTO tab_scheduling_notifications
|
suggestionId: req.suggestionId,
|
||||||
(suggestion_id, current_plate, candidate_plate, operator_id, operator_name, status)
|
currentPlate: req.currentPlate,
|
||||||
VALUES (?, ?, ?, ?, ?, 'sent')`,
|
candidatePlate: req.candidatePlate,
|
||||||
[req.suggestionId, req.currentPlate, req.candidatePlate, operator.id, operator.name],
|
operatorId: operator.id,
|
||||||
)) as [any, unknown];
|
operatorName: operator.name,
|
||||||
|
});
|
||||||
|
const row = await findNotificationById(db, insertedId);
|
||||||
|
return rowToNotificationRecord(row as NotificationDbRow);
|
||||||
|
}
|
||||||
|
|
||||||
const insertedId = Number(result.insertId);
|
// POST /api/scheduling/notify — single notify
|
||||||
const [rows] = (await pool.execute(
|
app.post('/', async (c) => {
|
||||||
`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 {
|
try {
|
||||||
const body = await c.req.json<NotifyRequest>();
|
const body = await c.req.json<NotifyRequest>();
|
||||||
const { suggestionId, currentPlate, candidatePlate } = body;
|
const { suggestionId, currentPlate, candidatePlate } = body;
|
||||||
@@ -99,12 +87,9 @@ app.post('/', async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = (c as any).get('user') as AuthUser | undefined;
|
const user = (c as any).get('user') as AuthUser | undefined;
|
||||||
const operator = {
|
const operator = { id: user?.userId ?? null, name: user?.userName ?? null };
|
||||||
id: user?.userId ?? null,
|
|
||||||
name: user?.userName ?? null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await insertNotification(body, operator);
|
const result = await insertOne(body, operator);
|
||||||
if ('skipped' in result) {
|
if ('skipped' in result) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{ success: false, message: `此车已有干预(候选车 ${result.existingPlate}),请先解除` },
|
{ success: false, message: `此车已有干预(候选车 ${result.existingPlate}),请先解除` },
|
||||||
@@ -125,10 +110,10 @@ app.post('/', async (c) => {
|
|||||||
console.error('scheduling notify error:', e);
|
console.error('scheduling notify error:', e);
|
||||||
return c.json({ success: false, message: '登记干预失败' }, 500);
|
return c.json({ success: false, message: '登记干预失败' }, 500);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/scheduling/notify/batch — bulk notify
|
// POST /api/scheduling/notify/batch — bulk notify
|
||||||
app.post('/batch', async (c) => {
|
app.post('/batch', async (c) => {
|
||||||
try {
|
try {
|
||||||
const body = await c.req.json<NotifyBatchRequest>();
|
const body = await c.req.json<NotifyBatchRequest>();
|
||||||
if (!Array.isArray(body.items) || body.items.length === 0) {
|
if (!Array.isArray(body.items) || body.items.length === 0) {
|
||||||
@@ -136,10 +121,7 @@ app.post('/batch', async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = (c as any).get('user') as AuthUser | undefined;
|
const user = (c as any).get('user') as AuthUser | undefined;
|
||||||
const operator = {
|
const operator = { id: user?.userId ?? null, name: user?.userName ?? null };
|
||||||
id: user?.userId ?? null,
|
|
||||||
name: user?.userName ?? null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result: NotifyBatchResult = { success: 0, skipped: 0, failed: 0, records: [] };
|
const result: NotifyBatchResult = { success: 0, skipped: 0, failed: 0, records: [] };
|
||||||
for (const item of body.items) {
|
for (const item of body.items) {
|
||||||
@@ -148,7 +130,7 @@ app.post('/batch', async (c) => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const r = await insertNotification(item, operator);
|
const r = await insertOne(item, operator);
|
||||||
if ('skipped' in r) result.skipped++;
|
if ('skipped' in r) result.skipped++;
|
||||||
else {
|
else {
|
||||||
result.success++;
|
result.success++;
|
||||||
@@ -172,40 +154,23 @@ app.post('/batch', async (c) => {
|
|||||||
console.error('scheduling batch notify error:', e);
|
console.error('scheduling batch notify error:', e);
|
||||||
return c.json({ success: false, message: '批量干预失败' }, 500);
|
return c.json({ success: false, message: '批量干预失败' }, 500);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/scheduling/notify — list all notifications (history)
|
// GET /api/scheduling/notify — list all notifications (history)
|
||||||
app.get('/', async (c) => {
|
app.get('/', async (c) => {
|
||||||
try {
|
try {
|
||||||
const status = c.req.query('status');
|
const rawStatus = c.req.query('status');
|
||||||
const limit = Math.min(Number(c.req.query('limit')) || 200, 500);
|
const limit = Math.min(Number(c.req.query('limit')) || 200, 500);
|
||||||
|
const rows = await listNotifications(db, { status: rawStatus || null, limit });
|
||||||
const where: string[] = [];
|
return c.json({ records: rows.map(rowToNotificationRecord) });
|
||||||
const params: (string | number)[] = [];
|
|
||||||
if (status) {
|
|
||||||
where.push('status = ?');
|
|
||||||
params.push(status);
|
|
||||||
}
|
|
||||||
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];
|
|
||||||
|
|
||||||
return c.json({ records: (rows as NotificationDbRow[]).map(rowToNotificationRecord) });
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
console.error('scheduling notifications list error:', e);
|
console.error('scheduling notifications list error:', e);
|
||||||
return c.json({ records: [] }, 500);
|
return c.json({ records: [] }, 500);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /api/scheduling/notify/:id — update status (execute / cancel)
|
// PATCH /api/scheduling/notify/:id — update status (execute / cancel)
|
||||||
app.patch('/:id', async (c) => {
|
app.patch('/:id', async (c) => {
|
||||||
try {
|
try {
|
||||||
const id = Number(c.req.param('id'));
|
const id = Number(c.req.param('id'));
|
||||||
if (!Number.isFinite(id) || id <= 0) {
|
if (!Number.isFinite(id) || id <= 0) {
|
||||||
@@ -222,29 +187,27 @@ app.patch('/:id', async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { fields, params } = buildNotificationUpdate(body, id);
|
const { fields, params } = buildNotificationUpdate(body, id);
|
||||||
|
await updateNotification(db, fields, params);
|
||||||
|
|
||||||
await pool.execute(
|
const row = await findNotificationById(db, id);
|
||||||
`UPDATE tab_scheduling_notifications SET ${fields.join(', ')} WHERE id = ?`,
|
if (!row) {
|
||||||
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: false, message: '记录不存在' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({
|
return c.json({ success: true, record: rowToNotificationRecord(row) });
|
||||||
success: true,
|
|
||||||
record: rowToNotificationRecord(rows[0] as NotificationDbRow),
|
|
||||||
});
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
console.error('scheduling notification update error:', e);
|
console.error('scheduling notification update error:', e);
|
||||||
return c.json({ success: false, message: '更新失败' }, 500);
|
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;
|
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 { Hono } from 'hono';
|
||||||
import pool from '../../db/mysql.js';
|
import mysqlPool from '../../db/mysql.js';
|
||||||
import { fetchVehicleInfoMap } from '../mileage/vehicle-info.js';
|
import { fetchVehicleInfoMap } from '../mileage/vehicle-info.js';
|
||||||
import { fetchOneOsMileageDates } from '../mileage/oneos-api.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 { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
||||||
import { classifyVehicle, generateSuggestions } from './algorithm.js';
|
import { classifyVehicle, generateSuggestions } from './algorithm.js';
|
||||||
import { fetchActiveNotificationMap, fetchRecentInterventionCount } from './notify.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 { EnrichedVehicle, InventoryVehicle, SchedulingResponse, SchedulingSummary } from './types.js';
|
||||||
import type { AuthUser } from '../../auth/types.js';
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
|
|
||||||
@@ -64,17 +72,28 @@ function recentCompletedDates(count: number): string[] {
|
|||||||
// Route
|
// 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 {
|
try {
|
||||||
const targetIdParam = c.req.query('targetId');
|
const targetIdParam = c.req.query('targetId');
|
||||||
const filterTargetId = targetIdParam ? Number(targetIdParam) : null;
|
const filterTargetId = targetIdParam ? Number(targetIdParam) : null;
|
||||||
|
|
||||||
// ---- Query 1: Assessment targets ----
|
// ---- Query 1: Assessment targets ----
|
||||||
const [targets] = await pool.execute(
|
const targets = await loadAssessmentTargets(db);
|
||||||
'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 targetMap = new Map<number, { targetName: string; annualMileage: number }>();
|
const targetMap = new Map<number, { targetName: string; annualMileage: number }>();
|
||||||
for (const t of targets) {
|
for (const t of targets) {
|
||||||
@@ -85,26 +104,13 @@ app.get('/', async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- Query 2: Assessment vehicles ----
|
// ---- Query 2: Assessment vehicles ----
|
||||||
const [assessmentRows] = await pool.execute(`
|
const assessmentRows = await loadAssessmentVehicles(db);
|
||||||
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];
|
|
||||||
|
|
||||||
// ---- Query 3: Vehicle info (customer, dept, manager) ----
|
// ---- Query 3: Vehicle info (customer, dept, manager) ----
|
||||||
const vehicleInfoMap = await fetchVehicleInfoMap();
|
const vehicleInfoMap = await loadVehicleInfoMap();
|
||||||
|
|
||||||
// ---- Query 4: Vehicle types from vehicle_info ----
|
// ---- Query 4: Vehicle types from vehicle_info ----
|
||||||
const [truckTypeRows] = await pool.execute(`
|
const truckTypeRows = await loadVehicleTypes(db);
|
||||||
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 truckTypeMap = new Map<string, { typeName: string; modelRaw: string }>();
|
const truckTypeMap = new Map<string, { typeName: string; modelRaw: string }>();
|
||||||
for (const row of truckTypeRows) {
|
for (const row of truckTypeRows) {
|
||||||
@@ -115,11 +121,7 @@ app.get('/', async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- Query 5: Real-time location ----
|
// ---- Query 5: Real-time location ----
|
||||||
const [locationRows] = await pool.execute(`
|
const locationRows = await loadRealtimeLocations(db);
|
||||||
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 locationMap = new Map<string, { province: string; city: string }>();
|
const locationMap = new Map<string, { province: string; city: string }>();
|
||||||
for (const row of locationRows) {
|
for (const row of locationRows) {
|
||||||
@@ -139,7 +141,7 @@ app.get('/', async (c) => {
|
|||||||
const dates = recentCompletedDates(30);
|
const dates = recentCompletedDates(30);
|
||||||
const sevenDayDates = new Set(dates.slice(-7));
|
const sevenDayDates = new Set(dates.slice(-7));
|
||||||
const allowedPlates = new Set(allPlates);
|
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 }>();
|
const aggregates = new Map<string, { sum30: number; count30: number; sum7: number; count7: number }>();
|
||||||
for (const [date, rows] of rowsByDate) {
|
for (const [date, rows] of rowsByDate) {
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -182,15 +184,7 @@ app.get('/', async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- Query 7: Inventory vehicles (rent_status = 0) ----
|
// ---- Query 7: Inventory vehicles (rent_status = 0) ----
|
||||||
const [inventoryTruckRows] = await pool.execute(`
|
const inventoryTruckRows = await loadInventoryVehicles(db);
|
||||||
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];
|
|
||||||
|
|
||||||
// ---- Build assessment vehicle lookup for inventory cross-reference ----
|
// ---- Build assessment vehicle lookup for inventory cross-reference ----
|
||||||
const assessmentByPlate = new Map<string, any>();
|
const assessmentByPlate = new Map<string, any>();
|
||||||
@@ -311,7 +305,7 @@ app.get('/', async (c) => {
|
|||||||
const { suggestions, summary } = generateSuggestions(enrichedVehicles, inventoryVehicles);
|
const { suggestions, summary } = generateSuggestions(enrichedVehicles, inventoryVehicles);
|
||||||
|
|
||||||
// ---- Attach notification status to candidates ----
|
// ---- Attach notification status to candidates ----
|
||||||
const notificationMap = await fetchActiveNotificationMap();
|
const notificationMap = await fetchActiveNotificationMap(db);
|
||||||
for (const s of suggestions) {
|
for (const s of suggestions) {
|
||||||
for (const c of s.candidates) {
|
for (const c of s.candidates) {
|
||||||
const key = `${s.id}::${c.plateNumber}`;
|
const key = `${s.id}::${c.plateNumber}`;
|
||||||
@@ -365,7 +359,7 @@ app.get('/', async (c) => {
|
|||||||
// Recalculate summary based on permission-filtered results
|
// Recalculate summary based on permission-filtered results
|
||||||
const filteredQualified = masked.filter((s: any) => s.type === 'replace_qualified').length;
|
const filteredQualified = masked.filter((s: any) => s.type === 'replace_qualified').length;
|
||||||
const filteredHopeless = masked.filter((s: any) => s.type === 'rescue_hopeless').length;
|
const filteredHopeless = masked.filter((s: any) => s.type === 'rescue_hopeless').length;
|
||||||
const recentInterventionCount = await fetchRecentInterventionCount();
|
const recentInterventionCount = await fetchRecentInterventionCount(db);
|
||||||
const filteredSummary: SchedulingSummary = {
|
const filteredSummary: SchedulingSummary = {
|
||||||
qualifiedCount: filteredQualified,
|
qualifiedCount: filteredQualified,
|
||||||
hopelessCount: filteredHopeless,
|
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;
|
export default app;
|
||||||
|
|||||||
Reference in New Issue
Block a user