feat(M08-C): 补管理员验券与经营统计
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<FastifyIn
|
||||
if (options.cleaning) {
|
||||
await registerCleaningRoutes(app, options.cleaning);
|
||||
}
|
||||
if (options.businessStatistics) {
|
||||
await registerBusinessStatisticsRoutes(app, options.businessStatistics);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { RowDataPacket } from 'mysql2/promise';
|
||||
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||
import type { MySqlPool } from '../db/mysql.js';
|
||||
|
||||
interface OrderSummaryRow extends RowDataPacket {
|
||||
orderTotal: number;
|
||||
activeOrderTotal: number;
|
||||
finishedOrderTotal: number;
|
||||
bookedAmountCents: number;
|
||||
payingMemberTotal: number;
|
||||
}
|
||||
|
||||
interface OrderStatusRow extends RowDataPacket {
|
||||
status: string;
|
||||
total: number;
|
||||
amountCents: number;
|
||||
}
|
||||
|
||||
interface PaymentChannelRow extends RowDataPacket {
|
||||
channel: string;
|
||||
total: number;
|
||||
amountCents: number;
|
||||
}
|
||||
|
||||
interface DailyRevenueRow extends RowDataPacket {
|
||||
date: string | Date;
|
||||
total: number;
|
||||
amountCents: number;
|
||||
}
|
||||
|
||||
interface RoomSummaryRow extends RowDataPacket {
|
||||
roomTotal: number;
|
||||
availableRoomTotal: number;
|
||||
attentionRoomTotal: number;
|
||||
}
|
||||
|
||||
interface VoucherSummaryRow extends RowDataPacket {
|
||||
voucherTotal: number;
|
||||
voucherSucceeded: number;
|
||||
voucherFailed: number;
|
||||
}
|
||||
|
||||
export class BusinessStatisticsError extends Error {
|
||||
constructor(public readonly code: string) { super(code); }
|
||||
}
|
||||
|
||||
export class BusinessStatisticsRepository {
|
||||
constructor(private readonly pool: MySqlPool) {}
|
||||
|
||||
async overview(
|
||||
actor: ManagementActor,
|
||||
input: { storeId: string; from: Date; to: Date }
|
||||
) {
|
||||
this.assertStoreScope(actor, input.storeId);
|
||||
const rangeParams = [actor.tenantId, input.storeId, input.from, input.to];
|
||||
const [
|
||||
orderSummaryResult,
|
||||
orderStatusResult,
|
||||
paymentChannelResult,
|
||||
dailyRevenueResult,
|
||||
roomSummaryResult,
|
||||
voucherSummaryResult
|
||||
] = await Promise.all([
|
||||
this.pool.execute<OrderSummaryRow[]>(
|
||||
`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<OrderStatusRow[]>(
|
||||
`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<PaymentChannelRow[]>(
|
||||
`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<DailyRevenueRow[]>(
|
||||
`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<RoomSummaryRow[]>(
|
||||
`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<VoucherSummaryRow[]>(
|
||||
`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);
|
||||
}
|
||||
@@ -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<BusinessStatisticsRepository, 'overview'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
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<ManagementActor | null> {
|
||||
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
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
}));
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
+75
-17
@@ -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<RowDataPacket[]>(
|
||||
`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<RowDataPacket[]>(
|
||||
`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 };
|
||||
}
|
||||
|
||||
@@ -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