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
@@ -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.');