This commit is contained in:
@@ -1,11 +1,19 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ReportsService } from './reports.service';
|
||||
|
||||
type CapturedSql = { strings: string[]; values: unknown[]; text: string };
|
||||
|
||||
function sqlText(query: CapturedSql) {
|
||||
return query.strings.join(' ');
|
||||
}
|
||||
|
||||
describe('ReportsService', () => {
|
||||
const tx = {
|
||||
dailyReconciliationReport: { deleteMany: jest.fn() },
|
||||
dailyProfitReport: { deleteMany: jest.fn() },
|
||||
dailyQualityReport: { deleteMany: jest.fn() },
|
||||
$executeRaw: jest.fn(),
|
||||
$queryRaw: jest.fn(),
|
||||
};
|
||||
const prisma = {
|
||||
dailyReconciliationReport: { findMany: jest.fn(), count: jest.fn(), aggregate: jest.fn() },
|
||||
@@ -14,25 +22,62 @@ describe('ReportsService', () => {
|
||||
$transaction: jest.fn((callback: (client: typeof tx) => unknown) => callback(tx)),
|
||||
};
|
||||
let service: ReportsService;
|
||||
const environmentKeys = [
|
||||
'REPORT_DAILY_REFRESH_ENABLED',
|
||||
'REPORT_REFRESH_INTERVAL_MS',
|
||||
'REPORT_REFRESH_TRANSACTION_TIMEOUT_MS',
|
||||
] as const;
|
||||
const originalEnvironment = Object.fromEntries(environmentKeys.map((key) => [key, process.env[key]]));
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.resetAllMocks();
|
||||
for (const key of environmentKeys) delete process.env[key];
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
prisma.$transaction.mockImplementation((callback: (client: typeof tx) => unknown) => callback(tx));
|
||||
tx.dailyReconciliationReport.deleteMany.mockResolvedValue({ count: 0 });
|
||||
tx.dailyProfitReport.deleteMany.mockResolvedValue({ count: 0 });
|
||||
tx.dailyQualityReport.deleteMany.mockResolvedValue({ count: 0 });
|
||||
tx.$executeRaw.mockResolvedValue(0);
|
||||
tx.$queryRaw.mockResolvedValue([{ locked: true }]);
|
||||
prisma.dailyReconciliationReport.findMany.mockResolvedValue([{ id: 'recon-1' }]);
|
||||
prisma.dailyReconciliationReport.count.mockResolvedValue(1);
|
||||
prisma.dailyReconciliationReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } });
|
||||
prisma.dailyReconciliationReport.aggregate.mockResolvedValue({
|
||||
_sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 },
|
||||
});
|
||||
prisma.dailyProfitReport.findMany.mockResolvedValue([{ id: 'profit-1', refundCents: BigInt(100) }]);
|
||||
prisma.dailyProfitReport.count.mockResolvedValue(1);
|
||||
prisma.dailyProfitReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1, revenueCents: BigInt(1000), costCents: BigInt(600), profitCents: BigInt(400) } });
|
||||
prisma.dailyProfitReport.aggregate.mockResolvedValue({
|
||||
_sum: {
|
||||
submittedUnits: 11,
|
||||
sentUnits: 10,
|
||||
unknownUnits: 2,
|
||||
successUnits: 7,
|
||||
failedUnits: 1,
|
||||
revenueCents: BigInt(1000),
|
||||
costCents: BigInt(600),
|
||||
profitCents: BigInt(400),
|
||||
},
|
||||
});
|
||||
prisma.dailyQualityReport.findMany.mockResolvedValue([{ id: 'quality-1' }]);
|
||||
prisma.dailyQualityReport.count.mockResolvedValue(1);
|
||||
prisma.dailyQualityReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } });
|
||||
prisma.dailyQualityReport.aggregate.mockResolvedValue({
|
||||
_sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 },
|
||||
});
|
||||
service = new ReportsService(prisma as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.onModuleDestroy();
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
for (const key of environmentKeys) {
|
||||
const value = originalEnvironment[key];
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
it('rebuilds exactly T-4 through T-1 in independent transactions', async () => {
|
||||
await expect(service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'))).resolves.toEqual({
|
||||
refreshedDates: ['2026-07-11', '2026-07-12', '2026-07-13', '2026-07-14'],
|
||||
@@ -41,14 +86,193 @@ describe('ReportsService', () => {
|
||||
expect(tx.dailyReconciliationReport.deleteMany).toHaveBeenCalledTimes(4);
|
||||
expect(tx.dailyProfitReport.deleteMany).toHaveBeenCalledTimes(4);
|
||||
expect(tx.dailyQualityReport.deleteMany).toHaveBeenCalledTimes(4);
|
||||
expect(tx.$executeRaw).toHaveBeenCalledTimes(28);
|
||||
expect(tx.$executeRaw).toHaveBeenCalledTimes(32);
|
||||
expect(tx.$queryRaw).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['2026-12-31T15:59:59.999Z', ['2026-12-27', '2026-12-28', '2026-12-29', '2026-12-30']],
|
||||
['2026-12-31T16:00:00.000Z', ['2026-12-28', '2026-12-29', '2026-12-30', '2026-12-31']],
|
||||
['2026-02-28T16:00:00.000Z', ['2026-02-25', '2026-02-26', '2026-02-27', '2026-02-28']],
|
||||
])('uses completed Shanghai dates at boundary %s', async (now, expectedDates) => {
|
||||
await expect(service.refreshRollingWindow(new Date(now))).resolves.toEqual({ refreshedDates: expectedDates });
|
||||
expect(
|
||||
tx.dailyReconciliationReport.deleteMany.mock.calls.map(([query]) =>
|
||||
query.where.reportDate.toISOString().slice(0, 10),
|
||||
),
|
||||
).toEqual(expectedDates);
|
||||
});
|
||||
|
||||
it('bounds application costs by the original message day while retaining cross-day submits', async () => {
|
||||
await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
|
||||
const applicationProfit = tx.$executeRaw.mock.calls
|
||||
.map(([query]) => query as CapturedSql)
|
||||
.find((query) => sqlText(query).includes('WITH costs AS'))!;
|
||||
const costs = applicationProfit.text.split('INSERT INTO "DailyProfitReport"')[0];
|
||||
|
||||
expect(costs).toMatch(/message\."queuedAt" >= \$1/);
|
||||
expect(costs).toMatch(/message\."queuedAt" < \$2/);
|
||||
expect(applicationProfit.values.slice(0, 2)).toEqual([
|
||||
new Date('2026-07-10T16:00:00.000Z'),
|
||||
new Date('2026-07-11T16:00:00.000Z'),
|
||||
]);
|
||||
expect(costs).toContain('submit."submitStatus" = \'accepted\'');
|
||||
expect(costs).not.toContain('submit."submittedAt"');
|
||||
expect(costs).not.toContain('submit."createdAt"');
|
||||
expect(costs).toContain('WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count');
|
||||
expect(costs).toContain('WHEN legacy_receipt.delivered THEN message."billingUnits"');
|
||||
|
||||
const channelProfit = tx.$executeRaw.mock.calls
|
||||
.map(([query]) => sqlText(query))
|
||||
.find((query) => query.includes("CONCAT('profit-channel-'"))!;
|
||||
expect(channelProfit).toContain('COALESCE(submit."submittedAt", submit."createdAt") >=');
|
||||
expect(channelProfit).toContain('COALESCE(submit."submittedAt", submit."createdAt") <');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[undefined, 30_000],
|
||||
['45000', 45_000],
|
||||
['200000', 120_000],
|
||||
['0', 30_000],
|
||||
['-1', 30_000],
|
||||
['1.5', 30_000],
|
||||
['invalid', 30_000],
|
||||
['Infinity', 30_000],
|
||||
])('uses a bounded report transaction timeout for %s', async (configured, expected) => {
|
||||
if (configured !== undefined) process.env.REPORT_REFRESH_TRANSACTION_TIMEOUT_MS = configured;
|
||||
await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { maxWait: 5_000, timeout: expected });
|
||||
const timeoutQueries = tx.$executeRaw.mock.calls
|
||||
.map(([query]) => query as CapturedSql)
|
||||
.filter((query) => sqlText(query).includes('statement_timeout'));
|
||||
expect(timeoutQueries).toHaveLength(4);
|
||||
for (const query of timeoutQueries) {
|
||||
expect(sqlText(query)).toContain("set_config('statement_timeout',");
|
||||
expect(sqlText(query)).toContain(', true)');
|
||||
expect(query.values).toEqual([`${expected}ms`]);
|
||||
}
|
||||
});
|
||||
|
||||
it('continues later dates after one transaction fails and reports partial completion', async () => {
|
||||
const failure = new Error('database transaction expired');
|
||||
tx.$executeRaw.mockResolvedValueOnce(0).mockRejectedValueOnce(failure);
|
||||
|
||||
await expect(service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'))).rejects.toThrow(
|
||||
'failed dates: 2026-07-11; refreshed dates: 2026-07-12, 2026-07-13, 2026-07-14',
|
||||
);
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(4);
|
||||
expect(
|
||||
tx.dailyQualityReport.deleteMany.mock.calls.map(([query]) => query.where.reportDate.toISOString().slice(0, 10)),
|
||||
).toEqual(['2026-07-11', '2026-07-12', '2026-07-13', '2026-07-14']);
|
||||
expect(Logger.prototype.error).toHaveBeenCalledWith('Daily report refresh failed for 2026-07-11', failure.stack);
|
||||
});
|
||||
|
||||
it('does not delete reports for a date whose lock is owned by another transaction', async () => {
|
||||
tx.$queryRaw.mockResolvedValueOnce([{ locked: false }]);
|
||||
|
||||
await expect(service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'))).rejects.toThrow(
|
||||
'failed dates: 2026-07-11',
|
||||
);
|
||||
for (const model of [tx.dailyReconciliationReport, tx.dailyProfitReport, tx.dailyQualityReport]) {
|
||||
expect(model.deleteMany.mock.calls.map(([query]) => query.where.reportDate.toISOString().slice(0, 10))).toEqual([
|
||||
'2026-07-12',
|
||||
'2026-07-13',
|
||||
'2026-07-14',
|
||||
]);
|
||||
}
|
||||
expect(tx.$queryRaw.mock.calls.map(([query]) => query.values[1])).toEqual([20260711, 20260712, 20260713, 20260714]);
|
||||
expect(sqlText(tx.$queryRaw.mock.calls[0][0])).toContain('pg_try_advisory_xact_lock');
|
||||
expect(tx.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
|
||||
tx.dailyReconciliationReport.deleteMany.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('fails safely when the transaction lock query returns no result', async () => {
|
||||
tx.$queryRaw.mockResolvedValue([]);
|
||||
await expect(service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'))).rejects.toThrow(
|
||||
'refreshed dates: none',
|
||||
);
|
||||
expect(tx.dailyReconciliationReport.deleteMany).not.toHaveBeenCalled();
|
||||
expect(tx.dailyProfitReport.deleteMany).not.toHaveBeenCalled();
|
||||
expect(tx.dailyQualityReport.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries partial failures and skips the business day only after full success', async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-07-15T05:30:00.000Z'));
|
||||
tx.$executeRaw.mockRejectedValueOnce(new Error('database unavailable'));
|
||||
|
||||
await service['runScheduledRefresh']();
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(4);
|
||||
expect(Logger.prototype.log).not.toHaveBeenCalled();
|
||||
await service['runScheduledRefresh']();
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(8);
|
||||
expect(Logger.prototype.log).toHaveBeenCalledTimes(1);
|
||||
await service['runScheduledRefresh']();
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(8);
|
||||
|
||||
jest.setSystemTime(new Date('2026-07-15T16:00:00.000Z'));
|
||||
await service['runScheduledRefresh']();
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(12);
|
||||
});
|
||||
|
||||
it('prevents overlapping scheduled refreshes in the same service instance', async () => {
|
||||
let finishRefresh!: (result: { refreshedDates: string[] }) => void;
|
||||
const refresh = jest.spyOn(service, 'refreshRollingWindow').mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishRefresh = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const running = service['runScheduledRefresh']();
|
||||
await service['runScheduledRefresh']();
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
finishRefresh({ refreshedDates: ['2026-07-14'] });
|
||||
await running;
|
||||
});
|
||||
|
||||
it('cancels startup and interval timers when destroyed before startup refresh', async () => {
|
||||
jest.useFakeTimers();
|
||||
const refresh = jest.spyOn(service, 'refreshRollingWindow');
|
||||
service.onModuleInit();
|
||||
expect(jest.getTimerCount()).toBe(2);
|
||||
service.onModuleDestroy();
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
await jest.advanceTimersByTimeAsync(2 * 60 * 60 * 1000);
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts after 15 seconds and retries a failed scheduled run on the configured interval', async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-07-15T05:30:00.000Z'));
|
||||
process.env.REPORT_REFRESH_INTERVAL_MS = '60000';
|
||||
const refresh = jest
|
||||
.spyOn(service, 'refreshRollingWindow')
|
||||
.mockRejectedValueOnce(new Error('temporarily unavailable'));
|
||||
service.onModuleInit();
|
||||
|
||||
await jest.advanceTimersByTimeAsync(14_999);
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
expect(refresh).toHaveBeenCalledTimes(2);
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(refresh).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not schedule reports when daily refresh is disabled', () => {
|
||||
jest.useFakeTimers();
|
||||
process.env.REPORT_DAILY_REFRESH_ENABLED = 'false';
|
||||
service.onModuleInit();
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('calculates profit cost from channel unit price times delivered fragment count', async () => {
|
||||
await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
|
||||
const firstDayQueries = tx.$executeRaw.mock.calls.slice(0, 7).map(([query]) =>
|
||||
Array.isArray(query?.strings) ? query.strings.join(' ') : String(query),
|
||||
);
|
||||
const firstDayQueries = tx.$executeRaw.mock.calls
|
||||
.slice(1, 8)
|
||||
.map(([query]) => (Array.isArray(query?.strings) ? query.strings.join(' ') : String(query)));
|
||||
const profitQueries = firstDayQueries.slice(1, 3).join('\n');
|
||||
|
||||
expect(profitQueries).toContain('"SmsMessageSegmentAudit"');
|
||||
@@ -59,9 +283,9 @@ describe('ReportsService', () => {
|
||||
|
||||
it('calculates income from successful billing units and the message unit price snapshot without refund status', async () => {
|
||||
await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
|
||||
const firstDayQueries = tx.$executeRaw.mock.calls.slice(0, 7).map(([query]) =>
|
||||
Array.isArray(query?.strings) ? query.strings.join(' ') : String(query),
|
||||
);
|
||||
const firstDayQueries = tx.$executeRaw.mock.calls
|
||||
.slice(1, 8)
|
||||
.map(([query]) => (Array.isArray(query?.strings) ? query.strings.join(' ') : String(query)));
|
||||
const profitQueries = firstDayQueries.slice(1, 3).join('\n');
|
||||
|
||||
expect(profitQueries).toContain('message."billingUnits" * message."unitPrice"');
|
||||
@@ -71,58 +295,111 @@ describe('ReportsService', () => {
|
||||
});
|
||||
|
||||
it('queries reconciliation reports with server-side filters and bounded pagination', async () => {
|
||||
await expect(service.listReconciliation({
|
||||
dateFrom: '2026-07-01',
|
||||
dateTo: '2026-07-14',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
await expect(
|
||||
service.listReconciliation({
|
||||
dateFrom: '2026-07-01',
|
||||
dateTo: '2026-07-14',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
page: 2,
|
||||
pageSize: 500,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
items: [{ id: 'recon-1' }],
|
||||
total: 1,
|
||||
page: 2,
|
||||
pageSize: 500,
|
||||
})).resolves.toEqual({ items: [{ id: 'recon-1' }], total: 1, page: 2, pageSize: 100, summary: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } });
|
||||
expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1' }),
|
||||
skip: 100,
|
||||
take: 100,
|
||||
}));
|
||||
pageSize: 100,
|
||||
summary: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 },
|
||||
});
|
||||
expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1' }),
|
||||
skip: 100,
|
||||
take: 100,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps application and channel profit filters separate', async () => {
|
||||
const result = await service.listProfit({ dimensionType: 'channel', tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1' });
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
summary: expect.objectContaining({ revenueCents: 1000, profitCents: 400, profitRateBps: 4000 }),
|
||||
}));
|
||||
const result = await service.listProfit({
|
||||
dimensionType: 'channel',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
channelId: 'channel-1',
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
summary: expect.objectContaining({ revenueCents: 1000, profitCents: 400, profitRateBps: 4000 }),
|
||||
}),
|
||||
);
|
||||
expect(result.summary).not.toHaveProperty('refundCents');
|
||||
expect(result.items[0]).not.toHaveProperty('refundCents');
|
||||
expect(prisma.dailyProfitReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
dimensionType: 'channel',
|
||||
tenantId: undefined,
|
||||
applicationId: undefined,
|
||||
channelId: 'channel-1',
|
||||
expect(prisma.dailyProfitReport.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
dimensionType: 'channel',
|
||||
tenantId: undefined,
|
||||
applicationId: undefined,
|
||||
channelId: 'channel-1',
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
);
|
||||
});
|
||||
|
||||
it('returns zero full-result totals and rates when a filtered report has no rows', async () => {
|
||||
prisma.dailyProfitReport.findMany.mockResolvedValueOnce([]);
|
||||
prisma.dailyProfitReport.count.mockResolvedValueOnce(0);
|
||||
prisma.dailyProfitReport.aggregate.mockResolvedValueOnce({
|
||||
_sum: { submittedUnits: null, sentUnits: null, unknownUnits: null, successUnits: null, failedUnits: null, revenueCents: null, costCents: null, profitCents: null },
|
||||
_sum: {
|
||||
submittedUnits: null,
|
||||
sentUnits: null,
|
||||
unknownUnits: null,
|
||||
successUnits: null,
|
||||
failedUnits: null,
|
||||
revenueCents: null,
|
||||
costCents: null,
|
||||
profitCents: null,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.listProfit({ dimensionType: 'application', tenantId: 'missing' })).resolves.toEqual(expect.objectContaining({
|
||||
total: 0,
|
||||
summary: { submittedUnits: 0, sentUnits: 0, unknownUnits: 0, successUnits: 0, failedUnits: 0, revenueCents: 0, costCents: 0, profitCents: 0, profitRateBps: 0 },
|
||||
}));
|
||||
await expect(service.listProfit({ dimensionType: 'application', tenantId: 'missing' })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
total: 0,
|
||||
summary: {
|
||||
submittedUnits: 0,
|
||||
sentUnits: 0,
|
||||
unknownUnits: 0,
|
||||
successUnits: 0,
|
||||
failedUnits: 0,
|
||||
revenueCents: 0,
|
||||
costCents: 0,
|
||||
profitCents: 0,
|
||||
profitRateBps: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('exports income without refund columns', async () => {
|
||||
prisma.dailyProfitReport.findMany.mockResolvedValueOnce([{
|
||||
id: 'profit-export', reportDate: new Date('2026-07-14'), dimensionName: '应用A', tenantName: '示例企业',
|
||||
submittedUnits: 14, sentUnits: 12, unknownUnits: 1, successUnits: 10, failedUnits: 1,
|
||||
revenueCents: BigInt(3500), refundCents: BigInt(200), costCents: BigInt(2100), profitCents: BigInt(1400),
|
||||
profitRateBps: 4000, generatedAt: new Date('2026-07-15T00:00:00Z'),
|
||||
}]);
|
||||
prisma.dailyProfitReport.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'profit-export',
|
||||
reportDate: new Date('2026-07-14'),
|
||||
dimensionName: '应用A',
|
||||
tenantName: '示例企业',
|
||||
submittedUnits: 14,
|
||||
sentUnits: 12,
|
||||
unknownUnits: 1,
|
||||
successUnits: 10,
|
||||
failedUnits: 1,
|
||||
revenueCents: BigInt(3500),
|
||||
refundCents: BigInt(200),
|
||||
costCents: BigInt(2100),
|
||||
profitCents: BigInt(1400),
|
||||
profitRateBps: 4000,
|
||||
generatedAt: new Date('2026-07-15T00:00:00Z'),
|
||||
},
|
||||
]);
|
||||
|
||||
const exported = await service.exportProfit({ dimensionType: 'application' });
|
||||
expect(exported.content).toContain('收入金额(元)');
|
||||
@@ -131,28 +408,59 @@ describe('ReportsService', () => {
|
||||
});
|
||||
|
||||
it('sorts quality reports by send volume and keeps the selected dimension', async () => {
|
||||
await expect(service.listQuality({ dimensionType: 'drainage', tenantId: 'tenant-1', page: 1, pageSize: 20 })).resolves.toEqual({
|
||||
items: [{ id: 'quality-1' }], total: 1, page: 1, pageSize: 20, dimensionType: 'drainage',
|
||||
summary: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1, successRateBps: 7000 },
|
||||
await expect(
|
||||
service.listQuality({ dimensionType: 'drainage', tenantId: 'tenant-1', page: 1, pageSize: 20 }),
|
||||
).resolves.toEqual({
|
||||
items: [{ id: 'quality-1' }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
dimensionType: 'drainage',
|
||||
summary: {
|
||||
submittedUnits: 11,
|
||||
sentUnits: 10,
|
||||
unknownUnits: 2,
|
||||
successUnits: 7,
|
||||
failedUnits: 1,
|
||||
successRateBps: 7000,
|
||||
},
|
||||
});
|
||||
expect(prisma.dailyQualityReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ dimensionType: 'drainage', tenantId: 'tenant-1' }),
|
||||
orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }],
|
||||
}));
|
||||
expect(prisma.dailyQualityReport.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ dimensionType: 'drainage', tenantId: 'tenant-1' }),
|
||||
orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('exports complete filtered report data as escaped CSV instead of the current page', async () => {
|
||||
prisma.dailyReconciliationReport.findMany.mockResolvedValueOnce([{
|
||||
id: 'recon-export', reportDate: new Date('2026-07-14'), tenantName: '示例,企业', applicationName: '应用A',
|
||||
submittedUnits: 14, sentUnits: 12, unknownUnits: 1, successUnits: 10, failedUnits: 1, generatedAt: new Date('2026-07-15T00:00:00Z'),
|
||||
}]);
|
||||
const exported = await service.exportReconciliation({ tenantId: 'tenant-1', dateFrom: '2026-07-01', dateTo: '2026-07-14' });
|
||||
prisma.dailyReconciliationReport.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'recon-export',
|
||||
reportDate: new Date('2026-07-14'),
|
||||
tenantName: '示例,企业',
|
||||
applicationName: '应用A',
|
||||
submittedUnits: 14,
|
||||
sentUnits: 12,
|
||||
unknownUnits: 1,
|
||||
successUnits: 10,
|
||||
failedUnits: 1,
|
||||
generatedAt: new Date('2026-07-15T00:00:00Z'),
|
||||
},
|
||||
]);
|
||||
const exported = await service.exportReconciliation({
|
||||
tenantId: 'tenant-1',
|
||||
dateFrom: '2026-07-01',
|
||||
dateTo: '2026-07-14',
|
||||
});
|
||||
expect(exported.fileName).toContain('对账单-');
|
||||
expect(exported.content).toContain('"示例,企业"');
|
||||
expect(exported.content).toContain('提交条数');
|
||||
expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ tenantId: 'tenant-1' }),
|
||||
orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }],
|
||||
}));
|
||||
expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ tenantId: 'tenant-1' }),
|
||||
orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const DEFAULT_REFRESH_INTERVAL_MS = 60 * 60 * 1000;
|
||||
const DEFAULT_REFRESH_TRANSACTION_TIMEOUT_MS = 30_000;
|
||||
const REPORT_LOCK_NAMESPACE = 0x434d5052;
|
||||
|
||||
export type ReportListQuery = {
|
||||
dateFrom?: string;
|
||||
@@ -21,6 +23,7 @@ export type ReportListQuery = {
|
||||
@Injectable()
|
||||
export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(ReportsService.name);
|
||||
private startupTimer?: ReturnType<typeof setTimeout>;
|
||||
private refreshTimer?: ReturnType<typeof setInterval>;
|
||||
private refreshRunning = false;
|
||||
private lastRefreshBusinessDate?: string;
|
||||
@@ -29,8 +32,8 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
onModuleInit() {
|
||||
if (process.env.REPORT_DAILY_REFRESH_ENABLED === 'false') return;
|
||||
const startupTimer = setTimeout(() => void this.runScheduledRefresh(), 15_000);
|
||||
startupTimer.unref?.();
|
||||
this.startupTimer = setTimeout(() => void this.runScheduledRefresh(), 15_000);
|
||||
this.startupTimer.unref?.();
|
||||
this.refreshTimer = setInterval(
|
||||
() => void this.runScheduledRefresh(),
|
||||
positiveInteger(process.env.REPORT_REFRESH_INTERVAL_MS, DEFAULT_REFRESH_INTERVAL_MS),
|
||||
@@ -39,6 +42,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
if (this.startupTimer) clearTimeout(this.startupTimer);
|
||||
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
||||
}
|
||||
|
||||
@@ -46,7 +50,12 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
const { page, pageSize, skip } = pagination(query);
|
||||
const where = reconciliationWhere(query);
|
||||
const [items, total, aggregate] = await Promise.all([
|
||||
this.prisma.dailyReconciliationReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }], skip, take: pageSize }),
|
||||
this.prisma.dailyReconciliationReport.findMany({
|
||||
where,
|
||||
orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }],
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.dailyReconciliationReport.count({ where }),
|
||||
this.prisma.dailyReconciliationReport.aggregate({ where, _sum: reportVolumeSumSelection }),
|
||||
]);
|
||||
@@ -57,7 +66,12 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
const { page, pageSize, skip } = pagination(query);
|
||||
const { dimensionType, where } = profitWhere(query);
|
||||
const [storedItems, total, aggregate] = await Promise.all([
|
||||
this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }),
|
||||
this.prisma.dailyProfitReport.findMany({
|
||||
where,
|
||||
orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }],
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.dailyProfitReport.count({ where }),
|
||||
this.prisma.dailyProfitReport.aggregate({
|
||||
where,
|
||||
@@ -65,7 +79,10 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
}),
|
||||
]);
|
||||
// refundCents 暂留在持久化模型中兼容既有数据和回滚,但利润报表接口不再暴露返还口径。
|
||||
const items = storedItems.map(({ refundCents: _refundCents, ...item }) => item);
|
||||
const items = storedItems.map(({ refundCents, ...item }) => {
|
||||
void refundCents;
|
||||
return item;
|
||||
});
|
||||
const summary = {
|
||||
...volumeSummary(aggregate._sum),
|
||||
revenueCents: Number(aggregate._sum.revenueCents ?? 0),
|
||||
@@ -81,7 +98,12 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
const { page, pageSize, skip } = pagination(query);
|
||||
const { dimensionType, where } = qualityWhere(query);
|
||||
const [items, total, aggregate] = await Promise.all([
|
||||
this.prisma.dailyQualityReport.findMany({ where, orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }),
|
||||
this.prisma.dailyQualityReport.findMany({
|
||||
where,
|
||||
orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }],
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.dailyQualityReport.count({ where }),
|
||||
this.prisma.dailyQualityReport.aggregate({ where, _sum: reportVolumeSumSelection }),
|
||||
]);
|
||||
@@ -94,34 +116,136 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
async exportReconciliation(query: ReportListQuery) {
|
||||
const items = await this.prisma.dailyReconciliationReport.findMany({ where: reconciliationWhere(query), orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }] });
|
||||
return csvExport('对账单', ['发送日期', '企业', '企业应用', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.tenantName, item.applicationName, item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, formatCsvDate(item.generatedAt)]));
|
||||
const items = await this.prisma.dailyReconciliationReport.findMany({
|
||||
where: reconciliationWhere(query),
|
||||
orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }],
|
||||
});
|
||||
return csvExport(
|
||||
'对账单',
|
||||
['发送日期', '企业', '企业应用', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '生成时间'],
|
||||
items.map((item) => [
|
||||
dateKey(item.reportDate),
|
||||
item.tenantName,
|
||||
item.applicationName,
|
||||
item.submittedUnits,
|
||||
item.sentUnits,
|
||||
item.unknownUnits,
|
||||
item.successUnits,
|
||||
item.failedUnits,
|
||||
formatCsvDate(item.generatedAt),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
async exportProfit(query: ReportListQuery) {
|
||||
const { dimensionType, where } = profitWhere(query);
|
||||
const items = await this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }] });
|
||||
return csvExport(`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`, ['发送日期', '统计维度', '企业', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '收入金额(元)', '成本金额(元)', '利润(元)', '利润率(%)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, moneyUnitsToFixedYuan(item.revenueCents), moneyUnitsToFixedYuan(item.costCents), moneyUnitsToFixedYuan(item.profitCents), (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)]));
|
||||
const items = await this.prisma.dailyProfitReport.findMany({
|
||||
where,
|
||||
orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }],
|
||||
});
|
||||
return csvExport(
|
||||
`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`,
|
||||
[
|
||||
'发送日期',
|
||||
'统计维度',
|
||||
'企业',
|
||||
'提交条数',
|
||||
'发送条数',
|
||||
'未知条数',
|
||||
'成功条数',
|
||||
'失败条数',
|
||||
'收入金额(元)',
|
||||
'成本金额(元)',
|
||||
'利润(元)',
|
||||
'利润率(%)',
|
||||
'生成时间',
|
||||
],
|
||||
items.map((item) => [
|
||||
dateKey(item.reportDate),
|
||||
item.dimensionName,
|
||||
item.tenantName ?? '',
|
||||
item.submittedUnits,
|
||||
item.sentUnits,
|
||||
item.unknownUnits,
|
||||
item.successUnits,
|
||||
item.failedUnits,
|
||||
moneyUnitsToFixedYuan(item.revenueCents),
|
||||
moneyUnitsToFixedYuan(item.costCents),
|
||||
moneyUnitsToFixedYuan(item.profitCents),
|
||||
(item.profitRateBps / 100).toFixed(2),
|
||||
formatCsvDate(item.generatedAt),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
async exportQuality(query: ReportListQuery) {
|
||||
const { dimensionType, where } = qualityWhere(query);
|
||||
const items = await this.prisma.dailyQualityReport.findMany({ where, orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }] });
|
||||
return csvExport(`发送质量报表-${dimensionType}`, ['发送日期', '统计对象', '企业', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '成功率(%)', '平均到达时长(毫秒)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, (item.successRateBps / 100).toFixed(2), item.avgArrivalMs ?? '', formatCsvDate(item.generatedAt)]));
|
||||
const items = await this.prisma.dailyQualityReport.findMany({
|
||||
where,
|
||||
orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }],
|
||||
});
|
||||
return csvExport(
|
||||
`发送质量报表-${dimensionType}`,
|
||||
[
|
||||
'发送日期',
|
||||
'统计对象',
|
||||
'企业',
|
||||
'提交条数',
|
||||
'发送条数',
|
||||
'未知条数',
|
||||
'成功条数',
|
||||
'失败条数',
|
||||
'成功率(%)',
|
||||
'平均到达时长(毫秒)',
|
||||
'生成时间',
|
||||
],
|
||||
items.map((item) => [
|
||||
dateKey(item.reportDate),
|
||||
item.dimensionName,
|
||||
item.tenantName ?? '',
|
||||
item.submittedUnits,
|
||||
item.sentUnits,
|
||||
item.unknownUnits,
|
||||
item.successUnits,
|
||||
item.failedUnits,
|
||||
(item.successRateBps / 100).toFixed(2),
|
||||
item.avgArrivalMs ?? '',
|
||||
formatCsvDate(item.generatedAt),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
async refreshRollingWindow(now = new Date()) {
|
||||
const days = completedBusinessDays(now, 4);
|
||||
for (const day of days) await this.refreshBusinessDay(day);
|
||||
return { refreshedDates: days.map((day) => day.key) };
|
||||
const refreshedDates: string[] = [];
|
||||
const failedDates: string[] = [];
|
||||
for (const day of days) {
|
||||
try {
|
||||
await this.refreshBusinessDay(day);
|
||||
refreshedDates.push(day.key);
|
||||
} catch (error) {
|
||||
failedDates.push(day.key);
|
||||
this.logger.error(
|
||||
`Daily report refresh failed for ${day.key}`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (failedDates.length) {
|
||||
throw new Error(
|
||||
`Daily report refresh incomplete; failed dates: ${failedDates.join(', ')}; refreshed dates: ${refreshedDates.join(', ') || 'none'}`,
|
||||
);
|
||||
}
|
||||
return { refreshedDates };
|
||||
}
|
||||
|
||||
private async runScheduledRefresh() {
|
||||
const businessDate = shanghaiDateKey(new Date());
|
||||
const now = new Date();
|
||||
const businessDate = shanghaiDateKey(now);
|
||||
if (this.refreshRunning || this.lastRefreshBusinessDate === businessDate) return;
|
||||
this.refreshRunning = true;
|
||||
try {
|
||||
const result = await this.refreshRollingWindow();
|
||||
const result = await this.refreshRollingWindow(now);
|
||||
this.lastRefreshBusinessDate = businessDate;
|
||||
this.logger.log(`Daily reports refreshed for ${result.refreshedDates.join(', ')}`);
|
||||
} catch (error) {
|
||||
@@ -132,12 +256,22 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
private async refreshBusinessDay(day: BusinessDay) {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.dailyReconciliationReport.deleteMany({ where: { reportDate: day.reportDate } });
|
||||
await tx.dailyProfitReport.deleteMany({ where: { reportDate: day.reportDate } });
|
||||
await tx.dailyQualityReport.deleteMany({ where: { reportDate: day.reportDate } });
|
||||
const timeout = Math.min(
|
||||
120_000,
|
||||
positiveInteger(process.env.REPORT_REFRESH_TRANSACTION_TIMEOUT_MS, DEFAULT_REFRESH_TRANSACTION_TIMEOUT_MS),
|
||||
);
|
||||
await this.prisma.$transaction(
|
||||
async (tx) => {
|
||||
await tx.$executeRaw(Prisma.sql`SELECT set_config('statement_timeout', ${`${timeout}ms`}, true)`);
|
||||
const [lock] = await tx.$queryRaw<{ locked: boolean }[]>(Prisma.sql`
|
||||
SELECT pg_try_advisory_xact_lock(${REPORT_LOCK_NAMESPACE}::integer, ${Number(day.key.replaceAll('-', ''))}::integer) AS locked
|
||||
`);
|
||||
if (!lock?.locked) throw new Error(`Daily reports for ${day.key} are being refreshed by another transaction`);
|
||||
await tx.dailyReconciliationReport.deleteMany({ where: { reportDate: day.reportDate } });
|
||||
await tx.dailyProfitReport.deleteMany({ where: { reportDate: day.reportDate } });
|
||||
await tx.dailyQualityReport.deleteMany({ where: { reportDate: day.reportDate } });
|
||||
|
||||
await tx.$executeRaw(Prisma.sql`
|
||||
await tx.$executeRaw(Prisma.sql`
|
||||
INSERT INTO "DailyReconciliationReport" (
|
||||
"id", "reportDate", "tenantId", "tenantName", "applicationId", "applicationName",
|
||||
"submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "generatedAt", "updatedAt"
|
||||
@@ -169,7 +303,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
GROUP BY tenant.id, tenant.name, application.id, application.name
|
||||
`);
|
||||
|
||||
await tx.$executeRaw(Prisma.sql`
|
||||
await tx.$executeRaw(Prisma.sql`
|
||||
WITH costs AS (
|
||||
SELECT
|
||||
submit."messageRecordId",
|
||||
@@ -197,6 +331,9 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
) AS delivered
|
||||
) legacy_receipt ON TRUE
|
||||
WHERE submit."submitStatus" = 'accepted'
|
||||
-- 应用成本归属于原短信日,仍包含该短信全部跨日补发尝试。
|
||||
AND message."queuedAt" >= ${day.startAt}
|
||||
AND message."queuedAt" < ${day.endAt}
|
||||
GROUP BY submit."messageRecordId"
|
||||
)
|
||||
INSERT INTO "DailyProfitReport" (
|
||||
@@ -249,7 +386,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
GROUP BY tenant.id, tenant.name, application.id, application.name
|
||||
`);
|
||||
|
||||
await tx.$executeRaw(Prisma.sql`
|
||||
await tx.$executeRaw(Prisma.sql`
|
||||
INSERT INTO "DailyProfitReport" (
|
||||
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
|
||||
"tenantId", "tenantName", "applicationId", "channelId",
|
||||
@@ -348,29 +485,34 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
GROUP BY channel.id, channel.name
|
||||
`);
|
||||
|
||||
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'application'));
|
||||
await tx.$executeRaw(qualityByChannelSql(day));
|
||||
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'signature'));
|
||||
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'drainage'));
|
||||
});
|
||||
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'application'));
|
||||
await tx.$executeRaw(qualityByChannelSql(day));
|
||||
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'signature'));
|
||||
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'drainage'));
|
||||
},
|
||||
{ maxWait: 5_000, timeout },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'application' | 'signature' | 'drainage') {
|
||||
const dimensionTypeSql = Prisma.raw(`'${dimensionType}'`);
|
||||
const dimensionId = dimensionType === 'application'
|
||||
? Prisma.sql`application.id`
|
||||
: dimensionType === 'signature'
|
||||
? Prisma.sql`COALESCE(signature.id, 'unmatched:' || COALESCE(application.id, tenant.id))`
|
||||
: Prisma.sql`COALESCE(drainage.id, 'unmatched:' || COALESCE(application.id, tenant.id))`;
|
||||
const dimensionName = dimensionType === 'application'
|
||||
? Prisma.sql`application.name`
|
||||
: dimensionType === 'signature'
|
||||
? Prisma.sql`COALESCE(signature.name, '未关联签名')`
|
||||
: Prisma.sql`COALESCE(drainage."siteName", '未关联引流信息')`;
|
||||
const applicationJoin = dimensionType === 'application'
|
||||
? Prisma.sql`JOIN "SmsApplication" application ON application.id = message."applicationId"`
|
||||
: Prisma.sql`LEFT JOIN "SmsApplication" application ON application.id = message."applicationId"`;
|
||||
const dimensionId =
|
||||
dimensionType === 'application'
|
||||
? Prisma.sql`application.id`
|
||||
: dimensionType === 'signature'
|
||||
? Prisma.sql`COALESCE(signature.id, 'unmatched:' || COALESCE(application.id, tenant.id))`
|
||||
: Prisma.sql`COALESCE(drainage.id, 'unmatched:' || COALESCE(application.id, tenant.id))`;
|
||||
const dimensionName =
|
||||
dimensionType === 'application'
|
||||
? Prisma.sql`application.name`
|
||||
: dimensionType === 'signature'
|
||||
? Prisma.sql`COALESCE(signature.name, '未关联签名')`
|
||||
: Prisma.sql`COALESCE(drainage."siteName", '未关联引流信息')`;
|
||||
const applicationJoin =
|
||||
dimensionType === 'application'
|
||||
? Prisma.sql`JOIN "SmsApplication" application ON application.id = message."applicationId"`
|
||||
: Prisma.sql`LEFT JOIN "SmsApplication" application ON application.id = message."applicationId"`;
|
||||
|
||||
return Prisma.sql`
|
||||
WITH base AS (
|
||||
@@ -548,7 +690,13 @@ const reportVolumeSumSelection = {
|
||||
failedUnits: true,
|
||||
} as const;
|
||||
|
||||
function volumeSummary(sum: { submittedUnits?: number | null; sentUnits?: number | null; unknownUnits?: number | null; successUnits?: number | null; failedUnits?: number | null }) {
|
||||
function volumeSummary(sum: {
|
||||
submittedUnits?: number | null;
|
||||
sentUnits?: number | null;
|
||||
unknownUnits?: number | null;
|
||||
successUnits?: number | null;
|
||||
failedUnits?: number | null;
|
||||
}) {
|
||||
return {
|
||||
submittedUnits: Number(sum.submittedUnits ?? 0),
|
||||
sentUnits: Number(sum.sentUnits ?? 0),
|
||||
@@ -559,11 +707,15 @@ function volumeSummary(sum: { submittedUnits?: number | null; sentUnits?: number
|
||||
}
|
||||
|
||||
function ratioBps(numerator: number, denominator: number) {
|
||||
return denominator === 0 ? 0 : Math.round(numerator * 10_000 / denominator);
|
||||
return denominator === 0 ? 0 : Math.round((numerator * 10_000) / denominator);
|
||||
}
|
||||
|
||||
function reconciliationWhere(query: ReportListQuery): Prisma.DailyReconciliationReportWhereInput {
|
||||
return { reportDate: dateFilter(query.dateFrom, query.dateTo), tenantId: query.tenantId || undefined, applicationId: query.applicationId || undefined };
|
||||
return {
|
||||
reportDate: dateFilter(query.dateFrom, query.dateTo),
|
||||
tenantId: query.tenantId || undefined,
|
||||
applicationId: query.applicationId || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function profitWhere(query: ReportListQuery) {
|
||||
@@ -580,7 +732,9 @@ function profitWhere(query: ReportListQuery) {
|
||||
|
||||
function qualityWhere(query: ReportListQuery) {
|
||||
const allowedDimensions = new Set(['application', 'channel', 'signature', 'drainage']);
|
||||
const dimensionType = allowedDimensions.has(String(query.dimensionType)) ? String(query.dimensionType) : 'application';
|
||||
const dimensionType = allowedDimensions.has(String(query.dimensionType))
|
||||
? String(query.dimensionType)
|
||||
: 'application';
|
||||
const where: Prisma.DailyQualityReportWhereInput = {
|
||||
dimensionType,
|
||||
reportDate: dateFilter(query.dateFrom, query.dateTo),
|
||||
|
||||
Reference in New Issue
Block a user