202 lines
12 KiB
TypeScript
202 lines
12 KiB
TypeScript
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;
|
|
}
|