|
|
|
@@ -0,0 +1,529 @@
|
|
|
|
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
|
|
|
|
import { Prisma } from '@prisma/client';
|
|
|
|
|
import ExcelJS from 'exceljs';
|
|
|
|
|
import { 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';
|
|
|
|
|
|
|
|
|
|
export type ImportMapping = {
|
|
|
|
|
sourceHeader: string;
|
|
|
|
|
sourceHeaderPath?: string;
|
|
|
|
|
sourceColumnIndex: number;
|
|
|
|
|
targetFieldCode: string;
|
|
|
|
|
targetKind: 'signatureName' | 'purpose' | 'siteName' | 'url' | 'remark' | 'dynamic';
|
|
|
|
|
fieldType: 'string' | 'image' | 'file';
|
|
|
|
|
required?: boolean;
|
|
|
|
|
transform?: string;
|
|
|
|
|
sortOrder?: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export interface CreateImportProfileDto {
|
|
|
|
|
id?: string;
|
|
|
|
|
name: string;
|
|
|
|
|
reportType: 'signature' | 'drainage';
|
|
|
|
|
tenantId?: string;
|
|
|
|
|
applicationId?: string;
|
|
|
|
|
sheetName?: string;
|
|
|
|
|
headerRowCount?: number;
|
|
|
|
|
dataStartRow?: number;
|
|
|
|
|
status?: string;
|
|
|
|
|
columns: ImportMapping[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ImportCommitDto {
|
|
|
|
|
mappings: ImportMapping[];
|
|
|
|
|
profile?: CreateImportProfileDto;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface CreateReportBatchDto {
|
|
|
|
|
createdById?: string;
|
|
|
|
|
items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string }>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type AnalyzeImportOptions = {
|
|
|
|
|
tenantId: string;
|
|
|
|
|
applicationId?: string;
|
|
|
|
|
reportType: 'signature' | 'drainage';
|
|
|
|
|
sheetName?: string;
|
|
|
|
|
headerRowCount: number;
|
|
|
|
|
dataStartRow: number;
|
|
|
|
|
profileId?: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type EmbeddedImage = { row: number; column: number; extension: string; buffer: Buffer };
|
|
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class ReportMaterialsService {
|
|
|
|
|
constructor(
|
|
|
|
|
private readonly prisma: PrismaService,
|
|
|
|
|
private readonly files: FilesService,
|
|
|
|
|
private readonly smsConfig: SmsConfigService,
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
async listPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string }) {
|
|
|
|
|
const [signatures, drainageInfos] = await Promise.all([
|
|
|
|
|
query.reportType === 'drainage' ? Promise.resolve([]) : this.prisma.smsSignature.findMany({
|
|
|
|
|
where: { pendingReport: true, auditStatus: 'approved', tenantId: query.tenantId, applicationId: query.applicationId },
|
|
|
|
|
include: { tenant: true, application: true },
|
|
|
|
|
orderBy: { reportChangedAt: 'desc' },
|
|
|
|
|
}),
|
|
|
|
|
query.reportType === 'signature' ? Promise.resolve([]) : this.prisma.smsDrainageInfo.findMany({
|
|
|
|
|
where: { pendingReport: true, auditStatus: 'approved', tenantId: query.tenantId, applicationId: query.applicationId },
|
|
|
|
|
include: { tenant: true, application: true, signature: true },
|
|
|
|
|
orderBy: { reportChangedAt: 'desc' },
|
|
|
|
|
}),
|
|
|
|
|
]);
|
|
|
|
|
return [
|
|
|
|
|
...signatures.map((item) => ({ id: `signature:${item.id}`, reportType: 'signature', signatureId: item.id, drainageItemId: null, materialVersion: item.materialVersion, changedAt: item.reportChangedAt, name: item.name, detail: item.purpose, tenant: item.tenant, application: item.application })),
|
|
|
|
|
...drainageInfos.map((item) => ({ id: `drainage:${item.id}`, reportType: 'drainage', signatureId: item.signatureId, drainageItemId: item.id, materialVersion: item.materialVersion, changedAt: item.reportChangedAt, name: item.siteName, detail: item.url, signatureName: item.signature.name, tenant: item.tenant, application: item.application })),
|
|
|
|
|
].sort((left, right) => new Date(right.changedAt).getTime() - new Date(left.changedAt).getTime());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
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),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
return { ...batch, sourceFile, sheets: workbook.worksheets.map((sheet) => sheet.name), columns, rows: previewRows, imageCount: images.length, suggestedMappings };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async commitImport(batchId: string, data: ImportCommitDto) {
|
|
|
|
|
const batch = await this.prisma.reportMaterialImportBatch.findUnique({ where: { id: batchId } });
|
|
|
|
|
if (!batch) throw new NotFoundException('导入批次不存在');
|
|
|
|
|
if (!data.mappings?.length) throw new BadRequestException('请至少配置一个导入字段映射');
|
|
|
|
|
if (data.profile) await this.saveImportProfile({ ...data.profile, reportType: batch.reportType as 'signature' | 'drainage', columns: data.mappings });
|
|
|
|
|
const { content } = await this.files.getDownload(batch.fileObjectId);
|
|
|
|
|
const workbook = await loadWorkbook(content);
|
|
|
|
|
const worksheet = workbook.getWorksheet(batch.sheetName);
|
|
|
|
|
if (!worksheet) throw new BadRequestException('导入工作表不存在');
|
|
|
|
|
const images = readEmbeddedImages(workbook, worksheet);
|
|
|
|
|
const imageByCell = new Map(images.map((image) => [`${image.row}:${image.column}`, image]));
|
|
|
|
|
let successCount = 0;
|
|
|
|
|
const failures: Array<{ rowNumber: number; reason: string }> = [];
|
|
|
|
|
for (let rowNumber = batch.dataStartRow; rowNumber <= worksheet.rowCount; rowNumber += 1) {
|
|
|
|
|
try {
|
|
|
|
|
const values: Record<string, unknown> = {};
|
|
|
|
|
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}`);
|
|
|
|
|
}
|
|
|
|
|
if (batch.reportType === 'signature') await this.importSignatureRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values);
|
|
|
|
|
else await this.importDrainageRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values);
|
|
|
|
|
successCount += 1;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
failures.push({ rowNumber, reason: error instanceof Error ? error.message : '导入失败' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return this.prisma.reportMaterialImportBatch.update({
|
|
|
|
|
where: { id: batchId },
|
|
|
|
|
data: {
|
|
|
|
|
status: failures.length ? (successCount ? 'partial_failed' : 'failed') : 'completed',
|
|
|
|
|
mapping: data.mappings as Prisma.InputJsonValue,
|
|
|
|
|
result: { failures } as Prisma.InputJsonValue,
|
|
|
|
|
successCount,
|
|
|
|
|
failedCount: failures.length,
|
|
|
|
|
completedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
listBatches() {
|
|
|
|
|
return this.prisma.reportMaterialBatch.findMany({
|
|
|
|
|
include: { exportFiles: true, items: true },
|
|
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
take: 100,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async createBatch(data: CreateReportBatchDto) {
|
|
|
|
|
if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息');
|
|
|
|
|
const uniqueItems = [...new Map(data.items.map((item) => [`${item.reportType}:${item.drainageItemId ?? item.signatureId}`, item])).values()];
|
|
|
|
|
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: uniqueItems.length },
|
|
|
|
|
});
|
|
|
|
|
try {
|
|
|
|
|
const prepared = [];
|
|
|
|
|
for (const selected of uniqueItems) prepared.push(await this.prepareBatchItem(batch.id, selected));
|
|
|
|
|
const channelMap = new Map<string, Array<(typeof prepared)[number]>>();
|
|
|
|
|
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<string>(prepared.filter((item) => item.channels.length === 0).map((item) => item.batchItem.id));
|
|
|
|
|
for (const [channelId, items] of channelMap) {
|
|
|
|
|
const result = await this.exportChannelBatch(batch.id, channelId, items);
|
|
|
|
|
exportedFiles.push(result.file);
|
|
|
|
|
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 } });
|
|
|
|
|
}
|
|
|
|
|
return 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 } });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
await this.prisma.reportMaterialBatch.update({ where: { id: batch.id }, data: { status: 'failed', errorMessage: error instanceof Error ? error.message : '生成报备批次失败', completedAt: new Date() } });
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async importSignatureRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record<string, unknown>) {
|
|
|
|
|
const name = mappedCoreValue(mappings, values, 'signatureName');
|
|
|
|
|
if (!name) throw new Error('缺少短信签名');
|
|
|
|
|
const purpose = mappedCoreValue(mappings, values, 'purpose');
|
|
|
|
|
const signatureReportValues = dynamicValues(mappings, values);
|
|
|
|
|
const existing = await this.prisma.smsSignature.findFirst({ where: { tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } } });
|
|
|
|
|
if (existing) return this.smsConfig.updateSignature(existing.id, { applicationId, name, purpose, drainageInfo: { ...jsonRecord(existing.drainageInfo), signatureReportValues } });
|
|
|
|
|
return this.smsConfig.createSignature({ tenantId, applicationId, name, purpose, drainageInfo: { signatureReportValues } }, { initialAuditStatus: 'approved' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async importDrainageRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record<string, unknown>) {
|
|
|
|
|
const signatureName = mappedCoreValue(mappings, values, 'signatureName');
|
|
|
|
|
const siteName = mappedCoreValue(mappings, values, 'siteName');
|
|
|
|
|
const url = mappedCoreValue(mappings, values, 'url');
|
|
|
|
|
if (!signatureName || !siteName || !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' } } });
|
|
|
|
|
if (existing) return this.smsConfig.updateDrainageInfo(existing.id, { siteName, url, remark, reportValues }, { initialAuditStatus: 'approved' });
|
|
|
|
|
return this.smsConfig.createDrainageInfo(signature.id, { siteName, url, remark, reportValues }, { initialAuditStatus: 'approved' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async prepareBatchItem(batchId: string, selected: CreateReportBatchDto['items'][number]) {
|
|
|
|
|
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 channels = [...new Map(routes.flatMap((route) => route.group.items.map((entry) => entry.channel)).filter((channel) => channel.status === 'active').map((channel) => [channel.id, channel])).values()];
|
|
|
|
|
const snapshot = selected.reportType === 'signature'
|
|
|
|
|
? { reportType: 'signature', 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', 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 };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async exportChannelBatch(batchId: string, channelId: string, items: Array<Awaited<ReturnType<ReportMaterialsService['prepareBatchItem']>>>) {
|
|
|
|
|
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
|
|
|
|
|
if (!channel) throw new NotFoundException('通道不存在');
|
|
|
|
|
const reportTypes = [...new Set(items.map((item) => item.reportType))];
|
|
|
|
|
const workbook = new ExcelJS.Workbook();
|
|
|
|
|
const fileRows: Array<{ item: (typeof items)[number]; taskId: string; rowNumber: number }> = [];
|
|
|
|
|
const incompleteBatchItemIds: string[] = [];
|
|
|
|
|
let totalRows = 0;
|
|
|
|
|
for (const reportType of reportTypes) {
|
|
|
|
|
const fields = await this.prisma.channelReportField.findMany({ where: { channelId, status: 'active', reportType: { in: [reportType, 'both'] } }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] });
|
|
|
|
|
const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名报备' : '引流信息报备', { views: [{ state: 'frozen', ySplit: 1 }] });
|
|
|
|
|
sheet.properties.defaultRowHeight = 22;
|
|
|
|
|
sheet.columns = fields.map((field) => ({ header: field.exportName || field.name, key: field.code, width: field.columnWidth }));
|
|
|
|
|
styleHeader(sheet.getRow(1));
|
|
|
|
|
for (const item of items.filter((current) => current.reportType === reportType)) {
|
|
|
|
|
const values = fields.map((field) => resolveExportValue(item.snapshot, field.code, field.name) ?? field.defaultValue ?? '');
|
|
|
|
|
const missing = fields.filter((field, index) => field.required && !hasValue(values[index]));
|
|
|
|
|
const missingReason = fields.length === 0 ? '通道未配置当前资料类型的报备字段' : missing.length ? `缺少字段:${missing.map((field) => field.exportName || field.name).join('、')}` : null;
|
|
|
|
|
const existingTask = await this.prisma.channelSignatureReportTask.findFirst({ where: { signatureId: item.signature.id, channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null } });
|
|
|
|
|
const task = existingTask
|
|
|
|
|
? await this.prisma.channelSignatureReportTask.update({ where: { id: existingTask.id }, data: { status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason } })
|
|
|
|
|
: await this.prisma.channelSignatureReportTask.create({ data: { tenantId: item.signature.tenantId, signatureId: item.signature.id, channelId, reportType, drainageItemId: item.drainageInfo?.id, status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason } });
|
|
|
|
|
if (missingReason) {
|
|
|
|
|
incompleteBatchItemIds.push(item.batchItem.id);
|
|
|
|
|
await this.recordTask(task.id, channelId, existingTask?.status, 'waiting_material', task.reason ?? undefined);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const row = sheet.addRow(values.map((value, index) => isFileRef(value) ? value.fileName : applyExportTransform(value, fields[index]?.transform)));
|
|
|
|
|
totalRows += 1;
|
|
|
|
|
let targetHeight = 22;
|
|
|
|
|
for (const [index, value] of values.entries()) {
|
|
|
|
|
if (!isFileRef(value)) continue;
|
|
|
|
|
const downloaded = await this.files.getDownload(value.fileObjectId);
|
|
|
|
|
if (!downloaded.fileObject.contentType.startsWith('image/')) continue;
|
|
|
|
|
const extension = normalizeImageExtension(extname(downloaded.fileObject.fileName).slice(1) || downloaded.fileObject.contentType.split('/')[1]);
|
|
|
|
|
if (!['png', 'jpeg', 'gif'].includes(extension)) continue;
|
|
|
|
|
const imageId = workbook.addImage({ base64: `data:${downloaded.fileObject.contentType};base64,${downloaded.content.toString('base64')}`, extension: extension as 'png' | 'jpeg' | 'gif' });
|
|
|
|
|
const widthCells = Math.max(0.8, fields[index].imageWidth / Math.max(60, fields[index].columnWidth * 7));
|
|
|
|
|
const heightRows = Math.max(0.8, fields[index].imageHeight / 20);
|
|
|
|
|
sheet.addImage(imageId, { tl: { col: index + 0.08, row: row.number - 1 + 0.08 }, br: { col: index + Math.min(0.95, widthCells), row: row.number - 1 + Math.min(0.95, heightRows) }, editAs: 'oneCell' } as never);
|
|
|
|
|
targetHeight = Math.max(targetHeight, fields[index].imageHeight * 0.75 + 8);
|
|
|
|
|
}
|
|
|
|
|
row.height = targetHeight;
|
|
|
|
|
fileRows.push({ item, taskId: task.id, rowNumber: row.number });
|
|
|
|
|
await this.recordTask(task.id, channelId, existingTask?.status, 'exporting');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (workbook.worksheets.every((sheet) => sheet.rowCount <= 1)) {
|
|
|
|
|
for (const sheet of [...workbook.worksheets]) workbook.removeWorksheet(sheet.id);
|
|
|
|
|
const empty = workbook.addWorksheet('无可导出数据');
|
|
|
|
|
empty.getCell('A1').value = '所选资料缺少当前通道必填字段,请补充后重新生成。';
|
|
|
|
|
empty.getColumn(1).width = 64;
|
|
|
|
|
}
|
|
|
|
|
const buffer = Buffer.from(await workbook.xlsx.writeBuffer());
|
|
|
|
|
const fileName = `${safeFileName(channel.name)}-${batchId.slice(-8)}.xlsx`;
|
|
|
|
|
const uploaded = await this.files.upload({ purpose: 'report_export', prefix: `report-exports/${batchId}` }, { originalname: fileName, mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: buffer.length, buffer });
|
|
|
|
|
const file = await this.prisma.reportExportFile.create({ data: { batchId, channelId, fileObjectId: uploaded.id, fileName, rowCount: totalRows } });
|
|
|
|
|
if (fileRows.length) await this.prisma.reportExportFileItem.createMany({ data: fileRows.map((entry) => ({ exportFileId: file.id, batchItemId: entry.item.batchItem.id, taskId: entry.taskId, rowNumber: entry.rowNumber })) });
|
|
|
|
|
return { file: { ...file, fileObject: uploaded }, incompleteBatchItemIds };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private recordTask(taskId: string, channelId: string, statusBefore: string | undefined, statusAfter: string, reason?: string) {
|
|
|
|
|
return this.prisma.channelSignatureReportRecord.create({ data: { taskId, channelId, action: 'batch_export', statusBefore, statusAfter, reason, sourceEntry: 'report_task' } });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function profileData(data: CreateImportProfileDto) {
|
|
|
|
|
return { name: data.name.trim(), reportType: data.reportType, tenantId: data.tenantId, applicationId: data.applicationId, sheetName: data.sheetName, headerRowCount: clamp(data.headerRowCount ?? 1, 1, 5), dataStartRow: Math.max(data.dataStartRow ?? 2, 2), status: data.status ?? 'active' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateProfile(data: CreateImportProfileDto) {
|
|
|
|
|
if (!data.name?.trim()) throw new BadRequestException('映射模板名称不能为空');
|
|
|
|
|
if (!data.columns?.length) throw new BadRequestException('映射模板至少包含一个字段');
|
|
|
|
|
const indexes = data.columns.map((column) => column.sourceColumnIndex);
|
|
|
|
|
if (new Set(indexes).size !== indexes.length) throw new BadRequestException('同一源列不能重复映射');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadWorkbook(buffer: Buffer) {
|
|
|
|
|
const workbook = new ExcelJS.Workbook();
|
|
|
|
|
await workbook.xlsx.load(buffer as never);
|
|
|
|
|
return workbook;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readEmbeddedImages(workbook: ExcelJS.Workbook, worksheet: ExcelJS.Worksheet): EmbeddedImage[] {
|
|
|
|
|
const getImages = (worksheet as unknown as { getImages?: () => Array<{ imageId: number; range: { tl: { nativeRow?: number; nativeCol?: number; row?: number; col?: number } } }> }).getImages;
|
|
|
|
|
if (!getImages) return [];
|
|
|
|
|
return getImages.call(worksheet).flatMap((drawing) => {
|
|
|
|
|
const image = (workbook as unknown as { getImage?: (id: number) => { buffer?: Buffer; base64?: string; extension?: string } }).getImage?.(drawing.imageId);
|
|
|
|
|
if (!image) return [];
|
|
|
|
|
const row = (drawing.range.tl.nativeRow ?? drawing.range.tl.row ?? 0) + 1;
|
|
|
|
|
const column = (drawing.range.tl.nativeCol ?? drawing.range.tl.col ?? 0) + 1;
|
|
|
|
|
const buffer = image.buffer ?? (image.base64 ? Buffer.from(image.base64.replace(/^data:[^;]+;base64,/, ''), 'base64') : undefined);
|
|
|
|
|
return buffer ? [{ row, column, extension: image.extension ?? 'png', buffer }] : [];
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function suggestMappings(columns: Array<{ sourceColumnIndex: number; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>, reportType: 'signature' | 'drainage'): ImportMapping[] {
|
|
|
|
|
return columns.flatMap((column, index) => {
|
|
|
|
|
const normalized = normalizeHeader(`${column.sourceHeaderPath}/${column.sourceHeader}`);
|
|
|
|
|
const core = reportType === 'signature' ? signatureCoreMapping(normalized) : drainageCoreMapping(normalized);
|
|
|
|
|
if (!core && !column.imageCount) return [];
|
|
|
|
|
return [{ sourceHeader: column.sourceHeader, sourceHeaderPath: column.sourceHeaderPath, sourceColumnIndex: column.sourceColumnIndex, targetFieldCode: core?.code ?? normalizeFieldCode(column.sourceHeader), targetKind: core?.kind ?? 'dynamic', fieldType: column.imageCount ? 'image' : 'string', required: Boolean(core?.required), sortOrder: (index + 1) * 10 }];
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function remapProfileColumns(profileColumns: ImportMapping[], sourceColumns: Array<{ sourceColumnIndex: number; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>): ImportMapping[] {
|
|
|
|
|
const used = new Set<number>();
|
|
|
|
|
return profileColumns.flatMap((profileColumn) => {
|
|
|
|
|
const headerPath = normalizeHeader(profileColumn.sourceHeaderPath || profileColumn.sourceHeader);
|
|
|
|
|
const header = normalizeHeader(profileColumn.sourceHeader);
|
|
|
|
|
const source = sourceColumns.find((column) => !used.has(column.sourceColumnIndex) && normalizeHeader(column.sourceHeaderPath) === headerPath)
|
|
|
|
|
?? sourceColumns.find((column) => !used.has(column.sourceColumnIndex) && normalizeHeader(column.sourceHeader) === header);
|
|
|
|
|
if (!source) return [];
|
|
|
|
|
used.add(source.sourceColumnIndex);
|
|
|
|
|
return [{ ...profileColumn, sourceColumnIndex: source.sourceColumnIndex, sourceHeader: source.sourceHeader, sourceHeaderPath: source.sourceHeaderPath, fieldType: source.imageCount > 0 && profileColumn.fieldType === 'string' ? 'image' : profileColumn.fieldType }];
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function signatureCoreMapping(header: string): { code: string; kind: ImportMapping['targetKind']; required?: boolean } | undefined {
|
|
|
|
|
if (/短信签名|签名名称|签名/.test(header)) return { code: 'signature_name', kind: 'signatureName', required: true };
|
|
|
|
|
if (/用途|签名依据/.test(header)) return { code: 'purpose', kind: 'purpose' };
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function drainageCoreMapping(header: string): { code: string; kind: ImportMapping['targetKind']; required?: boolean } | undefined {
|
|
|
|
|
if (/短信签名|签名名称/.test(header)) return { code: 'signature_name', kind: 'signatureName', required: true };
|
|
|
|
|
if (/站点|网站名称/.test(header)) return { code: 'site_name', kind: 'siteName', required: true };
|
|
|
|
|
if (/引流地址|网址|url|链接/.test(header)) return { code: 'url', kind: 'url', required: true };
|
|
|
|
|
if (/备注|说明/.test(header)) return { code: 'remark', kind: 'remark' };
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeHeader(value: string) { return value.toLowerCase().replace(/[\s**::()()_-]/g, ''); }
|
|
|
|
|
function normalizeFieldCode(value: string) { return `import_${value.trim().toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_').slice(0, 40) || randomUUID().slice(0, 8)}`; }
|
|
|
|
|
function clamp(value: number, minimum: number, maximum: number) { return Math.min(maximum, Math.max(minimum, Number.isFinite(value) ? Math.round(value) : minimum)); }
|
|
|
|
|
|
|
|
|
|
function cellText(cell: ExcelJS.Cell) {
|
|
|
|
|
const value = cell.value;
|
|
|
|
|
if (value === null || value === undefined) return '';
|
|
|
|
|
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : String(value);
|
|
|
|
|
if (typeof value === 'string' || typeof value === 'boolean') return String(value).trim();
|
|
|
|
|
if ('result' in value && value.result !== undefined) return String(value.result ?? '').trim();
|
|
|
|
|
if ('richText' in value) return value.richText.map((item) => item.text).join('').trim();
|
|
|
|
|
if ('text' in value) return String(value.text).trim();
|
|
|
|
|
return cell.text.trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function transformValue(value: string, transform?: string) {
|
|
|
|
|
if (!transform || transform === 'trim') return value.trim();
|
|
|
|
|
if (transform === 'digits') return value.replace(/\D/g, '');
|
|
|
|
|
if (transform === 'uppercase') return value.trim().toUpperCase();
|
|
|
|
|
if (transform === 'lowercase') return value.trim().toLowerCase();
|
|
|
|
|
return value.trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mappedCoreValue(mappings: ImportMapping[], values: Record<string, unknown>, kind: ImportMapping['targetKind']) {
|
|
|
|
|
const mapping = mappings.find((item) => item.targetKind === kind);
|
|
|
|
|
return mapping ? String(values[mapping.targetFieldCode] ?? '').trim() : '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function dynamicValues(mappings: ImportMapping[], values: Record<string, unknown>) {
|
|
|
|
|
return Object.fromEntries(mappings.filter((item) => item.targetKind === 'dynamic').map((item) => [item.targetFieldCode, values[item.targetFieldCode]]));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function jsonRecord(value: unknown): Record<string, unknown> { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
|
|
|
|
|
function hasValue(value: unknown) { return isFileRef(value) ? Boolean(value.fileObjectId) : value !== null && value !== undefined && String(value).trim().length > 0; }
|
|
|
|
|
function isFileRef(value: unknown): value is { fileObjectId: string; fileName: string; contentType?: string } { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && typeof (value as Record<string, unknown>).fileObjectId === 'string'; }
|
|
|
|
|
|
|
|
|
|
function resolveExportValue(snapshot: Record<string, unknown>, code: string, name?: string) {
|
|
|
|
|
const values = jsonRecord(snapshot.values);
|
|
|
|
|
if (hasValue(values[code])) return values[code];
|
|
|
|
|
const signature = jsonRecord(snapshot.signature);
|
|
|
|
|
const drainage = jsonRecord(snapshot.drainage);
|
|
|
|
|
const aliases: Record<string, unknown> = {
|
|
|
|
|
signature_name: signature.name, sign_name: signature.name, signatureName: signature.name,
|
|
|
|
|
purpose: signature.purpose, enterprise_name: signature.tenantName, company_name: signature.tenantName,
|
|
|
|
|
application_name: signature.applicationName, site_name: drainage.siteName, url: drainage.url, remark: drainage.remark,
|
|
|
|
|
};
|
|
|
|
|
if (hasValue(aliases[code])) return aliases[code];
|
|
|
|
|
const semantic = normalizeHeader(`${code}/${name ?? ''}`);
|
|
|
|
|
if (/短信签名|签名名称|signaturename|sms(?:signature|sign)|^sign$/.test(semantic)) return signature.name;
|
|
|
|
|
if (/签名用途|签名依据|purpose/.test(semantic)) return signature.purpose;
|
|
|
|
|
if (/企业名称|公司名称|enterprisename|companyname/.test(semantic)) return signature.tenantName;
|
|
|
|
|
if (/应用名称|applicationname|appname/.test(semantic)) return signature.applicationName;
|
|
|
|
|
if (/站点名称|网站名称|sitename/.test(semantic)) return drainage.siteName;
|
|
|
|
|
if (/引流地址|网址|链接|url/.test(semantic)) return drainage.url;
|
|
|
|
|
if (/备注|说明|remark/.test(semantic)) return drainage.remark;
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyExportTransform(value: unknown, transform?: string | null) {
|
|
|
|
|
const text = value === null || value === undefined ? '' : String(value);
|
|
|
|
|
return transformValue(text, transform ?? undefined);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function styleHeader(row: ExcelJS.Row) {
|
|
|
|
|
row.height = 28;
|
|
|
|
|
row.eachCell((cell) => {
|
|
|
|
|
cell.font = { bold: true, color: { argb: 'FFFFFFFF' } };
|
|
|
|
|
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF2563EB' } };
|
|
|
|
|
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
|
|
|
|
|
cell.border = { bottom: { style: 'thin', color: { argb: 'FFD1D5DB' } } };
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeImageExtension(value: string) { const normalized = value.toLowerCase().replace(/^\./, ''); return normalized === 'jpg' ? 'jpeg' : normalized; }
|
|
|
|
|
function imageContentType(extension: string) { const normalized = normalizeImageExtension(extension); return normalized === 'jpeg' ? 'image/jpeg' : normalized === 'gif' ? 'image/gif' : 'image/png'; }
|
|
|
|
|
function safeFileName(value: string) { return value.replace(/[\\/:*?"<>|]/g, '_').slice(0, 80) || '通道报备'; }
|