feat(M10-B): 完成可复算经营报表与日汇总

This commit is contained in:
Codex
2026-08-11 09:50:05 +08:00
parent 5d70114bab
commit ee7b16c932
35 changed files with 1355 additions and 65 deletions
+113
View File
@@ -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.');
+27 -1
View File
@@ -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.');
+10 -3
View File
@@ -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.');