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:verify": "npm run build && node dist/db/migrate-cli.js verify",
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down", "db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs", "test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && 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": { "dependencies": {
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
+7
View File
@@ -33,6 +33,9 @@ import { registerPricingRoutes, type PricingRouteOptions } from './routes/pricin
import { import {
registerOrderStateRoutes, type OrderStateRouteOptions registerOrderStateRoutes, type OrderStateRouteOptions
} from './routes/order-state.js'; } from './routes/order-state.js';
import {
registerOrderManagementRoutes, type OrderManagementRouteOptions
} from './routes/order-management.js';
export interface BuildAppOptions { export interface BuildAppOptions {
config?: AppConfig; config?: AppConfig;
@@ -45,6 +48,7 @@ export interface BuildAppOptions {
storeAccess?: StoreAccessRouteOptions; storeAccess?: StoreAccessRouteOptions;
pricing?: PricingRouteOptions; pricing?: PricingRouteOptions;
orderState?: OrderStateRouteOptions; orderState?: OrderStateRouteOptions;
orderManagement?: OrderManagementRouteOptions;
} }
declare module 'fastify' { declare module 'fastify' {
@@ -114,6 +118,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.orderState) { if (options.orderState) {
await registerOrderStateRoutes(app, options.orderState); await registerOrderStateRoutes(app, options.orderState);
} }
if (options.orderManagement) {
await registerOrderManagementRoutes(app, options.orderManagement);
}
return app; 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/2026061809_m03c_store_discovery.up.sql',
'database/migrations/2026061810_m03d_scene_wifi_access.up.sql', 'database/migrations/2026061810_m03d_scene_wifi_access.up.sql',
'database/migrations/2026061811_m04a_pricing_reservations.up.sql', '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: [ verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql', '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/2026061809_m03c_store_discovery.verify.sql',
'database/migrations/2026061810_m03d_scene_wifi_access.verify.sql', 'database/migrations/2026061810_m03d_scene_wifi_access.verify.sql',
'database/migrations/2026061811_m04a_pricing_reservations.verify.sql', '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: [ down: [
'database/migrations/2026062013_m04c_order_adjustments.down.sql',
'database/migrations/2026062012_m04b_order_state_machine.down.sql', 'database/migrations/2026062012_m04b_order_state_machine.down.sql',
'database/migrations/2026061811_m04a_pricing_reservations.down.sql', 'database/migrations/2026061811_m04a_pricing_reservations.down.sql',
'database/migrations/2026061810_m03d_scene_wifi_access.down.sql', 'database/migrations/2026061810_m03d_scene_wifi_access.down.sql',
@@ -188,7 +191,8 @@ export async function executeMigrationPlan(
2, 2, 1, 2, 2, 1,
3, 3, 1, 3, 3, 1,
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; ][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) { if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error( throw new Error(
@@ -0,0 +1,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 & { async reserve(input: QuoteInput & {
userId: string; userId: string;
holdMinutes?: number; holdMinutes?: number;
allowedStoreIds?: string[] | null;
}) { }) {
return this.transaction(async (connection) => { return this.transaction(async (connection) => {
const room = await this.loadRoom(connection, input.tenantId, input.roomId, true); 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.releaseExpired(input.tenantId, input.roomId, connection);
await this.assertAvailable(connection, input, true); await this.assertAvailable(connection, input, true);
const isHoliday = await this.isHoliday( const isHoliday = await this.isHoliday(
@@ -108,6 +112,7 @@ export class PricingRepository {
return { return {
orderId, orderId,
orderNo, orderNo,
storeId: String(room.storeId),
reservationId: String(reservationResult.insertId), reservationId: String(reservationResult.insertId),
holdMinutes, holdMinutes,
quote 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'>; authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> }; accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string; jwtSecret: string;
cancellationPolicy?: {
cancellationQuote(tenantId: string, userId: string, orderId: string): Promise<unknown>;
};
} }
export async function registerOrderStateRoutes( export async function registerOrderStateRoutes(
@@ -44,15 +47,19 @@ export async function registerOrderStateRoutes(
const body = cancelSchema.safeParse(request.body ?? {}); const body = cancelSchema.safeParse(request.body ?? {});
if (!auth) return unauthorized(reply, request.traceId); if (!auth) return unauthorized(reply, request.traceId);
if (!params.success || !body.success) return invalid(reply, request.traceId); if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({ return handle(reply, request.traceId, async () => {
code: 0, const cancellation = options.cancellationPolicy
data: await options.repository.transition({ ? 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', tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER',
source: 'APP', traceId: request.traceId, ip: request.ip, source: 'APP', traceId: request.traceId, ip: request.ip,
userAgent: request.headers['user-agent'] ?? '', access: auth.access userAgent: request.headers['user-agent'] ?? '', access: auth.access
}, params.data.orderId, 'CANCEL', body.data.reason), }, params.data.orderId, 'CANCEL', body.data.reason);
traceId: request.traceId return { code: 0, data: { transition, cancellation }, traceId: request.traceId };
})); });
}); });
app.post('/admin-api/orders/:orderId/actions', async (request, reply) => { 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(), endAt: z.coerce.date(),
pricingMode: z.enum(['HOURLY', 'OVERNIGHT', 'FULL_DAY']).default('HOURLY') pricingMode: z.enum(['HOURLY', 'OVERNIGHT', 'FULL_DAY']).default('HOURLY')
}).refine((value) => value.endAt > value.startAt); }).refine((value) => value.endAt > value.startAt);
const adminReserveSchema = requestSchema.and(z.object({
userId: z.string().regex(/^[1-9]\d{0,19}$/)
}));
export interface PricingRouteOptions { export interface PricingRouteOptions {
repository: Pick<PricingRepository, 'quote' | 'reserve' | 'releaseExpired'>; repository: Pick<PricingRepository, 'quote' | 'reserve' | 'releaseExpired'>;
@@ -69,6 +72,36 @@ export async function registerPricingRoutes(app: FastifyInstance, options: Prici
traceId: request.traceId 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( 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 { StoreAccessRepository } from './stores/access-repository.js';
import { PricingRepository } from './orders/pricing-repository.js'; import { PricingRepository } from './orders/pricing-repository.js';
import { OrderStateRepository } from './orders/order-state-repository.js'; import { OrderStateRepository } from './orders/order-state-repository.js';
import { OrderManagementRepository } from './orders/order-management-repository.js';
const config = loadConfig(); const config = loadConfig();
const pool = createMySqlPool(config); const pool = createMySqlPool(config);
const authRepository = new AuthRepository(pool); const authRepository = new AuthRepository(pool);
const accessControl = new RbacRepository(pool); const accessControl = new RbacRepository(pool);
const orderManagementRepository = new OrderManagementRepository(pool);
const app = await buildApp({ const app = await buildApp({
config, config,
platformConfigRepository: new PlatformConfigRepository(pool), platformConfigRepository: new PlatformConfigRepository(pool),
@@ -69,6 +71,13 @@ const app = await buildApp({
repository: new OrderStateRepository(pool), repository: new OrderStateRepository(pool),
authRepository, authRepository,
accessControl, accessControl,
jwtSecret: config.auth.jwtSecret,
cancellationPolicy: orderManagementRepository
},
orderManagement: {
repository: orderManagementRepository,
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret 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 orderStateUpSql = read('database/migrations/2026062012_m04b_order_state_machine.up.sql');
const orderStateDownSql = read('database/migrations/2026062012_m04b_order_state_machine.down.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 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 = [ const coreTables = [
'qipai_schema_migrations', '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, /actor_type VARCHAR/);
assert.match(orderStateUpSql, /source VARCHAR/); assert.match(orderStateUpSql, /source VARCHAR/);
assert.match(orderStateUpSql, /trace_id 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, /2026061809_m03c_store_discovery\.up\.sql/);
assert.match(plan.file, /2026061810_m03d_scene_wifi_access\.up\.sql/); assert.match(plan.file, /2026061810_m03d_scene_wifi_access\.up\.sql/);
assert.match(plan.file, /2026061811_m04a_pricing_reservations\.up\.sql/); assert.match(plan.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.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11); assert.ok(plan.statements.length >= 11);
@@ -21,6 +21,9 @@ import { PricingRepository, PricingError } from '../dist/orders/pricing-reposito
import { import {
OrderStateError, OrderStateRepository OrderStateError, OrderStateRepository
} from '../dist/orders/order-state-repository.js'; } from '../dist/orders/order-state-repository.js';
import {
OrderManagementError, OrderManagementRepository
} from '../dist/orders/order-management-repository.js';
import { import {
executeMigrationPlan, executeMigrationPlan,
loadMigrationPlan, loadMigrationPlan,
@@ -37,6 +40,7 @@ const expectedTables = [
'qipai_legacy_table_mappings', 'qipai_legacy_table_mappings',
'qipai_media_assets', 'qipai_media_assets',
'qipai_members', 'qipai_members',
'qipai_order_adjustments',
'qipai_order_price_snapshots', 'qipai_order_price_snapshots',
'qipai_order_status_history', 'qipai_order_status_history',
'qipai_order_user_access', 'qipai_order_user_access',
@@ -85,11 +89,11 @@ async function readMigrationVersions(pool) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT version, name `SELECT version, name
FROM qipai_schema_migrations FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ORDER BY version`, ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804', ['2026061601', '2026061802', '2026061803', '2026061804',
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809', '2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
'2026061810', '2026061811', '2026062012'] '2026061810', '2026061811', '2026062012', '2026062013']
); );
return rows; 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) { async function assertContentManagement(pool, context) {
const [adminRows] = await pool.query( const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u `SELECT u.id FROM qipai_users u
@@ -822,7 +989,8 @@ try {
{ version: '2026061809', name: 'm03c_store_discovery' }, { version: '2026061809', name: 'm03c_store_discovery' },
{ version: '2026061810', name: 'm03d_scene_wifi_access' }, { version: '2026061810', name: 'm03d_scene_wifi_access' },
{ version: '2026061811', name: 'm04a_pricing_reservations' }, { 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); await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool); const loginContext = await assertPlatformTenantIsolation(pool);
@@ -834,13 +1002,14 @@ try {
await assertSceneAndWifiAccess(pool, loginContext); await assertSceneAndWifiAccess(pool, loginContext);
await assertPricingAndReservations(pool, loginContext); await assertPricingAndReservations(pool, loginContext);
await assertOrderStateMachine(pool, loginContext); await assertOrderStateMachine(pool, loginContext);
await assertOrderAdjustments(pool, loginContext);
await assertLegacyCompatibility(pool); await assertLegacyCompatibility(pool);
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.'); console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
await executeMigrationPlan(pool, plans.down); await executeMigrationPlan(pool, plans.down);
assert.deepEqual(await readCoreTables(pool), []); assert.deepEqual(await readCoreTables(pool), []);
await assertLegacyCompatibility(pool); await assertLegacyCompatibility(pool);
console.log('PASS: down removed all M01-B 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.up);
await executeMigrationPlan(pool, plans.verify); await executeMigrationPlan(pool, plans.verify);
@@ -857,7 +1026,8 @@ try {
{ version: '2026061809', name: 'm03c_store_discovery' }, { version: '2026061809', name: 'm03c_store_discovery' },
{ version: '2026061810', name: 'm03d_scene_wifi_access' }, { version: '2026061810', name: 'm03d_scene_wifi_access' },
{ version: '2026061811', name: 'm04a_pricing_reservations' }, { 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); await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.'); console.log('PASS: second up and verify restored the schema.');
@@ -919,7 +1089,13 @@ try {
'controlled order actions', 'controlled order actions',
'idempotent transition trace', 'idempotent transition trace',
'complete order status history', '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)); }, null, 2));
} finally { } 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.');
@@ -0,0 +1,8 @@
DELETE FROM qipai_schema_migrations WHERE version = '2026062013';
DROP TABLE IF EXISTS qipai_order_adjustments;
ALTER TABLE qipai_orders
DROP COLUMN operator_note,
DROP COLUMN adjustment_amount_cents;
ALTER TABLE qipai_stores
DROP COLUMN cancellation_fee_bps,
DROP COLUMN cancellation_cutoff_minutes;
@@ -0,0 +1,35 @@
ALTER TABLE qipai_stores
ADD COLUMN cancellation_cutoff_minutes INT UNSIGNED NOT NULL DEFAULT 120
AFTER notification_url,
ADD COLUMN cancellation_fee_bps SMALLINT UNSIGNED NOT NULL DEFAULT 0
AFTER cancellation_cutoff_minutes;
ALTER TABLE qipai_orders
ADD COLUMN adjustment_amount_cents INT NOT NULL DEFAULT 0 AFTER paid_amount_cents,
ADD COLUMN operator_note VARCHAR(512) NOT NULL DEFAULT '' AFTER adjustment_amount_cents;
CREATE TABLE IF NOT EXISTS qipai_order_adjustments (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
adjustment_type VARCHAR(32) NOT NULL,
actor_id BIGINT UNSIGNED NOT NULL,
source VARCHAR(32) NOT NULL,
trace_id VARCHAR(128) NOT NULL,
reason VARCHAR(512) NOT NULL DEFAULT '',
before_values JSON NOT NULL,
after_values JSON NOT NULL,
amount_delta_cents INT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_order_adjustment_tenant
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_order_adjustment_order
FOREIGN KEY (order_id) REFERENCES qipai_orders(id),
CONSTRAINT fk_qipai_order_adjustment_actor
FOREIGN KEY (actor_id) REFERENCES qipai_users(id),
UNIQUE KEY uq_qipai_order_adjustment_trace (tenant_id, order_id, trace_id),
KEY idx_qipai_order_adjustment_order (tenant_id, order_id, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO qipai_schema_migrations (version, name)
VALUES ('2026062013', 'm04c_order_adjustments');
@@ -0,0 +1,19 @@
SELECT column_name FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'qipai_stores'
AND column_name IN ('cancellation_cutoff_minutes', 'cancellation_fee_bps')
ORDER BY column_name;
SELECT column_name FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'qipai_orders'
AND column_name IN ('adjustment_amount_cents', 'operator_note')
ORDER BY column_name;
SELECT table_name FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'qipai_order_adjustments';
SELECT index_name FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'qipai_order_adjustments'
AND index_name IN ('uq_qipai_order_adjustment_trace', 'idx_qipai_order_adjustment_order')
GROUP BY index_name ORDER BY index_name;
SELECT version, name FROM qipai_schema_migrations WHERE version = '2026062013';
@@ -0,0 +1,10 @@
# M04-C 订单调整 API
- `GET /app-api/orders/:orderId/cancellation-quote`:按门店提前取消分钟数和费率返回取消费、可退金额;退款执行留给 M05/M07。
- `POST /admin-api/orders/:orderId/renew`:按当前价或原锁定单价续费,并重新检查后续时段。
- `POST /admin-api/orders/:orderId/change-room`:事务内换房并计算基础房价差额。
- `POST /admin-api/orders/:orderId/adjust-time`:管理员增减订单时间。
- `POST /admin-api/orders/:orderId/note`:记录管理员备注。
- `POST /admin-api/orders/reserve-on-behalf`:管理员在门店数据范围内代下单。
所有调整金额均由服务端计算,客户端提交金额字段会被严格拒绝。每个调整使用订单 + traceId 幂等,并记录前后值、原因、操作者和审计日志。
@@ -0,0 +1,9 @@
# M04-C 订单调整数据库变更
- 迁移版本:`2026062013`
- 门店新增取消提前量和取消费率配置。
- 订单新增累计调整金额和管理员备注。
- 新增 `qipai_order_adjustments`,记录续费、换房、调时和备注的前后值、差额、操作者、来源、原因与 traceId。
- `(tenant_id, order_id, trace_id)` 唯一约束保证人工调整幂等。
续费、换房和调时均在事务内锁定订单与相关房间,检查禁用时段和重叠预占;失败时订单、预占和金额全部回滚。
+3
View File
@@ -52,6 +52,9 @@ $requiredFiles = @(
"database/migrations/2026062012_m04b_order_state_machine.up.sql", "database/migrations/2026062012_m04b_order_state_machine.up.sql",
"database/migrations/2026062012_m04b_order_state_machine.down.sql", "database/migrations/2026062012_m04b_order_state_machine.down.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.up.sql",
"database/migrations/2026062013_m04c_order_adjustments.down.sql",
"database/migrations/2026062013_m04c_order_adjustments.verify.sql",
"database/seeds/2026061601_m01b_minimal_seed.sql", "database/seeds/2026061601_m01b_minimal_seed.sql",
"deploy/pm2/ecosystem.config.cjs" "deploy/pm2/ecosystem.config.cjs"
) )
+2 -2
View File
@@ -93,6 +93,6 @@ export QIPAI_MYSQL_USER="${username}"
export QIPAI_MYSQL_PASSWORD="${password}" export QIPAI_MYSQL_PASSWORD="${password}"
export QIPAI_MYSQL_CONNECTION_LIMIT=2 export QIPAI_MYSQL_CONNECTION_LIMIT=2
echo "INFO: MySQL ${mysql_version}; running M01-B through M04-B migration roundtrip in a temporary database." echo "INFO: MySQL ${mysql_version}; running M01-B through M04-C migration roundtrip in a temporary database."
npm --prefix backend run test:mysql:migration npm --prefix backend run test:mysql:migration
echo "PASS: M01-B through M04-B live MySQL migration roundtrip completed." echo "PASS: M01-B through M04-C live MySQL migration roundtrip completed."