feat: add phone frequency controls and modularize codebase
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user