159 lines
8.8 KiB
TypeScript
159 lines
8.8 KiB
TypeScript
import { ReportsService } from './reports.service';
|
|
|
|
describe('ReportsService', () => {
|
|
const tx = {
|
|
dailyReconciliationReport: { deleteMany: jest.fn() },
|
|
dailyProfitReport: { deleteMany: jest.fn() },
|
|
dailyQualityReport: { deleteMany: jest.fn() },
|
|
$executeRaw: jest.fn(),
|
|
};
|
|
const prisma = {
|
|
dailyReconciliationReport: { findMany: jest.fn(), count: jest.fn(), aggregate: jest.fn() },
|
|
dailyProfitReport: { findMany: jest.fn(), count: jest.fn(), aggregate: jest.fn() },
|
|
dailyQualityReport: { findMany: jest.fn(), count: jest.fn(), aggregate: jest.fn() },
|
|
$transaction: jest.fn((callback: (client: typeof tx) => unknown) => callback(tx)),
|
|
};
|
|
let service: ReportsService;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
tx.dailyReconciliationReport.deleteMany.mockResolvedValue({ count: 0 });
|
|
tx.dailyProfitReport.deleteMany.mockResolvedValue({ count: 0 });
|
|
tx.dailyQualityReport.deleteMany.mockResolvedValue({ count: 0 });
|
|
tx.$executeRaw.mockResolvedValue(0);
|
|
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.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.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 } });
|
|
service = new ReportsService(prisma as never);
|
|
});
|
|
|
|
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'],
|
|
});
|
|
expect(prisma.$transaction).toHaveBeenCalledTimes(4);
|
|
expect(tx.dailyReconciliationReport.deleteMany).toHaveBeenCalledTimes(4);
|
|
expect(tx.dailyProfitReport.deleteMany).toHaveBeenCalledTimes(4);
|
|
expect(tx.dailyQualityReport.deleteMany).toHaveBeenCalledTimes(4);
|
|
expect(tx.$executeRaw).toHaveBeenCalledTimes(28);
|
|
});
|
|
|
|
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 profitQueries = firstDayQueries.slice(1, 3).join('\n');
|
|
|
|
expect(profitQueries).toContain('"SmsMessageSegmentAudit"');
|
|
expect(profitQueries).toContain(`FILTER (WHERE audit."receiptStatus" = 'delivered')`);
|
|
expect(profitQueries).toContain('submit."costUnitPrice"');
|
|
expect(profitQueries).not.toContain('SUM(submit."costAmountCents")');
|
|
});
|
|
|
|
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 profitQueries = firstDayQueries.slice(1, 3).join('\n');
|
|
|
|
expect(profitQueries).toContain('message."billingUnits" * message."unitPrice"');
|
|
expect(profitQueries).toContain('message."submitId" = submit."submitId"');
|
|
expect(profitQueries).not.toContain('"billingStatus"');
|
|
expect(profitQueries).not.toContain('billing.refund');
|
|
});
|
|
|
|
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',
|
|
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,
|
|
}));
|
|
});
|
|
|
|
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 }),
|
|
}));
|
|
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',
|
|
}),
|
|
}));
|
|
});
|
|
|
|
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 },
|
|
});
|
|
|
|
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'),
|
|
}]);
|
|
|
|
const exported = await service.exportProfit({ dimensionType: 'application' });
|
|
expect(exported.content).toContain('收入金额(元)');
|
|
expect(exported.content).not.toContain('净消费金额(元)');
|
|
expect(exported.content).not.toContain('返还金额(元)');
|
|
});
|
|
|
|
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 },
|
|
});
|
|
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' });
|
|
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' }],
|
|
}));
|
|
});
|
|
});
|