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 ReportBatchOperationService { constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService) {} async claimBatchOperation(idempotencyKey: string, fingerprint: string, userId?: string) { return this.prisma.$transaction(async (tx) => { await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${idempotencyKey}))`; const existing = await tx.operationLog.findFirst({ where: { action: 'report_material.batch_generation', resource: 'report_material_batch', resourceId: idempotencyKey }, orderBy: { createdAt: 'desc' } }); if (existing) { const detail = jsonRecord(existing.detail); if (detail.fingerprint !== fingerprint) throw new ConflictException({ code: 'IDEMPOTENCY_KEY_REUSED', message: '该幂等键已用于不同的报备范围' }); if (detail.status === 'completed' && detail.result) return { operationId: existing.id, replayed: true as const, result: { ...jsonRecord(detail.result), replayed: true, operationId: existing.id } }; throw new ConflictException({ code: 'REPORT_BATCH_IN_PROGRESS', message: detail.status === 'failed' ? '上次生成失败,请使用新的操作单重试' : '该报备操作正在处理中,请勿重复提交' }); } const operation = await tx.operationLog.create({ data: { userId, action: 'report_material.batch_generation', resource: 'report_material_batch', resourceId: idempotencyKey, detail: { status: 'processing', fingerprint } } }); return { operationId: operation.id, replayed: false as const, result: null }; }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); } async completeBatchOperation(operationId: string, batchId: string, result: Record) { await this.prisma.operationLog.update({ where: { id: operationId }, data: { detail: { status: 'completed', batchId, fingerprint: jsonRecord((await this.prisma.operationLog.findUnique({ where: { id: operationId } }))?.detail).fingerprint, result: jsonSafe(result) } as Prisma.InputJsonValue } }); } async failBatchOperation(operationId: string, message: string, batchId?: string) { const operation = await this.prisma.operationLog.findUnique({ where: { id: operationId } }); await this.prisma.operationLog.update({ where: { id: operationId }, data: { detail: { ...jsonRecord(operation?.detail), status: 'failed', batchId, message } as Prisma.InputJsonValue } }); } }