feat(M04-C): 完成续费换房与订单调整
This commit is contained in:
@@ -17,7 +17,7 @@
|
||||
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
||||
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
|
||||
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
|
||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs"
|
||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
|
||||
@@ -33,6 +33,9 @@ import { registerPricingRoutes, type PricingRouteOptions } from './routes/pricin
|
||||
import {
|
||||
registerOrderStateRoutes, type OrderStateRouteOptions
|
||||
} from './routes/order-state.js';
|
||||
import {
|
||||
registerOrderManagementRoutes, type OrderManagementRouteOptions
|
||||
} from './routes/order-management.js';
|
||||
|
||||
export interface BuildAppOptions {
|
||||
config?: AppConfig;
|
||||
@@ -45,6 +48,7 @@ export interface BuildAppOptions {
|
||||
storeAccess?: StoreAccessRouteOptions;
|
||||
pricing?: PricingRouteOptions;
|
||||
orderState?: OrderStateRouteOptions;
|
||||
orderManagement?: OrderManagementRouteOptions;
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
@@ -114,6 +118,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
||||
if (options.orderState) {
|
||||
await registerOrderStateRoutes(app, options.orderState);
|
||||
}
|
||||
if (options.orderManagement) {
|
||||
await registerOrderManagementRoutes(app, options.orderManagement);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026061809_m03c_store_discovery.up.sql',
|
||||
'database/migrations/2026061810_m03d_scene_wifi_access.up.sql',
|
||||
'database/migrations/2026061811_m04a_pricing_reservations.up.sql',
|
||||
'database/migrations/2026062012_m04b_order_state_machine.up.sql'
|
||||
'database/migrations/2026062012_m04b_order_state_machine.up.sql',
|
||||
'database/migrations/2026062013_m04c_order_adjustments.up.sql'
|
||||
],
|
||||
verify: [
|
||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||
@@ -46,9 +47,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026061809_m03c_store_discovery.verify.sql',
|
||||
'database/migrations/2026061810_m03d_scene_wifi_access.verify.sql',
|
||||
'database/migrations/2026061811_m04a_pricing_reservations.verify.sql',
|
||||
'database/migrations/2026062012_m04b_order_state_machine.verify.sql'
|
||||
'database/migrations/2026062012_m04b_order_state_machine.verify.sql',
|
||||
'database/migrations/2026062013_m04c_order_adjustments.verify.sql'
|
||||
],
|
||||
down: [
|
||||
'database/migrations/2026062013_m04c_order_adjustments.down.sql',
|
||||
'database/migrations/2026062012_m04b_order_state_machine.down.sql',
|
||||
'database/migrations/2026061811_m04a_pricing_reservations.down.sql',
|
||||
'database/migrations/2026061810_m03d_scene_wifi_access.down.sql',
|
||||
@@ -188,7 +191,8 @@ export async function executeMigrationPlan(
|
||||
2, 2, 1,
|
||||
3, 3, 1,
|
||||
2, 1, 3, 3, 1,
|
||||
2, 1, 3, 1
|
||||
2, 1, 3, 1,
|
||||
2, 2, 1, 2, 1
|
||||
][index] ?? 1;
|
||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||
throw new Error(
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
||||
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||
import type { MySqlPool } from '../db/mysql.js';
|
||||
import type { OrderActor } from './order-state-repository.js';
|
||||
|
||||
type PricingPolicy = 'CURRENT' | 'LOCKED';
|
||||
type AdjustableStatus = 'PENDING_PAYMENT' | 'PAID' | 'RESERVED' | 'IN_PROGRESS';
|
||||
|
||||
interface OrderRow extends RowDataPacket {
|
||||
id: string;
|
||||
storeId: string;
|
||||
roomId: string;
|
||||
status: AdjustableStatus;
|
||||
startAt: Date;
|
||||
endAt: Date;
|
||||
totalAmountCents: number;
|
||||
adjustmentAmountCents: number;
|
||||
cancellationCutoffMinutes: number;
|
||||
cancellationFeeBps: number;
|
||||
}
|
||||
interface RoomRow extends RowDataPacket {
|
||||
id: string;
|
||||
storeId: string;
|
||||
basePriceCents: number;
|
||||
weekdayPriceCents: number;
|
||||
holidayPriceCents: number;
|
||||
timezone: string;
|
||||
operationalStatus: string;
|
||||
configurationStatus: string;
|
||||
}
|
||||
interface SnapshotRow extends RowDataPacket { unitPriceCents: number }
|
||||
interface DuplicateRow extends RowDataPacket {
|
||||
id: string;
|
||||
adjustmentType: string;
|
||||
amountDeltaCents: number;
|
||||
}
|
||||
|
||||
export class OrderManagementError extends Error {
|
||||
constructor(public readonly code: string) { super(code); }
|
||||
}
|
||||
|
||||
export class OrderManagementRepository {
|
||||
constructor(private readonly pool: MySqlPool) {}
|
||||
|
||||
async renew(actor: OrderActor, orderId: string, input: {
|
||||
endAt: Date; pricingPolicy: PricingPolicy; reason: string;
|
||||
}) {
|
||||
return this.transaction(async (connection) => {
|
||||
const order = await this.loadOrder(connection, actor.tenantId, orderId);
|
||||
this.assertManager(actor.access, order.storeId);
|
||||
const duplicate = await this.duplicate(connection, actor, orderId);
|
||||
if (duplicate) return this.duplicateResult(orderId, duplicate);
|
||||
if (!['PAID', 'RESERVED', 'IN_PROGRESS'].includes(order.status)) {
|
||||
throw new OrderManagementError('ORDER_RENEW_STATUS_INVALID');
|
||||
}
|
||||
if (input.endAt <= order.endAt) throw new OrderManagementError('ORDER_RENEW_END_INVALID');
|
||||
await this.lockRooms(connection, actor.tenantId, [order.roomId]);
|
||||
await this.assertAvailable(
|
||||
connection, actor.tenantId, order.roomId, order.endAt, input.endAt, orderId
|
||||
);
|
||||
const unitPrice = await this.resolveUnitPrice(
|
||||
connection, actor.tenantId, order, input.pricingPolicy
|
||||
);
|
||||
const amountDeltaCents = Math.ceil(
|
||||
(input.endAt.getTime() - order.endAt.getTime()) / 3600000
|
||||
) * unitPrice;
|
||||
await connection.execute(
|
||||
`UPDATE qipai_orders
|
||||
SET end_at = ?, total_amount_cents = total_amount_cents + ?,
|
||||
adjustment_amount_cents = adjustment_amount_cents + ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.endAt, amountDeltaCents, amountDeltaCents, actor.tenantId, orderId]
|
||||
);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_room_reservations
|
||||
SET ends_at = ?, expires_at = GREATEST(expires_at, ?)
|
||||
WHERE tenant_id = ? AND order_id = ?`,
|
||||
[input.endAt, input.endAt, actor.tenantId, orderId]
|
||||
);
|
||||
return this.record(connection, actor, order, 'RENEW', input.reason, {
|
||||
endAt: order.endAt
|
||||
}, { endAt: input.endAt, pricingPolicy: input.pricingPolicy }, amountDeltaCents);
|
||||
});
|
||||
}
|
||||
|
||||
async changeRoom(actor: OrderActor, orderId: string, input: {
|
||||
roomId: string; reason: string;
|
||||
}) {
|
||||
return this.transaction(async (connection) => {
|
||||
const order = await this.loadOrder(connection, actor.tenantId, orderId);
|
||||
this.assertManager(actor.access, order.storeId);
|
||||
const duplicate = await this.duplicate(connection, actor, orderId);
|
||||
if (duplicate) return this.duplicateResult(orderId, duplicate);
|
||||
if (!['PENDING_PAYMENT', 'PAID', 'RESERVED', 'IN_PROGRESS'].includes(order.status)) {
|
||||
throw new OrderManagementError('ORDER_ROOM_CHANGE_STATUS_INVALID');
|
||||
}
|
||||
if (input.roomId === order.roomId) throw new OrderManagementError('ORDER_ROOM_UNCHANGED');
|
||||
await this.lockRooms(connection, actor.tenantId, [order.roomId, input.roomId]);
|
||||
const target = await this.loadRoom(connection, actor.tenantId, input.roomId);
|
||||
this.assertManager(actor.access, target.storeId);
|
||||
await this.assertAvailable(
|
||||
connection, actor.tenantId, target.id, order.startAt, order.endAt, orderId
|
||||
);
|
||||
const oldRoom = await this.loadRoom(connection, actor.tenantId, order.roomId);
|
||||
const hours = Math.ceil((order.endAt.getTime() - order.startAt.getTime()) / 3600000);
|
||||
const amountDeltaCents = hours * (target.basePriceCents - oldRoom.basePriceCents);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_orders
|
||||
SET store_id = ?, room_id = ?,
|
||||
total_amount_cents = GREATEST(0, total_amount_cents + ?),
|
||||
adjustment_amount_cents = adjustment_amount_cents + ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[target.storeId, target.id, amountDeltaCents, amountDeltaCents,
|
||||
actor.tenantId, orderId]
|
||||
);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_room_reservations SET room_id = ?
|
||||
WHERE tenant_id = ? AND order_id = ?`,
|
||||
[target.id, actor.tenantId, orderId]
|
||||
);
|
||||
return this.record(connection, actor, order, 'CHANGE_ROOM', input.reason, {
|
||||
storeId: order.storeId, roomId: order.roomId
|
||||
}, { storeId: target.storeId, roomId: target.id }, amountDeltaCents);
|
||||
});
|
||||
}
|
||||
|
||||
async adjustTime(actor: OrderActor, orderId: string, input: {
|
||||
startAt?: Date; endAt?: Date; reason: string;
|
||||
}) {
|
||||
return this.transaction(async (connection) => {
|
||||
const order = await this.loadOrder(connection, actor.tenantId, orderId);
|
||||
this.assertManager(actor.access, order.storeId);
|
||||
const duplicate = await this.duplicate(connection, actor, orderId);
|
||||
if (duplicate) return this.duplicateResult(orderId, duplicate);
|
||||
const startAt = input.startAt ?? order.startAt;
|
||||
const endAt = input.endAt ?? order.endAt;
|
||||
if (endAt <= startAt) throw new OrderManagementError('ORDER_TIME_WINDOW_INVALID');
|
||||
await this.lockRooms(connection, actor.tenantId, [order.roomId]);
|
||||
await this.assertAvailable(
|
||||
connection, actor.tenantId, order.roomId, startAt, endAt, orderId
|
||||
);
|
||||
const oldHours = Math.ceil((order.endAt.getTime() - order.startAt.getTime()) / 3600000);
|
||||
const newHours = Math.ceil((endAt.getTime() - startAt.getTime()) / 3600000);
|
||||
const unitPrice = await this.resolveUnitPrice(connection, actor.tenantId, order, 'LOCKED');
|
||||
const amountDeltaCents = (newHours - oldHours) * unitPrice;
|
||||
await connection.execute(
|
||||
`UPDATE qipai_orders SET start_at = ?, end_at = ?,
|
||||
total_amount_cents = GREATEST(0, total_amount_cents + ?),
|
||||
adjustment_amount_cents = adjustment_amount_cents + ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[startAt, endAt, amountDeltaCents, amountDeltaCents, actor.tenantId, orderId]
|
||||
);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_room_reservations SET starts_at = ?, ends_at = ?,
|
||||
expires_at = GREATEST(expires_at, ?)
|
||||
WHERE tenant_id = ? AND order_id = ?`,
|
||||
[startAt, endAt, endAt, actor.tenantId, orderId]
|
||||
);
|
||||
return this.record(connection, actor, order, 'ADJUST_TIME', input.reason, {
|
||||
startAt: order.startAt, endAt: order.endAt
|
||||
}, { startAt, endAt }, amountDeltaCents);
|
||||
});
|
||||
}
|
||||
|
||||
async note(actor: OrderActor, orderId: string, note: string) {
|
||||
return this.transaction(async (connection) => {
|
||||
const order = await this.loadOrder(connection, actor.tenantId, orderId);
|
||||
this.assertManager(actor.access, order.storeId);
|
||||
const duplicate = await this.duplicate(connection, actor, orderId);
|
||||
if (duplicate) return this.duplicateResult(orderId, duplicate);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_orders SET operator_note = ? WHERE tenant_id = ? AND id = ?`,
|
||||
[note.slice(0, 512), actor.tenantId, orderId]
|
||||
);
|
||||
return this.record(connection, actor, order, 'NOTE', note, {}, { note }, 0);
|
||||
});
|
||||
}
|
||||
|
||||
async cancellationQuote(tenantId: string, userId: string, orderId: string) {
|
||||
return this.transaction(async (connection) => {
|
||||
const order = await this.loadOrder(connection, tenantId, orderId);
|
||||
await this.assertOwner(connection, tenantId, orderId, userId);
|
||||
const minutesBeforeStart = Math.floor((order.startAt.getTime() - Date.now()) / 60000);
|
||||
const feeCents = minutesBeforeStart >= order.cancellationCutoffMinutes
|
||||
? 0 : Math.ceil(order.totalAmountCents * order.cancellationFeeBps / 10000);
|
||||
return {
|
||||
orderId,
|
||||
allowed: order.status !== 'IN_PROGRESS',
|
||||
cutoffMinutes: order.cancellationCutoffMinutes,
|
||||
feeCents,
|
||||
refundableCents: Math.max(0, order.totalAmountCents - feeCents)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async record(
|
||||
connection: PoolConnection, actor: OrderActor, order: OrderRow,
|
||||
type: string, reason: string, before: object, after: object, amountDeltaCents: number
|
||||
) {
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_order_adjustments
|
||||
(tenant_id, order_id, adjustment_type, actor_id, source, trace_id,
|
||||
reason, before_values, after_values, amount_delta_cents)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[actor.tenantId, order.id, type, actor.userId, actor.source, actor.traceId,
|
||||
reason.slice(0, 512), JSON.stringify(before), JSON.stringify(after), amountDeltaCents]
|
||||
);
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_audit_logs
|
||||
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||
trace_id, ip, user_agent, metadata)
|
||||
VALUES (?, 'USER', ?, 'ORDER_MANUALLY_ADJUSTED', 'ORDER', ?, ?, ?, ?,
|
||||
JSON_OBJECT('adjustmentType', ?, 'amountDeltaCents', ?))`,
|
||||
[actor.tenantId, actor.userId, order.id, actor.traceId, actor.ip,
|
||||
actor.userAgent.slice(0, 255), type, amountDeltaCents]
|
||||
);
|
||||
return {
|
||||
orderId: order.id, adjustmentId: String(result.insertId),
|
||||
adjustmentType: type, amountDeltaCents, idempotent: false
|
||||
};
|
||||
}
|
||||
|
||||
private async loadOrder(connection: PoolConnection, tenantId: string, orderId: string) {
|
||||
const [rows] = await connection.execute<OrderRow[]>(
|
||||
`SELECT o.id, o.store_id AS storeId, o.room_id AS roomId, o.status,
|
||||
o.start_at AS startAt, o.end_at AS endAt,
|
||||
o.total_amount_cents AS totalAmountCents,
|
||||
o.adjustment_amount_cents AS adjustmentAmountCents,
|
||||
s.cancellation_cutoff_minutes AS cancellationCutoffMinutes,
|
||||
s.cancellation_fee_bps AS cancellationFeeBps
|
||||
FROM qipai_orders o
|
||||
INNER JOIN qipai_stores s ON s.tenant_id = o.tenant_id AND s.id = o.store_id
|
||||
WHERE o.tenant_id = ? AND o.id = ? AND o.deleted_at IS NULL FOR UPDATE`,
|
||||
[tenantId, orderId]
|
||||
);
|
||||
if (!rows[0]) throw new OrderManagementError('ORDER_NOT_FOUND');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
private async loadRoom(connection: PoolConnection, tenantId: string, roomId: string) {
|
||||
const [rows] = await connection.execute<RoomRow[]>(
|
||||
`SELECT r.id, r.store_id AS storeId, r.base_price_cents AS basePriceCents,
|
||||
r.weekday_price_cents AS weekdayPriceCents,
|
||||
r.holiday_price_cents AS holidayPriceCents,
|
||||
r.operational_status AS operationalStatus,
|
||||
r.configuration_status AS configurationStatus, s.timezone
|
||||
FROM qipai_rooms r
|
||||
INNER JOIN qipai_stores s
|
||||
ON s.tenant_id = r.tenant_id AND s.id = r.store_id AND s.deleted_at IS NULL
|
||||
WHERE r.tenant_id = ? AND r.id = ? AND r.deleted_at IS NULL`,
|
||||
[tenantId, roomId]
|
||||
);
|
||||
const room = rows[0];
|
||||
if (!room) throw new OrderManagementError('ROOM_NOT_FOUND');
|
||||
if (room.operationalStatus !== 'AVAILABLE' || room.configurationStatus !== 'ENABLED') {
|
||||
throw new OrderManagementError('ROOM_NOT_AVAILABLE');
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
private async lockRooms(connection: PoolConnection, tenantId: string, roomIds: string[]) {
|
||||
const sorted = [...new Set(roomIds)].sort((a, b) => Number(a) - Number(b));
|
||||
for (const roomId of sorted) {
|
||||
await connection.execute(
|
||||
`SELECT id FROM qipai_rooms WHERE tenant_id = ? AND id = ? FOR UPDATE`,
|
||||
[tenantId, roomId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertAvailable(
|
||||
connection: PoolConnection, tenantId: string, roomId: string,
|
||||
startAt: Date, endAt: Date, excludingOrderId: string
|
||||
) {
|
||||
const [disabled] = await connection.execute<RowDataPacket[]>(
|
||||
`SELECT id FROM qipai_room_disabled_periods
|
||||
WHERE tenant_id = ? AND room_id = ? AND starts_at < ? AND ends_at > ? FOR UPDATE`,
|
||||
[tenantId, roomId, endAt, startAt]
|
||||
);
|
||||
if (disabled[0]) throw new OrderManagementError('ROOM_DISABLED_PERIOD');
|
||||
const [rows] = await connection.execute<RowDataPacket[]>(
|
||||
`SELECT id FROM qipai_room_reservations
|
||||
WHERE tenant_id = ? AND room_id = ? AND order_id <> ?
|
||||
AND status IN ('HELD', 'CONSUMED')
|
||||
AND (status = 'CONSUMED' OR expires_at > UTC_TIMESTAMP(3))
|
||||
AND starts_at < ? AND ends_at > ? FOR UPDATE`,
|
||||
[tenantId, roomId, excludingOrderId, endAt, startAt]
|
||||
);
|
||||
if (rows[0]) throw new OrderManagementError('TIME_SLOT_CONFLICT');
|
||||
}
|
||||
|
||||
private async resolveUnitPrice(
|
||||
connection: PoolConnection, tenantId: string, order: OrderRow, policy: PricingPolicy
|
||||
) {
|
||||
if (policy === 'LOCKED') {
|
||||
const [rows] = await connection.execute<SnapshotRow[]>(
|
||||
`SELECT unit_price_cents AS unitPriceCents
|
||||
FROM qipai_order_price_snapshots WHERE tenant_id = ? AND order_id = ?`,
|
||||
[tenantId, order.id]
|
||||
);
|
||||
if (!rows[0]) throw new OrderManagementError('ORDER_PRICE_SNAPSHOT_MISSING');
|
||||
return Number(rows[0].unitPriceCents);
|
||||
}
|
||||
const room = await this.loadRoom(connection, tenantId, order.roomId);
|
||||
const localDate = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: room.timezone, year: 'numeric', month: '2-digit', day: '2-digit'
|
||||
}).format(order.endAt);
|
||||
const [holidayRows] = await connection.execute<RowDataPacket[]>(
|
||||
`SELECT 1 FROM qipai_holiday_calendar
|
||||
WHERE tenant_id = ? AND holiday_date = ? LIMIT 1`,
|
||||
[tenantId, localDate]
|
||||
);
|
||||
if (holidayRows[0] && room.holidayPriceCents > 0) return Number(room.holidayPriceCents);
|
||||
const weekdayName = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: room.timezone, weekday: 'short'
|
||||
}).format(order.endAt);
|
||||
if (['Mon', 'Tue', 'Wed', 'Thu', 'Fri'].includes(weekdayName)
|
||||
&& room.weekdayPriceCents > 0) {
|
||||
return Number(room.weekdayPriceCents);
|
||||
}
|
||||
return Number(room.basePriceCents);
|
||||
}
|
||||
|
||||
private async duplicate(connection: PoolConnection, actor: OrderActor, orderId: string) {
|
||||
const [rows] = await connection.execute<DuplicateRow[]>(
|
||||
`SELECT id, adjustment_type AS adjustmentType,
|
||||
amount_delta_cents AS amountDeltaCents
|
||||
FROM qipai_order_adjustments
|
||||
WHERE tenant_id = ? AND order_id = ? AND trace_id = ? LIMIT 1`,
|
||||
[actor.tenantId, orderId, actor.traceId]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private duplicateResult(orderId: string, duplicate: DuplicateRow) {
|
||||
return {
|
||||
orderId, adjustmentId: String(duplicate.id),
|
||||
adjustmentType: duplicate.adjustmentType,
|
||||
amountDeltaCents: Number(duplicate.amountDeltaCents), idempotent: true
|
||||
};
|
||||
}
|
||||
|
||||
private assertManager(access: AccessProfile | undefined, storeId: string) {
|
||||
if (!access || !(access.capabilities.includes('tenant.manage')
|
||||
|| access.roles.includes('PLATFORM_ADMIN')
|
||||
|| (access.capabilities.includes('store.operation.write') && access.storeIds.includes(storeId)))) {
|
||||
throw new OrderManagementError('ORDER_MANAGEMENT_FORBIDDEN');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertOwner(
|
||||
connection: PoolConnection, tenantId: string, orderId: string, userId: string
|
||||
) {
|
||||
const [rows] = await connection.execute<RowDataPacket[]>(
|
||||
`SELECT 1 FROM qipai_order_user_access
|
||||
WHERE tenant_id = ? AND order_id = ? AND user_id = ?`,
|
||||
[tenantId, orderId, userId]
|
||||
);
|
||||
if (!rows[0]) throw new OrderManagementError('ORDER_ACCESS_FORBIDDEN');
|
||||
}
|
||||
|
||||
private async transaction<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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,9 +54,13 @@ export class PricingRepository {
|
||||
async reserve(input: QuoteInput & {
|
||||
userId: string;
|
||||
holdMinutes?: number;
|
||||
allowedStoreIds?: string[] | null;
|
||||
}) {
|
||||
return this.transaction(async (connection) => {
|
||||
const room = await this.loadRoom(connection, input.tenantId, input.roomId, true);
|
||||
if (input.allowedStoreIds && !input.allowedStoreIds.includes(String(room.storeId))) {
|
||||
throw new PricingError('STORE_SCOPE_FORBIDDEN');
|
||||
}
|
||||
await this.releaseExpired(input.tenantId, input.roomId, connection);
|
||||
await this.assertAvailable(connection, input, true);
|
||||
const isHoliday = await this.isHoliday(
|
||||
@@ -108,6 +112,7 @@ export class PricingRepository {
|
||||
return {
|
||||
orderId,
|
||||
orderNo,
|
||||
storeId: String(room.storeId),
|
||||
reservationId: String(reservationResult.insertId),
|
||||
holdMinutes,
|
||||
quote
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||
import {
|
||||
OrderManagementError, type OrderManagementRepository
|
||||
} from '../orders/order-management-repository.js';
|
||||
import type { OrderActor } from '../orders/order-state-repository.js';
|
||||
|
||||
const paramsSchema = z.object({ orderId: z.string().regex(/^[1-9]\d{0,19}$/) });
|
||||
const renewSchema = z.object({
|
||||
endAt: z.coerce.date(),
|
||||
pricingPolicy: z.enum(['CURRENT', 'LOCKED']).default('CURRENT'),
|
||||
reason: z.string().min(1).max(512)
|
||||
}).strict();
|
||||
const roomSchema = z.object({
|
||||
roomId: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||
reason: z.string().min(1).max(512)
|
||||
}).strict();
|
||||
const timeSchema = z.object({
|
||||
startAt: z.coerce.date().optional(),
|
||||
endAt: z.coerce.date().optional(),
|
||||
reason: z.string().min(1).max(512)
|
||||
}).strict().refine((value) => value.startAt || value.endAt);
|
||||
const noteSchema = z.object({ note: z.string().min(1).max(512) }).strict();
|
||||
|
||||
export interface OrderManagementRouteOptions {
|
||||
repository: Pick<OrderManagementRepository,
|
||||
'renew' | 'changeRoom' | 'adjustTime' | 'note' | 'cancellationQuote'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
jwtSecret: string;
|
||||
}
|
||||
|
||||
export async function registerOrderManagementRoutes(
|
||||
app: FastifyInstance, options: OrderManagementRouteOptions
|
||||
) {
|
||||
app.get('/app-api/orders/:orderId/cancellation-quote', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!params.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.cancellationQuote(
|
||||
auth.tenantId, auth.userId, params.data.orderId
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
const adminActions = [
|
||||
['renew', renewSchema, (actor: OrderActor, orderId: string, body: z.infer<typeof renewSchema>) =>
|
||||
options.repository.renew(actor, orderId, body)],
|
||||
['change-room', roomSchema, (actor: OrderActor, orderId: string, body: z.infer<typeof roomSchema>) =>
|
||||
options.repository.changeRoom(actor, orderId, body)],
|
||||
['adjust-time', timeSchema, (actor: OrderActor, orderId: string, body: z.infer<typeof timeSchema>) =>
|
||||
options.repository.adjustTime(actor, orderId, body)],
|
||||
['note', noteSchema, (actor: OrderActor, orderId: string, body: z.infer<typeof noteSchema>) =>
|
||||
options.repository.note(actor, orderId, body.note)]
|
||||
] as const;
|
||||
|
||||
for (const [path, schema, execute] of adminActions) {
|
||||
app.post(`/admin-api/orders/:orderId/${path}`, async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
const body = schema.safeParse(request.body);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
const actor = {
|
||||
tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER' as const,
|
||||
source: 'ADMIN' as const, traceId: request.traceId, ip: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? '', access: auth.access
|
||||
};
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await execute(actor, params.data.orderId, body.data as never),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticate(
|
||||
authorization: string | undefined, options: OrderManagementRouteOptions
|
||||
) {
|
||||
const result = await authenticateAccessToken(
|
||||
authorization, options.authRepository, options.jwtSecret
|
||||
);
|
||||
if (!result) return null;
|
||||
const tenantId = result.session.tenantId;
|
||||
const userId = result.session.user.id;
|
||||
return {
|
||||
tenantId, userId,
|
||||
access: await options.accessControl.getAccessProfile(tenantId, userId)
|
||||
};
|
||||
}
|
||||
|
||||
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
if (!(error instanceof OrderManagementError)) throw error;
|
||||
const status = error.code === 'ORDER_NOT_FOUND' || error.code === 'ROOM_NOT_FOUND' ? 404
|
||||
: error.code === 'TIME_SLOT_CONFLICT' ? 409
|
||||
: error.code.includes('FORBIDDEN') ? 403 : 400;
|
||||
return reply.status(status).send({
|
||||
code: error.code,
|
||||
message: 'The requested order adjustment is not available.',
|
||||
traceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function unauthorized(reply: FastifyReply, traceId: string) {
|
||||
return reply.status(401).send({
|
||||
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId
|
||||
});
|
||||
}
|
||||
|
||||
function invalid(reply: FastifyReply, traceId: string) {
|
||||
return reply.status(400).send({
|
||||
code: 'INVALID_ORDER_ADJUSTMENT', message: 'The order adjustment is invalid.', traceId
|
||||
});
|
||||
}
|
||||
@@ -19,6 +19,9 @@ export interface OrderStateRouteOptions {
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
jwtSecret: string;
|
||||
cancellationPolicy?: {
|
||||
cancellationQuote(tenantId: string, userId: string, orderId: string): Promise<unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerOrderStateRoutes(
|
||||
@@ -44,15 +47,19 @@ export async function registerOrderStateRoutes(
|
||||
const body = cancelSchema.safeParse(request.body ?? {});
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.transition({
|
||||
return handle(reply, request.traceId, async () => {
|
||||
const cancellation = options.cancellationPolicy
|
||||
? await options.cancellationPolicy.cancellationQuote(
|
||||
auth.tenantId, auth.userId, params.data.orderId
|
||||
)
|
||||
: null;
|
||||
const transition = await options.repository.transition({
|
||||
tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER',
|
||||
source: 'APP', traceId: request.traceId, ip: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? '', access: auth.access
|
||||
}, params.data.orderId, 'CANCEL', body.data.reason),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
}, params.data.orderId, 'CANCEL', body.data.reason);
|
||||
return { code: 0, data: { transition, cancellation }, traceId: request.traceId };
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/admin-api/orders/:orderId/actions', async (request, reply) => {
|
||||
|
||||
@@ -11,6 +11,9 @@ const requestSchema = z.object({
|
||||
endAt: z.coerce.date(),
|
||||
pricingMode: z.enum(['HOURLY', 'OVERNIGHT', 'FULL_DAY']).default('HOURLY')
|
||||
}).refine((value) => value.endAt > value.startAt);
|
||||
const adminReserveSchema = requestSchema.and(z.object({
|
||||
userId: z.string().regex(/^[1-9]\d{0,19}$/)
|
||||
}));
|
||||
|
||||
export interface PricingRouteOptions {
|
||||
repository: Pick<PricingRepository, 'quote' | 'reserve' | 'releaseExpired'>;
|
||||
@@ -69,6 +72,36 @@ export async function registerPricingRoutes(app: FastifyInstance, options: Prici
|
||||
traceId: request.traceId
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/admin-api/orders/reserve-on-behalf', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const body = adminReserveSchema.safeParse(request.body);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!body.success) return invalid(reply, request.traceId);
|
||||
const access = await options.accessControl.getAccessProfile(auth.tenantId, auth.userId);
|
||||
const unrestricted = access.capabilities.includes('tenant.manage')
|
||||
|| access.roles.includes('PLATFORM_ADMIN');
|
||||
if (!unrestricted && !access.capabilities.includes('store.operation.write')) {
|
||||
return reply.status(403).send({
|
||||
code: 'ORDER_MANAGEMENT_FORBIDDEN',
|
||||
message: 'Order management permission is required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
}
|
||||
return handle(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0,
|
||||
data: await options.repository.reserve({
|
||||
tenantId: auth.tenantId,
|
||||
userId: body.data.userId,
|
||||
roomId: body.data.roomId,
|
||||
startAt: body.data.startAt,
|
||||
endAt: body.data.endAt,
|
||||
pricingMode: body.data.pricingMode,
|
||||
allowedStoreIds: unrestricted ? null : access.storeIds
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async function authenticate(
|
||||
|
||||
@@ -14,11 +14,13 @@ import { StoreDiscoveryRepository } from './stores/store-discovery-repository.js
|
||||
import { StoreAccessRepository } from './stores/access-repository.js';
|
||||
import { PricingRepository } from './orders/pricing-repository.js';
|
||||
import { OrderStateRepository } from './orders/order-state-repository.js';
|
||||
import { OrderManagementRepository } from './orders/order-management-repository.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const pool = createMySqlPool(config);
|
||||
const authRepository = new AuthRepository(pool);
|
||||
const accessControl = new RbacRepository(pool);
|
||||
const orderManagementRepository = new OrderManagementRepository(pool);
|
||||
const app = await buildApp({
|
||||
config,
|
||||
platformConfigRepository: new PlatformConfigRepository(pool),
|
||||
@@ -69,6 +71,13 @@ const app = await buildApp({
|
||||
repository: new OrderStateRepository(pool),
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret,
|
||||
cancellationPolicy: orderManagementRepository
|
||||
},
|
||||
orderManagement: {
|
||||
repository: orderManagementRepository,
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
}
|
||||
});
|
||||
|
||||
@@ -45,6 +45,9 @@ const pricingVerifySql = read('database/migrations/2026061811_m04a_pricing_reser
|
||||
const orderStateUpSql = read('database/migrations/2026062012_m04b_order_state_machine.up.sql');
|
||||
const orderStateDownSql = read('database/migrations/2026062012_m04b_order_state_machine.down.sql');
|
||||
const orderStateVerifySql = read('database/migrations/2026062012_m04b_order_state_machine.verify.sql');
|
||||
const adjustmentUpSql = read('database/migrations/2026062013_m04c_order_adjustments.up.sql');
|
||||
const adjustmentDownSql = read('database/migrations/2026062013_m04c_order_adjustments.down.sql');
|
||||
const adjustmentVerifySql = read('database/migrations/2026062013_m04c_order_adjustments.verify.sql');
|
||||
|
||||
const coreTables = [
|
||||
'qipai_schema_migrations',
|
||||
@@ -207,5 +210,11 @@ assert.match(orderStateUpSql, /UNIQUE KEY uq_qipai_order_history_trace/);
|
||||
assert.match(orderStateUpSql, /actor_type VARCHAR/);
|
||||
assert.match(orderStateUpSql, /source VARCHAR/);
|
||||
assert.match(orderStateUpSql, /trace_id VARCHAR/);
|
||||
assert.match(adjustmentUpSql, /CREATE TABLE IF NOT EXISTS qipai_order_adjustments/);
|
||||
assert.match(adjustmentDownSql, /DROP TABLE IF EXISTS qipai_order_adjustments/);
|
||||
assert.match(adjustmentVerifySql, /'qipai_order_adjustments'/);
|
||||
assert.match(adjustmentUpSql, /cancellation_cutoff_minutes/);
|
||||
assert.match(adjustmentUpSql, /amount_delta_cents INT/);
|
||||
assert.match(adjustmentUpSql, /UNIQUE KEY uq_qipai_order_adjustment_trace/);
|
||||
|
||||
console.log('PASS: M01-B through M04-B migration contracts are present.');
|
||||
console.log('PASS: M01-B through M04-C migration contracts are present.');
|
||||
|
||||
@@ -23,7 +23,8 @@ assert.match(plan.file, /2026061808_m03b_decoration_ads_media\.up\.sql/);
|
||||
assert.match(plan.file, /2026061809_m03c_store_discovery\.up\.sql/);
|
||||
assert.match(plan.file, /2026061810_m03d_scene_wifi_access\.up\.sql/);
|
||||
assert.match(plan.file, /2026061811_m04a_pricing_reservations\.up\.sql/);
|
||||
assert.match(plan.file, /2026062012_m04b_order_state_machine\.up\.sql$/);
|
||||
assert.match(plan.file, /2026062012_m04b_order_state_machine\.up\.sql/);
|
||||
assert.match(plan.file, /2026062013_m04c_order_adjustments\.up\.sql$/);
|
||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||
assert.ok(plan.statements.length >= 11);
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ import { PricingRepository, PricingError } from '../dist/orders/pricing-reposito
|
||||
import {
|
||||
OrderStateError, OrderStateRepository
|
||||
} from '../dist/orders/order-state-repository.js';
|
||||
import {
|
||||
OrderManagementError, OrderManagementRepository
|
||||
} from '../dist/orders/order-management-repository.js';
|
||||
import {
|
||||
executeMigrationPlan,
|
||||
loadMigrationPlan,
|
||||
@@ -37,6 +40,7 @@ const expectedTables = [
|
||||
'qipai_legacy_table_mappings',
|
||||
'qipai_media_assets',
|
||||
'qipai_members',
|
||||
'qipai_order_adjustments',
|
||||
'qipai_order_price_snapshots',
|
||||
'qipai_order_status_history',
|
||||
'qipai_order_user_access',
|
||||
@@ -85,11 +89,11 @@ async function readMigrationVersions(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT version, name
|
||||
FROM qipai_schema_migrations
|
||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ORDER BY version`,
|
||||
['2026061601', '2026061802', '2026061803', '2026061804',
|
||||
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
||||
'2026061810', '2026061811', '2026062012']
|
||||
'2026061810', '2026061811', '2026062012', '2026062013']
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
@@ -723,6 +727,169 @@ async function assertOrderStateMachine(pool, context) {
|
||||
]);
|
||||
}
|
||||
|
||||
async function assertOrderAdjustments(pool, context) {
|
||||
const [adminRows] = await pool.query(
|
||||
`SELECT u.id FROM qipai_users u
|
||||
INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
|
||||
INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id
|
||||
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN' LIMIT 1`,
|
||||
[context.tenantId]
|
||||
);
|
||||
const [customerRows] = await pool.query(
|
||||
`SELECT u.id FROM qipai_users u
|
||||
INNER JOIN qipai_user_identities i
|
||||
ON i.tenant_id = u.tenant_id AND i.user_id = u.id
|
||||
WHERE u.tenant_id = ? AND i.openid = 'm02b-openid-a' LIMIT 1`,
|
||||
[context.tenantId]
|
||||
);
|
||||
const [targetRows] = await pool.query(
|
||||
`SELECT s.id AS storeId, r.id AS roomId
|
||||
FROM qipai_stores s
|
||||
INNER JOIN qipai_rooms r ON r.tenant_id = s.tenant_id AND r.store_id = s.id
|
||||
WHERE s.tenant_id = ? AND s.name = 'M03A Store' LIMIT 1`,
|
||||
[context.tenantId]
|
||||
);
|
||||
const adminId = String(adminRows[0].id);
|
||||
const customerId = String(customerRows[0].id);
|
||||
const storeId = String(targetRows[0].storeId);
|
||||
const roomId = String(targetRows[0].roomId);
|
||||
const [newRoom] = await pool.query(
|
||||
`INSERT INTO qipai_rooms
|
||||
(tenant_id, store_id, name, room_no, base_price_cents,
|
||||
configuration_status, operational_status, minimum_minutes, max_advance_days)
|
||||
VALUES (?, ?, 'M04C Target Room', 'C02', 1800, 'ENABLED', 'AVAILABLE', 60, 30)`,
|
||||
[context.tenantId, storeId]
|
||||
);
|
||||
const targetRoomId = String(newRoom.insertId);
|
||||
await pool.query(
|
||||
`UPDATE qipai_stores
|
||||
SET cancellation_cutoff_minutes = 20160, cancellation_fee_bps = 2500
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[context.tenantId, storeId]
|
||||
);
|
||||
const startAt = new Date(Date.now() + 12 * 86400000);
|
||||
startAt.setUTCHours(2, 0, 0, 0);
|
||||
const endAt = new Date(startAt.getTime() + 2 * 3600000);
|
||||
const pricing = new PricingRepository(pool);
|
||||
const created = await pricing.reserve({
|
||||
tenantId: context.tenantId, userId: customerId, roomId,
|
||||
startAt, endAt, pricingMode: 'HOURLY'
|
||||
});
|
||||
const access = await new RbacRepository(pool).getAccessProfile(context.tenantId, adminId);
|
||||
const state = new OrderStateRepository(pool);
|
||||
await state.transition({
|
||||
tenantId: context.tenantId, userId: adminId, actorType: 'USER',
|
||||
source: 'ADMIN', traceId: 'm04c-order-paid', ip: '127.0.0.1',
|
||||
userAgent: 'M04-C live test', access
|
||||
}, created.orderId, 'CONFIRM_PAYMENT');
|
||||
const repository = new OrderManagementRepository(pool);
|
||||
const actor = (traceId) => ({
|
||||
tenantId: context.tenantId, userId: adminId, actorType: 'USER',
|
||||
source: 'ADMIN', traceId, ip: '127.0.0.1',
|
||||
userAgent: 'M04-C live test', access
|
||||
});
|
||||
|
||||
const blockedEnd = new Date(endAt.getTime() + 2 * 3600000);
|
||||
const [blockingOrder] = await pool.query(
|
||||
`INSERT INTO qipai_orders
|
||||
(tenant_id, store_id, room_id, order_no, status, start_at, end_at)
|
||||
VALUES (?, ?, ?, 'M04C-RENEW-BLOCK', 'PAID', ?, ?)`,
|
||||
[context.tenantId, storeId, roomId, endAt, blockedEnd]
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO qipai_room_reservations
|
||||
(tenant_id, order_id, room_id, starts_at, ends_at, status, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'CONSUMED', ?)`,
|
||||
[context.tenantId, blockingOrder.insertId, roomId, endAt, blockedEnd, blockedEnd]
|
||||
);
|
||||
await assert.rejects(
|
||||
() => repository.renew(actor('m04c-renew-conflict'), created.orderId, {
|
||||
endAt: blockedEnd, pricingPolicy: 'LOCKED', reason: 'conflict rehearsal'
|
||||
}),
|
||||
(error) => error instanceof OrderManagementError && error.code === 'TIME_SLOT_CONFLICT'
|
||||
);
|
||||
const [unchanged] = await pool.query(
|
||||
`SELECT end_at AS endAt FROM qipai_orders WHERE id = ?`,
|
||||
[created.orderId]
|
||||
);
|
||||
assert.equal(new Date(unchanged[0].endAt).getTime(), endAt.getTime());
|
||||
await pool.query(`DELETE FROM qipai_room_reservations WHERE order_id = ?`, [blockingOrder.insertId]);
|
||||
await pool.query(`DELETE FROM qipai_orders WHERE id = ?`, [blockingOrder.insertId]);
|
||||
|
||||
const renewed = await repository.renew(actor('m04c-renew'), created.orderId, {
|
||||
endAt: blockedEnd, pricingPolicy: 'LOCKED', reason: 'approved extension'
|
||||
});
|
||||
assert.equal(renewed.amountDeltaCents, created.quote.unitPriceCents * 2);
|
||||
const duplicate = await repository.renew(actor('m04c-renew'), created.orderId, {
|
||||
endAt: new Date(blockedEnd.getTime() + 3600000),
|
||||
pricingPolicy: 'CURRENT', reason: 'duplicate request'
|
||||
});
|
||||
assert.equal(duplicate.idempotent, true);
|
||||
|
||||
const [targetBlockOrder] = await pool.query(
|
||||
`INSERT INTO qipai_orders
|
||||
(tenant_id, store_id, room_id, order_no, status, start_at, end_at)
|
||||
VALUES (?, ?, ?, 'M04C-ROOM-BLOCK', 'PAID', ?, ?)`,
|
||||
[context.tenantId, storeId, targetRoomId, startAt, blockedEnd]
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO qipai_room_reservations
|
||||
(tenant_id, order_id, room_id, starts_at, ends_at, status, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'CONSUMED', ?)`,
|
||||
[context.tenantId, targetBlockOrder.insertId, targetRoomId, startAt, blockedEnd, blockedEnd]
|
||||
);
|
||||
await assert.rejects(
|
||||
() => repository.changeRoom(actor('m04c-room-conflict'), created.orderId, {
|
||||
roomId: targetRoomId, reason: 'rollback rehearsal'
|
||||
}),
|
||||
(error) => error instanceof OrderManagementError && error.code === 'TIME_SLOT_CONFLICT'
|
||||
);
|
||||
const [stillOldRoom] = await pool.query(
|
||||
`SELECT o.room_id AS orderRoomId, r.room_id AS reservationRoomId
|
||||
FROM qipai_orders o INNER JOIN qipai_room_reservations r ON r.order_id = o.id
|
||||
WHERE o.id = ?`,
|
||||
[created.orderId]
|
||||
);
|
||||
assert.equal(String(stillOldRoom[0].orderRoomId), roomId);
|
||||
assert.equal(String(stillOldRoom[0].reservationRoomId), roomId);
|
||||
await pool.query(`DELETE FROM qipai_room_reservations WHERE order_id = ?`, [targetBlockOrder.insertId]);
|
||||
await pool.query(`DELETE FROM qipai_orders WHERE id = ?`, [targetBlockOrder.insertId]);
|
||||
|
||||
const changed = await repository.changeRoom(actor('m04c-room-change'), created.orderId, {
|
||||
roomId: targetRoomId, reason: 'customer requested target room'
|
||||
});
|
||||
assert.equal(changed.adjustmentType, 'CHANGE_ROOM');
|
||||
const adjustedEnd = new Date(blockedEnd.getTime() + 3600000);
|
||||
const adjusted = await repository.adjustTime(actor('m04c-time-adjust'), created.orderId, {
|
||||
endAt: adjustedEnd, reason: 'manager granted one hour'
|
||||
});
|
||||
assert.equal(adjusted.adjustmentType, 'ADJUST_TIME');
|
||||
await repository.note(actor('m04c-note'), created.orderId, 'sanitized operator note');
|
||||
const cancellation = await repository.cancellationQuote(
|
||||
context.tenantId, customerId, created.orderId
|
||||
);
|
||||
assert.equal(cancellation.allowed, true);
|
||||
assert.ok(cancellation.feeCents > 0);
|
||||
await assert.rejects(
|
||||
() => pricing.reserve({
|
||||
tenantId: context.tenantId, userId: customerId, roomId: targetRoomId,
|
||||
startAt: new Date(adjustedEnd.getTime() + 86400000),
|
||||
endAt: new Date(adjustedEnd.getTime() + 90000000),
|
||||
pricingMode: 'HOURLY', allowedStoreIds: [String(Number(storeId) + 999)]
|
||||
}),
|
||||
(error) => error instanceof PricingError && error.code === 'STORE_SCOPE_FORBIDDEN'
|
||||
);
|
||||
const [history] = await pool.query(
|
||||
`SELECT adjustment_type AS adjustmentType, amount_delta_cents AS amountDeltaCents
|
||||
FROM qipai_order_adjustments
|
||||
WHERE tenant_id = ? AND order_id = ? ORDER BY id`,
|
||||
[context.tenantId, created.orderId]
|
||||
);
|
||||
assert.deepEqual(history.map((row) => row.adjustmentType), [
|
||||
'RENEW', 'CHANGE_ROOM', 'ADJUST_TIME', 'NOTE'
|
||||
]);
|
||||
}
|
||||
|
||||
async function assertContentManagement(pool, context) {
|
||||
const [adminRows] = await pool.query(
|
||||
`SELECT u.id FROM qipai_users u
|
||||
@@ -822,7 +989,8 @@ try {
|
||||
{ version: '2026061809', name: 'm03c_store_discovery' },
|
||||
{ version: '2026061810', name: 'm03d_scene_wifi_access' },
|
||||
{ version: '2026061811', name: 'm04a_pricing_reservations' },
|
||||
{ version: '2026062012', name: 'm04b_order_state_machine' }
|
||||
{ version: '2026062012', name: 'm04b_order_state_machine' },
|
||||
{ version: '2026062013', name: 'm04c_order_adjustments' }
|
||||
]);
|
||||
await assertTaskDurability(pool);
|
||||
const loginContext = await assertPlatformTenantIsolation(pool);
|
||||
@@ -834,13 +1002,14 @@ try {
|
||||
await assertSceneAndWifiAccess(pool, loginContext);
|
||||
await assertPricingAndReservations(pool, loginContext);
|
||||
await assertOrderStateMachine(pool, loginContext);
|
||||
await assertOrderAdjustments(pool, loginContext);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
|
||||
|
||||
await executeMigrationPlan(pool, plans.down);
|
||||
assert.deepEqual(await readCoreTables(pool), []);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: down removed all M01-B through M04-B tables.');
|
||||
console.log('PASS: down removed all M01-B through M04-C tables.');
|
||||
|
||||
await executeMigrationPlan(pool, plans.up);
|
||||
await executeMigrationPlan(pool, plans.verify);
|
||||
@@ -857,7 +1026,8 @@ try {
|
||||
{ version: '2026061809', name: 'm03c_store_discovery' },
|
||||
{ version: '2026061810', name: 'm03d_scene_wifi_access' },
|
||||
{ version: '2026061811', name: 'm04a_pricing_reservations' },
|
||||
{ version: '2026062012', name: 'm04b_order_state_machine' }
|
||||
{ version: '2026062012', name: 'm04b_order_state_machine' },
|
||||
{ version: '2026062013', name: 'm04c_order_adjustments' }
|
||||
]);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: second up and verify restored the schema.');
|
||||
@@ -919,7 +1089,13 @@ try {
|
||||
'controlled order actions',
|
||||
'idempotent transition trace',
|
||||
'complete order status history',
|
||||
'reservation and access release on close'
|
||||
'reservation and access release on close',
|
||||
'renewal conflict leaves original end time unchanged',
|
||||
'idempotent renewal adjustment',
|
||||
'room-change conflict transaction rollback',
|
||||
'room price difference and manager time adjustment',
|
||||
'configured cancellation fee quote',
|
||||
'store-scoped on-behalf booking rejection'
|
||||
]
|
||||
}, null, 2));
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildApp } from '../dist/app.js';
|
||||
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||
|
||||
const secret = 'test-only-order-management-secret-32-chars';
|
||||
const token = signAccessToken({
|
||||
sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
|
||||
tid: '7', aid: '9', rv: 1
|
||||
}, secret, 900);
|
||||
let called;
|
||||
const authRepository = {
|
||||
async validateSession() {
|
||||
return {
|
||||
id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
|
||||
tenantId: '7', platformAppId: '9', expiresAt: new Date(Date.now() + 60000),
|
||||
user: {
|
||||
id: '21', tenantId: '7', userType: 'ADMIN', status: 'ACTIVE',
|
||||
roleVersion: 1, nickname: '', avatarUrl: '', phone: ''
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
const accessControl = {
|
||||
async getAccessProfile() {
|
||||
return { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] };
|
||||
}
|
||||
};
|
||||
const app = await buildApp({
|
||||
orderManagement: {
|
||||
jwtSecret: secret, authRepository, accessControl,
|
||||
repository: {
|
||||
async renew(actor, orderId, body) {
|
||||
called = { actor, orderId, body };
|
||||
return { orderId, adjustmentType: 'RENEW', amountDeltaCents: 1200 };
|
||||
},
|
||||
async changeRoom() { throw new Error('not called'); },
|
||||
async adjustTime() { throw new Error('not called'); },
|
||||
async note() { throw new Error('not called'); },
|
||||
async cancellationQuote() {
|
||||
return { allowed: true, feeCents: 0, refundableCents: 3000 };
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const rejectedAmount = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/orders/31/renew',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
endAt: new Date(Date.now() + 7200000).toISOString(),
|
||||
pricingPolicy: 'LOCKED',
|
||||
reason: 'extend',
|
||||
amountDeltaCents: 1
|
||||
}
|
||||
});
|
||||
assert.equal(rejectedAmount.statusCode, 400);
|
||||
|
||||
const renewed = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/orders/31/renew',
|
||||
headers: { authorization: `Bearer ${token}`, 'x-trace-id': 'm04c-renew-route' },
|
||||
payload: {
|
||||
endAt: new Date(Date.now() + 7200000).toISOString(),
|
||||
pricingPolicy: 'LOCKED',
|
||||
reason: 'customer requested extension'
|
||||
}
|
||||
});
|
||||
assert.equal(renewed.statusCode, 200);
|
||||
assert.equal(called.orderId, '31');
|
||||
assert.equal(called.actor.traceId, 'm04c-renew-route');
|
||||
assert.equal(called.body.pricingPolicy, 'LOCKED');
|
||||
|
||||
const quote = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/orders/31/cancellation-quote',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(quote.statusCode, 200);
|
||||
assert.equal(quote.json().data.refundableCents, 3000);
|
||||
|
||||
await app.close();
|
||||
console.log('PASS: M04-C routes reject client amounts and expose controlled adjustments.');
|
||||
Reference in New Issue
Block a user