898 lines
37 KiB
TypeScript
898 lines
37 KiB
TypeScript
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
|
|
|
import { Prisma } from '@prisma/client';
|
|
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import type {
|
|
CreateReportFieldDto,
|
|
ReplaceReportFieldsDto,
|
|
CreateReportMaterialDto,
|
|
CreateReportTaskDto,
|
|
ChangeReportTaskStatusesDto,
|
|
CreateReportExportDto,
|
|
CreateReceiptImportDto,
|
|
} from './channels.contracts';
|
|
import {
|
|
parseReceiptContent,
|
|
deriveReceiptStatus,
|
|
ChannelReportDeliveryRow,
|
|
summarizeChannelReportDelivery,
|
|
latestDate,
|
|
currentShanghaiDayRange,
|
|
normalizeSpreadsheetSize,
|
|
normalizeBusinessCarrier,
|
|
normalizeChannelCarriers,
|
|
normalizeReportType,
|
|
summarizeReportStatuses,
|
|
} 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;
|
|
void _id;
|
|
void _createdAt;
|
|
void _updatedAt;
|
|
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:
|
|
status === 'reporting' || status === 'exporting'
|
|
? { in: ['reporting', 'exporting'] }
|
|
: status === 'failed'
|
|
? { in: ['failed', 'rejected'] }
|
|
: 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;
|
|
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;
|
|
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 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 },
|
|
},
|
|
},
|
|
},
|
|
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) {
|
|
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;
|
|
batchNo?: string;
|
|
statusAfter?: string;
|
|
action?: string;
|
|
sourceEntry?: string;
|
|
operatorKeyword?: 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 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,
|
|
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({
|
|
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 }),
|
|
]);
|
|
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 },
|
|
});
|
|
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,
|
|
},
|
|
});
|
|
}
|
|
}
|