From 089d34877b94da0e1b021bd2ed6747dcfe6b4fda Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 20 Jun 2026 14:05:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(M04-C):=20=E5=AE=8C=E6=88=90=E7=BB=AD?= =?UTF-8?q?=E8=B4=B9=E6=8D=A2=E6=88=BF=E4=B8=8E=E8=AE=A2=E5=8D=95=E8=B0=83?= =?UTF-8?q?=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package.json | 2 +- backend/src/app.ts | 7 + backend/src/db/migration-runner.ts | 10 +- .../src/orders/order-management-repository.ts | 376 ++++++++++++++++++ backend/src/orders/pricing-repository.ts | 5 + backend/src/routes/order-management.ts | 126 ++++++ backend/src/routes/order-state.ts | 19 +- backend/src/routes/pricing.ts | 33 ++ backend/src/server.ts | 9 + backend/tests/migration-contract.test.mjs | 11 +- backend/tests/migration-runner.test.mjs | 3 +- .../tests/mysql-migration-roundtrip.test.mjs | 188 ++++++++- backend/tests/order-management.test.mjs | 83 ++++ ...2026062013_m04c_order_adjustments.down.sql | 8 + .../2026062013_m04c_order_adjustments.up.sql | 35 ++ ...26062013_m04c_order_adjustments.verify.sql | 19 + .../2026-06-20-M04-C-order-adjustments.md | 10 + .../2026-06-20-M04-C-order-adjustments.md | 9 + scripts/dev/windows/check-backend.ps1 | 3 + scripts/dev/wsl/mysql-migration-roundtrip.sh | 4 +- 20 files changed, 940 insertions(+), 20 deletions(-) create mode 100644 backend/src/orders/order-management-repository.ts create mode 100644 backend/src/routes/order-management.ts create mode 100644 backend/tests/order-management.test.mjs create mode 100644 database/migrations/2026062013_m04c_order_adjustments.down.sql create mode 100644 database/migrations/2026062013_m04c_order_adjustments.up.sql create mode 100644 database/migrations/2026062013_m04c_order_adjustments.verify.sql create mode 100644 docs/api-changelog/2026-06-20-M04-C-order-adjustments.md create mode 100644 docs/db-changelog/2026-06-20-M04-C-order-adjustments.md diff --git a/backend/package.json b/backend/package.json index d79bc55..b289c08 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,7 +17,7 @@ "db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify", "db:migrate:down": "npm run build && node dist/db/migrate-cli.js down", "test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs", - "test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs" + "test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs" }, "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/backend/src/app.ts b/backend/src/app.ts index c5a1cb8..6a13627 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -33,6 +33,9 @@ import { registerPricingRoutes, type PricingRouteOptions } from './routes/pricin import { registerOrderStateRoutes, type OrderStateRouteOptions } from './routes/order-state.js'; +import { + registerOrderManagementRoutes, type OrderManagementRouteOptions +} from './routes/order-management.js'; export interface BuildAppOptions { config?: AppConfig; @@ -45,6 +48,7 @@ export interface BuildAppOptions { storeAccess?: StoreAccessRouteOptions; pricing?: PricingRouteOptions; orderState?: OrderStateRouteOptions; + orderManagement?: OrderManagementRouteOptions; } declare module 'fastify' { @@ -114,6 +118,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise = { 'database/migrations/2026061809_m03c_store_discovery.up.sql', 'database/migrations/2026061810_m03d_scene_wifi_access.up.sql', 'database/migrations/2026061811_m04a_pricing_reservations.up.sql', - 'database/migrations/2026062012_m04b_order_state_machine.up.sql' + 'database/migrations/2026062012_m04b_order_state_machine.up.sql', + 'database/migrations/2026062013_m04c_order_adjustments.up.sql' ], verify: [ 'database/migrations/2026061601_m01b_core_schema.verify.sql', @@ -46,9 +47,11 @@ const migrationFiles: Record = { 'database/migrations/2026061809_m03c_store_discovery.verify.sql', 'database/migrations/2026061810_m03d_scene_wifi_access.verify.sql', 'database/migrations/2026061811_m04a_pricing_reservations.verify.sql', - 'database/migrations/2026062012_m04b_order_state_machine.verify.sql' + 'database/migrations/2026062012_m04b_order_state_machine.verify.sql', + 'database/migrations/2026062013_m04c_order_adjustments.verify.sql' ], down: [ + 'database/migrations/2026062013_m04c_order_adjustments.down.sql', 'database/migrations/2026062012_m04b_order_state_machine.down.sql', 'database/migrations/2026061811_m04a_pricing_reservations.down.sql', 'database/migrations/2026061810_m03d_scene_wifi_access.down.sql', @@ -188,7 +191,8 @@ export async function executeMigrationPlan( 2, 2, 1, 3, 3, 1, 2, 1, 3, 3, 1, - 2, 1, 3, 1 + 2, 1, 3, 1, + 2, 2, 1, 2, 1 ][index] ?? 1; if (!Array.isArray(result) || result.length < minimumRows) { throw new Error( diff --git a/backend/src/orders/order-management-repository.ts b/backend/src/orders/order-management-repository.ts new file mode 100644 index 0000000..5420e01 --- /dev/null +++ b/backend/src/orders/order-management-repository.ts @@ -0,0 +1,376 @@ +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 { OrderActor } from './order-state-repository.js'; + +type PricingPolicy = 'CURRENT' | 'LOCKED'; +type AdjustableStatus = 'PENDING_PAYMENT' | 'PAID' | 'RESERVED' | 'IN_PROGRESS'; + +interface OrderRow extends RowDataPacket { + id: string; + storeId: string; + roomId: string; + status: AdjustableStatus; + startAt: Date; + endAt: Date; + totalAmountCents: number; + adjustmentAmountCents: number; + cancellationCutoffMinutes: number; + cancellationFeeBps: number; +} +interface RoomRow extends RowDataPacket { + id: string; + storeId: string; + basePriceCents: number; + weekdayPriceCents: number; + holidayPriceCents: number; + timezone: string; + operationalStatus: string; + configurationStatus: string; +} +interface SnapshotRow extends RowDataPacket { unitPriceCents: number } +interface DuplicateRow extends RowDataPacket { + id: string; + adjustmentType: string; + amountDeltaCents: number; +} + +export class OrderManagementError extends Error { + constructor(public readonly code: string) { super(code); } +} + +export class OrderManagementRepository { + constructor(private readonly pool: MySqlPool) {} + + async renew(actor: OrderActor, orderId: string, input: { + endAt: Date; pricingPolicy: PricingPolicy; reason: string; + }) { + return this.transaction(async (connection) => { + const order = await this.loadOrder(connection, actor.tenantId, orderId); + this.assertManager(actor.access, order.storeId); + const duplicate = await this.duplicate(connection, actor, orderId); + if (duplicate) return this.duplicateResult(orderId, duplicate); + if (!['PAID', 'RESERVED', 'IN_PROGRESS'].includes(order.status)) { + throw new OrderManagementError('ORDER_RENEW_STATUS_INVALID'); + } + if (input.endAt <= order.endAt) throw new OrderManagementError('ORDER_RENEW_END_INVALID'); + await this.lockRooms(connection, actor.tenantId, [order.roomId]); + await this.assertAvailable( + connection, actor.tenantId, order.roomId, order.endAt, input.endAt, orderId + ); + const unitPrice = await this.resolveUnitPrice( + connection, actor.tenantId, order, input.pricingPolicy + ); + const amountDeltaCents = Math.ceil( + (input.endAt.getTime() - order.endAt.getTime()) / 3600000 + ) * unitPrice; + await connection.execute( + `UPDATE qipai_orders + SET end_at = ?, total_amount_cents = total_amount_cents + ?, + adjustment_amount_cents = adjustment_amount_cents + ? + WHERE tenant_id = ? AND id = ?`, + [input.endAt, amountDeltaCents, amountDeltaCents, actor.tenantId, orderId] + ); + await connection.execute( + `UPDATE qipai_room_reservations + SET ends_at = ?, expires_at = GREATEST(expires_at, ?) + WHERE tenant_id = ? AND order_id = ?`, + [input.endAt, input.endAt, actor.tenantId, orderId] + ); + return this.record(connection, actor, order, 'RENEW', input.reason, { + endAt: order.endAt + }, { endAt: input.endAt, pricingPolicy: input.pricingPolicy }, amountDeltaCents); + }); + } + + async changeRoom(actor: OrderActor, orderId: string, input: { + roomId: string; reason: string; + }) { + return this.transaction(async (connection) => { + const order = await this.loadOrder(connection, actor.tenantId, orderId); + this.assertManager(actor.access, order.storeId); + const duplicate = await this.duplicate(connection, actor, orderId); + if (duplicate) return this.duplicateResult(orderId, duplicate); + if (!['PENDING_PAYMENT', 'PAID', 'RESERVED', 'IN_PROGRESS'].includes(order.status)) { + throw new OrderManagementError('ORDER_ROOM_CHANGE_STATUS_INVALID'); + } + if (input.roomId === order.roomId) throw new OrderManagementError('ORDER_ROOM_UNCHANGED'); + await this.lockRooms(connection, actor.tenantId, [order.roomId, input.roomId]); + const target = await this.loadRoom(connection, actor.tenantId, input.roomId); + this.assertManager(actor.access, target.storeId); + await this.assertAvailable( + connection, actor.tenantId, target.id, order.startAt, order.endAt, orderId + ); + const oldRoom = await this.loadRoom(connection, actor.tenantId, order.roomId); + const hours = Math.ceil((order.endAt.getTime() - order.startAt.getTime()) / 3600000); + const amountDeltaCents = hours * (target.basePriceCents - oldRoom.basePriceCents); + await connection.execute( + `UPDATE qipai_orders + SET store_id = ?, room_id = ?, + total_amount_cents = GREATEST(0, total_amount_cents + ?), + adjustment_amount_cents = adjustment_amount_cents + ? + WHERE tenant_id = ? AND id = ?`, + [target.storeId, target.id, amountDeltaCents, amountDeltaCents, + actor.tenantId, orderId] + ); + await connection.execute( + `UPDATE qipai_room_reservations SET room_id = ? + WHERE tenant_id = ? AND order_id = ?`, + [target.id, actor.tenantId, orderId] + ); + return this.record(connection, actor, order, 'CHANGE_ROOM', input.reason, { + storeId: order.storeId, roomId: order.roomId + }, { storeId: target.storeId, roomId: target.id }, amountDeltaCents); + }); + } + + async adjustTime(actor: OrderActor, orderId: string, input: { + startAt?: Date; endAt?: Date; reason: string; + }) { + return this.transaction(async (connection) => { + const order = await this.loadOrder(connection, actor.tenantId, orderId); + this.assertManager(actor.access, order.storeId); + const duplicate = await this.duplicate(connection, actor, orderId); + if (duplicate) return this.duplicateResult(orderId, duplicate); + const startAt = input.startAt ?? order.startAt; + const endAt = input.endAt ?? order.endAt; + if (endAt <= startAt) throw new OrderManagementError('ORDER_TIME_WINDOW_INVALID'); + await this.lockRooms(connection, actor.tenantId, [order.roomId]); + await this.assertAvailable( + connection, actor.tenantId, order.roomId, startAt, endAt, orderId + ); + const oldHours = Math.ceil((order.endAt.getTime() - order.startAt.getTime()) / 3600000); + const newHours = Math.ceil((endAt.getTime() - startAt.getTime()) / 3600000); + const unitPrice = await this.resolveUnitPrice(connection, actor.tenantId, order, 'LOCKED'); + const amountDeltaCents = (newHours - oldHours) * unitPrice; + await connection.execute( + `UPDATE qipai_orders SET start_at = ?, end_at = ?, + total_amount_cents = GREATEST(0, total_amount_cents + ?), + adjustment_amount_cents = adjustment_amount_cents + ? + WHERE tenant_id = ? AND id = ?`, + [startAt, endAt, amountDeltaCents, amountDeltaCents, actor.tenantId, orderId] + ); + await connection.execute( + `UPDATE qipai_room_reservations SET starts_at = ?, ends_at = ?, + expires_at = GREATEST(expires_at, ?) + WHERE tenant_id = ? AND order_id = ?`, + [startAt, endAt, endAt, actor.tenantId, orderId] + ); + return this.record(connection, actor, order, 'ADJUST_TIME', input.reason, { + startAt: order.startAt, endAt: order.endAt + }, { startAt, endAt }, amountDeltaCents); + }); + } + + async note(actor: OrderActor, orderId: string, note: string) { + return this.transaction(async (connection) => { + const order = await this.loadOrder(connection, actor.tenantId, orderId); + this.assertManager(actor.access, order.storeId); + const duplicate = await this.duplicate(connection, actor, orderId); + if (duplicate) return this.duplicateResult(orderId, duplicate); + await connection.execute( + `UPDATE qipai_orders SET operator_note = ? WHERE tenant_id = ? AND id = ?`, + [note.slice(0, 512), actor.tenantId, orderId] + ); + return this.record(connection, actor, order, 'NOTE', note, {}, { note }, 0); + }); + } + + async cancellationQuote(tenantId: string, userId: string, orderId: string) { + return this.transaction(async (connection) => { + const order = await this.loadOrder(connection, tenantId, orderId); + await this.assertOwner(connection, tenantId, orderId, userId); + const minutesBeforeStart = Math.floor((order.startAt.getTime() - Date.now()) / 60000); + const feeCents = minutesBeforeStart >= order.cancellationCutoffMinutes + ? 0 : Math.ceil(order.totalAmountCents * order.cancellationFeeBps / 10000); + return { + orderId, + allowed: order.status !== 'IN_PROGRESS', + cutoffMinutes: order.cancellationCutoffMinutes, + feeCents, + refundableCents: Math.max(0, order.totalAmountCents - feeCents) + }; + }); + } + + private async record( + connection: PoolConnection, actor: OrderActor, order: OrderRow, + type: string, reason: string, before: object, after: object, amountDeltaCents: number + ) { + const [result] = await connection.execute( + `INSERT INTO qipai_order_adjustments + (tenant_id, order_id, adjustment_type, actor_id, source, trace_id, + reason, before_values, after_values, amount_delta_cents) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [actor.tenantId, order.id, type, actor.userId, actor.source, actor.traceId, + reason.slice(0, 512), JSON.stringify(before), JSON.stringify(after), amountDeltaCents] + ); + 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 (?, 'USER', ?, 'ORDER_MANUALLY_ADJUSTED', 'ORDER', ?, ?, ?, ?, + JSON_OBJECT('adjustmentType', ?, 'amountDeltaCents', ?))`, + [actor.tenantId, actor.userId, order.id, actor.traceId, actor.ip, + actor.userAgent.slice(0, 255), type, amountDeltaCents] + ); + return { + orderId: order.id, adjustmentId: String(result.insertId), + adjustmentType: type, amountDeltaCents, idempotent: false + }; + } + + private async loadOrder(connection: PoolConnection, tenantId: string, orderId: string) { + const [rows] = await connection.execute( + `SELECT o.id, o.store_id AS storeId, o.room_id AS roomId, o.status, + o.start_at AS startAt, o.end_at AS endAt, + o.total_amount_cents AS totalAmountCents, + o.adjustment_amount_cents AS adjustmentAmountCents, + s.cancellation_cutoff_minutes AS cancellationCutoffMinutes, + s.cancellation_fee_bps AS cancellationFeeBps + FROM qipai_orders o + INNER JOIN qipai_stores s ON s.tenant_id = o.tenant_id AND s.id = o.store_id + WHERE o.tenant_id = ? AND o.id = ? AND o.deleted_at IS NULL FOR UPDATE`, + [tenantId, orderId] + ); + if (!rows[0]) throw new OrderManagementError('ORDER_NOT_FOUND'); + return rows[0]; + } + + private async loadRoom(connection: PoolConnection, tenantId: string, roomId: string) { + const [rows] = await connection.execute( + `SELECT r.id, r.store_id AS storeId, r.base_price_cents AS basePriceCents, + r.weekday_price_cents AS weekdayPriceCents, + r.holiday_price_cents AS holidayPriceCents, + r.operational_status AS operationalStatus, + r.configuration_status AS configurationStatus, s.timezone + FROM qipai_rooms r + INNER JOIN qipai_stores s + ON s.tenant_id = r.tenant_id AND s.id = r.store_id AND s.deleted_at IS NULL + WHERE r.tenant_id = ? AND r.id = ? AND r.deleted_at IS NULL`, + [tenantId, roomId] + ); + const room = rows[0]; + if (!room) throw new OrderManagementError('ROOM_NOT_FOUND'); + if (room.operationalStatus !== 'AVAILABLE' || room.configurationStatus !== 'ENABLED') { + throw new OrderManagementError('ROOM_NOT_AVAILABLE'); + } + return room; + } + + private async lockRooms(connection: PoolConnection, tenantId: string, roomIds: string[]) { + const sorted = [...new Set(roomIds)].sort((a, b) => Number(a) - Number(b)); + for (const roomId of sorted) { + await connection.execute( + `SELECT id FROM qipai_rooms WHERE tenant_id = ? AND id = ? FOR UPDATE`, + [tenantId, roomId] + ); + } + } + + private async assertAvailable( + connection: PoolConnection, tenantId: string, roomId: string, + startAt: Date, endAt: Date, excludingOrderId: string + ) { + const [disabled] = await connection.execute( + `SELECT id FROM qipai_room_disabled_periods + WHERE tenant_id = ? AND room_id = ? AND starts_at < ? AND ends_at > ? FOR UPDATE`, + [tenantId, roomId, endAt, startAt] + ); + if (disabled[0]) throw new OrderManagementError('ROOM_DISABLED_PERIOD'); + const [rows] = await connection.execute( + `SELECT id FROM qipai_room_reservations + WHERE tenant_id = ? AND room_id = ? AND order_id <> ? + AND status IN ('HELD', 'CONSUMED') + AND (status = 'CONSUMED' OR expires_at > UTC_TIMESTAMP(3)) + AND starts_at < ? AND ends_at > ? FOR UPDATE`, + [tenantId, roomId, excludingOrderId, endAt, startAt] + ); + if (rows[0]) throw new OrderManagementError('TIME_SLOT_CONFLICT'); + } + + private async resolveUnitPrice( + connection: PoolConnection, tenantId: string, order: OrderRow, policy: PricingPolicy + ) { + if (policy === 'LOCKED') { + const [rows] = await connection.execute( + `SELECT unit_price_cents AS unitPriceCents + FROM qipai_order_price_snapshots WHERE tenant_id = ? AND order_id = ?`, + [tenantId, order.id] + ); + if (!rows[0]) throw new OrderManagementError('ORDER_PRICE_SNAPSHOT_MISSING'); + return Number(rows[0].unitPriceCents); + } + const room = await this.loadRoom(connection, tenantId, order.roomId); + const localDate = new Intl.DateTimeFormat('en-CA', { + timeZone: room.timezone, year: 'numeric', month: '2-digit', day: '2-digit' + }).format(order.endAt); + const [holidayRows] = await connection.execute( + `SELECT 1 FROM qipai_holiday_calendar + WHERE tenant_id = ? AND holiday_date = ? LIMIT 1`, + [tenantId, localDate] + ); + if (holidayRows[0] && room.holidayPriceCents > 0) return Number(room.holidayPriceCents); + const weekdayName = new Intl.DateTimeFormat('en-US', { + timeZone: room.timezone, weekday: 'short' + }).format(order.endAt); + if (['Mon', 'Tue', 'Wed', 'Thu', 'Fri'].includes(weekdayName) + && room.weekdayPriceCents > 0) { + return Number(room.weekdayPriceCents); + } + return Number(room.basePriceCents); + } + + private async duplicate(connection: PoolConnection, actor: OrderActor, orderId: string) { + const [rows] = await connection.execute( + `SELECT id, adjustment_type AS adjustmentType, + amount_delta_cents AS amountDeltaCents + FROM qipai_order_adjustments + WHERE tenant_id = ? AND order_id = ? AND trace_id = ? LIMIT 1`, + [actor.tenantId, orderId, actor.traceId] + ); + return rows[0] ?? null; + } + + private duplicateResult(orderId: string, duplicate: DuplicateRow) { + return { + orderId, adjustmentId: String(duplicate.id), + adjustmentType: duplicate.adjustmentType, + amountDeltaCents: Number(duplicate.amountDeltaCents), idempotent: true + }; + } + + private assertManager(access: AccessProfile | undefined, storeId: string) { + if (!access || !(access.capabilities.includes('tenant.manage') + || access.roles.includes('PLATFORM_ADMIN') + || (access.capabilities.includes('store.operation.write') && access.storeIds.includes(storeId)))) { + throw new OrderManagementError('ORDER_MANAGEMENT_FORBIDDEN'); + } + } + + private async assertOwner( + connection: PoolConnection, tenantId: string, orderId: string, userId: string + ) { + const [rows] = await connection.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 OrderManagementError('ORDER_ACCESS_FORBIDDEN'); + } + + 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(); + } + } +} diff --git a/backend/src/orders/pricing-repository.ts b/backend/src/orders/pricing-repository.ts index 53db4d5..e25bc1c 100644 --- a/backend/src/orders/pricing-repository.ts +++ b/backend/src/orders/pricing-repository.ts @@ -54,9 +54,13 @@ export class PricingRepository { async reserve(input: QuoteInput & { userId: string; holdMinutes?: number; + allowedStoreIds?: string[] | null; }) { return this.transaction(async (connection) => { const room = await this.loadRoom(connection, input.tenantId, input.roomId, true); + if (input.allowedStoreIds && !input.allowedStoreIds.includes(String(room.storeId))) { + throw new PricingError('STORE_SCOPE_FORBIDDEN'); + } await this.releaseExpired(input.tenantId, input.roomId, connection); await this.assertAvailable(connection, input, true); const isHoliday = await this.isHoliday( @@ -108,6 +112,7 @@ export class PricingRepository { return { orderId, orderNo, + storeId: String(room.storeId), reservationId: String(reservationResult.insertId), holdMinutes, quote diff --git a/backend/src/routes/order-management.ts b/backend/src/routes/order-management.ts new file mode 100644 index 0000000..13b419b --- /dev/null +++ b/backend/src/routes/order-management.ts @@ -0,0 +1,126 @@ +import type { FastifyInstance, FastifyReply } from 'fastify'; +import { z } from 'zod'; +import type { AuthRepository } from '../auth/auth-repository.js'; +import { authenticateAccessToken } from '../auth/authenticate.js'; +import type { AccessProfile } from '../auth/rbac-repository.js'; +import { + OrderManagementError, type OrderManagementRepository +} from '../orders/order-management-repository.js'; +import type { OrderActor } from '../orders/order-state-repository.js'; + +const paramsSchema = z.object({ orderId: z.string().regex(/^[1-9]\d{0,19}$/) }); +const renewSchema = z.object({ + endAt: z.coerce.date(), + pricingPolicy: z.enum(['CURRENT', 'LOCKED']).default('CURRENT'), + reason: z.string().min(1).max(512) +}).strict(); +const roomSchema = z.object({ + roomId: z.string().regex(/^[1-9]\d{0,19}$/), + reason: z.string().min(1).max(512) +}).strict(); +const timeSchema = z.object({ + startAt: z.coerce.date().optional(), + endAt: z.coerce.date().optional(), + reason: z.string().min(1).max(512) +}).strict().refine((value) => value.startAt || value.endAt); +const noteSchema = z.object({ note: z.string().min(1).max(512) }).strict(); + +export interface OrderManagementRouteOptions { + repository: Pick; + authRepository: Pick; + accessControl: { getAccessProfile(tenantId: string, userId: string): Promise }; + jwtSecret: string; +} + +export async function registerOrderManagementRoutes( + app: FastifyInstance, options: OrderManagementRouteOptions +) { + app.get('/app-api/orders/:orderId/cancellation-quote', async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options); + const params = paramsSchema.safeParse(request.params); + if (!auth) return unauthorized(reply, request.traceId); + if (!params.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.repository.cancellationQuote( + auth.tenantId, auth.userId, params.data.orderId + ), + traceId: request.traceId + })); + }); + + const adminActions = [ + ['renew', renewSchema, (actor: OrderActor, orderId: string, body: z.infer) => + options.repository.renew(actor, orderId, body)], + ['change-room', roomSchema, (actor: OrderActor, orderId: string, body: z.infer) => + options.repository.changeRoom(actor, orderId, body)], + ['adjust-time', timeSchema, (actor: OrderActor, orderId: string, body: z.infer) => + options.repository.adjustTime(actor, orderId, body)], + ['note', noteSchema, (actor: OrderActor, orderId: string, body: z.infer) => + options.repository.note(actor, orderId, body.note)] + ] as const; + + for (const [path, schema, execute] of adminActions) { + app.post(`/admin-api/orders/:orderId/${path}`, async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options); + const params = paramsSchema.safeParse(request.params); + const body = schema.safeParse(request.body); + if (!auth) return unauthorized(reply, request.traceId); + if (!params.success || !body.success) return invalid(reply, request.traceId); + const actor = { + tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER' as const, + source: 'ADMIN' as const, traceId: request.traceId, ip: request.ip, + userAgent: request.headers['user-agent'] ?? '', access: auth.access + }; + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await execute(actor, params.data.orderId, body.data as never), + traceId: request.traceId + })); + }); + } +} + +async function authenticate( + authorization: string | undefined, options: OrderManagementRouteOptions +) { + const result = await authenticateAccessToken( + authorization, options.authRepository, options.jwtSecret + ); + if (!result) return null; + const tenantId = result.session.tenantId; + const userId = result.session.user.id; + return { + tenantId, userId, + access: await options.accessControl.getAccessProfile(tenantId, userId) + }; +} + +async function handle(reply: FastifyReply, traceId: string, work: () => Promise) { + try { + return await work(); + } catch (error) { + if (!(error instanceof OrderManagementError)) throw error; + const status = error.code === 'ORDER_NOT_FOUND' || error.code === 'ROOM_NOT_FOUND' ? 404 + : error.code === 'TIME_SLOT_CONFLICT' ? 409 + : error.code.includes('FORBIDDEN') ? 403 : 400; + return reply.status(status).send({ + code: error.code, + message: 'The requested order adjustment is not available.', + traceId + }); + } +} + +function unauthorized(reply: FastifyReply, traceId: string) { + return reply.status(401).send({ + code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId + }); +} + +function invalid(reply: FastifyReply, traceId: string) { + return reply.status(400).send({ + code: 'INVALID_ORDER_ADJUSTMENT', message: 'The order adjustment is invalid.', traceId + }); +} diff --git a/backend/src/routes/order-state.ts b/backend/src/routes/order-state.ts index 4b20dbe..d30096a 100644 --- a/backend/src/routes/order-state.ts +++ b/backend/src/routes/order-state.ts @@ -19,6 +19,9 @@ export interface OrderStateRouteOptions { authRepository: Pick; accessControl: { getAccessProfile(tenantId: string, userId: string): Promise }; jwtSecret: string; + cancellationPolicy?: { + cancellationQuote(tenantId: string, userId: string, orderId: string): Promise; + }; } export async function registerOrderStateRoutes( @@ -44,15 +47,19 @@ export async function registerOrderStateRoutes( const body = cancelSchema.safeParse(request.body ?? {}); if (!auth) return unauthorized(reply, request.traceId); if (!params.success || !body.success) return invalid(reply, request.traceId); - return handle(reply, request.traceId, async () => ({ - code: 0, - data: await options.repository.transition({ + return handle(reply, request.traceId, async () => { + const cancellation = options.cancellationPolicy + ? await options.cancellationPolicy.cancellationQuote( + auth.tenantId, auth.userId, params.data.orderId + ) + : null; + const transition = await options.repository.transition({ tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER', source: 'APP', traceId: request.traceId, ip: request.ip, userAgent: request.headers['user-agent'] ?? '', access: auth.access - }, params.data.orderId, 'CANCEL', body.data.reason), - traceId: request.traceId - })); + }, params.data.orderId, 'CANCEL', body.data.reason); + return { code: 0, data: { transition, cancellation }, traceId: request.traceId }; + }); }); app.post('/admin-api/orders/:orderId/actions', async (request, reply) => { diff --git a/backend/src/routes/pricing.ts b/backend/src/routes/pricing.ts index 3e0b3fb..878a446 100644 --- a/backend/src/routes/pricing.ts +++ b/backend/src/routes/pricing.ts @@ -11,6 +11,9 @@ const requestSchema = z.object({ endAt: z.coerce.date(), pricingMode: z.enum(['HOURLY', 'OVERNIGHT', 'FULL_DAY']).default('HOURLY') }).refine((value) => value.endAt > value.startAt); +const adminReserveSchema = requestSchema.and(z.object({ + userId: z.string().regex(/^[1-9]\d{0,19}$/) +})); export interface PricingRouteOptions { repository: Pick; @@ -69,6 +72,36 @@ export async function registerPricingRoutes(app: FastifyInstance, options: Prici traceId: request.traceId }; }); + + app.post('/admin-api/orders/reserve-on-behalf', async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options); + const body = adminReserveSchema.safeParse(request.body); + if (!auth) return unauthorized(reply, request.traceId); + if (!body.success) return invalid(reply, request.traceId); + const access = await options.accessControl.getAccessProfile(auth.tenantId, auth.userId); + const unrestricted = access.capabilities.includes('tenant.manage') + || access.roles.includes('PLATFORM_ADMIN'); + if (!unrestricted && !access.capabilities.includes('store.operation.write')) { + return reply.status(403).send({ + code: 'ORDER_MANAGEMENT_FORBIDDEN', + message: 'Order management permission is required.', + traceId: request.traceId + }); + } + return handle(reply, request.traceId, async () => reply.status(201).send({ + code: 0, + data: await options.repository.reserve({ + tenantId: auth.tenantId, + userId: body.data.userId, + roomId: body.data.roomId, + startAt: body.data.startAt, + endAt: body.data.endAt, + pricingMode: body.data.pricingMode, + allowedStoreIds: unrestricted ? null : access.storeIds + }), + traceId: request.traceId + })); + }); } async function authenticate( diff --git a/backend/src/server.ts b/backend/src/server.ts index 6939b22..0bd7575 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -14,11 +14,13 @@ import { StoreDiscoveryRepository } from './stores/store-discovery-repository.js import { StoreAccessRepository } from './stores/access-repository.js'; import { PricingRepository } from './orders/pricing-repository.js'; import { OrderStateRepository } from './orders/order-state-repository.js'; +import { OrderManagementRepository } from './orders/order-management-repository.js'; const config = loadConfig(); const pool = createMySqlPool(config); const authRepository = new AuthRepository(pool); const accessControl = new RbacRepository(pool); +const orderManagementRepository = new OrderManagementRepository(pool); const app = await buildApp({ config, platformConfigRepository: new PlatformConfigRepository(pool), @@ -69,6 +71,13 @@ const app = await buildApp({ repository: new OrderStateRepository(pool), authRepository, accessControl, + jwtSecret: config.auth.jwtSecret, + cancellationPolicy: orderManagementRepository + }, + orderManagement: { + repository: orderManagementRepository, + authRepository, + accessControl, jwtSecret: config.auth.jwtSecret } }); diff --git a/backend/tests/migration-contract.test.mjs b/backend/tests/migration-contract.test.mjs index b75c5c5..791b9f8 100644 --- a/backend/tests/migration-contract.test.mjs +++ b/backend/tests/migration-contract.test.mjs @@ -45,6 +45,9 @@ const pricingVerifySql = read('database/migrations/2026061811_m04a_pricing_reser const orderStateUpSql = read('database/migrations/2026062012_m04b_order_state_machine.up.sql'); const orderStateDownSql = read('database/migrations/2026062012_m04b_order_state_machine.down.sql'); const orderStateVerifySql = read('database/migrations/2026062012_m04b_order_state_machine.verify.sql'); +const adjustmentUpSql = read('database/migrations/2026062013_m04c_order_adjustments.up.sql'); +const adjustmentDownSql = read('database/migrations/2026062013_m04c_order_adjustments.down.sql'); +const adjustmentVerifySql = read('database/migrations/2026062013_m04c_order_adjustments.verify.sql'); const coreTables = [ 'qipai_schema_migrations', @@ -207,5 +210,11 @@ assert.match(orderStateUpSql, /UNIQUE KEY uq_qipai_order_history_trace/); assert.match(orderStateUpSql, /actor_type VARCHAR/); assert.match(orderStateUpSql, /source VARCHAR/); assert.match(orderStateUpSql, /trace_id VARCHAR/); +assert.match(adjustmentUpSql, /CREATE TABLE IF NOT EXISTS qipai_order_adjustments/); +assert.match(adjustmentDownSql, /DROP TABLE IF EXISTS qipai_order_adjustments/); +assert.match(adjustmentVerifySql, /'qipai_order_adjustments'/); +assert.match(adjustmentUpSql, /cancellation_cutoff_minutes/); +assert.match(adjustmentUpSql, /amount_delta_cents INT/); +assert.match(adjustmentUpSql, /UNIQUE KEY uq_qipai_order_adjustment_trace/); -console.log('PASS: M01-B through M04-B migration contracts are present.'); +console.log('PASS: M01-B through M04-C migration contracts are present.'); diff --git a/backend/tests/migration-runner.test.mjs b/backend/tests/migration-runner.test.mjs index c0c6665..e8a23fd 100644 --- a/backend/tests/migration-runner.test.mjs +++ b/backend/tests/migration-runner.test.mjs @@ -23,7 +23,8 @@ assert.match(plan.file, /2026061808_m03b_decoration_ads_media\.up\.sql/); assert.match(plan.file, /2026061809_m03c_store_discovery\.up\.sql/); assert.match(plan.file, /2026061810_m03d_scene_wifi_access\.up\.sql/); assert.match(plan.file, /2026061811_m04a_pricing_reservations\.up\.sql/); -assert.match(plan.file, /2026062012_m04b_order_state_machine\.up\.sql$/); +assert.match(plan.file, /2026062012_m04b_order_state_machine\.up\.sql/); +assert.match(plan.file, /2026062013_m04c_order_adjustments\.up\.sql$/); assert.match(plan.checksum, /^[a-f0-9]{64}$/); assert.ok(plan.statements.length >= 11); diff --git a/backend/tests/mysql-migration-roundtrip.test.mjs b/backend/tests/mysql-migration-roundtrip.test.mjs index 5f38aa3..342478a 100644 --- a/backend/tests/mysql-migration-roundtrip.test.mjs +++ b/backend/tests/mysql-migration-roundtrip.test.mjs @@ -21,6 +21,9 @@ import { PricingRepository, PricingError } from '../dist/orders/pricing-reposito import { OrderStateError, OrderStateRepository } from '../dist/orders/order-state-repository.js'; +import { + OrderManagementError, OrderManagementRepository +} from '../dist/orders/order-management-repository.js'; import { executeMigrationPlan, loadMigrationPlan, @@ -37,6 +40,7 @@ const expectedTables = [ 'qipai_legacy_table_mappings', 'qipai_media_assets', 'qipai_members', + 'qipai_order_adjustments', 'qipai_order_price_snapshots', 'qipai_order_status_history', 'qipai_order_user_access', @@ -85,11 +89,11 @@ async function readMigrationVersions(pool) { const [rows] = await pool.query( `SELECT version, name FROM qipai_schema_migrations - WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ORDER BY version`, ['2026061601', '2026061802', '2026061803', '2026061804', '2026061805', '2026061806', '2026061807', '2026061808', '2026061809', - '2026061810', '2026061811', '2026062012'] + '2026061810', '2026061811', '2026062012', '2026062013'] ); return rows; } @@ -723,6 +727,169 @@ async function assertOrderStateMachine(pool, context) { ]); } +async function assertOrderAdjustments(pool, context) { + const [adminRows] = await pool.query( + `SELECT u.id FROM qipai_users u + INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id + INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id + WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN' LIMIT 1`, + [context.tenantId] + ); + const [customerRows] = await pool.query( + `SELECT u.id FROM qipai_users u + INNER JOIN qipai_user_identities i + ON i.tenant_id = u.tenant_id AND i.user_id = u.id + WHERE u.tenant_id = ? AND i.openid = 'm02b-openid-a' LIMIT 1`, + [context.tenantId] + ); + const [targetRows] = await pool.query( + `SELECT s.id AS storeId, r.id AS roomId + FROM qipai_stores s + INNER JOIN qipai_rooms r ON r.tenant_id = s.tenant_id AND r.store_id = s.id + WHERE s.tenant_id = ? AND s.name = 'M03A Store' LIMIT 1`, + [context.tenantId] + ); + const adminId = String(adminRows[0].id); + const customerId = String(customerRows[0].id); + const storeId = String(targetRows[0].storeId); + const roomId = String(targetRows[0].roomId); + const [newRoom] = await pool.query( + `INSERT INTO qipai_rooms + (tenant_id, store_id, name, room_no, base_price_cents, + configuration_status, operational_status, minimum_minutes, max_advance_days) + VALUES (?, ?, 'M04C Target Room', 'C02', 1800, 'ENABLED', 'AVAILABLE', 60, 30)`, + [context.tenantId, storeId] + ); + const targetRoomId = String(newRoom.insertId); + await pool.query( + `UPDATE qipai_stores + SET cancellation_cutoff_minutes = 20160, cancellation_fee_bps = 2500 + WHERE tenant_id = ? AND id = ?`, + [context.tenantId, storeId] + ); + const startAt = new Date(Date.now() + 12 * 86400000); + startAt.setUTCHours(2, 0, 0, 0); + const endAt = new Date(startAt.getTime() + 2 * 3600000); + const pricing = new PricingRepository(pool); + const created = await pricing.reserve({ + tenantId: context.tenantId, userId: customerId, roomId, + startAt, endAt, pricingMode: 'HOURLY' + }); + const access = await new RbacRepository(pool).getAccessProfile(context.tenantId, adminId); + const state = new OrderStateRepository(pool); + await state.transition({ + tenantId: context.tenantId, userId: adminId, actorType: 'USER', + source: 'ADMIN', traceId: 'm04c-order-paid', ip: '127.0.0.1', + userAgent: 'M04-C live test', access + }, created.orderId, 'CONFIRM_PAYMENT'); + const repository = new OrderManagementRepository(pool); + const actor = (traceId) => ({ + tenantId: context.tenantId, userId: adminId, actorType: 'USER', + source: 'ADMIN', traceId, ip: '127.0.0.1', + userAgent: 'M04-C live test', access + }); + + const blockedEnd = new Date(endAt.getTime() + 2 * 3600000); + const [blockingOrder] = await pool.query( + `INSERT INTO qipai_orders + (tenant_id, store_id, room_id, order_no, status, start_at, end_at) + VALUES (?, ?, ?, 'M04C-RENEW-BLOCK', 'PAID', ?, ?)`, + [context.tenantId, storeId, roomId, endAt, blockedEnd] + ); + await pool.query( + `INSERT INTO qipai_room_reservations + (tenant_id, order_id, room_id, starts_at, ends_at, status, expires_at) + VALUES (?, ?, ?, ?, ?, 'CONSUMED', ?)`, + [context.tenantId, blockingOrder.insertId, roomId, endAt, blockedEnd, blockedEnd] + ); + await assert.rejects( + () => repository.renew(actor('m04c-renew-conflict'), created.orderId, { + endAt: blockedEnd, pricingPolicy: 'LOCKED', reason: 'conflict rehearsal' + }), + (error) => error instanceof OrderManagementError && error.code === 'TIME_SLOT_CONFLICT' + ); + const [unchanged] = await pool.query( + `SELECT end_at AS endAt FROM qipai_orders WHERE id = ?`, + [created.orderId] + ); + assert.equal(new Date(unchanged[0].endAt).getTime(), endAt.getTime()); + await pool.query(`DELETE FROM qipai_room_reservations WHERE order_id = ?`, [blockingOrder.insertId]); + await pool.query(`DELETE FROM qipai_orders WHERE id = ?`, [blockingOrder.insertId]); + + const renewed = await repository.renew(actor('m04c-renew'), created.orderId, { + endAt: blockedEnd, pricingPolicy: 'LOCKED', reason: 'approved extension' + }); + assert.equal(renewed.amountDeltaCents, created.quote.unitPriceCents * 2); + const duplicate = await repository.renew(actor('m04c-renew'), created.orderId, { + endAt: new Date(blockedEnd.getTime() + 3600000), + pricingPolicy: 'CURRENT', reason: 'duplicate request' + }); + assert.equal(duplicate.idempotent, true); + + const [targetBlockOrder] = await pool.query( + `INSERT INTO qipai_orders + (tenant_id, store_id, room_id, order_no, status, start_at, end_at) + VALUES (?, ?, ?, 'M04C-ROOM-BLOCK', 'PAID', ?, ?)`, + [context.tenantId, storeId, targetRoomId, startAt, blockedEnd] + ); + await pool.query( + `INSERT INTO qipai_room_reservations + (tenant_id, order_id, room_id, starts_at, ends_at, status, expires_at) + VALUES (?, ?, ?, ?, ?, 'CONSUMED', ?)`, + [context.tenantId, targetBlockOrder.insertId, targetRoomId, startAt, blockedEnd, blockedEnd] + ); + await assert.rejects( + () => repository.changeRoom(actor('m04c-room-conflict'), created.orderId, { + roomId: targetRoomId, reason: 'rollback rehearsal' + }), + (error) => error instanceof OrderManagementError && error.code === 'TIME_SLOT_CONFLICT' + ); + const [stillOldRoom] = await pool.query( + `SELECT o.room_id AS orderRoomId, r.room_id AS reservationRoomId + FROM qipai_orders o INNER JOIN qipai_room_reservations r ON r.order_id = o.id + WHERE o.id = ?`, + [created.orderId] + ); + assert.equal(String(stillOldRoom[0].orderRoomId), roomId); + assert.equal(String(stillOldRoom[0].reservationRoomId), roomId); + await pool.query(`DELETE FROM qipai_room_reservations WHERE order_id = ?`, [targetBlockOrder.insertId]); + await pool.query(`DELETE FROM qipai_orders WHERE id = ?`, [targetBlockOrder.insertId]); + + const changed = await repository.changeRoom(actor('m04c-room-change'), created.orderId, { + roomId: targetRoomId, reason: 'customer requested target room' + }); + assert.equal(changed.adjustmentType, 'CHANGE_ROOM'); + const adjustedEnd = new Date(blockedEnd.getTime() + 3600000); + const adjusted = await repository.adjustTime(actor('m04c-time-adjust'), created.orderId, { + endAt: adjustedEnd, reason: 'manager granted one hour' + }); + assert.equal(adjusted.adjustmentType, 'ADJUST_TIME'); + await repository.note(actor('m04c-note'), created.orderId, 'sanitized operator note'); + const cancellation = await repository.cancellationQuote( + context.tenantId, customerId, created.orderId + ); + assert.equal(cancellation.allowed, true); + assert.ok(cancellation.feeCents > 0); + await assert.rejects( + () => pricing.reserve({ + tenantId: context.tenantId, userId: customerId, roomId: targetRoomId, + startAt: new Date(adjustedEnd.getTime() + 86400000), + endAt: new Date(adjustedEnd.getTime() + 90000000), + pricingMode: 'HOURLY', allowedStoreIds: [String(Number(storeId) + 999)] + }), + (error) => error instanceof PricingError && error.code === 'STORE_SCOPE_FORBIDDEN' + ); + const [history] = await pool.query( + `SELECT adjustment_type AS adjustmentType, amount_delta_cents AS amountDeltaCents + FROM qipai_order_adjustments + WHERE tenant_id = ? AND order_id = ? ORDER BY id`, + [context.tenantId, created.orderId] + ); + assert.deepEqual(history.map((row) => row.adjustmentType), [ + 'RENEW', 'CHANGE_ROOM', 'ADJUST_TIME', 'NOTE' + ]); +} + async function assertContentManagement(pool, context) { const [adminRows] = await pool.query( `SELECT u.id FROM qipai_users u @@ -822,7 +989,8 @@ try { { version: '2026061809', name: 'm03c_store_discovery' }, { version: '2026061810', name: 'm03d_scene_wifi_access' }, { version: '2026061811', name: 'm04a_pricing_reservations' }, - { version: '2026062012', name: 'm04b_order_state_machine' } + { version: '2026062012', name: 'm04b_order_state_machine' }, + { version: '2026062013', name: 'm04c_order_adjustments' } ]); await assertTaskDurability(pool); const loginContext = await assertPlatformTenantIsolation(pool); @@ -834,13 +1002,14 @@ try { await assertSceneAndWifiAccess(pool, loginContext); await assertPricingAndReservations(pool, loginContext); await assertOrderStateMachine(pool, loginContext); + await assertOrderAdjustments(pool, loginContext); await assertLegacyCompatibility(pool); console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.'); await executeMigrationPlan(pool, plans.down); assert.deepEqual(await readCoreTables(pool), []); await assertLegacyCompatibility(pool); - console.log('PASS: down removed all M01-B through M04-B tables.'); + console.log('PASS: down removed all M01-B through M04-C tables.'); await executeMigrationPlan(pool, plans.up); await executeMigrationPlan(pool, plans.verify); @@ -857,7 +1026,8 @@ try { { version: '2026061809', name: 'm03c_store_discovery' }, { version: '2026061810', name: 'm03d_scene_wifi_access' }, { version: '2026061811', name: 'm04a_pricing_reservations' }, - { version: '2026062012', name: 'm04b_order_state_machine' } + { version: '2026062012', name: 'm04b_order_state_machine' }, + { version: '2026062013', name: 'm04c_order_adjustments' } ]); await assertLegacyCompatibility(pool); console.log('PASS: second up and verify restored the schema.'); @@ -919,7 +1089,13 @@ try { 'controlled order actions', 'idempotent transition trace', 'complete order status history', - 'reservation and access release on close' + 'reservation and access release on close', + 'renewal conflict leaves original end time unchanged', + 'idempotent renewal adjustment', + 'room-change conflict transaction rollback', + 'room price difference and manager time adjustment', + 'configured cancellation fee quote', + 'store-scoped on-behalf booking rejection' ] }, null, 2)); } finally { diff --git a/backend/tests/order-management.test.mjs b/backend/tests/order-management.test.mjs new file mode 100644 index 0000000..46af7ab --- /dev/null +++ b/backend/tests/order-management.test.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { buildApp } from '../dist/app.js'; +import { signAccessToken } from '../dist/auth/jwt.js'; + +const secret = 'test-only-order-management-secret-32-chars'; +const token = signAccessToken({ + sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', + tid: '7', aid: '9', rv: 1 +}, secret, 900); +let called; +const authRepository = { + async validateSession() { + return { + id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', + tenantId: '7', platformAppId: '9', expiresAt: new Date(Date.now() + 60000), + user: { + id: '21', tenantId: '7', userType: 'ADMIN', status: 'ACTIVE', + roleVersion: 1, nickname: '', avatarUrl: '', phone: '' + } + }; + } +}; +const accessControl = { + async getAccessProfile() { + return { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] }; + } +}; +const app = await buildApp({ + orderManagement: { + jwtSecret: secret, authRepository, accessControl, + repository: { + async renew(actor, orderId, body) { + called = { actor, orderId, body }; + return { orderId, adjustmentType: 'RENEW', amountDeltaCents: 1200 }; + }, + async changeRoom() { throw new Error('not called'); }, + async adjustTime() { throw new Error('not called'); }, + async note() { throw new Error('not called'); }, + async cancellationQuote() { + return { allowed: true, feeCents: 0, refundableCents: 3000 }; + } + } + } +}); + +const rejectedAmount = await app.inject({ + method: 'POST', + url: '/admin-api/orders/31/renew', + headers: { authorization: `Bearer ${token}` }, + payload: { + endAt: new Date(Date.now() + 7200000).toISOString(), + pricingPolicy: 'LOCKED', + reason: 'extend', + amountDeltaCents: 1 + } +}); +assert.equal(rejectedAmount.statusCode, 400); + +const renewed = await app.inject({ + method: 'POST', + url: '/admin-api/orders/31/renew', + headers: { authorization: `Bearer ${token}`, 'x-trace-id': 'm04c-renew-route' }, + payload: { + endAt: new Date(Date.now() + 7200000).toISOString(), + pricingPolicy: 'LOCKED', + reason: 'customer requested extension' + } +}); +assert.equal(renewed.statusCode, 200); +assert.equal(called.orderId, '31'); +assert.equal(called.actor.traceId, 'm04c-renew-route'); +assert.equal(called.body.pricingPolicy, 'LOCKED'); + +const quote = await app.inject({ + method: 'GET', + url: '/app-api/orders/31/cancellation-quote', + headers: { authorization: `Bearer ${token}` } +}); +assert.equal(quote.statusCode, 200); +assert.equal(quote.json().data.refundableCents, 3000); + +await app.close(); +console.log('PASS: M04-C routes reject client amounts and expose controlled adjustments.'); diff --git a/database/migrations/2026062013_m04c_order_adjustments.down.sql b/database/migrations/2026062013_m04c_order_adjustments.down.sql new file mode 100644 index 0000000..950221c --- /dev/null +++ b/database/migrations/2026062013_m04c_order_adjustments.down.sql @@ -0,0 +1,8 @@ +DELETE FROM qipai_schema_migrations WHERE version = '2026062013'; +DROP TABLE IF EXISTS qipai_order_adjustments; +ALTER TABLE qipai_orders + DROP COLUMN operator_note, + DROP COLUMN adjustment_amount_cents; +ALTER TABLE qipai_stores + DROP COLUMN cancellation_fee_bps, + DROP COLUMN cancellation_cutoff_minutes; diff --git a/database/migrations/2026062013_m04c_order_adjustments.up.sql b/database/migrations/2026062013_m04c_order_adjustments.up.sql new file mode 100644 index 0000000..468f82b --- /dev/null +++ b/database/migrations/2026062013_m04c_order_adjustments.up.sql @@ -0,0 +1,35 @@ +ALTER TABLE qipai_stores + ADD COLUMN cancellation_cutoff_minutes INT UNSIGNED NOT NULL DEFAULT 120 + AFTER notification_url, + ADD COLUMN cancellation_fee_bps SMALLINT UNSIGNED NOT NULL DEFAULT 0 + AFTER cancellation_cutoff_minutes; + +ALTER TABLE qipai_orders + ADD COLUMN adjustment_amount_cents INT NOT NULL DEFAULT 0 AFTER paid_amount_cents, + ADD COLUMN operator_note VARCHAR(512) NOT NULL DEFAULT '' AFTER adjustment_amount_cents; + +CREATE TABLE IF NOT EXISTS qipai_order_adjustments ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + order_id BIGINT UNSIGNED NOT NULL, + adjustment_type VARCHAR(32) NOT NULL, + actor_id BIGINT UNSIGNED NOT NULL, + source VARCHAR(32) NOT NULL, + trace_id VARCHAR(128) NOT NULL, + reason VARCHAR(512) NOT NULL DEFAULT '', + before_values JSON NOT NULL, + after_values JSON NOT NULL, + amount_delta_cents INT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + CONSTRAINT fk_qipai_order_adjustment_tenant + FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_order_adjustment_order + FOREIGN KEY (order_id) REFERENCES qipai_orders(id), + CONSTRAINT fk_qipai_order_adjustment_actor + FOREIGN KEY (actor_id) REFERENCES qipai_users(id), + UNIQUE KEY uq_qipai_order_adjustment_trace (tenant_id, order_id, trace_id), + KEY idx_qipai_order_adjustment_order (tenant_id, order_id, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO qipai_schema_migrations (version, name) +VALUES ('2026062013', 'm04c_order_adjustments'); diff --git a/database/migrations/2026062013_m04c_order_adjustments.verify.sql b/database/migrations/2026062013_m04c_order_adjustments.verify.sql new file mode 100644 index 0000000..43d7e35 --- /dev/null +++ b/database/migrations/2026062013_m04c_order_adjustments.verify.sql @@ -0,0 +1,19 @@ +SELECT column_name FROM information_schema.columns +WHERE table_schema = DATABASE() AND table_name = 'qipai_stores' + AND column_name IN ('cancellation_cutoff_minutes', 'cancellation_fee_bps') +ORDER BY column_name; + +SELECT column_name FROM information_schema.columns +WHERE table_schema = DATABASE() AND table_name = 'qipai_orders' + AND column_name IN ('adjustment_amount_cents', 'operator_note') +ORDER BY column_name; + +SELECT table_name FROM information_schema.tables +WHERE table_schema = DATABASE() AND table_name = 'qipai_order_adjustments'; + +SELECT index_name FROM information_schema.statistics +WHERE table_schema = DATABASE() AND table_name = 'qipai_order_adjustments' + AND index_name IN ('uq_qipai_order_adjustment_trace', 'idx_qipai_order_adjustment_order') +GROUP BY index_name ORDER BY index_name; + +SELECT version, name FROM qipai_schema_migrations WHERE version = '2026062013'; diff --git a/docs/api-changelog/2026-06-20-M04-C-order-adjustments.md b/docs/api-changelog/2026-06-20-M04-C-order-adjustments.md new file mode 100644 index 0000000..f553d9a --- /dev/null +++ b/docs/api-changelog/2026-06-20-M04-C-order-adjustments.md @@ -0,0 +1,10 @@ +# M04-C 订单调整 API + +- `GET /app-api/orders/:orderId/cancellation-quote`:按门店提前取消分钟数和费率返回取消费、可退金额;退款执行留给 M05/M07。 +- `POST /admin-api/orders/:orderId/renew`:按当前价或原锁定单价续费,并重新检查后续时段。 +- `POST /admin-api/orders/:orderId/change-room`:事务内换房并计算基础房价差额。 +- `POST /admin-api/orders/:orderId/adjust-time`:管理员增减订单时间。 +- `POST /admin-api/orders/:orderId/note`:记录管理员备注。 +- `POST /admin-api/orders/reserve-on-behalf`:管理员在门店数据范围内代下单。 + +所有调整金额均由服务端计算,客户端提交金额字段会被严格拒绝。每个调整使用订单 + traceId 幂等,并记录前后值、原因、操作者和审计日志。 diff --git a/docs/db-changelog/2026-06-20-M04-C-order-adjustments.md b/docs/db-changelog/2026-06-20-M04-C-order-adjustments.md new file mode 100644 index 0000000..975bc29 --- /dev/null +++ b/docs/db-changelog/2026-06-20-M04-C-order-adjustments.md @@ -0,0 +1,9 @@ +# M04-C 订单调整数据库变更 + +- 迁移版本:`2026062013` +- 门店新增取消提前量和取消费率配置。 +- 订单新增累计调整金额和管理员备注。 +- 新增 `qipai_order_adjustments`,记录续费、换房、调时和备注的前后值、差额、操作者、来源、原因与 traceId。 +- `(tenant_id, order_id, trace_id)` 唯一约束保证人工调整幂等。 + +续费、换房和调时均在事务内锁定订单与相关房间,检查禁用时段和重叠预占;失败时订单、预占和金额全部回滚。 diff --git a/scripts/dev/windows/check-backend.ps1 b/scripts/dev/windows/check-backend.ps1 index 9d62849..91a5a19 100644 --- a/scripts/dev/windows/check-backend.ps1 +++ b/scripts/dev/windows/check-backend.ps1 @@ -52,6 +52,9 @@ $requiredFiles = @( "database/migrations/2026062012_m04b_order_state_machine.up.sql", "database/migrations/2026062012_m04b_order_state_machine.down.sql", "database/migrations/2026062012_m04b_order_state_machine.verify.sql", + "database/migrations/2026062013_m04c_order_adjustments.up.sql", + "database/migrations/2026062013_m04c_order_adjustments.down.sql", + "database/migrations/2026062013_m04c_order_adjustments.verify.sql", "database/seeds/2026061601_m01b_minimal_seed.sql", "deploy/pm2/ecosystem.config.cjs" ) diff --git a/scripts/dev/wsl/mysql-migration-roundtrip.sh b/scripts/dev/wsl/mysql-migration-roundtrip.sh index 31692cd..2c2ce73 100644 --- a/scripts/dev/wsl/mysql-migration-roundtrip.sh +++ b/scripts/dev/wsl/mysql-migration-roundtrip.sh @@ -93,6 +93,6 @@ export QIPAI_MYSQL_USER="${username}" export QIPAI_MYSQL_PASSWORD="${password}" export QIPAI_MYSQL_CONNECTION_LIMIT=2 -echo "INFO: MySQL ${mysql_version}; running M01-B through M04-B migration roundtrip in a temporary database." +echo "INFO: MySQL ${mysql_version}; running M01-B through M04-C migration roundtrip in a temporary database." npm --prefix backend run test:mysql:migration -echo "PASS: M01-B through M04-B live MySQL migration roundtrip completed." +echo "PASS: M01-B through M04-C live MySQL migration roundtrip completed."