This commit is contained in:
@@ -1,11 +1,19 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
import { ReportsService } from './reports.service';
|
import { ReportsService } from './reports.service';
|
||||||
|
|
||||||
|
type CapturedSql = { strings: string[]; values: unknown[]; text: string };
|
||||||
|
|
||||||
|
function sqlText(query: CapturedSql) {
|
||||||
|
return query.strings.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
describe('ReportsService', () => {
|
describe('ReportsService', () => {
|
||||||
const tx = {
|
const tx = {
|
||||||
dailyReconciliationReport: { deleteMany: jest.fn() },
|
dailyReconciliationReport: { deleteMany: jest.fn() },
|
||||||
dailyProfitReport: { deleteMany: jest.fn() },
|
dailyProfitReport: { deleteMany: jest.fn() },
|
||||||
dailyQualityReport: { deleteMany: jest.fn() },
|
dailyQualityReport: { deleteMany: jest.fn() },
|
||||||
$executeRaw: jest.fn(),
|
$executeRaw: jest.fn(),
|
||||||
|
$queryRaw: jest.fn(),
|
||||||
};
|
};
|
||||||
const prisma = {
|
const prisma = {
|
||||||
dailyReconciliationReport: { findMany: jest.fn(), count: jest.fn(), aggregate: jest.fn() },
|
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)),
|
$transaction: jest.fn((callback: (client: typeof tx) => unknown) => callback(tx)),
|
||||||
};
|
};
|
||||||
let service: ReportsService;
|
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(() => {
|
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.dailyReconciliationReport.deleteMany.mockResolvedValue({ count: 0 });
|
||||||
tx.dailyProfitReport.deleteMany.mockResolvedValue({ count: 0 });
|
tx.dailyProfitReport.deleteMany.mockResolvedValue({ count: 0 });
|
||||||
tx.dailyQualityReport.deleteMany.mockResolvedValue({ count: 0 });
|
tx.dailyQualityReport.deleteMany.mockResolvedValue({ count: 0 });
|
||||||
tx.$executeRaw.mockResolvedValue(0);
|
tx.$executeRaw.mockResolvedValue(0);
|
||||||
|
tx.$queryRaw.mockResolvedValue([{ locked: true }]);
|
||||||
prisma.dailyReconciliationReport.findMany.mockResolvedValue([{ id: 'recon-1' }]);
|
prisma.dailyReconciliationReport.findMany.mockResolvedValue([{ id: 'recon-1' }]);
|
||||||
prisma.dailyReconciliationReport.count.mockResolvedValue(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.findMany.mockResolvedValue([{ id: 'profit-1', refundCents: BigInt(100) }]);
|
||||||
prisma.dailyProfitReport.count.mockResolvedValue(1);
|
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.findMany.mockResolvedValue([{ id: 'quality-1' }]);
|
||||||
prisma.dailyQualityReport.count.mockResolvedValue(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);
|
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 () => {
|
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({
|
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'],
|
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.dailyReconciliationReport.deleteMany).toHaveBeenCalledTimes(4);
|
||||||
expect(tx.dailyProfitReport.deleteMany).toHaveBeenCalledTimes(4);
|
expect(tx.dailyProfitReport.deleteMany).toHaveBeenCalledTimes(4);
|
||||||
expect(tx.dailyQualityReport.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 () => {
|
it('calculates profit cost from channel unit price times delivered fragment count', async () => {
|
||||||
await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
|
await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
|
||||||
const firstDayQueries = tx.$executeRaw.mock.calls.slice(0, 7).map(([query]) =>
|
const firstDayQueries = tx.$executeRaw.mock.calls
|
||||||
Array.isArray(query?.strings) ? query.strings.join(' ') : String(query),
|
.slice(1, 8)
|
||||||
);
|
.map(([query]) => (Array.isArray(query?.strings) ? query.strings.join(' ') : String(query)));
|
||||||
const profitQueries = firstDayQueries.slice(1, 3).join('\n');
|
const profitQueries = firstDayQueries.slice(1, 3).join('\n');
|
||||||
|
|
||||||
expect(profitQueries).toContain('"SmsMessageSegmentAudit"');
|
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 () => {
|
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'));
|
await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
|
||||||
const firstDayQueries = tx.$executeRaw.mock.calls.slice(0, 7).map(([query]) =>
|
const firstDayQueries = tx.$executeRaw.mock.calls
|
||||||
Array.isArray(query?.strings) ? query.strings.join(' ') : String(query),
|
.slice(1, 8)
|
||||||
);
|
.map(([query]) => (Array.isArray(query?.strings) ? query.strings.join(' ') : String(query)));
|
||||||
const profitQueries = firstDayQueries.slice(1, 3).join('\n');
|
const profitQueries = firstDayQueries.slice(1, 3).join('\n');
|
||||||
|
|
||||||
expect(profitQueries).toContain('message."billingUnits" * message."unitPrice"');
|
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 () => {
|
it('queries reconciliation reports with server-side filters and bounded pagination', async () => {
|
||||||
await expect(service.listReconciliation({
|
await expect(
|
||||||
|
service.listReconciliation({
|
||||||
dateFrom: '2026-07-01',
|
dateFrom: '2026-07-01',
|
||||||
dateTo: '2026-07-14',
|
dateTo: '2026-07-14',
|
||||||
tenantId: 'tenant-1',
|
tenantId: 'tenant-1',
|
||||||
applicationId: 'app-1',
|
applicationId: 'app-1',
|
||||||
page: 2,
|
page: 2,
|
||||||
pageSize: 500,
|
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({
|
).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' }),
|
where: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1' }),
|
||||||
skip: 100,
|
skip: 100,
|
||||||
take: 100,
|
take: 100,
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps application and channel profit filters separate', async () => {
|
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' });
|
const result = await service.listProfit({
|
||||||
expect(result).toEqual(expect.objectContaining({
|
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 }),
|
summary: expect.objectContaining({ revenueCents: 1000, profitCents: 400, profitRateBps: 4000 }),
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
expect(result.summary).not.toHaveProperty('refundCents');
|
expect(result.summary).not.toHaveProperty('refundCents');
|
||||||
expect(result.items[0]).not.toHaveProperty('refundCents');
|
expect(result.items[0]).not.toHaveProperty('refundCents');
|
||||||
expect(prisma.dailyProfitReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
expect(prisma.dailyProfitReport.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
where: expect.objectContaining({
|
where: expect.objectContaining({
|
||||||
dimensionType: 'channel',
|
dimensionType: 'channel',
|
||||||
tenantId: undefined,
|
tenantId: undefined,
|
||||||
applicationId: undefined,
|
applicationId: undefined,
|
||||||
channelId: 'channel-1',
|
channelId: 'channel-1',
|
||||||
}),
|
}),
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns zero full-result totals and rates when a filtered report has no rows', async () => {
|
it('returns zero full-result totals and rates when a filtered report has no rows', async () => {
|
||||||
prisma.dailyProfitReport.findMany.mockResolvedValueOnce([]);
|
prisma.dailyProfitReport.findMany.mockResolvedValueOnce([]);
|
||||||
prisma.dailyProfitReport.count.mockResolvedValueOnce(0);
|
prisma.dailyProfitReport.count.mockResolvedValueOnce(0);
|
||||||
prisma.dailyProfitReport.aggregate.mockResolvedValueOnce({
|
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({
|
await expect(service.listProfit({ dimensionType: 'application', tenantId: 'missing' })).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
total: 0,
|
total: 0,
|
||||||
summary: { submittedUnits: 0, sentUnits: 0, unknownUnits: 0, successUnits: 0, failedUnits: 0, revenueCents: 0, costCents: 0, profitCents: 0, profitRateBps: 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 () => {
|
it('exports income without refund columns', async () => {
|
||||||
prisma.dailyProfitReport.findMany.mockResolvedValueOnce([{
|
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,
|
id: 'profit-export',
|
||||||
revenueCents: BigInt(3500), refundCents: BigInt(200), costCents: BigInt(2100), profitCents: BigInt(1400),
|
reportDate: new Date('2026-07-14'),
|
||||||
profitRateBps: 4000, generatedAt: new Date('2026-07-15T00:00:00Z'),
|
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' });
|
const exported = await service.exportProfit({ dimensionType: 'application' });
|
||||||
expect(exported.content).toContain('收入金额(元)');
|
expect(exported.content).toContain('收入金额(元)');
|
||||||
@@ -131,28 +408,59 @@ describe('ReportsService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('sorts quality reports by send volume and keeps the selected dimension', async () => {
|
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({
|
await expect(
|
||||||
items: [{ id: 'quality-1' }], total: 1, page: 1, pageSize: 20, dimensionType: 'drainage',
|
service.listQuality({ dimensionType: 'drainage', tenantId: 'tenant-1', page: 1, pageSize: 20 }),
|
||||||
summary: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1, successRateBps: 7000 },
|
).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({
|
expect(prisma.dailyQualityReport.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
where: expect.objectContaining({ dimensionType: 'drainage', tenantId: 'tenant-1' }),
|
where: expect.objectContaining({ dimensionType: 'drainage', tenantId: 'tenant-1' }),
|
||||||
orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }],
|
orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }],
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exports complete filtered report data as escaped CSV instead of the current page', async () => {
|
it('exports complete filtered report data as escaped CSV instead of the current page', async () => {
|
||||||
prisma.dailyReconciliationReport.findMany.mockResolvedValueOnce([{
|
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'),
|
id: 'recon-export',
|
||||||
}]);
|
reportDate: new Date('2026-07-14'),
|
||||||
const exported = await service.exportReconciliation({ tenantId: 'tenant-1', dateFrom: '2026-07-01', dateTo: '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.fileName).toContain('对账单-');
|
||||||
expect(exported.content).toContain('"示例,企业"');
|
expect(exported.content).toContain('"示例,企业"');
|
||||||
expect(exported.content).toContain('提交条数');
|
expect(exported.content).toContain('提交条数');
|
||||||
expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
where: expect.objectContaining({ tenantId: 'tenant-1' }),
|
where: expect.objectContaining({ tenantId: 'tenant-1' }),
|
||||||
orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }],
|
orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }],
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000;
|
const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000;
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
const DEFAULT_REFRESH_INTERVAL_MS = 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 = {
|
export type ReportListQuery = {
|
||||||
dateFrom?: string;
|
dateFrom?: string;
|
||||||
@@ -21,6 +23,7 @@ export type ReportListQuery = {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(ReportsService.name);
|
private readonly logger = new Logger(ReportsService.name);
|
||||||
|
private startupTimer?: ReturnType<typeof setTimeout>;
|
||||||
private refreshTimer?: ReturnType<typeof setInterval>;
|
private refreshTimer?: ReturnType<typeof setInterval>;
|
||||||
private refreshRunning = false;
|
private refreshRunning = false;
|
||||||
private lastRefreshBusinessDate?: string;
|
private lastRefreshBusinessDate?: string;
|
||||||
@@ -29,8 +32,8 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
if (process.env.REPORT_DAILY_REFRESH_ENABLED === 'false') return;
|
if (process.env.REPORT_DAILY_REFRESH_ENABLED === 'false') return;
|
||||||
const startupTimer = setTimeout(() => void this.runScheduledRefresh(), 15_000);
|
this.startupTimer = setTimeout(() => void this.runScheduledRefresh(), 15_000);
|
||||||
startupTimer.unref?.();
|
this.startupTimer.unref?.();
|
||||||
this.refreshTimer = setInterval(
|
this.refreshTimer = setInterval(
|
||||||
() => void this.runScheduledRefresh(),
|
() => void this.runScheduledRefresh(),
|
||||||
positiveInteger(process.env.REPORT_REFRESH_INTERVAL_MS, DEFAULT_REFRESH_INTERVAL_MS),
|
positiveInteger(process.env.REPORT_REFRESH_INTERVAL_MS, DEFAULT_REFRESH_INTERVAL_MS),
|
||||||
@@ -39,6 +42,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onModuleDestroy() {
|
onModuleDestroy() {
|
||||||
|
if (this.startupTimer) clearTimeout(this.startupTimer);
|
||||||
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +50,12 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const { page, pageSize, skip } = pagination(query);
|
const { page, pageSize, skip } = pagination(query);
|
||||||
const where = reconciliationWhere(query);
|
const where = reconciliationWhere(query);
|
||||||
const [items, total, aggregate] = await Promise.all([
|
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.count({ where }),
|
||||||
this.prisma.dailyReconciliationReport.aggregate({ where, _sum: reportVolumeSumSelection }),
|
this.prisma.dailyReconciliationReport.aggregate({ where, _sum: reportVolumeSumSelection }),
|
||||||
]);
|
]);
|
||||||
@@ -57,7 +66,12 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const { page, pageSize, skip } = pagination(query);
|
const { page, pageSize, skip } = pagination(query);
|
||||||
const { dimensionType, where } = profitWhere(query);
|
const { dimensionType, where } = profitWhere(query);
|
||||||
const [storedItems, total, aggregate] = await Promise.all([
|
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.count({ where }),
|
||||||
this.prisma.dailyProfitReport.aggregate({
|
this.prisma.dailyProfitReport.aggregate({
|
||||||
where,
|
where,
|
||||||
@@ -65,7 +79,10 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
// refundCents 暂留在持久化模型中兼容既有数据和回滚,但利润报表接口不再暴露返还口径。
|
// refundCents 暂留在持久化模型中兼容既有数据和回滚,但利润报表接口不再暴露返还口径。
|
||||||
const items = storedItems.map(({ refundCents: _refundCents, ...item }) => item);
|
const items = storedItems.map(({ refundCents, ...item }) => {
|
||||||
|
void refundCents;
|
||||||
|
return item;
|
||||||
|
});
|
||||||
const summary = {
|
const summary = {
|
||||||
...volumeSummary(aggregate._sum),
|
...volumeSummary(aggregate._sum),
|
||||||
revenueCents: Number(aggregate._sum.revenueCents ?? 0),
|
revenueCents: Number(aggregate._sum.revenueCents ?? 0),
|
||||||
@@ -81,7 +98,12 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const { page, pageSize, skip } = pagination(query);
|
const { page, pageSize, skip } = pagination(query);
|
||||||
const { dimensionType, where } = qualityWhere(query);
|
const { dimensionType, where } = qualityWhere(query);
|
||||||
const [items, total, aggregate] = await Promise.all([
|
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.count({ where }),
|
||||||
this.prisma.dailyQualityReport.aggregate({ where, _sum: reportVolumeSumSelection }),
|
this.prisma.dailyQualityReport.aggregate({ where, _sum: reportVolumeSumSelection }),
|
||||||
]);
|
]);
|
||||||
@@ -94,34 +116,136 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async exportReconciliation(query: ReportListQuery) {
|
async exportReconciliation(query: ReportListQuery) {
|
||||||
const items = await this.prisma.dailyReconciliationReport.findMany({ where: reconciliationWhere(query), orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }] });
|
const items = await this.prisma.dailyReconciliationReport.findMany({
|
||||||
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)]));
|
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) {
|
async exportProfit(query: ReportListQuery) {
|
||||||
const { dimensionType, where } = profitWhere(query);
|
const { dimensionType, where } = profitWhere(query);
|
||||||
const items = await this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }] });
|
const items = await this.prisma.dailyProfitReport.findMany({
|
||||||
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)]));
|
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) {
|
async exportQuality(query: ReportListQuery) {
|
||||||
const { dimensionType, where } = qualityWhere(query);
|
const { dimensionType, where } = qualityWhere(query);
|
||||||
const items = await this.prisma.dailyQualityReport.findMany({ where, orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }] });
|
const items = await this.prisma.dailyQualityReport.findMany({
|
||||||
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)]));
|
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()) {
|
async refreshRollingWindow(now = new Date()) {
|
||||||
const days = completedBusinessDays(now, 4);
|
const days = completedBusinessDays(now, 4);
|
||||||
for (const day of days) await this.refreshBusinessDay(day);
|
const refreshedDates: string[] = [];
|
||||||
return { refreshedDates: days.map((day) => day.key) };
|
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() {
|
private async runScheduledRefresh() {
|
||||||
const businessDate = shanghaiDateKey(new Date());
|
const now = new Date();
|
||||||
|
const businessDate = shanghaiDateKey(now);
|
||||||
if (this.refreshRunning || this.lastRefreshBusinessDate === businessDate) return;
|
if (this.refreshRunning || this.lastRefreshBusinessDate === businessDate) return;
|
||||||
this.refreshRunning = true;
|
this.refreshRunning = true;
|
||||||
try {
|
try {
|
||||||
const result = await this.refreshRollingWindow();
|
const result = await this.refreshRollingWindow(now);
|
||||||
this.lastRefreshBusinessDate = businessDate;
|
this.lastRefreshBusinessDate = businessDate;
|
||||||
this.logger.log(`Daily reports refreshed for ${result.refreshedDates.join(', ')}`);
|
this.logger.log(`Daily reports refreshed for ${result.refreshedDates.join(', ')}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -132,7 +256,17 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async refreshBusinessDay(day: BusinessDay) {
|
private async refreshBusinessDay(day: BusinessDay) {
|
||||||
await this.prisma.$transaction(async (tx) => {
|
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.dailyReconciliationReport.deleteMany({ where: { reportDate: day.reportDate } });
|
||||||
await tx.dailyProfitReport.deleteMany({ where: { reportDate: day.reportDate } });
|
await tx.dailyProfitReport.deleteMany({ where: { reportDate: day.reportDate } });
|
||||||
await tx.dailyQualityReport.deleteMany({ where: { reportDate: day.reportDate } });
|
await tx.dailyQualityReport.deleteMany({ where: { reportDate: day.reportDate } });
|
||||||
@@ -197,6 +331,9 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
) AS delivered
|
) AS delivered
|
||||||
) legacy_receipt ON TRUE
|
) legacy_receipt ON TRUE
|
||||||
WHERE submit."submitStatus" = 'accepted'
|
WHERE submit."submitStatus" = 'accepted'
|
||||||
|
-- 应用成本归属于原短信日,仍包含该短信全部跨日补发尝试。
|
||||||
|
AND message."queuedAt" >= ${day.startAt}
|
||||||
|
AND message."queuedAt" < ${day.endAt}
|
||||||
GROUP BY submit."messageRecordId"
|
GROUP BY submit."messageRecordId"
|
||||||
)
|
)
|
||||||
INSERT INTO "DailyProfitReport" (
|
INSERT INTO "DailyProfitReport" (
|
||||||
@@ -352,23 +489,28 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
await tx.$executeRaw(qualityByChannelSql(day));
|
await tx.$executeRaw(qualityByChannelSql(day));
|
||||||
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'signature'));
|
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'signature'));
|
||||||
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'drainage'));
|
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'drainage'));
|
||||||
});
|
},
|
||||||
|
{ maxWait: 5_000, timeout },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'application' | 'signature' | 'drainage') {
|
function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'application' | 'signature' | 'drainage') {
|
||||||
const dimensionTypeSql = Prisma.raw(`'${dimensionType}'`);
|
const dimensionTypeSql = Prisma.raw(`'${dimensionType}'`);
|
||||||
const dimensionId = dimensionType === 'application'
|
const dimensionId =
|
||||||
|
dimensionType === 'application'
|
||||||
? Prisma.sql`application.id`
|
? Prisma.sql`application.id`
|
||||||
: dimensionType === 'signature'
|
: dimensionType === 'signature'
|
||||||
? Prisma.sql`COALESCE(signature.id, 'unmatched:' || COALESCE(application.id, tenant.id))`
|
? Prisma.sql`COALESCE(signature.id, 'unmatched:' || COALESCE(application.id, tenant.id))`
|
||||||
: Prisma.sql`COALESCE(drainage.id, 'unmatched:' || COALESCE(application.id, tenant.id))`;
|
: Prisma.sql`COALESCE(drainage.id, 'unmatched:' || COALESCE(application.id, tenant.id))`;
|
||||||
const dimensionName = dimensionType === 'application'
|
const dimensionName =
|
||||||
|
dimensionType === 'application'
|
||||||
? Prisma.sql`application.name`
|
? Prisma.sql`application.name`
|
||||||
: dimensionType === 'signature'
|
: dimensionType === 'signature'
|
||||||
? Prisma.sql`COALESCE(signature.name, '未关联签名')`
|
? Prisma.sql`COALESCE(signature.name, '未关联签名')`
|
||||||
: Prisma.sql`COALESCE(drainage."siteName", '未关联引流信息')`;
|
: Prisma.sql`COALESCE(drainage."siteName", '未关联引流信息')`;
|
||||||
const applicationJoin = dimensionType === 'application'
|
const applicationJoin =
|
||||||
|
dimensionType === 'application'
|
||||||
? Prisma.sql`JOIN "SmsApplication" application ON application.id = message."applicationId"`
|
? Prisma.sql`JOIN "SmsApplication" application ON application.id = message."applicationId"`
|
||||||
: Prisma.sql`LEFT JOIN "SmsApplication" application ON application.id = message."applicationId"`;
|
: Prisma.sql`LEFT JOIN "SmsApplication" application ON application.id = message."applicationId"`;
|
||||||
|
|
||||||
@@ -548,7 +690,13 @@ const reportVolumeSumSelection = {
|
|||||||
failedUnits: true,
|
failedUnits: true,
|
||||||
} as const;
|
} 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 {
|
return {
|
||||||
submittedUnits: Number(sum.submittedUnits ?? 0),
|
submittedUnits: Number(sum.submittedUnits ?? 0),
|
||||||
sentUnits: Number(sum.sentUnits ?? 0),
|
sentUnits: Number(sum.sentUnits ?? 0),
|
||||||
@@ -559,11 +707,15 @@ function volumeSummary(sum: { submittedUnits?: number | null; sentUnits?: number
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ratioBps(numerator: number, denominator: 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 {
|
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) {
|
function profitWhere(query: ReportListQuery) {
|
||||||
@@ -580,7 +732,9 @@ function profitWhere(query: ReportListQuery) {
|
|||||||
|
|
||||||
function qualityWhere(query: ReportListQuery) {
|
function qualityWhere(query: ReportListQuery) {
|
||||||
const allowedDimensions = new Set(['application', 'channel', 'signature', 'drainage']);
|
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 = {
|
const where: Prisma.DailyQualityReportWhereInput = {
|
||||||
dimensionType,
|
dimensionType,
|
||||||
reportDate: dateFilter(query.dateFrom, query.dateTo),
|
reportDate: dateFilter(query.dateFrom, query.dateTo),
|
||||||
|
|||||||
@@ -2232,3 +2232,10 @@
|
|||||||
## 2026-09-08 分页交互补充
|
## 2026-09-08 分页交互补充
|
||||||
|
|
||||||
签名质量检测四个Tab的每页数量选择移至各自列表底部,与上一页、下一页和页码跳转放在同一分页区;保留独立日期、筛选和每页10/25/50/100,默认25,空结果也可选择数量。通道报备明细增加相同四档容量,默认25;修改容量回到第一页、清空当页勾选、保留已应用筛选,使用真实服务端分页,较早请求的成功或失败不得覆盖最新查询结果。本节补充前述运营修复要求;不更改后端接口、端口或数据模型,详见[运营修复设计](operations-fixes-20260908.md#分页交互补充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)。
|
||||||
|
|||||||
@@ -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、端口或租户过滤。
|
||||||
|
- 本轮授权修改代码并本地提交;不推送、不部署两环境、不补跑预生产报表、不发送短信或修改业务配置。测试数据仅在隔离本地测试库构造,结果与未验证项记入进度。
|
||||||
@@ -5254,3 +5254,20 @@ OPS0908-01至07已按本轮范围验证;精确证据见testing-progress.md对
|
|||||||
| OPS-PAGE0908-03 | 打开通道报备明细,设置筛选,到第2页勾选后依次改变四档容量 | 默认25;真实接口page=1及正确pageSize,总数/页数/行数一致;筛选与URL范围保留,旧勾选清空,不执行状态或发送操作 |
|
| OPS-PAGE0908-03 | 打开通道报备明细,设置筛选,到第2页勾选后依次改变四档容量 | 默认25;真实接口page=1及正确pageSize,总数/页数/行数一致;筛选与URL范围保留,旧勾选清空,不执行状态或发送操作 |
|
||||||
| OPS-PAGE0908-04 | 隔离测试让旧25条查询晚于新100条返回,分别返回成功和失败 | 旧结果和旧错误均不能覆盖最新查询;当前失败显示真实错误;组件卸载后旧请求失效 |
|
| OPS-PAGE0908-04 | 隔离测试让旧25条查询晚于新100条返回,分别返回成功和失败 | 旧结果和旧错误均不能覆盖最新查询;当前失败显示真实错误;组件卸载后旧请求失效 |
|
||||||
| OPS-PAGE0908-05 | 在1600×1000、1366×768、390×844检查两页面,刷新/跨路由,回归原公共分页消费者 | 下拉完整可见、可操作,表格内部滚动不造成页面整体溢出;旧消费者不新增容量控件;跳转输入在真实页码改变后重置,返回旧页不恢复未提交草稿 |
|
| 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 无假成功或静态数据 |
|
||||||
|
|||||||
@@ -4750,3 +4750,17 @@ git diff --check
|
|||||||
- 验收脚本问题与边界:独立Vite开发服务器曾出现本机连接超时,改用同一验收进程创建/关闭生产预览后通过;一次脚本假定Escape关闭Select导致等待超时,按当前触发按钮关闭方式修正后全流程通过,未扩改Select。错误/乱序以隔离测试验证;未报备真实非空、热力图超过100个维度、全部旧分页消费者的逐页人工验收未覆盖。真实API数据来自现有服务及PostgreSQL查询;本轮不修改数据库,未另取得测试受限数据库配置作直接SQL对账。
|
- 验收脚本问题与边界:独立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快照。截图/脚本/凭据不进入提交。临时预览已关闭。
|
- 证据位于%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,本地提交不等于已上线。
|
- 交付边界:本地代码、需求、设计和用例完成;本节随本轮精确文件/追加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 为准;未推送、未测试部署、未预生产部署、未短信发送/补发/重投/入队,未修改业务余额、通道、客户配置或管理员。提交前保护校验确认全部已有跟踪修改未被覆盖。
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user