feat: improve channel resilience and operations
This commit is contained in:
@@ -82,11 +82,12 @@ describe('ReportsService', () => {
|
||||
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',
|
||||
sentUnits: 12, successUnits: 10, generatedAt: new Date('2026-07-15T00:00:00Z'),
|
||||
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' }],
|
||||
|
||||
@@ -74,19 +74,19 @@ 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.sentUnits, item.successUnits, item.failedUnits, formatCsvDate(item.generatedAt)]));
|
||||
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.sentUnits, item.successUnits, item.failedUnits, moneyUnitsToFixedYuan(item.revenueCents), moneyUnitsToFixedYuan(item.refundCents), moneyUnitsToFixedYuan(item.costCents), moneyUnitsToFixedYuan(item.profitCents), (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)]));
|
||||
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.refundCents), 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.sentUnits, item.successUnits, item.failedUnits, (item.successRateBps / 100).toFixed(2), item.avgArrivalMs ?? '', formatCsvDate(item.generatedAt)]));
|
||||
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()) {
|
||||
@@ -119,7 +119,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
await tx.$executeRaw(Prisma.sql`
|
||||
INSERT INTO "DailyReconciliationReport" (
|
||||
"id", "reportDate", "tenantId", "tenantName", "applicationId", "applicationName",
|
||||
"sentUnits", "successUnits", "failedUnits", "generatedAt", "updatedAt"
|
||||
"submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "generatedAt", "updatedAt"
|
||||
)
|
||||
SELECT
|
||||
CONCAT('recon-', MD5(${day.key} || ':' || tenant.id || ':' || application.id)),
|
||||
@@ -129,8 +129,15 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
application.id,
|
||||
application.name,
|
||||
COALESCE(SUM(message."billingUnits"), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(message.status, '') <> 'rejected' THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(message.status, '') <> 'rejected'
|
||||
AND NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false))
|
||||
AND NOT (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false))
|
||||
THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN message.status IN ('submit_failed', 'failed', 'timeout') OR message."receiptStatus" = 'undelivered' THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false))
|
||||
AND (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false))
|
||||
THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
FROM "SmsMessageRecord" message
|
||||
@@ -157,7 +164,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
INSERT INTO "DailyProfitReport" (
|
||||
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
|
||||
"tenantId", "tenantName", "applicationId", "channelId",
|
||||
"sentUnits", "successUnits", "failedUnits", "revenueCents", "refundCents", "costCents", "profitCents", "profitRateBps",
|
||||
"submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "revenueCents", "refundCents", "costCents", "profitCents", "profitRateBps",
|
||||
"generatedAt", "updatedAt"
|
||||
)
|
||||
SELECT
|
||||
@@ -171,8 +178,15 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
application.id,
|
||||
NULL,
|
||||
COALESCE(SUM(message."billingUnits"), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(message.status, '') <> 'rejected' THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(message.status, '') <> 'rejected'
|
||||
AND NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false))
|
||||
AND NOT (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false))
|
||||
THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN message.status IN ('submit_failed', 'failed', 'timeout') OR message."receiptStatus" = 'undelivered' THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false))
|
||||
AND (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false))
|
||||
THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(billing.revenue), 0)::bigint,
|
||||
COALESCE(SUM(billing.refund), 0)::bigint,
|
||||
COALESCE(SUM(costs.cost), 0)::bigint,
|
||||
@@ -202,7 +216,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
INSERT INTO "DailyProfitReport" (
|
||||
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
|
||||
"tenantId", "tenantName", "applicationId", "channelId",
|
||||
"sentUnits", "successUnits", "failedUnits", "revenueCents", "refundCents", "costCents", "profitCents", "profitRateBps",
|
||||
"submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "revenueCents", "refundCents", "costCents", "profitCents", "profitRateBps",
|
||||
"generatedAt", "updatedAt"
|
||||
)
|
||||
SELECT
|
||||
@@ -216,18 +230,30 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
NULL,
|
||||
channel.id,
|
||||
COALESCE(SUM(message."billingUnits"), 0)::integer,
|
||||
COALESCE(SUM(message."billingUnits"), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN NOT EXISTS (
|
||||
SELECT 1 FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" IN ('delivered', 'undelivered')
|
||||
) AND submit."submitStatus" NOT IN ('rejected', 'timeout') THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN EXISTS (
|
||||
SELECT 1 FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'delivered'
|
||||
) THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN EXISTS (
|
||||
COALESCE(SUM(CASE WHEN NOT EXISTS (
|
||||
SELECT 1 FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'delivered'
|
||||
) AND (EXISTS (
|
||||
SELECT 1 FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'undelivered'
|
||||
) OR submit."submitStatus" IN ('rejected', 'timeout') THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
) OR submit."submitStatus" IN ('rejected', 'timeout')) THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0)::bigint,
|
||||
COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.refund ELSE 0 END), 0)::bigint,
|
||||
COALESCE(SUM(submit."costAmountCents"), 0)::bigint,
|
||||
@@ -280,8 +306,15 @@ function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'applicat
|
||||
tenant.name AS tenant_name,
|
||||
application.id AS application_id,
|
||||
message."billingUnits" AS billing_units,
|
||||
CASE WHEN COALESCE(message.status, '') <> 'rejected' THEN message."billingUnits" ELSE 0 END AS sent_units,
|
||||
CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN message."billingUnits" ELSE 0 END AS success_units,
|
||||
CASE WHEN message.status IN ('submit_failed', 'failed', 'timeout') OR message."receiptStatus" = 'undelivered' THEN message."billingUnits" ELSE 0 END AS failed_units,
|
||||
CASE WHEN NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false))
|
||||
AND (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false))
|
||||
THEN message."billingUnits" ELSE 0 END AS failed_units,
|
||||
CASE WHEN COALESCE(message.status, '') <> 'rejected'
|
||||
AND NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false))
|
||||
AND NOT (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false))
|
||||
THEN message."billingUnits" ELSE 0 END AS unknown_units,
|
||||
CASE WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
|
||||
AND message."submittedAt" IS NOT NULL AND message."deliveredAt" >= message."submittedAt"
|
||||
THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000 END AS arrival_ms
|
||||
@@ -299,7 +332,7 @@ function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'applicat
|
||||
INSERT INTO "DailyQualityReport" (
|
||||
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
|
||||
"tenantId", "tenantName", "applicationId", "channelId", "signatureId", "drainageInfoId",
|
||||
"sentUnits", "successUnits", "failedUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt"
|
||||
"submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt"
|
||||
)
|
||||
SELECT
|
||||
CONCAT('quality-', ${dimensionTypeSql}, '-', MD5(${day.key} || ':' || base.dimension_id)),
|
||||
@@ -314,9 +347,11 @@ function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'applicat
|
||||
CASE WHEN ${dimensionTypeSql} = 'signature' AND base.dimension_id NOT LIKE 'unmatched:%' THEN base.dimension_id ELSE NULL END,
|
||||
CASE WHEN ${dimensionTypeSql} = 'drainage' AND base.dimension_id NOT LIKE 'unmatched:%' THEN base.dimension_id ELSE NULL END,
|
||||
SUM(base.billing_units)::integer,
|
||||
SUM(base.sent_units)::integer,
|
||||
SUM(base.unknown_units)::integer,
|
||||
SUM(base.success_units)::integer,
|
||||
SUM(base.failed_units)::integer,
|
||||
CASE WHEN SUM(base.billing_units) = 0 THEN 0 ELSE ROUND(SUM(base.success_units) * 10000.0 / SUM(base.billing_units))::integer END,
|
||||
CASE WHEN SUM(base.sent_units) = 0 THEN 0 ELSE ROUND(SUM(base.success_units) * 10000.0 / SUM(base.sent_units))::integer END,
|
||||
ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL AND base.arrival_ms <= thresholds.p95_ms))::integer,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
@@ -334,7 +369,8 @@ function qualityByChannelSql(day: BusinessDay) {
|
||||
channel.name AS dimension_name,
|
||||
message."billingUnits" AS billing_units,
|
||||
CASE WHEN receipt."deliveredAt" IS NOT NULL THEN message."billingUnits" ELSE 0 END AS success_units,
|
||||
CASE WHEN failed_receipt."failedAt" IS NOT NULL THEN message."billingUnits" ELSE 0 END AS failed_units,
|
||||
CASE WHEN receipt."deliveredAt" IS NULL AND failed_receipt."failedAt" IS NOT NULL THEN message."billingUnits" ELSE 0 END AS failed_units,
|
||||
CASE WHEN receipt."deliveredAt" IS NULL AND failed_receipt."failedAt" IS NULL THEN message."billingUnits" ELSE 0 END AS unknown_units,
|
||||
CASE WHEN receipt."deliveredAt" >= COALESCE(submit."submittedAt", submit."createdAt")
|
||||
THEN EXTRACT(EPOCH FROM (receipt."deliveredAt" - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000 END AS arrival_ms
|
||||
FROM "SmsSubmitRecord" submit
|
||||
@@ -364,7 +400,7 @@ function qualityByChannelSql(day: BusinessDay) {
|
||||
INSERT INTO "DailyQualityReport" (
|
||||
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
|
||||
"tenantId", "tenantName", "applicationId", "channelId", "signatureId", "drainageInfoId",
|
||||
"sentUnits", "successUnits", "failedUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt"
|
||||
"submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt"
|
||||
)
|
||||
SELECT
|
||||
CONCAT('quality-channel-', MD5(${day.key} || ':' || base.dimension_id)),
|
||||
@@ -374,6 +410,8 @@ function qualityByChannelSql(day: BusinessDay) {
|
||||
MAX(base.dimension_name),
|
||||
NULL, NULL, NULL, base.dimension_id, NULL, NULL,
|
||||
SUM(base.billing_units)::integer,
|
||||
SUM(base.billing_units)::integer,
|
||||
SUM(base.unknown_units)::integer,
|
||||
SUM(base.success_units)::integer,
|
||||
SUM(base.failed_units)::integer,
|
||||
CASE WHEN SUM(base.billing_units) = 0 THEN 0 ELSE ROUND(SUM(base.success_units) * 10000.0 / SUM(base.billing_units))::integer END,
|
||||
|
||||
Reference in New Issue
Block a user