feat: build reporting workbench workflow
This commit is contained in:
@@ -1,13 +1,99 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
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';
|
||||
|
||||
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 {
|
||||
@@ -94,7 +180,11 @@ export class ChannelReportingService {
|
||||
},
|
||||
});
|
||||
}
|
||||
return tx.channelReportField.findMany({ where: { channelId, reportType }, include: { drainageField: true }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] });
|
||||
return tx.channelReportField.findMany({
|
||||
where: { channelId, reportType },
|
||||
include: { drainageField: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -245,11 +335,12 @@ export class ChannelReportingService {
|
||||
`);
|
||||
|
||||
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 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,
|
||||
@@ -261,10 +352,65 @@ export class ChannelReportingService {
|
||||
|
||||
async listReportTasksPage(query: {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
status?: string;
|
||||
channelId?: string;
|
||||
reportType?: string;
|
||||
keyword?: string;
|
||||
carrier?: string;
|
||||
todaySendMin?: number;
|
||||
todaySendMax?: number;
|
||||
sort?: 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 from = query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined;
|
||||
const to = query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined;
|
||||
const all = await this.listReportTasks(query.tenantId, query.status, query.channelId, query.reportType);
|
||||
const filtered = all.filter((task) => {
|
||||
const createdAt = task.createdAt instanceof Date ? task.createdAt : new Date(task.createdAt);
|
||||
const total = (task as typeof task & { deliveryStats?: { total: number } }).deliveryStats?.total ?? 0;
|
||||
if (query.applicationId && task.signature.applicationId !== query.applicationId) return false;
|
||||
if (query.carrier && task.carrier !== query.carrier) return false;
|
||||
if (from && createdAt < from) return false;
|
||||
if (to && createdAt > to) return false;
|
||||
if (Number.isFinite(query.todaySendMin) && total < Number(query.todaySendMin)) return false;
|
||||
if (Number.isFinite(query.todaySendMax) && total > Number(query.todaySendMax)) return false;
|
||||
if (!keyword) return true;
|
||||
return [
|
||||
task.id,
|
||||
task.channel.name,
|
||||
task.signature.name,
|
||||
task.signature.tenant.name,
|
||||
task.signature.application?.name,
|
||||
task.drainageInfo?.siteName,
|
||||
task.drainageInfo?.url,
|
||||
].some((value) => String(value ?? '').includes(keyword));
|
||||
});
|
||||
filtered.sort((left, right) =>
|
||||
query.sort === 'todaySendDesc'
|
||||
? ((right as typeof right & { deliveryStats?: { total: number } }).deliveryStats?.total ?? 0) -
|
||||
((left as typeof left & { deliveryStats?: { total: number } }).deliveryStats?.total ?? 0) ||
|
||||
right.updatedAt.getTime() - left.updatedAt.getTime()
|
||||
: right.createdAt.getTime() - left.createdAt.getTime(),
|
||||
);
|
||||
return { items: filtered.slice((page - 1) * pageSize, page * pageSize), total: filtered.length, page, pageSize };
|
||||
}
|
||||
|
||||
async listReportDetailsPage(query: {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
signatureId?: string;
|
||||
channelId?: string;
|
||||
carrier?: string;
|
||||
status?: string;
|
||||
reportType?: string;
|
||||
keyword?: string;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
page?: number;
|
||||
@@ -272,48 +418,123 @@ export class ChannelReportingService {
|
||||
}) {
|
||||
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,
|
||||
const signatures = await this.prisma.smsSignature.findMany({
|
||||
where: {
|
||||
id: query.signatureId,
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
auditStatus: 'approved',
|
||||
},
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
drainageItems: { where: { auditStatus: 'approved' } },
|
||||
reportTasks: {
|
||||
include: {
|
||||
channel: true,
|
||||
drainageInfo: true,
|
||||
exportItems: {
|
||||
include: { exportFile: true, batchItem: { include: { batch: true } } },
|
||||
orderBy: { id: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
records: { orderBy: { createdAt: 'desc' }, take: 20 },
|
||||
},
|
||||
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 };
|
||||
},
|
||||
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
const applicationIds = [
|
||||
...new Set(signatures.map((item) => item.applicationId).filter((id): id is string => Boolean(id))),
|
||||
];
|
||||
const routes = applicationIds.length
|
||||
? await this.prisma.channelRouteRule.findMany({
|
||||
where: { applicationId: { in: applicationIds }, status: 'active' },
|
||||
include: { group: { include: { items: { include: { channel: true } } } } },
|
||||
})
|
||||
: [];
|
||||
const details = signatures
|
||||
.flatMap((signature) => {
|
||||
const channels = [
|
||||
...new Map(
|
||||
routes
|
||||
.filter((route) => route.applicationId === signature.applicationId && route.group.status === 'active')
|
||||
.flatMap((route) => route.group.items.map((item) => item.channel))
|
||||
.filter((channel) => channel.status === 'active')
|
||||
.map((channel) => [channel.id, channel]),
|
||||
).values(),
|
||||
];
|
||||
const signatureDetails = channels.flatMap((channel) =>
|
||||
normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => {
|
||||
const existing = signature.reportTasks.find(
|
||||
(task) =>
|
||||
task.reportType === 'signature' &&
|
||||
task.channelId === channel.id &&
|
||||
(task.carrier === carrier || (!task.carrier && task.approvalScope === 'legacy_channel')),
|
||||
);
|
||||
return existing
|
||||
? { ...existing, signature }
|
||||
: {
|
||||
id: `virtual:${signature.id}:${channel.id}:${carrier}`,
|
||||
tenantId: signature.tenantId,
|
||||
signatureId: signature.id,
|
||||
channelId: channel.id,
|
||||
carrier,
|
||||
approvalScope: 'carrier_specific',
|
||||
reportType: 'signature',
|
||||
drainageItemId: null,
|
||||
status: 'pending',
|
||||
reason: null,
|
||||
approvedAt: null,
|
||||
createdAt: signature.reportChangedAt ?? signature.updatedAt,
|
||||
updatedAt: signature.reportChangedAt ?? signature.updatedAt,
|
||||
createdById: null,
|
||||
signature,
|
||||
channel,
|
||||
drainageInfo: null,
|
||||
exportItems: [],
|
||||
records: [],
|
||||
virtual: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const drainageDetails = signature.drainageItems.flatMap((drainageInfo) =>
|
||||
channels
|
||||
.map((channel) => {
|
||||
const existing = signature.reportTasks.find(
|
||||
(task) =>
|
||||
task.reportType === 'drainage' &&
|
||||
task.channelId === channel.id &&
|
||||
task.drainageItemId === drainageInfo.id,
|
||||
);
|
||||
return existing ? { ...existing, signature } : undefined;
|
||||
})
|
||||
.filter(Boolean),
|
||||
);
|
||||
return [...signatureDetails, ...drainageDetails];
|
||||
})
|
||||
.filter((task) => {
|
||||
if (!task) return false;
|
||||
if (query.channelId && task.channelId !== query.channelId) return false;
|
||||
if (query.carrier && task.carrier !== query.carrier) return false;
|
||||
if (query.status && task.status !== query.status) return false;
|
||||
if (query.reportType && task.reportType !== query.reportType) return false;
|
||||
const changedAt = new Date(task.updatedAt);
|
||||
if (query.createdAtFrom && changedAt < new Date(`${query.createdAtFrom}T00:00:00+08:00`)) return false;
|
||||
if (query.createdAtTo && changedAt > new Date(`${query.createdAtTo}T23:59:59.999+08:00`)) return false;
|
||||
if (!query.keyword?.trim()) return true;
|
||||
const keyword = query.keyword.trim();
|
||||
return [
|
||||
task.id,
|
||||
task.signature.name,
|
||||
task.signature.tenant.name,
|
||||
task.signature.application?.name,
|
||||
task.channel.name,
|
||||
task.drainageInfo?.siteName,
|
||||
task.drainageInfo?.url,
|
||||
].some((value) => String(value ?? '').includes(keyword));
|
||||
});
|
||||
return { items: details.slice((page - 1) * pageSize, page * pageSize), total: details.length, page, pageSize };
|
||||
}
|
||||
|
||||
async createReportTask(data: CreateReportTaskDto) {
|
||||
@@ -321,7 +542,8 @@ export class ChannelReportingService {
|
||||
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 || drainageInfo.signatureId !== data.signatureId)
|
||||
throw new NotFoundException('Drainage info not found');
|
||||
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备');
|
||||
throw new BadRequestException('引流信息通道报备任务由运营审核通过后按应用路由自动生成');
|
||||
}
|
||||
@@ -333,7 +555,13 @@ export class ChannelReportingService {
|
||||
throw new BadRequestException('报备运营商不在通道支持范围内');
|
||||
}
|
||||
const existing = await this.prisma.channelSignatureReportTask.findFirst({
|
||||
where: { signatureId: data.signatureId, channelId: data.channelId, carrier, reportType: 'signature', drainageItemId: null },
|
||||
where: {
|
||||
signatureId: data.signatureId,
|
||||
channelId: data.channelId,
|
||||
carrier,
|
||||
reportType: 'signature',
|
||||
drainageItemId: null,
|
||||
},
|
||||
});
|
||||
if (existing) throw new BadRequestException('该签名在当前通道和运营商下已存在报备任务');
|
||||
const task = await this.prisma.channelSignatureReportTask.create({
|
||||
@@ -355,7 +583,15 @@ export class ChannelReportingService {
|
||||
|
||||
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']);
|
||||
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');
|
||||
}
|
||||
@@ -364,43 +600,99 @@ export class ChannelReportingService {
|
||||
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 }> = [];
|
||||
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');
|
||||
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('引流信息审核通过后才能修改通道报备状态');
|
||||
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 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 });
|
||||
? 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));
|
||||
for (const signatureId of signatureIds)
|
||||
summaries.push(await this.recomputeSignatureReportSummary(tx, signatureId));
|
||||
return [...summaries, ...drainageResults];
|
||||
});
|
||||
}
|
||||
@@ -408,28 +700,55 @@ export class ChannelReportingService {
|
||||
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 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 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 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 } });
|
||||
@@ -487,6 +806,11 @@ export class ChannelReportingService {
|
||||
async listReportRecordsPage(query: {
|
||||
taskId?: string;
|
||||
channelId?: string;
|
||||
batchNo?: string;
|
||||
statusAfter?: string;
|
||||
action?: string;
|
||||
sourceEntry?: string;
|
||||
operatorKeyword?: string;
|
||||
keyword?: string;
|
||||
reportType?: string;
|
||||
createdAtFrom?: string;
|
||||
@@ -497,23 +821,51 @@ export class ChannelReportingService {
|
||||
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 operatorKeyword = query.operatorKeyword?.trim();
|
||||
const operatorIds = operatorKeyword
|
||||
? (
|
||||
await this.prisma.user.findMany({
|
||||
where: {
|
||||
OR: [{ username: { contains: operatorKeyword } }, { displayName: { contains: operatorKeyword } }],
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
).map((item) => item.id)
|
||||
: undefined;
|
||||
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,
|
||||
statusAfter: query.statusAfter,
|
||||
action: query.action,
|
||||
sourceEntry: query.sourceEntry,
|
||||
operatorId: operatorIds ? { in: operatorIds } : undefined,
|
||||
task:
|
||||
query.reportType || query.batchNo
|
||||
? {
|
||||
reportType: query.reportType,
|
||||
exportItems: query.batchNo
|
||||
? { some: { batchItem: { batch: { batchNo: { contains: query.batchNo.trim() } } } } }
|
||||
: undefined,
|
||||
}
|
||||
: 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({
|
||||
@@ -525,11 +877,30 @@ export class ChannelReportingService {
|
||||
}),
|
||||
this.prisma.channelSignatureReportRecord.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
const userIds = [...new Set(items.map((item) => item.operatorId).filter((id): id is string => Boolean(id)))];
|
||||
const operators = userIds.length
|
||||
? await this.prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: { id: true, username: true, displayName: true },
|
||||
})
|
||||
: [];
|
||||
const operatorMap = new Map(operators.map((item) => [item.id, item]));
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
...item,
|
||||
operator: item.operatorId ? operatorMap.get(item.operatorId) : undefined,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async getReportTaskOrThrow(taskId: string) {
|
||||
const task = await this.prisma.channelSignatureReportTask.findUnique({ where: { id: taskId }, include: { drainageInfo: true } });
|
||||
const task = await this.prisma.channelSignatureReportTask.findUnique({
|
||||
where: { id: taskId },
|
||||
include: { drainageInfo: true },
|
||||
});
|
||||
if (!task) {
|
||||
throw new NotFoundException('Report task not found');
|
||||
}
|
||||
@@ -552,8 +923,13 @@ export class ChannelReportingService {
|
||||
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.prisma.channelSignatureReportTask.findUnique({
|
||||
where: { id: taskId },
|
||||
select: { reportType: true, status: true, approvedAt: true },
|
||||
})
|
||||
)?.reportType === 'signature'
|
||||
? { approvedAt: statusAfter === 'approved' ? (statusBefore === 'approved' ? undefined : new Date()) : null }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user