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
+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.');