feat(M04-A): 完成定价快照与并发时段预占
This commit is contained in:
@@ -17,7 +17,7 @@
|
|||||||
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
"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",
|
"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: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": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
registerStoreAccessRoutes,
|
registerStoreAccessRoutes,
|
||||||
type StoreAccessRouteOptions
|
type StoreAccessRouteOptions
|
||||||
} from './routes/store-access.js';
|
} from './routes/store-access.js';
|
||||||
|
import { registerPricingRoutes, type PricingRouteOptions } from './routes/pricing.js';
|
||||||
|
|
||||||
export interface BuildAppOptions {
|
export interface BuildAppOptions {
|
||||||
config?: AppConfig;
|
config?: AppConfig;
|
||||||
@@ -39,6 +40,7 @@ export interface BuildAppOptions {
|
|||||||
content?: ContentRouteOptions;
|
content?: ContentRouteOptions;
|
||||||
storeDiscovery?: StoreDiscoveryRouteOptions;
|
storeDiscovery?: StoreDiscoveryRouteOptions;
|
||||||
storeAccess?: StoreAccessRouteOptions;
|
storeAccess?: StoreAccessRouteOptions;
|
||||||
|
pricing?: PricingRouteOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
@@ -102,6 +104,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||||||
if (options.storeAccess) {
|
if (options.storeAccess) {
|
||||||
await registerStoreAccessRoutes(app, options.storeAccess);
|
await registerStoreAccessRoutes(app, options.storeAccess);
|
||||||
}
|
}
|
||||||
|
if (options.pricing) {
|
||||||
|
await registerPricingRoutes(app, options.pricing);
|
||||||
|
}
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
|||||||
'database/migrations/2026061807_m03a_store_room_domain.up.sql',
|
'database/migrations/2026061807_m03a_store_room_domain.up.sql',
|
||||||
'database/migrations/2026061808_m03b_decoration_ads_media.up.sql',
|
'database/migrations/2026061808_m03b_decoration_ads_media.up.sql',
|
||||||
'database/migrations/2026061809_m03c_store_discovery.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: [
|
verify: [
|
||||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||||
@@ -42,9 +43,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
|||||||
'database/migrations/2026061807_m03a_store_room_domain.verify.sql',
|
'database/migrations/2026061807_m03a_store_room_domain.verify.sql',
|
||||||
'database/migrations/2026061808_m03b_decoration_ads_media.verify.sql',
|
'database/migrations/2026061808_m03b_decoration_ads_media.verify.sql',
|
||||||
'database/migrations/2026061809_m03c_store_discovery.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: [
|
down: [
|
||||||
|
'database/migrations/2026061811_m04a_pricing_reservations.down.sql',
|
||||||
'database/migrations/2026061810_m03d_scene_wifi_access.down.sql',
|
'database/migrations/2026061810_m03d_scene_wifi_access.down.sql',
|
||||||
'database/migrations/2026061809_m03c_store_discovery.down.sql',
|
'database/migrations/2026061809_m03c_store_discovery.down.sql',
|
||||||
'database/migrations/2026061808_m03b_decoration_ads_media.down.sql',
|
'database/migrations/2026061808_m03b_decoration_ads_media.down.sql',
|
||||||
@@ -180,7 +183,8 @@ export async function executeMigrationPlan(
|
|||||||
3, 6, 13, 1,
|
3, 6, 13, 1,
|
||||||
3, 3, 1,
|
3, 3, 1,
|
||||||
2, 2, 1,
|
2, 2, 1,
|
||||||
3, 3, 1
|
3, 3, 1,
|
||||||
|
2, 1, 3, 3, 1
|
||||||
][index] ?? 1;
|
][index] ?? 1;
|
||||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -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<ResultSetHeader>(
|
||||||
|
`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<ResultSetHeader>(
|
||||||
|
`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<MySqlPool, 'execute'> | 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<CountRow[]>(
|
||||||
|
`SELECT COUNT(*) AS total FROM qipai_room_reservations r
|
||||||
|
WHERE ${filters.join(' AND ')}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
await connection.execute<ResultSetHeader>(
|
||||||
|
`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<MySqlPool, 'execute'> | PoolConnection,
|
||||||
|
tenantId: string,
|
||||||
|
roomId: string,
|
||||||
|
lock = false
|
||||||
|
) {
|
||||||
|
const [rows] = await connection.execute<RoomPricingRow[]>(
|
||||||
|
`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<MySqlPool, 'execute'> | PoolConnection,
|
||||||
|
input: QuoteInput,
|
||||||
|
lock: boolean
|
||||||
|
) {
|
||||||
|
const [disabled] = await connection.execute<CountRow[]>(
|
||||||
|
`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<CountRow[]>(
|
||||||
|
`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<MySqlPool, 'execute'> | PoolConnection,
|
||||||
|
tenantId: string,
|
||||||
|
date: Date,
|
||||||
|
timezone: string
|
||||||
|
) {
|
||||||
|
const [rows] = await connection.execute<CountRow[]>(
|
||||||
|
`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<T>(work: (connection: PoolConnection) => Promise<T>) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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<PricingRepository, 'quote' | 'reserve' | 'releaseExpired'>;
|
||||||
|
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||||
|
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||||
|
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<unknown>) {
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -44,6 +44,8 @@ const roomSchema = z.object({
|
|||||||
weekdayPriceCents: z.number().int().min(0).max(10000000),
|
weekdayPriceCents: z.number().int().min(0).max(10000000),
|
||||||
holidayPriceCents: z.number().int().min(0).max(10000000),
|
holidayPriceCents: z.number().int().min(0).max(10000000),
|
||||||
overnightPriceCents: 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),
|
depositCents: z.number().int().min(0).max(10000000),
|
||||||
minimumMinutes: z.number().int().min(15).max(1440),
|
minimumMinutes: z.number().int().min(15).max(1440),
|
||||||
maxAdvanceStartMinutes: z.number().int().min(0).max(1440),
|
maxAdvanceStartMinutes: z.number().int().min(0).max(1440),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { MediaStorage } from './content/media-storage.js';
|
|||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { StoreDiscoveryRepository } from './stores/store-discovery-repository.js';
|
import { StoreDiscoveryRepository } from './stores/store-discovery-repository.js';
|
||||||
import { StoreAccessRepository } from './stores/access-repository.js';
|
import { StoreAccessRepository } from './stores/access-repository.js';
|
||||||
|
import { PricingRepository } from './orders/pricing-repository.js';
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const pool = createMySqlPool(config);
|
const pool = createMySqlPool(config);
|
||||||
@@ -56,6 +57,12 @@ const app = await buildApp({
|
|||||||
authRepository,
|
authRepository,
|
||||||
accessControl,
|
accessControl,
|
||||||
jwtSecret: config.auth.jwtSecret
|
jwtSecret: config.auth.jwtSecret
|
||||||
|
},
|
||||||
|
pricing: {
|
||||||
|
repository: new PricingRepository(pool),
|
||||||
|
authRepository,
|
||||||
|
accessControl,
|
||||||
|
jwtSecret: config.auth.jwtSecret
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
app.addHook('onClose', async () => {
|
app.addHook('onClose', async () => {
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export interface RoomInput {
|
|||||||
weekdayPriceCents: number;
|
weekdayPriceCents: number;
|
||||||
holidayPriceCents: number;
|
holidayPriceCents: number;
|
||||||
overnightPriceCents: number;
|
overnightPriceCents: number;
|
||||||
|
fullDayPriceCents: number;
|
||||||
|
minimumSpendCents: number;
|
||||||
depositCents: number;
|
depositCents: number;
|
||||||
minimumMinutes: number;
|
minimumMinutes: number;
|
||||||
maxAdvanceStartMinutes: number;
|
maxAdvanceStartMinutes: number;
|
||||||
@@ -56,7 +58,8 @@ interface StoreRow extends RowDataPacket {
|
|||||||
interface RoomRow extends RowDataPacket {
|
interface RoomRow extends RowDataPacket {
|
||||||
id: string; storeId: string; categoryName: string; name: string; roomNo: string; capacity: number;
|
id: string; storeId: string; categoryName: string; name: string; roomNo: string; capacity: number;
|
||||||
basePriceCents: number; weekdayPriceCents: number; holidayPriceCents: 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;
|
maxAdvanceStartMinutes: number; maxAdvanceDays: number; configurationStatus: string;
|
||||||
operationalStatus: string; tags: string | string[] | null; images: string | string[] | null;
|
operationalStatus: string; tags: string | string[] | null; images: string | string[] | null;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
@@ -155,7 +158,10 @@ export class StoreRoomRepository {
|
|||||||
r.name, r.room_no AS roomNo, r.capacity,
|
r.name, r.room_no AS roomNo, r.capacity,
|
||||||
r.base_price_cents AS basePriceCents, r.weekday_price_cents AS weekdayPriceCents,
|
r.base_price_cents AS basePriceCents, r.weekday_price_cents AS weekdayPriceCents,
|
||||||
r.holiday_price_cents AS holidayPriceCents,
|
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.minimum_minutes AS minimumMinutes,
|
||||||
r.max_advance_start_minutes AS maxAdvanceStartMinutes,
|
r.max_advance_start_minutes AS maxAdvanceStartMinutes,
|
||||||
r.max_advance_days AS maxAdvanceDays,
|
r.max_advance_days AS maxAdvanceDays,
|
||||||
@@ -185,10 +191,11 @@ export class StoreRoomRepository {
|
|||||||
const [result] = await connection.execute<ResultSetHeader>(
|
const [result] = await connection.execute<ResultSetHeader>(
|
||||||
`INSERT INTO qipai_rooms
|
`INSERT INTO qipai_rooms
|
||||||
(tenant_id, store_id, category_id, name, room_no, capacity, base_price_cents,
|
(tenant_id, store_id, category_id, name, room_no, capacity, base_price_cents,
|
||||||
weekday_price_cents, holiday_price_cents, overnight_price_cents, deposit_cents,
|
weekday_price_cents, holiday_price_cents, overnight_price_cents,
|
||||||
minimum_minutes, max_advance_start_minutes, max_advance_days,
|
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)
|
configuration_status, operational_status, tags, images, sort_order, status)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
roomParams(actor.tenantId, categoryId, input)
|
roomParams(actor.tenantId, categoryId, input)
|
||||||
);
|
);
|
||||||
const roomId = String(result.insertId);
|
const roomId = String(result.insertId);
|
||||||
@@ -207,7 +214,8 @@ export class StoreRoomRepository {
|
|||||||
await connection.execute(
|
await connection.execute(
|
||||||
`UPDATE qipai_rooms SET category_id = ?, name = ?, room_no = ?, capacity = ?,
|
`UPDATE qipai_rooms SET category_id = ?, name = ?, room_no = ?, capacity = ?,
|
||||||
base_price_cents = ?, weekday_price_cents = ?, holiday_price_cents = ?,
|
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 = ?,
|
max_advance_start_minutes = ?, max_advance_days = ?, configuration_status = ?,
|
||||||
operational_status = ?, tags = ?, images = ?, sort_order = ?, status = ?
|
operational_status = ?, tags = ?, images = ?, sort_order = ?, status = ?
|
||||||
WHERE tenant_id = ? AND store_id = ? AND id = ?`,
|
WHERE tenant_id = ? AND store_id = ? AND id = ?`,
|
||||||
@@ -369,7 +377,8 @@ function roomParams(tenantId: string, categoryId: string, input: RoomInput) {
|
|||||||
return [
|
return [
|
||||||
tenantId, input.storeId, categoryId, input.name, input.roomNo, input.capacity,
|
tenantId, input.storeId, categoryId, input.name, input.roomNo, input.capacity,
|
||||||
input.basePriceCents, input.weekdayPriceCents, input.holidayPriceCents,
|
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.maxAdvanceStartMinutes, input.maxAdvanceDays, input.configurationStatus,
|
||||||
input.operationalStatus, JSON.stringify(input.tags), JSON.stringify(input.images),
|
input.operationalStatus, JSON.stringify(input.tags), JSON.stringify(input.images),
|
||||||
input.sortOrder, legacyStatus
|
input.sortOrder, legacyStatus
|
||||||
@@ -382,7 +391,8 @@ function roomUpdateParams(categoryId: string, input: RoomInput) {
|
|||||||
return [
|
return [
|
||||||
categoryId, input.name, input.roomNo, input.capacity, input.basePriceCents,
|
categoryId, input.name, input.roomNo, input.capacity, input.basePriceCents,
|
||||||
input.weekdayPriceCents, input.holidayPriceCents, input.overnightPriceCents,
|
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,
|
input.maxAdvanceDays, input.configurationStatus, input.operationalStatus,
|
||||||
JSON.stringify(input.tags), JSON.stringify(input.images), input.sortOrder, legacyStatus
|
JSON.stringify(input.tags), JSON.stringify(input.images), input.sortOrder, legacyStatus
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 accessUpSql = read('database/migrations/2026061810_m03d_scene_wifi_access.up.sql');
|
||||||
const accessDownSql = read('database/migrations/2026061810_m03d_scene_wifi_access.down.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 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 = [
|
const coreTables = [
|
||||||
'qipai_schema_migrations',
|
'qipai_schema_migrations',
|
||||||
@@ -183,5 +186,15 @@ for (const table of [
|
|||||||
assert.match(accessUpSql, /scan_count BIGINT UNSIGNED/);
|
assert.match(accessUpSql, /scan_count BIGINT UNSIGNED/);
|
||||||
assert.match(accessUpSql, /target_type = 'STORE'/);
|
assert.match(accessUpSql, /target_type = 'STORE'/);
|
||||||
assert.match(accessUpSql, /PRIMARY KEY \(tenant_id, order_id, user_id\)/);
|
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.');
|
||||||
|
|||||||
@@ -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, /2026061807_m03a_store_room_domain\.up\.sql/);
|
||||||
assert.match(plan.file, /2026061808_m03b_decoration_ads_media\.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, /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.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||||
assert.ok(plan.statements.length >= 11);
|
assert.ok(plan.statements.length >= 11);
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { StoreRoomRepository, StoreRoomError } from '../dist/stores/store-room-r
|
|||||||
import { ContentRepository, ContentError } from '../dist/content/content-repository.js';
|
import { ContentRepository, ContentError } from '../dist/content/content-repository.js';
|
||||||
import { StoreDiscoveryRepository } from '../dist/stores/store-discovery-repository.js';
|
import { StoreDiscoveryRepository } from '../dist/stores/store-discovery-repository.js';
|
||||||
import { StoreAccessRepository, StoreAccessError } from '../dist/stores/access-repository.js';
|
import { StoreAccessRepository, StoreAccessError } from '../dist/stores/access-repository.js';
|
||||||
|
import { PricingRepository, PricingError } from '../dist/orders/pricing-repository.js';
|
||||||
import {
|
import {
|
||||||
executeMigrationPlan,
|
executeMigrationPlan,
|
||||||
loadMigrationPlan,
|
loadMigrationPlan,
|
||||||
@@ -29,9 +30,11 @@ const expectedTables = [
|
|||||||
'qipai_audit_logs',
|
'qipai_audit_logs',
|
||||||
'qipai_auth_sessions',
|
'qipai_auth_sessions',
|
||||||
'qipai_devices',
|
'qipai_devices',
|
||||||
|
'qipai_holiday_calendar',
|
||||||
'qipai_legacy_table_mappings',
|
'qipai_legacy_table_mappings',
|
||||||
'qipai_media_assets',
|
'qipai_media_assets',
|
||||||
'qipai_members',
|
'qipai_members',
|
||||||
|
'qipai_order_price_snapshots',
|
||||||
'qipai_order_user_access',
|
'qipai_order_user_access',
|
||||||
'qipai_orders',
|
'qipai_orders',
|
||||||
'qipai_outbox_events',
|
'qipai_outbox_events',
|
||||||
@@ -42,6 +45,7 @@ const expectedTables = [
|
|||||||
'qipai_roles',
|
'qipai_roles',
|
||||||
'qipai_room_categories',
|
'qipai_room_categories',
|
||||||
'qipai_room_disabled_periods',
|
'qipai_room_disabled_periods',
|
||||||
|
'qipai_room_reservations',
|
||||||
'qipai_rooms',
|
'qipai_rooms',
|
||||||
'qipai_scene_codes',
|
'qipai_scene_codes',
|
||||||
'qipai_scene_scan_events',
|
'qipai_scene_scan_events',
|
||||||
@@ -77,11 +81,11 @@ async function readMigrationVersions(pool) {
|
|||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT version, name
|
`SELECT version, name
|
||||||
FROM qipai_schema_migrations
|
FROM qipai_schema_migrations
|
||||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ORDER BY version`,
|
ORDER BY version`,
|
||||||
['2026061601', '2026061802', '2026061803', '2026061804',
|
['2026061601', '2026061802', '2026061803', '2026061804',
|
||||||
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
||||||
'2026061810']
|
'2026061810', '2026061811']
|
||||||
);
|
);
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
@@ -527,6 +531,102 @@ async function assertSceneAndWifiAccess(pool, context) {
|
|||||||
assert.doesNotMatch(auditRows[0].metadata, /sanitized-password/);
|
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) {
|
async function assertContentManagement(pool, context) {
|
||||||
const [adminRows] = await pool.query(
|
const [adminRows] = await pool.query(
|
||||||
`SELECT u.id FROM qipai_users u
|
`SELECT u.id FROM qipai_users u
|
||||||
@@ -624,7 +724,8 @@ try {
|
|||||||
{ version: '2026061807', name: 'm03a_store_room_domain' },
|
{ version: '2026061807', name: 'm03a_store_room_domain' },
|
||||||
{ version: '2026061808', name: 'm03b_decoration_ads_media' },
|
{ version: '2026061808', name: 'm03b_decoration_ads_media' },
|
||||||
{ version: '2026061809', name: 'm03c_store_discovery' },
|
{ 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);
|
await assertTaskDurability(pool);
|
||||||
const loginContext = await assertPlatformTenantIsolation(pool);
|
const loginContext = await assertPlatformTenantIsolation(pool);
|
||||||
@@ -634,13 +735,14 @@ try {
|
|||||||
await assertContentManagement(pool, loginContext);
|
await assertContentManagement(pool, loginContext);
|
||||||
await assertStoreDiscovery(pool, loginContext);
|
await assertStoreDiscovery(pool, loginContext);
|
||||||
await assertSceneAndWifiAccess(pool, loginContext);
|
await assertSceneAndWifiAccess(pool, loginContext);
|
||||||
|
await assertPricingAndReservations(pool, loginContext);
|
||||||
await assertLegacyCompatibility(pool);
|
await assertLegacyCompatibility(pool);
|
||||||
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
|
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
|
||||||
|
|
||||||
await executeMigrationPlan(pool, plans.down);
|
await executeMigrationPlan(pool, plans.down);
|
||||||
assert.deepEqual(await readCoreTables(pool), []);
|
assert.deepEqual(await readCoreTables(pool), []);
|
||||||
await assertLegacyCompatibility(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.up);
|
||||||
await executeMigrationPlan(pool, plans.verify);
|
await executeMigrationPlan(pool, plans.verify);
|
||||||
@@ -655,7 +757,8 @@ try {
|
|||||||
{ version: '2026061807', name: 'm03a_store_room_domain' },
|
{ version: '2026061807', name: 'm03a_store_room_domain' },
|
||||||
{ version: '2026061808', name: 'm03b_decoration_ads_media' },
|
{ version: '2026061808', name: 'm03b_decoration_ads_media' },
|
||||||
{ version: '2026061809', name: 'm03c_store_discovery' },
|
{ 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);
|
await assertLegacyCompatibility(pool);
|
||||||
console.log('PASS: second up and verify restored the schema.');
|
console.log('PASS: second up and verify restored the schema.');
|
||||||
@@ -709,7 +812,11 @@ try {
|
|||||||
'scene scan statistics',
|
'scene scan statistics',
|
||||||
'Wi-Fi denied without active order',
|
'Wi-Fi denied without active order',
|
||||||
'Wi-Fi allowed by active order grant',
|
'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));
|
}, null, 2));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -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.');
|
||||||
@@ -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;
|
||||||
@@ -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');
|
||||||
@@ -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';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# M04-A 定价与时段预占 API
|
||||||
|
|
||||||
|
- `POST /app-api/pricing/quote`:按房间配置、节假日和定价模式返回服务端报价。
|
||||||
|
- `POST /app-api/orders/reserve`:事务内锁定房间、检查重叠并创建待支付订单、价格快照和限时预占。
|
||||||
|
- `POST /admin-api/reservations/release-expired`:租户管理员释放已过期待支付预占。
|
||||||
|
|
||||||
|
客户端提交的最终金额、折扣或套餐抵扣字段会被忽略。优惠和套餐抵扣只允许后续 M05 的可信服务端权益模块传入定价引擎。
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# M04-A 定价与预占数据库变更
|
||||||
|
|
||||||
|
- 迁移版本:`2026061811`
|
||||||
|
- 房间新增包场价和最低消费。
|
||||||
|
- 订单新增预占过期时间。
|
||||||
|
- `qipai_holiday_calendar`:租户节假日日历。
|
||||||
|
- `qipai_order_price_snapshots`:不可变订单价格快照。
|
||||||
|
- `qipai_room_reservations`:房间时段预占、过期和释放状态。
|
||||||
|
|
||||||
|
并发控制使用事务锁定房间行,再检查重叠预占;不引入 Redis。
|
||||||
@@ -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 建立完整订单状态机、状态迁移历史和来源/原因审计。
|
||||||
@@ -93,6 +93,6 @@ export QIPAI_MYSQL_USER="${username}"
|
|||||||
export QIPAI_MYSQL_PASSWORD="${password}"
|
export QIPAI_MYSQL_PASSWORD="${password}"
|
||||||
export QIPAI_MYSQL_CONNECTION_LIMIT=2
|
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
|
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."
|
||||||
|
|||||||
Reference in New Issue
Block a user