Files
CRM_Montzar/apps/api/src/common/excel.ts
T
MahdiandCursor cf326d1ed9 fix: keep birthdays and show Shamsi dates
Imported customers keep their birthday, and assistant text plus remaining calendars use the Jalali date.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 18:04:53 +03:30

468 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { BadRequestException } from '@nestjs/common';
import ExcelJS from 'exceljs';
import { EXCEL_IMPORT_LARGE_THRESHOLD } from './excel-import-session';
export function cellStr(value: ExcelJS.CellValue | undefined): string {
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 '';
}
}
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')}`;
}
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;
}
/** تاریخ تولد: سال زیر ۱۷۰۰ شمسی است، سال ۱۷۰۰ به بعد میلادی. ظهر 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));
}
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();
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 ذخیره کنید و دوباره تلاش کنید.',
);
}
const sheet = wb.worksheets[0];
if (!sheet) throw new BadRequestException('شیت اکسل پیدا نشد');
return sheet;
}
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;
};
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;
}
export async function previewExcel(
file: Express.Multer.File,
aliases: Record<string, string>,
fields: ExcelFieldOption[],
options?: ExcelReadOptions,
): Promise<ExcelPreviewResult> {
const sheet = await workbookFromUpload(file);
const firstCells = readFirstRowCells(sheet);
const headerless =
Boolean(options?.positionalColumns?.length) &&
Boolean(options?.detectHeaderless?.(firstCells));
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);
}
});
}
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);
return {
columns,
fields,
suggested,
needsMapping: headerless
? false
: unrecognized || missingRequired || !mapped.size,
rowCount,
largeFile: rowCount >= EXCEL_IMPORT_LARGE_THRESHOLD,
};
}
export async function readMappedRows(
file: Express.Multer.File,
aliases: Record<string, string>,
mapping?: Record<string, string>,
options?: ExcelReadOptions,
): Promise<Record<string, string>[]> {
const sheet = await workbookFromUpload(file);
const firstCells = readFirstRowCells(sheet);
const headerless =
!mapping &&
Boolean(options?.positionalColumns?.length) &&
Boolean(options?.detectHeaderless?.(firstCells));
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);
});
}
if (!map.size) {
throw new BadRequestException(
'ستون‌های فایل شناسایی نشد. ستون‌های فایل را با فیلدهای سیستم تطبیق دهید.',
);
}
const rows: Record<string, string>[] = [];
sheet.eachRow((row, rowNumber) => {
if (rowNumber < dataStartRow) return;
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);
}