218 lines
9.2 KiB
TypeScript
218 lines
9.2 KiB
TypeScript
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<Array<{ total: number }>>(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 },
|
|
);
|
|
}
|
|
}
|