fix: align daily operations statistics
This commit is contained in:
@@ -222,11 +222,38 @@ describe('OperationsService', () => {
|
||||
lte: new Date('2026-07-02T23:59:59.999+08:00'),
|
||||
},
|
||||
},
|
||||
include: { tenant: true, application: true, channel: true, submitRecords: true, receiptRecords: true },
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
channel: true,
|
||||
submitRecords: true,
|
||||
receiptRecords: true,
|
||||
downstreamDeliveries: {
|
||||
where: { deliveryType: 'receipt' },
|
||||
select: { id: true, deliveryType: true, status: true, deliveredAt: true, lastError: true },
|
||||
},
|
||||
},
|
||||
orderBy: { queuedAt: 'desc' },
|
||||
});
|
||||
});
|
||||
|
||||
it('separates upstream submit failures from post-acceptance delivery failures', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
await service.listMessages({ status: 'submit_failed' });
|
||||
expect(prisma.smsMessageRecord.findMany).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }],
|
||||
}),
|
||||
}));
|
||||
|
||||
await service.listMessages({ status: 'failed' });
|
||||
expect(prisma.smsMessageRecord.findMany).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ status: 'failed', submitStatus: 'accepted' }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns uplink messages with tenant and channel display data', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new OperationsService(prisma as never);
|
||||
@@ -399,6 +426,9 @@ describe('OperationsService', () => {
|
||||
channelId: 'channel-1',
|
||||
channelName: '通道一',
|
||||
total: 5,
|
||||
acceptedCount: 4,
|
||||
submitFailureCount: 1,
|
||||
submitFailureRate: 20,
|
||||
successCount: 3,
|
||||
unknownCount: 1,
|
||||
failureCount: 1,
|
||||
@@ -420,15 +450,48 @@ describe('OperationsService', () => {
|
||||
failureCount: 1,
|
||||
successRate: 60,
|
||||
averageArrivalMs: 1200,
|
||||
}])
|
||||
.mockResolvedValueOnce([{
|
||||
total: 5,
|
||||
successCount: 3,
|
||||
unknownCount: 1,
|
||||
failureCount: 1,
|
||||
successRate: 60,
|
||||
}])
|
||||
.mockResolvedValueOnce([{
|
||||
applicationId: 'app-1',
|
||||
applicationName: '通知应用',
|
||||
tenantId: 'tenant-1',
|
||||
tenantName: '租户A',
|
||||
total: 5,
|
||||
successCount: 3,
|
||||
unknownCount: 1,
|
||||
failureCount: 1,
|
||||
successRate: 60,
|
||||
}]);
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
await expect(service.sendQuality('2026-07-24')).resolves.toEqual({
|
||||
date: '2026-07-24',
|
||||
channels: [expect.objectContaining({ channelId: 'channel-1', total: 5, successRate: 60 })],
|
||||
summary: {
|
||||
total: 5,
|
||||
successCount: 3,
|
||||
unknownCount: 1,
|
||||
failureCount: 1,
|
||||
successRate: 60,
|
||||
},
|
||||
channels: [expect.objectContaining({
|
||||
channelId: 'channel-1',
|
||||
total: 5,
|
||||
acceptedCount: 4,
|
||||
submitFailureCount: 1,
|
||||
submitFailureRate: 20,
|
||||
successRate: 60,
|
||||
})],
|
||||
signatures: [expect.objectContaining({ signatureId: 'signature-1', signatureName: '【测试签名】', hasDrainage: false })],
|
||||
applications: [expect.objectContaining({ applicationId: 'app-1', applicationName: '通知应用', tenantName: '租户A', total: 5 })],
|
||||
});
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('rejects invalid send quality dates', async () => {
|
||||
|
||||
@@ -98,7 +98,17 @@ export class OperationsService {
|
||||
listMessages(query: MessageQuery) {
|
||||
return this.prisma.smsMessageRecord.findMany({
|
||||
where: messageWhere(query),
|
||||
include: { tenant: true, application: true, channel: true, submitRecords: true, receiptRecords: true },
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
channel: true,
|
||||
submitRecords: true,
|
||||
receiptRecords: true,
|
||||
downstreamDeliveries: {
|
||||
where: { deliveryType: 'receipt' },
|
||||
select: { id: true, deliveryType: true, status: true, deliveredAt: true, lastError: true },
|
||||
},
|
||||
},
|
||||
orderBy: { queuedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
@@ -356,11 +366,14 @@ export class OperationsService {
|
||||
|
||||
async sendQuality(date?: string) {
|
||||
const day = qualityBusinessDay(date);
|
||||
const [channels, signatures] = await Promise.all([
|
||||
const [channels, signatures, summaryRows, applications] = await Promise.all([
|
||||
this.prisma.$queryRaw<Array<{
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
total: number;
|
||||
acceptedCount: number;
|
||||
submitFailureCount: number;
|
||||
submitFailureRate: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
@@ -373,14 +386,32 @@ export class OperationsService {
|
||||
SELECT
|
||||
submit."channelId" AS channel_id,
|
||||
channel.name AS channel_name,
|
||||
submit."submitStatus" AS submit_status,
|
||||
receipt."deliveredAt" AS delivered_at,
|
||||
failed_receipt."failedAt" AS failed_at,
|
||||
COALESCE(segment_summary.segment_count, 0) AS segment_count,
|
||||
COALESCE(segment_summary.delivered_count, 0) AS segment_delivered_count,
|
||||
COALESCE(segment_summary.failure_count, 0) AS segment_failure_count,
|
||||
CASE
|
||||
WHEN receipt."deliveredAt" >= COALESCE(submit."submittedAt", submit."createdAt")
|
||||
WHEN segment_summary.segment_count > 0
|
||||
AND segment_summary.delivered_count = segment_summary.segment_count
|
||||
AND segment_summary.completed_at >= COALESCE(submit."submittedAt", submit."createdAt")
|
||||
THEN EXTRACT(EPOCH FROM (segment_summary.completed_at - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000
|
||||
WHEN segment_summary.segment_count = 0
|
||||
AND 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
|
||||
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COUNT(*)::integer AS segment_count,
|
||||
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count,
|
||||
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count,
|
||||
MAX(segment."deliveredAt") FILTER (WHERE segment."receiptStatus" = 'delivered') AS completed_at
|
||||
FROM "SmsMessageSegmentAudit" segment
|
||||
WHERE segment."submitRecordId" = submit.id
|
||||
) segment_summary ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MIN(receipt."deliveredAt") AS "deliveredAt"
|
||||
FROM "SmsReceiptRecord" receipt
|
||||
@@ -395,22 +426,37 @@ export class OperationsService {
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'undelivered'
|
||||
) failed_receipt ON TRUE
|
||||
WHERE submit."submitStatus" = 'accepted'
|
||||
WHERE submit."submitStatus" IN ('accepted', 'rejected', 'timeout')
|
||||
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt}
|
||||
AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt}
|
||||
), classified AS (
|
||||
SELECT
|
||||
*,
|
||||
CASE
|
||||
WHEN submit_status <> 'accepted' THEN 'submit_failed'
|
||||
WHEN segment_count > 0 AND segment_failure_count > 0 THEN 'failure'
|
||||
WHEN segment_count > 0 AND segment_delivered_count = segment_count THEN 'success'
|
||||
WHEN segment_count = 0 AND failed_at IS NOT NULL THEN 'failure'
|
||||
WHEN segment_count = 0 AND delivered_at IS NOT NULL THEN 'success'
|
||||
ELSE 'unknown'
|
||||
END AS delivery_status
|
||||
FROM base
|
||||
)
|
||||
SELECT
|
||||
channel_id AS "channelId",
|
||||
MAX(channel_name) AS "channelName",
|
||||
COUNT(*)::integer AS total,
|
||||
COUNT(*) FILTER (WHERE delivered_at IS NOT NULL)::integer AS "successCount",
|
||||
COUNT(*) FILTER (WHERE delivered_at IS NULL AND failed_at IS NULL)::integer AS "unknownCount",
|
||||
COUNT(*) FILTER (WHERE delivered_at IS NULL AND failed_at IS NOT NULL)::integer AS "failureCount",
|
||||
CASE WHEN COUNT(*) = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivered_at IS NOT NULL) * 100.0 / COUNT(*), 1)::double precision END AS "successRate",
|
||||
CASE WHEN COUNT(*) = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivered_at IS NULL AND failed_at IS NULL) * 100.0 / COUNT(*), 1)::double precision END AS "unknownRate",
|
||||
CASE WHEN COUNT(*) = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivered_at IS NULL AND failed_at IS NOT NULL) * 100.0 / COUNT(*), 1)::double precision END AS "failureRate",
|
||||
ROUND(AVG(arrival_ms) FILTER (WHERE arrival_ms IS NOT NULL))::integer AS "averageArrivalMs"
|
||||
FROM base
|
||||
COUNT(*) FILTER (WHERE submit_status = 'accepted')::integer AS "acceptedCount",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'submit_failed')::integer AS "submitFailureCount",
|
||||
CASE WHEN COUNT(*) = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'submit_failed') * 100.0 / COUNT(*), 1)::double precision END AS "submitFailureRate",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'success')::integer AS "successCount",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'unknown')::integer AS "unknownCount",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'failure')::integer AS "failureCount",
|
||||
CASE WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'success') * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), 1)::double precision END AS "successRate",
|
||||
CASE WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'unknown') * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), 1)::double precision END AS "unknownRate",
|
||||
CASE WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'failure') * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), 1)::double precision END AS "failureRate",
|
||||
ROUND(AVG(arrival_ms) FILTER (WHERE delivery_status = 'success' AND arrival_ms IS NOT NULL))::integer AS "averageArrivalMs"
|
||||
FROM classified
|
||||
GROUP BY channel_id
|
||||
ORDER BY COUNT(*) DESC, channel_id
|
||||
`),
|
||||
@@ -479,8 +525,101 @@ export class OperationsService {
|
||||
GROUP BY signature.id, signature.name, tenant.id, tenant.name, base.has_drainage
|
||||
ORDER BY "successCount" DESC, total DESC, signature.name
|
||||
`),
|
||||
this.prisma.$queryRaw<Array<{
|
||||
total: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
}>>(Prisma.sql`
|
||||
WITH base AS (
|
||||
SELECT message.status, message."receiptStatus" AS receipt_status
|
||||
FROM "SmsMessageRecord" message
|
||||
WHERE message."queuedAt" >= ${day.startAt}
|
||||
AND message."queuedAt" < ${day.endAt}
|
||||
AND COALESCE(message.status, '') <> 'rejected'
|
||||
)
|
||||
SELECT
|
||||
COUNT(*)::integer AS total,
|
||||
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||
AND NOT (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||
)::integer AS "unknownCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||
AND (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||
)::integer AS "failureCount",
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE ROUND(
|
||||
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')
|
||||
* 100.0 / COUNT(*),
|
||||
1
|
||||
)::double precision
|
||||
END AS "successRate"
|
||||
FROM base
|
||||
`),
|
||||
this.prisma.$queryRaw<Array<{
|
||||
applicationId: string;
|
||||
applicationName: string;
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
total: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
}>>(Prisma.sql`
|
||||
WITH base AS (
|
||||
SELECT
|
||||
message."applicationId" AS application_id,
|
||||
message.status,
|
||||
message."receiptStatus" AS receipt_status
|
||||
FROM "SmsMessageRecord" message
|
||||
WHERE message."applicationId" IS NOT NULL
|
||||
AND message."queuedAt" >= ${day.startAt}
|
||||
AND message."queuedAt" < ${day.endAt}
|
||||
AND COALESCE(message.status, '') <> 'rejected'
|
||||
)
|
||||
SELECT
|
||||
application.id AS "applicationId",
|
||||
application.name AS "applicationName",
|
||||
tenant.id AS "tenantId",
|
||||
tenant.name AS "tenantName",
|
||||
COUNT(*)::integer AS total,
|
||||
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||
AND NOT (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||
)::integer AS "unknownCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||
AND (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||
)::integer AS "failureCount",
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE ROUND(
|
||||
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')
|
||||
* 100.0 / COUNT(*),
|
||||
1
|
||||
)::double precision
|
||||
END AS "successRate"
|
||||
FROM base
|
||||
JOIN "SmsApplication" application ON application.id = base.application_id
|
||||
JOIN "Tenant" tenant ON tenant.id = application."tenantId"
|
||||
GROUP BY application.id, application.name, tenant.id, tenant.name
|
||||
ORDER BY total DESC, application.name
|
||||
`),
|
||||
]);
|
||||
return { date: day.key, channels, signatures };
|
||||
const summary = summaryRows[0] ?? {
|
||||
total: 0,
|
||||
successCount: 0,
|
||||
unknownCount: 0,
|
||||
failureCount: 0,
|
||||
successRate: 0,
|
||||
};
|
||||
return { date: day.key, summary, channels, signatures, applications };
|
||||
}
|
||||
|
||||
async auditLogs(query: { tenantId?: string; userId?: string; page?: number; pageSize?: number }) {
|
||||
@@ -1057,6 +1196,13 @@ export class OperationsService {
|
||||
}
|
||||
|
||||
function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
|
||||
const statusWhere = query.status === 'submit_failed'
|
||||
? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] }
|
||||
: query.status === 'failed'
|
||||
? { status: 'failed', submitStatus: 'accepted' }
|
||||
: query.status
|
||||
? { status: query.status }
|
||||
: {};
|
||||
return {
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
@@ -1064,7 +1210,7 @@ function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
|
||||
batchTaskId: query.taskId,
|
||||
messageId: query.messageId,
|
||||
phoneNumber: query.phoneNumber,
|
||||
status: query.status,
|
||||
...statusWhere,
|
||||
...(query.contentKeyword ? { content: { contains: query.contentKeyword, mode: 'insensitive' } } : {}),
|
||||
...(query.channelKeyword ? { channel: { name: { contains: query.channelKeyword, mode: 'insensitive' } } } : {}),
|
||||
...(query.queuedAtFrom || query.queuedAtTo ? {
|
||||
|
||||
Reference in New Issue
Block a user