feat(M04-C): 完成续费换房与订单调整
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user