diff --git a/api/src/reports/reports.service.spec.ts b/api/src/reports/reports.service.spec.ts index 3e6a774..7100710 100644 --- a/api/src/reports/reports.service.spec.ts +++ b/api/src/reports/reports.service.spec.ts @@ -1,11 +1,19 @@ +import { Logger } from '@nestjs/common'; import { ReportsService } from './reports.service'; +type CapturedSql = { strings: string[]; values: unknown[]; text: string }; + +function sqlText(query: CapturedSql) { + return query.strings.join(' '); +} + describe('ReportsService', () => { const tx = { dailyReconciliationReport: { deleteMany: jest.fn() }, dailyProfitReport: { deleteMany: jest.fn() }, dailyQualityReport: { deleteMany: jest.fn() }, $executeRaw: jest.fn(), + $queryRaw: jest.fn(), }; const prisma = { dailyReconciliationReport: { findMany: jest.fn(), count: jest.fn(), aggregate: jest.fn() }, @@ -14,25 +22,62 @@ describe('ReportsService', () => { $transaction: jest.fn((callback: (client: typeof tx) => unknown) => callback(tx)), }; let service: ReportsService; + const environmentKeys = [ + 'REPORT_DAILY_REFRESH_ENABLED', + 'REPORT_REFRESH_INTERVAL_MS', + 'REPORT_REFRESH_TRANSACTION_TIMEOUT_MS', + ] as const; + const originalEnvironment = Object.fromEntries(environmentKeys.map((key) => [key, process.env[key]])); beforeEach(() => { - jest.clearAllMocks(); + jest.resetAllMocks(); + for (const key of environmentKeys) delete process.env[key]; + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + prisma.$transaction.mockImplementation((callback: (client: typeof tx) => unknown) => callback(tx)); tx.dailyReconciliationReport.deleteMany.mockResolvedValue({ count: 0 }); tx.dailyProfitReport.deleteMany.mockResolvedValue({ count: 0 }); tx.dailyQualityReport.deleteMany.mockResolvedValue({ count: 0 }); tx.$executeRaw.mockResolvedValue(0); + tx.$queryRaw.mockResolvedValue([{ locked: true }]); prisma.dailyReconciliationReport.findMany.mockResolvedValue([{ id: 'recon-1' }]); prisma.dailyReconciliationReport.count.mockResolvedValue(1); - prisma.dailyReconciliationReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } }); + prisma.dailyReconciliationReport.aggregate.mockResolvedValue({ + _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 }, + }); prisma.dailyProfitReport.findMany.mockResolvedValue([{ id: 'profit-1', refundCents: BigInt(100) }]); prisma.dailyProfitReport.count.mockResolvedValue(1); - prisma.dailyProfitReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1, revenueCents: BigInt(1000), costCents: BigInt(600), profitCents: BigInt(400) } }); + prisma.dailyProfitReport.aggregate.mockResolvedValue({ + _sum: { + submittedUnits: 11, + sentUnits: 10, + unknownUnits: 2, + successUnits: 7, + failedUnits: 1, + revenueCents: BigInt(1000), + costCents: BigInt(600), + profitCents: BigInt(400), + }, + }); prisma.dailyQualityReport.findMany.mockResolvedValue([{ id: 'quality-1' }]); prisma.dailyQualityReport.count.mockResolvedValue(1); - prisma.dailyQualityReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } }); + prisma.dailyQualityReport.aggregate.mockResolvedValue({ + _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 }, + }); service = new ReportsService(prisma as never); }); + afterEach(() => { + service.onModuleDestroy(); + jest.useRealTimers(); + jest.restoreAllMocks(); + for (const key of environmentKeys) { + const value = originalEnvironment[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + it('rebuilds exactly T-4 through T-1 in independent transactions', async () => { await expect(service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'))).resolves.toEqual({ refreshedDates: ['2026-07-11', '2026-07-12', '2026-07-13', '2026-07-14'], @@ -41,14 +86,193 @@ describe('ReportsService', () => { expect(tx.dailyReconciliationReport.deleteMany).toHaveBeenCalledTimes(4); expect(tx.dailyProfitReport.deleteMany).toHaveBeenCalledTimes(4); expect(tx.dailyQualityReport.deleteMany).toHaveBeenCalledTimes(4); - expect(tx.$executeRaw).toHaveBeenCalledTimes(28); + expect(tx.$executeRaw).toHaveBeenCalledTimes(32); + expect(tx.$queryRaw).toHaveBeenCalledTimes(4); + }); + + it.each([ + ['2026-12-31T15:59:59.999Z', ['2026-12-27', '2026-12-28', '2026-12-29', '2026-12-30']], + ['2026-12-31T16:00:00.000Z', ['2026-12-28', '2026-12-29', '2026-12-30', '2026-12-31']], + ['2026-02-28T16:00:00.000Z', ['2026-02-25', '2026-02-26', '2026-02-27', '2026-02-28']], + ])('uses completed Shanghai dates at boundary %s', async (now, expectedDates) => { + await expect(service.refreshRollingWindow(new Date(now))).resolves.toEqual({ refreshedDates: expectedDates }); + expect( + tx.dailyReconciliationReport.deleteMany.mock.calls.map(([query]) => + query.where.reportDate.toISOString().slice(0, 10), + ), + ).toEqual(expectedDates); + }); + + it('bounds application costs by the original message day while retaining cross-day submits', async () => { + await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z')); + const applicationProfit = tx.$executeRaw.mock.calls + .map(([query]) => query as CapturedSql) + .find((query) => sqlText(query).includes('WITH costs AS'))!; + const costs = applicationProfit.text.split('INSERT INTO "DailyProfitReport"')[0]; + + expect(costs).toMatch(/message\."queuedAt" >= \$1/); + expect(costs).toMatch(/message\."queuedAt" < \$2/); + expect(applicationProfit.values.slice(0, 2)).toEqual([ + new Date('2026-07-10T16:00:00.000Z'), + new Date('2026-07-11T16:00:00.000Z'), + ]); + expect(costs).toContain('submit."submitStatus" = \'accepted\''); + expect(costs).not.toContain('submit."submittedAt"'); + expect(costs).not.toContain('submit."createdAt"'); + expect(costs).toContain('WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count'); + expect(costs).toContain('WHEN legacy_receipt.delivered THEN message."billingUnits"'); + + const channelProfit = tx.$executeRaw.mock.calls + .map(([query]) => sqlText(query)) + .find((query) => query.includes("CONCAT('profit-channel-'"))!; + expect(channelProfit).toContain('COALESCE(submit."submittedAt", submit."createdAt") >='); + expect(channelProfit).toContain('COALESCE(submit."submittedAt", submit."createdAt") <'); + }); + + it.each([ + [undefined, 30_000], + ['45000', 45_000], + ['200000', 120_000], + ['0', 30_000], + ['-1', 30_000], + ['1.5', 30_000], + ['invalid', 30_000], + ['Infinity', 30_000], + ])('uses a bounded report transaction timeout for %s', async (configured, expected) => { + if (configured !== undefined) process.env.REPORT_REFRESH_TRANSACTION_TIMEOUT_MS = configured; + await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z')); + + expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { maxWait: 5_000, timeout: expected }); + const timeoutQueries = tx.$executeRaw.mock.calls + .map(([query]) => query as CapturedSql) + .filter((query) => sqlText(query).includes('statement_timeout')); + expect(timeoutQueries).toHaveLength(4); + for (const query of timeoutQueries) { + expect(sqlText(query)).toContain("set_config('statement_timeout',"); + expect(sqlText(query)).toContain(', true)'); + expect(query.values).toEqual([`${expected}ms`]); + } + }); + + it('continues later dates after one transaction fails and reports partial completion', async () => { + const failure = new Error('database transaction expired'); + tx.$executeRaw.mockResolvedValueOnce(0).mockRejectedValueOnce(failure); + + await expect(service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'))).rejects.toThrow( + 'failed dates: 2026-07-11; refreshed dates: 2026-07-12, 2026-07-13, 2026-07-14', + ); + expect(prisma.$transaction).toHaveBeenCalledTimes(4); + expect( + tx.dailyQualityReport.deleteMany.mock.calls.map(([query]) => query.where.reportDate.toISOString().slice(0, 10)), + ).toEqual(['2026-07-11', '2026-07-12', '2026-07-13', '2026-07-14']); + expect(Logger.prototype.error).toHaveBeenCalledWith('Daily report refresh failed for 2026-07-11', failure.stack); + }); + + it('does not delete reports for a date whose lock is owned by another transaction', async () => { + tx.$queryRaw.mockResolvedValueOnce([{ locked: false }]); + + await expect(service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'))).rejects.toThrow( + 'failed dates: 2026-07-11', + ); + for (const model of [tx.dailyReconciliationReport, tx.dailyProfitReport, tx.dailyQualityReport]) { + expect(model.deleteMany.mock.calls.map(([query]) => query.where.reportDate.toISOString().slice(0, 10))).toEqual([ + '2026-07-12', + '2026-07-13', + '2026-07-14', + ]); + } + expect(tx.$queryRaw.mock.calls.map(([query]) => query.values[1])).toEqual([20260711, 20260712, 20260713, 20260714]); + expect(sqlText(tx.$queryRaw.mock.calls[0][0])).toContain('pg_try_advisory_xact_lock'); + expect(tx.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan( + tx.dailyReconciliationReport.deleteMany.mock.invocationCallOrder[0], + ); + }); + + it('fails safely when the transaction lock query returns no result', async () => { + tx.$queryRaw.mockResolvedValue([]); + await expect(service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'))).rejects.toThrow( + 'refreshed dates: none', + ); + expect(tx.dailyReconciliationReport.deleteMany).not.toHaveBeenCalled(); + expect(tx.dailyProfitReport.deleteMany).not.toHaveBeenCalled(); + expect(tx.dailyQualityReport.deleteMany).not.toHaveBeenCalled(); + }); + + it('retries partial failures and skips the business day only after full success', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-07-15T05:30:00.000Z')); + tx.$executeRaw.mockRejectedValueOnce(new Error('database unavailable')); + + await service['runScheduledRefresh'](); + expect(prisma.$transaction).toHaveBeenCalledTimes(4); + expect(Logger.prototype.log).not.toHaveBeenCalled(); + await service['runScheduledRefresh'](); + expect(prisma.$transaction).toHaveBeenCalledTimes(8); + expect(Logger.prototype.log).toHaveBeenCalledTimes(1); + await service['runScheduledRefresh'](); + expect(prisma.$transaction).toHaveBeenCalledTimes(8); + + jest.setSystemTime(new Date('2026-07-15T16:00:00.000Z')); + await service['runScheduledRefresh'](); + expect(prisma.$transaction).toHaveBeenCalledTimes(12); + }); + + it('prevents overlapping scheduled refreshes in the same service instance', async () => { + let finishRefresh!: (result: { refreshedDates: string[] }) => void; + const refresh = jest.spyOn(service, 'refreshRollingWindow').mockImplementation( + () => + new Promise((resolve) => { + finishRefresh = resolve; + }), + ); + + const running = service['runScheduledRefresh'](); + await service['runScheduledRefresh'](); + expect(refresh).toHaveBeenCalledTimes(1); + finishRefresh({ refreshedDates: ['2026-07-14'] }); + await running; + }); + + it('cancels startup and interval timers when destroyed before startup refresh', async () => { + jest.useFakeTimers(); + const refresh = jest.spyOn(service, 'refreshRollingWindow'); + service.onModuleInit(); + expect(jest.getTimerCount()).toBe(2); + service.onModuleDestroy(); + expect(jest.getTimerCount()).toBe(0); + await jest.advanceTimersByTimeAsync(2 * 60 * 60 * 1000); + expect(refresh).not.toHaveBeenCalled(); + }); + + it('starts after 15 seconds and retries a failed scheduled run on the configured interval', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-07-15T05:30:00.000Z')); + process.env.REPORT_REFRESH_INTERVAL_MS = '60000'; + const refresh = jest + .spyOn(service, 'refreshRollingWindow') + .mockRejectedValueOnce(new Error('temporarily unavailable')); + service.onModuleInit(); + + await jest.advanceTimersByTimeAsync(14_999); + expect(refresh).not.toHaveBeenCalled(); + await jest.advanceTimersByTimeAsync(1); + expect(refresh).toHaveBeenCalledTimes(1); + await jest.advanceTimersByTimeAsync(45_000); + expect(refresh).toHaveBeenCalledTimes(2); + await jest.advanceTimersByTimeAsync(60_000); + expect(refresh).toHaveBeenCalledTimes(2); + }); + + it('does not schedule reports when daily refresh is disabled', () => { + jest.useFakeTimers(); + process.env.REPORT_DAILY_REFRESH_ENABLED = 'false'; + service.onModuleInit(); + expect(jest.getTimerCount()).toBe(0); }); it('calculates profit cost from channel unit price times delivered fragment count', async () => { await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z')); - const firstDayQueries = tx.$executeRaw.mock.calls.slice(0, 7).map(([query]) => - Array.isArray(query?.strings) ? query.strings.join(' ') : String(query), - ); + const firstDayQueries = tx.$executeRaw.mock.calls + .slice(1, 8) + .map(([query]) => (Array.isArray(query?.strings) ? query.strings.join(' ') : String(query))); const profitQueries = firstDayQueries.slice(1, 3).join('\n'); expect(profitQueries).toContain('"SmsMessageSegmentAudit"'); @@ -59,9 +283,9 @@ describe('ReportsService', () => { it('calculates income from successful billing units and the message unit price snapshot without refund status', async () => { await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z')); - const firstDayQueries = tx.$executeRaw.mock.calls.slice(0, 7).map(([query]) => - Array.isArray(query?.strings) ? query.strings.join(' ') : String(query), - ); + const firstDayQueries = tx.$executeRaw.mock.calls + .slice(1, 8) + .map(([query]) => (Array.isArray(query?.strings) ? query.strings.join(' ') : String(query))); const profitQueries = firstDayQueries.slice(1, 3).join('\n'); expect(profitQueries).toContain('message."billingUnits" * message."unitPrice"'); @@ -71,58 +295,111 @@ describe('ReportsService', () => { }); it('queries reconciliation reports with server-side filters and bounded pagination', async () => { - await expect(service.listReconciliation({ - dateFrom: '2026-07-01', - dateTo: '2026-07-14', - tenantId: 'tenant-1', - applicationId: 'app-1', + await expect( + service.listReconciliation({ + dateFrom: '2026-07-01', + dateTo: '2026-07-14', + tenantId: 'tenant-1', + applicationId: 'app-1', + page: 2, + pageSize: 500, + }), + ).resolves.toEqual({ + items: [{ id: 'recon-1' }], + total: 1, page: 2, - pageSize: 500, - })).resolves.toEqual({ items: [{ id: 'recon-1' }], total: 1, page: 2, pageSize: 100, summary: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } }); - expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(expect.objectContaining({ - where: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1' }), - skip: 100, - take: 100, - })); + pageSize: 100, + summary: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 }, + }); + expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1' }), + skip: 100, + take: 100, + }), + ); }); it('keeps application and channel profit filters separate', async () => { - const result = await service.listProfit({ dimensionType: 'channel', tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1' }); - expect(result).toEqual(expect.objectContaining({ - summary: expect.objectContaining({ revenueCents: 1000, profitCents: 400, profitRateBps: 4000 }), - })); + const result = await service.listProfit({ + dimensionType: 'channel', + tenantId: 'tenant-1', + applicationId: 'app-1', + channelId: 'channel-1', + }); + expect(result).toEqual( + expect.objectContaining({ + summary: expect.objectContaining({ revenueCents: 1000, profitCents: 400, profitRateBps: 4000 }), + }), + ); expect(result.summary).not.toHaveProperty('refundCents'); expect(result.items[0]).not.toHaveProperty('refundCents'); - expect(prisma.dailyProfitReport.findMany).toHaveBeenCalledWith(expect.objectContaining({ - where: expect.objectContaining({ - dimensionType: 'channel', - tenantId: undefined, - applicationId: undefined, - channelId: 'channel-1', + expect(prisma.dailyProfitReport.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + dimensionType: 'channel', + tenantId: undefined, + applicationId: undefined, + channelId: 'channel-1', + }), }), - })); + ); }); it('returns zero full-result totals and rates when a filtered report has no rows', async () => { prisma.dailyProfitReport.findMany.mockResolvedValueOnce([]); prisma.dailyProfitReport.count.mockResolvedValueOnce(0); prisma.dailyProfitReport.aggregate.mockResolvedValueOnce({ - _sum: { submittedUnits: null, sentUnits: null, unknownUnits: null, successUnits: null, failedUnits: null, revenueCents: null, costCents: null, profitCents: null }, + _sum: { + submittedUnits: null, + sentUnits: null, + unknownUnits: null, + successUnits: null, + failedUnits: null, + revenueCents: null, + costCents: null, + profitCents: null, + }, }); - await expect(service.listProfit({ dimensionType: 'application', tenantId: 'missing' })).resolves.toEqual(expect.objectContaining({ - total: 0, - summary: { submittedUnits: 0, sentUnits: 0, unknownUnits: 0, successUnits: 0, failedUnits: 0, revenueCents: 0, costCents: 0, profitCents: 0, profitRateBps: 0 }, - })); + await expect(service.listProfit({ dimensionType: 'application', tenantId: 'missing' })).resolves.toEqual( + expect.objectContaining({ + total: 0, + summary: { + submittedUnits: 0, + sentUnits: 0, + unknownUnits: 0, + successUnits: 0, + failedUnits: 0, + revenueCents: 0, + costCents: 0, + profitCents: 0, + profitRateBps: 0, + }, + }), + ); }); it('exports income without refund columns', async () => { - prisma.dailyProfitReport.findMany.mockResolvedValueOnce([{ - id: 'profit-export', reportDate: new Date('2026-07-14'), dimensionName: '应用A', tenantName: '示例企业', - submittedUnits: 14, sentUnits: 12, unknownUnits: 1, successUnits: 10, failedUnits: 1, - revenueCents: BigInt(3500), refundCents: BigInt(200), costCents: BigInt(2100), profitCents: BigInt(1400), - profitRateBps: 4000, generatedAt: new Date('2026-07-15T00:00:00Z'), - }]); + prisma.dailyProfitReport.findMany.mockResolvedValueOnce([ + { + id: 'profit-export', + reportDate: new Date('2026-07-14'), + dimensionName: '应用A', + tenantName: '示例企业', + submittedUnits: 14, + sentUnits: 12, + unknownUnits: 1, + successUnits: 10, + failedUnits: 1, + revenueCents: BigInt(3500), + refundCents: BigInt(200), + costCents: BigInt(2100), + profitCents: BigInt(1400), + profitRateBps: 4000, + generatedAt: new Date('2026-07-15T00:00:00Z'), + }, + ]); const exported = await service.exportProfit({ dimensionType: 'application' }); expect(exported.content).toContain('收入金额(元)'); @@ -131,28 +408,59 @@ describe('ReportsService', () => { }); it('sorts quality reports by send volume and keeps the selected dimension', async () => { - await expect(service.listQuality({ dimensionType: 'drainage', tenantId: 'tenant-1', page: 1, pageSize: 20 })).resolves.toEqual({ - items: [{ id: 'quality-1' }], total: 1, page: 1, pageSize: 20, dimensionType: 'drainage', - summary: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1, successRateBps: 7000 }, + await expect( + service.listQuality({ dimensionType: 'drainage', tenantId: 'tenant-1', page: 1, pageSize: 20 }), + ).resolves.toEqual({ + items: [{ id: 'quality-1' }], + total: 1, + page: 1, + pageSize: 20, + dimensionType: 'drainage', + summary: { + submittedUnits: 11, + sentUnits: 10, + unknownUnits: 2, + successUnits: 7, + failedUnits: 1, + successRateBps: 7000, + }, }); - expect(prisma.dailyQualityReport.findMany).toHaveBeenCalledWith(expect.objectContaining({ - where: expect.objectContaining({ dimensionType: 'drainage', tenantId: 'tenant-1' }), - orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }], - })); + expect(prisma.dailyQualityReport.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ dimensionType: 'drainage', tenantId: 'tenant-1' }), + orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }], + }), + ); }); it('exports complete filtered report data as escaped CSV instead of the current page', async () => { - prisma.dailyReconciliationReport.findMany.mockResolvedValueOnce([{ - id: 'recon-export', reportDate: new Date('2026-07-14'), tenantName: '示例,企业', applicationName: '应用A', - submittedUnits: 14, sentUnits: 12, unknownUnits: 1, successUnits: 10, failedUnits: 1, generatedAt: new Date('2026-07-15T00:00:00Z'), - }]); - const exported = await service.exportReconciliation({ tenantId: 'tenant-1', dateFrom: '2026-07-01', dateTo: '2026-07-14' }); + prisma.dailyReconciliationReport.findMany.mockResolvedValueOnce([ + { + id: 'recon-export', + reportDate: new Date('2026-07-14'), + tenantName: '示例,企业', + applicationName: '应用A', + submittedUnits: 14, + sentUnits: 12, + unknownUnits: 1, + successUnits: 10, + failedUnits: 1, + generatedAt: new Date('2026-07-15T00:00:00Z'), + }, + ]); + const exported = await service.exportReconciliation({ + tenantId: 'tenant-1', + dateFrom: '2026-07-01', + dateTo: '2026-07-14', + }); expect(exported.fileName).toContain('对账单-'); expect(exported.content).toContain('"示例,企业"'); expect(exported.content).toContain('提交条数'); - expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(expect.objectContaining({ - where: expect.objectContaining({ tenantId: 'tenant-1' }), - orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }], - })); + expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ tenantId: 'tenant-1' }), + orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }], + }), + ); }); }); diff --git a/api/src/reports/reports.service.ts b/api/src/reports/reports.service.ts index db25803..d5353e5 100644 --- a/api/src/reports/reports.service.ts +++ b/api/src/reports/reports.service.ts @@ -6,6 +6,8 @@ import { PrismaService } from '../prisma/prisma.service'; const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000; const DEFAULT_REFRESH_INTERVAL_MS = 60 * 60 * 1000; +const DEFAULT_REFRESH_TRANSACTION_TIMEOUT_MS = 30_000; +const REPORT_LOCK_NAMESPACE = 0x434d5052; export type ReportListQuery = { dateFrom?: string; @@ -21,6 +23,7 @@ export type ReportListQuery = { @Injectable() export class ReportsService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(ReportsService.name); + private startupTimer?: ReturnType; private refreshTimer?: ReturnType; private refreshRunning = false; private lastRefreshBusinessDate?: string; @@ -29,8 +32,8 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { onModuleInit() { if (process.env.REPORT_DAILY_REFRESH_ENABLED === 'false') return; - const startupTimer = setTimeout(() => void this.runScheduledRefresh(), 15_000); - startupTimer.unref?.(); + this.startupTimer = setTimeout(() => void this.runScheduledRefresh(), 15_000); + this.startupTimer.unref?.(); this.refreshTimer = setInterval( () => void this.runScheduledRefresh(), positiveInteger(process.env.REPORT_REFRESH_INTERVAL_MS, DEFAULT_REFRESH_INTERVAL_MS), @@ -39,6 +42,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { } onModuleDestroy() { + if (this.startupTimer) clearTimeout(this.startupTimer); if (this.refreshTimer) clearInterval(this.refreshTimer); } @@ -46,7 +50,12 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { const { page, pageSize, skip } = pagination(query); const where = reconciliationWhere(query); const [items, total, aggregate] = await Promise.all([ - this.prisma.dailyReconciliationReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }], skip, take: pageSize }), + this.prisma.dailyReconciliationReport.findMany({ + where, + orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }], + skip, + take: pageSize, + }), this.prisma.dailyReconciliationReport.count({ where }), this.prisma.dailyReconciliationReport.aggregate({ where, _sum: reportVolumeSumSelection }), ]); @@ -57,7 +66,12 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { const { page, pageSize, skip } = pagination(query); const { dimensionType, where } = profitWhere(query); const [storedItems, total, aggregate] = await Promise.all([ - this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }), + this.prisma.dailyProfitReport.findMany({ + where, + orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }], + skip, + take: pageSize, + }), this.prisma.dailyProfitReport.count({ where }), this.prisma.dailyProfitReport.aggregate({ where, @@ -65,7 +79,10 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { }), ]); // refundCents 暂留在持久化模型中兼容既有数据和回滚,但利润报表接口不再暴露返还口径。 - const items = storedItems.map(({ refundCents: _refundCents, ...item }) => item); + const items = storedItems.map(({ refundCents, ...item }) => { + void refundCents; + return item; + }); const summary = { ...volumeSummary(aggregate._sum), revenueCents: Number(aggregate._sum.revenueCents ?? 0), @@ -81,7 +98,12 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { const { page, pageSize, skip } = pagination(query); const { dimensionType, where } = qualityWhere(query); const [items, total, aggregate] = await Promise.all([ - this.prisma.dailyQualityReport.findMany({ where, orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }), + this.prisma.dailyQualityReport.findMany({ + where, + orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }], + skip, + take: pageSize, + }), this.prisma.dailyQualityReport.count({ where }), this.prisma.dailyQualityReport.aggregate({ where, _sum: reportVolumeSumSelection }), ]); @@ -94,34 +116,136 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { } async exportReconciliation(query: ReportListQuery) { - const items = await this.prisma.dailyReconciliationReport.findMany({ where: reconciliationWhere(query), orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }] }); - return csvExport('对账单', ['发送日期', '企业', '企业应用', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.tenantName, item.applicationName, item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, formatCsvDate(item.generatedAt)])); + const items = await this.prisma.dailyReconciliationReport.findMany({ + where: reconciliationWhere(query), + orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }], + }); + return csvExport( + '对账单', + ['发送日期', '企业', '企业应用', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '生成时间'], + items.map((item) => [ + dateKey(item.reportDate), + item.tenantName, + item.applicationName, + item.submittedUnits, + item.sentUnits, + item.unknownUnits, + item.successUnits, + item.failedUnits, + formatCsvDate(item.generatedAt), + ]), + ); } async exportProfit(query: ReportListQuery) { const { dimensionType, where } = profitWhere(query); - const items = await this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }] }); - return csvExport(`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`, ['发送日期', '统计维度', '企业', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '收入金额(元)', '成本金额(元)', '利润(元)', '利润率(%)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, moneyUnitsToFixedYuan(item.revenueCents), moneyUnitsToFixedYuan(item.costCents), moneyUnitsToFixedYuan(item.profitCents), (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)])); + const items = await this.prisma.dailyProfitReport.findMany({ + where, + orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }], + }); + return csvExport( + `利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`, + [ + '发送日期', + '统计维度', + '企业', + '提交条数', + '发送条数', + '未知条数', + '成功条数', + '失败条数', + '收入金额(元)', + '成本金额(元)', + '利润(元)', + '利润率(%)', + '生成时间', + ], + items.map((item) => [ + dateKey(item.reportDate), + item.dimensionName, + item.tenantName ?? '', + item.submittedUnits, + item.sentUnits, + item.unknownUnits, + item.successUnits, + item.failedUnits, + moneyUnitsToFixedYuan(item.revenueCents), + moneyUnitsToFixedYuan(item.costCents), + moneyUnitsToFixedYuan(item.profitCents), + (item.profitRateBps / 100).toFixed(2), + formatCsvDate(item.generatedAt), + ]), + ); } async exportQuality(query: ReportListQuery) { const { dimensionType, where } = qualityWhere(query); - const items = await this.prisma.dailyQualityReport.findMany({ where, orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }] }); - return csvExport(`发送质量报表-${dimensionType}`, ['发送日期', '统计对象', '企业', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '成功率(%)', '平均到达时长(毫秒)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, (item.successRateBps / 100).toFixed(2), item.avgArrivalMs ?? '', formatCsvDate(item.generatedAt)])); + const items = await this.prisma.dailyQualityReport.findMany({ + where, + orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }], + }); + return csvExport( + `发送质量报表-${dimensionType}`, + [ + '发送日期', + '统计对象', + '企业', + '提交条数', + '发送条数', + '未知条数', + '成功条数', + '失败条数', + '成功率(%)', + '平均到达时长(毫秒)', + '生成时间', + ], + items.map((item) => [ + dateKey(item.reportDate), + item.dimensionName, + item.tenantName ?? '', + item.submittedUnits, + item.sentUnits, + item.unknownUnits, + item.successUnits, + item.failedUnits, + (item.successRateBps / 100).toFixed(2), + item.avgArrivalMs ?? '', + formatCsvDate(item.generatedAt), + ]), + ); } async refreshRollingWindow(now = new Date()) { const days = completedBusinessDays(now, 4); - for (const day of days) await this.refreshBusinessDay(day); - return { refreshedDates: days.map((day) => day.key) }; + const refreshedDates: string[] = []; + const failedDates: string[] = []; + for (const day of days) { + try { + await this.refreshBusinessDay(day); + refreshedDates.push(day.key); + } catch (error) { + failedDates.push(day.key); + this.logger.error( + `Daily report refresh failed for ${day.key}`, + error instanceof Error ? error.stack : String(error), + ); + } + } + if (failedDates.length) { + throw new Error( + `Daily report refresh incomplete; failed dates: ${failedDates.join(', ')}; refreshed dates: ${refreshedDates.join(', ') || 'none'}`, + ); + } + return { refreshedDates }; } private async runScheduledRefresh() { - const businessDate = shanghaiDateKey(new Date()); + const now = new Date(); + const businessDate = shanghaiDateKey(now); if (this.refreshRunning || this.lastRefreshBusinessDate === businessDate) return; this.refreshRunning = true; try { - const result = await this.refreshRollingWindow(); + const result = await this.refreshRollingWindow(now); this.lastRefreshBusinessDate = businessDate; this.logger.log(`Daily reports refreshed for ${result.refreshedDates.join(', ')}`); } catch (error) { @@ -132,12 +256,22 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { } private async refreshBusinessDay(day: BusinessDay) { - await this.prisma.$transaction(async (tx) => { - await tx.dailyReconciliationReport.deleteMany({ where: { reportDate: day.reportDate } }); - await tx.dailyProfitReport.deleteMany({ where: { reportDate: day.reportDate } }); - await tx.dailyQualityReport.deleteMany({ where: { reportDate: day.reportDate } }); + const timeout = Math.min( + 120_000, + positiveInteger(process.env.REPORT_REFRESH_TRANSACTION_TIMEOUT_MS, DEFAULT_REFRESH_TRANSACTION_TIMEOUT_MS), + ); + await this.prisma.$transaction( + async (tx) => { + await tx.$executeRaw(Prisma.sql`SELECT set_config('statement_timeout', ${`${timeout}ms`}, true)`); + const [lock] = await tx.$queryRaw<{ locked: boolean }[]>(Prisma.sql` + SELECT pg_try_advisory_xact_lock(${REPORT_LOCK_NAMESPACE}::integer, ${Number(day.key.replaceAll('-', ''))}::integer) AS locked + `); + if (!lock?.locked) throw new Error(`Daily reports for ${day.key} are being refreshed by another transaction`); + await tx.dailyReconciliationReport.deleteMany({ where: { reportDate: day.reportDate } }); + await tx.dailyProfitReport.deleteMany({ where: { reportDate: day.reportDate } }); + await tx.dailyQualityReport.deleteMany({ where: { reportDate: day.reportDate } }); - await tx.$executeRaw(Prisma.sql` + await tx.$executeRaw(Prisma.sql` INSERT INTO "DailyReconciliationReport" ( "id", "reportDate", "tenantId", "tenantName", "applicationId", "applicationName", "submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "generatedAt", "updatedAt" @@ -169,7 +303,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { GROUP BY tenant.id, tenant.name, application.id, application.name `); - await tx.$executeRaw(Prisma.sql` + await tx.$executeRaw(Prisma.sql` WITH costs AS ( SELECT submit."messageRecordId", @@ -197,6 +331,9 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { ) AS delivered ) legacy_receipt ON TRUE WHERE submit."submitStatus" = 'accepted' + -- 应用成本归属于原短信日,仍包含该短信全部跨日补发尝试。 + AND message."queuedAt" >= ${day.startAt} + AND message."queuedAt" < ${day.endAt} GROUP BY submit."messageRecordId" ) INSERT INTO "DailyProfitReport" ( @@ -249,7 +386,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { GROUP BY tenant.id, tenant.name, application.id, application.name `); - await tx.$executeRaw(Prisma.sql` + await tx.$executeRaw(Prisma.sql` INSERT INTO "DailyProfitReport" ( "id", "reportDate", "dimensionType", "dimensionId", "dimensionName", "tenantId", "tenantName", "applicationId", "channelId", @@ -348,29 +485,34 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { GROUP BY channel.id, channel.name `); - await tx.$executeRaw(qualityByMessageDimensionSql(day, 'application')); - await tx.$executeRaw(qualityByChannelSql(day)); - await tx.$executeRaw(qualityByMessageDimensionSql(day, 'signature')); - await tx.$executeRaw(qualityByMessageDimensionSql(day, 'drainage')); - }); + await tx.$executeRaw(qualityByMessageDimensionSql(day, 'application')); + await tx.$executeRaw(qualityByChannelSql(day)); + await tx.$executeRaw(qualityByMessageDimensionSql(day, 'signature')); + await tx.$executeRaw(qualityByMessageDimensionSql(day, 'drainage')); + }, + { maxWait: 5_000, timeout }, + ); } } function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'application' | 'signature' | 'drainage') { const dimensionTypeSql = Prisma.raw(`'${dimensionType}'`); - const dimensionId = dimensionType === 'application' - ? Prisma.sql`application.id` - : dimensionType === 'signature' - ? Prisma.sql`COALESCE(signature.id, 'unmatched:' || COALESCE(application.id, tenant.id))` - : Prisma.sql`COALESCE(drainage.id, 'unmatched:' || COALESCE(application.id, tenant.id))`; - const dimensionName = dimensionType === 'application' - ? Prisma.sql`application.name` - : dimensionType === 'signature' - ? Prisma.sql`COALESCE(signature.name, '未关联签名')` - : Prisma.sql`COALESCE(drainage."siteName", '未关联引流信息')`; - const applicationJoin = dimensionType === 'application' - ? Prisma.sql`JOIN "SmsApplication" application ON application.id = message."applicationId"` - : Prisma.sql`LEFT JOIN "SmsApplication" application ON application.id = message."applicationId"`; + const dimensionId = + dimensionType === 'application' + ? Prisma.sql`application.id` + : dimensionType === 'signature' + ? Prisma.sql`COALESCE(signature.id, 'unmatched:' || COALESCE(application.id, tenant.id))` + : Prisma.sql`COALESCE(drainage.id, 'unmatched:' || COALESCE(application.id, tenant.id))`; + const dimensionName = + dimensionType === 'application' + ? Prisma.sql`application.name` + : dimensionType === 'signature' + ? Prisma.sql`COALESCE(signature.name, '未关联签名')` + : Prisma.sql`COALESCE(drainage."siteName", '未关联引流信息')`; + const applicationJoin = + dimensionType === 'application' + ? Prisma.sql`JOIN "SmsApplication" application ON application.id = message."applicationId"` + : Prisma.sql`LEFT JOIN "SmsApplication" application ON application.id = message."applicationId"`; return Prisma.sql` WITH base AS ( @@ -548,7 +690,13 @@ const reportVolumeSumSelection = { failedUnits: true, } as const; -function volumeSummary(sum: { submittedUnits?: number | null; sentUnits?: number | null; unknownUnits?: number | null; successUnits?: number | null; failedUnits?: number | null }) { +function volumeSummary(sum: { + submittedUnits?: number | null; + sentUnits?: number | null; + unknownUnits?: number | null; + successUnits?: number | null; + failedUnits?: number | null; +}) { return { submittedUnits: Number(sum.submittedUnits ?? 0), sentUnits: Number(sum.sentUnits ?? 0), @@ -559,11 +707,15 @@ function volumeSummary(sum: { submittedUnits?: number | null; sentUnits?: number } function ratioBps(numerator: number, denominator: number) { - return denominator === 0 ? 0 : Math.round(numerator * 10_000 / denominator); + return denominator === 0 ? 0 : Math.round((numerator * 10_000) / denominator); } function reconciliationWhere(query: ReportListQuery): Prisma.DailyReconciliationReportWhereInput { - return { reportDate: dateFilter(query.dateFrom, query.dateTo), tenantId: query.tenantId || undefined, applicationId: query.applicationId || undefined }; + return { + reportDate: dateFilter(query.dateFrom, query.dateTo), + tenantId: query.tenantId || undefined, + applicationId: query.applicationId || undefined, + }; } function profitWhere(query: ReportListQuery) { @@ -580,7 +732,9 @@ function profitWhere(query: ReportListQuery) { function qualityWhere(query: ReportListQuery) { const allowedDimensions = new Set(['application', 'channel', 'signature', 'drainage']); - const dimensionType = allowedDimensions.has(String(query.dimensionType)) ? String(query.dimensionType) : 'application'; + const dimensionType = allowedDimensions.has(String(query.dimensionType)) + ? String(query.dimensionType) + : 'application'; const where: Prisma.DailyQualityReportWhereInput = { dimensionType, reportDate: dateFilter(query.dateFrom, query.dateTo), diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 1be4609..e99d2b6 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -2232,3 +2232,10 @@ ## 2026-09-08 分页交互补充 签名质量检测四个Tab的每页数量选择移至各自列表底部,与上一页、下一页和页码跳转放在同一分页区;保留独立日期、筛选和每页10/25/50/100,默认25,空结果也可选择数量。通道报备明细增加相同四档容量,默认25;修改容量回到第一页、清空当页勾选、保留已应用筛选,使用真实服务端分页,较早请求的成功或失败不得覆盖最新查询结果。本节补充前述运营修复要求;不更改后端接口、端口或数据模型,详见[运营修复设计](operations-fixes-20260908.md#分页交互补充2026-09-08)。 + + +## 2026-09-08 报表生成可靠性补充 + +本节明确 5.19.1 中的事务范围为“每个日期独立事务”,四日窗口不共用一个长事务。对账、应用/通道利润及四类质量报表在同一日期内原子重建;失败保留该日旧报表,继续处理窗口内其他日期,只有四日全部成功才标记当天完成,失败下次检查仍可重试。默认启动 15 秒后执行、每小时检查,不承诺固定分钟触发。 + +应用利润成本只扫描目标日原短信关联的全部 accepted 提交,包括跨日补发;收入、成本快照、分片审计优先和历史成功回执兼容语义不变。通道维度仍按实际提交日统计。报表使用专属有限事务预算和数据库日锁,不调整发送/计费事务。T-5 及更早数据不被正常日任务改写,历史缺口须单独授权补齐;本轮不新增自动历史重算。实现与验收见 [日报生成超时修复](report-generation-reliability-20260908.md)。 diff --git a/docs/report-generation-reliability-20260908.md b/docs/report-generation-reliability-20260908.md new file mode 100644 index 0000000..fd78799 --- /dev/null +++ b/docs/report-generation-reliability-20260908.md @@ -0,0 +1,25 @@ +# 日报生成超时修复 + +维护日期:2026-09-08。适用于对账单、利润报表、发送质量报表的共同生成任务。本文补充[需求 5.19.1](first-version-development-requirements.md#5191-报表对账)的执行与失败恢复机制,不改变收入、成本、计费条数、质量维度及 T+1 / T-4~T-1 业务口径。实施结果见[测试进度](testing-progress.md)。 + +## 证据与影响 + +预生产版本 `633ba597754c1b89943ea3a819f45042f41b019a` 在 2026-09-08 16:40~16:51 只读核验时,三类报表最新日期均为 9 月 2 日,9 月 3~7 日有短信却缺报表。保留日志有 93 次生成失败,最近 16:12 的错误为 Prisma 事务上限 5000ms、实际已耗时 7510ms。应用利润成本 CTE 未限定原短信日期;9 月 4 日只有 2492 条短信,却扫描 103450 条 accepted 提交。仅执行原 SELECT 的实际计划耗时 9090.976ms;添加原短信日期范围的只读候选耗时 90.515ms。单次对照可能受缓存影响,不代表完整任务提速比例或已经上线。 + +同日期三类报表共用事务,应用利润耗时使事务过期,在下一条通道利润语句处报错,已插入的对账单也回滚。原滚动任务遇到一个日期失败即退出,阻止后续日期执行。 + +## 最小修复设计 + +1. 应用利润的成本 CTE 在关联 `SmsMessageRecord` 后限定 `message.queuedAt >= startAt AND message.queuedAt < endAt`,继续累计这些短信的全部 accepted 提交,包括跨日补发。分片审计优先、无分片审计才兼容明确成功的历史回执;不按当前通道价格倒算,不改通道维度的实际提交日归属。 +2. 每个日期仍在一个独立事务内原子重建对账、应用/通道利润及四个质量维度;任何失败都保留该日期旧报表,不能先删后在事务外插入。 +3. 报表专用事务默认上限 30000ms、获取连接最长等待 5000ms。`REPORT_REFRESH_TRANSACTION_TIMEOUT_MS` 可设正整数毫秒,非法值回退默认,上限 120000ms;不修改其他业务事务的全局设置。事务内设置同上限的 PostgreSQL `statement_timeout`,避免单条异常 SQL 无界运行。 +4. 同日期使用 PostgreSQL 事务级 advisory lock(固定报表命名空间 + YYYYMMDD)。取锁失败视为该日期未完成,不删除报表;事务结束自动释放锁,下一调度周期可重试。日锁同时保护不同 API 实例和同进程手动服务调用。 +5. T-4~T-1 逐日执行;记录每个失败日期及错误后继续其他日期,最后汇总失败并向调用者抛错。只有四天全部成功,调度器才记录本日刷新完成;部分失败保持下个小时重试资格,不静默报告成功。启动 15 秒后的首次执行和默认每小时检查保持;销毁服务同时清除启动与周期定时器。 +6. 本轮不新增迁移、持久化任务表、API 写入口或自动历史回算。T-5 及更早报表不被日常任务改写;已发现的历史缺口须在修复部署后按明确授权、日期清单单独补齐。进程重启仍按现有四日窗口执行,不能声称历史缺口永久恢复机制已实现。 + +## 验收与交付边界 + +- 定向回归覆盖日期边界、成本扫描范围、部分失败继续后续日期、失败重试/成功去重、并发、原子回滚和生命周期停止;真实 PostgreSQL 验证跨日尝试、部分分片成功及历史回执兼容、三类报表及重复生成一致性。 +- 真实 SQL 性能与结果对照在预生产只允许 SELECT、限时和只读事务,不调用生成服务或写业务表。完整生成测试使用本机独立 PostgreSQL 测试库,不能以 mock 通过代替真实数据库证据。 +- 执行 API 全量、类型/生产构建及现有相关质量门禁。没有前端改动,不改变页面、权限、查询 API、端口或租户过滤。 +- 本轮授权修改代码并本地提交;不推送、不部署两环境、不补跑预生产报表、不发送短信或修改业务配置。测试数据仅在隔离本地测试库构造,结果与未验证项记入进度。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index beb6a69..38f07b3 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -5254,3 +5254,20 @@ OPS0908-01至07已按本轮范围验证;精确证据见testing-progress.md对 | OPS-PAGE0908-03 | 打开通道报备明细,设置筛选,到第2页勾选后依次改变四档容量 | 默认25;真实接口page=1及正确pageSize,总数/页数/行数一致;筛选与URL范围保留,旧勾选清空,不执行状态或发送操作 | | OPS-PAGE0908-04 | 隔离测试让旧25条查询晚于新100条返回,分别返回成功和失败 | 旧结果和旧错误均不能覆盖最新查询;当前失败显示真实错误;组件卸载后旧请求失效 | | OPS-PAGE0908-05 | 在1600×1000、1366×768、390×844检查两页面,刷新/跨路由,回归原公共分页消费者 | 下拉完整可见、可操作,表格内部滚动不造成页面整体溢出;旧消费者不新增容量控件;跳转输入在真实页码改变后重置,返回旧页不恢复未提交草稿 | + + +## 2026-09-08 日报生成超时与失败恢复 + +依据 [日报生成超时修复](report-generation-reliability-20260908.md)。涉及造数和失败注入的用例仅在本地独立 PostgreSQL 测试库执行;预生产仅限时只读核验,短信发送、业务配置变更与历史补跑不包含在验收授权中。 + +| 编号 | 场景 | 预期结果 | +|---|---|---| +| TC-DAILY-0908-01 | 大量窗口外历史记录下,对照目标日期应用利润原 SELECT 与限定原短信日期后的 SELECT | 除生成时间外结果字段逐项一致;成本扫描受原短信日期限定,记录实际执行计划和耗时,不能仅比较返回行数 | +| TC-DAILY-0908-02 | 原短信日后跨天、跨通道 accepted 补发,最终收入归最新成功提交 | 应用累计所有关联成功分片成本,仍归原短信日;通道按实际提交日归属且收入不重复;不存在按提交日期裁剪应用成本 | +| TC-DAILY-0908-03 | 长短信部分分片成功、审计存在但零成功、无审计但旧回执成功、失败/未知提交 | 审计优先;零成功不退回历史回执计整条成本;只有无审计且明确旧成功回执时按计费分片数兼容 | +| TC-DAILY-0908-04 | 北京时间零点、跨月/跨年、目标日起点及终点边界 | 每次恰好 T-4~T-1,起点包含、终点排除;不生成当天,不改写 T-5 更早报表 | +| TC-DAILY-0908-05 | 某日对账重建后注入真实 SQL 错误,其他三日继续,之后恢复再执行 | 失败日三表旧结果全部保留、无半成品;其余日期成功;返回含失败/成功日期的错误,不标记当天完成;重试成功后相同日期无重复 | +| TC-DAILY-0908-06 | 同实例重叠触发、两个事务/服务实例同时刷新同日 | 同实例定时入口防重入;数据库日锁未获取时不删除报表,明确失败并保留重试资格;锁释放后可重算且无重复 | +| TC-DAILY-0908-07 | 默认/有效/非法/超大事务超时配置,实际 PostgreSQL 限时及事务结束 | 默认 30000ms、maxWait 5000ms;非法值回退默认、最大 120000ms;statement_timeout 仅当前事务有效,其他业务事务不改变 | +| TC-DAILY-0908-08 | 失败后次周期、成功后同日重复周期,以及启动 15 秒内销毁服务 | 失败继续重试、全成功后同日跳过;销毁清除启动及周期定时器,无销毁后新任务 | +| TC-DAILY-0908-09 | 真实生成后调用报表查询/汇总/分页及 CSV | 数据与 PostgreSQL 一致;既有日期/租户/维度筛选和金额精度不变,API 无假成功或静态数据 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 88dcc81..6a6f392 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -4750,3 +4750,17 @@ git diff --check - 验收脚本问题与边界:独立Vite开发服务器曾出现本机连接超时,改用同一验收进程创建/关闭生产预览后通过;一次脚本假定Escape关闭Select导致等待超时,按当前触发按钮关闭方式修正后全流程通过,未扩改Select。错误/乱序以隔离测试验证;未报备真实非空、热力图超过100个维度、全部旧分页消费者的逐页人工验收未覆盖。真实API数据来自现有服务及PostgreSQL查询;本轮不修改数据库,未另取得测试受限数据库配置作直接SQL对账。 - 证据位于%TEMP%/cmpp-pagination-20260908:before-quality/report截图、acceptance.json、filter-acceptance.json、三视口截图、frontend-tests.log、build.log、format.log、lint.log和开工diff快照。截图/脚本/凭据不进入提交。临时预览已关闭。 - 交付边界:本地代码、需求、设计和用例完成;本节随本轮精确文件/追加hunk作本地提交,提交号见Git记录。不推送、不测试部署、不预生产部署;不发送/补发/重投/入队短信,不变更余额、通道、客户配置或恢复管理员。测试环境仍运行旧2c228a9,本地提交不等于已上线。 + + +## 2026-09-08 17:14 日报生成超时修复与本地提交前验收 + +- 授权:修改代码并本地提交,不推送、不测试部署、不预生产部署、不补跑历史报表。开工 main/HEAD 为 2a9d03be2edc06dbd9a494e971e6b04a8336619f,暂存空;只读 ls-remote 回读远端 main 为 50ae37242bc33acf1038fb62618fc4cd95409956,本地领先 3 提交。17 个已有脏跟踪文件及原未跟踪发布工具/诊断脚本继续保护;涉及三份旧脏文档仅追加并精确暂存本轮增量。 +- 需求/设计:[可靠性补充](first-version-development-requirements.md#2026-09-08-报表生成可靠性补充)、[日报生成超时修复](report-generation-reliability-20260908.md);TC-DAILY-0908-01~09。明确单日期三类报表原子事务、T-4~T-1 和既有财务口径,历史缺口不自动扩窗。 +- 根因证据:预生产 633ba597754c1b89943ea3a819f45042f41b019a 三类表最新 reportDate 均为 9 月 2 日;9 月 3~7 日源短信非空。保留日志 93 次事务超时,9 月 8 日 16:12 的上限 5000ms、实际 7510ms。成本 CTE 扫全部 accepted 历史提交,单条 SELECT 实测 9090.976ms 已超过整日报表事务预算。 +- 实现:应用成本 CTE 按原短信 queuedAt 限定日期,保留全部跨日提交的成功分片成本;日事务默认 30000ms、maxWait 5000ms,REPORT_REFRESH_TRANSACTION_TIMEOUT_MS 正整数覆盖且最大 120000ms,并设置事务局部 statement_timeout。删除前获取 PostgreSQL 日期 advisory lock;单日失败保留旧报表并继续其他日期,最终汇总抛错,只有全成功才标记本日完成。启动/周期定时器销毁清理,调度日与窗口共用同一时刻。未新增迁移/API 写入口/历史重算队列。两份已修改 TS 按当前 Prettier 门禁格式化,并显式忽略原 refundCents 解构值以修复该文件既有 lint 错误,列表/CSV 语义不变。 +- 预生产只读结果一致性:在同一 REPEATABLE READ READ ONLY 事务中、statement_timeout=15000ms,9 月 4 日原 SELECT 8919ms、当前候选 SELECT 100ms;两行全部结果列(含金额,按数组避免同名列丢失)摘要均为 576592fc45b7f3137882229321eaf905d1e9bba12631ccfe1a0b2016c759231b。仅移除 INSERT 头部执行 SELECT,防写入检查通过;事务最终 ROLLBACK,不调用线上生成服务。这是单日查询对照,缓存可能影响耗时,不宣称完整任务同比提速或全日期金额对账已完成。 +- 代码验收:API 全量 64 套 694 项通过(工作区包含原保护 metrics 的 1 项额外测试,未夹带提交);最终报表定向 1 套 29 项通过。API TypeScript 生产构建、前端 TypeScript(既有 lint 组成)、changed-code Prettier/ESLint、结构检查、Stylelint、CSS 治理及 15 项工具测试、安全/部署静态门禁、git diff --check 通过。npm 当前不在 PATH,执行 package.json 对应 Node CLI;未安装依赖。API 命令为 node node_modules/jest/bin/jest.js --runInBand、node node_modules/typescript/bin/tsc -p tsconfig.build.json。 +- 真实数据库/API:新增 tools/testing/verify-report-refresh.mjs,强制独立 REPORT_TEST_DATABASE_URL、loopback 地址及 cmpp_report_test_ 数据库前缀,不使用业务 DATABASE_URL。独立本机 PostgreSQL 8 组通过:7 个报表维度与完整自然日、跨日补发/审计优先/历史兼容、重复幂等、真实 SQL 失败三表回滚且后续日期成功、跨连接日期锁、超过旧 5 秒的成功事务、200ms 限额超时回滚恢复、真实 ReportsController HTTP 筛选/分页/汇总及三类 CSV。10 条消息/10 次提交的应用 9 月 4 日收入 4000、成本 800、利润 3200(0.0001 元整数单位)一致;单次注入 5.2 秒延迟后单日事务实际 5248ms 成功。金额源列采用生产 bigint,报表表完整列/类型/唯一约束,源表只建查询用列,未做全库迁移验收。测试 schema 残留 0,临时 PostgreSQL 已停止;测试资料目录保留。 +- 证据:%TEMP%/cmpp-report-fix-20260908/{baseline.json,api-tests.log,report-tests-final.log};%TEMP%/cmpp-report-diagnosis-20260908/{profile-select.cjs.result.jsonl,compare-candidate.cjs.result.jsonl};%TEMP%/cmpp-report-pg-1788858316792/integration.log。脚本、源库结果未输出凭据或短信正文。 +- 未执行:预生产完整任务写入验收/自然定时观察、两环境部署、历史缺口补齐、完整应用登录与浏览器。本轮无前端改动;本地 HTTP 是独立真实报表 Controller/Service/PG,不代表完整认证/UI 验收。旧 api/tools/verify-report-recalculation.ts 含过期金额断言和业务造数,不执行也不在本轮改写。T-5 更早缺口仍须部署修复后单独授权补齐。 +- 交付:本地报表代码、测试工具、需求/设计/用例和本节一起按精确 7 文件/文档增量提交,提交号以 Git 为准;未推送、未测试部署、未预生产部署、未短信发送/补发/重投/入队,未修改业务余额、通道、客户配置或管理员。提交前保护校验确认全部已有跟踪修改未被覆盖。 diff --git a/tools/testing/verify-report-refresh.mjs b/tools/testing/verify-report-refresh.mjs new file mode 100644 index 0000000..ca6b56d --- /dev/null +++ b/tools/testing/verify-report-refresh.mjs @@ -0,0 +1,400 @@ +/** + * Real PostgreSQL report integration, isolated from all messaging lifecycles. + * Build API first. Set REPORT_TEST_DATABASE_URL to a disposable local database + * named cmpp_report_test_*. The caller owns database/server creation and shutdown. + * This script creates and drops only its unique schema; it never uses DATABASE_URL. + */ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { randomUUID } from 'node:crypto'; + +const require = createRequire(new URL('../../api/package.json', import.meta.url)); +const { Pool } = require('pg'); +const { PrismaClient } = require('@prisma/client'); +const { PrismaPg } = require('@prisma/adapter-pg'); +const { ReportsService } = require('./dist/reports/reports.service.js'); +const { ReportsController } = require('./dist/reports/reports.controller.js'); +const { Module, Logger } = require('@nestjs/common'); +const { NestFactory } = require('@nestjs/core'); + +const connectionString = process.env.REPORT_TEST_DATABASE_URL; +assert.ok(connectionString, 'Set REPORT_TEST_DATABASE_URL explicitly; DATABASE_URL is never used'); +const target = new URL(connectionString); +assert.ok(['postgres:', 'postgresql:'].includes(target.protocol), 'PostgreSQL URL required'); +assert.ok(['127.0.0.1', 'localhost', '[::1]'].includes(target.hostname), 'Only loopback PostgreSQL is allowed'); +assert.match( + decodeURIComponent(target.pathname.slice(1)), + /^cmpp_report_test_[a-z0-9_]+$/, + 'Dedicated disposable database name required', +); +assert.equal(target.search, '', 'URL query overrides are forbidden'); + +process.env.REPORT_DAILY_REFRESH_ENABLED = 'false'; +delete process.env.REPORT_REFRESH_TRANSACTION_TIMEOUT_MS; +Logger.overrideLogger(false); +const schema = `report_refresh_${randomUUID().replaceAll('-', '')}`; +const admin = new Pool({ connectionString, max: 2 }); +const pool = new Pool({ connectionString, max: 4, options: `-c search_path=${schema} -c timezone=UTC` }); +const prisma = new PrismaClient({ adapter: new PrismaPg(pool, { schema, disposeExternalPool: true }) }); +const service = new ReportsService(prisma); +const checks = []; +let slowDayElapsedMs; +const now = new Date('2026-09-08T03:00:00Z'); +const days = ['2026-09-04', '2026-09-05', '2026-09-06', '2026-09-07']; +const tables = ['DailyReconciliationReport', 'DailyProfitReport', 'DailyQualityReport']; +let app; +let schemaCreated = false; + +async function check(name, operation) { + await operation(); + checks.push(name); +} + +async function snapshot(date, includeTimestamps = false) { + const result = {}; + for (const table of tables) { + const expression = includeTimestamps ? 'to_jsonb(row)' : "to_jsonb(row) - 'generatedAt' - 'updatedAt'"; + const rows = await pool.query( + `SELECT ${expression} AS value FROM "${table}" row WHERE "reportDate" = $1::date ORDER BY id`, + [date], + ); + result[table] = rows.rows.map(({ value }) => value); + } + return result; +} + +async function installFailureTrigger(body) { + await pool.query(`CREATE FUNCTION fail_report_test() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN + IF NEW."reportDate" = DATE '2026-09-04' THEN ${body} END IF; + RETURN NEW; + END $$; + CREATE TRIGGER fail_report_test BEFORE INSERT ON "DailyQualityReport" FOR EACH ROW EXECUTE FUNCTION fail_report_test()`); +} + +async function removeFailureTrigger() { + await pool.query('DROP TRIGGER fail_report_test ON "DailyQualityReport"; DROP FUNCTION fail_report_test()'); +} + +async function assertPartialFailure() { + await assert.rejects(service.refreshRollingWindow(now), (error) => { + assert.match(error.message, /failed dates: 2026-09-04/); + assert.match(error.message, /refreshed dates: 2026-09-05, 2026-09-06, 2026-09-07/); + return true; + }); +} + +try { + const identity = await admin.query('SELECT current_database() AS database, host(inet_server_addr()) AS address'); + assert.equal(identity.rows[0].database, decodeURIComponent(target.pathname.slice(1))); + assert.ok(['127.0.0.1', '::1'].includes(identity.rows[0].address), 'Server must actually be loopback'); + await admin.query(`CREATE SCHEMA "${schema}"`); + schemaCreated = true; + // Minimal source tables expose exactly the columns read by reporting SQL. + // Report table columns/types and uniqueness mirror the production Prisma model. + await pool.query(` + CREATE TABLE "Tenant" (id text PRIMARY KEY, name text NOT NULL); + CREATE TABLE "SmsApplication" (id text PRIMARY KEY, name text NOT NULL); + CREATE TABLE "SmsChannel" (id text PRIMARY KEY, name text NOT NULL); + CREATE TABLE "SmsSignature" (id text PRIMARY KEY, name text NOT NULL); + CREATE TABLE "SmsDrainageInfo" (id text PRIMARY KEY, "siteName" text NOT NULL); + CREATE TABLE "SmsMessageRecord" ( + id text PRIMARY KEY, "tenantId" text NOT NULL, "applicationId" text NOT NULL, + "signatureId" text, "drainageInfoId" text, "billingUnits" integer NOT NULL, + status text, "receiptStatus" text, "queuedAt" timestamp(3) NOT NULL, + "submittedAt" timestamp(3), "deliveredAt" timestamp(3), "unitPrice" bigint NOT NULL, "submitId" text + ); + CREATE INDEX ON "SmsMessageRecord" ("queuedAt"); + CREATE TABLE "SmsSubmitRecord" ( + id text PRIMARY KEY, "messageRecordId" text NOT NULL, "channelId" text NOT NULL, + "gatewayMessageId" text, "submitId" text, "submitStatus" text NOT NULL, + "costUnitPrice" bigint NOT NULL, "submittedAt" timestamp(3), "createdAt" timestamp(3) NOT NULL + ); + CREATE INDEX ON "SmsSubmitRecord" ("messageRecordId"); + CREATE TABLE "SmsMessageSegmentAudit" (id text PRIMARY KEY, "submitRecordId" text NOT NULL, "receiptStatus" text); + CREATE INDEX ON "SmsMessageSegmentAudit" ("submitRecordId"); + CREATE TABLE "SmsReceiptRecord" (id text PRIMARY KEY, "gatewayMessageId" text, "channelId" text, "receiptStatus" text, "deliveredAt" timestamp(3)); + CREATE INDEX ON "SmsReceiptRecord" ("channelId", "gatewayMessageId"); + CREATE TABLE "DailyReconciliationReport" ( + id text PRIMARY KEY, "reportDate" date NOT NULL, "tenantId" text NOT NULL, "tenantName" text NOT NULL, + "applicationId" text NOT NULL, "applicationName" text NOT NULL, + "submittedUnits" integer NOT NULL, "sentUnits" integer NOT NULL, "unknownUnits" integer NOT NULL, + "successUnits" integer NOT NULL, "failedUnits" integer NOT NULL, + "generatedAt" timestamp(3) NOT NULL, "updatedAt" timestamp(3) NOT NULL, + UNIQUE ("reportDate", "tenantId", "applicationId") + ); + CREATE TABLE "DailyProfitReport" ( + id text PRIMARY KEY, "reportDate" date NOT NULL, "dimensionType" text NOT NULL, "dimensionId" text NOT NULL, "dimensionName" text NOT NULL, + "tenantId" text, "tenantName" text, "applicationId" text, "channelId" text, + "submittedUnits" integer NOT NULL, "sentUnits" integer NOT NULL, "unknownUnits" integer NOT NULL, + "successUnits" integer NOT NULL, "failedUnits" integer NOT NULL, + "revenueCents" bigint NOT NULL, "refundCents" bigint NOT NULL, "costCents" bigint NOT NULL, + "profitCents" bigint NOT NULL, "profitRateBps" integer NOT NULL, + "generatedAt" timestamp(3) NOT NULL, "updatedAt" timestamp(3) NOT NULL, + UNIQUE ("reportDate", "dimensionType", "dimensionId") + ); + CREATE TABLE "DailyQualityReport" ( + id text PRIMARY KEY, "reportDate" date NOT NULL, "dimensionType" text NOT NULL, "dimensionId" text NOT NULL, "dimensionName" text NOT NULL, + "tenantId" text, "tenantName" text, "applicationId" text, "channelId" text, "signatureId" text, "drainageInfoId" text, + "submittedUnits" integer NOT NULL, "sentUnits" integer NOT NULL, "unknownUnits" integer NOT NULL, + "successUnits" integer NOT NULL, "failedUnits" integer NOT NULL, "successRateBps" integer NOT NULL, "avgArrivalMs" integer, + "generatedAt" timestamp(3) NOT NULL, "updatedAt" timestamp(3) NOT NULL, + UNIQUE ("reportDate", "dimensionType", "dimensionId") + ); + INSERT INTO "Tenant" VALUES ('t1', 'Report Test One'), ('t2', 'Report Test Two'); + INSERT INTO "SmsApplication" VALUES ('a1', 'Test Application One'), ('a2', 'Test Application Two'); + INSERT INTO "SmsChannel" VALUES ('c1', 'Test Channel One'), ('c2', 'Test Channel Two'); + INSERT INTO "SmsSignature" VALUES ('sig1', 'Test Signature'), ('sig2', 'Second Test Signature'); + INSERT INTO "SmsDrainageInfo" VALUES ('drain1', 'Test Site'), ('drain2', 'Second Test Site'); + `); + const messages = [ + ['cross', '2026-09-03T16:00:00.000Z', 3, 'delivered', 1000, 'cross_retry'], + ['legacy', '2026-09-04T02:00:00.000Z', 2, 'delivered', 500, 'legacy_submit'], + ['failed', '2026-09-04T03:00:00.000Z', 1, 'failed', 800, 'failed_submit'], + ['unknown', '2026-09-04T04:00:00.000Z', 1, 'unknown', 800, 'unknown_submit'], + ['before', '2026-09-03T15:59:59.999Z', 7, 'delivered', 900, 'before_submit'], + ['next', '2026-09-04T16:00:00.000Z', 1, 'delivered', 1000, 'next_submit'], + ['six', '2026-09-06T01:00:00.000Z', 1, 'delivered', 1000, 'six_submit'], + ['seven', '2026-09-07T01:00:00.000Z', 1, 'delivered', 1000, 'seven_submit'], + ['today', '2026-09-07T16:00:00.000Z', 11, 'delivered', 900, 'today_submit'], + ['other', '2026-09-04T02:00:00.000Z', 1, 'delivered', 2000, null], + ]; + for (const [id, date, units, status, price, submitId] of messages) { + await pool.query( + `INSERT INTO "SmsMessageRecord" VALUES ($1,$2,$3,$9,$10,$4,$5,NULL,$6,$6,$6::timestamp + INTERVAL '1 second',$7,$8)`, + [ + id, + id === 'other' ? 't2' : 't1', + id === 'other' ? 'a2' : 'a1', + units, + status, + date, + price, + submitId, + id === 'other' ? 'sig2' : 'sig1', + id === 'other' ? 'drain2' : 'drain1', + ], + ); + } + const submits = [ + ['cross_first', 'cross', 'c1', '2026-09-04T01:00:00Z', 100, ['delivered', 'undelivered', 'undelivered'], true], + ['cross_retry', 'cross', 'c2', '2026-09-05T01:00:00Z', 200, ['delivered', 'delivered', 'undelivered'], true], + ['legacy_submit', 'legacy', 'c1', '2026-09-04T02:00:00Z', 150, [], true], + ['failed_submit', 'failed', 'c1', '2026-09-04T03:00:00Z', 50, ['undelivered'], true], + ['unknown_submit', 'unknown', 'c1', '2026-09-04T04:00:00Z', 70, ['unknown'], false], + ['before_submit', 'before', 'c1', '2026-09-03T15:59:59Z', 99, ['delivered'], true], + ['next_submit', 'next', 'c1', '2026-09-04T16:00:00Z', 100, ['delivered'], true], + ['six_submit', 'six', 'c1', '2026-09-06T01:00:00Z', 100, ['delivered'], true], + ['seven_submit', 'seven', 'c1', '2026-09-07T01:00:00Z', 100, ['delivered'], true], + ['today_submit', 'today', 'c1', '2026-09-07T16:00:00Z', 99, ['delivered'], true], + ]; + for (const [id, messageId, channel, date, price, audits, delivered] of submits) { + await pool.query('INSERT INTO "SmsSubmitRecord" VALUES ($1,$2,$3,$1,$1,\'accepted\',$4,$5,$5)', [ + id, + messageId, + channel, + price, + date, + ]); + for (const [index, status] of audits.entries()) { + await pool.query('INSERT INTO "SmsMessageSegmentAudit" VALUES ($1,$2,$3)', [`${id}_${index}`, id, status]); + } + if (delivered) + await pool.query( + "INSERT INTO \"SmsReceiptRecord\" VALUES ($1,$1,$2,'delivered',$3::timestamp + INTERVAL '1 second')", + [id, channel, date], + ); + } + await pool.query( + 'INSERT INTO "SmsReceiptRecord" SELECT \'duplicate_legacy\', "gatewayMessageId", "channelId", "receiptStatus", "deliveredAt" FROM "SmsReceiptRecord" WHERE id=\'legacy_submit\'', + ); + + await check('seven report dimensions and Beijing complete-day boundaries', async () => { + assert.deepEqual(await service.refreshRollingWindow(now), { refreshedDates: days }); + const stored = await snapshot(days[0]); + assert.equal(stored.DailyReconciliationReport.length, 2); + assert.deepEqual([...new Set(stored.DailyProfitReport.map((row) => row.dimensionType))].sort(), [ + 'application', + 'channel', + ]); + assert.deepEqual([...new Set(stored.DailyQualityReport.map((row) => row.dimensionType))].sort(), [ + 'application', + 'channel', + 'drainage', + 'signature', + ]); + const appRow = stored.DailyReconciliationReport.find((row) => row.applicationId === 'a1'); + assert.deepEqual( + [appRow.submittedUnits, appRow.sentUnits, appRow.successUnits, appRow.failedUnits, appRow.unknownUnits], + [7, 7, 5, 1, 1], + ); + for (const table of tables) { + const dates = await pool.query(`SELECT DISTINCT "reportDate"::text AS date FROM "${table}" ORDER BY date`); + assert.deepEqual( + dates.rows.map((row) => row.date), + days, + ); + } + }); + + await check('cross-day retry cost, segment priority, legacy fallback and final-only revenue', async () => { + const appRow = (await snapshot(days[0])).DailyProfitReport.find((row) => row.dimensionId === 'a1'); + assert.deepEqual( + [appRow.revenueCents, appRow.costCents, appRow.profitCents, appRow.profitRateBps], + [4000, 800, 3200, 8000], + ); + const dayFourChannel = (await snapshot(days[0])).DailyProfitReport.find((row) => row.dimensionId === 'c1'); + assert.deepEqual([dayFourChannel.revenueCents, dayFourChannel.costCents], [1000, 400]); + const retryChannel = (await snapshot(days[1])).DailyProfitReport.find((row) => row.dimensionId === 'c2'); + assert.deepEqual([retryChannel.revenueCents, retryChannel.costCents], [3000, 400]); + const nextApp = (await snapshot(days[1])).DailyProfitReport.find((row) => row.dimensionId === 'a1'); + assert.deepEqual([nextApp.revenueCents, nextApp.costCents, nextApp.submittedUnits], [1000, 100, 1]); + }); + + await check('repeated refresh is idempotent', async () => { + const before = await Promise.all(days.map((date) => snapshot(date))); + await service.refreshRollingWindow(now); + assert.deepEqual(await Promise.all(days.map((date) => snapshot(date))), before); + }); + + await check('real SQL failure rolls back all three tables and later days still refresh', async () => { + const before = await snapshot(days[0], true); + const later = await snapshot(days[1], true); + await pool.query('UPDATE "SmsMessageRecord" SET "unitPrice"=1100 WHERE id=\'cross\''); + await installFailureTrigger("RAISE EXCEPTION 'intentional isolated report failure';"); + await assertPartialFailure(); + assert.deepEqual(await snapshot(days[0], true), before); + assert.notDeepEqual(await snapshot(days[1], true), later); + await removeFailureTrigger(); + await service.refreshRollingWindow(now); + const recovered = (await snapshot(days[0])).DailyProfitReport.find((row) => row.dimensionId === 'a1'); + assert.equal(recovered.revenueCents, 4300); + await pool.query('UPDATE "SmsMessageRecord" SET "unitPrice"=1000 WHERE id=\'cross\''); + await service.refreshRollingWindow(now); + }); + + await check('database transaction day lock protects prior reports and permits retry', async () => { + const before = await snapshot(days[0], true); + const holder = await pool.connect(); + try { + await holder.query('BEGIN'); + await holder.query('SELECT pg_advisory_xact_lock($1::integer, 20260904)', [0x434d5052]); + await assertPartialFailure(); + assert.deepEqual(await snapshot(days[0], true), before); + } finally { + await holder.query('ROLLBACK'); + holder.release(); + } + assert.deepEqual(await service.refreshRollingWindow(now), { refreshedDates: days }); + }); + + await check('day transaction exceeding the old five-second limit completes within the new budget', async () => { + await installFailureTrigger( + 'IF NEW."dimensionType" = \'application\' AND NEW."dimensionId" = \'a1\' THEN PERFORM pg_sleep(5.2); END IF;', + ); + try { + const start = performance.now(); + await service.refreshBusinessDay({ + key: days[0], + reportDate: new Date('2026-09-04T00:00:00Z'), + startAt: new Date('2026-09-03T16:00:00Z'), + endAt: new Date('2026-09-04T16:00:00Z'), + }); + slowDayElapsedMs = Math.round(performance.now() - start); + assert.ok(slowDayElapsedMs >= 5200); + } finally { + await removeFailureTrigger(); + } + }); + + await check('bounded real statement timeout rolls back and recovers', async () => { + const before = await snapshot(days[0], true); + await installFailureTrigger('PERFORM pg_sleep(0.3);'); + process.env.REPORT_REFRESH_TRANSACTION_TIMEOUT_MS = '200'; + try { + await assertPartialFailure(); + assert.deepEqual(await snapshot(days[0], true), before); + } finally { + delete process.env.REPORT_REFRESH_TRANSACTION_TIMEOUT_MS; + await removeFailureTrigger(); + } + assert.deepEqual(await service.refreshRollingWindow(now), { refreshedDates: days }); + }); + + await check('real ReportsController HTTP reads match PostgreSQL filtering pagination and exports', async () => { + // Harness exposes only the real reporting controller on an ephemeral loopback + // port. Full application authentication and UI are outside this integration. + class ReportTestModule {} + Module({ controllers: [ReportsController], providers: [{ provide: ReportsService, useValue: service }] })( + ReportTestModule, + ); + app = await NestFactory.create(ReportTestModule, { logger: false }); + app.use((_request, response, next) => { + response.app.set('json replacer', (_key, value) => (typeof value === 'bigint' ? Number(value) : value)); + next(); + }); + await app.listen(0, '127.0.0.1'); + const base = await app.getUrl(); + const read = async (path) => { + const response = await fetch(`${base}/admin/reports/${path}`); + assert.equal(response.status, 200); + return response.json(); + }; + const recon = await read('reconciliation?dateFrom=2026-09-04&dateTo=2026-09-04&tenantId=t1&pageSize=1'); + assert.equal(recon.total, 1); + assert.equal(recon.items[0].applicationId, 'a1'); + assert.deepEqual(recon.summary, { + submittedUnits: 7, + sentUnits: 7, + unknownUnits: 1, + successUnits: 5, + failedUnits: 1, + }); + const profit = await read('profit?dateFrom=2026-09-04&dateTo=2026-09-04&dimensionType=application&tenantId=t1'); + assert.deepEqual([profit.summary.revenueCents, profit.summary.costCents], [4000, 800]); + assert.equal('refundCents' in profit.items[0], false); + for (const dimension of ['application', 'channel', 'signature', 'drainage']) { + const result = await read(`quality?dateFrom=2026-09-04&dateTo=2026-09-04&dimensionType=${dimension}&pageSize=1`); + const expected = await pool.query( + 'SELECT count(*)::integer AS total, sum("sentUnits")::integer AS sent FROM "DailyQualityReport" WHERE "reportDate"=\'2026-09-04\' AND "dimensionType"=$1', + [dimension], + ); + assert.equal(result.total, expected.rows[0].total); + assert.equal(result.summary.sentUnits, expected.rows[0].sent); + assert.equal(result.items.length, 1); + } + const empty = await read('reconciliation?tenantId=missing'); + assert.equal(empty.total, 0); + for (const report of ['reconciliation', 'profit', 'quality']) { + const response = await fetch( + `${base}/admin/reports/${report}/export?dateFrom=2026-09-04&dateTo=2026-09-04&tenantId=t1`, + ); + assert.equal(response.status, 200); + assert.match(response.headers.get('content-type'), /text\/csv/); + assert.ok((await response.text()).split('\n').length > 1); + } + }); + + console.log( + JSON.stringify( + { + passed: checks.length, + checks, + sourceMessages: messages.length, + sourceSubmits: submits.length, + reportDates: days, + slowDayElapsedMs, + expectedApplicationDayFour: { revenue: 4000, cost: 800, profit: 3200 }, + schemaCleanup: 'performed in finally', + environment: 'isolated local PostgreSQL; no application or messaging lifecycle', + }, + null, + 2, + ), + ); +} finally { + if (app) await app.close(); + service.onModuleDestroy(); + await prisma.$disconnect(); + if (schemaCreated) await admin.query(`DROP SCHEMA "${schema}" CASCADE`); + await admin.end(); +}