feat: improve operations diagnostics and channel management
This commit is contained in:
@@ -8,9 +8,9 @@ describe('ReportsService', () => {
|
||||
$executeRaw: jest.fn(),
|
||||
};
|
||||
const prisma = {
|
||||
dailyReconciliationReport: { findMany: jest.fn(), count: jest.fn() },
|
||||
dailyProfitReport: { findMany: jest.fn(), count: jest.fn() },
|
||||
dailyQualityReport: { findMany: jest.fn(), count: jest.fn() },
|
||||
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;
|
||||
@@ -23,10 +23,13 @@ describe('ReportsService', () => {
|
||||
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' }]);
|
||||
prisma.dailyProfitReport.count.mockResolvedValue(1);
|
||||
prisma.dailyProfitReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1, revenueCents: BigInt(1000), refundCents: BigInt(100), 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);
|
||||
});
|
||||
|
||||
@@ -62,7 +65,7 @@ describe('ReportsService', () => {
|
||||
applicationId: 'app-1',
|
||||
page: 2,
|
||||
pageSize: 500,
|
||||
})).resolves.toEqual({ items: [{ id: 'recon-1' }], total: 1, page: 2, pageSize: 100 });
|
||||
})).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,
|
||||
@@ -71,7 +74,9 @@ describe('ReportsService', () => {
|
||||
});
|
||||
|
||||
it('keeps application and channel profit filters separate', async () => {
|
||||
await service.listProfit({ dimensionType: 'channel', tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1' });
|
||||
await expect(service.listProfit({ dimensionType: 'channel', tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1' })).resolves.toEqual(expect.objectContaining({
|
||||
summary: expect.objectContaining({ revenueCents: 1000, profitCents: 400, profitRateBps: 4000 }),
|
||||
}));
|
||||
expect(prisma.dailyProfitReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
dimensionType: 'channel',
|
||||
@@ -82,9 +87,23 @@ describe('ReportsService', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
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, refundCents: 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, refundCents: 0, costCents: 0, profitCents: 0, profitRateBps: 0 },
|
||||
}));
|
||||
});
|
||||
|
||||
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' }),
|
||||
|
||||
@@ -45,31 +45,51 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
async listReconciliation(query: ReportListQuery) {
|
||||
const { page, pageSize, skip } = pagination(query);
|
||||
const where = reconciliationWhere(query);
|
||||
const [items, total] = 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.count({ where }),
|
||||
this.prisma.dailyReconciliationReport.aggregate({ where, _sum: reportVolumeSumSelection }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
return { items, total, page, pageSize, summary: volumeSummary(aggregate._sum) };
|
||||
}
|
||||
|
||||
async listProfit(query: ReportListQuery) {
|
||||
const { page, pageSize, skip } = pagination(query);
|
||||
const { dimensionType, where } = profitWhere(query);
|
||||
const [items, total] = await Promise.all([
|
||||
const [items, total, aggregate] = await Promise.all([
|
||||
this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }),
|
||||
this.prisma.dailyProfitReport.count({ where }),
|
||||
this.prisma.dailyProfitReport.aggregate({
|
||||
where,
|
||||
_sum: { ...reportVolumeSumSelection, revenueCents: true, refundCents: true, costCents: true, profitCents: true },
|
||||
}),
|
||||
]);
|
||||
return { items, total, page, pageSize, dimensionType };
|
||||
const summary = {
|
||||
...volumeSummary(aggregate._sum),
|
||||
revenueCents: Number(aggregate._sum.revenueCents ?? 0),
|
||||
refundCents: Number(aggregate._sum.refundCents ?? 0),
|
||||
costCents: Number(aggregate._sum.costCents ?? 0),
|
||||
profitCents: Number(aggregate._sum.profitCents ?? 0),
|
||||
// 利润率必须用全量筛选结果的合计利润/合计收入重新计算,不能对每日百分比求和或简单平均。
|
||||
profitRateBps: ratioBps(Number(aggregate._sum.profitCents ?? 0), Number(aggregate._sum.revenueCents ?? 0)),
|
||||
};
|
||||
return { items, total, page, pageSize, dimensionType, summary };
|
||||
}
|
||||
|
||||
async listQuality(query: ReportListQuery) {
|
||||
const { page, pageSize, skip } = pagination(query);
|
||||
const { dimensionType, where } = qualityWhere(query);
|
||||
const [items, total] = 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.count({ where }),
|
||||
this.prisma.dailyQualityReport.aggregate({ where, _sum: reportVolumeSumSelection }),
|
||||
]);
|
||||
return { items, total, page, pageSize, dimensionType };
|
||||
const summary = {
|
||||
...volumeSummary(aggregate._sum),
|
||||
// 成功率按全量筛选结果的成功量/发送量重新计算,避免分页和分组大小导致失真。
|
||||
successRateBps: ratioBps(Number(aggregate._sum.successUnits ?? 0), Number(aggregate._sum.sentUnits ?? 0)),
|
||||
};
|
||||
return { items, total, page, pageSize, dimensionType, summary };
|
||||
}
|
||||
|
||||
async exportReconciliation(query: ReportListQuery) {
|
||||
@@ -516,6 +536,28 @@ function pagination(query: ReportListQuery) {
|
||||
return { page, pageSize, skip: (page - 1) * pageSize };
|
||||
}
|
||||
|
||||
const reportVolumeSumSelection = {
|
||||
submittedUnits: true,
|
||||
sentUnits: true,
|
||||
unknownUnits: true,
|
||||
successUnits: true,
|
||||
failedUnits: true,
|
||||
} as const;
|
||||
|
||||
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),
|
||||
unknownUnits: Number(sum.unknownUnits ?? 0),
|
||||
successUnits: Number(sum.successUnits ?? 0),
|
||||
failedUnits: Number(sum.failedUnits ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function ratioBps(numerator: number, denominator: number) {
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user