import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import ExcelJS from 'exceljs'; import { createHash, randomUUID } from 'node:crypto'; 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 { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts'; import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers'; import type { ReportBatchGenerationService } from './batch-generation.service'; import { normalizeChannelCarriers } from '../channels/channels.helpers'; /** 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 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 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 = reportType === 'signature' ? normalizeChannelCarriers(channel.carriers, channel.carrier) : [null]; const tasks: Array<{ task: { id: string; reason: string | null }; existingTask: { status: string } | null }> = []; for (const carrier of reportCarriers) { const existingTask = await this.prisma.channelSignatureReportTask.findFirst({ where: { signatureId: item.signature.id, channelId, carrier, reportType, drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null } }); const task = existingTask ? await this.prisma.channelSignatureReportTask.update({ where: { id: existingTask.id }, data: { status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason, ...(reportType === 'signature' ? { approvedAt: null } : {}) } }) : await this.prisma.channelSignatureReportTask.create({ data: { tenantId: item.signature.tenantId, signatureId: item.signature.id, channelId, carrier, approvalScope: reportType === 'signature' ? 'carrier_specific' : 'legacy_channel', reportType, drainageItemId: item.drainageInfo?.id, status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason } }); tasks.push({ task, existingTask }); } 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 }); 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 }; } 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' } }); } }