diff --git a/backend/package.json b/backend/package.json index 4cb64b8..57cb847 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,7 +17,7 @@ "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", "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/mqtt-service.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-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs" + "test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.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-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs" }, "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/backend/src/app.ts b/backend/src/app.ts index e4c8ad1..6900bed 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -51,6 +51,10 @@ import { import { registerMemberRoutes, type MemberRouteOptions } from './routes/members.js'; import { registerRechargeRoutes, type RechargeRouteOptions } from './routes/recharge.js'; import { registerCleaningRoutes, type CleaningRouteOptions } from './routes/cleaning.js'; +import { + registerBusinessStatisticsRoutes, + type BusinessStatisticsRouteOptions +} from './routes/business-statistics.js'; export interface BuildAppOptions { config?: AppConfig; @@ -74,6 +78,7 @@ export interface BuildAppOptions { members?: MemberRouteOptions; recharge?: RechargeRouteOptions; cleaning?: CleaningRouteOptions; + businessStatistics?: BusinessStatisticsRouteOptions; } declare module 'fastify' { @@ -175,6 +180,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise( + `SELECT COUNT(*) AS orderTotal, + COALESCE(SUM(o.status IN ('PAID', 'RESERVED', 'IN_PROGRESS')), 0) AS activeOrderTotal, + COALESCE(SUM(o.status = 'FINISHED'), 0) AS finishedOrderTotal, + COALESCE(SUM(o.total_amount_cents), 0) AS bookedAmountCents, + COUNT(DISTINCT owner.user_id) AS payingMemberTotal + FROM qipai_orders o + LEFT JOIN ( + SELECT tenant_id, order_id, MIN(user_id) AS user_id + FROM qipai_order_user_access + WHERE access_type = 'OWNER' + GROUP BY tenant_id, order_id + ) owner ON owner.tenant_id = o.tenant_id AND owner.order_id = o.id + WHERE o.tenant_id = ? AND o.store_id = ? AND o.deleted_at IS NULL + AND o.created_at >= ? AND o.created_at < ?`, + rangeParams + ), + this.pool.execute( + `SELECT o.status, COUNT(*) AS total, + COALESCE(SUM(o.total_amount_cents), 0) AS amountCents + FROM qipai_orders o + WHERE o.tenant_id = ? AND o.store_id = ? AND o.deleted_at IS NULL + AND o.created_at >= ? AND o.created_at < ? + GROUP BY o.status ORDER BY total DESC, o.status`, + rangeParams + ), + this.pool.execute( + `SELECT p.channel, COUNT(*) AS total, + COALESCE(SUM(p.amount_cents), 0) AS amountCents + FROM qipai_payments p + WHERE p.tenant_id = ? AND p.store_id = ? AND p.status = 'SUCCEEDED' + AND p.paid_at >= ? AND p.paid_at < ? + GROUP BY p.channel ORDER BY amountCents DESC, p.channel`, + rangeParams + ), + this.pool.execute( + `SELECT DATE_FORMAT(p.paid_at, '%Y-%m-%d') AS date, COUNT(*) AS total, + COALESCE(SUM(p.amount_cents), 0) AS amountCents + FROM qipai_payments p + WHERE p.tenant_id = ? AND p.store_id = ? AND p.status = 'SUCCEEDED' + AND p.paid_at >= ? AND p.paid_at < ? + GROUP BY DATE_FORMAT(p.paid_at, '%Y-%m-%d') ORDER BY date`, + rangeParams + ), + this.pool.execute( + `SELECT COUNT(*) AS roomTotal, + COALESCE(SUM(configuration_status = 'ENABLED' + AND operational_status = 'AVAILABLE'), 0) AS availableRoomTotal, + COALESCE(SUM(configuration_status = 'DISABLED' + OR operational_status IN ('MAINTENANCE', 'CLEANING_REQUIRED')), 0) AS attentionRoomTotal + FROM qipai_rooms + WHERE tenant_id = ? AND store_id = ? AND deleted_at IS NULL`, + [actor.tenantId, input.storeId] + ), + this.pool.execute( + `SELECT COUNT(*) AS voucherTotal, + COALESCE(SUM(status = 'SUCCEEDED'), 0) AS voucherSucceeded, + COALESCE(SUM(status = 'FAILED'), 0) AS voucherFailed + FROM qipai_group_redemptions + WHERE tenant_id = ? AND store_id = ? AND created_at >= ? AND created_at < ?`, + rangeParams + ) + ]); + const order = orderSummaryResult[0][0]; + const room = roomSummaryResult[0][0]; + const voucher = voucherSummaryResult[0][0]; + const paymentChannels = paymentChannelResult[0].map((row) => ({ + channel: row.channel, + total: Number(row.total), + amountCents: Number(row.amountCents) + })); + return { + storeId: input.storeId, + from: input.from.toISOString(), + to: input.to.toISOString(), + summary: { + orderTotal: Number(order?.orderTotal ?? 0), + activeOrderTotal: Number(order?.activeOrderTotal ?? 0), + finishedOrderTotal: Number(order?.finishedOrderTotal ?? 0), + bookedAmountCents: Number(order?.bookedAmountCents ?? 0), + collectedAmountCents: paymentChannels.reduce((sum, row) => sum + row.amountCents, 0), + payingMemberTotal: Number(order?.payingMemberTotal ?? 0), + voucherTotal: Number(voucher?.voucherTotal ?? 0), + voucherSucceeded: Number(voucher?.voucherSucceeded ?? 0), + voucherFailed: Number(voucher?.voucherFailed ?? 0), + roomTotal: Number(room?.roomTotal ?? 0), + availableRoomTotal: Number(room?.availableRoomTotal ?? 0), + attentionRoomTotal: Number(room?.attentionRoomTotal ?? 0) + }, + orderStatuses: orderStatusResult[0].map((row) => ({ + status: row.status, + total: Number(row.total), + amountCents: Number(row.amountCents) + })), + paymentChannels, + dailyRevenue: dailyRevenueResult[0].map((row) => ({ + date: formatDate(row.date), + total: Number(row.total), + amountCents: Number(row.amountCents) + })) + }; + } + + private assertStoreScope(actor: ManagementActor, storeId: string) { + if (actor.access.capabilities.includes('tenant.manage') + || actor.access.roles.includes('PLATFORM_ADMIN')) return; + if (!actor.access.capabilities.includes('store.operation.read') + || !actor.access.storeIds.includes(storeId)) { + throw new BusinessStatisticsError('BUSINESS_STATISTICS_FORBIDDEN'); + } + } +} + +function formatDate(value: string | Date) { + if (typeof value === 'string') return value.slice(0, 10); + return value.toISOString().slice(0, 10); +} diff --git a/backend/src/routes/business-statistics.ts b/backend/src/routes/business-statistics.ts new file mode 100644 index 0000000..20c7bdd --- /dev/null +++ b/backend/src/routes/business-statistics.ts @@ -0,0 +1,94 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } 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 type { ManagementActor } from '../auth/user-management-repository.js'; +import { + BusinessStatisticsError, + type BusinessStatisticsRepository +} from '../operations/business-statistics-repository.js'; + +const querySchema = z.object({ + storeId: z.string().regex(/^[1-9]\d{0,19}$/), + from: z.coerce.date().optional(), + to: z.coerce.date().optional() +}).strict(); + +export interface BusinessStatisticsRouteOptions { + repository: Pick; + authRepository: Pick; + accessControl: { getAccessProfile(tenantId: string, userId: string): Promise }; + jwtSecret: string; +} + +export async function registerBusinessStatisticsRoutes( + app: FastifyInstance, + options: BusinessStatisticsRouteOptions +) { + app.get('/app-api/management/statistics', async (request, reply) => { + const actor = await requireActor(request, reply, options); + if (!actor) return; + const query = querySchema.safeParse(request.query); + if (!query.success) return invalid(reply, request.traceId); + const to = query.data.to ?? new Date(); + const from = query.data.from ?? new Date(to.getTime() - 30 * 86400000); + const duration = to.getTime() - from.getTime(); + if (duration <= 0 || duration > 93 * 86400000) return invalid(reply, request.traceId); + try { + return { + code: 0, + data: await options.repository.overview(actor, { + storeId: query.data.storeId, + from, + to + }), + traceId: request.traceId + }; + } catch (error) { + if (!(error instanceof BusinessStatisticsError)) throw error; + return reply.status(403).send({ + code: error.code, + message: 'Business statistics permission is required.', + traceId: request.traceId + }); + } + }); +} + +async function requireActor( + request: FastifyRequest, + reply: FastifyReply, + options: BusinessStatisticsRouteOptions +): Promise { + const auth = await authenticateAccessToken( + request.headers.authorization, options.authRepository, options.jwtSecret + ); + if (!auth) { + reply.status(401).send({ + code: 'AUTH_SESSION_INVALID', + message: 'Authentication required.', + traceId: request.traceId + }); + return null; + } + const access = await options.accessControl.getAccessProfile( + auth.session.tenantId, auth.session.user.id + ); + return { + tenantId: auth.session.tenantId, + userId: auth.session.user.id, + access, + traceId: request.traceId, + ip: request.ip, + userAgent: request.headers['user-agent'] ?? '' + }; +} + +function invalid(reply: FastifyReply, traceId: string) { + return reply.status(400).send({ + code: 'INVALID_BUSINESS_STATISTICS_REQUEST', + message: 'The statistics request is invalid.', + traceId + }); +} diff --git a/backend/src/routes/third-party.ts b/backend/src/routes/third-party.ts index b71d5c5..8096094 100644 --- a/backend/src/routes/third-party.ts +++ b/backend/src/routes/third-party.ts @@ -36,7 +36,8 @@ const bookingSchema = z.object({ const bookingParams = z.object({ bookingId: z.string().regex(/^[1-9]\d{0,19}$/) }); const recordsQuery = z.object({ provider: providerSchema.optional(), - status: z.string().min(1).max(32).optional() + status: z.string().min(1).max(32).optional(), + storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional() }); const configSchema = z.object({ provider: providerSchema, @@ -99,6 +100,40 @@ export async function registerThirdPartyRoutes( })); }); + app.post('/app-api/management/group-vouchers/redeem', async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options, true); + const body = redeemSchema.safeParse(request.body); + if (!auth) return unauthorized(reply, request.traceId); + if (!body.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.service.redeemVoucherAsManager({ + tenantId: auth.tenantId, + actorId: auth.userId, + access: auth.access!, + ...body.data + }), + traceId: request.traceId + })); + }); + + app.post('/app-api/management/group-vouchers/redeem-manual', async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options, true); + const body = manualSchema.safeParse(request.body); + if (!auth) return unauthorized(reply, request.traceId); + if (!body.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.service.redeemVoucherManually({ + tenantId: auth.tenantId, + actorId: auth.userId, + access: auth.access!, + ...body.data + }), + traceId: request.traceId + })); + }); + app.post( '/app-api/third-party/:provider/tenants/:tenantId/bookings/notify', async (request, reply) => { @@ -150,7 +185,26 @@ export async function registerThirdPartyRoutes( tenantId: auth.tenantId, access: auth.access!, provider: query.data.provider as ThirdPartyProvider | undefined, - status: query.data.status + status: query.data.status, + storeId: query.data.storeId + }), + traceId: request.traceId + })); + }); + + app.get('/app-api/management/third-party/records', async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options, true); + const query = recordsQuery.safeParse(request.query); + if (!auth) return unauthorized(reply, request.traceId); + if (!query.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.service.listRecords({ + tenantId: auth.tenantId, + access: auth.access!, + provider: query.data.provider as ThirdPartyProvider | undefined, + status: query.data.status, + storeId: query.data.storeId }), traceId: request.traceId })); diff --git a/backend/src/server.ts b/backend/src/server.ts index f8e6614..8bb0084 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -39,6 +39,7 @@ import { WalletLedgerService } from './wallets/wallet-ledger-service.js'; import { MarketingBenefitService } from './wallets/marketing-benefit-service.js'; import { CleaningTaskRepository } from './cleaning/cleaning-task-repository.js'; import { CleaningPayoutService } from './cleaning/cleaning-payout-service.js'; +import { BusinessStatisticsRepository } from './operations/business-statistics-repository.js'; const config = loadConfig(); const pool = createMySqlPool(config); @@ -202,6 +203,12 @@ const app = await buildApp({ authRepository, accessControl, jwtSecret: config.auth.jwtSecret + }, + businessStatistics: { + repository: new BusinessStatisticsRepository(pool), + authRepository, + accessControl, + jwtSecret: config.auth.jwtSecret } }); app.addHook('onClose', async () => { diff --git a/backend/src/third-party/third-party-service.ts b/backend/src/third-party/third-party-service.ts index 58e5816..9551a06 100644 --- a/backend/src/third-party/third-party-service.ts +++ b/backend/src/third-party/third-party-service.ts @@ -119,6 +119,45 @@ export class ThirdPartyService { }); } + async redeemVoucherAsManager(input: { + tenantId: string; + actorId: string; + access: AccessProfile; + provider: ThirdPartyProvider; + voucherCode: string; + orderId: string; + clientRequestId: string; + }) { + const existing = await this.findRedemptionByRequest( + input.tenantId, input.clientRequestId, input.orderId + ); + if (existing) return { ...existing, idempotent: true }; + const order = await this.loadOrder(input.tenantId, input.orderId); + assertStoreAccess(input.access, order.storeId); + const config = await this.resolveConfig(input.tenantId, order.storeId, input.provider); + const expectedAmountCents = Number(order.totalAmountCents) - Number(order.paidAmountCents); + if (expectedAmountCents <= 0) throw new ThirdPartyError('PAYMENT_NOT_REQUIRED'); + const result = await this.client.redeemVoucher({ + mode: config.mode, + provider: input.provider, + voucherCode: input.voucherCode, + orderNo: order.orderNo, + expectedAmountCents, + settings: config.settings, + credential: this.resolveCredential(config.credentialRef) + }); + return this.recordRedemption({ + ...input, + mode: config.mode, + amountCents: result.amountCents, + expectedAmountCents, + externalProductId: result.externalProductId ?? '', + status: result.status, + failureCode: result.failureCode ?? '', + response: result.response ?? {} + }); + } + async receiveDirectBooking(input: { tenantId: string; provider: ThirdPartyProvider; @@ -242,26 +281,31 @@ export class ThirdPartyService { access: AccessProfile; provider?: ThirdPartyProvider; status?: string; + storeId?: string; }) { - const storeFilter = input.access.capabilities.includes('tenant.manage') - || input.access.roles.includes('PLATFORM_ADMIN') - ? null : input.access.storeIds; + const unrestricted = input.access.capabilities.includes('tenant.manage') + || input.access.roles.includes('PLATFORM_ADMIN'); + if (input.storeId && !unrestricted && !input.access.storeIds.includes(input.storeId)) { + throw new ThirdPartyError('STORE_SCOPE_FORBIDDEN'); + } + const storeFilter = input.storeId ? [input.storeId] + : unrestricted ? null : input.access.storeIds; if (storeFilter && storeFilter.length === 0) { throw new ThirdPartyError('STORE_SCOPE_FORBIDDEN'); } - const params: string[] = [input.tenantId]; - const filters = ['tenant_id = ?']; + const bookingParams: string[] = [input.tenantId]; + const bookingFilters = ['tenant_id = ?']; if (input.provider) { - filters.push('provider = ?'); - params.push(input.provider); + bookingFilters.push('provider = ?'); + bookingParams.push(input.provider); } if (input.status) { - filters.push('status = ?'); - params.push(input.status); + bookingFilters.push('status = ?'); + bookingParams.push(input.status); } if (storeFilter) { - filters.push(`store_id IN (${storeFilter.map(() => '?').join(',')})`); - params.push(...storeFilter); + bookingFilters.push(`store_id IN (${storeFilter.map(() => '?').join(',')})`); + bookingParams.push(...storeFilter); } const [bookings] = await this.pool.execute( `SELECT id, provider, external_booking_no AS externalBookingNo, @@ -269,20 +313,34 @@ export class ThirdPartyService { ends_at AS endsAt, amount_cents AS amountCents, status, order_id AS orderId, failure_code AS failureCode, created_at AS createdAt FROM qipai_direct_bookings - WHERE ${filters.join(' AND ')} ORDER BY id DESC LIMIT 100`, - params + WHERE ${bookingFilters.join(' AND ')} ORDER BY id DESC LIMIT 100`, + bookingParams ); + const redemptionParams: string[] = [input.tenantId]; + const redemptionFilters = ['r.tenant_id = ?']; + if (input.provider) { + redemptionFilters.push('v.provider = ?'); + redemptionParams.push(input.provider); + } + if (input.status) { + redemptionFilters.push('r.status = ?'); + redemptionParams.push(input.status); + } + if (storeFilter) { + redemptionFilters.push(`r.store_id IN (${storeFilter.map(() => '?').join(',')})`); + redemptionParams.push(...storeFilter); + } const [redemptions] = await this.pool.execute( `SELECT r.id, v.provider, v.voucher_masked AS voucherMasked, r.order_id AS orderId, r.store_id AS storeId, r.redemption_mode AS mode, - r.status, r.failure_code AS failureCode, r.created_at AS createdAt + r.actor_id AS actorId, r.status, r.failure_code AS failureCode, + r.created_at AS createdAt, r.completed_at AS completedAt FROM qipai_group_redemptions r INNER JOIN qipai_group_vouchers v ON v.tenant_id = r.tenant_id AND v.id = r.voucher_id - WHERE r.tenant_id = ? - ${storeFilter ? `AND r.store_id IN (${storeFilter.map(() => '?').join(',')})` : ''} + WHERE ${redemptionFilters.join(' AND ')} ORDER BY r.id DESC LIMIT 100`, - storeFilter ? [input.tenantId, ...storeFilter] : [input.tenantId] + redemptionParams ); return { bookings, redemptions }; } diff --git a/backend/tests/business-statistics.test.mjs b/backend/tests/business-statistics.test.mjs new file mode 100644 index 0000000..20cdd3e --- /dev/null +++ b/backend/tests/business-statistics.test.mjs @@ -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.'); diff --git a/backend/tests/mysql-migration-roundtrip.test.mjs b/backend/tests/mysql-migration-roundtrip.test.mjs index fdc33fd..9b1e82c 100644 --- a/backend/tests/mysql-migration-roundtrip.test.mjs +++ b/backend/tests/mysql-migration-roundtrip.test.mjs @@ -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' diff --git a/backend/tests/third-party.test.mjs b/backend/tests/third-party.test.mjs index 3e9a5c5..653efdd 100644 --- a/backend/tests/third-party.test.mjs +++ b/backend/tests/third-party.test.mjs @@ -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', diff --git a/miniapp/app.json b/miniapp/app.json index f0d1b62..ff3bcd8 100644 --- a/miniapp/app.json +++ b/miniapp/app.json @@ -10,6 +10,7 @@ "pages/recharge/index", "pages/cleaner/tasks", "pages/manager/dashboard", + "pages/manager/business", "pages/manager/operations", "pages/manager/people", "pages/manager/order-create", diff --git a/miniapp/pages/manager/business.js b/miniapp/pages/manager/business.js new file mode 100644 index 0000000..d8807d1 --- /dev/null +++ b/miniapp/pages/manager/business.js @@ -0,0 +1,223 @@ +const { request, cents, clientRequestId, ensureLogin } = require('../../utils/api.js') + +const providers = [ + { value: 'MEITUAN', label: '美团' }, + { value: 'DIANPING', label: '大众点评' }, + { value: 'DOUYIN', label: '抖音' }, + { value: 'KUAISHOU', label: '快手' }, +] + +const providerLabels = Object.fromEntries(providers.map((item) => [item.value, item.label])) + +Page({ + data: { + storeId: '', + storeName: '', + loading: false, + redeeming: false, + errorMessage: '', + canRedeem: false, + rangeDays: 30, + ranges: [7, 30, 90], + summary: { + orderTotal: 0, + collectedAmountText: '¥0.00', + payingMemberTotal: 0, + voucherSucceeded: 0, + availableRoomTotal: 0, + attentionRoomTotal: 0, + }, + paymentChannels: [], + dailyRevenue: [], + providers, + providerIndex: 0, + pendingOrders: [], + orderIndex: 0, + voucherCode: '', + manualNote: '', + redemptionRecords: [], + }, + + async onLoad(options) { + this.setData({ + storeId: options.storeId || '', + storeName: options.storeName || '', + }) + await this.loadBusiness() + }, + + async onPullDownRefresh() { + await this.loadBusiness() + wx.stopPullDownRefresh() + }, + + async loadBusiness() { + if (!this.data.storeId) { + this.setData({ errorMessage: '缺少门店信息' }) + return + } + this.setData({ loading: true, errorMessage: '' }) + try { + await ensureLogin() + const me = await request('/auth/me') + const access = me.data?.access || { roles: [], capabilities: [] } + const canRedeem = access.capabilities.includes('store.operation.write') + || access.capabilities.includes('tenant.manage') + || access.roles.includes('PLATFORM_ADMIN') + this.setData({ canRedeem }) + const storeId = encodeURIComponent(this.data.storeId) + const to = new Date() + const from = new Date(to.getTime() - this.data.rangeDays * 86400000) + const [statistics, orders, records] = await Promise.all([ + request(`/management/statistics?storeId=${storeId}&from=${encodeURIComponent(from.toISOString())}&to=${encodeURIComponent(to.toISOString())}`), + canRedeem + ? request(`/orders?page=1&pageSize=50&storeId=${storeId}&status=PENDING_PAYMENT`) + : Promise.resolve({ data: { items: [] } }), + request(`/management/third-party/records?storeId=${storeId}`), + ]) + this.presentStatistics(statistics.data || {}) + const pendingOrders = (orders.data?.items || []).map((item) => { + const unpaidAmountCents = Math.max(0, Number(item.totalAmountCents || 0) - Number(item.paidAmountCents || 0)) + return { + ...item, + unpaidAmountCents, + label: `${item.orderNo} · ${item.roomName || item.roomNo || '房间'} · ${cents(unpaidAmountCents)}`, + } + }) + const redemptionRecords = (records.data?.redemptions || []).map((item) => ({ + ...item, + providerText: providerLabels[item.provider] || item.provider, + statusText: this.redemptionStatus(item.status), + timeText: this.formatTime(item.completedAt || item.createdAt), + })) + this.setData({ + pendingOrders, + orderIndex: Math.min(this.data.orderIndex, Math.max(0, pendingOrders.length - 1)), + redemptionRecords, + }) + } catch (error) { + this.setData({ errorMessage: error.message || '经营数据加载失败' }) + } finally { + this.setData({ loading: false }) + } + }, + + presentStatistics(data) { + const summary = data.summary || {} + this.setData({ + summary: { + ...summary, + collectedAmountText: cents(summary.collectedAmountCents), + }, + paymentChannels: (data.paymentChannels || []).map((item) => ({ + ...item, + amountText: cents(item.amountCents), + })), + dailyRevenue: (data.dailyRevenue || []).slice(-14).reverse().map((item) => ({ + ...item, + amountText: cents(item.amountCents), + })), + }) + }, + + selectRange(event) { + const rangeDays = Number(event.currentTarget.dataset.days) + if (!rangeDays || rangeDays === this.data.rangeDays) return + this.setData({ rangeDays }) + this.loadBusiness() + }, + + selectProvider(event) { + this.setData({ providerIndex: Number(event.detail.value) || 0 }) + }, + + selectOrder(event) { + this.setData({ orderIndex: Number(event.detail.value) || 0 }) + }, + + inputVoucherCode(event) { + this.setData({ voucherCode: String(event.detail.value || '').trim() }) + }, + + inputManualNote(event) { + this.setData({ manualNote: String(event.detail.value || '').trim() }) + }, + + scanVoucher() { + if (!this.data.canRedeem) return + wx.scanCode({ + scanType: ['barCode', 'qrCode'], + success: ({ result }) => this.setData({ voucherCode: String(result || '').trim() }), + fail: (error) => { + if (!String(error.errMsg || '').includes('cancel')) { + this.setData({ errorMessage: '扫码失败,请手工输入券码' }) + } + }, + }) + }, + + redeemVoucher(event) { + if (!this.data.canRedeem || this.data.redeeming) return + const manual = event.currentTarget.dataset.mode === 'manual' + const order = this.data.pendingOrders[this.data.orderIndex] + const provider = this.data.providers[this.data.providerIndex] + const voucherCode = this.data.voucherCode.trim() + const manualNote = this.data.manualNote.trim() + if (!order || !provider || voucherCode.length < 4) { + this.setData({ errorMessage: '请选择待支付订单并输入有效券码' }) + return + } + if (manual && !manualNote) { + this.setData({ errorMessage: '人工核销必须填写确认说明' }) + return + } + const title = manual ? '人工确认核销' : '在线验券' + wx.showModal({ + title, + content: `确认将${provider.label}券用于订单 ${order.orderNo},抵扣 ${cents(order.unpaidAmountCents)} 吗?`, + success: ({ confirm }) => { + if (confirm) this.submitRedemption({ manual, order, provider, voucherCode, manualNote }) + }, + }) + }, + + async submitRedemption({ manual, order, provider, voucherCode, manualNote }) { + this.setData({ redeeming: true, errorMessage: '' }) + try { + const payload = { + provider: provider.value, + voucherCode, + orderId: order.id, + clientRequestId: clientRequestId(manual ? 'manager-manual-redeem' : 'manager-redeem'), + ...(manual ? { amountCents: order.unpaidAmountCents, note: manualNote } : {}), + } + const endpoint = manual + ? '/management/group-vouchers/redeem-manual' + : '/management/group-vouchers/redeem' + const response = await request(endpoint, { method: 'POST', data: payload }) + const result = response.data || {} + if (result.status !== 'SUCCEEDED') { + throw new Error(result.failureCode ? `验券未成功:${result.failureCode}` : '验券正在处理中') + } + this.setData({ voucherCode: '', manualNote: '' }) + wx.showToast({ title: '核销成功', icon: 'success' }) + await this.loadBusiness() + } catch (error) { + this.setData({ errorMessage: error.message || '验券失败' }) + } finally { + this.setData({ redeeming: false }) + } + }, + + redemptionStatus(status) { + return { SUCCEEDED: '成功', FAILED: '失败', PENDING: '处理中' }[status] || status + }, + + formatTime(value) { + if (!value) return '' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return String(value) + const pad = (part) => String(part).padStart(2, '0') + return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` + }, +}) diff --git a/miniapp/pages/manager/business.json b/miniapp/pages/manager/business.json new file mode 100644 index 0000000..956ced7 --- /dev/null +++ b/miniapp/pages/manager/business.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "验券与经营", + "enablePullDownRefresh": true +} diff --git a/miniapp/pages/manager/business.wxml b/miniapp/pages/manager/business.wxml new file mode 100644 index 0000000..cae3e7d --- /dev/null +++ b/miniapp/pages/manager/business.wxml @@ -0,0 +1,73 @@ + + + + + 验券与经营 + {{storeName || '当前门店'}} + + {{canRedeem ? '可验券' : '只读'}} + + + {{errorMessage}} + 正在加载经营数据... + + + + + + {{summary.collectedAmountText}}实收 + {{summary.orderTotal}}订单 + {{summary.payingMemberTotal}}消费会员 + {{summary.voucherSucceeded}}验券成功 + {{summary.availableRoomTotal}}当前空闲 + {{summary.attentionRoomTotal}}房态待处理 + + + 收款渠道 + 当前区间暂无成功收款 + + {{item.channel}} · {{item.total}} 笔{{item.amountText}} + + + + 管理员验券 + + 平台 + + {{providers[providerIndex].label}} + + 待支付订单 + + {{pendingOrders[orderIndex].label}} + + 当前门店没有待支付订单 + 团购券码 + + + + + 人工确认说明 + + + + + + 核销金额取订单服务端未支付金额;券码仅保存哈希与脱敏值。 + + + + 近期核销记录 + 当前门店暂无核销记录 + + {{item.providerText}} · {{item.voucherMasked}}{{item.statusText}} + 订单 {{item.orderId}} · 操作人 {{item.actorId || '系统'}} · {{item.timeText}} + {{item.failureCode}} + + + 每日实收 + 当前区间暂无每日实收 + + {{item.date}} · {{item.total}} 笔{{item.amountText}} + + + diff --git a/miniapp/pages/manager/business.wxss b/miniapp/pages/manager/business.wxss new file mode 100644 index 0000000..c47777d --- /dev/null +++ b/miniapp/pages/manager/business.wxss @@ -0,0 +1,136 @@ +.business-page { + padding-bottom: 48rpx; +} + +.page-heading, +.metric-row, +.voucher-input-row { + align-items: center; + display: flex; + justify-content: space-between; +} + +.subtitle, +.record-meta, +.form-tip { + color: #64748b; + font-size: 23rpx; + margin-top: 8rpx; +} + +.permission-tag { + background: #eef2ff; + border-radius: 999rpx; + color: #3730a3; + font-size: 22rpx; + padding: 8rpx 16rpx; +} + +.range-tabs { + display: flex; + gap: 12rpx; + margin: 24rpx 0; +} + +.range-tabs button { + margin: 0; +} + +.range-tabs button.active { + background: #0f172a; + color: #fff; +} + +.summary-grid { + display: grid; + gap: 12rpx; + grid-template-columns: repeat(3, 1fr); +} + +.summary-card { + background: #f8fafc; + border: 1rpx solid #e2e8f0; + border-radius: 16rpx; + display: flex; + flex-direction: column; + padding: 16rpx; +} + +.summary-card strong { font-size: 30rpx; } +.summary-card text { color: #64748b; font-size: 21rpx; } +.summary-card.primary strong { color: #2563eb; } +.summary-card.success strong { color: #15803d; } +.summary-card.warning strong { color: #c2410c; } + +.section-title { + font-size: 30rpx; + font-weight: 600; + margin: 30rpx 0 16rpx; +} + +.metric-row, +.record-card, +.form-card { + background: #fff; + border: 1rpx solid #e2e8f0; + border-radius: 16rpx; + margin-bottom: 12rpx; + padding: 20rpx; +} + +.form-card input, +.picker-value { + background: #f8fafc; + border: 1rpx solid #cbd5e1; + border-radius: 12rpx; + min-height: 52rpx; + padding: 12rpx 16rpx; +} + +.field-label { + color: #475569; + font-size: 24rpx; + margin: 20rpx 0 10rpx; +} + +.field-label:first-child { margin-top: 0; } + +.voucher-input-row { + gap: 12rpx; +} + +.voucher-input-row input { flex: 1; } +.voucher-input-row button { margin: 0; } + +.form-actions { + display: flex; + gap: 12rpx; + margin-top: 24rpx; +} + +.form-actions button { + flex: 1; + font-size: 25rpx; + margin: 0; +} + +.status-SUCCEEDED { color: #15803d; } +.status-FAILED, +.failure-code { color: #b91c1c; } +.status-PENDING { color: #c2410c; } + +.failure-code { + font-size: 22rpx; + margin-top: 8rpx; +} + +.loading, +.empty { + color: #64748b; + padding: 30rpx 0; + text-align: center; +} + +.empty.compact { + padding: 14rpx 0; +} diff --git a/miniapp/pages/manager/dashboard.js b/miniapp/pages/manager/dashboard.js index def5b12..c78f6bf 100644 --- a/miniapp/pages/manager/dashboard.js +++ b/miniapp/pages/manager/dashboard.js @@ -236,6 +236,13 @@ Page({ }) }, + openBusiness() { + if (!this.data.selectedStoreId) return + wx.navigateTo({ + url: `/pages/manager/business?storeId=${encodeURIComponent(this.data.selectedStoreId)}&storeName=${encodeURIComponent(this.data.selectedStoreName)}`, + }) + }, + manageOrder(event) { if (!this.data.canWrite) return const orderId = event.currentTarget.dataset.orderId diff --git a/miniapp/pages/manager/dashboard.wxml b/miniapp/pages/manager/dashboard.wxml index c71814d..cbdfa73 100644 --- a/miniapp/pages/manager/dashboard.wxml +++ b/miniapp/pages/manager/dashboard.wxml @@ -40,6 +40,14 @@ + + + 验券与经营 + 团购券扫码/输入核销与门店经营统计 + + + + {{summary.roomTotal}}房间 {{summary.available}}空闲 diff --git a/scripts/check-miniapp-m08-c.mjs b/scripts/check-miniapp-m08-c.mjs index e081163..72a0521 100644 --- a/scripts/check-miniapp-m08-c.mjs +++ b/scripts/check-miniapp-m08-c.mjs @@ -7,6 +7,7 @@ const read = (path) => readFileSync(join(root, path), 'utf8'); const appJson = JSON.parse(read('miniapp/app.json')); assert.ok(appJson.pages.includes('pages/manager/dashboard')); +assert.ok(appJson.pages.includes('pages/manager/business')); assert.ok(appJson.pages.includes('pages/manager/operations')); assert.ok(appJson.pages.includes('pages/manager/people')); assert.ok(appJson.pages.includes('pages/manager/order-create')); @@ -42,6 +43,7 @@ for (const pattern of [ 'canReadOperations', 'openPeople', 'openOperations', + 'openBusiness', 'changeRoomStatus', 'toggleRoomConfiguration', 'activeOrders', @@ -157,6 +159,41 @@ for (const pattern of [ } assert.match(read('backend/src/devices/device-control-service.ts'), /DEVICE_COMMAND_REQUESTED/); +const business = read('miniapp/pages/manager/business.js') + + read('miniapp/pages/manager/business.wxml') + + read('miniapp/pages/manager/business.wxss'); +for (const pattern of [ + '/management/statistics?storeId=${storeId}', + '/management/group-vouchers/redeem', + '/management/group-vouchers/redeem-manual', + '/management/third-party/records?storeId=${storeId}', + 'wx.scanCode', + 'clientRequestId', + '近 {{item}} 天', + '在线验券', + '人工确认核销', + '券码仅保存哈希与脱敏值' +]) { + assert.match(business, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); +} + +const businessRoutes = read('backend/src/routes/third-party.ts') + + read('backend/src/third-party/third-party-service.ts') + + read('backend/src/routes/business-statistics.ts') + + read('backend/src/operations/business-statistics-repository.ts'); +for (const pattern of [ + '/app-api/management/group-vouchers/redeem', + '/app-api/management/group-vouchers/redeem-manual', + '/app-api/management/third-party/records', + '/app-api/management/statistics', + 'redeemVoucherAsManager', + 'qipai_group_redemptions', + 'qipai_payments', + 'BUSINESS_STATISTICS_FORBIDDEN' +]) { + assert.match(businessRoutes, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); +} + const routes = read('backend/src/routes/store-room-management.ts'); for (const pattern of [ '/app-api/management/stores',