395 lines
17 KiB
TypeScript
395 lines
17 KiB
TypeScript
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
|
import { Prisma } from '@prisma/client';
|
|
import ExcelJS from 'exceljs';
|
|
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 { SingleReportMaterialDto } from './report-materials.contracts';
|
|
import {
|
|
jsonRecord,
|
|
hasValue,
|
|
isFileRef,
|
|
resolveExportValue,
|
|
applyExportTransform,
|
|
styleHeader,
|
|
normalizeImageExtension,
|
|
safeFileName,
|
|
} from './report-materials.helpers';
|
|
import { convertWorkbookOutput } from './workbook-compatibility';
|
|
import type { ReportBatchGenerationService } from './batch-generation.service';
|
|
|
|
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
|
export class ReportChannelExportService {
|
|
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 briefItems: Array<{ batchItemId: string; smsContent: string }> = [];
|
|
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 smsContentField = fields.find((field) => field.name.trim() === '短信内容');
|
|
const smsContentValue = smsContentField
|
|
? (resolveExportValue(item.snapshot, smsContentField.code, smsContentField.name) ?? '')
|
|
: '';
|
|
const smsContent = isFileRef(smsContentValue) ? '' : String(smsContentValue ?? '');
|
|
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 = item.eligibleTargets
|
|
.filter((target) => target.channelId === channelId)
|
|
.map((target) => target.carrier as 'mobile' | 'unicom' | 'telecom');
|
|
const tasks: Array<{ task: { id: string; reason: string | null }; existingTask: { status: string } | null }> =
|
|
[];
|
|
for (const carrier of reportCarriers) {
|
|
const entry = await this.prisma.$transaction(async (tx) => {
|
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${item.signature.id}, 910))`;
|
|
const existingTask = await tx.channelSignatureReportTask.findFirst({
|
|
where: {
|
|
signatureId: item.signature.id,
|
|
channelId,
|
|
carrier,
|
|
reportType,
|
|
drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null,
|
|
},
|
|
});
|
|
const task = existingTask
|
|
? await tx.channelSignatureReportTask.update({
|
|
where: { id: existingTask.id },
|
|
data: {
|
|
status: missingReason ? 'waiting_material' : 'exporting',
|
|
reason: missingReason,
|
|
approvedAt: null,
|
|
},
|
|
})
|
|
: await tx.channelSignatureReportTask.create({
|
|
data: {
|
|
tenantId: item.signature.tenantId,
|
|
signatureId: item.signature.id,
|
|
channelId,
|
|
carrier,
|
|
approvalScope: 'carrier_specific',
|
|
reportType,
|
|
drainageItemId: item.drainageInfo?.id,
|
|
status: missingReason ? 'waiting_material' : 'exporting',
|
|
reason: missingReason,
|
|
},
|
|
});
|
|
return { task, existingTask };
|
|
});
|
|
tasks.push(entry);
|
|
}
|
|
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 });
|
|
briefItems.push({ batchItemId: item.batchItem.id, smsContent });
|
|
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, briefItems };
|
|
}
|
|
|
|
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 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;
|
|
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]) => !configuredCodes.has(code) && historicalDefinitionByCode.get(code)?.status !== 'deleted')
|
|
.sort(([left], [right]) => left.localeCompare(right, 'zh-CN'))
|
|
.map(([code, value]) => ({ code, name: historicalDefinitionByCode.get(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 | undefined, operatorId?: string) {
|
|
if (!data) throw new BadRequestException('导出参数不能为空');
|
|
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 = await convertWorkbookOutput(Buffer.from(await workbook.xlsx.writeBuffer()), data.outputFormat);
|
|
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',
|
|
},
|
|
});
|
|
}
|
|
}
|