feat: 优化签名热力图与金额展示
This commit is contained in:
@@ -24,9 +24,9 @@ describe('ReportsService', () => {
|
||||
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.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), refundCents: BigInt(100), 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 } });
|
||||
@@ -57,6 +57,19 @@ describe('ReportsService', () => {
|
||||
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',
|
||||
@@ -74,9 +87,12 @@ describe('ReportsService', () => {
|
||||
});
|
||||
|
||||
it('keeps application and channel profit filters separate', async () => {
|
||||
await expect(service.listProfit({ dimensionType: 'channel', tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1' })).resolves.toEqual(expect.objectContaining({
|
||||
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',
|
||||
@@ -91,15 +107,29 @@ describe('ReportsService', () => {
|
||||
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 },
|
||||
_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, refundCents: 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 () => {
|
||||
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',
|
||||
|
||||
@@ -56,18 +56,19 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
async listProfit(query: ReportListQuery) {
|
||||
const { page, pageSize, skip } = pagination(query);
|
||||
const { dimensionType, where } = profitWhere(query);
|
||||
const [items, 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.count({ where }),
|
||||
this.prisma.dailyProfitReport.aggregate({
|
||||
where,
|
||||
_sum: { ...reportVolumeSumSelection, revenueCents: true, refundCents: true, costCents: true, profitCents: true },
|
||||
_sum: { ...reportVolumeSumSelection, revenueCents: true, costCents: true, profitCents: true },
|
||||
}),
|
||||
]);
|
||||
// refundCents 暂留在持久化模型中兼容既有数据和回滚,但利润报表接口不再暴露返还口径。
|
||||
const items = storedItems.map(({ refundCents: _refundCents, ...item }) => item);
|
||||
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),
|
||||
// 利润率必须用全量筛选结果的合计利润/合计收入重新计算,不能对每日百分比求和或简单平均。
|
||||
@@ -100,7 +101,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
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.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.costCents), moneyUnitsToFixedYuan(item.profitCents), (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)]));
|
||||
}
|
||||
|
||||
async exportQuality(query: ReportListQuery) {
|
||||
@@ -169,13 +170,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
`);
|
||||
|
||||
await tx.$executeRaw(Prisma.sql`
|
||||
WITH billing AS (
|
||||
SELECT "messageId",
|
||||
SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::bigint AS revenue,
|
||||
SUM(CASE WHEN "billingStatus" = 'refunded' THEN "amountCents" ELSE 0 END)::bigint AS refund
|
||||
FROM "SmsBillingRecord"
|
||||
GROUP BY "messageId"
|
||||
), costs AS (
|
||||
WITH costs AS (
|
||||
SELECT
|
||||
submit."messageRecordId",
|
||||
SUM(submit."costUnitPrice" * CASE
|
||||
@@ -230,18 +225,24 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
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,
|
||||
-- 收入按每条最终成功短信的计费条数和发送时客户价快照计算,不能依赖随后可能变为 refunded 的账单状态。
|
||||
COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
|
||||
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0)::bigint,
|
||||
0::bigint,
|
||||
COALESCE(SUM(costs.cost), 0)::bigint,
|
||||
(COALESCE(SUM(billing.revenue), 0) - COALESCE(SUM(costs.cost), 0))::bigint,
|
||||
CASE WHEN COALESCE(SUM(billing.revenue), 0) = 0 THEN 0
|
||||
ELSE ROUND((COALESCE(SUM(billing.revenue), 0) - COALESCE(SUM(costs.cost), 0)) * 10000.0 / SUM(billing.revenue))::integer END,
|
||||
(COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
|
||||
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) - COALESCE(SUM(costs.cost), 0))::bigint,
|
||||
CASE WHEN COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
|
||||
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) = 0 THEN 0
|
||||
ELSE ROUND((COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
|
||||
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) - COALESCE(SUM(costs.cost), 0)) * 10000.0 /
|
||||
SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
|
||||
THEN message."billingUnits" * message."unitPrice" ELSE 0 END))::integer END,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
FROM "SmsMessageRecord" message
|
||||
JOIN "Tenant" tenant ON tenant.id = message."tenantId"
|
||||
JOIN "SmsApplication" application ON application.id = message."applicationId"
|
||||
LEFT JOIN billing ON billing."messageId" = message."messageId"
|
||||
LEFT JOIN costs ON costs."messageRecordId" = message.id
|
||||
WHERE message."queuedAt" >= ${day.startAt}
|
||||
AND message."queuedAt" < ${day.endAt}
|
||||
@@ -249,13 +250,6 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
`);
|
||||
|
||||
await tx.$executeRaw(Prisma.sql`
|
||||
WITH billing AS (
|
||||
SELECT "messageId",
|
||||
SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::bigint AS revenue,
|
||||
SUM(CASE WHEN "billingStatus" = 'refunded' THEN "amountCents" ELSE 0 END)::bigint AS refund
|
||||
FROM "SmsBillingRecord"
|
||||
GROUP BY "messageId"
|
||||
)
|
||||
INSERT INTO "DailyProfitReport" (
|
||||
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
|
||||
"tenantId", "tenantName", "applicationId", "channelId",
|
||||
@@ -297,31 +291,41 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'undelivered'
|
||||
) 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(CASE WHEN message."submitId" = submit."submitId"
|
||||
AND (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
|
||||
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0)::bigint,
|
||||
0::bigint,
|
||||
COALESCE(SUM(submit."costUnitPrice" * CASE
|
||||
WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count
|
||||
WHEN legacy_receipt.delivered THEN message."billingUnits"
|
||||
ELSE 0
|
||||
END), 0)::bigint,
|
||||
(COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costUnitPrice" * CASE
|
||||
(COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId"
|
||||
AND (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
|
||||
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) - COALESCE(SUM(submit."costUnitPrice" * CASE
|
||||
WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count
|
||||
WHEN legacy_receipt.delivered THEN message."billingUnits"
|
||||
ELSE 0
|
||||
END), 0))::bigint,
|
||||
CASE WHEN COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) = 0 THEN 0
|
||||
ELSE ROUND((COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costUnitPrice" * CASE
|
||||
CASE WHEN COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId"
|
||||
AND (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
|
||||
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) = 0 THEN 0
|
||||
ELSE ROUND((COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId"
|
||||
AND (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
|
||||
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) - COALESCE(SUM(submit."costUnitPrice" * CASE
|
||||
WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count
|
||||
WHEN legacy_receipt.delivered THEN message."billingUnits"
|
||||
ELSE 0
|
||||
END), 0)) * 10000.0 /
|
||||
SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END))::integer END,
|
||||
SUM(CASE WHEN message."submitId" = submit."submitId"
|
||||
AND (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
|
||||
THEN message."billingUnits" * message."unitPrice" ELSE 0 END))::integer END,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
FROM "SmsSubmitRecord" submit
|
||||
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
|
||||
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
|
||||
LEFT JOIN billing ON billing."messageId" = message."messageId"
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COUNT(*)::integer AS audit_count,
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('SignatureRetirementService dimensions', () => {
|
||||
|
||||
it('returns enterprise application metadata for heatmap hover and search', async () => {
|
||||
const prisma = {
|
||||
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValue([{ id: 'detection-1', detectionDate: new Date('2026-08-10T00:00:00Z'), signatureId: 'signature-1', channelId: null, tenantId: 'tenant-1' }]) },
|
||||
smsSignature: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
smsChannel: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
tenant: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
@@ -76,6 +76,24 @@ describe('SignatureRetirementService dimensions', () => {
|
||||
expect.objectContaining({ dimensionType: 'enterprise', signatureName: '测试签名', applicationName: '测试应用' }),
|
||||
expect.objectContaining({ dimensionType: 'channel', channelName: '移动通道', applicationName: '测试应用' }),
|
||||
]));
|
||||
expect(result.items[0]).toEqual(expect.objectContaining({ activityDate: '2026-08-09' }));
|
||||
});
|
||||
|
||||
it('persists daily observing snapshots without opening alert cycles', async () => {
|
||||
const prisma = {
|
||||
signatureRetirementSuppression: { updateMany: jest.fn().mockResolvedValue({ count: 0 }), findUnique: jest.fn().mockResolvedValue(null) },
|
||||
signatureRetirementRule: { findMany: jest.fn().mockResolvedValue([rule('enterprise_global', ''), rule('channel_global', '')]) },
|
||||
channelSignatureReportTask: { findMany: jest.fn().mockResolvedValue([task('channel-1', '移动一号', 'mobile', '2026-08-09T00:00:00Z')]) },
|
||||
signatureRetirementDetection: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'detection-1' }) },
|
||||
signatureRetirementCycle: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
|
||||
$queryRaw: jest.fn().mockResolvedValue([{ submittedAttempts: 12, acceptedBusinessCount: 10, deliveredBusinessCount: 9 }]),
|
||||
};
|
||||
const observingService = new SignatureRetirementService(prisma as never);
|
||||
|
||||
await expect(observingService.runDetection('2026-08-10')).resolves.toEqual({ detectionDate: '2026-08-10', dimensions: 2, alerted: 0, healthy: 0, ineligible: 2 });
|
||||
expect(prisma.signatureRetirementDetection.create).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.signatureRetirementDetection.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: 'observing', acceptedBusinessCount: 10, cycleId: undefined, notificationTitle: null, notificationContent: null }) });
|
||||
expect(prisma.signatureRetirementCycle.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps the real unreported-signature aggregation to an independent page', async () => {
|
||||
|
||||
@@ -225,7 +225,7 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
|
||||
const endKey = assertDateKey(date || shanghaiDateKey());
|
||||
const startKey = addDays(endKey, -30);
|
||||
const detections = await this.prisma.signatureRetirementDetection.findMany({
|
||||
where: { detectionDate: { gte: databaseDate(startKey), lt: databaseDate(endKey) } },
|
||||
where: { detectionDate: { gt: databaseDate(startKey), lte: databaseDate(endKey) } },
|
||||
orderBy: [{ dimensionType: 'asc' }, { signatureId: 'asc' }, { channelKey: 'asc' }, { carrier: 'asc' }, { detectionDate: 'desc' }],
|
||||
});
|
||||
const [signatures, channels, tenants, approvedTasks] = await Promise.all([
|
||||
@@ -249,7 +249,8 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
|
||||
return {
|
||||
date: endKey,
|
||||
dimensions: [...dimensionMap.values()],
|
||||
items: detections.map((item) => ({ ...item, signatureName: signatures.find((entry) => entry.id === item.signatureId)?.name, channelName: item.channelId ? channels.find((entry) => entry.id === item.channelId)?.name : null, tenantName: tenants.find((entry) => entry.id === item.tenantId)?.name })),
|
||||
// 检测在次日04:00运行,因此检测日对应的活动自然日固定为T-1。
|
||||
items: detections.map((item) => ({ ...item, activityDate: addDays(shanghaiDateKey(item.detectionDate), -1), signatureName: signatures.find((entry) => entry.id === item.signatureId)?.name, channelName: item.channelId ? channels.find((entry) => entry.id === item.channelId)?.name : null, tenantName: tenants.find((entry) => entry.id === item.tenantId)?.name })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -357,13 +358,23 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
|
||||
const { windowDays, threshold } = carrierRule(dimension.rule, dimension.carrier);
|
||||
const windowStartKey = addDays(detectionKey, -windowDays);
|
||||
const windowStart = shanghaiStart(windowStartKey);
|
||||
if (dimension.approvedAt > windowStart) {
|
||||
const activityStart = shanghaiStart(addDays(detectionKey, -1));
|
||||
const activityEnd = shanghaiStart(detectionKey);
|
||||
const effectiveActivityStart = dimension.approvedAt > activityStart ? dimension.approvedAt : activityStart;
|
||||
if (effectiveActivityStart >= activityEnd) {
|
||||
ineligible += 1;
|
||||
continue;
|
||||
}
|
||||
const counts = await this.activityCounts(dimension, windowStart, shanghaiStart(detectionKey));
|
||||
const isAlert = counts.acceptedBusinessCount < threshold;
|
||||
await this.persistDetection(detectionKey, dimension, windowDays, threshold, counts, isAlert);
|
||||
const dailyCounts = await this.activityCounts(dimension, effectiveActivityStart, activityEnd);
|
||||
if (dimension.approvedAt > windowStart) {
|
||||
// 观察期只禁止预警,不能吞掉真实发送快照,否则热力图会错误显示无数据。
|
||||
await this.persistDetection(detectionKey, dimension, windowDays, threshold, dailyCounts, false, true);
|
||||
ineligible += 1;
|
||||
continue;
|
||||
}
|
||||
const windowCounts = await this.activityCounts(dimension, windowStart, activityEnd);
|
||||
const isAlert = windowCounts.acceptedBusinessCount < threshold;
|
||||
await this.persistDetection(detectionKey, dimension, windowDays, threshold, dailyCounts, isAlert, false, windowCounts);
|
||||
if (isAlert) alerted += 1;
|
||||
else healthy += 1;
|
||||
}
|
||||
@@ -482,7 +493,7 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
|
||||
return rows[0] ?? { submittedAttempts: 0, acceptedBusinessCount: 0, deliveredBusinessCount: 0 };
|
||||
}
|
||||
|
||||
private async persistDetection(dateKey: string, dimension: DetectionDimension, windowDays: number, threshold: number, counts: ActivityCounts, isAlert: boolean) {
|
||||
private async persistDetection(dateKey: string, dimension: DetectionDimension, windowDays: number, threshold: number, counts: ActivityCounts, isAlert: boolean, observing = false, alertCounts = counts) {
|
||||
const detectionDate = databaseDate(dateKey);
|
||||
const channelKey = dimension.channelId ?? '';
|
||||
const existingDetection = await this.prisma.signatureRetirementDetection.findUnique({
|
||||
@@ -494,7 +505,9 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
|
||||
const suppression = await this.prisma.signatureRetirementSuppression.findUnique({ where: { dimensionType_signatureId_channelKey_carrier: { dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelKey, carrier: dimension.carrier } } });
|
||||
const suppressed = Boolean(suppression?.active && (suppression.mode === 'permanent' || !suppression.muteUntil || suppression.muteUntil >= detectionDate));
|
||||
let cycle = await this.prisma.signatureRetirementCycle.findFirst({ where: { dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelKey, carrier: dimension.carrier, status: 'open' } });
|
||||
if (isAlert) {
|
||||
if (observing) {
|
||||
cycle = null;
|
||||
} else if (isAlert) {
|
||||
if (!cycle) {
|
||||
try {
|
||||
cycle = await this.prisma.signatureRetirementCycle.create({ data: { dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelId: dimension.channelId, channelKey, carrier: dimension.carrier, startedOn: detectionDate, lastDetectedOn: detectionDate } });
|
||||
@@ -510,10 +523,10 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
|
||||
cycle = null;
|
||||
}
|
||||
const notificationTitle = isAlert && cycle ? (dimension.dimensionType === 'enterprise' ? '企业签名清退预警' : '通道签名清退预警') : null;
|
||||
const notificationContent = isAlert && cycle ? renderMessage(dimension.rule.messageTemplate, dimension, windowDays, threshold, counts.acceptedBusinessCount) : null;
|
||||
const notificationContent = isAlert && cycle ? renderMessage(dimension.rule.messageTemplate, dimension, windowDays, threshold, alertCounts.acceptedBusinessCount) : null;
|
||||
try {
|
||||
await this.prisma.signatureRetirementDetection.create({
|
||||
data: { detectionDate, dimensionType: dimension.dimensionType, tenantId: dimension.tenantId, applicationId: dimension.applicationId, signatureId: dimension.signatureId, channelId: dimension.channelId, channelKey, carrier: dimension.carrier, windowDays, threshold, ...counts, approvedAt: dimension.approvedAt, ruleId: dimension.rule.id, ruleVersion: dimension.rule.version, status: isAlert ? 'alert' : 'healthy', cycleId: cycle?.id, suppressed, notificationTitle, notificationContent },
|
||||
data: { detectionDate, dimensionType: dimension.dimensionType, tenantId: dimension.tenantId, applicationId: dimension.applicationId, signatureId: dimension.signatureId, channelId: dimension.channelId, channelKey, carrier: dimension.carrier, windowDays, threshold, ...counts, approvedAt: dimension.approvedAt, ruleId: dimension.rule.id, ruleVersion: dimension.rule.version, status: observing ? 'observing' : isAlert ? 'alert' : 'healthy', cycleId: cycle?.id, suppressed, notificationTitle, notificationContent },
|
||||
});
|
||||
} catch (error) {
|
||||
// 两个检测实例可能同时越过前置查询;唯一快照已由另一实例生成时直接结束本维度。
|
||||
|
||||
Reference in New Issue
Block a user