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 { ReportBatchOperationService } from './batch-operation.service'; import { ReportChannelExportService } from './channel-export.service'; /** R4 report-materials domain service composed behind ReportMaterialsService. */ export class ReportBatchGenerationService { constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService, private readonly operations: ReportBatchOperationService, private readonly channelExport: ReportChannelExportService) {} async listBatches(query: PagedQuery = {}) { const page = normalizePage(query.page); const pageSize = normalizePageSize(query.pageSize); const where: Prisma.ReportMaterialBatchWhereInput = { createdAt: dateRange(query.startAt, query.endAt), batchNo: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined, }; const [batches, total] = await Promise.all([ this.prisma.reportMaterialBatch.findMany({ where, include: { exportFiles: { include: { items: { include: { task: { select: { id: true, status: true } } } }, }, }, items: true, }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.reportMaterialBatch.count({ where }), ]); return { items: batches.map((batch) => { const reportItems = batch.exportFiles.flatMap((file) => file.items); const reportTotal = reportItems.length; const successCount = reportItems.filter((item) => item.task.status === 'approved').length; return { ...batch, reportTotal, successCount, successRate: reportTotal ? successCount / reportTotal : 0, }; }), total, page, pageSize, }; } async createBatch(data: CreateReportBatchDto) { if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息'); const idempotencyKey = normalizeBatchIdempotencyKey(data.idempotencyKey); const uniqueItems = [...new Map(data.items.map((item) => [`${item.reportType}:${item.drainageItemId ?? item.signatureId}`, item])).values()]; const fingerprint = createHash('sha256').update(JSON.stringify(uniqueItems.map((item) => ({ reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? null, materialVersion: item.materialVersion ?? null })).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))))).digest('hex'); const claimed = await this.operations.claimBatchOperation(idempotencyKey, fingerprint, data.createdById); if (claimed.replayed) return claimed.result; let preflight: Awaited>; try { preflight = await this.preflightBatch({ items: uniqueItems }); } catch (error) { await this.operations.failBatchOperation(claimed.operationId, error instanceof Error ? error.message : '报备资格预检失败'); throw error; } if (preflight.eligibleTargetCount === 0) { await this.operations.failBatchOperation(claimed.operationId, '没有可生成的报备目标'); throw new BadRequestException({ code: 'REPORT_BATCH_NOT_ELIGIBLE', message: '所选资料没有可生成的通道,请按资格检查补充后重试', preflight }); } const eligibleInspections = preflight.items.filter((item) => item.targets.some((target) => target.eligible)); const batch = await this.prisma.reportMaterialBatch.create({ data: { batchNo: `RB${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}${randomUUID().slice(0, 4).toUpperCase()}`, createdById: data.createdById, selectedCount: eligibleInspections.length }, }); try { const prepared = []; for (const inspection of eligibleInspections) { const selected = uniqueItems.find((item) => item.reportType === inspection.reportType && (item.drainageItemId ?? item.signatureId) === (inspection.drainageItemId ?? inspection.signatureId))!; prepared.push(await this.prepareBatchItem(batch.id, selected, inspection)); } const channelMap = new Map>(); for (const item of prepared) { for (const channel of item.channels) { const current = channelMap.get(channel.id) ?? []; current.push({ ...item, channels: [channel] }); channelMap.set(channel.id, current); } } const exportedFiles = []; const incomplete = new Set(prepared.filter((item) => item.channels.length === 0).map((item) => item.batchItem.id)); let failedTargetCount = 0; for (const [channelId, items] of channelMap) { const result = await this.channelExport.exportChannelBatch(batch.id, channelId, items); exportedFiles.push(result.file); failedTargetCount += result.incompleteBatchItemIds.length; for (const itemId of result.incompleteBatchItemIds) incomplete.add(itemId); } for (const item of prepared) { if (incomplete.has(item.batchItem.id) || item.channels.length === 0) continue; if (item.reportType === 'signature') await this.prisma.smsSignature.update({ where: { id: item.signature.id }, data: { pendingReport: false } }); else await this.prisma.smsDrainageInfo.update({ where: { id: item.drainageInfo!.id }, data: { pendingReport: false } }); } const completed = await this.prisma.reportMaterialBatch.update({ where: { id: batch.id }, data: { status: incomplete.size ? 'partial_failed' : 'completed', channelCount: channelMap.size, fileCount: exportedFiles.length, completedAt: new Date() }, include: { exportFiles: true, items: true } }); const result = { ...completed, operationId: claimed.operationId, replayed: false, result: { successCount: preflight.eligibleTargetCount - failedTargetCount, skippedCount: preflight.skippedTargetCount, failedCount: failedTargetCount, items: preflight.items, }, }; await this.operations.completeBatchOperation(claimed.operationId, batch.id, result); return result; } catch (error) { await this.prisma.reportMaterialBatch.update({ where: { id: batch.id }, data: { status: 'failed', errorMessage: error instanceof Error ? error.message : '生成报备批次失败', completedAt: new Date() } }); await this.operations.failBatchOperation(claimed.operationId, error instanceof Error ? error.message : '生成报备批次失败', batch.id); throw error; } } async preflightBatch(data: Pick) { if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息'); for (const item of data.items) { if (!['signature', 'drainage'].includes(item.reportType) || !item.signatureId) throw new BadRequestException({ code: 'REPORT_BATCH_ITEM_INVALID', message: '每条报备资料必须包含有效的资料类型和签名ID' }); if (item.reportType === 'drainage' && !item.drainageItemId) throw new BadRequestException({ code: 'REPORT_BATCH_ITEM_INVALID', message: '引流资料必须包含引流资料ID' }); } const uniqueItems = [...new Map(data.items.map((item) => [`${item.reportType}:${item.drainageItemId ?? item.signatureId}`, item])).values()]; const items = await Promise.all(uniqueItems.map((item) => this.inspectBatchItem(item))); return { checkedAt: new Date().toISOString(), eligible: items.some((item) => item.eligible), eligibleItemCount: items.filter((item) => item.eligible).length, blockedItemCount: items.filter((item) => !item.eligible).length, eligibleTargetCount: items.reduce((sum, item) => sum + item.targets.filter((target) => target.eligible).length, 0), skippedTargetCount: items.reduce((sum, item) => sum + item.targets.filter((target) => !target.eligible).length, 0), items, }; } async prepareBatchItem(batchId: string, selected: CreateReportBatchDto['items'][number], inspection: ReportBatchInspection) { const signature = await this.prisma.smsSignature.findUnique({ where: { id: selected.signatureId }, include: { tenant: true, application: true } }); if (!signature || signature.auditStatus !== 'approved') throw new BadRequestException('签名不存在或未审核通过'); const drainageInfo = selected.reportType === 'drainage' && selected.drainageItemId ? await this.prisma.smsDrainageInfo.findUnique({ where: { id: selected.drainageItemId } }) : null; if (selected.reportType === 'drainage' && (!drainageInfo || drainageInfo.signatureId !== signature.id || drainageInfo.auditStatus !== 'approved')) throw new BadRequestException('引流信息不存在或未审核通过'); const routes = signature.applicationId ? await this.prisma.channelRouteRule.findMany({ where: { applicationId: signature.applicationId, status: 'active' }, include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } }, orderBy: { priority: 'asc' }, }) : []; const eligibleChannelIds = new Set(inspection.targets.filter((target) => target.eligible).map((target) => target.id)); const channels = [...new Map(routes.flatMap((route) => route.group.items.map((entry) => entry.channel)).filter((channel) => channel.status === 'active' && eligibleChannelIds.has(channel.id)).map((channel) => [channel.id, channel])).values()]; const snapshot = selected.reportType === 'signature' ? { reportType: 'signature', applicationId: signature.applicationId, businessKeys: inspection.targets.filter((target) => target.eligible).map((target) => target.businessKey), signature: { id: signature.id, name: signature.name, purpose: signature.purpose, tenantName: signature.tenant.name, applicationName: signature.application?.name }, values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues) } : { reportType: 'drainage', applicationId: signature.applicationId, businessKeys: inspection.targets.filter((target) => target.eligible).map((target) => target.businessKey), signature: { id: signature.id, name: signature.name, tenantName: signature.tenant.name, applicationName: signature.application?.name }, drainage: { id: drainageInfo!.id, siteName: drainageInfo!.siteName, url: drainageInfo!.url, remark: drainageInfo!.remark }, values: jsonRecord(drainageInfo!.reportValues) }; const materialVersion = selected.reportType === 'signature' ? signature.materialVersion : drainageInfo!.materialVersion; const batchItem = await this.prisma.reportMaterialBatchItem.create({ data: { batchId, signatureId: signature.id, drainageItemId: drainageInfo?.id, reportType: selected.reportType, materialVersion, snapshot: snapshot as Prisma.InputJsonValue } }); return { batchItem, signature, drainageInfo, reportType: selected.reportType, snapshot, channels }; } async inspectBatchItem(selected: CreateReportBatchDto['items'][number]): Promise { const signature = await this.prisma.smsSignature.findUnique({ where: { id: selected.signatureId }, include: { tenant: true, application: true } }); if (!signature) throw new NotFoundException('签名不存在'); const drainageInfo = selected.reportType === 'drainage' && selected.drainageItemId ? await this.prisma.smsDrainageInfo.findUnique({ where: { id: selected.drainageItemId } }) : null; const materialVersion = selected.reportType === 'signature' ? signature.materialVersion : drainageInfo?.materialVersion ?? 0; const blockedReasons: string[] = []; if (signature.auditStatus !== 'approved') blockedReasons.push('签名尚未审核通过'); if (!signature.pendingReport) blockedReasons.push('该签名版本已不在待报备池'); if (!signature.applicationId || !signature.application) blockedReasons.push('未绑定短信应用'); else if (signature.application.status !== 'active') blockedReasons.push('短信应用未启用'); if (selected.materialVersion !== undefined && selected.materialVersion !== materialVersion) blockedReasons.push(`资料版本已变化(当前 V${materialVersion})`); if (selected.reportType === 'drainage') { if (!drainageInfo || drainageInfo.signatureId !== signature.id) blockedReasons.push('引流资料不存在或不属于当前签名'); else { if (drainageInfo.auditStatus !== 'approved') blockedReasons.push('引流资料尚未审核通过'); if (!drainageInfo.pendingReport) blockedReasons.push('该引流资料版本已不在待报备池'); } } const snapshot = selected.reportType === 'signature' ? { signature: { name: signature.name, purpose: signature.purpose, tenantName: signature.tenant.name, applicationName: signature.application?.name }, values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues) } : { signature: { name: signature.name, tenantName: signature.tenant.name, applicationName: signature.application?.name }, drainage: { siteName: drainageInfo?.siteName, url: drainageInfo?.url, remark: drainageInfo?.remark }, values: jsonRecord(drainageInfo?.reportValues) }; const routes = signature.applicationId ? await this.prisma.channelRouteRule.findMany({ where: { applicationId: signature.applicationId, status: 'active' }, include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } }, orderBy: { priority: 'asc' }, }) : []; const channelCarriers = new Map }>(); for (const route of routes) { if (route.group.status !== 'active') continue; for (const entry of route.group.items) { if (entry.channel.status !== 'active') continue; const current = channelCarriers.get(entry.channel.id) ?? { channel: entry.channel, carriers: new Set() }; current.carriers.add(route.carrier || entry.carrier || entry.channel.carrier || 'all'); channelCarriers.set(entry.channel.id, current); } } if (blockedReasons.length === 0 && channelCarriers.size === 0) blockedReasons.push('当前应用没有启用且可路由的通道'); const previous = await this.prisma.reportMaterialBatchItem.findMany({ where: { signatureId: signature.id, drainageItemId: selected.reportType === 'drainage' ? drainageInfo?.id : null, reportType: selected.reportType, materialVersion, batch: { status: { in: ['completed', 'partial_failed'] } } }, select: { batchId: true, snapshot: true, exportItems: { select: { exportFile: { select: { channelId: true } } } } }, orderBy: { createdAt: 'desc' }, }); const priorKeys = new Map(); for (const item of previous) { const exportedChannelIds = new Set(item.exportItems.map((entry) => entry.exportFile.channelId).filter((value): value is string => Boolean(value))); for (const key of jsonStringArray(jsonRecord(item.snapshot).businessKeys)) { if ([...exportedChannelIds].some((channelId) => key.includes(`:channel:${channelId}:`)) && !priorKeys.has(key)) priorKeys.set(key, item.batchId); } } const targets: ReportBatchTarget[] = []; for (const { channel, carriers } of channelCarriers.values()) { const carrier = [...carriers].sort().join(','); const businessKey = `${selected.reportType}:${selected.drainageItemId ?? signature.id}:v${materialVersion}:app:${signature.applicationId}:channel:${channel.id}:carrier:${carrier}`; const targetReasons = [...blockedReasons]; const fields = await this.prisma.channelReportField.findMany({ where: { channelId: channel.id, status: 'active', reportType: { in: [selected.reportType, 'both'] } }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] }); if (fields.length === 0) targetReasons.push('通道未配置当前资料类型的报备字段'); else { const missing = fields.filter((field) => field.required && !hasValue(resolveExportValue(snapshot, field.code, field.name) ?? field.defaultValue)); if (missing.length) targetReasons.push(`缺少必填字段:${missing.map((field) => field.exportName || field.name).join('、')}`); } const duplicateBatchId = priorKeys.get(businessKey); if (duplicateBatchId) targetReasons.push(`同一资料版本已在批次 ${duplicateBatchId} 生成`); targets.push({ id: channel.id, name: channel.name, carrier, businessKey, eligible: targetReasons.length === 0, blockedReasons: targetReasons, duplicateBatchId }); } return { id: `${selected.reportType}:${selected.drainageItemId ?? signature.id}`, reportType: selected.reportType, signatureId: signature.id, drainageItemId: drainageInfo?.id, materialVersion, name: selected.reportType === 'signature' ? signature.name : drainageInfo?.siteName ?? '引流资料', tenantName: signature.tenant.name, applicationId: signature.applicationId ?? undefined, applicationName: signature.application?.name ?? '未指定应用', eligible: targets.some((target) => target.eligible), blockedReasons: targets.length ? [...new Set(targets.flatMap((target) => target.blockedReasons))] : blockedReasons, targets, }; } }