feat: build reporting workbench workflow

This commit is contained in:
hectorzhao
2026-09-02 14:30:15 +08:00
parent 53d3668306
commit 9b8196ecab
31 changed files with 7651 additions and 1973 deletions
+488 -112
View File
@@ -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 }
: {}),
},
});