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'; /** R4 report-materials domain service composed behind ReportMaterialsService. */ export class ReportImportParserService { constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService) {} listImportProfiles(reportType?: 'signature' | 'drainage') { return this.prisma.reportMaterialImportProfile.findMany({ where: { reportType, status: 'active' }, include: { columns: { orderBy: [{ sortOrder: 'asc' }, { sourceColumnIndex: 'asc' }] } }, orderBy: { updatedAt: 'desc' }, }); } async saveImportProfile(data: CreateImportProfileDto) { validateProfile(data); return this.prisma.$transaction(async (tx) => { const profile = data.id ? await tx.reportMaterialImportProfile.update({ where: { id: data.id }, data: profileData(data) }) : await tx.reportMaterialImportProfile.create({ data: profileData(data) }); await tx.reportMaterialImportProfileColumn.deleteMany({ where: { profileId: profile.id } }); await tx.reportMaterialImportProfileColumn.createMany({ data: data.columns.map((column, index) => ({ profileId: profile.id, sourceHeader: column.sourceHeader, sourceHeaderPath: column.sourceHeaderPath, sourceColumnIndex: column.sourceColumnIndex, targetFieldCode: column.targetFieldCode, targetKind: column.targetKind, fieldType: column.fieldType, required: column.required ?? false, transform: column.transform, sortOrder: column.sortOrder ?? (index + 1) * 10, })), }); return tx.reportMaterialImportProfile.findUnique({ where: { id: profile.id }, include: { columns: { orderBy: { sortOrder: 'asc' } } } }); }); } async analyzeImport(file: { originalname: string; mimetype: string; size: number; buffer: Buffer }, options: AnalyzeImportOptions) { if (!options.tenantId) throw new BadRequestException('tenantId is required'); if (!['signature', 'drainage'].includes(options.reportType)) throw new BadRequestException('reportType must be signature or drainage'); if (extname(file.originalname).toLowerCase() !== '.xlsx' || file.buffer[0] !== 0x50 || file.buffer[1] !== 0x4b) throw new BadRequestException('仅支持有效的 XLSX 文件'); const workbook = await loadWorkbook(file.buffer); assertSafeWorkbook(workbook); const profile = options.profileId ? await this.prisma.reportMaterialImportProfile.findUnique({ where: { id: options.profileId }, include: { columns: { orderBy: { sortOrder: 'asc' } } } }) : null; const selectedSheetName = options.sheetName || profile?.sheetName || undefined; const worksheet = selectedSheetName ? workbook.getWorksheet(selectedSheetName) : workbook.worksheets[0]; if (!worksheet) throw new BadRequestException('工作簿没有可读取的工作表'); const headerRowCount = clamp(options.headerRowCount, 1, 5); const dataStartRow = Math.max(options.dataStartRow, headerRowCount + 1); const images = readEmbeddedImages(workbook, worksheet); const columnCount = Math.min(worksheet.columnCount, 200); const columns = Array.from({ length: columnCount }, (_, offset) => { const sourceColumnIndex = offset + 1; const parts = Array.from({ length: headerRowCount }, (_, headerOffset) => cellText(worksheet.getCell(headerOffset + 1, sourceColumnIndex))).filter(Boolean); const sourceHeaderPath = [...new Set(parts)].join('/'); return { sourceColumnIndex, columnLetter: worksheet.getColumn(sourceColumnIndex).letter, sourceHeader: parts.at(-1) || `第${sourceColumnIndex}列`, sourceHeaderPath, imageCount: images.filter((image) => image.column === sourceColumnIndex).length, }; }).filter((column) => column.sourceHeaderPath || column.imageCount > 0); const previewRows = []; for (let rowNumber = dataStartRow; rowNumber <= Math.min(worksheet.rowCount, dataStartRow + 9); rowNumber += 1) { const values = Object.fromEntries(columns.map((column) => [String(column.sourceColumnIndex), cellText(worksheet.getCell(rowNumber, column.sourceColumnIndex))])); const imageColumns = images.filter((image) => image.row === rowNumber).map((image) => image.column); if (Object.values(values).some(Boolean) || imageColumns.length) previewRows.push({ rowNumber, values, imageColumns }); } const sourceFile = await this.files.upload({ tenantId: options.tenantId, purpose: 'report_material_import', prefix: 'report-material-imports' }, file); const profileMappings = profile?.columns.map((column) => ({ sourceHeader: column.sourceHeader, sourceHeaderPath: column.sourceHeaderPath ?? undefined, sourceColumnIndex: column.sourceColumnIndex, targetFieldCode: column.targetFieldCode, targetKind: column.targetKind as ImportMapping['targetKind'], fieldType: column.fieldType as ImportMapping['fieldType'], required: column.required, transform: column.transform ?? undefined, sortOrder: column.sortOrder, })); const suggestedMappings = profileMappings?.length ? remapProfileColumns(profileMappings, columns) : suggestMappings(columns, options.reportType); const batch = await this.prisma.reportMaterialImportBatch.create({ data: { tenantId: options.tenantId, applicationId: options.applicationId, profileId: options.profileId, fileObjectId: sourceFile.id, fileName: sourceFile.fileName, reportType: options.reportType, sheetName: worksheet.name, headerRowCount, dataStartRow, mapping: suggestedMappings as Prisma.InputJsonValue, preview: { sheets: workbook.worksheets.map((sheet) => sheet.name), columns, rows: previewRows, imageCount: images.length } as Prisma.InputJsonValue, rowCount: Math.max(0, worksheet.rowCount - dataStartRow + 1), }, }); await this.prisma.operationLog.create({ data: { tenantId: options.tenantId, userId: options.operatorId, action: 'report_material.import_analyzed', resource: 'report_material_import', resourceId: batch.id, detail: { fileName: sourceFile.fileName, filters: { applicationId: options.applicationId, reportType: options.reportType, sheetName: worksheet.name }, successCount: previewRows.length, failedCount: 0 } as Prisma.InputJsonValue, } }); return { ...batch, sourceFile, sheets: workbook.worksheets.map((sheet) => sheet.name), columns, rows: previewRows, imageCount: images.length, suggestedMappings }; } }