feat: enhance operations dashboard and reporting controls

This commit is contained in:
hectorzhao
2026-09-04 16:26:22 +08:00
parent bc18c7ff12
commit 48d0363920
23 changed files with 544 additions and 49 deletions
+19 -2
View File
@@ -511,6 +511,11 @@ describe('OperationsService', () => {
todaySpendCents: 24000n,
balanceCents: 1000000n,
creditCents: 50000n,
}]).mockResolvedValueOnce([{
segmentCount: 20n,
deliveredSegmentCount: 18n,
billedCents: 360n,
costCents: 216n,
}]).mockResolvedValueOnce([
{ hour: 9, submittedCount: 12n, successCount: 10n },
{ hour: 10, submittedCount: 5n, successCount: 4n },
@@ -541,7 +546,15 @@ describe('OperationsService', () => {
templates: 1,
total: 5,
},
today: expect.objectContaining({ returnedCents: 10 }),
today: expect.objectContaining({
returnedCents: 10,
segmentCount: 20,
deliveredSegmentCount: 18,
arrivalRate: 90,
billedCents: 360,
profitCents: 144,
profitRate: 40,
}),
hourlySendTrend: expect.arrayContaining([
{ hour: 9, label: '09:00', submittedCount: 12, successCount: 10 },
{ hour: 10, label: '10:00', submittedCount: 5, successCount: 4 },
@@ -592,7 +605,11 @@ describe('OperationsService', () => {
updatedAt: { gte: expect.any(Date) },
},
});
const hourlyTrendQuery = prisma.$queryRaw.mock.calls[1]?.[0] as { sql?: string };
const dashboardMetricsQuery = prisma.$queryRaw.mock.calls[1]?.[0] as { sql?: string };
expect(dashboardMetricsQuery.sql).toContain('FROM "SmsMessageSegmentAudit" segment');
expect(dashboardMetricsQuery.sql).toContain('message."billingUnits" * message."unitPrice"');
expect(dashboardMetricsQuery.sql).toContain('submit."costUnitPrice"');
const hourlyTrendQuery = prisma.$queryRaw.mock.calls[2]?.[0] as { sql?: string };
expect(hourlyTrendQuery.sql).toContain(
`HOUR FROM (message."queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai'`,
);
@@ -32,6 +32,7 @@ async dashboard(query: { tenantId?: string }) {
recentTasks,
recentRecharges,
enterpriseSpendRows,
todayBusinessMetricsRows,
downstreamPendingCount,
downstreamFailedCount,
downstreamDeliveredCount,
@@ -117,6 +118,67 @@ async dashboard(query: { tenantId?: string }) {
GROUP BY tenant.id, tenant.name, account."balanceCents", account."creditCents"
ORDER BY "todaySpendCents" DESC, tenant.name ASC
`),
this.prisma.$queryRaw<Array<{
segmentCount: bigint;
deliveredSegmentCount: bigint;
billedCents: bigint;
costCents: bigint;
}>>(Prisma.sql`
WITH segment_metrics AS (
SELECT
COUNT(segment.id)::bigint AS "segmentCount",
COUNT(segment.id) FILTER (WHERE segment."receiptStatus" = 'delivered')::bigint AS "deliveredSegmentCount"
FROM "SmsMessageSegmentAudit" segment
JOIN "SmsMessageRecord" message ON message.id = segment."messageRecordId"
WHERE message."queuedAt" >= ${businessDay.startAt}
AND message."queuedAt" < ${businessDay.endAt}
AND (${query.tenantId ?? null}::text IS NULL OR message."tenantId" = ${query.tenantId ?? null})
),
message_revenue AS (
SELECT COALESCE(SUM(
CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
THEN message."billingUnits" * message."unitPrice" ELSE 0 END
), 0)::bigint AS "billedCents"
FROM "SmsMessageRecord" message
WHERE message."queuedAt" >= ${businessDay.startAt}
AND message."queuedAt" < ${businessDay.endAt}
AND (${query.tenantId ?? null}::text IS NULL OR message."tenantId" = ${query.tenantId ?? null})
),
submit_cost AS (
SELECT 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 AS "costCents"
FROM "SmsSubmitRecord" submit
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
LEFT JOIN LATERAL (
SELECT
COUNT(*)::integer AS audit_count,
COUNT(*) FILTER (WHERE audit."receiptStatus" = 'delivered')::integer AS delivered_count
FROM "SmsMessageSegmentAudit" audit
WHERE audit."submitRecordId" = submit.id
) segment_receipts ON TRUE
LEFT JOIN LATERAL (
SELECT EXISTS (
SELECT 1 FROM "SmsReceiptRecord" receipt
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'delivered'
) AS delivered
) legacy_receipt ON TRUE
WHERE submit."submitStatus" = 'accepted'
AND message."queuedAt" >= ${businessDay.startAt}
AND message."queuedAt" < ${businessDay.endAt}
AND (${query.tenantId ?? null}::text IS NULL OR message."tenantId" = ${query.tenantId ?? null})
)
SELECT
segment_metrics."segmentCount",
segment_metrics."deliveredSegmentCount",
message_revenue."billedCents",
submit_cost."costCents"
FROM segment_metrics, message_revenue, submit_cost
`),
this.prisma.cmppDownstreamDelivery.count({
where: { tenantId: query.tenantId, status: 'pending' },
}),
@@ -239,6 +301,12 @@ async dashboard(query: { tenantId?: string }) {
`),
]);
const todayTotals = summarizeMessageGroups(todayMessageGroups);
const todayBusinessMetrics = todayBusinessMetricsRows[0];
const segmentCount = Number(todayBusinessMetrics?.segmentCount ?? 0);
const deliveredSegmentCount = Number(todayBusinessMetrics?.deliveredSegmentCount ?? 0);
const billedCents = moneyToNumber(todayBusinessMetrics?.billedCents);
const costCents = moneyToNumber(todayBusinessMetrics?.costCents);
const profitCents = billedCents - costCents;
const hourlyRowsByHour = new Map(hourlySendRows.map((row) => [Number(row.hour), row]));
// Always return all 24 Shanghai-time buckets so the line chart does not imply missing hours are missing data.
const hourlySendTrend = Array.from({ length: 24 }, (_, hour) => {
@@ -279,6 +347,12 @@ async dashboard(query: { tenantId?: string }) {
spendCents: todayTotals.amountCents,
returnedCents: moneyToNumber(transactionAggregate._sum.amountCents),
billingUnits: todayTotals.billingUnits,
segmentCount,
deliveredSegmentCount,
arrivalRate: segmentCount > 0 ? Number(((deliveredSegmentCount / segmentCount) * 100).toFixed(1)) : 0,
billedCents,
profitCents,
profitRate: billedCents > 0 ? Number(((profitCents / billedCents) * 100).toFixed(1)) : 0,
},
uplinkCount,
billing: billingAggregate,