2026-10-03 18:28:47 +03:30
|
|
|
|
import * as XLSX from 'xlsx';
|
|
|
|
|
|
import type { ExcelFieldOption, ExcelPreviewResult } from '@mento/shared';
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeHeader(raw: string) {
|
|
|
|
|
|
return raw
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
|
.replace(/[_-]+/g, '')
|
|
|
|
|
|
.replace(//g, '')
|
|
|
|
|
|
.replace(/\s+/g, '')
|
|
|
|
|
|
.replace(/[()]/g, '');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function cellToString(value: unknown): string {
|
|
|
|
|
|
if (value == null) return '';
|
|
|
|
|
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
|
|
|
|
return String(value).trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (value instanceof Date) {
|
|
|
|
|
|
return Number.isNaN(value.getTime()) ? '' : value.toISOString();
|
|
|
|
|
|
}
|
|
|
|
|
|
return String(value).trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveFieldKey(
|
|
|
|
|
|
raw: string,
|
|
|
|
|
|
aliases: Record<string, string>,
|
|
|
|
|
|
mapping?: Record<string, string>,
|
|
|
|
|
|
) {
|
|
|
|
|
|
if (mapping) {
|
|
|
|
|
|
return mapping[raw] || mapping[normalizeHeader(raw)] || '';
|
|
|
|
|
|
}
|
|
|
|
|
|
const compact = normalizeHeader(raw);
|
|
|
|
|
|
return aliases[compact] ?? aliases[raw.trim()] ?? '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-04 10:44:00 +03:30
|
|
|
|
export type ClientExcelPositionalColumn = { key: string; label: string };
|
|
|
|
|
|
|
|
|
|
|
|
export type ClientExcelReadOptions = {
|
|
|
|
|
|
positionalColumns?: ClientExcelPositionalColumn[];
|
|
|
|
|
|
detectHeaderless?: (firstRowCells: string[]) => boolean;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function readMatrix(fileBuffer: ArrayBuffer) {
|
|
|
|
|
|
const wb = XLSX.read(fileBuffer, { type: 'array', cellDates: true });
|
|
|
|
|
|
const sheetName = wb.SheetNames[0];
|
|
|
|
|
|
if (!sheetName) throw new Error('شیت اکسل پیدا نشد');
|
|
|
|
|
|
const sheet = wb.Sheets[sheetName]!;
|
|
|
|
|
|
return XLSX.utils.sheet_to_json<unknown[]>(sheet, {
|
|
|
|
|
|
header: 1,
|
|
|
|
|
|
defval: '',
|
|
|
|
|
|
raw: false,
|
|
|
|
|
|
}) as unknown[][];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 18:28:47 +03:30
|
|
|
|
/** خواندن اکسل در مرورگر — بدون آپلود فایل سنگین به API */
|
|
|
|
|
|
export async function previewExcelClient(
|
|
|
|
|
|
file: File,
|
|
|
|
|
|
aliases: Record<string, string>,
|
|
|
|
|
|
fields: ExcelFieldOption[],
|
2026-10-04 10:44:00 +03:30
|
|
|
|
options?: ClientExcelReadOptions,
|
2026-10-03 18:28:47 +03:30
|
|
|
|
): Promise<ExcelPreviewResult> {
|
2026-10-04 10:44:00 +03:30
|
|
|
|
const matrix = readMatrix(await file.arrayBuffer());
|
2026-10-03 18:28:47 +03:30
|
|
|
|
const headerRow = (matrix[0] ?? []).map((c) => cellToString(c));
|
2026-10-04 10:44:00 +03:30
|
|
|
|
const headerless =
|
|
|
|
|
|
Boolean(options?.positionalColumns?.length) &&
|
|
|
|
|
|
Boolean(options?.detectHeaderless?.(headerRow));
|
|
|
|
|
|
|
2026-10-03 18:28:47 +03:30
|
|
|
|
const columns: ExcelPreviewResult['columns'] = [];
|
|
|
|
|
|
const suggested: Record<string, string> = {};
|
|
|
|
|
|
const used = new Set<string>();
|
|
|
|
|
|
|
2026-10-04 10:44:00 +03:30
|
|
|
|
if (headerless && options?.positionalColumns) {
|
|
|
|
|
|
options.positionalColumns.forEach((colDef, idx) => {
|
|
|
|
|
|
const samples: string[] = [];
|
|
|
|
|
|
for (let r = 0; r < Math.min(matrix.length, 5); r += 1) {
|
|
|
|
|
|
const value = cellToString(matrix[r]?.[idx]);
|
|
|
|
|
|
if (value) samples.push(value);
|
|
|
|
|
|
if (samples.length >= 3) break;
|
|
|
|
|
|
}
|
|
|
|
|
|
columns.push({ header: colDef.label, index: idx + 1, samples });
|
|
|
|
|
|
suggested[colDef.label] = colDef.key;
|
|
|
|
|
|
used.add(colDef.key);
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
headerRow.forEach((header, idx) => {
|
|
|
|
|
|
if (!header) return;
|
|
|
|
|
|
const samples: string[] = [];
|
|
|
|
|
|
for (let r = 1; r < Math.min(matrix.length, 6); r += 1) {
|
|
|
|
|
|
const value = cellToString(matrix[r]?.[idx]);
|
|
|
|
|
|
if (value) samples.push(value);
|
|
|
|
|
|
if (samples.length >= 3) break;
|
|
|
|
|
|
}
|
|
|
|
|
|
columns.push({ header, index: idx + 1, samples });
|
|
|
|
|
|
const key = resolveFieldKey(header, aliases);
|
|
|
|
|
|
if (key && !used.has(key)) {
|
|
|
|
|
|
suggested[header] = key;
|
|
|
|
|
|
used.add(key);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-10-03 18:28:47 +03:30
|
|
|
|
|
|
|
|
|
|
if (!columns.length) throw new Error('ستون عنوان در ردیف اول فایل پیدا نشد');
|
|
|
|
|
|
|
|
|
|
|
|
let rowCount = 0;
|
2026-10-04 10:44:00 +03:30
|
|
|
|
const start = headerless ? 0 : 1;
|
|
|
|
|
|
for (let r = start; r < matrix.length; r += 1) {
|
2026-10-03 18:28:47 +03:30
|
|
|
|
const row = matrix[r] ?? [];
|
|
|
|
|
|
if (row.some((c) => cellToString(c))) rowCount += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const required = fields.filter((f) => f.required).map((f) => f.key);
|
|
|
|
|
|
const mapped = new Set(Object.values(suggested));
|
|
|
|
|
|
const missingRequired = required.some((key) => !mapped.has(key));
|
|
|
|
|
|
const unrecognized = columns.some((col) => !suggested[col.header]);
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
columns,
|
|
|
|
|
|
fields,
|
|
|
|
|
|
suggested,
|
2026-10-04 10:44:00 +03:30
|
|
|
|
needsMapping: headerless
|
|
|
|
|
|
? false
|
|
|
|
|
|
: unrecognized || missingRequired || !mapped.size,
|
2026-10-03 18:28:47 +03:30
|
|
|
|
rowCount,
|
|
|
|
|
|
largeFile: rowCount >= 80,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function readMappedRowsClient(
|
|
|
|
|
|
file: File,
|
|
|
|
|
|
aliases: Record<string, string>,
|
|
|
|
|
|
mapping?: Record<string, string>,
|
2026-10-04 10:44:00 +03:30
|
|
|
|
options?: ClientExcelReadOptions,
|
2026-10-03 18:28:47 +03:30
|
|
|
|
): Promise<Record<string, string>[]> {
|
2026-10-04 10:44:00 +03:30
|
|
|
|
const matrix = readMatrix(await file.arrayBuffer());
|
2026-10-03 18:28:47 +03:30
|
|
|
|
const headerRow = (matrix[0] ?? []).map((c) => cellToString(c));
|
2026-10-04 10:44:00 +03:30
|
|
|
|
const headerless =
|
|
|
|
|
|
!mapping &&
|
|
|
|
|
|
Boolean(options?.positionalColumns?.length) &&
|
|
|
|
|
|
Boolean(options?.detectHeaderless?.(headerRow));
|
|
|
|
|
|
|
2026-10-03 18:28:47 +03:30
|
|
|
|
const colMap = new Map<number, string>();
|
2026-10-04 10:44:00 +03:30
|
|
|
|
let dataStart = 1;
|
|
|
|
|
|
|
|
|
|
|
|
if (headerless && options?.positionalColumns) {
|
|
|
|
|
|
options.positionalColumns.forEach((colDef, idx) => {
|
|
|
|
|
|
colMap.set(idx, colDef.key);
|
|
|
|
|
|
});
|
|
|
|
|
|
dataStart = 0;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
headerRow.forEach((header, idx) => {
|
|
|
|
|
|
if (!header) return;
|
|
|
|
|
|
const key = resolveFieldKey(header, aliases, mapping);
|
|
|
|
|
|
if (key) colMap.set(idx, key);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 18:28:47 +03:30
|
|
|
|
if (!colMap.size) {
|
|
|
|
|
|
throw new Error('ستونهای فایل شناسایی نشد. ستونهای فایل را با فیلدهای سیستم تطبیق دهید.');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const rows: Record<string, string>[] = [];
|
2026-10-04 10:44:00 +03:30
|
|
|
|
for (let r = dataStart; r < matrix.length; r += 1) {
|
2026-10-03 18:28:47 +03:30
|
|
|
|
const line = matrix[r] ?? [];
|
|
|
|
|
|
const item: Record<string, string> = {};
|
|
|
|
|
|
let any = false;
|
|
|
|
|
|
for (const [idx, key] of colMap) {
|
|
|
|
|
|
const value = cellToString(line[idx]);
|
|
|
|
|
|
if (value) any = true;
|
|
|
|
|
|
item[key] = value;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (any) rows.push(item);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!rows.length) throw new Error('هیچ ردیف دادهای در فایل نیست');
|
|
|
|
|
|
return rows;
|
|
|
|
|
|
}
|