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(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, }, }); } }