feat: add phone frequency controls and modularize codebase

This commit is contained in:
hectorzhao
2026-07-31 22:25:23 +08:00
parent 0af671b4ed
commit ca4f591a13
216 changed files with 41579 additions and 23694 deletions
@@ -0,0 +1,252 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import { createHash, randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.service';
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
import { ReportBatchOperationService } from './batch-operation.service';
import { ReportChannelExportService } from './channel-export.service';
/** R4 report-materials domain service composed behind ReportMaterialsService. */
export class ReportBatchGenerationService {
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService, private readonly operations: ReportBatchOperationService, private readonly channelExport: ReportChannelExportService) {}
async listBatches(query: PagedQuery = {}) {
const page = normalizePage(query.page);
const pageSize = normalizePageSize(query.pageSize);
const where: Prisma.ReportMaterialBatchWhereInput = {
createdAt: dateRange(query.startAt, query.endAt),
batchNo: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined,
};
const [batches, total] = await Promise.all([
this.prisma.reportMaterialBatch.findMany({
where,
include: {
exportFiles: {
include: {
items: { include: { task: { select: { id: true, status: true } } } },
},
},
items: true,
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.reportMaterialBatch.count({ where }),
]);
return {
items: batches.map((batch) => {
const reportItems = batch.exportFiles.flatMap((file) => file.items);
const reportTotal = reportItems.length;
const successCount = reportItems.filter((item) => item.task.status === 'approved').length;
return {
...batch,
reportTotal,
successCount,
successRate: reportTotal ? successCount / reportTotal : 0,
};
}),
total,
page,
pageSize,
};
}
async createBatch(data: CreateReportBatchDto) {
if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息');
const idempotencyKey = normalizeBatchIdempotencyKey(data.idempotencyKey);
const uniqueItems = [...new Map(data.items.map((item) => [`${item.reportType}:${item.drainageItemId ?? item.signatureId}`, item])).values()];
const fingerprint = createHash('sha256').update(JSON.stringify(uniqueItems.map((item) => ({ reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? null, materialVersion: item.materialVersion ?? null })).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))))).digest('hex');
const claimed = await this.operations.claimBatchOperation(idempotencyKey, fingerprint, data.createdById);
if (claimed.replayed) return claimed.result;
let preflight: Awaited<ReturnType<ReportBatchGenerationService['preflightBatch']>>;
try {
preflight = await this.preflightBatch({ items: uniqueItems });
} catch (error) {
await this.operations.failBatchOperation(claimed.operationId, error instanceof Error ? error.message : '报备资格预检失败');
throw error;
}
if (preflight.eligibleTargetCount === 0) {
await this.operations.failBatchOperation(claimed.operationId, '没有可生成的报备目标');
throw new BadRequestException({ code: 'REPORT_BATCH_NOT_ELIGIBLE', message: '所选资料没有可生成的通道,请按资格检查补充后重试', preflight });
}
const eligibleInspections = preflight.items.filter((item) => item.targets.some((target) => target.eligible));
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: eligibleInspections.length },
});
try {
const prepared = [];
for (const inspection of eligibleInspections) {
const selected = uniqueItems.find((item) => item.reportType === inspection.reportType && (item.drainageItemId ?? item.signatureId) === (inspection.drainageItemId ?? inspection.signatureId))!;
prepared.push(await this.prepareBatchItem(batch.id, selected, inspection));
}
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));
let failedTargetCount = 0;
for (const [channelId, items] of channelMap) {
const result = await this.channelExport.exportChannelBatch(batch.id, channelId, items);
exportedFiles.push(result.file);
failedTargetCount += result.incompleteBatchItemIds.length;
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 } });
}
const completed = await 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 } });
const result = {
...completed,
operationId: claimed.operationId,
replayed: false,
result: {
successCount: preflight.eligibleTargetCount - failedTargetCount,
skippedCount: preflight.skippedTargetCount,
failedCount: failedTargetCount,
items: preflight.items,
},
};
await this.operations.completeBatchOperation(claimed.operationId, batch.id, result);
return result;
} catch (error) {
await this.prisma.reportMaterialBatch.update({ where: { id: batch.id }, data: { status: 'failed', errorMessage: error instanceof Error ? error.message : '生成报备批次失败', completedAt: new Date() } });
await this.operations.failBatchOperation(claimed.operationId, error instanceof Error ? error.message : '生成报备批次失败', batch.id);
throw error;
}
}
async preflightBatch(data: Pick<CreateReportBatchDto, 'items'>) {
if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息');
for (const item of data.items) {
if (!['signature', 'drainage'].includes(item.reportType) || !item.signatureId) throw new BadRequestException({ code: 'REPORT_BATCH_ITEM_INVALID', message: '每条报备资料必须包含有效的资料类型和签名ID' });
if (item.reportType === 'drainage' && !item.drainageItemId) throw new BadRequestException({ code: 'REPORT_BATCH_ITEM_INVALID', message: '引流资料必须包含引流资料ID' });
}
const uniqueItems = [...new Map(data.items.map((item) => [`${item.reportType}:${item.drainageItemId ?? item.signatureId}`, item])).values()];
const items = await Promise.all(uniqueItems.map((item) => this.inspectBatchItem(item)));
return {
checkedAt: new Date().toISOString(),
eligible: items.some((item) => item.eligible),
eligibleItemCount: items.filter((item) => item.eligible).length,
blockedItemCount: items.filter((item) => !item.eligible).length,
eligibleTargetCount: items.reduce((sum, item) => sum + item.targets.filter((target) => target.eligible).length, 0),
skippedTargetCount: items.reduce((sum, item) => sum + item.targets.filter((target) => !target.eligible).length, 0),
items,
};
}
async prepareBatchItem(batchId: string, selected: CreateReportBatchDto['items'][number], inspection: ReportBatchInspection) {
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 eligibleChannelIds = new Set(inspection.targets.filter((target) => target.eligible).map((target) => target.id));
const channels = [...new Map(routes.flatMap((route) => route.group.items.map((entry) => entry.channel)).filter((channel) => channel.status === 'active' && eligibleChannelIds.has(channel.id)).map((channel) => [channel.id, channel])).values()];
const snapshot = selected.reportType === 'signature'
? { reportType: 'signature', applicationId: signature.applicationId, businessKeys: inspection.targets.filter((target) => target.eligible).map((target) => target.businessKey), 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', applicationId: signature.applicationId, businessKeys: inspection.targets.filter((target) => target.eligible).map((target) => target.businessKey), 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 };
}
async inspectBatchItem(selected: CreateReportBatchDto['items'][number]): Promise<ReportBatchInspection> {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: selected.signatureId }, include: { tenant: true, application: true } });
if (!signature) throw new NotFoundException('签名不存在');
const drainageInfo = selected.reportType === 'drainage' && selected.drainageItemId
? await this.prisma.smsDrainageInfo.findUnique({ where: { id: selected.drainageItemId } }) : null;
const materialVersion = selected.reportType === 'signature' ? signature.materialVersion : drainageInfo?.materialVersion ?? 0;
const blockedReasons: string[] = [];
if (signature.auditStatus !== 'approved') blockedReasons.push('签名尚未审核通过');
if (!signature.pendingReport) blockedReasons.push('该签名版本已不在待报备池');
if (!signature.applicationId || !signature.application) blockedReasons.push('未绑定短信应用');
else if (signature.application.status !== 'active') blockedReasons.push('短信应用未启用');
if (selected.materialVersion !== undefined && selected.materialVersion !== materialVersion) blockedReasons.push(`资料版本已变化(当前 V${materialVersion}`);
if (selected.reportType === 'drainage') {
if (!drainageInfo || drainageInfo.signatureId !== signature.id) blockedReasons.push('引流资料不存在或不属于当前签名');
else {
if (drainageInfo.auditStatus !== 'approved') blockedReasons.push('引流资料尚未审核通过');
if (!drainageInfo.pendingReport) blockedReasons.push('该引流资料版本已不在待报备池');
}
}
const snapshot = selected.reportType === 'signature'
? { signature: { name: signature.name, purpose: signature.purpose, tenantName: signature.tenant.name, applicationName: signature.application?.name }, values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues) }
: { signature: { name: signature.name, tenantName: signature.tenant.name, applicationName: signature.application?.name }, drainage: { siteName: drainageInfo?.siteName, url: drainageInfo?.url, remark: drainageInfo?.remark }, values: jsonRecord(drainageInfo?.reportValues) };
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 channelCarriers = new Map<string, { channel: { id: string; name: string; status: string; carrier?: string | null }; carriers: Set<string> }>();
for (const route of routes) {
if (route.group.status !== 'active') continue;
for (const entry of route.group.items) {
if (entry.channel.status !== 'active') continue;
const current = channelCarriers.get(entry.channel.id) ?? { channel: entry.channel, carriers: new Set<string>() };
current.carriers.add(route.carrier || entry.carrier || entry.channel.carrier || 'all');
channelCarriers.set(entry.channel.id, current);
}
}
if (blockedReasons.length === 0 && channelCarriers.size === 0) blockedReasons.push('当前应用没有启用且可路由的通道');
const previous = await this.prisma.reportMaterialBatchItem.findMany({
where: { signatureId: signature.id, drainageItemId: selected.reportType === 'drainage' ? drainageInfo?.id : null, reportType: selected.reportType, materialVersion, batch: { status: { in: ['completed', 'partial_failed'] } } },
select: { batchId: true, snapshot: true, exportItems: { select: { exportFile: { select: { channelId: true } } } } },
orderBy: { createdAt: 'desc' },
});
const priorKeys = new Map<string, string>();
for (const item of previous) {
const exportedChannelIds = new Set(item.exportItems.map((entry) => entry.exportFile.channelId).filter((value): value is string => Boolean(value)));
for (const key of jsonStringArray(jsonRecord(item.snapshot).businessKeys)) {
if ([...exportedChannelIds].some((channelId) => key.includes(`:channel:${channelId}:`)) && !priorKeys.has(key)) priorKeys.set(key, item.batchId);
}
}
const targets: ReportBatchTarget[] = [];
for (const { channel, carriers } of channelCarriers.values()) {
const carrier = [...carriers].sort().join(',');
const businessKey = `${selected.reportType}:${selected.drainageItemId ?? signature.id}:v${materialVersion}:app:${signature.applicationId}:channel:${channel.id}:carrier:${carrier}`;
const targetReasons = [...blockedReasons];
const fields = await this.prisma.channelReportField.findMany({ where: { channelId: channel.id, status: 'active', reportType: { in: [selected.reportType, 'both'] } }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] });
if (fields.length === 0) targetReasons.push('通道未配置当前资料类型的报备字段');
else {
const missing = fields.filter((field) => field.required && !hasValue(resolveExportValue(snapshot, field.code, field.name) ?? field.defaultValue));
if (missing.length) targetReasons.push(`缺少必填字段:${missing.map((field) => field.exportName || field.name).join('、')}`);
}
const duplicateBatchId = priorKeys.get(businessKey);
if (duplicateBatchId) targetReasons.push(`同一资料版本已在批次 ${duplicateBatchId} 生成`);
targets.push({ id: channel.id, name: channel.name, carrier, businessKey, eligible: targetReasons.length === 0, blockedReasons: targetReasons, duplicateBatchId });
}
return {
id: `${selected.reportType}:${selected.drainageItemId ?? signature.id}`,
reportType: selected.reportType,
signatureId: signature.id,
drainageItemId: drainageInfo?.id,
materialVersion,
name: selected.reportType === 'signature' ? signature.name : drainageInfo?.siteName ?? '引流资料',
tenantName: signature.tenant.name,
applicationId: signature.applicationId ?? undefined,
applicationName: signature.application?.name ?? '未指定应用',
eligible: targets.some((target) => target.eligible),
blockedReasons: targets.length ? [...new Set(targets.flatMap((target) => target.blockedReasons))] : blockedReasons,
targets,
};
}
}
@@ -0,0 +1,40 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import { createHash, randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.service';
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
/** R4 report-materials domain service composed behind ReportMaterialsService. */
export class ReportBatchOperationService {
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService) {}
async claimBatchOperation(idempotencyKey: string, fingerprint: string, userId?: string) {
return this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${idempotencyKey}))`;
const existing = await tx.operationLog.findFirst({ where: { action: 'report_material.batch_generation', resource: 'report_material_batch', resourceId: idempotencyKey }, orderBy: { createdAt: 'desc' } });
if (existing) {
const detail = jsonRecord(existing.detail);
if (detail.fingerprint !== fingerprint) throw new ConflictException({ code: 'IDEMPOTENCY_KEY_REUSED', message: '该幂等键已用于不同的报备范围' });
if (detail.status === 'completed' && detail.result) return { operationId: existing.id, replayed: true as const, result: { ...jsonRecord(detail.result), replayed: true, operationId: existing.id } };
throw new ConflictException({ code: 'REPORT_BATCH_IN_PROGRESS', message: detail.status === 'failed' ? '上次生成失败,请使用新的操作单重试' : '该报备操作正在处理中,请勿重复提交' });
}
const operation = await tx.operationLog.create({ data: { userId, action: 'report_material.batch_generation', resource: 'report_material_batch', resourceId: idempotencyKey, detail: { status: 'processing', fingerprint } } });
return { operationId: operation.id, replayed: false as const, result: null };
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
}
async completeBatchOperation(operationId: string, batchId: string, result: Record<string, unknown>) {
await this.prisma.operationLog.update({ where: { id: operationId }, data: { detail: { status: 'completed', batchId, fingerprint: jsonRecord((await this.prisma.operationLog.findUnique({ where: { id: operationId } }))?.detail).fingerprint, result: jsonSafe(result) } as Prisma.InputJsonValue } });
}
async failBatchOperation(operationId: string, message: string, batchId?: string) {
const operation = await this.prisma.operationLog.findUnique({ where: { id: operationId } });
await this.prisma.operationLog.update({ where: { id: operationId }, data: { detail: { ...jsonRecord(operation?.detail), status: 'failed', batchId, message } as Prisma.InputJsonValue } });
}
}
@@ -0,0 +1,81 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import { createHash, randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.service';
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
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 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 };
}
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' } });
}
}
@@ -0,0 +1,117 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import { createHash, randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.service';
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
/** R4 report-materials domain service composed behind ReportMaterialsService. */
export class ReportImportParserService {
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService) {}
listImportProfiles(reportType?: 'signature' | 'drainage') {
return this.prisma.reportMaterialImportProfile.findMany({
where: { reportType, status: 'active' },
include: { columns: { orderBy: [{ sortOrder: 'asc' }, { sourceColumnIndex: 'asc' }] } },
orderBy: { updatedAt: 'desc' },
});
}
async saveImportProfile(data: CreateImportProfileDto) {
validateProfile(data);
return this.prisma.$transaction(async (tx) => {
const profile = data.id
? await tx.reportMaterialImportProfile.update({ where: { id: data.id }, data: profileData(data) })
: await tx.reportMaterialImportProfile.create({ data: profileData(data) });
await tx.reportMaterialImportProfileColumn.deleteMany({ where: { profileId: profile.id } });
await tx.reportMaterialImportProfileColumn.createMany({
data: data.columns.map((column, index) => ({
profileId: profile.id,
sourceHeader: column.sourceHeader,
sourceHeaderPath: column.sourceHeaderPath,
sourceColumnIndex: column.sourceColumnIndex,
targetFieldCode: column.targetFieldCode,
targetKind: column.targetKind,
fieldType: column.fieldType,
required: column.required ?? false,
transform: column.transform,
sortOrder: column.sortOrder ?? (index + 1) * 10,
})),
});
return tx.reportMaterialImportProfile.findUnique({ where: { id: profile.id }, include: { columns: { orderBy: { sortOrder: 'asc' } } } });
});
}
async analyzeImport(file: { originalname: string; mimetype: string; size: number; buffer: Buffer }, options: AnalyzeImportOptions) {
if (!options.tenantId) throw new BadRequestException('tenantId is required');
if (!['signature', 'drainage'].includes(options.reportType)) throw new BadRequestException('reportType must be signature or drainage');
if (extname(file.originalname).toLowerCase() !== '.xlsx' || file.buffer[0] !== 0x50 || file.buffer[1] !== 0x4b) throw new BadRequestException('仅支持有效的 XLSX 文件');
const workbook = await loadWorkbook(file.buffer);
assertSafeWorkbook(workbook);
const profile = options.profileId ? await this.prisma.reportMaterialImportProfile.findUnique({ where: { id: options.profileId }, include: { columns: { orderBy: { sortOrder: 'asc' } } } }) : null;
const selectedSheetName = options.sheetName || profile?.sheetName || undefined;
const worksheet = selectedSheetName ? workbook.getWorksheet(selectedSheetName) : workbook.worksheets[0];
if (!worksheet) throw new BadRequestException('工作簿没有可读取的工作表');
const headerRowCount = clamp(options.headerRowCount, 1, 5);
const dataStartRow = Math.max(options.dataStartRow, headerRowCount + 1);
const images = readEmbeddedImages(workbook, worksheet);
const columnCount = Math.min(worksheet.columnCount, 200);
const columns = Array.from({ length: columnCount }, (_, offset) => {
const sourceColumnIndex = offset + 1;
const parts = Array.from({ length: headerRowCount }, (_, headerOffset) => cellText(worksheet.getCell(headerOffset + 1, sourceColumnIndex))).filter(Boolean);
const sourceHeaderPath = [...new Set(parts)].join('/');
return {
sourceColumnIndex,
columnLetter: worksheet.getColumn(sourceColumnIndex).letter,
sourceHeader: parts.at(-1) || `${sourceColumnIndex}`,
sourceHeaderPath,
imageCount: images.filter((image) => image.column === sourceColumnIndex).length,
};
}).filter((column) => column.sourceHeaderPath || column.imageCount > 0);
const previewRows = [];
for (let rowNumber = dataStartRow; rowNumber <= Math.min(worksheet.rowCount, dataStartRow + 9); rowNumber += 1) {
const values = Object.fromEntries(columns.map((column) => [String(column.sourceColumnIndex), cellText(worksheet.getCell(rowNumber, column.sourceColumnIndex))]));
const imageColumns = images.filter((image) => image.row === rowNumber).map((image) => image.column);
if (Object.values(values).some(Boolean) || imageColumns.length) previewRows.push({ rowNumber, values, imageColumns });
}
const sourceFile = await this.files.upload({ tenantId: options.tenantId, purpose: 'report_material_import', prefix: 'report-material-imports' }, file);
const profileMappings = profile?.columns.map((column) => ({
sourceHeader: column.sourceHeader,
sourceHeaderPath: column.sourceHeaderPath ?? undefined,
sourceColumnIndex: column.sourceColumnIndex,
targetFieldCode: column.targetFieldCode,
targetKind: column.targetKind as ImportMapping['targetKind'],
fieldType: column.fieldType as ImportMapping['fieldType'],
required: column.required,
transform: column.transform ?? undefined,
sortOrder: column.sortOrder,
}));
const suggestedMappings = profileMappings?.length ? remapProfileColumns(profileMappings, columns) : suggestMappings(columns, options.reportType);
const batch = await this.prisma.reportMaterialImportBatch.create({
data: {
tenantId: options.tenantId,
applicationId: options.applicationId,
profileId: options.profileId,
fileObjectId: sourceFile.id,
fileName: sourceFile.fileName,
reportType: options.reportType,
sheetName: worksheet.name,
headerRowCount,
dataStartRow,
mapping: suggestedMappings as Prisma.InputJsonValue,
preview: { sheets: workbook.worksheets.map((sheet) => sheet.name), columns, rows: previewRows, imageCount: images.length } as Prisma.InputJsonValue,
rowCount: Math.max(0, worksheet.rowCount - dataStartRow + 1),
},
});
await this.prisma.operationLog.create({ data: {
tenantId: options.tenantId, userId: options.operatorId, action: 'report_material.import_analyzed', resource: 'report_material_import', resourceId: batch.id,
detail: { fileName: sourceFile.fileName, filters: { applicationId: options.applicationId, reportType: options.reportType, sheetName: worksheet.name }, successCount: previewRows.length, failedCount: 0 } as Prisma.InputJsonValue,
} });
return { ...batch, sourceFile, sheets: workbook.worksheets.map((sheet) => sheet.name), columns, rows: previewRows, imageCount: images.length, suggestedMappings };
}
}
@@ -0,0 +1,342 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import { createHash, randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.service';
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
import { ReportImportParserService } from './import-parser.service';
/** R4 report-materials domain service composed behind ReportMaterialsService. */
export class ReportImportReviewService {
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService, private readonly importParser: ReportImportParserService) {}
async commitImport(batchId: string, data: ImportCommitDto) {
const batch = await this.prisma.reportMaterialImportBatch.findUnique({ where: { id: batchId } });
if (!batch) throw new NotFoundException('导入批次不存在');
if (batch.status !== 'analyzed') throw new ConflictException('该导入批次已提交审核,不能重复导入');
if (!data.mappings?.length) throw new BadRequestException('请至少配置一个导入字段映射');
if (data.profile) await this.importParser.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);
assertSafeWorkbook(workbook);
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 }> = [];
const stagedItems: Prisma.ReportMaterialImportItemCreateManyInput[] = [];
for (let rowNumber = batch.dataStartRow; rowNumber <= worksheet.rowCount; rowNumber += 1) {
const values: Record<string, unknown> = {};
try {
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}`);
}
const staged = batch.reportType === 'signature'
? await this.stageSignatureRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values)
: await this.stageDrainageRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values);
stagedItems.push({
batchId,
rowNumber,
reportType: batch.reportType,
operation: staged.operation,
targetId: staged.targetId,
status: 'pending_review',
payload: staged.payload as Prisma.InputJsonValue,
originalSnapshot: staged.originalSnapshot as Prisma.InputJsonValue | undefined,
});
successCount += 1;
} catch (error) {
const reason = error instanceof Error ? error.message : '导入失败';
failures.push({ rowNumber, reason });
stagedItems.push({
batchId,
rowNumber,
reportType: batch.reportType,
operation: 'invalid',
status: 'invalid',
payload: values as Prisma.InputJsonValue,
errorMessage: reason,
});
}
}
const updated = await this.prisma.$transaction(async (tx) => {
if (stagedItems.length) await tx.reportMaterialImportItem.createMany({ data: stagedItems });
return tx.reportMaterialImportBatch.update({
where: { id: batchId },
data: {
status: successCount ? 'pending_review' : 'failed',
mapping: data.mappings as Prisma.InputJsonValue,
result: { failures } as Prisma.InputJsonValue,
successCount,
failedCount: failures.length,
},
include: { items: { orderBy: { rowNumber: 'asc' } } },
});
});
await this.prisma.operationLog.create({ data: {
tenantId: batch.tenantId, userId: data.operatorId, action: 'report_material.import_committed', resource: 'report_material_import', resourceId: batch.id,
detail: { fileName: batch.fileName, filters: { applicationId: batch.applicationId, reportType: batch.reportType, sheetName: batch.sheetName }, successCount, failedCount: failures.length } as Prisma.InputJsonValue,
} });
return updated;
}
async listImportReviewBatches(query: PagedQuery & { reportType?: 'signature' | 'drainage'; status?: string } = {}) {
const page = normalizePage(query.page);
const pageSize = normalizePageSize(query.pageSize);
const where: Prisma.ReportMaterialImportBatchWhereInput = {
reportType: query.reportType,
status: query.status && query.status !== 'all' ? query.status : undefined,
createdAt: dateRange(query.startAt, query.endAt),
OR: query.keyword?.trim() ? [
{ fileName: { contains: query.keyword.trim() } },
{ id: { contains: query.keyword.trim() } },
] : undefined,
};
const [batches, total] = await Promise.all([
this.prisma.reportMaterialImportBatch.findMany({
where,
include: { items: { orderBy: { rowNumber: 'asc' } } },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.reportMaterialImportBatch.count({ where }),
]);
const tenantIds = [...new Set(batches.map((batch) => batch.tenantId))];
const applicationIds = [...new Set(batches.map((batch) => batch.applicationId).filter((id): id is string => Boolean(id)))];
const reviewerIds = [...new Set(batches.flatMap((batch) => [
batch.reviewedById,
...batch.items.map((item) => item.reviewedById),
]).filter((id): id is string => Boolean(id)))];
const [tenants, applications, reviewers] = await Promise.all([
tenantIds.length ? this.prisma.tenant.findMany({ where: { id: { in: tenantIds } }, select: { id: true, name: true } }) : [],
applicationIds.length ? this.prisma.smsApplication.findMany({ where: { id: { in: applicationIds } }, select: { id: true, name: true } }) : [],
reviewerIds.length ? this.prisma.user.findMany({ where: { id: { in: reviewerIds } }, select: { id: true, username: true, displayName: true } }) : [],
]);
const tenantById = new Map(tenants.map((item) => [item.id, item]));
const applicationById = new Map(applications.map((item) => [item.id, item]));
const reviewerById = new Map(reviewers.map((item) => [item.id, item]));
return {
items: batches.map((batch) => ({
...batch,
tenant: tenantById.get(batch.tenantId) ?? null,
application: batch.applicationId ? applicationById.get(batch.applicationId) ?? null : null,
reviewer: batch.reviewedById ? reviewerById.get(batch.reviewedById) ?? null : null,
items: batch.items.map((item) => ({
...item,
reviewer: item.reviewedById ? reviewerById.get(item.reviewedById) ?? null : null,
})),
})),
total,
page,
pageSize,
};
}
async reviewImportItems(batchId: string, data: ReviewImportItemsDto) {
if (!data.reviewerId) throw new BadRequestException('Reviewer session is required');
if (!['approve', 'reject'].includes(data.decision)) throw new BadRequestException('Unsupported import review decision');
const batch = await this.prisma.reportMaterialImportBatch.findUnique({
where: { id: batchId },
include: { items: { where: { id: data.itemIds?.length ? { in: data.itemIds } : undefined, status: 'pending_review' }, orderBy: { rowNumber: 'asc' } } },
});
if (!batch) throw new NotFoundException('导入审核批次不存在');
if (!batch.items.length) throw new BadRequestException('没有可审核的导入明细');
let approvedCount = 0;
let rejectedCount = 0;
const failures: Array<{ itemId: string; rowNumber: number; reason: string }> = [];
for (const item of batch.items) {
if (data.decision === 'reject') {
await this.prisma.reportMaterialImportItem.update({
where: { id: item.id },
data: { status: 'rejected', reviewReason: data.reason?.trim(), reviewedById: data.reviewerId, reviewedAt: new Date() },
});
rejectedCount += 1;
continue;
}
try {
const targetId = await this.applyImportItem(batch, item, data.reviewerId);
await this.prisma.reportMaterialImportItem.update({
where: { id: item.id },
data: { targetId, status: 'approved', reviewReason: data.reason?.trim(), reviewedById: data.reviewerId, reviewedAt: new Date(), errorMessage: null },
});
approvedCount += 1;
} catch (error) {
const reason = error instanceof Error ? error.message : '导入审核应用失败';
failures.push({ itemId: item.id, rowNumber: item.rowNumber, reason });
await this.prisma.reportMaterialImportItem.update({
where: { id: item.id },
data: { status: 'invalid', errorMessage: reason, reviewedById: data.reviewerId, reviewedAt: new Date() },
});
}
}
const counts = await this.prisma.reportMaterialImportItem.groupBy({
by: ['status'],
where: { batchId },
_count: { _all: true },
});
const countByStatus = new Map(counts.map((item) => [item.status, item._count._all]));
const pendingCount = countByStatus.get('pending_review') ?? 0;
const totalApproved = countByStatus.get('approved') ?? 0;
const totalRejected = countByStatus.get('rejected') ?? 0;
const totalInvalid = countByStatus.get('invalid') ?? 0;
const status = pendingCount
? 'partially_reviewed'
: totalApproved && (totalRejected || totalInvalid)
? 'partially_approved'
: totalApproved
? 'approved'
: totalRejected
? 'rejected'
: 'failed';
await this.prisma.reportMaterialImportBatch.update({
where: { id: batchId },
data: {
status,
reviewedById: pendingCount ? undefined : data.reviewerId,
reviewedAt: pendingCount ? undefined : new Date(),
completedAt: pendingCount ? undefined : new Date(),
},
});
return { batchId, status, approvedCount, rejectedCount, failedCount: failures.length, failures };
}
async stageSignatureRow(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' } } });
return {
operation: existing ? 'update' : 'create',
targetId: existing?.id,
payload: {
tenantId,
applicationId,
name,
purpose,
drainageInfo: { ...jsonRecord(existing?.drainageInfo), signatureReportValues },
},
originalSnapshot: existing ? {
id: existing.id,
applicationId: existing.applicationId,
name: existing.name,
purpose: existing.purpose,
drainageInfo: existing.drainageInfo,
auditStatus: existing.auditStatus,
updatedAt: existing.updatedAt,
} : undefined,
};
}
async stageDrainageRow(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' } } });
return {
operation: existing ? 'update' : 'create',
targetId: existing?.id,
payload: { tenantId, applicationId, signatureId: signature.id, signatureName, siteName, url, remark, reportValues },
originalSnapshot: existing ? {
id: existing.id,
siteName: existing.siteName,
url: existing.url,
remark: existing.remark,
reportValues: existing.reportValues,
auditStatus: existing.auditStatus,
updatedAt: existing.updatedAt,
} : undefined,
};
}
async applyImportItem(
batch: { tenantId: string; applicationId: string | null; reportType: string },
item: { reportType: string; targetId: string | null; payload: Prisma.JsonValue },
reviewerId: string,
) {
const payload = jsonRecord(item.payload);
if (item.reportType === 'signature') {
const name = String(payload.name ?? '');
const applicationId = typeof payload.applicationId === 'string' ? payload.applicationId : undefined;
const body = {
applicationId,
name,
purpose: typeof payload.purpose === 'string' ? payload.purpose : undefined,
drainageInfo: jsonRecord(payload.drainageInfo),
};
let targetId = item.targetId;
if (targetId) {
const current = await this.prisma.smsSignature.findUnique({ where: { id: targetId } });
if (!current || current.auditStatus === 'deleted') throw new Error('原签名已删除,不能应用导入修改');
await this.smsConfig.updateSignature(targetId, body, batch.tenantId);
} else {
const duplicate = await this.prisma.smsSignature.findFirst({
where: { tenantId: batch.tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } },
});
if (duplicate) {
targetId = duplicate.id;
await this.smsConfig.updateSignature(targetId, body, batch.tenantId);
} else {
const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...body });
targetId = created.id;
}
}
await this.smsConfig.approveSignature(targetId, { reviewerId, reason: `批量导入审核通过:${name}` });
return targetId;
}
const signatureId = String(payload.signatureId ?? '');
const siteName = String(payload.siteName ?? '');
const url = String(payload.url ?? '');
const body = {
siteName,
url,
remark: typeof payload.remark === 'string' ? payload.remark : undefined,
reportValues: jsonRecord(payload.reportValues),
};
let targetId = item.targetId;
if (targetId) {
const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: targetId } });
if (!current || current.auditStatus === 'deleted') throw new Error('原引流信息已删除,不能应用导入修改');
await this.smsConfig.updateDrainageInfo(targetId, body, { initialAuditStatus: 'pending' }, batch.tenantId);
} else {
const duplicate = await this.prisma.smsDrainageInfo.findFirst({
where: { signatureId, url, auditStatus: { not: 'deleted' } },
});
if (duplicate) {
targetId = duplicate.id;
await this.smsConfig.updateDrainageInfo(targetId, body, { initialAuditStatus: 'pending' }, batch.tenantId);
} else {
const created = await this.smsConfig.createDrainageInfo(signatureId, body, { initialAuditStatus: 'pending' }, batch.tenantId);
targetId = created.id;
}
}
await this.smsConfig.approveDrainageInfo(targetId, { reviewerId, reason: `批量导入审核通过:${siteName || url}` });
return targetId;
}
}
@@ -0,0 +1,58 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import { createHash, randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.service';
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
import { ReportPendingQueryService } from './pending-query.service';
/** R4 report-materials domain service composed behind ReportMaterialsService. */
export class ReportOfficialExportService {
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService, private readonly pending: ReportPendingQueryService) {}
async buildOfficialTemplate(reportType: 'signature' | 'drainage', operatorId?: string) {
const workbook = new ExcelJS.Workbook();
workbook.creator = 'CMPP短信平台';
const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名资料' : '引流信息', { views: [{ state: 'frozen', ySplit: 1 }] });
const headers = reportType === 'signature'
? ['短信签名', '用途说明', '营业执照图片', '授权书图片', '备注']
: ['短信签名', '站点名称', 'URL', '备注', '网站截图'];
sheet.addRow(headers);
sheet.addRow(reportType === 'signature'
? ['示例签名', '验证码通知', '请在本单元格插入图片', '请在本单元格插入图片', '示例行,导入前请删除']
: ['示例签名', '官方站点', 'https://example.com', '示例行,导入前请删除', '请在本单元格插入图片']);
styleHeader(sheet.getRow(1));
sheet.columns.forEach((column) => { column.width = 24; });
sheet.getRow(2).height = 48;
const content = Buffer.from(await workbook.xlsx.writeBuffer());
const fileName = `${reportType === 'signature' ? '签名' : '引流信息'}报备资料官方模板.xlsx`;
await this.prisma.operationLog.create({ data: {
userId: operatorId, action: 'report_material.template_downloaded', resource: 'report_material',
detail: { fileName, filters: { reportType }, successCount: 1, failedCount: 0 } as Prisma.InputJsonValue,
} });
return { fileName, content };
}
async exportPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string }, operatorId?: string) {
const items = await this.pending.findPendingItems(query);
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('待报备资料', { views: [{ state: 'frozen', ySplit: 1 }] });
sheet.addRow(['资料类型', '企业', '企业应用', '签名/站点', '详情', '变更时间']);
styleHeader(sheet.getRow(1));
for (const item of items) sheet.addRow([
item.reportType === 'signature' ? '签名' : '引流信息', safeSpreadsheetText(item.tenant?.name),
safeSpreadsheetText(item.application?.name), safeSpreadsheetText(item.name), safeSpreadsheetText(item.detail), item.changedAt,
]);
sheet.columns.forEach((column, index) => { column.width = index === 4 ? 42 : 22; });
const fileName = `待报备资料-${new Date().toISOString().slice(0, 10)}.xlsx`;
await this.prisma.operationLog.create({ data: {
tenantId: query.tenantId, userId: operatorId, action: 'report_material.pending_export', resource: 'report_material',
detail: { fileName, filters: query, successCount: items.length, failedCount: 0 } as Prisma.InputJsonValue,
} });
return { fileName, content: Buffer.from(await workbook.xlsx.writeBuffer()) };
}
}
@@ -0,0 +1,73 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import { createHash, randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.service';
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
/** R4 report-materials domain service composed behind ReportMaterialsService. */
export class ReportPendingQueryService {
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService) {}
async listPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } & PagedQuery) {
const items = await this.findPendingItems(query);
const page = normalizePage(query.page);
const pageSize = normalizePageSize(query.pageSize);
return {
items: items.slice((page - 1) * pageSize, page * pageSize),
total: items.length,
page,
pageSize,
};
}
async findPendingItems(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } & PagedQuery) {
const changedAt = dateRange(query.startAt, query.endAt);
const keyword = query.keyword?.trim();
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,
reportChangedAt: changedAt,
OR: keyword ? [
{ name: { contains: keyword } },
{ tenant: { name: { contains: keyword } } },
{ application: { name: { contains: keyword } } },
] : undefined,
},
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,
reportChangedAt: changedAt,
OR: keyword ? [
{ siteName: { contains: keyword } },
{ url: { contains: keyword } },
{ signature: { name: { contains: keyword } } },
{ tenant: { name: { contains: keyword } } },
{ application: { name: { contains: keyword } } },
] : undefined,
},
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());
}
}
@@ -0,0 +1,83 @@
/** Stable request, query and internal data contracts for report-material domains. */
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;
operatorId?: string;
}
export interface ReviewImportItemsDto {
decision: 'approve' | 'reject';
itemIds?: string[];
reason?: string;
reviewerId?: string;
}
export type PagedQuery = {
keyword?: string;
startAt?: string;
endAt?: string;
page?: number;
pageSize?: number;
};
export interface CreateReportBatchDto {
createdById?: string;
idempotencyKey?: string;
items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion?: number }>;
}
export type ReportBatchTarget = { id: string; name: string; carrier: string; businessKey: string; eligible: boolean; blockedReasons: string[]; duplicateBatchId?: string };
export type ReportBatchInspection = {
id: string;
reportType: 'signature' | 'drainage';
signatureId: string;
drainageItemId?: string;
materialVersion: number;
name: string;
tenantName: string;
applicationId?: string;
applicationName: string;
eligible: boolean;
blockedReasons: string[];
targets: ReportBatchTarget[];
};
export type AnalyzeImportOptions = {
tenantId: string;
applicationId?: string;
reportType: 'signature' | 'drainage';
sheetName?: string;
headerRowCount: number;
dataStartRow: number;
profileId?: string;
operatorId?: string;
};
export type EmbeddedImage = { row: number; column: number; extension: string; buffer: Buffer };
@@ -3,7 +3,8 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags } from '@nestjs/swagger';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { CreateImportProfileDto, CreateReportBatchDto, ImportCommitDto, ReportMaterialsService, ReviewImportItemsDto } from './report-materials.service';
import { ReportMaterialsService } from './report-materials.service';
import { CreateImportProfileDto, CreateReportBatchDto, ImportCommitDto, ReviewImportItemsDto } from './report-materials.contracts';
type UploadedWorkbook = { originalname: string; mimetype: string; size: number; buffer: Buffer };
type DownloadResponse = { setHeader(name: string, value: string): void; send(content: Buffer): void };
@@ -0,0 +1,201 @@
import { BadRequestException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import { randomUUID } from 'node:crypto';
import type { CreateImportProfileDto, EmbeddedImage, ImportMapping } from './report-materials.contracts';
/** Pure workbook, mapping, pagination and export helpers shared by R4 domains. */
export 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' };
}
export 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('同一源列不能重复映射');
}
export async function loadWorkbook(buffer: Buffer) {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buffer as never);
return workbook;
}
export function assertSafeWorkbook(workbook: ExcelJS.Workbook) {
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)) {
throw new BadRequestException(`工作表 ${worksheet.name} 包含公式或可执行单元格`);
}
const text = typeof value === 'string' ? value.trimStart() : '';
if (/^[=+@]/.test(text) || /^-[^\d.]/.test(text)) {
throw new BadRequestException(`工作表 ${worksheet.name} 包含公式或可执行单元格`);
}
}));
}
}
export function safeSpreadsheetText(value: unknown) {
const text = value == null ? '' : String(value);
return /^[=+@]/.test(text) || /^-[^\d.]/.test(text) ? `'${text}` : text;
}
export 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 }] : [];
});
}
export 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 }];
});
}
export 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 }];
});
}
export 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;
}
export 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;
}
export function normalizeHeader(value: string) { return value.toLowerCase().replace(/[\s**::()()_-]/g, ''); }
export function normalizeFieldCode(value: string) { return `import_${value.trim().toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_').slice(0, 40) || randomUUID().slice(0, 8)}`; }
export function clamp(value: number, minimum: number, maximum: number) { return Math.min(maximum, Math.max(minimum, Number.isFinite(value) ? Math.round(value) : minimum)); }
export function normalizePage(value?: number) { return Math.max(1, Math.floor(Number(value) || 1)); }
export function normalizePageSize(value?: number) { return Math.min(100, Math.max(1, Math.floor(Number(value) || 20))); }
export function dateRange(startAt?: string, endAt?: string) {
const start = startAt ? new Date(`${startAt}T00:00:00+08:00`) : undefined;
const end = endAt ? new Date(`${endAt}T23:59:59.999+08:00`) : undefined;
if (start && Number.isNaN(start.getTime())) throw new BadRequestException('开始日期无效');
if (end && Number.isNaN(end.getTime())) throw new BadRequestException('结束日期无效');
return start || end ? { gte: start, lte: end } : undefined;
}
export 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();
}
export 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();
}
export 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() : '';
}
export function dynamicValues(mappings: ImportMapping[], values: Record<string, unknown>) {
return Object.fromEntries(mappings.filter((item) => item.targetKind === 'dynamic').map((item) => [item.targetFieldCode, values[item.targetFieldCode]]));
}
export function jsonRecord(value: unknown): Record<string, unknown> { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
export function hasValue(value: unknown) { return isFileRef(value) ? Boolean(value.fileObjectId) : value !== null && value !== undefined && String(value).trim().length > 0; }
export 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'; }
export 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;
}
export function applyExportTransform(value: unknown, transform?: string | null) {
const text = value === null || value === undefined ? '' : String(value);
return transformValue(text, transform ?? undefined);
}
export 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' } } };
});
}
export function normalizeImageExtension(value: string) { const normalized = value.toLowerCase().replace(/^\./, ''); return normalized === 'jpg' ? 'jpeg' : normalized; }
export function imageContentType(extension: string) { const normalized = normalizeImageExtension(extension); return normalized === 'jpeg' ? 'image/jpeg' : normalized === 'gif' ? 'image/gif' : 'image/png'; }
export function safeFileName(value: string) { return value.replace(/[\\/:*?"<>|]/g, '_').slice(0, 80) || '通道报备'; }
export function normalizeBatchIdempotencyKey(value?: string) {
const key = value?.trim();
if (!key || key.length > 128 || !/^[A-Za-z0-9._:-]{8,128}$/.test(key)) throw new BadRequestException({ code: 'IDEMPOTENCY_KEY_INVALID', message: 'idempotencyKey 必填且长度为8至128位' });
return key;
}
export function jsonStringArray(value: unknown) {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
}
export function jsonSafe(value: unknown): Prisma.InputJsonValue {
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
}
File diff suppressed because it is too large Load Diff