583 lines
30 KiB
TypeScript
583 lines
30 KiB
TypeScript
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
|
import { Queue } from 'bullmq';
|
|
import IORedis from 'ioredis';
|
|
import { Prisma } from '@prisma/client';
|
|
import { randomUUID } from 'crypto';
|
|
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
|
|
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, normalizeChannelCarriers, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
|
|
|
|
|
|
/** R5 channel domain service composed behind ChannelsService. */
|
|
export class ChannelReportingService {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
listReportFields(channelId?: string) {
|
|
return this.prisma.channelReportField.findMany({
|
|
where: channelId ? { channelId } : undefined,
|
|
include: { drainageField: true },
|
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
|
});
|
|
}
|
|
|
|
async createReportField(data: CreateReportFieldDto) {
|
|
if (!data.drainageFieldId) throw new BadRequestException('drainageFieldId is required');
|
|
const field = await this.prisma.drainageField.findUnique({ where: { id: data.drainageFieldId } });
|
|
if (!field || field.status !== 'active') {
|
|
throw new BadRequestException('报备字段库字段不存在或已停用');
|
|
}
|
|
const reportType = normalizeReportType(data.reportType);
|
|
return this.prisma.channelReportField.create({
|
|
data: {
|
|
channelId: data.channelId,
|
|
drainageFieldId: field.id,
|
|
reportType,
|
|
code: field.code,
|
|
name: field.name,
|
|
exportName: data.exportName?.trim() || field.name,
|
|
fieldType: field.fieldType,
|
|
required: data.required ?? field.required,
|
|
description: data.description ?? field.description,
|
|
sortOrder: data.sortOrder ?? 100,
|
|
columnWidth: normalizeSpreadsheetSize(data.columnWidth, 18, 6, 80),
|
|
imageWidth: normalizeSpreadsheetSize(data.imageWidth, 120, 24, 600),
|
|
imageHeight: normalizeSpreadsheetSize(data.imageHeight, 80, 24, 600),
|
|
defaultValue: data.defaultValue,
|
|
transform: data.transform,
|
|
status: data.status ?? 'active',
|
|
},
|
|
});
|
|
}
|
|
|
|
async replaceReportFields(channelId: string, reportType: 'signature' | 'drainage', data: ReplaceReportFieldsDto) {
|
|
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
|
|
if (!channel) throw new NotFoundException('Channel not found');
|
|
const ids = data.fields.map((field) => field.drainageFieldId);
|
|
if (new Set(ids).size !== ids.length) throw new BadRequestException('同一通道报备类型不能重复配置字段');
|
|
const libraryFields = await this.prisma.drainageField.findMany({ where: { id: { in: ids }, status: 'active' } });
|
|
if (libraryFields.length !== ids.length) throw new BadRequestException('报备字段库字段不存在或已停用');
|
|
const fieldById = new Map(libraryFields.map((field) => [field.id, field]));
|
|
return this.prisma.$transaction(async (tx) => {
|
|
const oppositeType = reportType === 'signature' ? 'drainage' : 'signature';
|
|
const [legacyBoth, oppositeFields] = await Promise.all([
|
|
tx.channelReportField.findMany({ where: { channelId, reportType: 'both' } }),
|
|
tx.channelReportField.findMany({ where: { channelId, reportType: oppositeType } }),
|
|
]);
|
|
const oppositeCodes = new Set(oppositeFields.map((field) => field.code));
|
|
await tx.channelReportField.deleteMany({ where: { channelId, reportType: { in: [reportType, 'both'] } } });
|
|
for (const legacy of legacyBoth) {
|
|
if (oppositeCodes.has(legacy.code)) continue;
|
|
const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...legacyData } = legacy;
|
|
await tx.channelReportField.create({ data: { ...legacyData, reportType: oppositeType } });
|
|
}
|
|
for (const [index, configured] of data.fields.entries()) {
|
|
const field = fieldById.get(configured.drainageFieldId)!;
|
|
await tx.channelReportField.create({
|
|
data: {
|
|
channelId,
|
|
drainageFieldId: field.id,
|
|
reportType,
|
|
code: field.code,
|
|
name: field.name,
|
|
exportName: configured.exportName?.trim() || field.name,
|
|
fieldType: field.fieldType,
|
|
required: configured.required ?? field.required,
|
|
description: configured.description ?? field.description,
|
|
sortOrder: configured.sortOrder ?? (index + 1) * 10,
|
|
columnWidth: normalizeSpreadsheetSize(configured.columnWidth, 18, 6, 80),
|
|
imageWidth: normalizeSpreadsheetSize(configured.imageWidth, 120, 24, 600),
|
|
imageHeight: normalizeSpreadsheetSize(configured.imageHeight, 80, 24, 600),
|
|
defaultValue: configured.defaultValue,
|
|
transform: configured.transform,
|
|
status: configured.status ?? 'active',
|
|
},
|
|
});
|
|
}
|
|
return tx.channelReportField.findMany({ where: { channelId, reportType }, include: { drainageField: true }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] });
|
|
});
|
|
}
|
|
|
|
listReportMaterials(signatureId?: string, channelId?: string) {
|
|
return this.prisma.signatureReportMaterial.findMany({
|
|
where: {
|
|
signatureId,
|
|
channelId,
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
}
|
|
|
|
upsertReportMaterial(data: CreateReportMaterialDto) {
|
|
return this.prisma.signatureReportMaterial.upsert({
|
|
where: {
|
|
signatureId_channelId_fieldCode: {
|
|
signatureId: data.signatureId,
|
|
channelId: data.channelId,
|
|
fieldCode: data.fieldCode,
|
|
},
|
|
},
|
|
update: {
|
|
fieldValue: data.fieldValue,
|
|
fileObjectId: data.fileObjectId,
|
|
},
|
|
create: {
|
|
signatureId: data.signatureId,
|
|
channelId: data.channelId,
|
|
fieldCode: data.fieldCode,
|
|
fieldValue: data.fieldValue,
|
|
fileObjectId: data.fileObjectId,
|
|
},
|
|
});
|
|
}
|
|
|
|
async listReportTasks(tenantId?: string, status?: string, channelId?: string, reportType?: string) {
|
|
const tasks = await this.prisma.channelSignatureReportTask.findMany({
|
|
where: {
|
|
tenantId,
|
|
status,
|
|
channelId,
|
|
reportType,
|
|
signature: { auditStatus: { not: 'deleted' } },
|
|
},
|
|
include: {
|
|
signature: { include: { tenant: true, application: true } },
|
|
channel: true,
|
|
drainageInfo: true,
|
|
exportItems: {
|
|
include: { exportFile: true, batchItem: { include: { batch: true } } },
|
|
orderBy: { id: 'desc' },
|
|
take: 1,
|
|
},
|
|
records: { orderBy: { createdAt: 'desc' }, take: 20 },
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
if (tasks.length === 0) {
|
|
return tasks;
|
|
}
|
|
|
|
const channelIds = [...new Set(tasks.map((task) => task.channelId))];
|
|
const signatureIds = [...new Set(tasks.map((task) => task.signatureId))];
|
|
const day = currentShanghaiDayRange();
|
|
const rows = await this.prisma.$queryRaw<ChannelReportDeliveryRow[]>(Prisma.sql`
|
|
WITH base AS (
|
|
SELECT
|
|
submit."channelId" AS channel_id,
|
|
message."signatureId" AS signature_id,
|
|
message."drainageInfoId" AS drainage_info_id,
|
|
submit."submitStatus" AS submit_status,
|
|
COALESCE(submit."submittedAt", submit."createdAt") AS attempted_at,
|
|
CASE
|
|
WHEN segment_summary.segment_count > 0
|
|
AND segment_summary.delivered_count = segment_summary.segment_count
|
|
THEN segment_summary.completed_at
|
|
WHEN segment_summary.segment_count = 0 THEN delivered_receipt.delivered_at
|
|
END AS successful_at,
|
|
CASE
|
|
WHEN submit."submitStatus" <> 'accepted' THEN 'submit_failed'
|
|
WHEN segment_summary.segment_count > 0 AND segment_summary.failure_count > 0 THEN 'failure'
|
|
WHEN segment_summary.segment_count > 0
|
|
AND segment_summary.delivered_count = segment_summary.segment_count THEN 'success'
|
|
WHEN segment_summary.segment_count = 0 AND failed_receipt.failed_at IS NOT NULL THEN 'failure'
|
|
WHEN segment_summary.segment_count = 0 AND delivered_receipt.delivered_at IS NOT NULL THEN 'success'
|
|
ELSE 'unknown'
|
|
END AS delivery_status
|
|
FROM "SmsSubmitRecord" submit
|
|
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
|
|
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 delivered_at
|
|
FROM "SmsReceiptRecord" receipt
|
|
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
|
AND receipt."channelId" = submit."channelId"
|
|
AND receipt."receiptStatus" = 'delivered'
|
|
) delivered_receipt ON TRUE
|
|
LEFT JOIN LATERAL (
|
|
SELECT MIN(receipt."deliveredAt") AS failed_at
|
|
FROM "SmsReceiptRecord" receipt
|
|
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
|
AND receipt."channelId" = submit."channelId"
|
|
AND receipt."receiptStatus" = 'undelivered'
|
|
) failed_receipt ON TRUE
|
|
WHERE submit."submitStatus" IN ('accepted', 'rejected', 'timeout')
|
|
AND submit."channelId" IN (${Prisma.join(channelIds)})
|
|
AND message."signatureId" IN (${Prisma.join(signatureIds)})
|
|
)
|
|
SELECT
|
|
channel_id AS "channelId",
|
|
signature_id AS "signatureId",
|
|
drainage_info_id AS "drainageInfoId",
|
|
COUNT(*) FILTER (
|
|
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
|
|
)::integer AS total,
|
|
COUNT(*) FILTER (
|
|
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
|
|
AND submit_status = 'accepted'
|
|
)::integer AS "acceptedCount",
|
|
COUNT(*) FILTER (
|
|
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
|
|
AND delivery_status = 'submit_failed'
|
|
)::integer AS "submitFailureCount",
|
|
COUNT(*) FILTER (
|
|
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
|
|
AND delivery_status = 'success'
|
|
)::integer AS "successCount",
|
|
COUNT(*) FILTER (
|
|
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
|
|
AND delivery_status = 'unknown'
|
|
)::integer AS "unknownCount",
|
|
COUNT(*) FILTER (
|
|
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
|
|
AND delivery_status = 'failure'
|
|
)::integer AS "failureCount",
|
|
MAX(successful_at) FILTER (WHERE delivery_status = 'success') AS "lastSuccessfulSentAt"
|
|
FROM base
|
|
GROUP BY channel_id, signature_id, drainage_info_id
|
|
`);
|
|
|
|
return tasks.map((task) => {
|
|
const taskRows = rows.filter((row) => (
|
|
row.channelId === task.channelId
|
|
&& row.signatureId === task.signatureId
|
|
&& ((task.reportType ?? 'signature') === 'signature' || row.drainageInfoId === task.drainageItemId)
|
|
));
|
|
const deliveryStats = summarizeChannelReportDelivery(taskRows);
|
|
return {
|
|
...task,
|
|
deliveryStats,
|
|
lastSuccessfulSentAt: latestDate(taskRows.map((row) => row.lastSuccessfulSentAt)),
|
|
};
|
|
});
|
|
}
|
|
|
|
async listReportTasksPage(query: {
|
|
tenantId?: string;
|
|
status?: string;
|
|
channelId?: string;
|
|
reportType?: string;
|
|
keyword?: string;
|
|
createdAtFrom?: string;
|
|
createdAtTo?: string;
|
|
page?: number;
|
|
pageSize?: number;
|
|
}) {
|
|
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
|
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
|
const keyword = query.keyword?.trim();
|
|
const where: Prisma.ChannelSignatureReportTaskWhereInput = {
|
|
tenantId: query.tenantId,
|
|
status: query.status,
|
|
channelId: query.channelId,
|
|
reportType: query.reportType,
|
|
signature: { auditStatus: { not: 'deleted' } },
|
|
createdAt: query.createdAtFrom || query.createdAtTo ? {
|
|
gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined,
|
|
lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined,
|
|
} : undefined,
|
|
OR: keyword ? [
|
|
{ id: { contains: keyword } },
|
|
{ channel: { name: { contains: keyword } } },
|
|
{ signature: { name: { contains: keyword } } },
|
|
{ signature: { tenant: { name: { contains: keyword } } } },
|
|
{ signature: { application: { name: { contains: keyword } } } },
|
|
{ drainageInfo: { siteName: { contains: keyword } } },
|
|
{ drainageInfo: { url: { contains: keyword } } },
|
|
] : undefined,
|
|
};
|
|
const [items, total] = await Promise.all([
|
|
this.prisma.channelSignatureReportTask.findMany({
|
|
where,
|
|
include: {
|
|
signature: { include: { tenant: true, application: true } },
|
|
channel: true,
|
|
drainageInfo: true,
|
|
exportItems: {
|
|
include: { exportFile: true, batchItem: { include: { batch: true } } },
|
|
orderBy: { id: 'desc' },
|
|
take: 1,
|
|
},
|
|
records: { orderBy: { createdAt: 'desc' }, take: 20 },
|
|
},
|
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
|
skip: (page - 1) * pageSize,
|
|
take: pageSize,
|
|
}),
|
|
this.prisma.channelSignatureReportTask.count({ where }),
|
|
]);
|
|
return { items, total, page, pageSize };
|
|
}
|
|
|
|
async createReportTask(data: CreateReportTaskDto) {
|
|
const reportType = data.reportType ?? 'signature';
|
|
if (reportType === 'drainage' && !data.drainageItemId) throw new BadRequestException('drainageItemId is required');
|
|
if (reportType === 'drainage') {
|
|
const drainageInfo = await this.prisma.smsDrainageInfo.findUnique({ where: { id: data.drainageItemId! } });
|
|
if (!drainageInfo || drainageInfo.signatureId !== data.signatureId) throw new NotFoundException('Drainage info not found');
|
|
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备');
|
|
throw new BadRequestException('引流信息通道报备任务由运营审核通过后按应用路由自动生成');
|
|
}
|
|
const channel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } });
|
|
if (!channel) throw new NotFoundException('Channel not found');
|
|
if (!data.carrier) throw new BadRequestException('签名报备任务必须指定运营商');
|
|
const carrier = normalizeBusinessCarrier(data.carrier);
|
|
if (!normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)) {
|
|
throw new BadRequestException('报备运营商不在通道支持范围内');
|
|
}
|
|
const existing = await this.prisma.channelSignatureReportTask.findFirst({
|
|
where: { signatureId: data.signatureId, channelId: data.channelId, carrier, reportType: 'signature', drainageItemId: null },
|
|
});
|
|
if (existing) throw new BadRequestException('该签名在当前通道和运营商下已存在报备任务');
|
|
const task = await this.prisma.channelSignatureReportTask.create({
|
|
data: {
|
|
tenantId: data.tenantId,
|
|
signatureId: data.signatureId,
|
|
channelId: data.channelId,
|
|
carrier,
|
|
approvalScope: 'carrier_specific',
|
|
reportType,
|
|
drainageItemId: undefined,
|
|
createdById: data.createdById,
|
|
status: 'pending',
|
|
},
|
|
});
|
|
await this.recordReportTask(task.id, task.channelId, 'create', undefined, 'pending');
|
|
return task;
|
|
}
|
|
|
|
async changeReportTaskStatuses(data: ChangeReportTaskStatusesDto) {
|
|
if (!data.items.length) throw new BadRequestException('items is required');
|
|
const allowed = new Set(['pending', 'waiting_material', 'reporting', 'approved', 'failed', 'rejected', 'abandoned']);
|
|
for (const item of data.items) {
|
|
if (!allowed.has(item.status)) throw new BadRequestException('unsupported report task status');
|
|
}
|
|
const sourceEntry = data.sourceEntry ?? 'report_task';
|
|
if (!['enterprise_signature', 'report_task', 'channel_report'].includes(sourceEntry)) {
|
|
throw new BadRequestException('unsupported report task source entry');
|
|
}
|
|
return this.prisma.$transaction(async (tx) => {
|
|
const signatureIds = [...new Set(data.items.filter((item) => (item.reportType ?? 'signature') === 'signature').map((item) => item.signatureId))];
|
|
const drainageResults: Array<{ signatureId: string; reportType: 'drainage'; drainageItemId: string; channelId: string; status: string }> = [];
|
|
for (const item of data.items) {
|
|
const reportType = item.reportType ?? 'signature';
|
|
if (reportType === 'drainage' && !item.drainageItemId) throw new BadRequestException('drainageItemId is required');
|
|
const signature = await tx.smsSignature.findUnique({ where: { id: item.signatureId } });
|
|
const channel = await tx.smsChannel.findUnique({ where: { id: item.channelId } });
|
|
if (!signature || !channel) throw new NotFoundException('Signature or channel not found');
|
|
if (reportType === 'drainage') {
|
|
const drainageInfo = await tx.smsDrainageInfo.findUnique({ where: { id: item.drainageItemId! } });
|
|
if (!drainageInfo || drainageInfo.signatureId !== item.signatureId) throw new NotFoundException('Drainage info not found');
|
|
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能修改通道报备状态');
|
|
}
|
|
const carrier = reportType === 'signature' && item.carrier ? normalizeBusinessCarrier(item.carrier) : null;
|
|
if (carrier && !normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)) {
|
|
throw new BadRequestException('报备运营商不在通道支持范围内');
|
|
}
|
|
const existing = await tx.channelSignatureReportTask.findFirst({ where: {
|
|
signatureId: item.signatureId,
|
|
channelId: item.channelId,
|
|
reportType,
|
|
drainageItemId: reportType === 'drainage' ? item.drainageItemId : null,
|
|
carrier: reportType === 'signature' ? carrier : null,
|
|
} });
|
|
if (reportType === 'drainage' && !existing) throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核');
|
|
if (reportType === 'signature' && !carrier && !existing) throw new BadRequestException('签名报备状态必须指定运营商');
|
|
const approvedAt = item.status === 'approved'
|
|
? existing?.status === 'approved' ? existing.approvedAt ?? new Date() : new Date()
|
|
: null;
|
|
const task = existing
|
|
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: item.status, reason: data.reason, ...(reportType === 'signature' ? { approvedAt } : {}) } })
|
|
: await tx.channelSignatureReportTask.create({ data: { tenantId: signature.tenantId, signatureId: item.signatureId, channelId: item.channelId, carrier, approvalScope: 'carrier_specific', approvedAt, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : undefined, status: item.status, reason: data.reason, createdById: data.operatorId } });
|
|
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: item.channelId, action: 'manual_status_change', statusBefore: existing?.status, statusAfter: item.status, reason: data.reason, operatorId: data.operatorId, sourceEntry } });
|
|
if (reportType === 'drainage') drainageResults.push({ signatureId: item.signatureId, reportType, drainageItemId: item.drainageItemId!, channelId: item.channelId, status: item.status });
|
|
}
|
|
const summaries = [];
|
|
for (const signatureId of signatureIds) summaries.push(await this.recomputeSignatureReportSummary(tx, signatureId));
|
|
return [...summaries, ...drainageResults];
|
|
});
|
|
}
|
|
|
|
async recomputeSignatureReportSummary(tx: Prisma.TransactionClient, signatureId: string) {
|
|
const signature = await tx.smsSignature.findUnique({ where: { id: signatureId } });
|
|
if (!signature) throw new NotFoundException('Signature not found');
|
|
const routes = signature.applicationId ? await tx.channelRouteRule.findMany({
|
|
where: { applicationId: signature.applicationId, status: 'active' },
|
|
include: { group: { include: { items: { include: { channel: true } } } } },
|
|
}) : [];
|
|
const configuredChannels = routes.flatMap((route) => route.group.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted');
|
|
const tasks = await tx.channelSignatureReportTask.findMany({ where: { signatureId, reportType: 'signature' }, include: { channel: true } });
|
|
const channels = configuredChannels.length ? configuredChannels : tasks.map((task) => task.channel);
|
|
const uniqueChannels = [...new Map(channels.map((channel) => [channel.id, channel])).values()];
|
|
const carrierReportSummary = Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
|
|
const targets = uniqueChannels.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier));
|
|
const statuses = targets.map((channel) => {
|
|
const task = tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier)
|
|
?? tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === null && candidate.approvalScope === 'legacy_channel');
|
|
return task?.status ?? 'pending';
|
|
});
|
|
return [carrier, summarizeReportStatuses(statuses)];
|
|
}));
|
|
const allStatuses = ['mobile', 'unicom', 'telecom'].flatMap((carrier) => {
|
|
const targets = uniqueChannels.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier));
|
|
return targets.map((channel) => tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier)?.status
|
|
?? tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === null && candidate.approvalScope === 'legacy_channel')?.status
|
|
?? 'pending');
|
|
});
|
|
const reportStatus = summarizeReportStatuses(allStatuses).status;
|
|
await tx.smsSignature.update({ where: { id: signatureId }, data: { reportStatus } });
|
|
return { signatureId, reportStatus, carrierReportSummary };
|
|
}
|
|
|
|
async createReportExport(taskId: string, data: CreateReportExportDto) {
|
|
const task = await this.getReportTaskOrThrow(taskId);
|
|
const exported = await this.prisma.reportExportFile.create({
|
|
data: {
|
|
taskId,
|
|
fileObjectId: data.fileObjectId,
|
|
fileName: data.fileName,
|
|
rowCount: data.rowCount ?? 0,
|
|
},
|
|
});
|
|
await this.updateReportTaskStatus(taskId, task.channelId, task.status, 'exporting', 'export');
|
|
return exported;
|
|
}
|
|
|
|
async importReportReceipt(taskId: string, data: CreateReceiptImportDto) {
|
|
const task = await this.getReportTaskOrThrow(taskId);
|
|
const parsed = data.fileContent ? parseReceiptContent(data.fileContent, data.delimiter) : undefined;
|
|
const rowCount = data.rowCount ?? parsed?.rowCount ?? 0;
|
|
const successCount = data.successCount ?? parsed?.successCount ?? 0;
|
|
const failedCount = data.failedCount ?? parsed?.failedCount ?? 0;
|
|
const statusAfter = data.statusAfter ?? deriveReceiptStatus(rowCount, successCount, failedCount);
|
|
const imported = await this.prisma.reportReceiptImport.create({
|
|
data: {
|
|
taskId,
|
|
fileObjectId: data.fileObjectId,
|
|
fileName: data.fileName,
|
|
rowCount,
|
|
successCount,
|
|
failedCount,
|
|
status: 'imported',
|
|
result: (data.result ?? parsed?.result) as Prisma.InputJsonValue | undefined,
|
|
},
|
|
});
|
|
await this.updateReportTaskStatus(taskId, task.channelId, task.status, statusAfter, 'receipt_import', data.reason);
|
|
if ((task.reportType ?? 'signature') === 'signature') {
|
|
await this.recomputeSignatureReportSummary(this.prisma as unknown as Prisma.TransactionClient, task.signatureId);
|
|
}
|
|
return imported;
|
|
}
|
|
|
|
listReportRecords(taskId?: string, channelId?: string) {
|
|
return this.prisma.channelSignatureReportRecord.findMany({
|
|
where: { taskId, channelId },
|
|
include: { channel: true, task: { include: { signature: true, drainageInfo: true } } },
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
}
|
|
|
|
async listReportRecordsPage(query: {
|
|
taskId?: string;
|
|
channelId?: string;
|
|
keyword?: string;
|
|
reportType?: string;
|
|
createdAtFrom?: string;
|
|
createdAtTo?: string;
|
|
page?: number;
|
|
pageSize?: number;
|
|
}) {
|
|
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
|
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
|
const keyword = query.keyword?.trim();
|
|
const where: Prisma.ChannelSignatureReportRecordWhereInput = {
|
|
taskId: query.taskId,
|
|
channelId: query.channelId,
|
|
task: query.reportType ? { reportType: query.reportType } : undefined,
|
|
createdAt: query.createdAtFrom || query.createdAtTo ? {
|
|
gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined,
|
|
lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined,
|
|
} : undefined,
|
|
OR: keyword ? [
|
|
{ taskId: { contains: keyword } },
|
|
{ action: { contains: keyword } },
|
|
{ reason: { contains: keyword } },
|
|
{ channel: { name: { contains: keyword } } },
|
|
{ task: { signature: { name: { contains: keyword } } } },
|
|
{ task: { drainageInfo: { siteName: { contains: keyword } } } },
|
|
{ task: { drainageInfo: { url: { contains: keyword } } } },
|
|
] : undefined,
|
|
};
|
|
const [items, total] = await Promise.all([
|
|
this.prisma.channelSignatureReportRecord.findMany({
|
|
where,
|
|
include: { channel: true, task: { include: { signature: true, drainageInfo: true } } },
|
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
|
skip: (page - 1) * pageSize,
|
|
take: pageSize,
|
|
}),
|
|
this.prisma.channelSignatureReportRecord.count({ where }),
|
|
]);
|
|
return { items, total, page, pageSize };
|
|
}
|
|
|
|
async getReportTaskOrThrow(taskId: string) {
|
|
const task = await this.prisma.channelSignatureReportTask.findUnique({ where: { id: taskId }, include: { drainageInfo: true } });
|
|
if (!task) {
|
|
throw new NotFoundException('Report task not found');
|
|
}
|
|
if (task.reportType === 'drainage' && task.drainageInfo?.auditStatus !== 'approved') {
|
|
throw new BadRequestException('引流信息审核通过后才能处理通道报备任务');
|
|
}
|
|
return task;
|
|
}
|
|
|
|
async updateReportTaskStatus(
|
|
taskId: string,
|
|
channelId: string,
|
|
statusBefore: string,
|
|
statusAfter: string,
|
|
action: string,
|
|
reason?: string,
|
|
) {
|
|
await this.prisma.channelSignatureReportTask.update({
|
|
where: { id: taskId },
|
|
data: {
|
|
status: statusAfter,
|
|
reason,
|
|
...((await this.prisma.channelSignatureReportTask.findUnique({ where: { id: taskId }, select: { reportType: true, status: true, approvedAt: true } }))?.reportType === 'signature'
|
|
? { approvedAt: statusAfter === 'approved' ? statusBefore === 'approved' ? undefined : new Date() : null }
|
|
: {}),
|
|
},
|
|
});
|
|
await this.recordReportTask(taskId, channelId, action, statusBefore, statusAfter, reason);
|
|
}
|
|
|
|
recordReportTask(
|
|
taskId: string,
|
|
channelId: string,
|
|
action: string,
|
|
statusBefore: string | undefined,
|
|
statusAfter: string,
|
|
reason?: string,
|
|
) {
|
|
return this.prisma.channelSignatureReportRecord.create({
|
|
data: {
|
|
taskId,
|
|
channelId,
|
|
action,
|
|
statusBefore,
|
|
statusAfter,
|
|
reason,
|
|
},
|
|
});
|
|
}
|
|
}
|