import { BadRequestException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { addDays, analyticsDate, analyticsPage, databaseDay, todayKey } from './analytics-date'; export interface ActivityQuery { date?: string; dimensionType: string; page?: number; pageSize?: number; tenantName?: string; applicationName?: string; signatureName?: string; channelName?: string; } export class SignatureAnalyticsRead { constructor(private readonly db: PrismaService) {} async metadata(date: string, now = new Date()) { const record = await this.db.signatureAnalyticsDay.findUnique({ where: { businessDate: databaseDay(date) } }); const run = await this.db.signatureAnalyticsRun.findUnique({ where: { scope_businessDate: { scope: 'daily', businessDate: databaseDay(date) } }, }); const reportState = run?.state === 'running' ? 'refreshing' : ['retry_wait', 'failed'].includes(run?.state ?? '') ? 'failed' : (record?.state ?? 'missing'); return { dataSource: 'report' as const, businessDate: date, serverBusinessDate: todayKey(now), reportState, frozen: date <= addDays(todayKey(now), -4), generatedAt: record?.generatedAt ?? null, sourceAsOf: record?.sourceAsOf ?? null, generationId: record?.publishedGenerationId ?? null, schemaVersion: record?.schemaVersion ?? 1, provenance: record?.provenance ?? null, }; } async quality(query: { date?: string; keyword?: string; page?: number; pageSize?: number }) { const date = analyticsDate(query.date); const { page, pageSize } = analyticsPage(query.page, query.pageSize); return this.db.$transaction( async (tx) => { const meta = await new SignatureAnalyticsRead(tx as PrismaService).metadata(date); if (!meta.generationId) return { date, items: [], total: 0, page, pageSize, ...meta }; const where: Prisma.SignatureQualityDailyWhereInput = { generationId: meta.generationId, ...(query.keyword?.trim() ? { OR: ['signatureName', 'tenantName', 'applicationNames'].map((field) => ({ [field]: { contains: query.keyword!.trim(), mode: 'insensitive' }, })), } : {}), }; const total = await tx.signatureQualityDaily.count({ where }); const rows = await tx.signatureQualityDaily.findMany({ where, orderBy: [{ total: 'desc' }, { signatureName: 'asc' }, { signatureId: 'asc' }], skip: (page - 1) * pageSize, take: pageSize, }); return { date, items: rows.map((r) => r.payload), total, page, pageSize, ...meta }; }, { isolationLevel: 'RepeatableRead', timeout: 15_000 }, ); } async unreported(query: { date?: string; keyword?: string; page?: number; pageSize?: number }) { const date = analyticsDate(query.date); const { page, pageSize } = analyticsPage(query.page, query.pageSize); return this.db.$transaction( async (tx) => { const meta = await new SignatureAnalyticsRead(tx as PrismaService).metadata(date); if (!meta.generationId) return { date, items: [], total: 0, page, pageSize, ...meta }; const where: Prisma.UnreportedSignatureDailyWhereInput = { generationId: meta.generationId, ...(query.keyword?.trim() ? { OR: ['signatureName', 'tenantName', 'applicationName'].map((field) => ({ [field]: { contains: query.keyword!.trim(), mode: 'insensitive' }, })), } : {}), }; const total = await tx.unreportedSignatureDaily.count({ where }); const rows = await tx.unreportedSignatureDaily.findMany({ where, orderBy: [{ messageCount: 'desc' }, { dimensionKey: 'asc' }], skip: (page - 1) * pageSize, take: pageSize, }); return { date, items: rows.map((r) => ({ ...r, signatureId: r.dimensionKey })), total, page, pageSize, ...meta, }; }, { isolationLevel: 'RepeatableRead', timeout: 15_000 }, ); } async activity(query: ActivityQuery) { const date = analyticsDate(query.date); if (!['enterprise', 'channel'].includes(query.dimensionType)) throw new BadRequestException('必须指定企业或通道维度'); const { page, pageSize } = analyticsPage(query.page, query.pageSize); const dates = Array.from({ length: 30 }, (_, i) => addDays(date, -i - 1)); return this.db.$transaction( async (tx) => { await tx.$executeRawUnsafe("SET LOCAL statement_timeout='12s'"); const manifests = await tx.signatureAnalyticsDay.findMany({ where: { businessDate: { in: dates.map(databaseDay) } }, }); const runs = await tx.signatureAnalyticsRun.findMany({ where: { scope: 'daily', businessDate: { in: dates.map(databaseDay) } }, }); const runStates = new Map(runs.map((r) => [r.businessDate.toISOString().slice(0, 10), r.state])); const byDate = new Map(manifests.map((r) => [r.businessDate.toISOString().slice(0, 10), r])); const coverage = dates.map((d) => { const r = byDate.get(d); return { date: d, generationId: r?.publishedGenerationId ?? null, reportState: runStates.get(d) === 'running' ? 'refreshing' : ['failed', 'retry_wait'].includes(runStates.get(d) ?? '') ? 'failed' : (r?.state ?? 'missing'), generatedAt: r?.generatedAt ?? null, sourceAsOf: r?.sourceAsOf ?? null, frozen: d <= addDays(todayKey(), -4), }; }); const generations = coverage.flatMap((c) => (c.generationId ? [c.generationId] : [])); if (!generations.length) return { date, items: [], dimensions: [], total: 0, page, pageSize, coverage, complete: false }; const filters = [ ['tenantName', query.tenantName], ['applicationName', query.applicationName], ['signatureName', query.signatureName], ['channelName', query.channelName], ] .filter(([, value]) => value?.trim()) .map(([field, value]) => Prisma.sql`AND r.${Prisma.raw(`"${field}"`)} ILIKE ${`%${value!.trim()}%`}`); const dimensions = await tx.$queryRaw< Array<{ dimensionKey: string; dimensionType: string; signatureId: string; channelKey: string; carrier: string; signatureName: string; channelName: string; tenantName: string; applicationName: string; approvedAt: Date | null; total: number; rowCount: number; }> >(Prisma.sql` WITH selected AS ( SELECT * FROM "SignatureActivityDaily" WHERE "generationId" IN (${Prisma.join(generations)}) AND "dimensionType"=${query.dimensionType} ), latest AS ( SELECT DISTINCT ON ("dimensionKey") * FROM selected ORDER BY "dimensionKey","businessDate" DESC ), sums AS (SELECT "dimensionKey",SUM("acceptedBusinessCount")::integer AS total FROM selected GROUP BY 1) SELECT r.*,s.total,COUNT(*) OVER()::integer AS "rowCount" FROM latest r JOIN sums s USING("dimensionKey") WHERE TRUE ${filters.length ? Prisma.join(filters, ' ') : Prisma.empty} ORDER BY s.total DESC,r."signatureName",r."dimensionKey" LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}`); // An empty out-of-range page still reports the filtered total. const emptyPageCount = dimensions.length ? [] : await tx.$queryRaw>(Prisma.sql` WITH latest AS ( SELECT DISTINCT ON ("dimensionKey") * FROM "SignatureActivityDaily" WHERE "generationId" IN (${Prisma.join(generations)}) AND "dimensionType"=${query.dimensionType} ORDER BY "dimensionKey","businessDate" DESC ) SELECT COUNT(*)::integer AS total FROM latest r WHERE TRUE ${filters.length ? Prisma.join(filters, ' ') : Prisma.empty}`); const items = dimensions.length ? await tx.signatureActivityDaily.findMany({ where: { generationId: { in: generations }, dimensionType: query.dimensionType, dimensionKey: { in: dimensions.map((d) => d.dimensionKey) }, }, }) : []; return { date, dimensions: dimensions.map((d) => ({ ...d, channelId: d.channelKey || null })), items: items.map((r) => ({ ...r, id: `${r.generationId}:${r.dimensionKey}`, channelId: r.channelKey || null, activityDate: r.businessDate.toISOString().slice(0, 10), status: r.applicability, })), total: dimensions[0]?.rowCount ?? emptyPageCount[0]?.total ?? 0, page, pageSize, coverage, complete: coverage.every((c) => Boolean(c.generationId)), }; }, { isolationLevel: 'RepeatableRead', timeout: 15_000 }, ); } }