Imported customers keep their birthday, and assistant text plus remaining calendars use the Jalali date. Co-authored-by: Cursor <cursoragent@cursor.com>
468 lines
14 KiB
TypeScript
468 lines
14 KiB
TypeScript
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(/ /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);
|
||
}
|