import { BadRequestException } from '@nestjs/common'; import ExcelJS from 'exceljs'; 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(//gi, '\n') .replace(/ /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; } 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; needsMapping: boolean; }; export function excelFieldsFromColumns( columns: Array<{ header: string; key: string }>, requiredKeys: string[] = [], ): ExcelFieldOption[] { const seen = new Set(); 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 | 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 = {}; for (const [header, field] of Object.entries(parsed as Record)) { if (typeof field === 'string' && field.trim()) mapping[header] = field.trim(); } return mapping; } catch { throw new BadRequestException('نگاشت ستون‌ها نامعتبر است'); } } function resolveFieldKey( raw: string, aliases: Record, mapping?: Record, ) { if (mapping) { return mapping[raw] || mapping[normalizeHeader(raw)] || ''; } const compact = normalizeHeader(raw); return aliases[compact] ?? aliases[raw.trim()] ?? aliases[normalizeHeader(raw)] ?? ''; } export async function previewExcel( file: Express.Multer.File, aliases: Record, fields: ExcelFieldOption[], ): Promise { const sheet = await workbookFromUpload(file); const headerRow = sheet.getRow(1); const columns: ExcelColumnPreview[] = []; const suggested: Record = {}; const used = new Set(); 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]); return { columns, fields, suggested, needsMapping: unrecognized || missingRequired || !mapped.size, }; } export async function readMappedRows( file: Express.Multer.File, aliases: Record, mapping?: Record, ): Promise[]> { const sheet = await workbookFromUpload(file); const headerRow = sheet.getRow(1); const map = new Map(); 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[] = []; sheet.eachRow((row, rowNumber) => { if (rowNumber === 1) return; const item: Record = {}; 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>, ): Promise { 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); }