feat: support WPS report material workbooks
This commit is contained in:
@@ -2,6 +2,8 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
|
||||
import JSZip from 'jszip';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { safeFileName } from './report-materials.helpers';
|
||||
import type { ReportWorkbookFormat } from './report-materials.contracts';
|
||||
import { convertWorkbookOutput } from './workbook-compatibility';
|
||||
|
||||
type BatchDownloadSource = {
|
||||
batchNo: string;
|
||||
@@ -29,7 +31,11 @@ type DownloadedBatchArtifact = {
|
||||
export class ReportBatchDownloadService {
|
||||
constructor(private readonly files: FilesService) {}
|
||||
|
||||
async exportFile(batch: BatchDownloadSource, fileId: string): Promise<DownloadedBatchArtifact> {
|
||||
async exportFile(
|
||||
batch: BatchDownloadSource,
|
||||
fileId: string,
|
||||
outputFormat: ReportWorkbookFormat = 'excel_drawing',
|
||||
): Promise<DownloadedBatchArtifact> {
|
||||
const brief = batch.briefs.find((item) => item.fileId === fileId);
|
||||
const exportFile = batch.exportFiles.find((item) => item.id === fileId);
|
||||
if (!brief || !exportFile?.fileObjectId) throw new NotFoundException('批次报备文件不存在');
|
||||
@@ -37,11 +43,14 @@ export class ReportBatchDownloadService {
|
||||
return {
|
||||
fileName: `${this.entryBaseName(batch, brief.channelName)}.xlsx`,
|
||||
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
content,
|
||||
content: await convertWorkbookOutput(content, outputFormat),
|
||||
};
|
||||
}
|
||||
|
||||
async exportBundle(batch: BatchDownloadSource): Promise<DownloadedBatchArtifact> {
|
||||
async exportBundle(
|
||||
batch: BatchDownloadSource,
|
||||
outputFormat: ReportWorkbookFormat = 'excel_drawing',
|
||||
): Promise<DownloadedBatchArtifact> {
|
||||
if (!batch.briefs.length) throw new BadRequestException('该批次暂无可导出的通道文件或简报');
|
||||
if (batch.briefs.length > 100) throw new BadRequestException('单次最多打包100个通道文件');
|
||||
const exportFileById = new Map(batch.exportFiles.map((item) => [item.id, item]));
|
||||
@@ -50,7 +59,10 @@ export class ReportBatchDownloadService {
|
||||
const exportFile = exportFileById.get(brief.fileId);
|
||||
if (!exportFile?.fileObjectId) throw new BadRequestException(`通道“${brief.channelName}”缺少报备文件`);
|
||||
const workbook = await this.files.getDownload(exportFile.fileObjectId);
|
||||
return { brief, workbook };
|
||||
return {
|
||||
brief,
|
||||
workbook: { ...workbook, content: await convertWorkbookOutput(workbook.content, outputFormat) },
|
||||
};
|
||||
}),
|
||||
);
|
||||
const totalBytes = downloaded.reduce((sum, item) => sum + item.workbook.content.length, 0);
|
||||
|
||||
@@ -1,45 +1,12 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, 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,
|
||||
SingleReportMaterialDto,
|
||||
} from './report-materials.contracts';
|
||||
import type { SingleReportMaterialDto } 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,
|
||||
@@ -47,12 +14,9 @@ import {
|
||||
applyExportTransform,
|
||||
styleHeader,
|
||||
normalizeImageExtension,
|
||||
imageContentType,
|
||||
safeFileName,
|
||||
normalizeBatchIdempotencyKey,
|
||||
jsonStringArray,
|
||||
jsonSafe,
|
||||
} from './report-materials.helpers';
|
||||
import { convertWorkbookOutput } from './workbook-compatibility';
|
||||
import type { ReportBatchGenerationService } from './batch-generation.service';
|
||||
|
||||
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
||||
@@ -97,7 +61,7 @@ export class ReportChannelExportService {
|
||||
);
|
||||
const smsContentField = fields.find((field) => field.name.trim() === '短信内容');
|
||||
const smsContentValue = smsContentField
|
||||
? resolveExportValue(item.snapshot, smsContentField.code, smsContentField.name) ?? ''
|
||||
? (resolveExportValue(item.snapshot, smsContentField.code, smsContentField.name) ?? '')
|
||||
: '';
|
||||
const smsContent = isFileRef(smsContentValue) ? '' : String(smsContentValue ?? '');
|
||||
const missing = fields.filter((field, index) => field.required && !hasValue(values[index]));
|
||||
@@ -292,6 +256,11 @@ export class ReportChannelExportService {
|
||||
});
|
||||
const configuredCodes = new Set(fields.map((field) => field.code));
|
||||
const values = jsonRecord(snapshot.values);
|
||||
const historicalCodes = Object.keys(values).filter((code) => !configuredCodes.has(code));
|
||||
const historicalDefinitions = historicalCodes.length
|
||||
? await this.prisma.drainageField.findMany({ where: { code: { in: historicalCodes } } })
|
||||
: [];
|
||||
const historicalDefinitionByCode = new Map(historicalDefinitions.map((field) => [field.code, field]));
|
||||
const materialFields = fields.map((field) => {
|
||||
const submittedValue = resolveExportValue(snapshot, field.code, field.name);
|
||||
const value = hasValue(submittedValue) ? submittedValue : field.defaultValue;
|
||||
@@ -312,9 +281,9 @@ export class ReportChannelExportService {
|
||||
};
|
||||
});
|
||||
const historicalFields = Object.entries(values)
|
||||
.filter(([code, value]) => !configuredCodes.has(code) && hasValue(value))
|
||||
.filter(([code]) => !configuredCodes.has(code) && historicalDefinitionByCode.get(code)?.status !== 'deleted')
|
||||
.sort(([left], [right]) => left.localeCompare(right, 'zh-CN'))
|
||||
.map(([code, value]) => ({ code, name: code, value }));
|
||||
.map(([code, value]) => ({ code, name: historicalDefinitionByCode.get(code)?.name ?? code, value }));
|
||||
return {
|
||||
reportType,
|
||||
signatureId: signature.id,
|
||||
@@ -380,7 +349,7 @@ export class ReportChannelExportService {
|
||||
}
|
||||
row.height = targetHeight;
|
||||
const fileName = `${safeFileName(detail.channel.name)}-${safeFileName(detail.signatureName)}-V${detail.materialVersion}.xlsx`;
|
||||
const content = Buffer.from(await workbook.xlsx.writeBuffer());
|
||||
const content = await convertWorkbookOutput(Buffer.from(await workbook.xlsx.writeBuffer()), data.outputFormat);
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: detail.tenant.id,
|
||||
|
||||
@@ -1,117 +1,177 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException } 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 type { AnalyzeImportOptions, CreateImportProfileDto, ImportMapping } from './report-materials.contracts';
|
||||
import {
|
||||
profileData,
|
||||
validateProfile,
|
||||
suggestMappings,
|
||||
remapProfileColumns,
|
||||
clamp,
|
||||
cellText,
|
||||
} from './report-materials.helpers';
|
||||
import { compatibleImages, loadCompatibleWorkbook } from './workbook-compatibility';
|
||||
|
||||
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
||||
export class ReportImportParserService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService) {}
|
||||
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' },
|
||||
});
|
||||
}
|
||||
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' } } } });
|
||||
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 };
|
||||
async analyzeImport(
|
||||
file: { originalname: string; mimetype: string; size: number; buffer: Buffer },
|
||||
options: AnalyzeImportOptions,
|
||||
) {
|
||||
if (!options.tenantId) throw new BadRequestException('tenantId is required');
|
||||
if (!options.applicationId) throw new BadRequestException('applicationId is required');
|
||||
await this.smsConfig.getApplication(options.applicationId, options.tenantId);
|
||||
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, wpsImagesBySheet } = await loadCompatibleWorkbook(file.buffer);
|
||||
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 = compatibleImages(workbook, worksheet, wpsImagesBySheet);
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,10 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, 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 type { ImportCommitDto, ImportMapping, PagedQuery, ReviewImportItemsDto } from './report-materials.contracts';
|
||||
import {
|
||||
profileData,
|
||||
validateProfile,
|
||||
loadWorkbook,
|
||||
assertSafeWorkbook,
|
||||
safeSpreadsheetText,
|
||||
readEmbeddedImages,
|
||||
suggestMappings,
|
||||
remapProfileColumns,
|
||||
signatureCoreMapping,
|
||||
drainageCoreMapping,
|
||||
normalizeHeader,
|
||||
normalizeFieldCode,
|
||||
clamp,
|
||||
normalizePage,
|
||||
normalizePageSize,
|
||||
dateRange,
|
||||
@@ -42,18 +15,11 @@ import {
|
||||
dynamicValues,
|
||||
jsonRecord,
|
||||
hasValue,
|
||||
isFileRef,
|
||||
resolveExportValue,
|
||||
applyExportTransform,
|
||||
styleHeader,
|
||||
normalizeImageExtension,
|
||||
imageContentType,
|
||||
safeFileName,
|
||||
normalizeBatchIdempotencyKey,
|
||||
jsonStringArray,
|
||||
jsonSafe,
|
||||
} 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 {
|
||||
@@ -76,11 +42,10 @@ export class ReportImportReviewService {
|
||||
columns: data.mappings,
|
||||
});
|
||||
const { content } = await this.files.getDownload(batch.fileObjectId);
|
||||
const workbook = await loadWorkbook(content);
|
||||
assertSafeWorkbook(workbook);
|
||||
const { workbook, wpsImagesBySheet } = await loadCompatibleWorkbook(content);
|
||||
const worksheet = workbook.getWorksheet(batch.sheetName);
|
||||
if (!worksheet) throw new BadRequestException('导入工作表不存在');
|
||||
const images = readEmbeddedImages(workbook, worksheet);
|
||||
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 }> = [];
|
||||
|
||||
@@ -64,6 +64,7 @@ export interface SingleReportMaterialDto {
|
||||
reportType?: 'signature' | 'drainage';
|
||||
drainageItemId?: string;
|
||||
batchItemId?: string;
|
||||
outputFormat?: ReportWorkbookFormat;
|
||||
}
|
||||
|
||||
export type ReportBatchTarget = {
|
||||
@@ -94,7 +95,7 @@ export type ReportBatchInspection = {
|
||||
|
||||
export type AnalyzeImportOptions = {
|
||||
tenantId: string;
|
||||
applicationId?: string;
|
||||
applicationId: string;
|
||||
reportType: 'signature' | 'drainage';
|
||||
sheetName?: string;
|
||||
headerRowCount: number;
|
||||
@@ -104,3 +105,5 @@ export type AnalyzeImportOptions = {
|
||||
};
|
||||
|
||||
export type EmbeddedImage = { row: number; column: number; extension: string; buffer: Buffer };
|
||||
|
||||
export type ReportWorkbookFormat = 'excel_drawing' | 'wps_cell_image';
|
||||
|
||||
@@ -93,7 +93,9 @@ export class ReportMaterialsController {
|
||||
}
|
||||
|
||||
@Post('imports/analyze')
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 12, parts: 13 } }))
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', { limits: { fileSize: 100 * 1024 * 1024, files: 1, fields: 12, parts: 13 } }),
|
||||
)
|
||||
analyzeImport(
|
||||
@UploadedFile() file: UploadedWorkbook,
|
||||
@Body() body: Record<string, string>,
|
||||
@@ -102,7 +104,7 @@ export class ReportMaterialsController {
|
||||
if (!file) throw new BadRequestException('请选择 XLSX 文件');
|
||||
return this.service.analyzeImport(file, {
|
||||
tenantId: body.tenantId,
|
||||
applicationId: body.applicationId || undefined,
|
||||
applicationId: body.applicationId,
|
||||
reportType: body.reportType as 'signature' | 'drainage',
|
||||
sheetName: body.sheetName || undefined,
|
||||
headerRowCount: Number(body.headerRowCount || 1),
|
||||
@@ -161,13 +163,25 @@ export class ReportMaterialsController {
|
||||
}
|
||||
|
||||
@Get('batches/:id/download')
|
||||
async downloadBatch(@Param('id') id: string, @Res() response: DownloadResponse) {
|
||||
this.sendDownload(response, await this.batchDownloads.exportBundle(await this.service.getBatch(id)));
|
||||
async downloadBatch(
|
||||
@Param('id') id: string,
|
||||
@Query('outputFormat') outputFormat: 'excel_drawing' | 'wps_cell_image' | undefined,
|
||||
@Res() response: DownloadResponse,
|
||||
) {
|
||||
this.sendDownload(response, await this.batchDownloads.exportBundle(await this.service.getBatch(id), outputFormat));
|
||||
}
|
||||
|
||||
@Get('batches/:id/files/:fileId/download')
|
||||
async downloadBatchFile(@Param('id') id: string, @Param('fileId') fileId: string, @Res() response: DownloadResponse) {
|
||||
this.sendDownload(response, await this.batchDownloads.exportFile(await this.service.getBatch(id), fileId));
|
||||
async downloadBatchFile(
|
||||
@Param('id') id: string,
|
||||
@Param('fileId') fileId: string,
|
||||
@Query('outputFormat') outputFormat: 'excel_drawing' | 'wps_cell_image' | undefined,
|
||||
@Res() response: DownloadResponse,
|
||||
) {
|
||||
this.sendDownload(
|
||||
response,
|
||||
await this.batchDownloads.exportFile(await this.service.getBatch(id), fileId, outputFormat),
|
||||
);
|
||||
}
|
||||
|
||||
@Get('batches/:id')
|
||||
|
||||
@@ -4,6 +4,22 @@ import { ReportMaterialsService } from './report-materials.service';
|
||||
import { mappedCorePatchValue } from './report-materials.helpers';
|
||||
|
||||
describe('ReportMaterialsService', () => {
|
||||
it('requires an enterprise application before parsing an import workbook', async () => {
|
||||
const service = new ReportMaterialsService({} as never, { upload: jest.fn() } as never, {} as never);
|
||||
await expect(
|
||||
service.analyzeImport(
|
||||
{ originalname: '资料.xlsx', mimetype: '', size: 2, buffer: Buffer.from('PK') },
|
||||
{
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: '',
|
||||
reportType: 'signature',
|
||||
headerRowCount: 1,
|
||||
dataStartRow: 2,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('applicationId is required');
|
||||
});
|
||||
|
||||
it('rejects an unparsed single-export body as a readable 400 instead of throwing a TypeError', async () => {
|
||||
const service = new ReportMaterialsService({} as never, {} as never, {} as never);
|
||||
await expect(service.exportSingleMaterial(undefined, 'operator-1')).rejects.toThrow('导出参数不能为空');
|
||||
@@ -59,7 +75,7 @@ describe('ReportMaterialsService', () => {
|
||||
const service = new ReportMaterialsService(
|
||||
{ reportMaterialImportProfile: { findUnique: jest.fn() } } as never,
|
||||
files as never,
|
||||
{} as never,
|
||||
{ getApplication: jest.fn().mockResolvedValue({ id: 'app-1' }) } as never,
|
||||
);
|
||||
|
||||
await expect(
|
||||
@@ -70,9 +86,9 @@ describe('ReportMaterialsService', () => {
|
||||
size: buffer.length,
|
||||
buffer,
|
||||
},
|
||||
{ tenantId: 'tenant-1', reportType: 'signature', headerRowCount: 1, dataStartRow: 2 },
|
||||
{ tenantId: 'tenant-1', applicationId: 'app-1', reportType: 'signature', headerRowCount: 1, dataStartRow: 2 },
|
||||
),
|
||||
).rejects.toThrow('公式或可执行单元格');
|
||||
).rejects.toThrow('公式');
|
||||
expect(files.upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -90,23 +106,21 @@ describe('ReportMaterialsService', () => {
|
||||
const buffer = Buffer.from(await workbook.xlsx.writeBuffer());
|
||||
const prisma = {
|
||||
reportMaterialImportProfile: {
|
||||
findUnique: jest
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
sheetName: '签名资料',
|
||||
columns: [
|
||||
{
|
||||
sourceHeader: '短信签名',
|
||||
sourceHeaderPath: '短信签名',
|
||||
sourceColumnIndex: 9,
|
||||
targetFieldCode: 'signature_name',
|
||||
targetKind: 'signatureName',
|
||||
fieldType: 'string',
|
||||
required: true,
|
||||
sortOrder: 10,
|
||||
},
|
||||
],
|
||||
}),
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
sheetName: '签名资料',
|
||||
columns: [
|
||||
{
|
||||
sourceHeader: '短信签名',
|
||||
sourceHeaderPath: '短信签名',
|
||||
sourceColumnIndex: 9,
|
||||
targetFieldCode: 'signature_name',
|
||||
targetKind: 'signatureName',
|
||||
fieldType: 'string',
|
||||
required: true,
|
||||
sortOrder: 10,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
reportMaterialImportBatch: {
|
||||
create: jest
|
||||
@@ -118,15 +132,17 @@ describe('ReportMaterialsService', () => {
|
||||
operationLog: { create: jest.fn().mockResolvedValue({ id: 'log-1' }) },
|
||||
};
|
||||
const files = {
|
||||
upload: jest
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
id: 'source-1',
|
||||
fileName: '签名资料.xlsx',
|
||||
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
}),
|
||||
upload: jest.fn().mockResolvedValue({
|
||||
id: 'source-1',
|
||||
fileName: '签名资料.xlsx',
|
||||
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
}),
|
||||
};
|
||||
const service = new ReportMaterialsService(prisma as never, files as never, {} as never);
|
||||
const service = new ReportMaterialsService(
|
||||
prisma as never,
|
||||
files as never,
|
||||
{ getApplication: jest.fn().mockResolvedValue({ id: 'app-1' }) } as never,
|
||||
);
|
||||
|
||||
const result = await service.analyzeImport(
|
||||
{
|
||||
@@ -181,42 +197,38 @@ describe('ReportMaterialsService', () => {
|
||||
),
|
||||
},
|
||||
smsSignature: {
|
||||
findUnique: jest
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
id: 'signature-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
name: '测试签名',
|
||||
purpose: '验证码',
|
||||
auditStatus: 'approved',
|
||||
pendingReport: true,
|
||||
materialVersion: 3,
|
||||
drainageInfo: {
|
||||
signatureReportValues: {
|
||||
license: { fileObjectId: 'image-1', fileName: 'license.png', contentType: 'image/png' },
|
||||
sms_content_primary: '第一条短信内容',
|
||||
sms_content_secondary: '第二条短信内容',
|
||||
},
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: 'signature-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
name: '测试签名',
|
||||
purpose: '验证码',
|
||||
auditStatus: 'approved',
|
||||
pendingReport: true,
|
||||
materialVersion: 3,
|
||||
drainageInfo: {
|
||||
signatureReportValues: {
|
||||
license: { fileObjectId: 'image-1', fileName: 'license.png', contentType: 'image/png' },
|
||||
sms_content_primary: '第一条短信内容',
|
||||
sms_content_secondary: '第二条短信内容',
|
||||
},
|
||||
tenant: { name: '测试企业' },
|
||||
application: { name: '测试应用', status: 'active' },
|
||||
}),
|
||||
},
|
||||
tenant: { name: '测试企业' },
|
||||
application: { name: '测试应用', status: 'active' },
|
||||
}),
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
},
|
||||
smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() },
|
||||
channelRouteRule: {
|
||||
findMany: jest
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{
|
||||
carrier: 'mobile',
|
||||
group: {
|
||||
status: 'active',
|
||||
items: channels.map((channel, index) => ({ priority: index, carrier: 'mobile', channel })),
|
||||
},
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{
|
||||
carrier: 'mobile',
|
||||
group: {
|
||||
status: 'active',
|
||||
items: channels.map((channel, index) => ({ priority: index, carrier: 'mobile', channel })),
|
||||
},
|
||||
]),
|
||||
},
|
||||
]),
|
||||
},
|
||||
reportMaterialBatchItem: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
@@ -231,52 +243,54 @@ describe('ReportMaterialsService', () => {
|
||||
),
|
||||
},
|
||||
channelReportField: {
|
||||
findMany: jest.fn().mockImplementation(({ where }: { where: { channelId: string } }) => Promise.resolve([
|
||||
{
|
||||
code: 'sign',
|
||||
name: '短信签名',
|
||||
exportName: '通道签名',
|
||||
required: true,
|
||||
columnWidth: 18,
|
||||
imageWidth: 120,
|
||||
imageHeight: 80,
|
||||
transform: null,
|
||||
defaultValue: null,
|
||||
},
|
||||
{
|
||||
code: 'license',
|
||||
name: '营业执照',
|
||||
exportName: '营业执照图片',
|
||||
required: true,
|
||||
columnWidth: 24,
|
||||
imageWidth: 120,
|
||||
imageHeight: 80,
|
||||
transform: null,
|
||||
defaultValue: null,
|
||||
},
|
||||
{
|
||||
code: where.channelId === 'channel-b' ? 'sms_content_missing' : 'sms_content_primary',
|
||||
name: '短信内容',
|
||||
exportName: '短信内容一',
|
||||
required: false,
|
||||
columnWidth: 24,
|
||||
imageWidth: 120,
|
||||
imageHeight: 80,
|
||||
transform: null,
|
||||
defaultValue: '第一条短信内容',
|
||||
},
|
||||
{
|
||||
code: 'sms_content_secondary',
|
||||
name: '短信内容',
|
||||
exportName: '短信内容二',
|
||||
required: false,
|
||||
columnWidth: 24,
|
||||
imageWidth: 120,
|
||||
imageHeight: 80,
|
||||
transform: null,
|
||||
defaultValue: '第二条短信内容',
|
||||
},
|
||||
])),
|
||||
findMany: jest.fn().mockImplementation(({ where }: { where: { channelId: string } }) =>
|
||||
Promise.resolve([
|
||||
{
|
||||
code: 'sign',
|
||||
name: '短信签名',
|
||||
exportName: '通道签名',
|
||||
required: true,
|
||||
columnWidth: 18,
|
||||
imageWidth: 120,
|
||||
imageHeight: 80,
|
||||
transform: null,
|
||||
defaultValue: null,
|
||||
},
|
||||
{
|
||||
code: 'license',
|
||||
name: '营业执照',
|
||||
exportName: '营业执照图片',
|
||||
required: true,
|
||||
columnWidth: 24,
|
||||
imageWidth: 120,
|
||||
imageHeight: 80,
|
||||
transform: null,
|
||||
defaultValue: null,
|
||||
},
|
||||
{
|
||||
code: where.channelId === 'channel-b' ? 'sms_content_missing' : 'sms_content_primary',
|
||||
name: '短信内容',
|
||||
exportName: '短信内容一',
|
||||
required: false,
|
||||
columnWidth: 24,
|
||||
imageWidth: 120,
|
||||
imageHeight: 80,
|
||||
transform: null,
|
||||
defaultValue: '第一条短信内容',
|
||||
},
|
||||
{
|
||||
code: 'sms_content_secondary',
|
||||
name: '短信内容',
|
||||
exportName: '短信内容二',
|
||||
required: false,
|
||||
columnWidth: 24,
|
||||
imageWidth: 120,
|
||||
imageHeight: 80,
|
||||
transform: null,
|
||||
defaultValue: '第二条短信内容',
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
channelSignatureReportTask: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
@@ -299,15 +313,13 @@ describe('ReportMaterialsService', () => {
|
||||
reportExportFileItem: { createMany: jest.fn().mockResolvedValue({ count: 1 }) },
|
||||
};
|
||||
const files = {
|
||||
getDownload: jest
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
fileObject: { fileName: 'license.png', contentType: 'image/png' },
|
||||
content: Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZsmAAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
),
|
||||
}),
|
||||
getDownload: jest.fn().mockResolvedValue({
|
||||
fileObject: { fileName: 'license.png', contentType: 'image/png' },
|
||||
content: Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZsmAAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
),
|
||||
}),
|
||||
upload: jest
|
||||
.fn()
|
||||
.mockImplementation((_options: unknown, file: { originalname: string; mimetype: string; buffer: Buffer }) => {
|
||||
@@ -370,35 +382,31 @@ describe('ReportMaterialsService', () => {
|
||||
update: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve(data)),
|
||||
},
|
||||
smsSignature: {
|
||||
findUnique: jest
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
id: 'signature-2',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
name: '测试签名',
|
||||
auditStatus: 'approved',
|
||||
pendingReport: true,
|
||||
materialVersion: 1,
|
||||
drainageInfo: {},
|
||||
tenant: { name: '企业' },
|
||||
application: { name: '应用', status: 'active' },
|
||||
}),
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: 'signature-2',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
name: '测试签名',
|
||||
auditStatus: 'approved',
|
||||
pendingReport: true,
|
||||
materialVersion: 1,
|
||||
drainageInfo: {},
|
||||
tenant: { name: '企业' },
|
||||
application: { name: '应用', status: 'active' },
|
||||
}),
|
||||
update: jest.fn(),
|
||||
},
|
||||
smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() },
|
||||
channelRouteRule: {
|
||||
findMany: jest
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{
|
||||
carrier: 'mobile',
|
||||
group: {
|
||||
status: 'active',
|
||||
items: [{ carrier: 'mobile', channel: { id: 'channel-a', name: '通道A', status: 'active' } }],
|
||||
},
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{
|
||||
carrier: 'mobile',
|
||||
group: {
|
||||
status: 'active',
|
||||
items: [{ carrier: 'mobile', channel: { id: 'channel-a', name: '通道A', status: 'active' } }],
|
||||
},
|
||||
]),
|
||||
},
|
||||
]),
|
||||
},
|
||||
reportMaterialBatchItem: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
@@ -420,13 +428,11 @@ describe('ReportMaterialsService', () => {
|
||||
reportExportFileItem: { createMany: jest.fn() },
|
||||
};
|
||||
const files = {
|
||||
upload: jest
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
id: 'file-2',
|
||||
fileName: 'empty.xlsx',
|
||||
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
}),
|
||||
upload: jest.fn().mockResolvedValue({
|
||||
id: 'file-2',
|
||||
fileName: 'empty.xlsx',
|
||||
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
}),
|
||||
};
|
||||
const service = new ReportMaterialsService(prisma as never, files as never, {} as never);
|
||||
|
||||
@@ -446,21 +452,19 @@ describe('ReportMaterialsService', () => {
|
||||
$transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)),
|
||||
$executeRaw: jest.fn().mockResolvedValue(1),
|
||||
operationLog: {
|
||||
findFirst: jest
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
id: 'operation-existing',
|
||||
detail: {
|
||||
findFirst: jest.fn().mockResolvedValue({
|
||||
id: 'operation-existing',
|
||||
detail: {
|
||||
status: 'completed',
|
||||
fingerprint: expect.anything(),
|
||||
result: {
|
||||
id: 'batch-existing',
|
||||
batchNo: 'RB-EXISTING',
|
||||
status: 'completed',
|
||||
fingerprint: expect.anything(),
|
||||
result: {
|
||||
id: 'batch-existing',
|
||||
batchNo: 'RB-EXISTING',
|
||||
status: 'completed',
|
||||
result: { successCount: 1, skippedCount: 0, failedCount: 0, items: [] },
|
||||
},
|
||||
result: { successCount: 1, skippedCount: 0, failedCount: 0, items: [] },
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
reportMaterialBatch: { create: jest.fn() },
|
||||
};
|
||||
@@ -607,10 +611,7 @@ describe('ReportMaterialsService', () => {
|
||||
batchNo: 'RB-STATS-1',
|
||||
exportFiles: [
|
||||
{
|
||||
items: [
|
||||
{ task: { id: 'task-1', status: 'approved' } },
|
||||
{ task: { id: 'task-2', status: 'rejected' } },
|
||||
],
|
||||
items: [{ task: { id: 'task-1', status: 'approved' } }, { task: { id: 'task-2', status: 'rejected' } }],
|
||||
},
|
||||
{
|
||||
items: [
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import ExcelJS from 'exceljs';
|
||||
import JSZip from 'jszip';
|
||||
import { compatibleImages, convertWorkbookOutput, loadCompatibleWorkbook } from './workbook-compatibility';
|
||||
|
||||
const PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZrYQAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
|
||||
async function standardWorkbook() {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('签名报备');
|
||||
sheet.getCell('A1').value = '营业执照';
|
||||
sheet.getCell('A2').value = 'license.png';
|
||||
const imageId = workbook.addImage({ buffer: PNG as never, extension: 'png' });
|
||||
sheet.addImage(imageId, { tl: { col: 0, row: 1 }, br: { col: 0.9, row: 1.9 }, editAs: 'oneCell' } as never);
|
||||
return Buffer.from(await workbook.xlsx.writeBuffer());
|
||||
}
|
||||
|
||||
describe('WPS workbook compatibility', () => {
|
||||
it('converts a standard Drawing image to DISPIMG and reads it back from cellimages.xml', async () => {
|
||||
const converted = await convertWorkbookOutput(await standardWorkbook(), 'wps_cell_image');
|
||||
const zip = await JSZip.loadAsync(converted);
|
||||
await expect(zip.file('xl/cellimages.xml')!.async('string')).resolves.toContain('ID_');
|
||||
const loaded = await loadCompatibleWorkbook(converted);
|
||||
const images = compatibleImages(
|
||||
loaded.workbook,
|
||||
loaded.workbook.getWorksheet('签名报备')!,
|
||||
loaded.wpsImagesBySheet,
|
||||
);
|
||||
expect(images).toHaveLength(1);
|
||||
expect(images[0]).toMatchObject({ row: 2, column: 1, extension: 'png' });
|
||||
expect(images[0].buffer.equals(PNG)).toBe(true);
|
||||
});
|
||||
|
||||
it('still rejects ordinary formulas instead of weakening spreadsheet safety', async () => {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.addWorksheet('危险').getCell('A1').value = { formula: 'HYPERLINK("https://example.com")' };
|
||||
const content = Buffer.from(await workbook.xlsx.writeBuffer());
|
||||
await expect(loadCompatibleWorkbook(content)).rejects.toThrow('不允许的公式');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import ExcelJS from 'exceljs';
|
||||
import JSZip from 'jszip';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { EmbeddedImage, ReportWorkbookFormat } from './report-materials.contracts';
|
||||
import { normalizeImageExtension, readEmbeddedImages } from './report-materials.helpers';
|
||||
|
||||
const MAX_WORKBOOK_BYTES = 100 * 1024 * 1024;
|
||||
const MAX_EXPANDED_BYTES = 500 * 1024 * 1024;
|
||||
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
||||
const MAX_TOTAL_IMAGE_BYTES = 300 * 1024 * 1024;
|
||||
const DISPIMG_FORMULA = /^_xlfn\.DISPIMG\(["'](ID_[A-F0-9]{32})["'],1\)$/i;
|
||||
|
||||
function decodeXml(value: string) {
|
||||
return value
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
function attributes(source: string) {
|
||||
return Object.fromEntries(
|
||||
[...source.matchAll(/([\w:-]+)="([^"]*)"/g)].map((match) => [match[1], decodeXml(match[2])]),
|
||||
);
|
||||
}
|
||||
|
||||
function packagePath(target: string) {
|
||||
return `xl/${target.replace(/^\/?xl\//, '').replace(/^\//, '')}`.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
function coordinates(address: string) {
|
||||
const match = /^([A-Z]+)(\d+)$/.exec(address.toUpperCase());
|
||||
if (!match) return null;
|
||||
let column = 0;
|
||||
for (const character of match[1]) column = column * 26 + character.charCodeAt(0) - 64;
|
||||
return { row: Number(match[2]), column };
|
||||
}
|
||||
|
||||
async function text(zip: JSZip, path: string) {
|
||||
return zip.file(path)?.async('string') ?? '';
|
||||
}
|
||||
|
||||
function validImageSignature(extension: string, buffer: Buffer) {
|
||||
if (extension === 'png')
|
||||
return buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
||||
if (extension === 'jpeg') return buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
|
||||
if (extension === 'gif') return buffer.subarray(0, 3).toString('ascii') === 'GIF';
|
||||
return false;
|
||||
}
|
||||
|
||||
function validateWorkbookValues(workbook: ExcelJS.Workbook, allowedWpsIds: Set<string>) {
|
||||
for (const worksheet of workbook.worksheets) {
|
||||
worksheet.eachRow((row) =>
|
||||
row.eachCell((cell) => {
|
||||
const value = cell.value;
|
||||
if (value && typeof value === 'object' && ('formula' in value || 'sharedFormula' in value)) {
|
||||
const formula =
|
||||
typeof (value as { formula?: unknown }).formula === 'string'
|
||||
? (value as { formula: string }).formula.trim()
|
||||
: '';
|
||||
const match = DISPIMG_FORMULA.exec(formula);
|
||||
if (!match || !allowedWpsIds.has(match[1]))
|
||||
throw new BadRequestException(`工作表 ${worksheet.name} 包含不允许的公式`);
|
||||
}
|
||||
const valueText = typeof value === 'string' ? value.trimStart() : '';
|
||||
if (/^[=+@]/.test(valueText) || /^-[^\d.]/.test(valueText))
|
||||
throw new BadRequestException(`工作表 ${worksheet.name} 包含公式或可执行单元格`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectWpsImages(zip: JSZip) {
|
||||
const cellImagesXml = await text(zip, 'xl/cellimages.xml');
|
||||
if (!cellImagesXml) return { allowedIds: new Set<string>(), imagesBySheet: new Map<string, EmbeddedImage[]>() };
|
||||
const relXml = await text(zip, 'xl/_rels/cellimages.xml.rels');
|
||||
const relTargets = new Map<string, string>();
|
||||
for (const match of relXml.matchAll(/<Relationship\b([^>]*)\/?>(?:<\/Relationship>)?/g)) {
|
||||
const attrs = attributes(match[1]);
|
||||
if (attrs.Id && attrs.Target && /\/image$/.test(attrs.Type ?? ''))
|
||||
relTargets.set(attrs.Id, packagePath(attrs.Target));
|
||||
}
|
||||
const imageTargets = new Map<string, string>();
|
||||
for (const match of cellImagesXml.matchAll(
|
||||
/<etc:cellImage\b[^>]*>[\s\S]*?<xdr:cNvPr\b([^>]*)\/?>(?:[\s\S]*?)<a:blip\b([^>]*)\/?>(?:[\s\S]*?)<\/etc:cellImage>/g,
|
||||
)) {
|
||||
const id = attributes(match[1]).name;
|
||||
const target = relTargets.get(attributes(match[2])['r:embed']);
|
||||
if (id && target) imageTargets.set(id, target);
|
||||
}
|
||||
const workbookXml = await text(zip, 'xl/workbook.xml');
|
||||
const workbookRelsXml = await text(zip, 'xl/_rels/workbook.xml.rels');
|
||||
const sheetRelTargets = new Map<string, string>();
|
||||
for (const match of workbookRelsXml.matchAll(/<Relationship\b([^>]*)\/?>(?:<\/Relationship>)?/g)) {
|
||||
const attrs = attributes(match[1]);
|
||||
if (attrs.Id && attrs.Target && /\/worksheet$/.test(attrs.Type ?? ''))
|
||||
sheetRelTargets.set(attrs.Id, packagePath(attrs.Target));
|
||||
}
|
||||
const allowedIds = new Set<string>();
|
||||
const imagesBySheet = new Map<string, EmbeddedImage[]>();
|
||||
for (const match of workbookXml.matchAll(/<sheet\b([^>]*)\/?>(?:<\/sheet>)?/g)) {
|
||||
const attrs = attributes(match[1]);
|
||||
const path = sheetRelTargets.get(attrs['r:id']);
|
||||
if (!attrs.name || !path) continue;
|
||||
const sheetXml = await text(zip, path);
|
||||
const images: EmbeddedImage[] = [];
|
||||
for (const cell of sheetXml.matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
|
||||
const formulaText = decodeXml(cell[2].match(/<f(?:\s[^>]*)?>([\s\S]*?)<\/f>/)?.[1]?.trim() ?? '');
|
||||
if (!formulaText) continue;
|
||||
const formula = DISPIMG_FORMULA.exec(formulaText);
|
||||
if (!formula) throw new BadRequestException(`工作簿包含不允许的公式:${formulaText.slice(0, 80)}`);
|
||||
const target = imageTargets.get(formula[1]);
|
||||
const cellPosition = coordinates(attributes(cell[1]).r);
|
||||
if (!target || !cellPosition) throw new BadRequestException('WPS单元格图片关系不完整');
|
||||
const imageEntry = zip.file(target);
|
||||
if (!imageEntry) throw new BadRequestException('WPS单元格图片文件缺失');
|
||||
const extension = normalizeImageExtension(target.split('.').pop() ?? 'png');
|
||||
if (!['png', 'jpeg', 'gif'].includes(extension)) throw new BadRequestException('WPS单元格图片格式不受支持');
|
||||
const imageBuffer = await imageEntry.async('nodebuffer');
|
||||
if (imageBuffer.length > MAX_IMAGE_BYTES) throw new BadRequestException('单张WPS图片不能超过20MB');
|
||||
if (!validImageSignature(extension, imageBuffer)) throw new BadRequestException('WPS单元格图片内容与格式不匹配');
|
||||
images.push({ ...cellPosition, extension, buffer: imageBuffer });
|
||||
allowedIds.add(formula[1]);
|
||||
}
|
||||
imagesBySheet.set(attrs.name, images);
|
||||
}
|
||||
const totalImageBytes = [...imagesBySheet.values()].flat().reduce((sum, image) => sum + image.buffer.length, 0);
|
||||
if (totalImageBytes > MAX_TOTAL_IMAGE_BYTES) throw new BadRequestException('WPS图片总量不能超过300MB');
|
||||
return { allowedIds, imagesBySheet };
|
||||
}
|
||||
|
||||
export async function loadCompatibleWorkbook(buffer: Buffer) {
|
||||
if (buffer.length > MAX_WORKBOOK_BYTES) throw new BadRequestException('导入文件不能超过100MB');
|
||||
let zip: JSZip;
|
||||
try {
|
||||
zip = await JSZip.loadAsync(buffer);
|
||||
} catch {
|
||||
throw new BadRequestException('仅支持有效的 XLSX 文件');
|
||||
}
|
||||
const expandedBytes = Object.values(zip.files).reduce(
|
||||
(sum, entry) =>
|
||||
sum + Number((entry as unknown as { _data?: { uncompressedSize?: number } })._data?.uncompressedSize ?? 0),
|
||||
0,
|
||||
);
|
||||
if (Object.keys(zip.files).length > 3000) throw new BadRequestException('工作簿ZIP条目数量超过安全限制');
|
||||
if (expandedBytes > MAX_EXPANDED_BYTES) throw new BadRequestException('工作簿解压后超过500MB安全限制');
|
||||
if (Object.keys(zip.files).some((name) => /(^|\/)(vbaProject|externalLinks|embeddings|activeX)(\/|\.)/i.test(name)))
|
||||
throw new BadRequestException('工作簿包含不允许的外部对象或宏');
|
||||
const wps = await inspectWpsImages(zip);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(buffer as never);
|
||||
validateWorkbookValues(workbook, wps.allowedIds);
|
||||
return { workbook, wpsImagesBySheet: wps.imagesBySheet };
|
||||
}
|
||||
|
||||
export function compatibleImages(
|
||||
workbook: ExcelJS.Workbook,
|
||||
worksheet: ExcelJS.Worksheet,
|
||||
wpsImagesBySheet: Map<string, EmbeddedImage[]>,
|
||||
) {
|
||||
const merged = new Map<string, EmbeddedImage>();
|
||||
const wpsImages = wpsImagesBySheet.get(worksheet.name) ?? [];
|
||||
for (const image of readEmbeddedImages(workbook, worksheet)) {
|
||||
if (wpsImages.length && image.buffer.length <= 128) continue;
|
||||
const extension = normalizeImageExtension(image.extension);
|
||||
if (image.buffer.length > MAX_IMAGE_BYTES) throw new BadRequestException('单张工作簿图片不能超过20MB');
|
||||
if (!validImageSignature(extension, image.buffer)) throw new BadRequestException('工作簿图片内容与格式不匹配');
|
||||
merged.set(`${image.row}:${image.column}`, image);
|
||||
}
|
||||
for (const image of wpsImages) merged.set(`${image.row}:${image.column}`, image);
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
export async function convertWorkbookOutput(content: Buffer, outputFormat: ReportWorkbookFormat = 'excel_drawing') {
|
||||
if (outputFormat === 'excel_drawing') return content;
|
||||
if (outputFormat !== 'wps_cell_image') throw new BadRequestException('不支持的报备文件格式');
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(content as never);
|
||||
const pictures: Array<{
|
||||
sheetName: string;
|
||||
address: string;
|
||||
extension: string;
|
||||
buffer: Buffer;
|
||||
id: string;
|
||||
relId: string;
|
||||
mediaPath: string;
|
||||
}> = [];
|
||||
for (const sheet of workbook.worksheets) {
|
||||
for (const image of readEmbeddedImages(workbook, sheet)) {
|
||||
const extension = normalizeImageExtension(image.extension);
|
||||
pictures.push({
|
||||
sheetName: sheet.name,
|
||||
address: `${sheet.getColumn(image.column).letter}${image.row}`,
|
||||
extension,
|
||||
buffer: image.buffer,
|
||||
id: `ID_${randomUUID().replace(/-/g, '').toUpperCase()}`,
|
||||
relId: `rId${pictures.length + 1}`,
|
||||
mediaPath: `xl/media/wps-cell-image-${pictures.length + 1}.${extension === 'jpeg' ? 'jpg' : extension}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!pictures.length) return content;
|
||||
const zip = await JSZip.loadAsync(content);
|
||||
Object.keys(zip.files)
|
||||
.filter((name) => /^xl\/media\//.test(name))
|
||||
.forEach((name) => zip.remove(name));
|
||||
const workbookXml = await text(zip, 'xl/workbook.xml');
|
||||
let workbookRels = await text(zip, 'xl/_rels/workbook.xml.rels');
|
||||
const relTargets = new Map<string, string>();
|
||||
for (const match of workbookRels.matchAll(/<Relationship\b([^>]*)\/?>(?:<\/Relationship>)?/g)) {
|
||||
const attrs = attributes(match[1]);
|
||||
if (attrs.Id && attrs.Target && /\/worksheet$/.test(attrs.Type ?? ''))
|
||||
relTargets.set(attrs.Id, packagePath(attrs.Target));
|
||||
}
|
||||
const sheetTargets = new Map<string, string>();
|
||||
for (const match of workbookXml.matchAll(/<sheet\b([^>]*)\/?>(?:<\/sheet>)?/g)) {
|
||||
const attrs = attributes(match[1]);
|
||||
const target = relTargets.get(attrs['r:id']);
|
||||
if (attrs.name && target) sheetTargets.set(attrs.name, target);
|
||||
}
|
||||
for (const [sheetName, path] of sheetTargets) {
|
||||
const sheetPictures = pictures.filter((picture) => picture.sheetName === sheetName);
|
||||
if (!sheetPictures.length) continue;
|
||||
let sheetXml = await text(zip, path);
|
||||
sheetXml = sheetXml.replace(/<drawing\b[^>]*\/?>(?:<\/drawing>)?/g, '');
|
||||
for (const picture of sheetPictures) {
|
||||
const fullCell = new RegExp(`<c\\b([^>]*\\br="${picture.address}"[^>]*)>[\\s\\S]*?<\\/c>`);
|
||||
const emptyCell = new RegExp(`<c\\b([^>]*\\br="${picture.address}"[^>]*)\\/>`);
|
||||
const replaceCell = (opening: string) =>
|
||||
`<c${opening.replace(/\s+t="[^"]*"/g, '')} t="str"><f>_xlfn.DISPIMG("${picture.id}",1)</f><v>=DISPIMG("${picture.id}",1)</v></c>`;
|
||||
if (fullCell.test(sheetXml))
|
||||
sheetXml = sheetXml.replace(fullCell, (_match, opening: string) => replaceCell(opening));
|
||||
else if (emptyCell.test(sheetXml))
|
||||
sheetXml = sheetXml.replace(emptyCell, (_match, opening: string) => replaceCell(opening));
|
||||
else throw new BadRequestException(`无法生成WPS单元格图片:${sheetName}!${picture.address}`);
|
||||
zip.file(picture.mediaPath, picture.buffer);
|
||||
}
|
||||
zip.file(path, sheetXml);
|
||||
const sheetRelsPath = path.replace(/\/([^/]+)$/, '/_rels/$1.rels');
|
||||
const sheetRels = await text(zip, sheetRelsPath);
|
||||
if (sheetRels)
|
||||
zip.file(
|
||||
sheetRelsPath,
|
||||
sheetRels.replace(/<Relationship\b[^>]*Type="[^"]*\/drawing"[^>]*\/?>(?:<\/Relationship>)?/g, ''),
|
||||
);
|
||||
}
|
||||
Object.keys(zip.files)
|
||||
.filter((name) => /^xl\/drawings\//.test(name))
|
||||
.forEach((name) => zip.remove(name));
|
||||
const cellImages = pictures
|
||||
.map(
|
||||
(picture, index) =>
|
||||
`<etc:cellImage><xdr:pic><xdr:nvPicPr><xdr:cNvPr id="${index + 1}" name="${picture.id}"/><xdr:cNvPicPr><a:picLocks noChangeAspect="1"/></xdr:cNvPicPr></xdr:nvPicPr><xdr:blipFill><a:blip r:embed="${picture.relId}"/><a:stretch><a:fillRect/></a:stretch></xdr:blipFill><xdr:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln w="9525"><a:noFill/></a:ln></xdr:spPr></xdr:pic></etc:cellImage>`,
|
||||
)
|
||||
.join('');
|
||||
zip.file(
|
||||
'xl/cellimages.xml',
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><etc:cellImages xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:etc="http://www.wps.cn/officeDocument/2017/etCustomData">${cellImages}</etc:cellImages>`,
|
||||
);
|
||||
zip.file(
|
||||
'xl/_rels/cellimages.xml.rels',
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${pictures.map((picture) => `<Relationship Id="${picture.relId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="${picture.mediaPath.replace(/^xl\//, '')}"/>`).join('')}</Relationships>`,
|
||||
);
|
||||
const nextRel = Math.max(0, ...[...workbookRels.matchAll(/Id="rId(\d+)"/g)].map((match) => Number(match[1]))) + 1;
|
||||
workbookRels = workbookRels.replace(
|
||||
'</Relationships>',
|
||||
`<Relationship Id="rId${nextRel}" Type="http://www.wps.cn/officeDocument/2020/cellImage" Target="cellimages.xml"/></Relationships>`,
|
||||
);
|
||||
zip.file('xl/_rels/workbook.xml.rels', workbookRels);
|
||||
let contentTypes = await text(zip, '[Content_Types].xml');
|
||||
contentTypes = contentTypes.replace(
|
||||
/<Override\b[^>]*PartName="\/xl\/drawings\/[^"]+"[^>]*\/?>(?:<\/Override>)?/g,
|
||||
'',
|
||||
);
|
||||
if (!contentTypes.includes('/xl/cellimages.xml'))
|
||||
contentTypes = contentTypes.replace(
|
||||
'</Types>',
|
||||
'<Override PartName="/xl/cellimages.xml" ContentType="application/vnd.wps-officedocument.cellimage+xml"/></Types>',
|
||||
);
|
||||
zip.file('[Content_Types].xml', contentTypes);
|
||||
return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: { level: 6 } });
|
||||
}
|
||||
Reference in New Issue
Block a user