feat(M10-B): 完成可复算经营报表与日汇总
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",
|
||||
"pretest": "npm run build && node tests/product-storage-service.test.mjs && node tests/product-storage-route.test.mjs && node tests/notification-adapter.test.mjs && node tests/notification-route.test.mjs",
|
||||
"pretest": "npm run build && node tests/product-storage-service.test.mjs && node tests/product-storage-route.test.mjs && node tests/notification-adapter.test.mjs && node tests/notification-route.test.mjs && node tests/business-report.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/admin-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/franchise.test.mjs && node tests/system-operations.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 && node tests/product-catalog.test.mjs && node tests/product-route.test.mjs && node tests/inventory-service.test.mjs && node tests/inventory-route.test.mjs && node tests/product-order-service.test.mjs && node tests/product-order-route.test.mjs && node tests/product-reconciliation.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -153,7 +153,8 @@ export class AuthRepository {
|
||||
'inventory.read', 'inventory.adjust',
|
||||
'goods.order.read', 'goods.order.manage',
|
||||
'goods.storage.read', 'goods.storage.manage',
|
||||
'notification.read', 'notification.manage'))
|
||||
'notification.read', 'notification.manage',
|
||||
'report.read', 'report.export'))
|
||||
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
|
||||
'device.read', 'device.write',
|
||||
@@ -161,7 +162,8 @@ export class AuthRepository {
|
||||
'inventory.read', 'inventory.adjust',
|
||||
'goods.order.read', 'goods.order.manage',
|
||||
'goods.storage.read', 'goods.storage.manage',
|
||||
'notification.read', 'notification.manage'))
|
||||
'notification.read', 'notification.manage',
|
||||
'report.read', 'report.export', 'report.manage'))
|
||||
WHERE r.tenant_id = ?`,
|
||||
[input.context.tenantId, input.context.tenantId]
|
||||
);
|
||||
|
||||
@@ -53,6 +53,7 @@ export class RbacRepository {
|
||||
'goods.order.read', 'goods.order.manage',
|
||||
'goods.storage.read', 'goods.storage.manage',
|
||||
'notification.read', 'notification.manage',
|
||||
'report.read', 'report.export',
|
||||
'cleaning.task.read', 'cleaning.task.write',
|
||||
'cleaning.statistics.read'))
|
||||
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||
@@ -63,6 +64,7 @@ export class RbacRepository {
|
||||
'goods.order.read', 'goods.order.manage',
|
||||
'goods.storage.read', 'goods.storage.manage',
|
||||
'notification.read', 'notification.manage',
|
||||
'report.read', 'report.export', 'report.manage',
|
||||
'cleaning.task.read', 'cleaning.task.write',
|
||||
'cleaning.statistics.read'))
|
||||
WHERE r.tenant_id = ?`,
|
||||
|
||||
@@ -74,7 +74,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.up.sql',
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.up.sql',
|
||||
'database/migrations/2026081109_m09d3_product_storage.up.sql',
|
||||
'database/migrations/2026081110_m10a_notification_center.up.sql'
|
||||
'database/migrations/2026081110_m10a_notification_center.up.sql',
|
||||
'database/migrations/2026081111_m10b_business_reports.up.sql'
|
||||
],
|
||||
verify: [
|
||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||
@@ -115,9 +116,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.verify.sql',
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.verify.sql',
|
||||
'database/migrations/2026081109_m09d3_product_storage.verify.sql',
|
||||
'database/migrations/2026081110_m10a_notification_center.verify.sql'
|
||||
'database/migrations/2026081110_m10a_notification_center.verify.sql',
|
||||
'database/migrations/2026081111_m10b_business_reports.verify.sql'
|
||||
],
|
||||
down: [
|
||||
'database/migrations/2026081111_m10b_business_reports.down.sql',
|
||||
'database/migrations/2026081110_m10a_notification_center.down.sql',
|
||||
'database/migrations/2026081109_m09d3_product_storage.down.sql',
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.down.sql',
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
||||
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||
import type { MySqlPool } from '../db/mysql.js';
|
||||
import type { AsyncTask } from '../tasks/task-repository.js';
|
||||
|
||||
export type ReportChannel = 'WECHAT' | 'BALANCE' | 'PACKAGE' | 'GROUP_BUY' | 'OTHER';
|
||||
|
||||
export interface BusinessDailyMetric {
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
date: string;
|
||||
timezone: string;
|
||||
orderCount: number;
|
||||
customerCount: number;
|
||||
usedMinutes: number;
|
||||
capacityMinutes: number;
|
||||
utilizationBasisPoints: number;
|
||||
roomGrossCents: number;
|
||||
roomRefundCents: number;
|
||||
roomNetCents: number;
|
||||
productGrossCents: number;
|
||||
productRefundCents: number;
|
||||
productNetCents: number;
|
||||
totalNetCents: number;
|
||||
cleaningCostCents: number;
|
||||
contributionCents: number;
|
||||
channels: Record<ReportChannel, number>;
|
||||
sourceChecksum: string;
|
||||
}
|
||||
|
||||
interface StoreRow extends RowDataPacket { id: string; name: string; timezone: string }
|
||||
interface MoneyRow extends RowDataPacket {
|
||||
provider: string; amountCents: number; occurredAt: string | Date;
|
||||
}
|
||||
interface OrderUsageRow extends RowDataPacket {
|
||||
id: string; userId: string | null; status: string; createdAt: string | Date;
|
||||
bookedStart: string | Date; bookedEnd: string | Date;
|
||||
actualStart: string | Date | null; actualEnd: string | Date | null;
|
||||
}
|
||||
interface CostRow extends RowDataPacket { amountCents: number; occurredAt: string | Date }
|
||||
interface AggregateRow extends RowDataPacket {
|
||||
storeId: string; businessDate: string | Date; sourceChecksum: string; calculatedAt: string | Date;
|
||||
}
|
||||
|
||||
export class BusinessReportError extends Error {
|
||||
constructor(public readonly code: string) { super(code); }
|
||||
}
|
||||
|
||||
export class BusinessReportService {
|
||||
private nextAutomaticEnqueueAt = 0;
|
||||
|
||||
constructor(private readonly pool: MySqlPool, private readonly now = () => new Date()) {}
|
||||
|
||||
async report(actor: ManagementActor, input: { storeId?: string; from: string; to: string }) {
|
||||
this.assertCapability(actor, 'report.read');
|
||||
assertDateRange(input.from, input.to);
|
||||
const stores = await this.resolveStores(actor, input.storeId);
|
||||
const daily: BusinessDailyMetric[] = [];
|
||||
const customerIds = new Set<string>();
|
||||
const detailCounts = { payments: 0, refunds: 0, productPayments: 0, productRefunds: 0, orders: 0, cleaningEntries: 0 };
|
||||
for (const store of stores) {
|
||||
const result = await this.calculateStore(store, actor.tenantId, input.from, input.to);
|
||||
daily.push(...result.daily);
|
||||
for (const customerId of result.customerIds) customerIds.add(customerId);
|
||||
for (const key of Object.keys(detailCounts) as Array<keyof typeof detailCounts>) {
|
||||
detailCounts[key] += result.counts[key];
|
||||
}
|
||||
}
|
||||
daily.sort((left, right) => left.date.localeCompare(right.date)
|
||||
|| left.storeName.localeCompare(right.storeName) || left.storeId.localeCompare(right.storeId));
|
||||
const persisted = await this.loadPersisted(actor.tenantId, stores.map((store) => String(store.id)), input.from, input.to);
|
||||
const persistedByKey = new Map(persisted.map((row) => [
|
||||
`${row.storeId}:${formatDate(row.businessDate)}`, row
|
||||
]));
|
||||
const mismatches = daily.filter((row) => persistedByKey.get(`${row.storeId}:${row.date}`)?.sourceChecksum !== row.sourceChecksum);
|
||||
return {
|
||||
scope: { storeId: input.storeId ?? null, storeCount: stores.length, from: input.from, to: input.to },
|
||||
summary: { ...summarize(daily), customerCount: customerIds.size },
|
||||
daily,
|
||||
detailCounts,
|
||||
reconciliation: {
|
||||
status: mismatches.length === 0 ? 'MATCHED' : persisted.length === 0 ? 'NOT_AGGREGATED' : 'MISMATCH',
|
||||
expectedRows: daily.length,
|
||||
persistedRows: persisted.length,
|
||||
mismatchRows: mismatches.length,
|
||||
lastCalculatedAt: persisted.length
|
||||
? persisted.map((row) => new Date(row.calculatedAt).toISOString()).sort().at(-1) ?? null : null
|
||||
},
|
||||
metricVersion: 'M10-B-v1'
|
||||
};
|
||||
}
|
||||
|
||||
async enqueueRange(actor: ManagementActor, input: { storeId?: string; from: string; to: string }) {
|
||||
this.assertCapability(actor, 'report.manage');
|
||||
assertDateRange(input.from, input.to);
|
||||
const stores = await this.resolveStores(actor, input.storeId);
|
||||
let queued = 0;
|
||||
for (const store of stores) {
|
||||
for (const date of dateKeys(input.from, input.to)) {
|
||||
const [result] = await this.pool.execute<ResultSetHeader>(
|
||||
`INSERT IGNORE INTO qipai_async_tasks
|
||||
(tenant_id, task_type, idempotency_key, payload, priority, available_at, max_attempts)
|
||||
VALUES (?, 'statistics.aggregate', ?, CAST(? AS JSON), 10, UTC_TIMESTAMP(3), 5)`,
|
||||
[actor.tenantId, `report:${store.id}:${date}:${actor.traceId}`,
|
||||
JSON.stringify({ storeId: String(store.id), date })]
|
||||
);
|
||||
queued += result.affectedRows;
|
||||
}
|
||||
}
|
||||
return { queued, storeCount: stores.length, dayCount: dateKeys(input.from, input.to).length };
|
||||
}
|
||||
|
||||
async enqueueDueAggregations() {
|
||||
const now = this.now();
|
||||
if (now.getTime() < this.nextAutomaticEnqueueAt) return 0;
|
||||
this.nextAutomaticEnqueueAt = now.getTime() + 5 * 60_000;
|
||||
const [stores] = await this.pool.execute<StoreRow[]>(
|
||||
`SELECT id, name, timezone FROM qipai_stores
|
||||
WHERE business_status <> 'SUSPENDED' AND deleted_at IS NULL`
|
||||
);
|
||||
const hourBucket = now.toISOString().slice(0, 13).replace(/\D/g, '');
|
||||
let queued = 0;
|
||||
for (const store of stores) {
|
||||
const today = localDateKey(now, store.timezone);
|
||||
for (let offset = -6; offset <= 0; offset += 1) {
|
||||
const date = shiftDate(today, offset);
|
||||
const [result] = await this.pool.execute<ResultSetHeader>(
|
||||
`INSERT IGNORE INTO qipai_async_tasks
|
||||
(tenant_id, task_type, idempotency_key, payload, priority, available_at, max_attempts)
|
||||
SELECT tenant_id, 'statistics.aggregate', ?, CAST(? AS JSON), 5, UTC_TIMESTAMP(3), 5
|
||||
FROM qipai_stores WHERE id = ? AND deleted_at IS NULL`,
|
||||
[`report:auto:${store.id}:${date}:${hourBucket}`,
|
||||
JSON.stringify({ storeId: String(store.id), date }), store.id]
|
||||
);
|
||||
queued += result.affectedRows;
|
||||
}
|
||||
}
|
||||
return queued;
|
||||
}
|
||||
|
||||
async handleTask(task: AsyncTask) {
|
||||
const payload = task.payload as { storeId?: unknown; date?: unknown };
|
||||
const storeId = typeof payload?.storeId === 'string' ? payload.storeId : '';
|
||||
const date = typeof payload?.date === 'string' ? payload.date : '';
|
||||
if (!/^[1-9]\d{0,19}$/.test(storeId) || !isDateKey(date)) {
|
||||
throw new BusinessReportError('BUSINESS_REPORT_TASK_INVALID');
|
||||
}
|
||||
const [stores] = await this.pool.execute<StoreRow[]>(
|
||||
`SELECT id, name, timezone FROM qipai_stores
|
||||
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`, [task.tenantId, storeId]
|
||||
);
|
||||
if (!stores[0]) throw new BusinessReportError('BUSINESS_REPORT_STORE_NOT_FOUND');
|
||||
const result = await this.calculateStore(stores[0], task.tenantId, date, date);
|
||||
await this.persist(task.tenantId, result.daily[0]);
|
||||
}
|
||||
|
||||
private async resolveStores(actor: ManagementActor, storeId?: string) {
|
||||
if (storeId && !this.canAccessStore(actor, storeId)) {
|
||||
throw new BusinessReportError('BUSINESS_REPORT_FORBIDDEN');
|
||||
}
|
||||
const scopedIds = this.hasTenantScope(actor) ? [] : actor.access.storeIds;
|
||||
if (!storeId && scopedIds.length === 0 && !this.hasTenantScope(actor)) {
|
||||
throw new BusinessReportError('BUSINESS_REPORT_FORBIDDEN');
|
||||
}
|
||||
const filters = ['tenant_id = ?', 'deleted_at IS NULL'];
|
||||
const params: string[] = [actor.tenantId];
|
||||
if (storeId) { filters.push('id = ?'); params.push(storeId); }
|
||||
else if (scopedIds.length) {
|
||||
filters.push(`id IN (${scopedIds.map(() => '?').join(', ')})`); params.push(...scopedIds);
|
||||
}
|
||||
const [rows] = await this.pool.execute<StoreRow[]>(
|
||||
`SELECT id, name, timezone FROM qipai_stores WHERE ${filters.join(' AND ')} ORDER BY id`, params
|
||||
);
|
||||
if (storeId && !rows[0]) throw new BusinessReportError('BUSINESS_REPORT_STORE_NOT_FOUND');
|
||||
return rows.map((row) => ({ ...row, id: String(row.id) }));
|
||||
}
|
||||
|
||||
private async calculateStore(store: StoreRow, tenantId: string, from: string, to: string) {
|
||||
const days = dateKeys(from, to);
|
||||
const boundaries = new Map(days.map((date) => [date, {
|
||||
start: zonedDateStart(date, store.timezone),
|
||||
end: zonedDateStart(shiftDate(date, 1), store.timezone)
|
||||
}]));
|
||||
const utcFrom = boundaries.get(days[0])!.start;
|
||||
const utcTo = boundaries.get(days.at(-1)!)!.end;
|
||||
const params = [tenantId, store.id, utcFrom, utcTo];
|
||||
const [paymentsResult, refundsResult, productPaymentsResult, productRefundsResult,
|
||||
ordersResult, roomResult, paidCostsResult, reversedCostsResult] = await Promise.all([
|
||||
this.pool.execute<MoneyRow[]>(
|
||||
`SELECT channel AS provider, amount_cents AS amountCents, paid_at AS occurredAt
|
||||
FROM qipai_payments WHERE tenant_id = ? AND store_id = ?
|
||||
AND status IN ('SUCCEEDED', 'PARTIALLY_REFUNDED', 'REFUNDED')
|
||||
AND paid_at >= ? AND paid_at < ? AND deleted_at IS NULL`, params),
|
||||
this.pool.execute<MoneyRow[]>(
|
||||
`SELECT p.channel AS provider, r.amount_cents AS amountCents, r.completed_at AS occurredAt
|
||||
FROM qipai_refunds r INNER JOIN qipai_payments p
|
||||
ON p.tenant_id = r.tenant_id AND p.id = r.payment_id
|
||||
WHERE r.tenant_id = ? AND p.store_id = ? AND r.status = 'SUCCEEDED'
|
||||
AND r.completed_at >= ? AND r.completed_at < ?`, params),
|
||||
this.pool.execute<MoneyRow[]>(
|
||||
`SELECT channel AS provider, amount_cents AS amountCents, paid_at AS occurredAt
|
||||
FROM qipai_product_payments WHERE tenant_id = ? AND store_id = ?
|
||||
AND status IN ('SUCCEEDED', 'REFUNDED')
|
||||
AND paid_at >= ? AND paid_at < ?`, params),
|
||||
this.pool.execute<MoneyRow[]>(
|
||||
`SELECT p.channel AS provider, r.amount_cents AS amountCents, r.completed_at AS occurredAt
|
||||
FROM qipai_product_refunds r INNER JOIN qipai_product_payments p
|
||||
ON p.tenant_id = r.tenant_id AND p.id = r.payment_id
|
||||
WHERE r.tenant_id = ? AND r.store_id = ? AND r.status = 'SUCCEEDED'
|
||||
AND r.completed_at >= ? AND r.completed_at < ?`, params),
|
||||
this.pool.execute<OrderUsageRow[]>(
|
||||
`SELECT o.id, owner.user_id AS userId, o.status, o.created_at AS createdAt,
|
||||
o.start_at AS bookedStart, o.end_at AS bookedEnd,
|
||||
MIN(CASE WHEN h.to_status = 'IN_PROGRESS' THEN h.created_at END) AS actualStart,
|
||||
MIN(CASE WHEN h.to_status = 'FINISHED' THEN h.created_at END) AS actualEnd
|
||||
FROM qipai_orders o
|
||||
LEFT JOIN qipai_order_user_access owner
|
||||
ON owner.tenant_id = o.tenant_id AND owner.order_id = o.id AND owner.access_type = 'OWNER'
|
||||
LEFT JOIN qipai_order_status_history h
|
||||
ON h.tenant_id = o.tenant_id AND h.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 < ?)
|
||||
OR (o.start_at < ? AND o.end_at > ?)
|
||||
OR (EXISTS (
|
||||
SELECT 1 FROM qipai_order_status_history started
|
||||
WHERE started.tenant_id = o.tenant_id AND started.order_id = o.id
|
||||
AND started.to_status = 'IN_PROGRESS' AND started.created_at < ?
|
||||
) AND (
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM qipai_order_status_history finished
|
||||
WHERE finished.tenant_id = o.tenant_id AND finished.order_id = o.id
|
||||
AND finished.to_status = 'FINISHED'
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM qipai_order_status_history finished
|
||||
WHERE finished.tenant_id = o.tenant_id AND finished.order_id = o.id
|
||||
AND finished.to_status = 'FINISHED' AND finished.created_at > ?
|
||||
)
|
||||
)))
|
||||
GROUP BY o.id, owner.user_id, o.status, o.created_at, o.start_at, o.end_at`,
|
||||
[tenantId, store.id, utcFrom, utcTo, utcTo, utcFrom, utcTo, utcFrom]),
|
||||
this.pool.execute<Array<RowDataPacket & { total: number }>>(
|
||||
`SELECT COUNT(*) AS total FROM qipai_rooms
|
||||
WHERE tenant_id = ? AND store_id = ? AND deleted_at IS NULL`, [tenantId, store.id]),
|
||||
this.pool.execute<CostRow[]>(
|
||||
`SELECT i.reward_cents AS amountCents, s.paid_at AS occurredAt
|
||||
FROM qipai_cleaning_settlement_items i
|
||||
INNER JOIN qipai_cleaning_settlements s
|
||||
ON s.tenant_id = i.tenant_id AND s.id = i.settlement_id
|
||||
INNER JOIN qipai_cleaning_tasks t
|
||||
ON t.tenant_id = i.tenant_id AND t.id = i.task_id
|
||||
WHERE i.tenant_id = ? AND t.store_id = ? AND s.paid_at >= ? AND s.paid_at < ?`, params),
|
||||
this.pool.execute<CostRow[]>(
|
||||
`SELECT -CAST(i.reward_cents AS SIGNED) AS amountCents, i.reversed_at AS occurredAt
|
||||
FROM qipai_cleaning_settlement_items i
|
||||
INNER JOIN qipai_cleaning_tasks t
|
||||
ON t.tenant_id = i.tenant_id AND t.id = i.task_id
|
||||
WHERE i.tenant_id = ? AND t.store_id = ?
|
||||
AND i.reversed_at >= ? AND i.reversed_at < ?`, params)
|
||||
]);
|
||||
const roomCount = Number(roomResult[0][0]?.total ?? 0);
|
||||
const metrics = new Map(days.map((date) => [date, emptyMetric(store, date, roomCount,
|
||||
Math.round((boundaries.get(date)!.end.getTime() - boundaries.get(date)!.start.getTime()) / 60000))]));
|
||||
applyMoney(metrics, paymentsResult[0], store.timezone, 'roomGrossCents', 1);
|
||||
applyMoney(metrics, refundsResult[0], store.timezone, 'roomRefundCents', -1);
|
||||
applyMoney(metrics, productPaymentsResult[0], store.timezone, 'productGrossCents', 1);
|
||||
applyMoney(metrics, productRefundsResult[0], store.timezone, 'productRefundCents', -1);
|
||||
for (const row of [...paidCostsResult[0], ...reversedCostsResult[0]]) {
|
||||
const metric = metrics.get(localDateKey(new Date(row.occurredAt), store.timezone));
|
||||
if (metric) metric.cleaningCostCents += Number(row.amountCents);
|
||||
}
|
||||
const customerSets = new Map(days.map((date) => [date, new Set<string>()]));
|
||||
for (const order of ordersResult[0]) {
|
||||
const createdDate = localDateKey(new Date(order.createdAt), store.timezone);
|
||||
const createdMetric = metrics.get(createdDate);
|
||||
if (createdMetric) {
|
||||
createdMetric.orderCount += 1;
|
||||
if (order.userId) customerSets.get(createdDate)!.add(String(order.userId));
|
||||
}
|
||||
const actualStart = order.actualStart ? new Date(order.actualStart)
|
||||
: ['IN_PROGRESS', 'FINISHED', 'CLOSED'].includes(order.status) ? new Date(order.bookedStart) : null;
|
||||
const actualEnd = order.actualEnd ? new Date(order.actualEnd)
|
||||
: order.status === 'IN_PROGRESS' ? this.now()
|
||||
: ['FINISHED', 'CLOSED'].includes(order.status) ? new Date(order.bookedEnd) : null;
|
||||
if (!actualStart || !actualEnd || actualEnd <= actualStart) continue;
|
||||
for (const date of days) {
|
||||
const boundary = boundaries.get(date)!;
|
||||
const overlapMs = Math.max(0, Math.min(actualEnd.getTime(), boundary.end.getTime())
|
||||
- Math.max(actualStart.getTime(), boundary.start.getTime()));
|
||||
metrics.get(date)!.usedMinutes += Math.round(overlapMs / 60000);
|
||||
}
|
||||
}
|
||||
const daily = days.map((date) => finalizeMetric(metrics.get(date)!, customerSets.get(date)!.size));
|
||||
return {
|
||||
daily,
|
||||
customerIds: new Set([...customerSets.values()].flatMap((set) => [...set])),
|
||||
counts: {
|
||||
payments: paymentsResult[0].length, refunds: refundsResult[0].length,
|
||||
productPayments: productPaymentsResult[0].length, productRefunds: productRefundsResult[0].length,
|
||||
orders: ordersResult[0].length,
|
||||
cleaningEntries: paidCostsResult[0].length + reversedCostsResult[0].length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async loadPersisted(tenantId: string, storeIds: string[], from: string, to: string) {
|
||||
if (!storeIds.length) return [];
|
||||
const [rows] = await this.pool.execute<AggregateRow[]>(
|
||||
`SELECT store_id AS storeId, business_date AS businessDate,
|
||||
source_checksum AS sourceChecksum, calculated_at AS calculatedAt
|
||||
FROM qipai_business_daily_summaries
|
||||
WHERE tenant_id = ? AND store_id IN (${storeIds.map(() => '?').join(', ')})
|
||||
AND business_date >= ? AND business_date <= ?`, [tenantId, ...storeIds, from, to]
|
||||
);
|
||||
return rows.map((row) => ({ ...row, storeId: String(row.storeId) }));
|
||||
}
|
||||
|
||||
private async persist(tenantId: string, metric: BusinessDailyMetric) {
|
||||
await this.pool.execute(
|
||||
`INSERT INTO qipai_business_daily_summaries
|
||||
(tenant_id, store_id, business_date, timezone, order_count, customer_count,
|
||||
used_minutes, capacity_minutes, room_gross_cents, room_refund_cents,
|
||||
product_gross_cents, product_refund_cents, cleaning_cost_cents,
|
||||
wechat_net_cents, balance_net_cents, package_net_cents, group_buy_net_cents,
|
||||
other_net_cents, source_checksum, calculated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP(3))
|
||||
ON DUPLICATE KEY UPDATE timezone = VALUES(timezone), order_count = VALUES(order_count),
|
||||
customer_count = VALUES(customer_count), used_minutes = VALUES(used_minutes),
|
||||
capacity_minutes = VALUES(capacity_minutes), room_gross_cents = VALUES(room_gross_cents),
|
||||
room_refund_cents = VALUES(room_refund_cents), product_gross_cents = VALUES(product_gross_cents),
|
||||
product_refund_cents = VALUES(product_refund_cents), cleaning_cost_cents = VALUES(cleaning_cost_cents),
|
||||
wechat_net_cents = VALUES(wechat_net_cents), balance_net_cents = VALUES(balance_net_cents),
|
||||
package_net_cents = VALUES(package_net_cents), group_buy_net_cents = VALUES(group_buy_net_cents),
|
||||
other_net_cents = VALUES(other_net_cents), source_checksum = VALUES(source_checksum),
|
||||
calculated_at = VALUES(calculated_at)`,
|
||||
[tenantId, metric.storeId, metric.date, metric.timezone, metric.orderCount, metric.customerCount,
|
||||
metric.usedMinutes, metric.capacityMinutes, metric.roomGrossCents, metric.roomRefundCents,
|
||||
metric.productGrossCents, metric.productRefundCents, metric.cleaningCostCents,
|
||||
metric.channels.WECHAT, metric.channels.BALANCE, metric.channels.PACKAGE,
|
||||
metric.channels.GROUP_BUY, metric.channels.OTHER, metric.sourceChecksum]
|
||||
);
|
||||
}
|
||||
|
||||
private assertCapability(actor: ManagementActor, capability: string) {
|
||||
if (this.hasTenantScope(actor) || actor.access.capabilities.includes(capability)) return;
|
||||
throw new BusinessReportError('BUSINESS_REPORT_FORBIDDEN');
|
||||
}
|
||||
|
||||
private hasTenantScope(actor: ManagementActor) {
|
||||
return actor.access.roles.includes('PLATFORM_ADMIN')
|
||||
|| actor.access.capabilities.includes('tenant.manage');
|
||||
}
|
||||
|
||||
private canAccessStore(actor: ManagementActor, storeId: string) {
|
||||
return this.hasTenantScope(actor) || actor.access.storeIds.includes(storeId);
|
||||
}
|
||||
}
|
||||
|
||||
type MutableMetric = Omit<BusinessDailyMetric, 'customerCount' | 'utilizationBasisPoints' |
|
||||
'roomNetCents' | 'productNetCents' | 'totalNetCents' | 'contributionCents' | 'sourceChecksum'>;
|
||||
|
||||
function emptyMetric(store: StoreRow, date: string, roomCount: number, dayMinutes: number): MutableMetric {
|
||||
return {
|
||||
storeId: String(store.id), storeName: store.name, date, timezone: store.timezone,
|
||||
orderCount: 0, usedMinutes: 0, capacityMinutes: roomCount * dayMinutes,
|
||||
roomGrossCents: 0, roomRefundCents: 0, productGrossCents: 0, productRefundCents: 0,
|
||||
cleaningCostCents: 0,
|
||||
channels: { WECHAT: 0, BALANCE: 0, PACKAGE: 0, GROUP_BUY: 0, OTHER: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
function applyMoney(
|
||||
metrics: Map<string, MutableMetric>, rows: MoneyRow[], timezone: string,
|
||||
field: 'roomGrossCents' | 'roomRefundCents' | 'productGrossCents' | 'productRefundCents',
|
||||
channelSign: 1 | -1
|
||||
) {
|
||||
for (const row of rows) {
|
||||
const metric = metrics.get(localDateKey(new Date(row.occurredAt), timezone));
|
||||
if (!metric) continue;
|
||||
const amount = Number(row.amountCents);
|
||||
metric[field] += amount;
|
||||
metric.channels[normalizeChannel(row.provider)] += amount * channelSign;
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeMetric(metric: MutableMetric, customerCount: number): BusinessDailyMetric {
|
||||
metric.usedMinutes = Math.min(metric.usedMinutes, metric.capacityMinutes);
|
||||
const roomNetCents = metric.roomGrossCents - metric.roomRefundCents;
|
||||
const productNetCents = metric.productGrossCents - metric.productRefundCents;
|
||||
const totalNetCents = roomNetCents + productNetCents;
|
||||
const value = {
|
||||
...metric,
|
||||
customerCount,
|
||||
utilizationBasisPoints: metric.capacityMinutes
|
||||
? Math.round(metric.usedMinutes * 10000 / metric.capacityMinutes) : 0,
|
||||
roomNetCents,
|
||||
productNetCents,
|
||||
totalNetCents,
|
||||
contributionCents: totalNetCents - metric.cleaningCostCents
|
||||
};
|
||||
return { ...value, sourceChecksum: checksum(value) };
|
||||
}
|
||||
|
||||
function summarize(rows: BusinessDailyMetric[]) {
|
||||
const result = {
|
||||
orderCount: 0, customerCount: 0, usedMinutes: 0, capacityMinutes: 0,
|
||||
utilizationBasisPoints: 0, roomGrossCents: 0, roomRefundCents: 0, roomNetCents: 0,
|
||||
productGrossCents: 0, productRefundCents: 0, productNetCents: 0,
|
||||
totalNetCents: 0, cleaningCostCents: 0, contributionCents: 0,
|
||||
channels: { WECHAT: 0, BALANCE: 0, PACKAGE: 0, GROUP_BUY: 0, OTHER: 0 } as Record<ReportChannel, number>
|
||||
};
|
||||
for (const row of rows) {
|
||||
for (const key of ['orderCount', 'customerCount', 'usedMinutes', 'capacityMinutes',
|
||||
'roomGrossCents', 'roomRefundCents', 'roomNetCents', 'productGrossCents',
|
||||
'productRefundCents', 'productNetCents', 'totalNetCents', 'cleaningCostCents',
|
||||
'contributionCents'] as const) result[key] += row[key];
|
||||
for (const channel of Object.keys(result.channels) as ReportChannel[]) result.channels[channel] += row.channels[channel];
|
||||
}
|
||||
result.utilizationBasisPoints = result.capacityMinutes
|
||||
? Math.round(result.usedMinutes * 10000 / result.capacityMinutes) : 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeChannel(value: string): ReportChannel {
|
||||
return (['WECHAT', 'BALANCE', 'PACKAGE', 'GROUP_BUY'] as const).includes(value as never)
|
||||
? value as ReportChannel : 'OTHER';
|
||||
}
|
||||
|
||||
function checksum(value: unknown) {
|
||||
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||||
}
|
||||
|
||||
export function assertDateRange(from: string, to: string) {
|
||||
if (!isDateKey(from) || !isDateKey(to) || from > to || dateKeys(from, to).length > 93) {
|
||||
throw new BusinessReportError('BUSINESS_REPORT_RANGE_INVALID');
|
||||
}
|
||||
}
|
||||
|
||||
function isDateKey(value: string) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||
return new Date(`${value}T00:00:00.000Z`).toISOString().slice(0, 10) === value;
|
||||
}
|
||||
|
||||
export function dateKeys(from: string, to: string) {
|
||||
const result: string[] = [];
|
||||
for (let value = from; value <= to; value = shiftDate(value, 1)) result.push(value);
|
||||
return result;
|
||||
}
|
||||
|
||||
function shiftDate(value: string, days: number) {
|
||||
const date = new Date(`${value}T00:00:00.000Z`);
|
||||
date.setUTCDate(date.getUTCDate() + days);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function localDateKey(date: Date, timezone: string) {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit'
|
||||
}).formatToParts(date);
|
||||
const get = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? '';
|
||||
return `${get('year')}-${get('month')}-${get('day')}`;
|
||||
}
|
||||
|
||||
export function zonedDateStart(value: string, timezone: string) {
|
||||
const [year, month, day] = value.split('-').map(Number);
|
||||
const desired = Date.UTC(year, month - 1, day);
|
||||
let timestamp = desired;
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23'
|
||||
}).formatToParts(new Date(timestamp));
|
||||
const get = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find((part) => part.type === type)?.value ?? 0);
|
||||
const displayed = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second'));
|
||||
timestamp += desired - displayed;
|
||||
}
|
||||
return new Date(timestamp);
|
||||
}
|
||||
|
||||
function formatDate(value: string | Date) {
|
||||
return typeof value === 'string' ? value.slice(0, 10) : value.toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -113,6 +113,7 @@ function adminAccess(access: AccessProfile) {
|
||||
const storeRead = tenant || access.capabilities.includes('store.operation.read');
|
||||
const menus = [
|
||||
...(storeRead ? ['overview', 'stores', 'orders', 'thirdParty'] : []),
|
||||
...(tenant || access.capabilities.includes('report.read') ? ['reports'] : []),
|
||||
...(tenant || access.capabilities.some((capability) => [
|
||||
'product.catalog.read', 'product.catalog.write', 'inventory.read', 'inventory.adjust',
|
||||
'goods.order.read', 'goods.order.manage', 'goods.storage.read', 'goods.storage.manage'
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
BusinessStatisticsError,
|
||||
type BusinessStatisticsRepository
|
||||
} from '../operations/business-statistics-repository.js';
|
||||
import {
|
||||
BusinessReportError,
|
||||
type BusinessReportService
|
||||
} from '../operations/business-report-service.js';
|
||||
|
||||
const querySchema = z.object({
|
||||
storeId: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||
@@ -15,8 +19,15 @@ const querySchema = z.object({
|
||||
to: z.coerce.date().optional()
|
||||
}).strict();
|
||||
|
||||
const reportQuerySchema = z.object({
|
||||
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
|
||||
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
}).strict();
|
||||
|
||||
export interface BusinessStatisticsRouteOptions {
|
||||
repository: Pick<BusinessStatisticsRepository, 'overview'>;
|
||||
reportService?: Pick<BusinessReportService, 'report' | 'enqueueRange'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
jwtSecret: string;
|
||||
@@ -56,6 +67,66 @@ export async function registerBusinessStatisticsRoutes(
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!options.reportService) return;
|
||||
for (const path of ['/app-api/management/reports/business', '/admin-api/reports/business']) {
|
||||
app.get(path, async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options);
|
||||
if (!actor) return;
|
||||
const query = reportQuerySchema.safeParse(request.query);
|
||||
if (!query.success) return invalidReport(reply, request.traceId);
|
||||
try {
|
||||
return { code: 0, data: await options.reportService!.report(actor, query.data), traceId: request.traceId };
|
||||
} catch (error) {
|
||||
return handleReportError(error, reply, request.traceId);
|
||||
}
|
||||
});
|
||||
}
|
||||
app.get('/admin-api/reports/business/export', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options);
|
||||
if (!actor) return;
|
||||
if (!actor.access.capabilities.includes('report.export')
|
||||
&& !actor.access.capabilities.includes('tenant.manage')
|
||||
&& !actor.access.roles.includes('PLATFORM_ADMIN')) {
|
||||
return reply.status(403).send({ code: 'BUSINESS_REPORT_FORBIDDEN', message: 'Report export permission is required.', traceId: request.traceId });
|
||||
}
|
||||
const query = reportQuerySchema.safeParse(request.query);
|
||||
if (!query.success) return invalidReport(reply, request.traceId);
|
||||
try {
|
||||
const report = await options.reportService!.report(actor, query.data);
|
||||
const rows: Array<Array<string | number>> = [[
|
||||
'日期', '门店', '时区', '订单数', '下单人数', '实际使用分钟', '可用分钟', '利用率(%)',
|
||||
'房费实收(分)', '房费退款(分)', '房费净收入(分)', '商品实收(分)', '商品退款(分)',
|
||||
'商品净收入(分)', '总净收入(分)', '保洁成本(分)', '贡献额(分)',
|
||||
'微信净收入(分)', '余额净收入(分)', '套餐净收入(分)', '团购净收入(分)', '其他净收入(分)'
|
||||
]];
|
||||
for (const item of report.daily) rows.push([
|
||||
item.date, item.storeName, item.timezone, item.orderCount, item.customerCount,
|
||||
item.usedMinutes, item.capacityMinutes, (item.utilizationBasisPoints / 100).toFixed(2),
|
||||
item.roomGrossCents, item.roomRefundCents, item.roomNetCents,
|
||||
item.productGrossCents, item.productRefundCents, item.productNetCents,
|
||||
item.totalNetCents, item.cleaningCostCents, item.contributionCents,
|
||||
item.channels.WECHAT, item.channels.BALANCE, item.channels.PACKAGE,
|
||||
item.channels.GROUP_BUY, item.channels.OTHER
|
||||
]);
|
||||
const csv = `\uFEFF${rows.map((row) => row.map(csvCell).join(',')).join('\r\n')}`;
|
||||
return reply.header('content-type', 'text/csv; charset=utf-8')
|
||||
.header('content-disposition', `attachment; filename="business-report-${query.data.from}-${query.data.to}.csv"`)
|
||||
.send(csv);
|
||||
} catch (error) {
|
||||
return handleReportError(error, reply, request.traceId);
|
||||
}
|
||||
});
|
||||
app.post('/admin-api/reports/business/rebuild', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options);
|
||||
if (!actor) return;
|
||||
const body = reportQuerySchema.safeParse(request.body);
|
||||
if (!body.success) return invalidReport(reply, request.traceId);
|
||||
try {
|
||||
return { code: 0, data: await options.reportService!.enqueueRange(actor, body.data), traceId: request.traceId };
|
||||
} catch (error) {
|
||||
return handleReportError(error, reply, request.traceId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function requireActor(
|
||||
@@ -94,3 +165,23 @@ function invalid(reply: FastifyReply, traceId: string) {
|
||||
traceId
|
||||
});
|
||||
}
|
||||
|
||||
function invalidReport(reply: FastifyReply, traceId: string) {
|
||||
return reply.status(400).send({
|
||||
code: 'INVALID_BUSINESS_REPORT_REQUEST',
|
||||
message: 'The business report request is invalid.',
|
||||
traceId
|
||||
});
|
||||
}
|
||||
|
||||
function handleReportError(error: unknown, reply: FastifyReply, traceId: string) {
|
||||
if (!(error instanceof BusinessReportError)) throw error;
|
||||
const status = error.code.endsWith('_FORBIDDEN') ? 403
|
||||
: error.code.endsWith('_NOT_FOUND') ? 404 : 400;
|
||||
return reply.status(status).send({ code: error.code, message: 'Business report request failed.', traceId });
|
||||
}
|
||||
|
||||
function csvCell(value: string | number) {
|
||||
const text = String(value);
|
||||
return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ 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';
|
||||
import { BusinessReportService } from './operations/business-report-service.js';
|
||||
import { FranchiseRepository } from './franchise/franchise-repository.js';
|
||||
import { SystemOperationsRepository } from './operations/system-operations-repository.js';
|
||||
import { AdminAuthRepository } from './auth/admin-auth-repository.js';
|
||||
@@ -229,6 +230,7 @@ const app = await buildApp({
|
||||
},
|
||||
businessStatistics: {
|
||||
repository: new BusinessStatisticsRepository(pool),
|
||||
reportService: new BusinessReportService(pool),
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
createNotificationAdaptersFromEnvironment, NotificationService
|
||||
} from '../notifications/notification-service.js';
|
||||
import { OutboxRepository } from './outbox-repository.js';
|
||||
import { BusinessReportService } from '../operations/business-report-service.js';
|
||||
|
||||
type TaskHandler = (task: AsyncTask) => Promise<void>;
|
||||
|
||||
@@ -84,11 +85,13 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1
|
||||
const orderDevices = new OrderDeviceAutomationService(pool, deviceControl);
|
||||
const notifications = new NotificationService(pool, createNotificationAdaptersFromEnvironment());
|
||||
const outbox = new OutboxRepository(pool);
|
||||
const reports = new BusinessReportService(pool);
|
||||
const worker = new TaskWorker({
|
||||
repository: new TaskRepository(pool),
|
||||
handlers: new Map([
|
||||
['device.command', async (task) => { await orderDevices.handleTask(task); }],
|
||||
['notification.dispatch', async (task) => { await notifications.handleTask(task); }],
|
||||
['statistics.aggregate', async (task) => { await reports.handleTask(task); }],
|
||||
['outbox.publish', async (task) => {
|
||||
const payload = task.payload as { eventId?: unknown };
|
||||
const eventId = typeof payload?.eventId === 'string' ? payload.eventId : '';
|
||||
@@ -98,7 +101,10 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1
|
||||
}]
|
||||
]),
|
||||
workerId: `${hostname()}:${process.pid}:${randomUUID()}`,
|
||||
onIdle: async () => { await notifications.enqueuePendingOutbox(); }
|
||||
onIdle: async () => {
|
||||
await notifications.enqueuePendingOutbox();
|
||||
await reports.enqueueDueAggregations();
|
||||
}
|
||||
});
|
||||
const shutdown = () => worker.stop();
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildApp } from '../dist/app.js';
|
||||
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||
import {
|
||||
BusinessReportError,
|
||||
BusinessReportService,
|
||||
assertDateRange,
|
||||
localDateKey,
|
||||
zonedDateStart
|
||||
} from '../dist/operations/business-report-service.js';
|
||||
|
||||
assert.equal(zonedDateStart('2026-08-10', 'Asia/Shanghai').toISOString(), '2026-08-09T16:00:00.000Z');
|
||||
assert.equal(zonedDateStart('2026-03-08', 'America/New_York').toISOString(), '2026-03-08T05:00:00.000Z');
|
||||
assert.equal(zonedDateStart('2026-03-09', 'America/New_York').toISOString(), '2026-03-09T04:00:00.000Z');
|
||||
assert.equal(localDateKey(new Date('2026-08-09T16:00:00.000Z'), 'Asia/Shanghai'), '2026-08-10');
|
||||
assert.throws(() => assertDateRange('2026-01-01', '2026-04-30'),
|
||||
(error) => error instanceof BusinessReportError && error.code === 'BUSINESS_REPORT_RANGE_INVALID');
|
||||
|
||||
const actor = {
|
||||
tenantId: '7', userId: '22', traceId: 'report-test', ip: '127.0.0.1', userAgent: 'test',
|
||||
access: { roles: ['STORE_ADMIN'], capabilities: ['report.read', 'report.export'], storeIds: ['11'] }
|
||||
};
|
||||
const calls = [];
|
||||
const service = new BusinessReportService({
|
||||
async execute(sql, params = []) {
|
||||
calls.push({ sql, params });
|
||||
if (sql.includes('SELECT id, name, timezone FROM qipai_stores WHERE')) {
|
||||
return [[{ id: '11', name: '浦东店', timezone: 'Asia/Shanghai' }], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_payments WHERE')) {
|
||||
return [[{ provider: 'WECHAT', amountCents: 10000, occurredAt: '2026-08-10T15:00:00.000Z' }], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_refunds r')) {
|
||||
return [[{ provider: 'WECHAT', amountCents: 1000, occurredAt: '2026-08-10T15:30:00.000Z' }], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_product_payments WHERE')) {
|
||||
return [[{ provider: 'WECHAT', amountCents: 2500, occurredAt: '2026-08-10T10:00:00.000Z' }], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_product_refunds r')) {
|
||||
return [[{ provider: 'WECHAT', amountCents: 500, occurredAt: '2026-08-10T11:00:00.000Z' }], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_orders o') && sql.includes('actualStart')) {
|
||||
return [[{
|
||||
id: '91', userId: '31', status: 'FINISHED', createdAt: '2026-08-10T09:00:00.000Z',
|
||||
bookedStart: '2026-08-10T15:00:00.000Z', bookedEnd: '2026-08-10T17:00:00.000Z',
|
||||
actualStart: '2026-08-10T15:30:00.000Z', actualEnd: '2026-08-10T16:30:00.000Z'
|
||||
}], []];
|
||||
}
|
||||
if (sql.includes('COUNT(*) AS total FROM qipai_rooms')) return [[{ total: 2 }], []];
|
||||
if (sql.includes('FROM qipai_cleaning_settlement_items i') && sql.includes('s.paid_at AS occurredAt')) {
|
||||
return [[{ amountCents: 600, occurredAt: '2026-08-10T12:00:00.000Z' }], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_cleaning_settlement_items i') && sql.includes('i.reversed_at AS occurredAt')) return [[], []];
|
||||
if (sql.includes('FROM qipai_business_daily_summaries')) return [[], []];
|
||||
throw new Error(`Unexpected report SQL: ${sql}`);
|
||||
}
|
||||
}, () => new Date('2026-08-11T00:00:00.000Z'));
|
||||
|
||||
const report = await service.report(actor, { storeId: '11', from: '2026-08-10', to: '2026-08-10' });
|
||||
assert.equal(report.summary.roomNetCents, 9000);
|
||||
assert.equal(report.summary.productNetCents, 2000);
|
||||
assert.equal(report.summary.totalNetCents, 11000);
|
||||
assert.equal(report.summary.channels.WECHAT, 11000);
|
||||
assert.equal(report.summary.cleaningCostCents, 600);
|
||||
assert.equal(report.summary.contributionCents, 10400);
|
||||
assert.equal(report.summary.orderCount, 1);
|
||||
assert.equal(report.summary.customerCount, 1);
|
||||
assert.equal(report.summary.usedMinutes, 30);
|
||||
assert.equal(report.summary.capacityMinutes, 2880);
|
||||
assert.equal(report.reconciliation.status, 'NOT_AGGREGATED');
|
||||
const paymentCall = calls.find(({ sql }) => sql.includes('FROM qipai_payments WHERE'));
|
||||
assert.equal(paymentCall.params[2].toISOString(), '2026-08-09T16:00:00.000Z');
|
||||
assert.equal(paymentCall.params[3].toISOString(), '2026-08-10T16:00:00.000Z');
|
||||
await assert.rejects(
|
||||
() => service.report({ ...actor, access: { ...actor.access, storeIds: ['12'] } },
|
||||
{ storeId: '11', from: '2026-08-10', to: '2026-08-10' }),
|
||||
(error) => error instanceof BusinessReportError && error.code === 'BUSINESS_REPORT_FORBIDDEN'
|
||||
);
|
||||
|
||||
const secret = 'business-report-secret-with-32-characters';
|
||||
const token = signAccessToken({ sub: '22', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tid: '7', aid: '9', rv: 1 }, secret, 900);
|
||||
let rebuildInput;
|
||||
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() { return {}; } },
|
||||
reportService: {
|
||||
async report() { return report; },
|
||||
async enqueueRange(_actor, input) { rebuildInput = input; return { queued: 1, storeCount: 1, dayCount: 1 }; }
|
||||
}
|
||||
}
|
||||
});
|
||||
const headers = { authorization: `Bearer ${token}` };
|
||||
const response = await app.inject({ method: 'GET', url: '/admin-api/reports/business?storeId=11&from=2026-08-10&to=2026-08-10', headers });
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.json().data.summary.totalNetCents, 11000);
|
||||
const exported = await app.inject({ method: 'GET', url: '/admin-api/reports/business/export?storeId=11&from=2026-08-10&to=2026-08-10', headers });
|
||||
assert.equal(exported.statusCode, 200);
|
||||
assert.match(exported.headers['content-type'], /text\/csv/);
|
||||
assert.match(exported.body, /浦东店/);
|
||||
const rebuilt = await app.inject({ method: 'POST', url: '/admin-api/reports/business/rebuild', headers,
|
||||
payload: { storeId: '11', from: '2026-08-10', to: '2026-08-10' } });
|
||||
assert.equal(rebuilt.statusCode, 200);
|
||||
assert.equal(rebuildInput.storeId, '11');
|
||||
await app.close();
|
||||
|
||||
console.log('PASS: M10-B report metrics, store timezone boundaries, detail reconciliation, CSV and rebuild routes are stable.');
|
||||
@@ -156,6 +156,15 @@ const notificationDownSql = read(
|
||||
const notificationVerifySql = read(
|
||||
'database/migrations/2026081110_m10a_notification_center.verify.sql'
|
||||
);
|
||||
const businessReportUpSql = read(
|
||||
'database/migrations/2026081111_m10b_business_reports.up.sql'
|
||||
);
|
||||
const businessReportDownSql = read(
|
||||
'database/migrations/2026081111_m10b_business_reports.down.sql'
|
||||
);
|
||||
const businessReportVerifySql = read(
|
||||
'database/migrations/2026081111_m10b_business_reports.verify.sql'
|
||||
);
|
||||
|
||||
const coreTables = [
|
||||
'qipai_schema_migrations',
|
||||
@@ -751,4 +760,21 @@ assert.match(notificationUpSql, /'notification\.read'/);
|
||||
assert.match(notificationUpSql, /'notification\.manage'/);
|
||||
assert.match(notificationVerifySql, /'2026081110'/);
|
||||
|
||||
console.log('PASS: M01-B through M10-A migration contracts are present.');
|
||||
assert.match(businessReportUpSql, /CREATE TABLE IF NOT EXISTS qipai_business_daily_summaries\b/);
|
||||
for (const column of ['room_gross_cents', 'room_refund_cents', 'product_gross_cents',
|
||||
'product_refund_cents', 'used_minutes', 'capacity_minutes', 'cleaning_cost_cents',
|
||||
'wechat_net_cents', 'balance_net_cents', 'package_net_cents', 'group_buy_net_cents',
|
||||
'source_checksum']) {
|
||||
assert.match(businessReportUpSql, new RegExp(`\\b${column}\\b`));
|
||||
}
|
||||
for (const permission of ['report.read', 'report.export', 'report.manage']) {
|
||||
const pattern = new RegExp(permission.replace('.', '\\.'));
|
||||
assert.match(businessReportUpSql, pattern);
|
||||
assert.match(businessReportDownSql, pattern);
|
||||
assert.match(businessReportVerifySql, pattern);
|
||||
}
|
||||
assert.match(businessReportUpSql, /uq_qipai_business_daily_summary_date/);
|
||||
assert.match(businessReportDownSql, /DROP TABLE IF EXISTS qipai_business_daily_summaries/);
|
||||
assert.match(businessReportVerifySql, /'2026081111'/);
|
||||
|
||||
console.log('PASS: M01-B through M10-B migration contracts are present.');
|
||||
|
||||
@@ -49,7 +49,8 @@ assert.match(plan.file, /2026081006_m09c_cleaning_settlement_integrity\.up\.sql/
|
||||
assert.match(plan.file, /2026081107_m09d1_product_inventory_foundation\.up\.sql/);
|
||||
assert.match(plan.file, /2026081108_m09d2_product_order_payment_inventory\.up\.sql/);
|
||||
assert.match(plan.file, /2026081109_m09d3_product_storage\.up\.sql/);
|
||||
assert.match(plan.file, /2026081110_m10a_notification_center\.up\.sql$/);
|
||||
assert.match(plan.file, /2026081110_m10a_notification_center\.up\.sql/);
|
||||
assert.match(plan.file, /2026081111_m10b_business_reports\.up\.sql$/);
|
||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||
assert.ok(plan.statements.length >= 11);
|
||||
|
||||
@@ -72,13 +73,19 @@ assert.match(
|
||||
verifyPlan.file,
|
||||
/2026081109_m09d3_product_storage\.verify\.sql/
|
||||
);
|
||||
assert.match(verifyPlan.file, /2026081110_m10a_notification_center\.verify\.sql$/);
|
||||
assert.match(verifyPlan.file, /2026081110_m10a_notification_center\.verify\.sql/);
|
||||
assert.match(verifyPlan.file, /2026081111_m10b_business_reports\.verify\.sql$/);
|
||||
|
||||
const downPlan = await loadMigrationPlan('down');
|
||||
assert.match(downPlan.file, /^database\/migrations\/2026081110_m10a_notification_center\.down\.sql/);
|
||||
assert.match(downPlan.file, /^database\/migrations\/2026081111_m10b_business_reports\.down\.sql/);
|
||||
assert.match(downPlan.file, /2026081110_m10a_notification_center\.down\.sql/);
|
||||
assert.match(downPlan.file, /2026081109_m09d3_product_storage\.down\.sql/);
|
||||
assert.match(downPlan.file, /2026081108_m09d2_product_order_payment_inventory\.down\.sql/);
|
||||
assert.match(downPlan.file, /2026081107_m09d1_product_inventory_foundation\.down\.sql/);
|
||||
assert.ok(
|
||||
downPlan.file.indexOf('2026081111_m10b_business_reports.down.sql')
|
||||
< downPlan.file.indexOf('2026081110_m10a_notification_center.down.sql')
|
||||
);
|
||||
assert.ok(
|
||||
downPlan.file.indexOf('2026081110_m10a_notification_center.down.sql')
|
||||
< downPlan.file.indexOf('2026081109_m09d3_product_storage.down.sql')
|
||||
|
||||
@@ -62,6 +62,9 @@ import {
|
||||
ProductStorageError, ProductStorageService, productStorageCredentialDigest
|
||||
} from '../dist/products/product-storage-service.js';
|
||||
import { NotificationService } from '../dist/notifications/notification-service.js';
|
||||
import {
|
||||
BusinessReportService, zonedDateStart
|
||||
} from '../dist/operations/business-report-service.js';
|
||||
import {
|
||||
executeMigrationPlan,
|
||||
loadMigrationPlan,
|
||||
@@ -74,6 +77,7 @@ const expectedTables = [
|
||||
'qipai_async_tasks',
|
||||
'qipai_audit_logs',
|
||||
'qipai_auth_sessions',
|
||||
'qipai_business_daily_summaries',
|
||||
'qipai_cleaning_settlement_events',
|
||||
'qipai_cleaning_settlement_reversals',
|
||||
'qipai_cleaning_task_photos',
|
||||
@@ -196,18 +200,29 @@ async function assertProductInventoryMigrationRetry(pool, fullUpPlan) {
|
||||
repoRoot,
|
||||
'database/migrations/2026081110_m10a_notification_center'
|
||||
);
|
||||
const reportMigrationBase = resolve(
|
||||
repoRoot,
|
||||
'database/migrations/2026081111_m10b_business_reports'
|
||||
);
|
||||
const [upSql, downSql, productOrderDownSql, productStorageDownSql,
|
||||
notificationDownSql] = await Promise.all([
|
||||
notificationDownSql, reportDownSql] = await Promise.all([
|
||||
readFile(`${migrationBase}.up.sql`, 'utf8'),
|
||||
readFile(`${migrationBase}.down.sql`, 'utf8'),
|
||||
readFile(`${productOrderMigrationBase}.down.sql`, 'utf8'),
|
||||
readFile(`${productStorageMigrationBase}.down.sql`, 'utf8'),
|
||||
readFile(`${notificationMigrationBase}.down.sql`, 'utf8')
|
||||
readFile(`${notificationMigrationBase}.down.sql`, 'utf8'),
|
||||
readFile(`${reportMigrationBase}.down.sql`, 'utf8')
|
||||
]);
|
||||
const upStatements = splitSqlStatements(upSql);
|
||||
let productOrderDownAttempt = 0;
|
||||
const removeProductOrderDependents = async () => {
|
||||
productOrderDownAttempt += 1;
|
||||
await executeMigrationPlan(pool, {
|
||||
direction: 'down',
|
||||
file: `${reportMigrationBase}.retry-${productOrderDownAttempt}.down.sql`,
|
||||
checksum: `m10b-before-m09d1-retry-${productOrderDownAttempt}`,
|
||||
statements: splitSqlStatements(reportDownSql)
|
||||
});
|
||||
await executeMigrationPlan(pool, {
|
||||
direction: 'down',
|
||||
file: `${notificationMigrationBase}.retry-${productOrderDownAttempt}.down.sql`,
|
||||
@@ -301,14 +316,15 @@ async function readMigrationVersions(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT version, name
|
||||
FROM qipai_schema_migrations
|
||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ORDER BY version`,
|
||||
['2026061601', '2026061802', '2026061803', '2026061804',
|
||||
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
||||
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
|
||||
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
|
||||
'2026062220', '2026081002', '2026081003', '2026081004', '2026081005',
|
||||
'2026081006', '2026081107', '2026081108', '2026081109', '2026081110']
|
||||
'2026081006', '2026081107', '2026081108', '2026081109', '2026081110',
|
||||
'2026081111']
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
@@ -1996,7 +2012,7 @@ async function assertSystemOperations(pool, context) {
|
||||
assert.equal(logs.items[0].metadata.nested.safe, 'visible');
|
||||
const overview = await repository.getSystemOverview(context.tenantId);
|
||||
assert.equal(overview.tenant.id, context.tenantId);
|
||||
assert.equal(overview.latestMigration.version, '2026081110');
|
||||
assert.equal(overview.latestMigration.version, '2026081111');
|
||||
assert.ok(overview.counts.userCount > 0);
|
||||
await repository.updateTenant(actor, context.tenantId, {
|
||||
name: overview.tenant.name, timezone: overview.tenant.timezone
|
||||
@@ -4812,6 +4828,73 @@ async function assertNotificationCenter(pool, context) {
|
||||
console.log('PASS: M10-A outbox fan-out, redaction, consent suppression, delivery, retry and immutable attempts are consistent.');
|
||||
}
|
||||
|
||||
async function assertBusinessReports(pool, context) {
|
||||
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.tenant_id = ur.tenant_id AND r.id = ur.role_id
|
||||
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN' AND u.deleted_at IS NULL LIMIT 1`,
|
||||
[context.tenantId]
|
||||
);
|
||||
const [storeRows] = await pool.query(
|
||||
`SELECT id, timezone FROM qipai_stores
|
||||
WHERE tenant_id = ? AND deleted_at IS NULL ORDER BY id LIMIT 1`, [context.tenantId]
|
||||
);
|
||||
assert.ok(adminRows[0] && storeRows[0]);
|
||||
const adminId = String(adminRows[0].id);
|
||||
const storeId = String(storeRows[0].id);
|
||||
const access = await new RbacRepository(pool).getAccessProfile(context.tenantId, adminId);
|
||||
for (const permission of ['report.read', 'report.export', 'report.manage']) {
|
||||
assert.ok(access.capabilities.includes(permission));
|
||||
}
|
||||
const actor = { tenantId: context.tenantId, userId: adminId, access,
|
||||
traceId: 'm10b-live-report', ip: '127.0.0.1', userAgent: 'M10-B live test' };
|
||||
const dateParts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: storeRows[0].timezone, year: 'numeric', month: '2-digit', day: '2-digit'
|
||||
}).formatToParts(new Date());
|
||||
const value = (type) => dateParts.find((part) => part.type === type)?.value;
|
||||
const date = `${value('year')}-${value('month')}-${value('day')}`;
|
||||
const service = new BusinessReportService(pool);
|
||||
const live = await service.report(actor, { storeId, from: date, to: date });
|
||||
const utcFrom = zonedDateStart(date, storeRows[0].timezone);
|
||||
const nextDate = new Date(`${date}T00:00:00.000Z`);
|
||||
nextDate.setUTCDate(nextDate.getUTCDate() + 1);
|
||||
const utcTo = zonedDateStart(nextDate.toISOString().slice(0, 10), storeRows[0].timezone);
|
||||
const [roomMoneyRows] = await pool.query(
|
||||
`SELECT
|
||||
(SELECT COALESCE(SUM(amount_cents), 0) FROM qipai_payments
|
||||
WHERE tenant_id = ? AND store_id = ?
|
||||
AND status IN ('SUCCEEDED', 'PARTIALLY_REFUNDED', 'REFUNDED')
|
||||
AND paid_at >= ? AND paid_at < ? AND deleted_at IS NULL) AS grossCents,
|
||||
(SELECT COALESCE(SUM(r.amount_cents), 0) FROM qipai_refunds r
|
||||
INNER JOIN qipai_payments p ON p.tenant_id = r.tenant_id AND p.id = r.payment_id
|
||||
WHERE r.tenant_id = ? AND p.store_id = ? AND r.status = 'SUCCEEDED'
|
||||
AND r.completed_at >= ? AND r.completed_at < ?) AS refundCents`,
|
||||
[context.tenantId, storeId, utcFrom, utcTo,
|
||||
context.tenantId, storeId, utcFrom, utcTo]
|
||||
);
|
||||
assert.equal(live.daily.length, 1);
|
||||
assert.equal(live.summary.roomGrossCents, Number(roomMoneyRows[0].grossCents));
|
||||
assert.equal(live.summary.roomRefundCents, Number(roomMoneyRows[0].refundCents));
|
||||
assert.equal(live.summary.totalNetCents,
|
||||
live.summary.roomNetCents + live.summary.productNetCents);
|
||||
assert.equal(Object.values(live.summary.channels).reduce((sum, amount) => sum + amount, 0),
|
||||
live.summary.totalNetCents);
|
||||
assert.ok(live.summary.usedMinutes <= live.summary.capacityMinutes);
|
||||
assert.equal(live.reconciliation.status, 'NOT_AGGREGATED');
|
||||
await service.handleTask({ id: 'm10b-task', tenantId: context.tenantId,
|
||||
taskType: 'statistics.aggregate', idempotencyKey: `report:${storeId}:${date}`,
|
||||
payload: { storeId, date }, status: 'RUNNING', attempts: 1, maxAttempts: 5 });
|
||||
const reconciled = await service.report(actor, { storeId, from: date, to: date });
|
||||
assert.equal(reconciled.reconciliation.status, 'MATCHED');
|
||||
const queued = await service.enqueueRange(actor, { storeId, from: date, to: date });
|
||||
assert.equal(queued.storeCount, 1); assert.equal(queued.dayCount, 1); assert.equal(queued.queued, 1);
|
||||
await assert.rejects(() => service.report({ ...actor,
|
||||
access: { roles: ['STORE_ADMIN'], capabilities: ['report.read'], storeIds: [] }
|
||||
}, { storeId, from: date, to: date }), (error) => error?.code === 'BUSINESS_REPORT_FORBIDDEN');
|
||||
console.log('PASS: M10-B timezone boundaries, net revenue, usage, daily aggregation and detail reconciliation are consistent.');
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.');
|
||||
assert.match(
|
||||
@@ -4868,7 +4951,8 @@ try {
|
||||
{ version: '2026081107', name: 'm09d1_product_inventory_foundation' },
|
||||
{ version: '2026081108', name: 'm09d2_product_order_payment_inventory' },
|
||||
{ version: '2026081109', name: 'm09d3_product_storage' },
|
||||
{ version: '2026081110', name: 'm10a_notification_center' }
|
||||
{ version: '2026081110', name: 'm10a_notification_center' },
|
||||
{ version: '2026081111', name: 'm10b_business_reports' }
|
||||
]);
|
||||
await assertTaskDurability(pool);
|
||||
const loginContext = await assertPlatformTenantIsolation(pool);
|
||||
@@ -4895,13 +4979,14 @@ try {
|
||||
await assertProductOrderPaymentInventory(pool, loginContext);
|
||||
await assertProductStorageLifecycle(pool, loginContext);
|
||||
await assertNotificationCenter(pool, loginContext);
|
||||
await assertBusinessReports(pool, loginContext);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
|
||||
|
||||
await executeMigrationPlan(pool, plans.down);
|
||||
assert.deepEqual(await readCoreTables(pool), []);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: down removed all M01-B through M10-A migration tables.');
|
||||
console.log('PASS: down removed all M01-B through M10-B migration tables.');
|
||||
|
||||
await executeMigrationPlan(pool, plans.up);
|
||||
await executeMigrationPlan(pool, plans.verify);
|
||||
@@ -4935,7 +5020,8 @@ try {
|
||||
{ version: '2026081107', name: 'm09d1_product_inventory_foundation' },
|
||||
{ version: '2026081108', name: 'm09d2_product_order_payment_inventory' },
|
||||
{ version: '2026081109', name: 'm09d3_product_storage' },
|
||||
{ version: '2026081110', name: 'm10a_notification_center' }
|
||||
{ version: '2026081110', name: 'm10a_notification_center' },
|
||||
{ version: '2026081111', name: 'm10b_business_reports' }
|
||||
]);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: second up and verify restored the schema.');
|
||||
|
||||
Reference in New Issue
Block a user