feat(scheduling): persist notifications, batch notify flow, dedup protection
- Add tab_scheduling_notifications table with bootstrap via ensureSchedulingTables() - Notify endpoint rewritten: dedup by (suggestion_id, candidate_plate), history list, PATCH /:id for execute/cancel lifecycle - Batch notify endpoint returns success/skipped/failed counts - Suggestions response now carries notificationId + notificationStatus per candidate (joined from active-notification map) - UI: select mode with checkboxes, floating action bar, confirmation modal listing each swap; already-notified items are dimmed and skipped - Detail view badges show sent/executed state, preventing duplicate notify Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -119,6 +119,8 @@ export function generateSuggestions(
|
||||
predictedAfterSwap,
|
||||
canQualifyAfterSwap,
|
||||
isSameRegion: inv.region === vehicle.region,
|
||||
notificationId: null,
|
||||
notificationStatus: null,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
@@ -188,6 +190,8 @@ export function generateSuggestions(
|
||||
predictedAfterSwap,
|
||||
canQualifyAfterSwap,
|
||||
isSameRegion: inv.region === vehicle.region,
|
||||
notificationId: null,
|
||||
notificationStatus: null,
|
||||
};
|
||||
})
|
||||
// Only keep candidates that can actually qualify at this customer —
|
||||
|
||||
34
src/server/routes/scheduling/db-schema.ts
Normal file
34
src/server/routes/scheduling/db-schema.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import pool from '../../db.js';
|
||||
|
||||
const CREATE_NOTIFICATIONS_TABLE = `
|
||||
CREATE TABLE IF NOT EXISTS tab_scheduling_notifications (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
suggestion_id VARCHAR(128) NOT NULL,
|
||||
current_plate VARCHAR(32) NOT NULL,
|
||||
candidate_plate VARCHAR(32) NOT NULL,
|
||||
operator_id VARCHAR(64),
|
||||
operator_name VARCHAR(128),
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'sent',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
executed_at DATETIME NULL,
|
||||
notes VARCHAR(500) NULL,
|
||||
before_mileage INT NULL,
|
||||
after_mileage INT NULL,
|
||||
INDEX idx_suggestion_id (suggestion_id),
|
||||
INDEX idx_current_plate (current_plate),
|
||||
INDEX idx_candidate_plate (candidate_plate),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='智能调度通知/执行记录'
|
||||
`;
|
||||
|
||||
export async function ensureSchedulingTables(): Promise<void> {
|
||||
try {
|
||||
await pool.query(CREATE_NOTIFICATIONS_TABLE);
|
||||
console.log('[scheduling] notifications table ready');
|
||||
} catch (e) {
|
||||
console.error('[scheduling] failed to ensure tables:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,98 @@
|
||||
import { Hono } from 'hono';
|
||||
import pool from '../../db.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import type { NotifyRequest } from './types.js';
|
||||
import type {
|
||||
NotifyRequest,
|
||||
NotifyBatchRequest,
|
||||
NotifyBatchResult,
|
||||
NotificationRecord,
|
||||
NotificationStatus,
|
||||
UpdateNotificationRequest,
|
||||
} from './types.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// In-memory set of processed suggestion IDs
|
||||
const processedSuggestions = new Set<string>();
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isProcessed(suggestionId: string): boolean {
|
||||
return processedSuggestions.has(suggestionId);
|
||||
function rowToRecord(row: any): NotificationRecord {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
suggestionId: row.suggestion_id,
|
||||
currentPlate: row.current_plate,
|
||||
candidatePlate: row.candidate_plate,
|
||||
operatorId: row.operator_id,
|
||||
operatorName: row.operator_name,
|
||||
status: row.status,
|
||||
createdAt: row.created_at ? new Date(row.created_at).toISOString() : '',
|
||||
updatedAt: row.updated_at ? new Date(row.updated_at).toISOString() : '',
|
||||
executedAt: row.executed_at ? new Date(row.executed_at).toISOString() : null,
|
||||
notes: row.notes,
|
||||
beforeMileage: row.before_mileage != null ? Number(row.before_mileage) : null,
|
||||
afterMileage: row.after_mileage != null ? Number(row.after_mileage) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch notification status map for the currently-visible (suggestion, candidate) pairs.
|
||||
* Key: `${suggestionId}::${candidatePlate}` → latest non-cancelled notification.
|
||||
*/
|
||||
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];
|
||||
|
||||
const map = new Map<string, { id: number; status: NotificationStatus }>();
|
||||
for (const row of rows) {
|
||||
const key = `${row.suggestion_id}::${row.candidate_plate}`;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { id: Number(row.id), status: row.status });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function insertNotification(
|
||||
req: NotifyRequest,
|
||||
operator: { id: string | null; name: string | null },
|
||||
): Promise<NotificationRecord | { skipped: true }> {
|
||||
// Check if a non-cancelled notification already exists for this pair
|
||||
const [existing] = (await pool.execute(
|
||||
`SELECT id FROM tab_scheduling_notifications
|
||||
WHERE suggestion_id = ? AND candidate_plate = ? AND status != 'cancelled'
|
||||
LIMIT 1`,
|
||||
[req.suggestionId, req.candidatePlate],
|
||||
)) as [any[], unknown];
|
||||
|
||||
if (existing.length > 0) return { skipped: true };
|
||||
|
||||
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 rowToRecord(rows[0]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// POST /api/scheduling/notify — single notify
|
||||
app.post('/', async (c) => {
|
||||
try {
|
||||
const body = await c.req.json<NotifyRequest>();
|
||||
@@ -20,22 +102,161 @@ app.post('/', async (c) => {
|
||||
return c.json({ success: false, message: '缺少必要参数' }, 400);
|
||||
}
|
||||
|
||||
if (processedSuggestions.has(suggestionId)) {
|
||||
const user = (c as any).get('user') as AuthUser | undefined;
|
||||
const operator = {
|
||||
id: user?.userId ?? null,
|
||||
name: user?.userName ?? null,
|
||||
};
|
||||
|
||||
const result = await insertNotification(body, operator);
|
||||
if ('skipped' in result) {
|
||||
return c.json({ success: false, message: '该建议已处理' }, 409);
|
||||
}
|
||||
|
||||
const user = (c as any).get('user') as AuthUser | undefined;
|
||||
const operator = user?.userName || '未知';
|
||||
console.log(
|
||||
`[scheduling:notify] operator=${operator.name} suggestion=${suggestionId} current=${currentPlate} candidate=${candidatePlate}`,
|
||||
);
|
||||
|
||||
console.log(`[scheduling:notify] operator=${operator} suggestion=${suggestionId} current=${currentPlate} candidate=${candidatePlate}`);
|
||||
|
||||
processedSuggestions.add(suggestionId);
|
||||
|
||||
return c.json({ success: true, message: `替换通知已发送:${currentPlate} → ${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 (!item.suggestionId || !item.currentPlate || !item.candidatePlate) {
|
||||
result.failed++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const r = await insertNotification(item, operator);
|
||||
if ('skipped' in r) result.skipped++;
|
||||
else {
|
||||
result.success++;
|
||||
result.records.push(r);
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
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.map(rowToRecord) });
|
||||
} catch (e: unknown) {
|
||||
console.error('scheduling notifications list error:', e);
|
||||
return c.json({ records: [] }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
const body = await c.req.json<UpdateNotificationRequest>();
|
||||
if (!body.status) {
|
||||
return c.json({ success: false, message: '缺少 status' }, 400);
|
||||
}
|
||||
|
||||
const validStatuses: NotificationStatus[] = ['sent', 'executed', 'cancelled'];
|
||||
if (!validStatuses.includes(body.status)) {
|
||||
return c.json({ success: false, message: 'status 不合法' }, 400);
|
||||
}
|
||||
|
||||
const fields: string[] = ['status = ?'];
|
||||
const params: (string | number | null)[] = [body.status];
|
||||
if (body.status === 'executed') {
|
||||
fields.push('executed_at = CURRENT_TIMESTAMP');
|
||||
}
|
||||
if (body.notes !== undefined) {
|
||||
fields.push('notes = ?');
|
||||
params.push(body.notes);
|
||||
}
|
||||
if (body.afterMileage !== undefined) {
|
||||
fields.push('after_mileage = ?');
|
||||
params.push(body.afterMileage);
|
||||
}
|
||||
params.push(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: rowToRecord(rows[0]) });
|
||||
} catch (e: unknown) {
|
||||
console.error('scheduling notification update error:', e);
|
||||
return c.json({ success: false, message: '更新失败' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { fetchVehicleInfoMap } from '../mileage/vehicle-info.js';
|
||||
import { mapRegion } from '../vehicles.js';
|
||||
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
||||
import { classifyVehicle, generateSuggestions } from './algorithm.js';
|
||||
import { fetchActiveNotificationMap } from './notify.js';
|
||||
import type { EnrichedVehicle, InventoryVehicle, SchedulingResponse, SchedulingSummary } from './types.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
|
||||
@@ -275,6 +276,19 @@ app.get('/', async (c) => {
|
||||
// ---- Run algorithm ----
|
||||
const { suggestions, summary } = generateSuggestions(enrichedVehicles, inventoryVehicles);
|
||||
|
||||
// ---- Attach notification status to candidates ----
|
||||
const notificationMap = await fetchActiveNotificationMap();
|
||||
for (const s of suggestions) {
|
||||
for (const c of s.candidates) {
|
||||
const key = `${s.id}::${c.plateNumber}`;
|
||||
const notif = notificationMap.get(key);
|
||||
if (notif) {
|
||||
c.notificationId = notif.id;
|
||||
c.notificationStatus = notif.status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Permission filtering & customer name masking ----
|
||||
const user = (c as any).get('user') as AuthUser | undefined;
|
||||
|
||||
|
||||
@@ -6,6 +6,11 @@ export type {
|
||||
SchedulingTargetOption,
|
||||
SchedulingResponse,
|
||||
NotifyRequest,
|
||||
NotifyBatchRequest,
|
||||
NotifyBatchResult,
|
||||
NotificationStatus,
|
||||
NotificationRecord,
|
||||
UpdateNotificationRequest,
|
||||
ReasonLine,
|
||||
ReasonBlock,
|
||||
} from '../../../shared/scheduling/types.js';
|
||||
|
||||
Reference in New Issue
Block a user