import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise'; import type { AccessProfile } from '../auth/rbac-repository.js'; import type { MySqlPool } from '../db/mysql.js'; import type { MarketingBenefitService } from '../wallets/marketing-benefit-service.js'; export const orderActions = [ 'SUBMIT', 'CONFIRM_PAYMENT', 'RESERVE', 'START', 'FINISH', 'CANCEL', 'BEGIN_REFUND', 'COMPLETE_REFUND', 'CLOSE' ] as const; export type OrderAction = typeof orderActions[number]; export type OrderStatus = | 'DRAFT' | 'PENDING_PAYMENT' | 'PAID' | 'RESERVED' | 'IN_PROGRESS' | 'FINISHED' | 'CANCELLED' | 'REFUNDING' | 'REFUNDED' | 'CLOSED'; export interface OrderActor { tenantId: string; userId: string; actorType: 'USER' | 'SYSTEM'; source: 'APP' | 'ADMIN' | 'PAYMENT' | 'WORKER' | 'SYSTEM'; traceId: string; ip: string; userAgent: string; access?: AccessProfile; } interface OrderRow extends RowDataPacket { id: string; storeId: string; status: OrderStatus; statusVersion: number; } interface HistoryRow extends RowDataPacket { id: string; fromStatus: OrderStatus | null; toStatus: OrderStatus; action: OrderAction | 'CREATED' | 'EXPIRED' | 'MIGRATED'; actorType: string; actorId: string | null; source: string; reason: string; traceId: string; createdAt: Date; } const targetByAction: Record = { SUBMIT: 'PENDING_PAYMENT', CONFIRM_PAYMENT: 'PAID', RESERVE: 'RESERVED', START: 'IN_PROGRESS', FINISH: 'FINISHED', CANCEL: 'CANCELLED', BEGIN_REFUND: 'REFUNDING', COMPLETE_REFUND: 'REFUNDED', CLOSE: 'CLOSED' }; const allowedActions: Record = { DRAFT: ['SUBMIT', 'CANCEL', 'CLOSE'], PENDING_PAYMENT: ['CONFIRM_PAYMENT', 'CANCEL', 'CLOSE'], PAID: ['RESERVE', 'START', 'CANCEL', 'BEGIN_REFUND'], RESERVED: ['START', 'CANCEL', 'BEGIN_REFUND'], IN_PROGRESS: ['FINISH', 'BEGIN_REFUND'], FINISHED: ['BEGIN_REFUND', 'CLOSE'], CANCELLED: ['BEGIN_REFUND', 'CLOSE'], REFUNDING: ['COMPLETE_REFUND'], REFUNDED: ['CLOSE'], CLOSED: [] }; export class OrderStateError extends Error { constructor(public readonly code: string) { super(code); } } export class OrderStateRepository { constructor( private readonly pool: MySqlPool, private readonly benefits?: Pick, private readonly cleaningTasks?: { createForFinishedOrder( connection: PoolConnection, input: { tenantId: string; orderId: string; actorId: string; traceId: string } ): Promise; } ) {} async transition(actor: OrderActor, orderId: string, action: OrderAction, reason = '') { return this.transaction(async (connection) => { const order = await this.loadOrder(connection, actor.tenantId, orderId, true); await this.assertAuthorized(connection, actor, order, action); const duplicate = await this.findByTrace(connection, actor.tenantId, orderId, actor.traceId); if (duplicate) { return { orderId, status: duplicate.toStatus, statusVersion: null, historyId: duplicate.id, idempotent: true }; } if (!allowedActions[order.status].includes(action)) { throw new OrderStateError('ORDER_TRANSITION_NOT_ALLOWED'); } const targetStatus = targetByAction[action]; const nextVersion = Number(order.statusVersion) + 1; await connection.execute( `UPDATE qipai_orders SET status = ?, status_version = ?, status_updated_at = UTC_TIMESTAMP(3) WHERE tenant_id = ? AND id = ?`, [targetStatus, nextVersion, actor.tenantId, orderId] ); await this.applyReservationState(connection, actor.tenantId, orderId, targetStatus); if (targetStatus === 'FINISHED' && this.cleaningTasks) { await this.cleaningTasks.createForFinishedOrder(connection, { tenantId: actor.tenantId, orderId, actorId: actor.userId, traceId: actor.traceId }); } const [result] = await connection.execute( `INSERT INTO qipai_order_status_history (tenant_id, order_id, from_status, to_status, action, actor_type, actor_id, source, reason, trace_id, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT('statusVersion', ?))`, [actor.tenantId, orderId, order.status, targetStatus, action, actor.actorType, actor.userId, actor.source, reason.slice(0, 512), actor.traceId, nextVersion] ); await connection.execute( `INSERT INTO qipai_audit_logs (tenant_id, actor_type, actor_id, action, resource_type, resource_id, trace_id, ip, user_agent, metadata) VALUES (?, ?, ?, 'ORDER_STATUS_CHANGED', 'ORDER', ?, ?, ?, ?, JSON_OBJECT('fromStatus', ?, 'toStatus', ?, 'orderAction', ?))`, [actor.tenantId, actor.actorType, actor.userId, orderId, actor.traceId, actor.ip, actor.userAgent.slice(0, 255), order.status, targetStatus, action] ); return { orderId, status: targetStatus, statusVersion: nextVersion, historyId: String(result.insertId), idempotent: false }; }); } async history(tenantId: string, userId: string, orderId: string, access: AccessProfile) { const order = await this.loadOrder(this.pool, tenantId, orderId, false); const manager = canManageStore(access, order.storeId); if (!manager) { const [rows] = await this.pool.execute( `SELECT 1 FROM qipai_order_user_access WHERE tenant_id = ? AND order_id = ? AND user_id = ?`, [tenantId, orderId, userId] ); if (!rows[0]) throw new OrderStateError('ORDER_ACCESS_FORBIDDEN'); } const [rows] = await this.pool.execute( `SELECT id, from_status AS fromStatus, to_status AS toStatus, action, actor_type AS actorType, actor_id AS actorId, source, reason, trace_id AS traceId, created_at AS createdAt FROM qipai_order_status_history WHERE tenant_id = ? AND order_id = ? ORDER BY id`, [tenantId, orderId] ); return rows.map((row) => ({ ...row, id: String(row.id), actorId: row.actorId === null ? null : String(row.actorId) })); } private async assertAuthorized( connection: PoolConnection, actor: OrderActor, order: OrderRow, action: OrderAction ) { if (actor.source !== 'APP') { if (!actor.access || !canManageStore(actor.access, order.storeId)) { throw new OrderStateError('ORDER_MANAGEMENT_FORBIDDEN'); } return; } if (action !== 'CANCEL') throw new OrderStateError('ORDER_ACTION_FORBIDDEN'); const [rows] = await connection.execute( `SELECT 1 FROM qipai_order_user_access WHERE tenant_id = ? AND order_id = ? AND user_id = ? AND revoked_at IS NULL`, [actor.tenantId, order.id, actor.userId] ); if (!rows[0]) throw new OrderStateError('ORDER_ACCESS_FORBIDDEN'); } private async loadOrder( connection: Pick | PoolConnection, tenantId: string, orderId: string, lock: boolean ) { const [rows] = await connection.execute( `SELECT id, store_id AS storeId, status, status_version AS statusVersion FROM qipai_orders WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL ${lock ? 'FOR UPDATE' : ''}`, [tenantId, orderId] ); if (!rows[0]) throw new OrderStateError('ORDER_NOT_FOUND'); return rows[0]; } private async findByTrace( connection: PoolConnection, tenantId: string, orderId: string, traceId: string ) { const [rows] = await connection.execute( `SELECT id, to_status AS toStatus FROM qipai_order_status_history WHERE tenant_id = ? AND order_id = ? AND trace_id = ? LIMIT 1`, [tenantId, orderId, traceId] ); return rows[0] ?? null; } private async applyReservationState( connection: PoolConnection, tenantId: string, orderId: string, status: OrderStatus ) { if (['PAID', 'RESERVED', 'IN_PROGRESS', 'FINISHED'].includes(status)) { await connection.execute( `UPDATE qipai_room_reservations SET status = 'CONSUMED', expires_at = GREATEST(expires_at, ends_at) WHERE tenant_id = ? AND order_id = ? AND status = 'HELD'`, [tenantId, orderId] ); } else if (['CANCELLED', 'REFUNDED', 'CLOSED'].includes(status)) { await connection.execute( `UPDATE qipai_room_reservations SET status = 'RELEASED', released_at = COALESCE(released_at, UTC_TIMESTAMP(3)) WHERE tenant_id = ? AND order_id = ? AND status IN ('HELD', 'CONSUMED')`, [tenantId, orderId] ); await connection.execute( `UPDATE qipai_order_user_access SET revoked_at = COALESCE(revoked_at, UTC_TIMESTAMP(3)) WHERE tenant_id = ? AND order_id = ?`, [tenantId, orderId] ); if (this.benefits) { const [users] = await connection.execute( `SELECT user_id AS userId FROM qipai_order_user_access WHERE tenant_id = ? AND order_id = ? ORDER BY id LIMIT 1`, [tenantId, orderId] ); if (users[0]?.userId) { await this.benefits.releaseReservedInTransaction(connection, { tenantId, userId: String(users[0].userId), orderId, traceId: `order-benefit-release-${orderId}` }); } } } } private async transaction(work: (connection: PoolConnection) => Promise) { const connection = await this.pool.getConnection(); try { await connection.beginTransaction(); const result = await work(connection); await connection.commit(); return result; } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } } } function canManageStore(access: AccessProfile, storeId: string) { return access.capabilities.includes('tenant.manage') || access.roles.includes('PLATFORM_ADMIN') || (access.capabilities.includes('store.operation.write') && access.storeIds.includes(storeId)); }