feat(M08-C): 补管理员验券与经营统计

This commit is contained in:
Codex
2026-08-10 11:08:55 +08:00
parent 3bfccb8513
commit ce52f07588
18 changed files with 1141 additions and 22 deletions
+1 -1
View File
@@ -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",
+8
View File
@@ -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);
}
+94
View File
@@ -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
});
}
+56 -2
View File
@@ -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
}));
+7
View File
@@ -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
View File
@@ -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 };
}
+122
View File
@@ -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'
+48 -2
View File
@@ -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',
+1
View File
@@ -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",
+223
View File
@@ -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())}`
},
})
+4
View File
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "验券与经营",
"enablePullDownRefresh": true
}
+73
View File
@@ -0,0 +1,73 @@
<scroll-view class="scrollarea" scroll-y type="list">
<view class="container business-page">
<view class="page-heading">
<view>
<view class="title">验券与经营</view>
<view class="subtitle">{{storeName || '当前门店'}}</view>
</view>
<view class="permission-tag">{{canRedeem ? '可验券' : '只读'}}</view>
</view>
<view wx:if="{{errorMessage}}" class="error">{{errorMessage}}</view>
<view wx:if="{{loading}}" class="loading">正在加载经营数据...</view>
<view class="range-tabs">
<button wx:for="{{ranges}}" wx:key="*this" size="mini" class="{{item === rangeDays ? 'active' : ''}}" data-days="{{item}}" bindtap="selectRange">近 {{item}} 天</button>
</view>
<view class="summary-grid">
<view class="summary-card primary"><strong>{{summary.collectedAmountText}}</strong><text>实收</text></view>
<view class="summary-card"><strong>{{summary.orderTotal}}</strong><text>订单</text></view>
<view class="summary-card"><strong>{{summary.payingMemberTotal}}</strong><text>消费会员</text></view>
<view class="summary-card success"><strong>{{summary.voucherSucceeded}}</strong><text>验券成功</text></view>
<view class="summary-card"><strong>{{summary.availableRoomTotal}}</strong><text>当前空闲</text></view>
<view class="summary-card warning"><strong>{{summary.attentionRoomTotal}}</strong><text>房态待处理</text></view>
</view>
<view class="section-title">收款渠道</view>
<view wx:if="{{paymentChannels.length === 0}}" class="empty compact">当前区间暂无成功收款</view>
<view wx:for="{{paymentChannels}}" wx:key="channel" class="metric-row">
<text>{{item.channel}} · {{item.total}} 笔</text><strong>{{item.amountText}}</strong>
</view>
<block wx:if="{{canRedeem}}">
<view class="section-title">管理员验券</view>
<view class="form-card">
<view class="field-label">平台</view>
<picker range="{{providers}}" range-key="label" value="{{providerIndex}}" bindchange="selectProvider">
<view class="picker-value">{{providers[providerIndex].label}}</view>
</picker>
<view class="field-label">待支付订单</view>
<picker wx:if="{{pendingOrders.length}}" range="{{pendingOrders}}" range-key="label" value="{{orderIndex}}" bindchange="selectOrder">
<view class="picker-value">{{pendingOrders[orderIndex].label}}</view>
</picker>
<view wx:else class="empty compact">当前门店没有待支付订单</view>
<view class="field-label">团购券码</view>
<view class="voucher-input-row">
<input value="{{voucherCode}}" maxlength="128" placeholder="扫码或输入券码" bindinput="inputVoucherCode" />
<button size="mini" bindtap="scanVoucher">扫码</button>
</view>
<view class="field-label">人工确认说明</view>
<input value="{{manualNote}}" maxlength="512" placeholder="仅人工核销时必填" bindinput="inputManualNote" />
<view class="form-actions">
<button type="primary" loading="{{redeeming}}" disabled="{{!pendingOrders.length}}" data-mode="online" bindtap="redeemVoucher">在线验券</button>
<button loading="{{redeeming}}" disabled="{{!pendingOrders.length}}" data-mode="manual" bindtap="redeemVoucher">人工确认核销</button>
</view>
<view class="form-tip">核销金额取订单服务端未支付金额;券码仅保存哈希与脱敏值。</view>
</view>
</block>
<view class="section-title">近期核销记录</view>
<view wx:if="{{redemptionRecords.length === 0}}" class="empty compact">当前门店暂无核销记录</view>
<view wx:for="{{redemptionRecords}}" wx:key="id" class="record-card">
<view class="metric-row"><strong>{{item.providerText}} · {{item.voucherMasked}}</strong><text class="status-{{item.status}}">{{item.statusText}}</text></view>
<view class="record-meta">订单 {{item.orderId}} · 操作人 {{item.actorId || '系统'}} · {{item.timeText}}</view>
<view wx:if="{{item.failureCode}}" class="failure-code">{{item.failureCode}}</view>
</view>
<view class="section-title">每日实收</view>
<view wx:if="{{dailyRevenue.length === 0}}" class="empty compact">当前区间暂无每日实收</view>
<view wx:for="{{dailyRevenue}}" wx:key="date" class="metric-row">
<text>{{item.date}} · {{item.total}} 笔</text><strong>{{item.amountText}}</strong>
</view>
</view>
</scroll-view>
+136
View File
@@ -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;
}
+7
View File
@@ -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
+8
View File
@@ -40,6 +40,14 @@
<button size="mini" bindtap="openOperations">进入</button>
</view>
<view wx:if="{{selectedStoreId}}" class="management-tools">
<view>
<view class="card-title">验券与经营</view>
<view class="card-meta">团购券扫码/输入核销与门店经营统计</view>
</view>
<button size="mini" bindtap="openBusiness">进入</button>
</view>
<view class="summary-grid">
<view class="summary-card"><strong>{{summary.roomTotal}}</strong><text>房间</text></view>
<view class="summary-card success"><strong>{{summary.available}}</strong><text>空闲</text></view>
+37
View File
@@ -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',