2026-09-15 17:33:05 +03:30
|
|
|
|
import { BadRequestException } from '@nestjs/common';
|
|
|
|
|
|
import ExcelJS from 'exceljs';
|
|
|
|
|
|
|
|
|
|
|
|
export function cellStr(value: ExcelJS.CellValue | undefined): string {
|
|
|
|
|
|
if (value == null) return '';
|
|
|
|
|
|
if (typeof value === 'string' || typeof value === 'number') {
|
|
|
|
|
|
return String(value).trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
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) {
|
|
|
|
|
|
return String(value.result).trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (value instanceof Date) {
|
|
|
|
|
|
return value.toISOString();
|
|
|
|
|
|
}
|
|
|
|
|
|
return String(value).trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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')}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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-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();
|
|
|
|
|
|
await wb.xlsx.load(Uint8Array.from(file.buffer) as unknown as ExcelJS.Buffer);
|
|
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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 async function previewExcel(
|
|
|
|
|
|
file: Express.Multer.File,
|
|
|
|
|
|
aliases: Record<string, string>,
|
|
|
|
|
|
fields: ExcelFieldOption[],
|
|
|
|
|
|
): Promise<ExcelPreviewResult> {
|
|
|
|
|
|
const sheet = await workbookFromUpload(file);
|
|
|
|
|
|
const headerRow = sheet.getRow(1);
|
|
|
|
|
|
const columns: ExcelColumnPreview[] = [];
|
|
|
|
|
|
const suggested: Record<string, string> = {};
|
|
|
|
|
|
const used = new Set<string>();
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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>,
|
2026-09-15 17:33:05 +03:30
|
|
|
|
): Promise<Record<string, string>[]> {
|
|
|
|
|
|
const sheet = await workbookFromUpload(file);
|
|
|
|
|
|
const headerRow = sheet.getRow(1);
|
|
|
|
|
|
const map = new Map<number, string>();
|
|
|
|
|
|
headerRow.eachCell((cell, col) => {
|
|
|
|
|
|
const raw = cellStr(cell.value);
|
|
|
|
|
|
if (!raw) return;
|
2026-09-19 15:30:31 +03:30
|
|
|
|
const key = resolveFieldKey(raw, aliases, mapping);
|
2026-09-15 17:33:05 +03:30
|
|
|
|
if (key) map.set(col, key);
|
|
|
|
|
|
});
|
|
|
|
|
|
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 === 1) 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);
|
|
|
|
|
|
}
|