feat(M08-C): 补管理员订单处置与代下单
This commit is contained in:
@@ -671,6 +671,57 @@ async function assertPricingAndReservations(pool, context) {
|
||||
startAt, endAt, pricingMode: 'FULL_DAY'
|
||||
});
|
||||
assert.equal(replacement.quote.unitPriceCents, 9000);
|
||||
|
||||
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 adminId = String(adminRows[0].id);
|
||||
const managedStartAt = new Date(startAt.getTime() + 24 * 3600000);
|
||||
const managedEndAt = new Date(managedStartAt.getTime() + 2 * 3600000);
|
||||
const managed = await repository.reserve({
|
||||
tenantId: context.tenantId, userId: customerId, roomId,
|
||||
startAt: managedStartAt, endAt: managedEndAt, pricingMode: 'HOURLY',
|
||||
actor: {
|
||||
userId: adminId, source: 'ADMIN', traceId: 'm08c-reserve-on-behalf',
|
||||
ip: '127.0.0.1', userAgent: 'M08-C live manager booking test'
|
||||
}
|
||||
});
|
||||
const [managedHistory] = await pool.query(
|
||||
`SELECT actor_id AS actorId, source, CAST(metadata AS CHAR) AS metadata
|
||||
FROM qipai_order_status_history WHERE tenant_id = ? AND order_id = ?`,
|
||||
[context.tenantId, managed.orderId]
|
||||
);
|
||||
assert.equal(String(managedHistory[0].actorId), adminId);
|
||||
assert.equal(managedHistory[0].source, 'ADMIN');
|
||||
assert.equal(String(JSON.parse(managedHistory[0].metadata).targetUserId), customerId);
|
||||
const [managedAudit] = await pool.query(
|
||||
`SELECT actor_id AS actorId, action, CAST(metadata AS CHAR) AS metadata
|
||||
FROM qipai_audit_logs
|
||||
WHERE tenant_id = ? AND trace_id = 'm08c-reserve-on-behalf'`,
|
||||
[context.tenantId]
|
||||
);
|
||||
assert.equal(String(managedAudit[0].actorId), adminId);
|
||||
assert.equal(managedAudit[0].action, 'ORDER_CREATED_ON_BEHALF');
|
||||
assert.equal(String(JSON.parse(managedAudit[0].metadata).targetUserId), customerId);
|
||||
|
||||
const [foreignUser] = await pool.query(
|
||||
`INSERT INTO qipai_users (tenant_id, user_type, status)
|
||||
VALUES (?, 'CUSTOMER', 'ACTIVE')`,
|
||||
[String(Number(context.tenantId) + 1)]
|
||||
);
|
||||
await assert.rejects(
|
||||
() => repository.reserve({
|
||||
tenantId: context.tenantId, userId: String(foreignUser.insertId), roomId,
|
||||
startAt: new Date(managedStartAt.getTime() + 24 * 3600000),
|
||||
endAt: new Date(managedEndAt.getTime() + 24 * 3600000),
|
||||
pricingMode: 'HOURLY'
|
||||
}),
|
||||
(error) => error instanceof PricingError && error.code === 'USER_NOT_FOUND'
|
||||
);
|
||||
}
|
||||
|
||||
async function assertOrderStateMachine(pool, context) {
|
||||
@@ -1947,6 +1998,8 @@ try {
|
||||
'room price difference and manager time adjustment',
|
||||
'configured cancellation fee quote',
|
||||
'store-scoped on-behalf booking rejection',
|
||||
'manager on-behalf booking history and audit attribution',
|
||||
'cross-tenant on-behalf target user rejection',
|
||||
'share token stored as SHA-256 only',
|
||||
'default view and door permissions',
|
||||
'renew permission denied by default',
|
||||
|
||||
@@ -9,6 +9,7 @@ const token = signAccessToken({
|
||||
}, secret, 900);
|
||||
let called;
|
||||
let customerCalled;
|
||||
let noteCalled;
|
||||
const authRepository = {
|
||||
async validateSession() {
|
||||
return {
|
||||
@@ -44,7 +45,10 @@ const app = await buildApp({
|
||||
return { orderId, adjustmentType: 'CHANGE_ROOM', amountDeltaCents: -500 };
|
||||
},
|
||||
async adjustTime() { throw new Error('not called'); },
|
||||
async note() { throw new Error('not called'); },
|
||||
async note(actor, orderId, note) {
|
||||
noteCalled = { actor, orderId, note };
|
||||
return { orderId, note };
|
||||
},
|
||||
async cancellationQuote() {
|
||||
return { allowed: true, feeCents: 0, refundableCents: 3000 };
|
||||
}
|
||||
@@ -80,6 +84,18 @@ assert.equal(called.orderId, '31');
|
||||
assert.equal(called.actor.traceId, 'm04c-renew-route');
|
||||
assert.equal(called.body.pricingPolicy, 'LOCKED');
|
||||
|
||||
const managementNote = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/management/orders/31/note',
|
||||
headers: { authorization: `Bearer ${token}`, 'x-trace-id': 'm08c-management-note' },
|
||||
payload: { note: 'front desk confirmed member arrival' }
|
||||
});
|
||||
assert.equal(managementNote.statusCode, 200);
|
||||
assert.equal(noteCalled.orderId, '31');
|
||||
assert.equal(noteCalled.actor.source, 'ADMIN');
|
||||
assert.equal(noteCalled.actor.traceId, 'm08c-management-note');
|
||||
assert.equal(noteCalled.note, 'front desk confirmed member arrival');
|
||||
|
||||
const customerRenewed = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/orders/31/renew',
|
||||
|
||||
@@ -77,7 +77,7 @@ const app = await buildApp({
|
||||
|
||||
const listed = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/orders?page=2&pageSize=5&status=PENDING_PAYMENT',
|
||||
url: '/app-api/orders?page=2&pageSize=5&status=PENDING_PAYMENT&storeId=18',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(listed.statusCode, 200);
|
||||
@@ -87,6 +87,7 @@ assert.equal(listInput.userId, '21');
|
||||
assert.equal(listInput.page, 2);
|
||||
assert.equal(listInput.pageSize, 5);
|
||||
assert.equal(listInput.status, 'PENDING_PAYMENT');
|
||||
assert.equal(listInput.storeId, '18');
|
||||
|
||||
const detail = await app.inject({
|
||||
method: 'GET',
|
||||
|
||||
@@ -61,6 +61,21 @@ assert.equal(transitionInput.action, 'CONFIRM_PAYMENT');
|
||||
assert.equal(transitionInput.actor.source, 'ADMIN');
|
||||
assert.equal(transitionInput.actor.traceId, 'm04b-route-transition');
|
||||
|
||||
const managementTransition = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/management/orders/31/actions',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'x-trace-id': 'm08c-route-transition'
|
||||
},
|
||||
payload: { action: 'START', reason: 'front desk started the room' }
|
||||
});
|
||||
assert.equal(managementTransition.statusCode, 200);
|
||||
assert.equal(transitionInput.orderId, '31');
|
||||
assert.equal(transitionInput.action, 'START');
|
||||
assert.equal(transitionInput.actor.source, 'ADMIN');
|
||||
assert.equal(transitionInput.actor.traceId, 'm08c-route-transition');
|
||||
|
||||
const cancelled = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/orders/31/cancel',
|
||||
|
||||
@@ -20,6 +20,7 @@ const token = signAccessToken({
|
||||
rv: 1
|
||||
}, secret, 900);
|
||||
let reserveInput;
|
||||
let accessProfile = { roles: ['CUSTOMER'], capabilities: [], storeIds: [] };
|
||||
const app = await buildApp({
|
||||
pricing: {
|
||||
jwtSecret: secret,
|
||||
@@ -45,7 +46,7 @@ const app = await buildApp({
|
||||
},
|
||||
accessControl: {
|
||||
async getAccessProfile() {
|
||||
return { roles: ['CUSTOMER'], capabilities: [], storeIds: [] };
|
||||
return accessProfile;
|
||||
}
|
||||
},
|
||||
repository: {
|
||||
@@ -98,6 +99,41 @@ assert.deepEqual(reserveInput.benefits, {
|
||||
clientRequestId: 'benefit-route-0001'
|
||||
});
|
||||
|
||||
const forbiddenOnBehalf = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/management/orders/reserve-on-behalf',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
userId: '88', roomId: '11',
|
||||
startAt: startAt.toISOString(), endAt: endAt.toISOString(), pricingMode: 'HOURLY'
|
||||
}
|
||||
});
|
||||
assert.equal(forbiddenOnBehalf.statusCode, 403);
|
||||
|
||||
accessProfile = {
|
||||
roles: ['STORE_ADMIN'],
|
||||
capabilities: ['store.operation.write'],
|
||||
storeIds: ['18']
|
||||
};
|
||||
const reservedOnBehalf = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/management/orders/reserve-on-behalf',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
userId: '88', roomId: '11',
|
||||
startAt: startAt.toISOString(), endAt: endAt.toISOString(), pricingMode: 'HOURLY'
|
||||
}
|
||||
});
|
||||
assert.equal(reservedOnBehalf.statusCode, 201);
|
||||
assert.equal(reserveInput.userId, '88');
|
||||
assert.deepEqual(reserveInput.allowedStoreIds, ['18']);
|
||||
assert.equal('benefits' in reserveInput, false);
|
||||
assert.equal(reserveInput.actor.userId, '21');
|
||||
assert.equal(reserveInput.actor.source, 'ADMIN');
|
||||
assert.ok(reserveInput.actor.traceId);
|
||||
assert.match(pricingSource, /tenant_id = \? AND id = \? AND status = 'ACTIVE'/);
|
||||
assert.match(pricingSource, /ORDER_CREATED_ON_BEHALF/);
|
||||
|
||||
const unauthenticated = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/pricing/quote',
|
||||
|
||||
Reference in New Issue
Block a user