Files
CRM_Montzar/apps/api/src/common/excel.ts
T

468 lines
14 KiB
TypeScript
Raw Normal View History

2026-09-15 17:33:05 +03:30
import { BadRequestException } from '@nestjs/common';
import ExcelJS from 'exceljs';
import { EXCEL_IMPORT_LARGE_THRESHOLD } from './excel-import-session';
2026-09-15 17:33:05 +03:30
export function cellStr(value: ExcelJS.CellValue | undefined): string {
2026-10-03 11:32:37 +03:30
try {
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();
}
if (typeof value === 'object' && 'richText' in value && Array.isArray(value.richText)) {
return value.richText.map((part) => part?.text ?? '').join('').trim();
}
if (typeof value === 'object' && 'text' in value && typeof value.text === 'string') {
return value.text.trim();
}
if (typeof value === 'object' && 'result' in value && value.result != null) {
const result = value.result;
if (typeof result === 'string' || typeof result === 'number' || typeof result === 'boolean') {
return String(result).trim();
}
if (result instanceof Date) {
return Number.isNaN(result.getTime()) ? '' : result.toISOString();
}
return '';
}
if (typeof value === 'object' && 'error' in value && value.error != null) {
return String(value.error).trim();
}
return '';
} catch {
return '';
2026-09-15 17:33:05 +03:30
}
}
export function normalizeHeader(raw: string) {
return raw
.trim()
.toLowerCase()
.replace(/[_-]+/g, '')
.replace(/‌/g, '')
.replace(/\s+/g, '')
.replace(/[()]/g, '');
}
export function stripHtml(raw: string) {
return raw
.replace(/<br\s*\/?>/gi, '\n')
.replace(/&nbsp;/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
export function toAsciiDigits(value: string) {
return value
.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)))
.replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)));
}
function div(a: number, b: number) {
return Math.trunc(a / b);
}
function mod(a: number, b: number) {
return a - Math.trunc(a / b) * b;
}
export function toJalali(gy: number, gm: number, gd: number) {
const gdm = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
const gy2 = gm > 2 ? gy + 1 : gy;
let days =
355666 +
365 * gy +
div(gy2 + 3, 4) -
div(gy2 + 99, 100) +
div(gy2 + 399, 400) +
gd +
gdm[gm - 1];
let jy = -1595 + 33 * div(days, 12053);
days %= 12053;
jy += 4 * div(days, 1461);
days %= 1461;
if (days > 365) {
jy += div(days - 1, 365);
days = (days - 1) % 365;
}
const jm = days < 186 ? 1 + div(days, 31) : 7 + div(days - 186, 30);
const jd = 1 + (days < 186 ? days % 31 : (days - 186) % 30);
return { jy, jm, jd };
}
export function toGregorian(jy: number, jm: number, jd: number) {
const jy2 = jy + 1595;
let days =
-355668 +
365 * jy2 +
div(jy2, 33) * 8 +
div(mod(jy2, 33) + 3, 4) +
jd +
(jm < 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186);
let gy = 400 * div(days, 146097);
days %= 146097;
if (days > 36524) {
gy += 100 * div(--days, 36524);
days %= 36524;
if (days >= 365) days += 1;
}
gy += 4 * div(days, 1461);
days %= 1461;
if (days > 365) {
gy += div(days - 1, 365);
days = (days - 1) % 365;
}
const gd = days + 1;
const salA = [
0,
31,
(gy % 4 === 0 && gy % 100 !== 0) || gy % 400 === 0 ? 29 : 28,
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let gm = 0;
let gDay = gd;
for (let i = 1; i <= 12; i += 1) {
const v = salA[i]!;
if (gDay <= v) {
gm = i;
break;
}
gDay -= v;
}
return { gy, gm, gd: gDay };
}
export function formatJalaliDate(d: Date | string | null | undefined) {
if (!d) return '';
const date = typeof d === 'string' ? new Date(d) : d;
if (Number.isNaN(date.getTime())) return '';
const { jy, jm, jd } = toJalali(date.getFullYear(), date.getMonth() + 1, date.getDate());
return `${jy}/${String(jm).padStart(2, '0')}/${String(jd).padStart(2, '0')}`;
}
export function formatClock(d: Date | string | null | undefined) {
if (!d) return '';
const date = typeof d === 'string' ? new Date(d) : d;
if (Number.isNaN(date.getTime())) return '';
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
}
2026-09-15 19:13:30 +03:30
export function parseAmount(raw?: string): number | null {
const text = toAsciiDigits(raw ?? '')
.replace(/[,،]/g, '')
.replace(/\s+/g, '')
.trim();
if (!text) return null;
const n = Number(text.replace(/[^\d.-]/g, ''));
return Number.isFinite(n) ? n : null;
}
2026-10-13 18:04:53 +03:30
/** تاریخ تولد: سال زیر ۱۷۰۰ شمسی است، سال ۱۷۰۰ به بعد میلادی. ظهر UTC تا روز جابه‌جا نشود. */
export function parseBirthDate(raw?: string | null): Date | null {
const text = toAsciiDigits(String(raw ?? '')).trim();
if (!text || text === '-' || text === '0' || text === '0000-00-00') return null;
const datePart = text.split(/[T\s]/)[0] ?? text;
const match = /^(\d{3,4})[\/\-.](\d{1,2})[\/\-.](\d{1,2})$/.exec(datePart);
if (!match) {
const fallback = new Date(text);
return Number.isNaN(fallback.getTime()) ? null : fallback;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
if (year >= 1700) {
return new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
}
if (year < 1200) return null;
const { gy, gm, gd } = toGregorian(year, month, day);
return new Date(Date.UTC(gy, gm - 1, gd, 12, 0, 0));
}
2026-09-15 17:33:05 +03:30
export function parseJalaliDateTime(dateRaw?: string, timeRaw?: string): Date | null {
const dateText = toAsciiDigits(dateRaw ?? '').trim();
if (!dateText) return null;
const match = /^(\d{3,4})[\/\-](\d{1,2})[\/\-](\d{1,2})$/.exec(dateText);
if (!match) {
const fallback = new Date(dateText);
return Number.isNaN(fallback.getTime()) ? null : fallback;
}
const jy = Number(match[1]);
const jm = Number(match[2]);
const jd = Number(match[3]);
const { gy, gm, gd } = toGregorian(jy, jm, jd);
let hh = 0;
let mm = 0;
const timeText = toAsciiDigits(timeRaw ?? '').trim();
const tm = /^(\d{1,2}):(\d{2})/.exec(timeText);
if (tm) {
hh = Number(tm[1]);
mm = Number(tm[2]);
}
return new Date(gy, gm - 1, gd, hh, mm, 0, 0);
}
export async function workbookFromUpload(file: Express.Multer.File | undefined) {
if (!file?.buffer?.length) {
throw new BadRequestException('فایل اکسل انتخاب نشده است');
}
const wb = new ExcelJS.Workbook();
2026-10-03 11:32:37 +03:30
try {
// Pass Node Buffer directly — Uint8Array copies can break ExcelJS on some runtimes.
await wb.xlsx.load(file.buffer as unknown as ExcelJS.Buffer);
} catch (err) {
const detail = err instanceof Error ? err.message : '';
throw new BadRequestException(
detail.includes('central directory') || detail.includes('zip')
? 'فایل اکسل نامعتبر است. فقط فرمت .xlsx پشتیبانی می‌شود (نه .xls یا csv).'
: 'خواندن فایل اکسل ناموفق بود. فایل را با فرمت .xlsx ذخیره کنید و دوباره تلاش کنید.',
);
}
2026-09-15 17:33:05 +03:30
const sheet = wb.worksheets[0];
if (!sheet) throw new BadRequestException('شیت اکسل پیدا نشد');
return sheet;
}
2026-09-19 15:30:31 +03:30
export type ExcelFieldOption = {
key: string;
label: string;
required?: boolean;
};
export type ExcelColumnPreview = {
header: string;
index: number;
samples: string[];
};
export type ExcelPreviewResult = {
columns: ExcelColumnPreview[];
fields: ExcelFieldOption[];
suggested: Record<string, string>;
needsMapping: boolean;
rowCount: number;
largeFile?: boolean;
2026-09-19 15:30:31 +03:30
};
export function excelFieldsFromColumns(
columns: Array<{ header: string; key: string }>,
requiredKeys: string[] = [],
): ExcelFieldOption[] {
const seen = new Set<string>();
const fields: ExcelFieldOption[] = [];
for (const col of columns) {
if (!col.key || col.key === '_type' || seen.has(col.key)) continue;
seen.add(col.key);
fields.push({
key: col.key,
label: col.header,
required: requiredKeys.includes(col.key),
});
}
return fields;
}
export function parseExcelMapping(raw?: string): Record<string, string> | undefined {
if (raw == null || !String(raw).trim()) return undefined;
try {
const parsed = JSON.parse(String(raw)) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('invalid');
}
const mapping: Record<string, string> = {};
for (const [header, field] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof field === 'string' && field.trim()) mapping[header] = field.trim();
}
return mapping;
} catch {
throw new BadRequestException('نگاشت ستون‌ها نامعتبر است');
}
}
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()] ?? aliases[normalizeHeader(raw)] ?? '';
}
export type ExcelPositionalColumn = { key: string; label: string };
export type ExcelReadOptions = {
/** اگر ردیف اول هدر نباشد، از این ترتیب ستون استفاده شود */
positionalColumns?: ExcelPositionalColumn[];
/** تشخیص فایل بدون هدر (مثل خروجی دیدار) */
detectHeaderless?: (firstRowCells: string[]) => boolean;
};
function readFirstRowCells(sheet: ExcelJS.Worksheet): string[] {
const cells: string[] = [];
const row = sheet.getRow(1);
const max = Math.max(row.cellCount || 0, sheet.columnCount || 0, 22);
for (let col = 1; col <= max; col += 1) {
cells.push(cellStr(row.getCell(col).value));
}
while (cells.length && !cells[cells.length - 1]) cells.pop();
return cells;
}
2026-09-19 15:30:31 +03:30
export async function previewExcel(
file: Express.Multer.File,
aliases: Record<string, string>,
fields: ExcelFieldOption[],
options?: ExcelReadOptions,
2026-09-19 15:30:31 +03:30
): Promise<ExcelPreviewResult> {
const sheet = await workbookFromUpload(file);
const firstCells = readFirstRowCells(sheet);
const headerless =
Boolean(options?.positionalColumns?.length) &&
Boolean(options?.detectHeaderless?.(firstCells));
2026-09-19 15:30:31 +03:30
const columns: ExcelColumnPreview[] = [];
const suggested: Record<string, string> = {};
const used = new Set<string>();
if (headerless && options?.positionalColumns) {
const last = Math.min(sheet.rowCount, 5);
options.positionalColumns.forEach((colDef, idx) => {
const col = idx + 1;
const samples: string[] = [];
for (let r = 1; r <= last; r += 1) {
const value = cellStr(sheet.getRow(r).getCell(col).value);
if (value) samples.push(value);
if (samples.length >= 3) break;
}
columns.push({ header: colDef.label, index: col, samples });
suggested[colDef.label] = colDef.key;
used.add(colDef.key);
});
} else {
const headerRow = sheet.getRow(1);
headerRow.eachCell((cell, col) => {
const header = cellStr(cell.value);
if (!header) return;
const samples: string[] = [];
const last = Math.min(sheet.rowCount, 6);
for (let r = 2; r <= last; r += 1) {
const value = cellStr(sheet.getRow(r).getCell(col).value);
if (value) samples.push(value);
if (samples.length >= 3) break;
}
columns.push({ header, index: col, samples });
const key = resolveFieldKey(header, aliases);
if (key && !used.has(key)) {
suggested[header] = key;
used.add(key);
}
});
}
2026-09-19 15:30:31 +03:30
if (!columns.length) {
throw new BadRequestException('ستون عنوان در ردیف اول فایل پیدا نشد');
}
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]);
const rawRows = Math.max(0, sheet.actualRowCount || sheet.rowCount || 0);
const rowCount = headerless ? rawRows : Math.max(0, rawRows - 1);
2026-09-19 15:30:31 +03:30
return {
columns,
fields,
suggested,
needsMapping: headerless
? false
: unrecognized || missingRequired || !mapped.size,
rowCount,
largeFile: rowCount >= EXCEL_IMPORT_LARGE_THRESHOLD,
2026-09-19 15:30:31 +03:30
};
}
2026-09-15 17:33:05 +03:30
export async function readMappedRows(
file: Express.Multer.File,
aliases: Record<string, string>,
2026-09-19 15:30:31 +03:30
mapping?: Record<string, string>,
options?: ExcelReadOptions,
2026-09-15 17:33:05 +03:30
): Promise<Record<string, string>[]> {
const sheet = await workbookFromUpload(file);
const firstCells = readFirstRowCells(sheet);
const headerless =
!mapping &&
Boolean(options?.positionalColumns?.length) &&
Boolean(options?.detectHeaderless?.(firstCells));
2026-09-15 17:33:05 +03:30
const map = new Map<number, string>();
let dataStartRow = 2;
if (headerless && options?.positionalColumns) {
options.positionalColumns.forEach((colDef, idx) => {
map.set(idx + 1, colDef.key);
});
dataStartRow = 1;
} else {
const headerRow = sheet.getRow(1);
headerRow.eachCell((cell, col) => {
const raw = cellStr(cell.value);
if (!raw) return;
const key = resolveFieldKey(raw, aliases, mapping);
if (key) map.set(col, key);
});
}
2026-09-15 17:33:05 +03:30
if (!map.size) {
throw new BadRequestException(
2026-09-19 15:30:31 +03:30
'ستون‌های فایل شناسایی نشد. ستون‌های فایل را با فیلدهای سیستم تطبیق دهید.',
2026-09-15 17:33:05 +03:30
);
}
const rows: Record<string, string>[] = [];
sheet.eachRow((row, rowNumber) => {
if (rowNumber < dataStartRow) return;
2026-09-15 17:33:05 +03:30
const item: Record<string, string> = {};
let any = false;
for (const [col, key] of map) {
const value = cellStr(row.getCell(col).value);
if (value) any = true;
if (value || item[key] == null) item[key] = value;
}
if (any) rows.push(item);
});
if (!rows.length) {
throw new BadRequestException('هیچ ردیف داده‌ای در فایل نیست');
}
return rows;
}
export async function writeXlsx(
sheetName: string,
columns: Array<{ header: string; key: string; width?: number }>,
rows: Array<Record<string, string | number | null | undefined>>,
): Promise<Buffer> {
const wb = new ExcelJS.Workbook();
const sheet = wb.addWorksheet(sheetName);
sheet.columns = columns;
for (const row of rows) sheet.addRow(row);
const buf = await wb.xlsx.writeBuffer();
return Buffer.from(buf);
}