import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { FilesService } from '../files/files.service'; import { PrismaService } from '../prisma/prisma.service'; import { SmsConfigService } from '../sms-config/sms-config.service'; import type { ImportCommitDto, ImportMapping, PagedQuery, ReviewImportItemsDto } from './report-materials.contracts'; import { normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, mappedCorePatchValue, dynamicValues, signatureIdentityReportValues, jsonRecord, hasValue, normalizeImageExtension, imageContentType, duplicateCoreMappingKind, } from './report-materials.helpers'; import { ReportImportParserService } from './import-parser.service'; import { compatibleImages, loadCompatibleWorkbook } from './workbook-compatibility'; /** R4 report-materials domain service composed behind ReportMaterialsService. */ export class ReportImportReviewService { constructor( private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService, private readonly importParser: ReportImportParserService, ) {} async commitImport(batchId: string, data: ImportCommitDto) { const batch = await this.prisma.reportMaterialImportBatch.findUnique({ where: { id: batchId } }); if (!batch) throw new NotFoundException('导入批次不存在'); if (batch.status !== 'analyzed') throw new ConflictException('该导入批次已提交审核,不能重复导入'); if (!data.mappings?.length) throw new BadRequestException('请至少配置一个导入字段映射'); const duplicateCoreKind = duplicateCoreMappingKind(data.mappings); if (duplicateCoreKind) { const label = { signatureName: '短信签名', purpose: '签名用途/依据', siteName: '站点名称', url: '引流 URL 或号码', remark: '备注', }[duplicateCoreKind]; throw new BadRequestException(`目标字段“${label}”只能映射一个源列`); } if (data.profile) await this.importParser.saveImportProfile({ ...data.profile, reportType: batch.reportType as 'signature' | 'drainage', columns: data.mappings, }); const { content } = await this.files.getDownload(batch.fileObjectId); const { workbook, wpsImagesBySheet } = await loadCompatibleWorkbook(content); const worksheet = workbook.getWorksheet(batch.sheetName); if (!worksheet) throw new BadRequestException('导入工作表不存在'); const images = compatibleImages(workbook, worksheet, wpsImagesBySheet); const imageByCell = new Map(images.map((image) => [`${image.row}:${image.column}`, image])); let successCount = 0; const failures: Array<{ rowNumber: number; reason: string }> = []; const stagedItems: Prisma.ReportMaterialImportItemCreateManyInput[] = []; for (let rowNumber = batch.dataStartRow; rowNumber <= worksheet.rowCount; rowNumber += 1) { const values: Record = {}; try { for (const mapping of data.mappings) { const image = imageByCell.get(`${rowNumber}:${mapping.sourceColumnIndex}`); if (image && mapping.fieldType !== 'string') { const uploaded = await this.files.upload( { tenantId: batch.tenantId, purpose: 'report_material', prefix: `report-materials/import-${batch.id}` }, { originalname: `${mapping.targetFieldCode}-row-${rowNumber}.${normalizeImageExtension(image.extension)}`, mimetype: imageContentType(image.extension), size: image.buffer.length, buffer: image.buffer, }, ); values[mapping.targetFieldCode] = { fileObjectId: uploaded.id, fileName: uploaded.fileName, contentType: uploaded.contentType, }; } else { values[mapping.targetFieldCode] = transformValue( cellText(worksheet.getCell(rowNumber, mapping.sourceColumnIndex)), mapping.transform, ); } } if (!Object.values(values).some(hasValue)) continue; for (const mapping of data.mappings.filter((item) => item.required)) { if (!hasValue(values[mapping.targetFieldCode])) throw new Error(`缺少必填字段:${mapping.sourceHeader}`); } const staged = batch.reportType === 'signature' ? await this.stageSignatureRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values) : await this.stageDrainageRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values); stagedItems.push({ batchId, rowNumber, reportType: batch.reportType, operation: staged.operation, targetId: staged.targetId, status: 'pending_review', payload: staged.payload as Prisma.InputJsonValue, originalSnapshot: staged.originalSnapshot as Prisma.InputJsonValue | undefined, }); successCount += 1; } catch (error) { const reason = error instanceof Error ? error.message : '导入失败'; failures.push({ rowNumber, reason }); stagedItems.push({ batchId, rowNumber, reportType: batch.reportType, operation: 'invalid', status: 'invalid', payload: values as Prisma.InputJsonValue, errorMessage: reason, }); } } const updated = await this.prisma.$transaction(async (tx) => { if (stagedItems.length) await tx.reportMaterialImportItem.createMany({ data: stagedItems }); return tx.reportMaterialImportBatch.update({ where: { id: batchId }, data: { status: successCount ? 'pending_review' : 'failed', mapping: data.mappings as Prisma.InputJsonValue, result: { failures } as Prisma.InputJsonValue, successCount, failedCount: failures.length, }, include: { items: { orderBy: { rowNumber: 'asc' } } }, }); }); await this.prisma.operationLog.create({ data: { tenantId: batch.tenantId, userId: data.operatorId, action: 'report_material.import_committed', resource: 'report_material_import', resourceId: batch.id, detail: { fileName: batch.fileName, filters: { applicationId: batch.applicationId, reportType: batch.reportType, sheetName: batch.sheetName }, successCount, failedCount: failures.length, } as Prisma.InputJsonValue, }, }); return updated; } async listImportReviewBatches(query: PagedQuery & { reportType?: 'signature' | 'drainage'; status?: string } = {}) { const page = normalizePage(query.page); const pageSize = normalizePageSize(query.pageSize); const where: Prisma.ReportMaterialImportBatchWhereInput = { reportType: query.reportType, status: query.status && query.status !== 'all' ? query.status : undefined, createdAt: dateRange(query.startAt, query.endAt), OR: query.keyword?.trim() ? [{ fileName: { contains: query.keyword.trim() } }, { id: { contains: query.keyword.trim() } }] : undefined, }; const [batches, total] = await Promise.all([ this.prisma.reportMaterialImportBatch.findMany({ where, include: { items: { orderBy: { rowNumber: 'asc' } } }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.reportMaterialImportBatch.count({ where }), ]); const tenantIds = [...new Set(batches.map((batch) => batch.tenantId))]; const applicationIds = [ ...new Set(batches.map((batch) => batch.applicationId).filter((id): id is string => Boolean(id))), ]; const reviewerIds = [ ...new Set( batches .flatMap((batch) => [batch.reviewedById, ...batch.items.map((item) => item.reviewedById)]) .filter((id): id is string => Boolean(id)), ), ]; const [tenants, applications, reviewers] = await Promise.all([ tenantIds.length ? this.prisma.tenant.findMany({ where: { id: { in: tenantIds } }, select: { id: true, name: true } }) : [], applicationIds.length ? this.prisma.smsApplication.findMany({ where: { id: { in: applicationIds } }, select: { id: true, name: true }, }) : [], reviewerIds.length ? this.prisma.user.findMany({ where: { id: { in: reviewerIds } }, select: { id: true, username: true, displayName: true }, }) : [], ]); const tenantById = new Map(tenants.map((item) => [item.id, item])); const applicationById = new Map(applications.map((item) => [item.id, item])); const reviewerById = new Map(reviewers.map((item) => [item.id, item])); return { items: batches.map((batch) => ({ ...batch, tenant: tenantById.get(batch.tenantId) ?? null, application: batch.applicationId ? (applicationById.get(batch.applicationId) ?? null) : null, reviewer: batch.reviewedById ? (reviewerById.get(batch.reviewedById) ?? null) : null, items: batch.items.map((item) => ({ ...item, reviewer: item.reviewedById ? (reviewerById.get(item.reviewedById) ?? null) : null, })), })), total, page, pageSize, }; } async reviewImportItems(batchId: string, data: ReviewImportItemsDto) { if (!data.reviewerId) throw new BadRequestException('Reviewer session is required'); if (!['approve', 'reject'].includes(data.decision)) throw new BadRequestException('Unsupported import review decision'); const batch = await this.prisma.reportMaterialImportBatch.findUnique({ where: { id: batchId }, include: { items: { where: { id: data.itemIds?.length ? { in: data.itemIds } : undefined, status: 'pending_review' }, orderBy: { rowNumber: 'asc' }, }, }, }); if (!batch) throw new NotFoundException('导入审核批次不存在'); if (!batch.items.length) throw new BadRequestException('没有可审核的导入明细'); let approvedCount = 0; let rejectedCount = 0; const failures: Array<{ itemId: string; rowNumber: number; reason: string }> = []; for (const item of batch.items) { if (data.decision === 'reject') { await this.prisma.reportMaterialImportItem.update({ where: { id: item.id }, data: { status: 'rejected', reviewReason: data.reason?.trim(), reviewedById: data.reviewerId, reviewedAt: new Date(), }, }); rejectedCount += 1; continue; } try { const targetId = await this.applyImportItem(batch, item, data.reviewerId); await this.prisma.reportMaterialImportItem.update({ where: { id: item.id }, data: { targetId, status: 'approved', reviewReason: data.reason?.trim(), reviewedById: data.reviewerId, reviewedAt: new Date(), errorMessage: null, }, }); approvedCount += 1; } catch (error) { const reason = error instanceof Error ? error.message : '导入审核应用失败'; failures.push({ itemId: item.id, rowNumber: item.rowNumber, reason }); await this.prisma.reportMaterialImportItem.update({ where: { id: item.id }, data: { status: 'invalid', errorMessage: reason, reviewedById: data.reviewerId, reviewedAt: new Date() }, }); } } const counts = await this.prisma.reportMaterialImportItem.groupBy({ by: ['status'], where: { batchId }, _count: { _all: true }, }); const countByStatus = new Map(counts.map((item) => [item.status, item._count._all])); const pendingCount = countByStatus.get('pending_review') ?? 0; const totalApproved = countByStatus.get('approved') ?? 0; const totalRejected = countByStatus.get('rejected') ?? 0; const totalInvalid = countByStatus.get('invalid') ?? 0; const status = pendingCount ? 'partially_reviewed' : totalApproved && (totalRejected || totalInvalid) ? 'partially_approved' : totalApproved ? 'approved' : totalRejected ? 'rejected' : 'failed'; await this.prisma.reportMaterialImportBatch.update({ where: { id: batchId }, data: { status, reviewedById: pendingCount ? undefined : data.reviewerId, reviewedAt: pendingCount ? undefined : new Date(), completedAt: pendingCount ? undefined : new Date(), }, }); return { batchId, status, approvedCount, rejectedCount, failedCount: failures.length, failures }; } async stageSignatureRow( tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record, ) { const name = mappedCoreValue(mappings, values, 'signatureName'); if (!name) throw new Error('缺少短信签名'); const purpose = mappedCorePatchValue(mappings, values, 'purpose'); const signatureReportValues = dynamicValues(mappings, values); const existing = await this.prisma.smsSignature.findFirst({ where: { tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } }, }); return { operation: existing ? 'update' : 'create', targetId: existing?.id, payload: { tenantId, applicationId, name, ...(purpose !== undefined ? { purpose } : {}), drainageInfo: { signatureReportValues }, }, originalSnapshot: existing ? { id: existing.id, applicationId: existing.applicationId, name: existing.name, purpose: existing.purpose, drainageInfo: existing.drainageInfo, auditStatus: existing.auditStatus, updatedAt: existing.updatedAt, } : undefined, }; } async stageDrainageRow( tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record, ) { const signatureName = mappedCoreValue(mappings, values, 'signatureName'); const url = mappedCoreValue(mappings, values, 'url'); if (!signatureName || !url) throw new Error('引流信息必须包含短信签名和引流 URL 或号码'); const signature = await this.prisma.smsSignature.findFirst({ where: { tenantId, applicationId: applicationId ?? null, name: signatureName, auditStatus: 'approved' }, }); if (!signature) throw new Error(`未找到已审核签名:${signatureName}`); const remark = mappedCoreValue(mappings, values, 'remark'); const reportValues = dynamicValues(mappings, values); const existing = await this.prisma.smsDrainageInfo.findFirst({ where: { signatureId: signature.id, url, auditStatus: { not: 'deleted' } }, }); return { operation: existing ? 'update' : 'create', targetId: existing?.id, payload: { tenantId, applicationId, signatureId: signature.id, signatureName, siteName: url, url, remark, reportValues, }, originalSnapshot: existing ? { id: existing.id, siteName: existing.siteName, url: existing.url, remark: existing.remark, reportValues: existing.reportValues, auditStatus: existing.auditStatus, updatedAt: existing.updatedAt, } : undefined, }; } async applyImportItem( batch: { tenantId: string; applicationId: string | null; reportType: string }, item: { reportType: string; targetId: string | null; payload: Prisma.JsonValue }, reviewerId: string, ) { const payload = jsonRecord(item.payload); if (item.reportType === 'signature') { const name = String(payload.name ?? ''); const applicationId = typeof payload.applicationId === 'string' ? payload.applicationId : undefined; const importedDrainage = jsonRecord(payload.drainageInfo); const importedReportValues = jsonRecord(importedDrainage.signatureReportValues); const [reportFields, tenant, application] = await Promise.all([ this.smsConfig.getApplicationReportFields(applicationId, 'signature'), this.prisma.tenant.findUnique({ where: { id: batch.tenantId }, select: { name: true } }), applicationId ? this.prisma.smsApplication.findUnique({ where: { id: applicationId }, select: { name: true } }) : Promise.resolve(null), ]); const buildBody = (current?: { drainageInfo: Prisma.JsonValue | null; purpose?: string | null }) => { const currentDrainage = jsonRecord(current?.drainageInfo); const purpose = Object.prototype.hasOwnProperty.call(payload, 'purpose') ? String(payload.purpose ?? '') : current?.purpose; const identityValues = signatureIdentityReportValues(reportFields, { signatureName: name, purpose, enterpriseName: tenant?.name, applicationName: application?.name, }); return { applicationId, name, ...(Object.prototype.hasOwnProperty.call(payload, 'purpose') ? { purpose: String(payload.purpose ?? '') } : {}), drainageInfo: { ...currentDrainage, signatureReportValues: { ...jsonRecord(currentDrainage.signatureReportValues), ...identityValues, ...importedReportValues, }, }, }; }; let targetId = item.targetId; if (targetId) { const current = await this.prisma.smsSignature.findUnique({ where: { id: targetId } }); if (!current || current.auditStatus === 'deleted') throw new Error('原签名已删除,不能应用导入修改'); await this.smsConfig.updateSignature(targetId, buildBody(current), batch.tenantId); } else { const duplicate = await this.prisma.smsSignature.findFirst({ where: { tenantId: batch.tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' }, }, }); if (duplicate) { targetId = duplicate.id; await this.smsConfig.updateSignature(targetId, buildBody(duplicate), batch.tenantId); } else { const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...buildBody() }); targetId = created.id; } } await this.smsConfig.approveSignature(targetId, { reviewerId, reason: `批量导入审核通过:${name}` }); return targetId; } const signatureId = String(payload.signatureId ?? ''); const url = String(payload.url ?? ''); const body = { url, remark: typeof payload.remark === 'string' ? payload.remark : undefined, reportValues: jsonRecord(payload.reportValues), }; let targetId = item.targetId; if (targetId) { const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: targetId } }); if (!current || current.auditStatus === 'deleted') throw new Error('原引流信息已删除,不能应用导入修改'); await this.smsConfig.updateDrainageInfo(targetId, body, { initialAuditStatus: 'pending' }, batch.tenantId); } else { const duplicate = await this.prisma.smsDrainageInfo.findFirst({ where: { signatureId, url, auditStatus: { not: 'deleted' } }, }); if (duplicate) { targetId = duplicate.id; await this.smsConfig.updateDrainageInfo(targetId, body, { initialAuditStatus: 'pending' }, batch.tenantId); } else { const created = await this.smsConfig.createDrainageInfo( signatureId, body, { initialAuditStatus: 'pending' }, batch.tenantId, ); targetId = created.id; } } await this.smsConfig.approveDrainageInfo(targetId, { reviewerId, reason: `批量导入审核通过:${url}` }); return targetId; } }