feat(M04-C): 完成续费换房与订单调整

This commit is contained in:
Codex
2026-06-20 14:05:31 +08:00
parent 4122db45ec
commit 089d34877b
20 changed files with 940 additions and 20 deletions
+1 -1
View File
@@ -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",
+7
View File
@@ -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;
}
+7 -3
View File
@@ -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();
}
}
}
+5
View File
@@ -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
+126
View File
@@ -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
});
}
+13 -6
View File
@@ -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) => {
+33
View File
@@ -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(
+9
View File
@@ -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
}
});
+10 -1
View File
@@ -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.');
+2 -1
View File
@@ -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 {
+83
View File
@@ -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.');