import type { RowDataPacket } from 'mysql2/promise'; import type { AccessProfile } from '../auth/rbac-repository.js'; import type { MySqlPool } from '../db/mysql.js'; import type { AsyncTask } from '../tasks/task-repository.js'; import { DeviceControlError, type DeviceControlService } from './device-control-service.js'; export const orderDeviceEvents = [ 'ORDER_PAID', 'ORDER_STARTED', 'ORDER_RENEWED', 'ORDER_CANCELLED', 'ORDER_ROOM_CHANGED', 'ORDER_FINISHED' ] as const; type OrderDeviceEvent = (typeof orderDeviceEvents)[number]; interface OrderRow extends RowDataPacket { id: string; tenantId: string; storeId: string; roomId: string; status: string; startAt: Date; endAt: Date; } export class OrderDeviceAutomationError extends Error { constructor(public readonly code: string) { super(code); } } export class OrderDeviceAutomationService { constructor( private readonly pool: MySqlPool, private readonly deviceControl: Pick ) {} async handleTask(task: AsyncTask) { if (task.taskType !== 'device.command') { throw new OrderDeviceAutomationError('DEVICE_TASK_TYPE_INVALID'); } const payload = parseTaskPayload(task.payload); if (payload.tenantId !== task.tenantId) { throw new OrderDeviceAutomationError('DEVICE_TASK_TENANT_MISMATCH'); } const order = await this.loadOrder(payload.tenantId, payload.orderId); if (['ORDER_PAID', 'ORDER_STARTED', 'ORDER_RENEWED'].includes(payload.event) && !['PAID', 'RESERVED', 'IN_PROGRESS'].includes(order.status)) { return { orderId: order.id, skipped: true, reason: 'ORDER_STATUS_TERMINAL' }; } if (payload.event === 'ORDER_PAID' || payload.event === 'ORDER_STARTED') { await this.startOrderDevices(order, payload.traceId); return { orderId: order.id, action: 'STARTED' }; } if (payload.event === 'ORDER_RENEWED') { await this.extendOrderDevices(order, payload.traceId, payload.addMinutes); return { orderId: order.id, action: 'EXTENDED' }; } if (payload.event === 'ORDER_ROOM_CHANGED') { if (payload.previousRoomId && payload.previousRoomId !== order.roomId) { await this.cancelRoomDevices(order, payload.traceId, payload.previousRoomId); } await this.startOrderDevices(order, payload.traceId); return { orderId: order.id, action: 'ROOM_CHANGED' }; } await this.cancelRoomDevices(order, payload.traceId, order.roomId); return { orderId: order.id, action: 'CANCELLED' }; } private async startOrderDevices(order: OrderRow, traceId: string) { const context = this.context(order, traceId, order.roomId); await this.deviceControl.startTask(context, { minute: remainingMinutes(order.endAt), type: 2 }); await this.switchSocketIfBound(context, true); } private async extendOrderDevices(order: OrderRow, traceId: string, addMinutes?: number) { const context = this.context(order, traceId, order.roomId); await this.deviceControl.extendTask(context, boundedMinutes(addMinutes ?? remainingMinutes(order.endAt))); await this.switchSocketIfBound(context, true); } private async cancelRoomDevices(order: OrderRow, traceId: string, roomId: string) { const context = this.context(order, traceId, roomId); await this.deviceControl.cancelTask(context); await this.switchSocketIfBound(context, false); } private async switchSocketIfBound(context: ReturnType, on: boolean) { try { await this.deviceControl.switchSmartSocket(context, { on, slotNum: 1, orderId: context.orderId }); } catch (error) { if (error instanceof DeviceControlError && error.code === 'SMART_SOCKET_NOT_BOUND') return; throw error; } } private context(order: OrderRow, traceId: string, roomId: string) { return { tenantId: order.tenantId, storeId: order.storeId, roomId, orderId: order.id, traceId, access: systemDeviceAccess() }; } private async loadOrder(tenantId: string, orderId: string) { const [rows] = await this.pool.execute( `SELECT id, tenant_id AS tenantId, store_id AS storeId, room_id AS roomId, status, start_at AS startAt, end_at AS endAt FROM qipai_orders WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`, [tenantId, orderId] ); if (!rows[0]) throw new OrderDeviceAutomationError('ORDER_NOT_FOUND'); return { ...rows[0], id: String(rows[0].id), tenantId: String(rows[0].tenantId), storeId: String(rows[0].storeId), roomId: String(rows[0].roomId) }; } } function parseTaskPayload(payload: unknown): { tenantId: string; orderId: string; event: OrderDeviceEvent; traceId: string; addMinutes?: number; previousRoomId?: string; } { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { throw new OrderDeviceAutomationError('DEVICE_TASK_PAYLOAD_INVALID'); } const record = payload as Record; const tenantId = readId(record.tenantId); const orderId = readId(record.orderId); const event = typeof record.event === 'string' && orderDeviceEvents.includes(record.event as OrderDeviceEvent) ? record.event as OrderDeviceEvent : null; const traceId = typeof record.traceId === 'string' && record.traceId.length > 0 ? record.traceId.slice(0, 128) : `device-order-${orderId ?? 'unknown'}`; if (!tenantId || !orderId || !event) { throw new OrderDeviceAutomationError('DEVICE_TASK_PAYLOAD_INVALID'); } const addMinutes = readPositiveInt(record.addMinutes); const previousRoomId = readId(record.previousRoomId); return { tenantId, orderId, event, traceId, addMinutes, previousRoomId: previousRoomId ?? undefined }; } function readId(value: unknown) { if (typeof value === 'string' && /^[1-9]\d{0,19}$/.test(value)) return value; if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return String(value); return null; } function readPositiveInt(value: unknown) { if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) return undefined; return value; } function remainingMinutes(endAt: Date) { return boundedMinutes(Math.ceil((endAt.getTime() - Date.now()) / 60000)); } function boundedMinutes(value: number) { return Math.max(1, Math.min(10080, value)); } function systemDeviceAccess(): AccessProfile { return { roles: ['PLATFORM_ADMIN'], capabilities: ['device.write'], storeIds: [] }; }