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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())}`
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "验券与经营",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user