feat: build reporting workbench workflow

This commit is contained in:
hectorzhao
2026-09-02 14:30:15 +08:00
parent 53d3668306
commit 9b8196ecab
31 changed files with 7651 additions and 1973 deletions
@@ -6,83 +6,411 @@ 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,
CreateReportBatchDto,
EmbeddedImage,
ImportCommitDto,
ImportMapping,
PagedQuery,
ReportBatchInspection,
ReportBatchTarget,
ReviewImportItemsDto,
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,
resolveExportValue,
applyExportTransform,
styleHeader,
normalizeImageExtension,
imageContentType,
safeFileName,
normalizeBatchIdempotencyKey,
jsonStringArray,
jsonSafe,
} from './report-materials.helpers';
import type { ReportBatchGenerationService } from './batch-generation.service';
import { normalizeChannelCarriers } from '../channels/channels.helpers';
/** R4 report-materials domain service composed behind ReportMaterialsService. */
export class ReportChannelExportService {
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,
) {}
async exportChannelBatch(batchId: string, channelId: string, items: Array<Awaited<ReturnType<ReportBatchGenerationService['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 reportCarriers = reportType === 'signature' ? normalizeChannelCarriers(channel.carriers, channel.carrier) : [null];
const tasks: Array<{ task: { id: string; reason: string | null }; existingTask: { status: string } | null }> = [];
for (const carrier of reportCarriers) {
const existingTask = await this.prisma.channelSignatureReportTask.findFirst({ where: { signatureId: item.signature.id, channelId, carrier, 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, ...(reportType === 'signature' ? { approvedAt: null } : {}) } })
: await this.prisma.channelSignatureReportTask.create({ data: { tenantId: item.signature.tenantId, signatureId: item.signature.id, channelId, carrier, approvalScope: reportType === 'signature' ? 'carrier_specific' : 'legacy_channel', reportType, drainageItemId: item.drainageInfo?.id, status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason } });
tasks.push({ task, existingTask });
}
const task = tasks[0].task;
if (missingReason) {
incompleteBatchItemIds.push(item.batchItem.id);
for (const entry of tasks) await this.recordTask(entry.task.id, channelId, entry.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 });
for (const entry of tasks) await this.recordTask(entry.task.id, channelId, entry.existingTask?.status, 'exporting');
async exportChannelBatch(
batchId: string,
channelId: string,
items: Array<Awaited<ReturnType<ReportBatchGenerationService['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 reportCarriers =
reportType === 'signature'
? item.eligibleTargets
.filter((target) => target.channelId === channelId)
.map((target) => target.carrier as 'mobile' | 'unicom' | 'telecom')
: [null];
const tasks: Array<{ task: { id: string; reason: string | null }; existingTask: { status: string } | null }> =
[];
for (const carrier of reportCarriers) {
const existingTask = await this.prisma.channelSignatureReportTask.findFirst({
where: {
signatureId: item.signature.id,
channelId,
carrier,
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,
...(reportType === 'signature' ? { approvedAt: null } : {}),
},
})
: await this.prisma.channelSignatureReportTask.create({
data: {
tenantId: item.signature.tenantId,
signatureId: item.signature.id,
channelId,
carrier,
approvalScope: reportType === 'signature' ? 'carrier_specific' : 'legacy_channel',
reportType,
drainageItemId: item.drainageInfo?.id,
status: missingReason ? 'waiting_material' : 'exporting',
reason: missingReason,
},
});
tasks.push({ task, existingTask });
}
const task = tasks[0].task;
if (missingReason) {
incompleteBatchItemIds.push(item.batchItem.id);
for (const entry of tasks)
await this.recordTask(
entry.task.id,
channelId,
entry.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 });
for (const entry of tasks)
await this.recordTask(entry.task.id, channelId, entry.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 };
}
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 };
}
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' } });
async getSingleMaterialDetail(data: SingleReportMaterialDto) {
const reportType = data.reportType ?? 'signature';
if (!data.signatureId || !data.channelId) throw new BadRequestException('签名和通道不能为空');
if (reportType === 'drainage' && !data.drainageItemId) throw new BadRequestException('引流信息不能为空');
const [signature, channel] = await Promise.all([
this.prisma.smsSignature.findUnique({
where: { id: data.signatureId },
include: { tenant: true, application: true },
}),
this.prisma.smsChannel.findUnique({ where: { id: data.channelId } }),
]);
if (!signature || signature.auditStatus === 'deleted') throw new NotFoundException('签名不存在');
if (!channel || channel.status === 'deleted') throw new NotFoundException('通道不存在');
let materialVersion = signature.materialVersion;
let snapshot: Record<string, unknown>;
if (data.batchItemId) {
const batchItem = await this.prisma.reportMaterialBatchItem.findFirst({
where: {
id: data.batchItemId,
signatureId: signature.id,
exportItems: { some: { exportFile: { channelId: channel.id } } },
},
});
if (!batchItem) throw new NotFoundException('当前批次资料快照不存在');
if (batchItem.reportType !== reportType) throw new BadRequestException('批次资料类型不一致');
materialVersion = batchItem.materialVersion;
snapshot = jsonRecord(batchItem.snapshot);
} else if (reportType === 'signature') {
snapshot = {
reportType,
signature: {
id: signature.id,
name: signature.name,
purpose: signature.purpose,
tenantName: signature.tenant.name,
applicationName: signature.application?.name,
},
values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues),
};
} else {
const drainage = await this.prisma.smsDrainageInfo.findFirst({
where: { id: data.drainageItemId, signatureId: signature.id },
});
if (!drainage || drainage.auditStatus === 'deleted') throw new NotFoundException('引流信息不存在');
materialVersion = drainage.materialVersion;
snapshot = {
reportType,
signature: {
id: signature.id,
name: signature.name,
tenantName: signature.tenant.name,
applicationName: signature.application?.name,
},
drainage: { id: drainage.id, siteName: drainage.siteName, url: drainage.url, remark: drainage.remark },
values: jsonRecord(drainage.reportValues),
};
}
const fields = await this.prisma.channelReportField.findMany({
where: { channelId: channel.id, status: 'active', reportType: { in: [reportType, 'both'] } },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
});
const configuredCodes = new Set(fields.map((field) => field.code));
const values = jsonRecord(snapshot.values);
const materialFields = fields.map((field) => {
const submittedValue = resolveExportValue(snapshot, field.code, field.name);
const value = hasValue(submittedValue) ? submittedValue : field.defaultValue;
return {
id: field.id,
code: field.code,
name: field.name,
exportName: field.exportName,
fieldType: field.fieldType,
required: field.required,
columnWidth: field.columnWidth,
imageWidth: field.imageWidth,
imageHeight: field.imageHeight,
transform: field.transform,
value: value ?? null,
submitted: hasValue(submittedValue),
missing: field.required && !hasValue(value),
};
});
const historicalFields = Object.entries(values)
.filter(([code, value]) => !configuredCodes.has(code) && hasValue(value))
.sort(([left], [right]) => left.localeCompare(right, 'zh-CN'))
.map(([code, value]) => ({ code, name: code, value }));
return {
reportType,
signatureId: signature.id,
signatureName: signature.name,
tenant: { id: signature.tenant.id, name: signature.tenant.name },
application: signature.application ? { id: signature.application.id, name: signature.application.name } : null,
channel: { id: channel.id, name: channel.name, code: channel.code },
carrier: data.carrier ?? null,
materialVersion,
batchItemId: data.batchItemId ?? null,
fields: materialFields,
historicalFields,
missingFields: materialFields.filter((field) => field.missing).map((field) => field.exportName || field.name),
};
}
async exportSingleMaterial(data: SingleReportMaterialDto, operatorId?: string) {
if ((data.reportType ?? 'signature') !== 'signature')
throw new BadRequestException('首版仅支持单条签名报备资料导出');
const detail = await this.getSingleMaterialDetail(data);
if (detail.missingFields.length)
throw new BadRequestException({
code: 'REPORT_MATERIAL_INCOMPLETE',
message: `缺少必填字段:${detail.missingFields.join('、')}`,
missingFields: detail.missingFields,
});
const workbook = new ExcelJS.Workbook();
workbook.creator = 'CMPP短信平台';
const sheet = workbook.addWorksheet('签名报备', { views: [{ state: 'frozen', ySplit: 1 }] });
sheet.columns = detail.fields.map((field) => ({
header: field.exportName || field.name,
key: field.code,
width: field.columnWidth,
}));
styleHeader(sheet.getRow(1));
const row = sheet.addRow(
detail.fields.map((field) =>
isFileRef(field.value) ? field.value.fileName : applyExportTransform(field.value, field.transform),
),
);
let targetHeight = 22;
for (const [index, field] of detail.fields.entries()) {
if (!isFileRef(field.value)) continue;
const downloaded = await this.files.getDownload(field.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, field.imageWidth / Math.max(60, field.columnWidth * 7));
const heightRows = Math.max(0.8, field.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, field.imageHeight * 0.75 + 8);
}
row.height = targetHeight;
const fileName = `${safeFileName(detail.channel.name)}-${safeFileName(detail.signatureName)}-V${detail.materialVersion}.xlsx`;
const content = Buffer.from(await workbook.xlsx.writeBuffer());
await this.prisma.operationLog.create({
data: {
tenantId: detail.tenant.id,
userId: operatorId,
action: 'report_material.single_export',
resource: 'report_material',
resourceId: detail.signatureId,
detail: {
fileName,
channelId: detail.channel.id,
carrier: detail.carrier,
materialVersion: detail.materialVersion,
batchItemId: detail.batchItemId,
successCount: 1,
failedCount: 0,
} as Prisma.InputJsonValue,
},
});
return { fileName, content };
}
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',
},
});
}
}