feat(M08-C): 补管理员验券与经营统计
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildApp } from '../dist/app.js';
|
||||
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||
import {
|
||||
BusinessStatisticsError,
|
||||
BusinessStatisticsRepository
|
||||
} from '../dist/operations/business-statistics-repository.js';
|
||||
|
||||
const actor = {
|
||||
tenantId: '7',
|
||||
userId: '22',
|
||||
access: {
|
||||
roles: ['STORE_ADMIN'],
|
||||
capabilities: ['store.operation.read'],
|
||||
storeIds: ['11']
|
||||
},
|
||||
traceId: 'business-stats-test',
|
||||
ip: '127.0.0.1',
|
||||
userAgent: 'test'
|
||||
};
|
||||
|
||||
const repository = new BusinessStatisticsRepository({
|
||||
async execute(sql) {
|
||||
if (sql.includes('FROM qipai_orders o') && sql.includes('COUNT(DISTINCT owner.user_id)')) {
|
||||
return [[{
|
||||
orderTotal: 12,
|
||||
activeOrderTotal: 3,
|
||||
finishedOrderTotal: 7,
|
||||
bookedAmountCents: 36000,
|
||||
payingMemberTotal: 9
|
||||
}], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_orders o') && sql.includes('GROUP BY o.status')) {
|
||||
return [[{ status: 'FINISHED', total: 7, amountCents: 21000 }], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_payments p') && sql.includes('GROUP BY p.channel')) {
|
||||
return [[
|
||||
{ channel: 'WECHAT', total: 5, amountCents: 15000 },
|
||||
{ channel: 'GROUP_BUY', total: 2, amountCents: 6000 }
|
||||
], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_payments p') && sql.includes('DATE_FORMAT')) {
|
||||
return [[{ date: '2026-08-10', total: 7, amountCents: 21000 }], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_rooms')) {
|
||||
return [[{ roomTotal: 8, availableRoomTotal: 4, attentionRoomTotal: 1 }], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_group_redemptions')) {
|
||||
return [[{ voucherTotal: 3, voucherSucceeded: 2, voucherFailed: 1 }], []];
|
||||
}
|
||||
throw new Error(`Unexpected statistics SQL: ${sql}`);
|
||||
}
|
||||
});
|
||||
|
||||
const overview = await repository.overview(actor, {
|
||||
storeId: '11',
|
||||
from: new Date('2026-08-01T00:00:00.000Z'),
|
||||
to: new Date('2026-09-01T00:00:00.000Z')
|
||||
});
|
||||
assert.equal(overview.summary.orderTotal, 12);
|
||||
assert.equal(overview.summary.collectedAmountCents, 21000);
|
||||
assert.equal(overview.summary.voucherSucceeded, 2);
|
||||
assert.equal(overview.summary.availableRoomTotal, 4);
|
||||
assert.equal(overview.dailyRevenue[0].date, '2026-08-10');
|
||||
await assert.rejects(
|
||||
() => repository.overview(actor, {
|
||||
storeId: '12',
|
||||
from: new Date('2026-08-01T00:00:00.000Z'),
|
||||
to: new Date('2026-09-01T00:00:00.000Z')
|
||||
}),
|
||||
(error) => error instanceof BusinessStatisticsError
|
||||
&& error.code === 'BUSINESS_STATISTICS_FORBIDDEN'
|
||||
);
|
||||
|
||||
const secret = 'business-statistics-secret-with-32-characters';
|
||||
const token = signAccessToken({
|
||||
sub: '22', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
|
||||
tid: '7', aid: '9', rv: 1
|
||||
}, secret, 900);
|
||||
let routed;
|
||||
const app = await buildApp({
|
||||
businessStatistics: {
|
||||
jwtSecret: secret,
|
||||
authRepository: {
|
||||
async validateSession() {
|
||||
return {
|
||||
id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
|
||||
tenantId: '7', platformAppId: '9', expiresAt: new Date(Date.now() + 60000),
|
||||
user: {
|
||||
id: '22', tenantId: '7', userType: 'STAFF', status: 'ACTIVE',
|
||||
roleVersion: 1, nickname: '', avatarUrl: '', phone: ''
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
accessControl: { async getAccessProfile() { return actor.access; } },
|
||||
repository: {
|
||||
async overview(routedActor, input) {
|
||||
routed = { actor: routedActor, input };
|
||||
return overview;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/management/statistics?storeId=11&from=2026-08-01T00%3A00%3A00.000Z&to=2026-09-01T00%3A00%3A00.000Z',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.json().data.summary.collectedAmountCents, 21000);
|
||||
assert.equal(routed.actor.userId, '22');
|
||||
assert.equal(routed.input.storeId, '11');
|
||||
const invalidRange = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/management/statistics?storeId=11&from=2026-01-01&to=2026-09-01',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(invalidRange.statusCode, 400);
|
||||
await app.close();
|
||||
|
||||
console.log('PASS: M08-C business statistics enforce store scope and aggregate stable metrics.');
|
||||
@@ -40,6 +40,7 @@ import { IotMessageService } from '../dist/devices/iot-message-service.js';
|
||||
import { DeviceCommandService } from '../dist/devices/device-command-service.js';
|
||||
import { DeviceControlService } from '../dist/devices/device-control-service.js';
|
||||
import { MemberProfileService } from '../dist/wallets/member-profile-service.js';
|
||||
import { BusinessStatisticsRepository } from '../dist/operations/business-statistics-repository.js';
|
||||
import {
|
||||
executeMigrationPlan,
|
||||
loadMigrationPlan,
|
||||
@@ -1322,6 +1323,64 @@ async function assertThirdPartyDomain(pool, context) {
|
||||
assert.equal(voucherRows[0].voucherMasked.includes('SENSITIVE'), false);
|
||||
assert.equal(Number(voucherRows[0].redemptionCount), 1);
|
||||
|
||||
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 adminAccess = await new RbacRepository(pool).getAccessProfile(context.tenantId, adminId);
|
||||
const managerStartAt = new Date(endAt.getTime() + 3600000);
|
||||
const managerEndAt = new Date(managerStartAt.getTime() + 2 * 3600000);
|
||||
const managerOrder = await pricing.reserve({
|
||||
tenantId: context.tenantId,
|
||||
userId: customerId,
|
||||
roomId,
|
||||
startAt: managerStartAt,
|
||||
endAt: managerEndAt,
|
||||
pricingMode: 'HOURLY'
|
||||
});
|
||||
const managerRedeemed = await service.redeemVoucherAsManager({
|
||||
tenantId: context.tenantId,
|
||||
actorId: adminId,
|
||||
access: adminAccess,
|
||||
provider: 'MEITUAN',
|
||||
voucherCode: 'M08C-MANAGER-VOUCHER-002',
|
||||
orderId: managerOrder.orderId,
|
||||
clientRequestId: 'm08c-manager-redeem-002'
|
||||
});
|
||||
assert.equal(managerRedeemed.status, 'SUCCEEDED');
|
||||
const managerRecords = await service.listRecords({
|
||||
tenantId: context.tenantId,
|
||||
access: adminAccess,
|
||||
provider: 'MEITUAN',
|
||||
status: 'SUCCEEDED',
|
||||
storeId
|
||||
});
|
||||
const managerRecord = managerRecords.redemptions.find(
|
||||
(item) => String(item.id) === managerRedeemed.redemptionId
|
||||
);
|
||||
assert.equal(String(managerRecord.actorId), adminId);
|
||||
assert.equal(managerRecord.voucherMasked.includes('MANAGER'), false);
|
||||
const statistics = await new BusinessStatisticsRepository(pool).overview({
|
||||
tenantId: context.tenantId,
|
||||
userId: adminId,
|
||||
access: adminAccess,
|
||||
traceId: 'm08c-business-statistics',
|
||||
ip: '127.0.0.1',
|
||||
userAgent: 'M08-C MySQL test'
|
||||
}, {
|
||||
storeId,
|
||||
from: new Date(Date.now() - 86400000),
|
||||
to: new Date(Date.now() + 2 * 86400000)
|
||||
});
|
||||
assert.ok(statistics.summary.orderTotal >= 2);
|
||||
assert.ok(statistics.summary.collectedAmountCents > 0);
|
||||
assert.ok(statistics.summary.voucherSucceeded >= 2);
|
||||
assert.ok(statistics.paymentChannels.some((item) => item.channel === 'GROUP_BUY'));
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO qipai_third_party_mappings
|
||||
(tenant_id, provider, resource_type, external_ref, local_resource_id)
|
||||
@@ -2077,6 +2136,8 @@ try {
|
||||
'Wechat reconciliation request history'
|
||||
,
|
||||
'group voucher hash-only storage and single redemption',
|
||||
'manager voucher redemption actor and store attribution',
|
||||
'store-scoped business statistics from orders and successful payments',
|
||||
'third-party booking webhook idempotency',
|
||||
'mapped booking claim creates a paid order',
|
||||
'unmapped booking enters manual queue'
|
||||
|
||||
@@ -62,6 +62,9 @@ const token = signAccessToken({
|
||||
tid: '7', aid: '9', rv: 1
|
||||
}, secret, 900);
|
||||
let redeemInput;
|
||||
let managerRedeemInput;
|
||||
let manualRedeemInput;
|
||||
let recordsInput;
|
||||
let notifyInput;
|
||||
const routeApp = await buildApp({
|
||||
thirdParty: {
|
||||
@@ -94,9 +97,14 @@ const routeApp = await buildApp({
|
||||
redeemInput = input;
|
||||
return { redemptionId: '51', status: 'SUCCEEDED' };
|
||||
},
|
||||
async redeemVoucherManually() {
|
||||
async redeemVoucherManually(input) {
|
||||
manualRedeemInput = input;
|
||||
return { redemptionId: '52', status: 'SUCCEEDED' };
|
||||
},
|
||||
async redeemVoucherAsManager(input) {
|
||||
managerRedeemInput = input;
|
||||
return { redemptionId: '53', status: 'SUCCEEDED', voucherMasked: '12******90' };
|
||||
},
|
||||
async receiveDirectBooking(input) {
|
||||
notifyInput = input;
|
||||
return { bookingId: '61', status: 'PENDING_MAPPING' };
|
||||
@@ -104,7 +112,8 @@ const routeApp = await buildApp({
|
||||
async claimDirectBooking() {
|
||||
return { bookingId: '61', orderId: '71' };
|
||||
},
|
||||
async listRecords() {
|
||||
async listRecords(input) {
|
||||
recordsInput = input;
|
||||
return { bookings: [], redemptions: [] };
|
||||
},
|
||||
async saveConfig() {
|
||||
@@ -131,6 +140,43 @@ const redeemed = await routeApp.inject({
|
||||
assert.equal(redeemed.statusCode, 200);
|
||||
assert.equal(redeemInput.userId, '21');
|
||||
|
||||
const managerRedeemed = await routeApp.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/management/group-vouchers/redeem',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
provider: 'DOUYIN', voucherCode: '9876543210', orderId: '31',
|
||||
clientRequestId: 'manager-redeem-request-001'
|
||||
}
|
||||
});
|
||||
assert.equal(managerRedeemed.statusCode, 200);
|
||||
assert.equal(managerRedeemInput.actorId, '21');
|
||||
assert.equal(managerRedeemInput.provider, 'DOUYIN');
|
||||
|
||||
const manualRedeemed = await routeApp.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/management/group-vouchers/redeem-manual',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
provider: 'MEITUAN', voucherCode: '1234567890', orderId: '31',
|
||||
amountCents: 3600, note: 'merchant confirmed',
|
||||
clientRequestId: 'manager-manual-request-001'
|
||||
}
|
||||
});
|
||||
assert.equal(manualRedeemed.statusCode, 200);
|
||||
assert.equal(manualRedeemInput.actorId, '21');
|
||||
assert.equal(manualRedeemInput.amountCents, 3600);
|
||||
|
||||
const records = await routeApp.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/management/third-party/records?storeId=11&provider=MEITUAN&status=SUCCEEDED',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(records.statusCode, 200);
|
||||
assert.equal(recordsInput.storeId, '11');
|
||||
assert.equal(recordsInput.provider, 'MEITUAN');
|
||||
assert.equal(recordsInput.status, 'SUCCEEDED');
|
||||
|
||||
const bookingPayload = {
|
||||
eventId: 'event-001',
|
||||
externalBookingNo: 'booking-001',
|
||||
|
||||
Reference in New Issue
Block a user