diff --git a/backend/package.json b/backend/package.json index 171c654..5b35e94 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" + "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" }, "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/backend/src/app.ts b/backend/src/app.ts index 3472bb8..3f4e3e7 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -29,6 +29,7 @@ import { registerStoreAccessRoutes, type StoreAccessRouteOptions } from './routes/store-access.js'; +import { registerPricingRoutes, type PricingRouteOptions } from './routes/pricing.js'; export interface BuildAppOptions { config?: AppConfig; @@ -39,6 +40,7 @@ export interface BuildAppOptions { content?: ContentRouteOptions; storeDiscovery?: StoreDiscoveryRouteOptions; storeAccess?: StoreAccessRouteOptions; + pricing?: PricingRouteOptions; } declare module 'fastify' { @@ -102,6 +104,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise = { 'database/migrations/2026061807_m03a_store_room_domain.up.sql', 'database/migrations/2026061808_m03b_decoration_ads_media.up.sql', 'database/migrations/2026061809_m03c_store_discovery.up.sql', - 'database/migrations/2026061810_m03d_scene_wifi_access.up.sql' + 'database/migrations/2026061810_m03d_scene_wifi_access.up.sql', + 'database/migrations/2026061811_m04a_pricing_reservations.up.sql' ], verify: [ 'database/migrations/2026061601_m01b_core_schema.verify.sql', @@ -42,9 +43,11 @@ const migrationFiles: Record = { 'database/migrations/2026061807_m03a_store_room_domain.verify.sql', 'database/migrations/2026061808_m03b_decoration_ads_media.verify.sql', 'database/migrations/2026061809_m03c_store_discovery.verify.sql', - 'database/migrations/2026061810_m03d_scene_wifi_access.verify.sql' + 'database/migrations/2026061810_m03d_scene_wifi_access.verify.sql', + 'database/migrations/2026061811_m04a_pricing_reservations.verify.sql' ], down: [ + 'database/migrations/2026061811_m04a_pricing_reservations.down.sql', 'database/migrations/2026061810_m03d_scene_wifi_access.down.sql', 'database/migrations/2026061809_m03c_store_discovery.down.sql', 'database/migrations/2026061808_m03b_decoration_ads_media.down.sql', @@ -180,7 +183,8 @@ export async function executeMigrationPlan( 3, 6, 13, 1, 3, 3, 1, 2, 2, 1, - 3, 3, 1 + 3, 3, 1, + 2, 1, 3, 3, 1 ][index] ?? 1; if (!Array.isArray(result) || result.length < minimumRows) { throw new Error( diff --git a/backend/src/orders/pricing-repository.ts b/backend/src/orders/pricing-repository.ts new file mode 100644 index 0000000..36879a8 --- /dev/null +++ b/backend/src/orders/pricing-repository.ts @@ -0,0 +1,307 @@ +import { randomBytes } from 'node:crypto'; +import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise'; +import type { MySqlPool } from '../db/mysql.js'; + +export type PricingMode = 'HOURLY' | 'OVERNIGHT' | 'FULL_DAY'; + +export interface PricingAdjustment { + discountCents?: number; + packageCreditCents?: number; +} + +export interface QuoteInput { + tenantId: string; + roomId: string; + startAt: Date; + endAt: Date; + pricingMode: PricingMode; + adjustment?: PricingAdjustment; +} + +interface RoomPricingRow extends RowDataPacket { + id: string; + storeId: string; + timezone: string; + configurationStatus: string; + operationalStatus: string; + basePriceCents: number; + weekdayPriceCents: number; + holidayPriceCents: number; + overnightPriceCents: number; + fullDayPriceCents: number; + minimumSpendCents: number; + depositCents: number; + minimumMinutes: number; + maxAdvanceDays: number; +} +interface CountRow extends RowDataPacket { total: number } + +export class PricingError extends Error { + constructor(public readonly code: string) { super(code); } +} + +export class PricingRepository { + constructor(private readonly pool: MySqlPool) {} + + async quote(input: QuoteInput) { + const room = await this.loadRoom(this.pool, input.tenantId, input.roomId); + await this.releaseExpired(input.tenantId, input.roomId); + await this.assertAvailable(this.pool, input, false); + const isHoliday = await this.isHoliday(this.pool, input.tenantId, input.startAt, room.timezone); + return this.calculate(room, input, isHoliday); + } + + async reserve(input: QuoteInput & { + userId: string; + holdMinutes?: number; + }) { + return this.transaction(async (connection) => { + const room = await this.loadRoom(connection, input.tenantId, input.roomId, true); + await this.releaseExpired(input.tenantId, input.roomId, connection); + await this.assertAvailable(connection, input, true); + const isHoliday = await this.isHoliday( + connection, input.tenantId, input.startAt, room.timezone + ); + const quote = this.calculate(room, input, isHoliday); + const holdMinutes = Math.min(Math.max(input.holdMinutes ?? 15, 5), 30); + const orderNo = `QP${Date.now()}${randomBytes(4).toString('hex').toUpperCase()}`; + const [orderResult] = await connection.execute( + `INSERT INTO qipai_orders + (tenant_id, store_id, room_id, order_no, status, start_at, end_at, + hold_expires_at, total_amount_cents) + VALUES (?, ?, ?, ?, 'PENDING_PAYMENT', ?, ?, + DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? MINUTE), ?)`, + [input.tenantId, room.storeId, input.roomId, orderNo, input.startAt, input.endAt, + holdMinutes, quote.totalCents] + ); + const orderId = String(orderResult.insertId); + await connection.execute( + `INSERT INTO qipai_order_price_snapshots + (tenant_id, order_id, pricing_mode, duration_minutes, unit_price_cents, + subtotal_cents, minimum_spend_cents, deposit_cents, discount_cents, + package_credit_cents, total_cents, rules) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [input.tenantId, orderId, input.pricingMode, quote.durationMinutes, + quote.unitPriceCents, quote.subtotalCents, quote.minimumSpendCents, + quote.depositCents, quote.discountCents, quote.packageCreditCents, + quote.totalCents, JSON.stringify(quote.rules)] + ); + const [reservationResult] = await connection.execute( + `INSERT INTO qipai_room_reservations + (tenant_id, order_id, room_id, starts_at, ends_at, expires_at) + VALUES (?, ?, ?, ?, ?, DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? MINUTE))`, + [input.tenantId, orderId, input.roomId, input.startAt, input.endAt, holdMinutes] + ); + await connection.execute( + `INSERT INTO qipai_order_user_access (tenant_id, order_id, user_id) + VALUES (?, ?, ?)`, + [input.tenantId, orderId, input.userId] + ); + return { + orderId, + orderNo, + reservationId: String(reservationResult.insertId), + holdMinutes, + quote + }; + }); + } + + async releaseExpired( + tenantId?: string, + roomId?: string, + connection: Pick | PoolConnection = this.pool + ) { + const filters = [`r.status = 'HELD'`, 'r.expires_at <= UTC_TIMESTAMP(3)']; + const params: string[] = []; + if (tenantId) { + filters.push('r.tenant_id = ?'); + params.push(tenantId); + } + if (roomId) { + filters.push('r.room_id = ?'); + params.push(roomId); + } + const [counts] = await connection.execute( + `SELECT COUNT(*) AS total FROM qipai_room_reservations r + WHERE ${filters.join(' AND ')}`, + params + ); + await connection.execute( + `UPDATE qipai_room_reservations r + INNER JOIN qipai_orders o + ON o.id = r.order_id AND o.tenant_id = r.tenant_id + SET r.status = 'RELEASED', r.released_at = UTC_TIMESTAMP(3), + o.status = 'CLOSED' + WHERE ${filters.join(' AND ')}`, + params + ); + return { released: Number(counts[0]?.total ?? 0) }; + } + + private calculate(room: RoomPricingRow, input: QuoteInput, isHoliday: boolean) { + this.validateWindow(room, input); + const durationMinutes = Math.ceil( + (input.endAt.getTime() - input.startAt.getTime()) / 60000 + ); + const localDate = localDateKey(input.startAt, room.timezone); + const weekday = localWeekday(input.startAt, room.timezone); + const adjustment = input.adjustment ?? {}; + let unitPriceCents = room.basePriceCents; + let priceSource = 'base'; + if (input.pricingMode === 'OVERNIGHT') { + unitPriceCents = room.overnightPriceCents || room.basePriceCents; + priceSource = 'overnight'; + } else if (input.pricingMode === 'FULL_DAY') { + unitPriceCents = room.fullDayPriceCents || room.basePriceCents * 24; + priceSource = 'fullDay'; + } else if (isHoliday && room.holidayPriceCents > 0) { + unitPriceCents = room.holidayPriceCents; + priceSource = 'holiday'; + } else if (weekday >= 1 && weekday <= 5 && room.weekdayPriceCents > 0) { + unitPriceCents = room.weekdayPriceCents; + priceSource = 'weekday'; + } + const subtotalCents = input.pricingMode === 'HOURLY' + ? Math.ceil(durationMinutes / 60) * unitPriceCents + : unitPriceCents; + const minimumSpendCents = room.minimumSpendCents; + const billableCents = Math.max(subtotalCents, minimumSpendCents); + const discountCents = clampAdjustment(adjustment.discountCents, billableCents); + const afterDiscount = billableCents - discountCents; + const packageCreditCents = clampAdjustment( + adjustment.packageCreditCents, afterDiscount + ); + const totalCents = afterDiscount - packageCreditCents + room.depositCents; + return { + durationMinutes, + unitPriceCents, + subtotalCents, + minimumSpendCents, + depositCents: room.depositCents, + discountCents, + packageCreditCents, + totalCents, + rules: { + priceSource, + isHoliday, + localDate, + timezone: room.timezone, + pricingMode: input.pricingMode, + minimumMinutes: room.minimumMinutes + } + }; + } + + private validateWindow(room: RoomPricingRow, input: QuoteInput) { + if (input.endAt <= input.startAt) throw new PricingError('INVALID_TIME_WINDOW'); + const durationMinutes = (input.endAt.getTime() - input.startAt.getTime()) / 60000; + if (durationMinutes < room.minimumMinutes) throw new PricingError('MINIMUM_DURATION_NOT_MET'); + if (input.startAt.getTime() > Date.now() + room.maxAdvanceDays * 86400000) { + throw new PricingError('ADVANCE_WINDOW_EXCEEDED'); + } + if (room.configurationStatus !== 'ENABLED' || room.operationalStatus !== 'AVAILABLE') { + throw new PricingError('ROOM_NOT_AVAILABLE'); + } + } + + private async loadRoom( + connection: Pick | PoolConnection, + tenantId: string, + roomId: string, + lock = false + ) { + const [rows] = await connection.execute( + `SELECT r.id, r.store_id AS storeId, s.timezone, + r.configuration_status AS configurationStatus, + r.operational_status AS operationalStatus, + r.base_price_cents AS basePriceCents, + r.weekday_price_cents AS weekdayPriceCents, + r.holiday_price_cents AS holidayPriceCents, + r.overnight_price_cents AS overnightPriceCents, + r.full_day_price_cents AS fullDayPriceCents, + r.minimum_spend_cents AS minimumSpendCents, + r.deposit_cents AS depositCents, + r.minimum_minutes AS minimumMinutes, + r.max_advance_days AS maxAdvanceDays + FROM qipai_rooms r + INNER JOIN qipai_stores s + ON s.id = r.store_id AND s.tenant_id = r.tenant_id AND s.deleted_at IS NULL + WHERE r.tenant_id = ? AND r.id = ? AND r.deleted_at IS NULL + ${lock ? 'FOR UPDATE' : ''}`, + [tenantId, roomId] + ); + if (!rows[0]) throw new PricingError('ROOM_NOT_FOUND'); + return rows[0]; + } + + private async assertAvailable( + connection: Pick | PoolConnection, + input: QuoteInput, + lock: boolean + ) { + const [disabled] = await connection.execute( + `SELECT COUNT(*) AS total FROM qipai_room_disabled_periods + WHERE tenant_id = ? AND room_id = ? AND starts_at < ? AND ends_at > ?`, + [input.tenantId, input.roomId, input.endAt, input.startAt] + ); + if (Number(disabled[0]?.total ?? 0) > 0) throw new PricingError('ROOM_DISABLED_PERIOD'); + const [reserved] = await connection.execute( + `SELECT COUNT(*) AS total FROM qipai_room_reservations + WHERE tenant_id = ? AND room_id = ? + AND status IN ('HELD', 'CONSUMED') + AND (status = 'CONSUMED' OR expires_at > UTC_TIMESTAMP(3)) + AND starts_at < ? AND ends_at > ? + ${lock ? 'FOR UPDATE' : ''}`, + [input.tenantId, input.roomId, input.endAt, input.startAt] + ); + if (Number(reserved[0]?.total ?? 0) > 0) throw new PricingError('TIME_SLOT_CONFLICT'); + } + + private async isHoliday( + connection: Pick | PoolConnection, + tenantId: string, + date: Date, + timezone: string + ) { + const [rows] = await connection.execute( + `SELECT COUNT(*) AS total FROM qipai_holiday_calendar + WHERE tenant_id = ? AND holiday_date = ?`, + [tenantId, localDateKey(date, timezone)] + ); + return Number(rows[0]?.total ?? 0) > 0; + } + + 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 clampAdjustment(value: number | undefined, maximum: number) { + if (!value || value < 0) return 0; + return Math.min(Math.trunc(value), maximum); +} + +function localDateKey(date: Date, timezone: string) { + return new Intl.DateTimeFormat('en-CA', { + timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit' + }).format(date); +} + +function localWeekday(date: Date, timezone: string) { + const day = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, weekday: 'short' + }).format(date); + return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(day); +} diff --git a/backend/src/routes/pricing.ts b/backend/src/routes/pricing.ts new file mode 100644 index 0000000..3e0b3fb --- /dev/null +++ b/backend/src/routes/pricing.ts @@ -0,0 +1,117 @@ +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 { PricingError, type PricingRepository } from '../orders/pricing-repository.js'; + +const requestSchema = z.object({ + roomId: z.string().regex(/^[1-9]\d{0,19}$/), + startAt: z.coerce.date(), + endAt: z.coerce.date(), + pricingMode: z.enum(['HOURLY', 'OVERNIGHT', 'FULL_DAY']).default('HOURLY') +}).refine((value) => value.endAt > value.startAt); + +export interface PricingRouteOptions { + repository: Pick; + authRepository: Pick; + accessControl: { getAccessProfile(tenantId: string, userId: string): Promise }; + jwtSecret: string; +} + +export async function registerPricingRoutes(app: FastifyInstance, options: PricingRouteOptions) { + app.post('/app-api/pricing/quote', async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options); + const body = requestSchema.safeParse(request.body); + if (!auth) return unauthorized(reply, request.traceId); + if (!body.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.repository.quote({ + tenantId: auth.tenantId, + ...body.data + }), + traceId: request.traceId + })); + }); + + app.post('/app-api/orders/reserve', async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options); + const body = requestSchema.safeParse(request.body); + if (!auth) return unauthorized(reply, request.traceId); + if (!body.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => reply.status(201).send({ + code: 0, + data: await options.repository.reserve({ + tenantId: auth.tenantId, + userId: auth.userId, + ...body.data + }), + traceId: request.traceId + })); + }); + + app.post('/admin-api/reservations/release-expired', async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options); + if (!auth) return unauthorized(reply, request.traceId); + const access = await options.accessControl.getAccessProfile(auth.tenantId, auth.userId); + if (!access.capabilities.includes('tenant.manage') + && !access.roles.includes('PLATFORM_ADMIN')) { + return reply.status(403).send({ + code: 'RESERVATION_RELEASE_FORBIDDEN', + message: 'Tenant management permission is required.', + traceId: request.traceId + }); + } + return { + code: 0, + data: await options.repository.releaseExpired(auth.tenantId), + traceId: request.traceId + }; + }); +} + +async function authenticate( + authorization: string | undefined, + options: PricingRouteOptions +) { + const result = await authenticateAccessToken( + authorization, options.authRepository, options.jwtSecret + ); + if (!result) return null; + return { + tenantId: result.session.tenantId, + userId: result.session.user.id + }; +} + +async function handle(reply: FastifyReply, traceId: string, work: () => Promise) { + try { + return await work(); + } catch (error) { + if (!(error instanceof PricingError)) throw error; + const conflict = error.code === 'TIME_SLOT_CONFLICT'; + const notFound = error.code === 'ROOM_NOT_FOUND'; + return reply.status(conflict ? 409 : notFound ? 404 : 400).send({ + code: error.code, + message: 'The requested price or time slot 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_PRICING_REQUEST', + message: 'The pricing request is invalid.', + traceId + }); +} diff --git a/backend/src/routes/store-room-management.ts b/backend/src/routes/store-room-management.ts index 7f872d3..33a040a 100644 --- a/backend/src/routes/store-room-management.ts +++ b/backend/src/routes/store-room-management.ts @@ -44,6 +44,8 @@ const roomSchema = z.object({ weekdayPriceCents: z.number().int().min(0).max(10000000), holidayPriceCents: z.number().int().min(0).max(10000000), overnightPriceCents: z.number().int().min(0).max(10000000), + fullDayPriceCents: z.number().int().min(0).max(10000000).default(0), + minimumSpendCents: z.number().int().min(0).max(10000000).default(0), depositCents: z.number().int().min(0).max(10000000), minimumMinutes: z.number().int().min(15).max(1440), maxAdvanceStartMinutes: z.number().int().min(0).max(1440), diff --git a/backend/src/server.ts b/backend/src/server.ts index 96360e8..095c8fe 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -12,6 +12,7 @@ import { MediaStorage } from './content/media-storage.js'; import { resolve } from 'node:path'; import { StoreDiscoveryRepository } from './stores/store-discovery-repository.js'; import { StoreAccessRepository } from './stores/access-repository.js'; +import { PricingRepository } from './orders/pricing-repository.js'; const config = loadConfig(); const pool = createMySqlPool(config); @@ -56,6 +57,12 @@ const app = await buildApp({ authRepository, accessControl, jwtSecret: config.auth.jwtSecret + }, + pricing: { + repository: new PricingRepository(pool), + authRepository, + accessControl, + jwtSecret: config.auth.jwtSecret } }); app.addHook('onClose', async () => { diff --git a/backend/src/stores/store-room-repository.ts b/backend/src/stores/store-room-repository.ts index 274ffcf..8888ea3 100644 --- a/backend/src/stores/store-room-repository.ts +++ b/backend/src/stores/store-room-repository.ts @@ -34,6 +34,8 @@ export interface RoomInput { weekdayPriceCents: number; holidayPriceCents: number; overnightPriceCents: number; + fullDayPriceCents: number; + minimumSpendCents: number; depositCents: number; minimumMinutes: number; maxAdvanceStartMinutes: number; @@ -56,7 +58,8 @@ interface StoreRow extends RowDataPacket { interface RoomRow extends RowDataPacket { id: string; storeId: string; categoryName: string; name: string; roomNo: string; capacity: number; basePriceCents: number; weekdayPriceCents: number; holidayPriceCents: number; - overnightPriceCents: number; depositCents: number; minimumMinutes: number; + overnightPriceCents: number; fullDayPriceCents: number; minimumSpendCents: number; + depositCents: number; minimumMinutes: number; maxAdvanceStartMinutes: number; maxAdvanceDays: number; configurationStatus: string; operationalStatus: string; tags: string | string[] | null; images: string | string[] | null; sortOrder: number; @@ -155,7 +158,10 @@ export class StoreRoomRepository { r.name, r.room_no AS roomNo, r.capacity, r.base_price_cents AS basePriceCents, r.weekday_price_cents AS weekdayPriceCents, r.holiday_price_cents AS holidayPriceCents, - r.overnight_price_cents AS overnightPriceCents, r.deposit_cents AS depositCents, + r.overnight_price_cents AS overnightPriceCents, + r.full_day_price_cents AS fullDayPriceCents, + r.minimum_spend_cents AS minimumSpendCents, + r.deposit_cents AS depositCents, r.minimum_minutes AS minimumMinutes, r.max_advance_start_minutes AS maxAdvanceStartMinutes, r.max_advance_days AS maxAdvanceDays, @@ -185,10 +191,11 @@ export class StoreRoomRepository { const [result] = await connection.execute( `INSERT INTO qipai_rooms (tenant_id, store_id, category_id, name, room_no, capacity, base_price_cents, - weekday_price_cents, holiday_price_cents, overnight_price_cents, deposit_cents, - minimum_minutes, max_advance_start_minutes, max_advance_days, + weekday_price_cents, holiday_price_cents, overnight_price_cents, + full_day_price_cents, minimum_spend_cents, deposit_cents, minimum_minutes, + max_advance_start_minutes, max_advance_days, configuration_status, operational_status, tags, images, sort_order, status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, roomParams(actor.tenantId, categoryId, input) ); const roomId = String(result.insertId); @@ -207,7 +214,8 @@ export class StoreRoomRepository { await connection.execute( `UPDATE qipai_rooms SET category_id = ?, name = ?, room_no = ?, capacity = ?, base_price_cents = ?, weekday_price_cents = ?, holiday_price_cents = ?, - overnight_price_cents = ?, deposit_cents = ?, minimum_minutes = ?, + overnight_price_cents = ?, full_day_price_cents = ?, minimum_spend_cents = ?, + deposit_cents = ?, minimum_minutes = ?, max_advance_start_minutes = ?, max_advance_days = ?, configuration_status = ?, operational_status = ?, tags = ?, images = ?, sort_order = ?, status = ? WHERE tenant_id = ? AND store_id = ? AND id = ?`, @@ -369,7 +377,8 @@ function roomParams(tenantId: string, categoryId: string, input: RoomInput) { return [ tenantId, input.storeId, categoryId, input.name, input.roomNo, input.capacity, input.basePriceCents, input.weekdayPriceCents, input.holidayPriceCents, - input.overnightPriceCents, input.depositCents, input.minimumMinutes, + input.overnightPriceCents, input.fullDayPriceCents ?? 0, input.minimumSpendCents ?? 0, + input.depositCents, input.minimumMinutes, input.maxAdvanceStartMinutes, input.maxAdvanceDays, input.configurationStatus, input.operationalStatus, JSON.stringify(input.tags), JSON.stringify(input.images), input.sortOrder, legacyStatus @@ -382,7 +391,8 @@ function roomUpdateParams(categoryId: string, input: RoomInput) { return [ categoryId, input.name, input.roomNo, input.capacity, input.basePriceCents, input.weekdayPriceCents, input.holidayPriceCents, input.overnightPriceCents, - input.depositCents, input.minimumMinutes, input.maxAdvanceStartMinutes, + input.fullDayPriceCents ?? 0, input.minimumSpendCents ?? 0, input.depositCents, + input.minimumMinutes, input.maxAdvanceStartMinutes, input.maxAdvanceDays, input.configurationStatus, input.operationalStatus, JSON.stringify(input.tags), JSON.stringify(input.images), input.sortOrder, legacyStatus ]; diff --git a/backend/tests/migration-contract.test.mjs b/backend/tests/migration-contract.test.mjs index 8c68053..e32bf24 100644 --- a/backend/tests/migration-contract.test.mjs +++ b/backend/tests/migration-contract.test.mjs @@ -39,6 +39,9 @@ const discoveryVerifySql = read('database/migrations/2026061809_m03c_store_disco const accessUpSql = read('database/migrations/2026061810_m03d_scene_wifi_access.up.sql'); const accessDownSql = read('database/migrations/2026061810_m03d_scene_wifi_access.down.sql'); const accessVerifySql = read('database/migrations/2026061810_m03d_scene_wifi_access.verify.sql'); +const pricingUpSql = read('database/migrations/2026061811_m04a_pricing_reservations.up.sql'); +const pricingDownSql = read('database/migrations/2026061811_m04a_pricing_reservations.down.sql'); +const pricingVerifySql = read('database/migrations/2026061811_m04a_pricing_reservations.verify.sql'); const coreTables = [ 'qipai_schema_migrations', @@ -183,5 +186,15 @@ for (const table of [ assert.match(accessUpSql, /scan_count BIGINT UNSIGNED/); assert.match(accessUpSql, /target_type = 'STORE'/); assert.match(accessUpSql, /PRIMARY KEY \(tenant_id, order_id, user_id\)/); +for (const table of [ + 'qipai_holiday_calendar', 'qipai_order_price_snapshots', 'qipai_room_reservations' +]) { + assert.match(pricingUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`)); + assert.match(pricingDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`)); + assert.match(pricingVerifySql, new RegExp(`'${table}'`)); +} +assert.match(pricingUpSql, /full_day_price_cents/); +assert.match(pricingUpSql, /minimum_spend_cents/); +assert.match(pricingUpSql, /idx_qipai_reservation_overlap/); -console.log('PASS: M01-B through M03-D migration contracts are present.'); +console.log('PASS: M01-B through M04-A migration contracts are present.'); diff --git a/backend/tests/migration-runner.test.mjs b/backend/tests/migration-runner.test.mjs index 2d91f95..73712fa 100644 --- a/backend/tests/migration-runner.test.mjs +++ b/backend/tests/migration-runner.test.mjs @@ -21,7 +21,8 @@ assert.match(plan.file, /2026061806_m02d_user_management\.up\.sql/); assert.match(plan.file, /2026061807_m03a_store_room_domain\.up\.sql/); 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, /2026061810_m03d_scene_wifi_access\.up\.sql/); +assert.match(plan.file, /2026061811_m04a_pricing_reservations\.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 cc39de0..1d0403c 100644 --- a/backend/tests/mysql-migration-roundtrip.test.mjs +++ b/backend/tests/mysql-migration-roundtrip.test.mjs @@ -17,6 +17,7 @@ import { StoreRoomRepository, StoreRoomError } from '../dist/stores/store-room-r import { ContentRepository, ContentError } from '../dist/content/content-repository.js'; import { StoreDiscoveryRepository } from '../dist/stores/store-discovery-repository.js'; import { StoreAccessRepository, StoreAccessError } from '../dist/stores/access-repository.js'; +import { PricingRepository, PricingError } from '../dist/orders/pricing-repository.js'; import { executeMigrationPlan, loadMigrationPlan, @@ -29,9 +30,11 @@ const expectedTables = [ 'qipai_audit_logs', 'qipai_auth_sessions', 'qipai_devices', + 'qipai_holiday_calendar', 'qipai_legacy_table_mappings', 'qipai_media_assets', 'qipai_members', + 'qipai_order_price_snapshots', 'qipai_order_user_access', 'qipai_orders', 'qipai_outbox_events', @@ -42,6 +45,7 @@ const expectedTables = [ 'qipai_roles', 'qipai_room_categories', 'qipai_room_disabled_periods', + 'qipai_room_reservations', 'qipai_rooms', 'qipai_scene_codes', 'qipai_scene_scan_events', @@ -77,11 +81,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'] + '2026061810', '2026061811'] ); return rows; } @@ -527,6 +531,102 @@ async function assertSceneAndWifiAccess(pool, context) { assert.doesNotMatch(auditRows[0].metadata, /sanitized-password/); } +async function assertPricingAndReservations(pool, context) { + 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 customerId = String(customerRows[0].id); + const roomId = String(targetRows[0].roomId); + await pool.query( + `UPDATE qipai_rooms SET base_price_cents = 1200, weekday_price_cents = 1000, + holiday_price_cents = 1800, overnight_price_cents = 5000, + full_day_price_cents = 9000, minimum_spend_cents = 2500, + deposit_cents = 500, minimum_minutes = 60, max_advance_days = 30, + configuration_status = 'ENABLED', operational_status = 'AVAILABLE' + WHERE tenant_id = ? AND id = ?`, + [context.tenantId, roomId] + ); + const startAt = new Date(Date.now() + 5 * 86400000); + startAt.setUTCHours(2, 0, 0, 0); + const endAt = new Date(startAt.getTime() + 2 * 3600000); + const holidayDate = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' + }).format(startAt); + await pool.query( + `INSERT INTO qipai_holiday_calendar (tenant_id, holiday_date, name) + VALUES (?, ?, 'M04-A Test Holiday')`, + [context.tenantId, holidayDate] + ); + const repository = new PricingRepository(pool); + const quote = await repository.quote({ + tenantId: context.tenantId, + roomId, + startAt, + endAt, + pricingMode: 'HOURLY', + adjustment: { discountCents: 300, packageCreditCents: 200 } + }); + assert.equal(quote.rules.priceSource, 'holiday'); + assert.equal(quote.subtotalCents, 3600); + assert.equal(quote.totalCents, 3600); + + const attempts = await Promise.allSettled([ + repository.reserve({ + tenantId: context.tenantId, userId: customerId, roomId, + startAt, endAt, pricingMode: 'HOURLY' + }), + repository.reserve({ + tenantId: context.tenantId, userId: customerId, roomId, + startAt, endAt, pricingMode: 'HOURLY' + }) + ]); + assert.equal(attempts.filter((item) => item.status === 'fulfilled').length, 1); + assert.equal(attempts.filter((item) => + item.status === 'rejected' + && item.reason instanceof PricingError + && item.reason.code === 'TIME_SLOT_CONFLICT' + ).length, 1); + const first = attempts.find((item) => item.status === 'fulfilled').value; + const [snapshotBefore] = await pool.query( + `SELECT total_cents AS totalCents, rules + FROM qipai_order_price_snapshots WHERE tenant_id = ? AND order_id = ?`, + [context.tenantId, first.orderId] + ); + await pool.query( + `UPDATE qipai_rooms SET holiday_price_cents = 9900 + WHERE tenant_id = ? AND id = ?`, + [context.tenantId, roomId] + ); + const [snapshotAfter] = await pool.query( + `SELECT total_cents AS totalCents, rules + FROM qipai_order_price_snapshots WHERE tenant_id = ? AND order_id = ?`, + [context.tenantId, first.orderId] + ); + assert.deepEqual(snapshotAfter, snapshotBefore); + await pool.query( + `UPDATE qipai_room_reservations SET expires_at = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND) + WHERE tenant_id = ? AND order_id = ?`, + [context.tenantId, first.orderId] + ); + assert.equal((await repository.releaseExpired(context.tenantId, roomId)).released, 1); + const replacement = await repository.reserve({ + tenantId: context.tenantId, userId: customerId, roomId, + startAt, endAt, pricingMode: 'FULL_DAY' + }); + assert.equal(replacement.quote.unitPriceCents, 9000); +} + async function assertContentManagement(pool, context) { const [adminRows] = await pool.query( `SELECT u.id FROM qipai_users u @@ -624,7 +724,8 @@ try { { version: '2026061807', name: 'm03a_store_room_domain' }, { version: '2026061808', name: 'm03b_decoration_ads_media' }, { version: '2026061809', name: 'm03c_store_discovery' }, - { version: '2026061810', name: 'm03d_scene_wifi_access' } + { version: '2026061810', name: 'm03d_scene_wifi_access' }, + { version: '2026061811', name: 'm04a_pricing_reservations' } ]); await assertTaskDurability(pool); const loginContext = await assertPlatformTenantIsolation(pool); @@ -634,13 +735,14 @@ try { await assertContentManagement(pool, loginContext); await assertStoreDiscovery(pool, loginContext); await assertSceneAndWifiAccess(pool, loginContext); + await assertPricingAndReservations(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/M01-C tables.'); + console.log('PASS: down removed all M01-B through M04-A tables.'); await executeMigrationPlan(pool, plans.up); await executeMigrationPlan(pool, plans.verify); @@ -655,7 +757,8 @@ try { { version: '2026061807', name: 'm03a_store_room_domain' }, { version: '2026061808', name: 'm03b_decoration_ads_media' }, { version: '2026061809', name: 'm03c_store_discovery' }, - { version: '2026061810', name: 'm03d_scene_wifi_access' } + { version: '2026061810', name: 'm03d_scene_wifi_access' }, + { version: '2026061811', name: 'm04a_pricing_reservations' } ]); await assertLegacyCompatibility(pool); console.log('PASS: second up and verify restored the schema.'); @@ -709,7 +812,11 @@ try { 'scene scan statistics', 'Wi-Fi denied without active order', 'Wi-Fi allowed by active order grant', - 'Wi-Fi audit excludes password' + 'Wi-Fi audit excludes password', + 'holiday and minimum-spend pricing', + 'immutable order price snapshot', + 'concurrent room hold conflict', + 'expired hold release' ] }, null, 2)); } finally { diff --git a/backend/tests/pricing.test.mjs b/backend/tests/pricing.test.mjs new file mode 100644 index 0000000..944389a --- /dev/null +++ b/backend/tests/pricing.test.mjs @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import { buildApp } from '../dist/app.js'; +import { signAccessToken } from '../dist/auth/jwt.js'; + +const secret = 'test-only-pricing-jwt-secret-with-32-characters'; +const token = signAccessToken({ + sub: '21', + sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', + tid: '7', + aid: '9', + rv: 1 +}, secret, 900); +let reserveInput; +const app = await buildApp({ + pricing: { + jwtSecret: secret, + 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: 'CUSTOMER', + status: 'ACTIVE', + roleVersion: 1, + nickname: '', + avatarUrl: '', + phone: '' + } + }; + } + }, + accessControl: { + async getAccessProfile() { + return { roles: ['CUSTOMER'], capabilities: [], storeIds: [] }; + } + }, + repository: { + async quote() { + return { totalCents: 2500, depositCents: 500 }; + }, + async reserve(input) { + reserveInput = input; + return { orderId: '31', reservationId: '41', quote: { totalCents: 2500 } }; + }, + async releaseExpired() { + return { released: 0 }; + } + } + } +}); + +const startAt = new Date(Date.now() + 3600000); +const endAt = new Date(startAt.getTime() + 7200000); +const reserved = await app.inject({ + method: 'POST', + url: '/app-api/orders/reserve', + headers: { authorization: `Bearer ${token}` }, + payload: { + roomId: '11', + startAt: startAt.toISOString(), + endAt: endAt.toISOString(), + pricingMode: 'HOURLY', + totalCents: 1, + discountCents: 999999 + } +}); +assert.equal(reserved.statusCode, 201); +assert.equal(reserveInput.tenantId, '7'); +assert.equal(reserveInput.userId, '21'); +assert.equal('totalCents' in reserveInput, false); +assert.equal('discountCents' in reserveInput, false); + +const unauthenticated = await app.inject({ + method: 'POST', + url: '/app-api/pricing/quote', + payload: { + roomId: '11', + startAt: startAt.toISOString(), + endAt: endAt.toISOString() + } +}); +assert.equal(unauthenticated.statusCode, 401); +await app.close(); + +console.log('PASS: M04-A pricing routes ignore client totals and require an authenticated session.'); diff --git a/database/migrations/2026061811_m04a_pricing_reservations.down.sql b/database/migrations/2026061811_m04a_pricing_reservations.down.sql new file mode 100644 index 0000000..d79ac43 --- /dev/null +++ b/database/migrations/2026061811_m04a_pricing_reservations.down.sql @@ -0,0 +1,8 @@ +DELETE FROM qipai_schema_migrations WHERE version = '2026061811'; +DROP TABLE IF EXISTS qipai_room_reservations; +DROP TABLE IF EXISTS qipai_order_price_snapshots; +DROP TABLE IF EXISTS qipai_holiday_calendar; +ALTER TABLE qipai_orders DROP COLUMN hold_expires_at; +ALTER TABLE qipai_rooms + DROP COLUMN minimum_spend_cents, + DROP COLUMN full_day_price_cents; diff --git a/database/migrations/2026061811_m04a_pricing_reservations.up.sql b/database/migrations/2026061811_m04a_pricing_reservations.up.sql new file mode 100644 index 0000000..c41333d --- /dev/null +++ b/database/migrations/2026061811_m04a_pricing_reservations.up.sql @@ -0,0 +1,60 @@ +ALTER TABLE qipai_rooms + ADD COLUMN full_day_price_cents INT UNSIGNED NOT NULL DEFAULT 0 AFTER overnight_price_cents, + ADD COLUMN minimum_spend_cents INT UNSIGNED NOT NULL DEFAULT 0 AFTER full_day_price_cents; + +ALTER TABLE qipai_orders + ADD COLUMN hold_expires_at DATETIME(3) NULL AFTER end_at; + +CREATE TABLE IF NOT EXISTS qipai_holiday_calendar ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + holiday_date DATE NOT NULL, + name VARCHAR(128) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + CONSTRAINT fk_qipai_holiday_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + UNIQUE KEY uq_qipai_holiday_date (tenant_id, holiday_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qipai_order_price_snapshots ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + order_id BIGINT UNSIGNED NOT NULL, + currency CHAR(3) NOT NULL DEFAULT 'CNY', + pricing_mode VARCHAR(16) NOT NULL, + duration_minutes INT UNSIGNED NOT NULL, + unit_price_cents INT UNSIGNED NOT NULL, + subtotal_cents INT UNSIGNED NOT NULL, + minimum_spend_cents INT UNSIGNED NOT NULL DEFAULT 0, + deposit_cents INT UNSIGNED NOT NULL DEFAULT 0, + discount_cents INT UNSIGNED NOT NULL DEFAULT 0, + package_credit_cents INT UNSIGNED NOT NULL DEFAULT 0, + total_cents INT UNSIGNED NOT NULL, + rules JSON NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + CONSTRAINT fk_qipai_price_snapshot_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_price_snapshot_order FOREIGN KEY (order_id) REFERENCES qipai_orders(id), + UNIQUE KEY uq_qipai_price_snapshot_order (tenant_id, order_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qipai_room_reservations ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + order_id BIGINT UNSIGNED NOT NULL, + room_id BIGINT UNSIGNED NOT NULL, + starts_at DATETIME(3) NOT NULL, + ends_at DATETIME(3) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'HELD', + expires_at DATETIME(3) NOT NULL, + released_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + CONSTRAINT fk_qipai_reservation_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_reservation_order FOREIGN KEY (order_id) REFERENCES qipai_orders(id), + CONSTRAINT fk_qipai_reservation_room FOREIGN KEY (room_id) REFERENCES qipai_rooms(id), + UNIQUE KEY uq_qipai_reservation_order (tenant_id, order_id), + KEY idx_qipai_reservation_overlap (tenant_id, room_id, status, starts_at, ends_at), + KEY idx_qipai_reservation_expiry (status, expires_at), + CONSTRAINT chk_qipai_reservation_window CHECK (ends_at > starts_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO qipai_schema_migrations (version, name) +VALUES ('2026061811', 'm04a_pricing_reservations'); diff --git a/database/migrations/2026061811_m04a_pricing_reservations.verify.sql b/database/migrations/2026061811_m04a_pricing_reservations.verify.sql new file mode 100644 index 0000000..32f71c2 --- /dev/null +++ b/database/migrations/2026061811_m04a_pricing_reservations.verify.sql @@ -0,0 +1,27 @@ +SELECT column_name FROM information_schema.columns +WHERE table_schema = DATABASE() AND table_name = 'qipai_rooms' + AND column_name IN ('full_day_price_cents', 'minimum_spend_cents') +ORDER BY column_name; + +SELECT column_name FROM information_schema.columns +WHERE table_schema = DATABASE() AND table_name = 'qipai_orders' + AND column_name = 'hold_expires_at'; + +SELECT table_name FROM information_schema.tables +WHERE table_schema = DATABASE() + AND table_name IN ( + 'qipai_holiday_calendar', + 'qipai_order_price_snapshots', + 'qipai_room_reservations' + ) +ORDER BY table_name; + +SELECT table_name, index_name FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND ((table_name = 'qipai_order_price_snapshots' + AND index_name = 'uq_qipai_price_snapshot_order') + OR (table_name = 'qipai_room_reservations' + AND index_name IN ('idx_qipai_reservation_overlap', 'idx_qipai_reservation_expiry'))) +GROUP BY table_name, index_name ORDER BY table_name, index_name; + +SELECT version, name FROM qipai_schema_migrations WHERE version = '2026061811'; diff --git a/docs/api-changelog/2026-06-18-M04-A-pricing-reservations.md b/docs/api-changelog/2026-06-18-M04-A-pricing-reservations.md new file mode 100644 index 0000000..942e2fc --- /dev/null +++ b/docs/api-changelog/2026-06-18-M04-A-pricing-reservations.md @@ -0,0 +1,7 @@ +# M04-A 定价与时段预占 API + +- `POST /app-api/pricing/quote`:按房间配置、节假日和定价模式返回服务端报价。 +- `POST /app-api/orders/reserve`:事务内锁定房间、检查重叠并创建待支付订单、价格快照和限时预占。 +- `POST /admin-api/reservations/release-expired`:租户管理员释放已过期待支付预占。 + +客户端提交的最终金额、折扣或套餐抵扣字段会被忽略。优惠和套餐抵扣只允许后续 M05 的可信服务端权益模块传入定价引擎。 diff --git a/docs/db-changelog/2026-06-18-M04-A-pricing-reservations.md b/docs/db-changelog/2026-06-18-M04-A-pricing-reservations.md new file mode 100644 index 0000000..c2ffbf2 --- /dev/null +++ b/docs/db-changelog/2026-06-18-M04-A-pricing-reservations.md @@ -0,0 +1,10 @@ +# M04-A 定价与预占数据库变更 + +- 迁移版本:`2026061811` +- 房间新增包场价和最低消费。 +- 订单新增预占过期时间。 +- `qipai_holiday_calendar`:租户节假日日历。 +- `qipai_order_price_snapshots`:不可变订单价格快照。 +- `qipai_room_reservations`:房间时段预占、过期和释放状态。 + +并发控制使用事务锁定房间行,再检查重叠预占;不引入 Redis。 diff --git a/docs/devlogs/2026-06-18-M04-A-定价与时段预占.md b/docs/devlogs/2026-06-18-M04-A-定价与时段预占.md new file mode 100644 index 0000000..166c25d --- /dev/null +++ b/docs/devlogs/2026-06-18-M04-A-定价与时段预占.md @@ -0,0 +1,27 @@ +# M04-A 定价引擎和可用性 + +- 日期:2026-06-18 +- 起始 commit:`6be7fa7` +- 工程 commit:本阶段工程提交 +- ENGINEERING_DELTA=YES +- 子阶段状态:待 push 与远端校验 + +## 工程增量 + +- 统一小时、工作日、节假日、通宵和包场定价。 +- 支持最低消费、押金及可信服务端优惠/套餐抵扣。 +- 创建订单时保存独立价格快照。 +- 房间行锁串行化并发预占,事务内检查重叠。 +- 待支付预占带过期时间,报价、预占和管理入口均可释放过期占位。 +- 客户端最终金额、优惠和套餐字段不进入定价输入。 + +## 验证 + +- Windows 后端全量测试通过。 +- WSL MySQL 8.4.9 往返迁移通过。 +- 迁移语句:up 56、verify 37、down 53。 +- 实测节假日/最低消费定价、历史快照不变、同房同时段并发仅一个成功、过期释放后可重新预占。 + +## 后续 + +M04-B 建立完整订单状态机、状态迁移历史和来源/原因审计。 diff --git a/scripts/dev/wsl/mysql-migration-roundtrip.sh b/scripts/dev/wsl/mysql-migration-roundtrip.sh index 1b9d7a2..fb8cef1 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 M03-D migration roundtrip in a temporary database." +echo "INFO: MySQL ${mysql_version}; running M01-B through M04-A migration roundtrip in a temporary database." npm --prefix backend run test:mysql:migration -echo "PASS: M01-B through M03-D live MySQL migration roundtrip completed." +echo "PASS: M01-B through M04-A live MySQL migration roundtrip completed."