import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import ExcelJS from 'exceljs'; import { extname } from 'node:path'; import { FilesService } from '../files/files.service'; import { PrismaService } from '../prisma/prisma.service'; import { SmsConfigService } from '../sms-config/sms-config.service'; import type { SingleReportMaterialDto } from './report-materials.contracts'; import { jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, safeFileName, } from './report-materials.helpers'; import { convertWorkbookOutput } from './workbook-compatibility'; import type { ReportBatchGenerationService } from './batch-generation.service'; /** R4 report-materials domain service composed behind ReportMaterialsService. */ export class ReportChannelExportService { constructor( private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService, ) {} async exportChannelBatch( batchId: string, channelId: string, items: Array>>, ) { const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } }); if (!channel) throw new NotFoundException('通道不存在'); const reportTypes = [...new Set(items.map((item) => item.reportType))]; const workbook = new ExcelJS.Workbook(); const fileRows: Array<{ item: (typeof items)[number]; taskId: string; rowNumber: number }> = []; const briefItems: Array<{ batchItemId: string; smsContent: string }> = []; const incompleteBatchItemIds: string[] = []; let totalRows = 0; for (const reportType of reportTypes) { const fields = await this.prisma.channelReportField.findMany({ where: { channelId, status: 'active', reportType: { in: [reportType, 'both'] } }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], }); const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名报备' : '引流信息报备', { views: [{ state: 'frozen', ySplit: 1 }], }); sheet.properties.defaultRowHeight = 22; sheet.columns = fields.map((field) => ({ header: field.exportName || field.name, key: field.code, width: field.columnWidth, })); styleHeader(sheet.getRow(1)); for (const item of items.filter((current) => current.reportType === reportType)) { const values = fields.map( (field) => resolveExportValue(item.snapshot, field.code, field.name) ?? field.defaultValue ?? '', ); const smsContentField = fields.find((field) => field.name.trim() === '短信内容'); const smsContentValue = smsContentField ? (resolveExportValue(item.snapshot, smsContentField.code, smsContentField.name) ?? '') : ''; const smsContent = isFileRef(smsContentValue) ? '' : String(smsContentValue ?? ''); const missing = fields.filter((field, index) => field.required && !hasValue(values[index])); const missingReason = fields.length === 0 ? '通道未配置当前资料类型的报备字段' : missing.length ? `缺少字段:${missing.map((field) => field.exportName || field.name).join('、')}` : null; const reportCarriers = item.eligibleTargets .filter((target) => target.channelId === channelId) .map((target) => target.carrier as 'mobile' | 'unicom' | 'telecom'); const tasks: Array<{ task: { id: string; reason: string | null }; existingTask: { status: string } | null }> = []; for (const carrier of reportCarriers) { const entry = await this.prisma.$transaction(async (tx) => { await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${item.signature.id}, 910))`; const existingTask = await tx.channelSignatureReportTask.findFirst({ where: { signatureId: item.signature.id, channelId, carrier, reportType, drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null, }, }); const task = existingTask ? await tx.channelSignatureReportTask.update({ where: { id: existingTask.id }, data: { status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason, approvedAt: null, }, }) : await tx.channelSignatureReportTask.create({ data: { tenantId: item.signature.tenantId, signatureId: item.signature.id, channelId, carrier, approvalScope: 'carrier_specific', reportType, drainageItemId: item.drainageInfo?.id, status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason, }, }); return { task, existingTask }; }); tasks.push(entry); } const task = tasks[0].task; if (missingReason) { incompleteBatchItemIds.push(item.batchItem.id); for (const entry of tasks) await this.recordTask( entry.task.id, channelId, entry.existingTask?.status, 'waiting_material', task.reason ?? undefined, ); continue; } const row = sheet.addRow( values.map((value, index) => isFileRef(value) ? value.fileName : applyExportTransform(value, fields[index]?.transform), ), ); totalRows += 1; let targetHeight = 22; for (const [index, value] of values.entries()) { if (!isFileRef(value)) continue; const downloaded = await this.files.getDownload(value.fileObjectId); if (!downloaded.fileObject.contentType.startsWith('image/')) continue; const extension = normalizeImageExtension( extname(downloaded.fileObject.fileName).slice(1) || downloaded.fileObject.contentType.split('/')[1], ); if (!['png', 'jpeg', 'gif'].includes(extension)) continue; const imageId = workbook.addImage({ base64: `data:${downloaded.fileObject.contentType};base64,${downloaded.content.toString('base64')}`, extension: extension as 'png' | 'jpeg' | 'gif', }); const widthCells = Math.max(0.8, fields[index].imageWidth / Math.max(60, fields[index].columnWidth * 7)); const heightRows = Math.max(0.8, fields[index].imageHeight / 20); sheet.addImage(imageId, { tl: { col: index + 0.08, row: row.number - 1 + 0.08 }, br: { col: index + Math.min(0.95, widthCells), row: row.number - 1 + Math.min(0.95, heightRows) }, editAs: 'oneCell', } as never); targetHeight = Math.max(targetHeight, fields[index].imageHeight * 0.75 + 8); } row.height = targetHeight; fileRows.push({ item, taskId: task.id, rowNumber: row.number }); briefItems.push({ batchItemId: item.batchItem.id, smsContent }); for (const entry of tasks) await this.recordTask(entry.task.id, channelId, entry.existingTask?.status, 'exporting'); } } if (workbook.worksheets.every((sheet) => sheet.rowCount <= 1)) { for (const sheet of [...workbook.worksheets]) workbook.removeWorksheet(sheet.id); const empty = workbook.addWorksheet('无可导出数据'); empty.getCell('A1').value = '所选资料缺少当前通道必填字段,请补充后重新生成。'; empty.getColumn(1).width = 64; } const buffer = Buffer.from(await workbook.xlsx.writeBuffer()); const fileName = `${safeFileName(channel.name)}-${batchId.slice(-8)}.xlsx`; const uploaded = await this.files.upload( { purpose: 'report_export', prefix: `report-exports/${batchId}` }, { originalname: fileName, mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: buffer.length, buffer, }, ); const file = await this.prisma.reportExportFile.create({ data: { batchId, channelId, fileObjectId: uploaded.id, fileName, rowCount: totalRows }, }); if (fileRows.length) await this.prisma.reportExportFileItem.createMany({ data: fileRows.map((entry) => ({ exportFileId: file.id, batchItemId: entry.item.batchItem.id, taskId: entry.taskId, rowNumber: entry.rowNumber, })), }); return { file: { ...file, fileObject: uploaded }, incompleteBatchItemIds, briefItems }; } async getSingleMaterialDetail(data: SingleReportMaterialDto) { const reportType = data.reportType ?? 'signature'; if (!data.signatureId || !data.channelId) throw new BadRequestException('签名和通道不能为空'); if (reportType === 'drainage' && !data.drainageItemId) throw new BadRequestException('引流信息不能为空'); const [signature, channel] = await Promise.all([ this.prisma.smsSignature.findUnique({ where: { id: data.signatureId }, include: { tenant: true, application: true }, }), this.prisma.smsChannel.findUnique({ where: { id: data.channelId } }), ]); if (!signature || signature.auditStatus === 'deleted') throw new NotFoundException('签名不存在'); if (!channel || channel.status === 'deleted') throw new NotFoundException('通道不存在'); let materialVersion = signature.materialVersion; let snapshot: Record; if (data.batchItemId) { const batchItem = await this.prisma.reportMaterialBatchItem.findFirst({ where: { id: data.batchItemId, signatureId: signature.id, exportItems: { some: { exportFile: { channelId: channel.id } } }, }, }); if (!batchItem) throw new NotFoundException('当前批次资料快照不存在'); if (batchItem.reportType !== reportType) throw new BadRequestException('批次资料类型不一致'); materialVersion = batchItem.materialVersion; snapshot = jsonRecord(batchItem.snapshot); } else if (reportType === 'signature') { snapshot = { reportType, signature: { id: signature.id, name: signature.name, purpose: signature.purpose, tenantName: signature.tenant.name, applicationName: signature.application?.name, }, values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues), }; } else { const drainage = await this.prisma.smsDrainageInfo.findFirst({ where: { id: data.drainageItemId, signatureId: signature.id }, }); if (!drainage || drainage.auditStatus === 'deleted') throw new NotFoundException('引流信息不存在'); materialVersion = drainage.materialVersion; snapshot = { reportType, signature: { id: signature.id, name: signature.name, tenantName: signature.tenant.name, applicationName: signature.application?.name, }, drainage: { id: drainage.id, siteName: drainage.siteName, url: drainage.url, remark: drainage.remark }, values: jsonRecord(drainage.reportValues), }; } const fields = await this.prisma.channelReportField.findMany({ where: { channelId: channel.id, status: 'active', reportType: { in: [reportType, 'both'] } }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], }); const configuredCodes = new Set(fields.map((field) => field.code)); const values = jsonRecord(snapshot.values); const historicalCodes = Object.keys(values).filter((code) => !configuredCodes.has(code)); const historicalDefinitions = historicalCodes.length ? await this.prisma.drainageField.findMany({ where: { code: { in: historicalCodes } } }) : []; const historicalDefinitionByCode = new Map(historicalDefinitions.map((field) => [field.code, field])); const materialFields = fields.map((field) => { const submittedValue = resolveExportValue(snapshot, field.code, field.name); const value = hasValue(submittedValue) ? submittedValue : field.defaultValue; return { id: field.id, code: field.code, name: field.name, exportName: field.exportName, fieldType: field.fieldType, required: field.required, columnWidth: field.columnWidth, imageWidth: field.imageWidth, imageHeight: field.imageHeight, transform: field.transform, value: value ?? null, submitted: hasValue(submittedValue), missing: field.required && !hasValue(value), }; }); const historicalFields = Object.entries(values) .filter(([code]) => !configuredCodes.has(code) && historicalDefinitionByCode.get(code)?.status !== 'deleted') .sort(([left], [right]) => left.localeCompare(right, 'zh-CN')) .map(([code, value]) => ({ code, name: historicalDefinitionByCode.get(code)?.name ?? code, value })); return { reportType, signatureId: signature.id, signatureName: signature.name, tenant: { id: signature.tenant.id, name: signature.tenant.name }, application: signature.application ? { id: signature.application.id, name: signature.application.name } : null, channel: { id: channel.id, name: channel.name, code: channel.code }, carrier: data.carrier ?? null, materialVersion, batchItemId: data.batchItemId ?? null, fields: materialFields, historicalFields, missingFields: materialFields.filter((field) => field.missing).map((field) => field.exportName || field.name), }; } async exportSingleMaterial(data: SingleReportMaterialDto | undefined, operatorId?: string) { if (!data) throw new BadRequestException('导出参数不能为空'); if ((data.reportType ?? 'signature') !== 'signature') throw new BadRequestException('首版仅支持单条签名报备资料导出'); const detail = await this.getSingleMaterialDetail(data); if (detail.missingFields.length) throw new BadRequestException({ code: 'REPORT_MATERIAL_INCOMPLETE', message: `缺少必填字段:${detail.missingFields.join('、')}`, missingFields: detail.missingFields, }); const workbook = new ExcelJS.Workbook(); workbook.creator = 'CMPP短信平台'; const sheet = workbook.addWorksheet('签名报备', { views: [{ state: 'frozen', ySplit: 1 }] }); sheet.columns = detail.fields.map((field) => ({ header: field.exportName || field.name, key: field.code, width: field.columnWidth, })); styleHeader(sheet.getRow(1)); const row = sheet.addRow( detail.fields.map((field) => isFileRef(field.value) ? field.value.fileName : applyExportTransform(field.value, field.transform), ), ); let targetHeight = 22; for (const [index, field] of detail.fields.entries()) { if (!isFileRef(field.value)) continue; const downloaded = await this.files.getDownload(field.value.fileObjectId); if (!downloaded.fileObject.contentType.startsWith('image/')) continue; const extension = normalizeImageExtension( extname(downloaded.fileObject.fileName).slice(1) || downloaded.fileObject.contentType.split('/')[1], ); if (!['png', 'jpeg', 'gif'].includes(extension)) continue; const imageId = workbook.addImage({ base64: `data:${downloaded.fileObject.contentType};base64,${downloaded.content.toString('base64')}`, extension: extension as 'png' | 'jpeg' | 'gif', }); const widthCells = Math.max(0.8, field.imageWidth / Math.max(60, field.columnWidth * 7)); const heightRows = Math.max(0.8, field.imageHeight / 20); sheet.addImage(imageId, { tl: { col: index + 0.08, row: row.number - 1 + 0.08 }, br: { col: index + Math.min(0.95, widthCells), row: row.number - 1 + Math.min(0.95, heightRows) }, editAs: 'oneCell', } as never); targetHeight = Math.max(targetHeight, field.imageHeight * 0.75 + 8); } row.height = targetHeight; const fileName = `${safeFileName(detail.channel.name)}-${safeFileName(detail.signatureName)}-V${detail.materialVersion}.xlsx`; const content = await convertWorkbookOutput(Buffer.from(await workbook.xlsx.writeBuffer()), data.outputFormat); await this.prisma.operationLog.create({ data: { tenantId: detail.tenant.id, userId: operatorId, action: 'report_material.single_export', resource: 'report_material', resourceId: detail.signatureId, detail: { fileName, channelId: detail.channel.id, carrier: detail.carrier, materialVersion: detail.materialVersion, batchItemId: detail.batchItemId, successCount: 1, failedCount: 0, } as Prisma.InputJsonValue, }, }); return { fileName, content }; } recordTask( taskId: string, channelId: string, statusBefore: string | undefined, statusAfter: string, reason?: string, ) { return this.prisma.channelSignatureReportRecord.create({ data: { taskId, channelId, action: 'batch_export', statusBefore, statusAfter, reason, sourceEntry: 'report_task', }, }); } }