342 lines
9.2 KiB
TypeScript
342 lines
9.2 KiB
TypeScript
import { randomBytes } from 'node:crypto';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { BadRequestException } from '@nestjs/common';
|
|
|
|
export const EXCEL_IMPORT_CHUNK_SIZE = 150;
|
|
/** بعد از این تعداد ردیف، کلاینت حتماً چندمرحلهای میرود (پیشنمایش هم نشان میدهد) */
|
|
export const EXCEL_IMPORT_LARGE_THRESHOLD = 80;
|
|
const SESSION_TTL_MS = 45 * 60 * 1000;
|
|
const MAX_SESSIONS = 40;
|
|
const MAX_ASYNC_ROWS = 100_000;
|
|
|
|
export type ExcelImportLookupCaches = {
|
|
user: Map<string, string | null>;
|
|
person: Map<string, string | null>;
|
|
company: Map<string, string | null>;
|
|
deal: Map<string, string | null>;
|
|
};
|
|
|
|
export type ExcelImportSessionStatus = {
|
|
sessionId: string;
|
|
status: 'parsing' | 'ready' | 'error';
|
|
total: number;
|
|
parsed: number;
|
|
error?: string;
|
|
hasMore?: boolean;
|
|
nextOffset?: number;
|
|
};
|
|
|
|
type ExcelImportSession = {
|
|
organizationId: string;
|
|
rows: Array<Record<string, string>>;
|
|
expiresAt: number;
|
|
caches: ExcelImportLookupCaches;
|
|
status: 'parsing' | 'ready' | 'error';
|
|
error?: string;
|
|
total: number;
|
|
parsed: number;
|
|
};
|
|
|
|
const sessions = new Map<string, ExcelImportSession>();
|
|
const TMP_DIR = path.join(os.tmpdir(), 'mento-excel-import');
|
|
|
|
function emptyCaches(): ExcelImportLookupCaches {
|
|
return {
|
|
user: new Map(),
|
|
person: new Map(),
|
|
company: new Map(),
|
|
deal: new Map(),
|
|
};
|
|
}
|
|
|
|
function purgeExpired() {
|
|
const now = Date.now();
|
|
for (const [id, session] of sessions) {
|
|
if (session.expiresAt <= now) {
|
|
sessions.delete(id);
|
|
cleanupSessionFiles(id);
|
|
}
|
|
}
|
|
if (sessions.size <= MAX_SESSIONS) return;
|
|
const ordered = [...sessions.entries()].sort(
|
|
(a, b) => a[1].expiresAt - b[1].expiresAt,
|
|
);
|
|
while (sessions.size > MAX_SESSIONS && ordered.length) {
|
|
const [id] = ordered.shift()!;
|
|
sessions.delete(id);
|
|
cleanupSessionFiles(id);
|
|
}
|
|
}
|
|
|
|
function cleanupSessionFiles(sessionId: string) {
|
|
for (const ext of ['.xlsx', '.jsonl']) {
|
|
try {
|
|
fs.unlinkSync(path.join(TMP_DIR, `${sessionId}${ext}`));
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
|
|
function touch(session: ExcelImportSession) {
|
|
session.expiresAt = Date.now() + SESSION_TTL_MS;
|
|
}
|
|
|
|
export function createExcelImportSession(
|
|
organizationId: string,
|
|
rows: Array<Record<string, string>>,
|
|
): string {
|
|
purgeExpired();
|
|
const id = randomBytes(16).toString('hex');
|
|
sessions.set(id, {
|
|
organizationId,
|
|
rows,
|
|
expiresAt: Date.now() + SESSION_TTL_MS,
|
|
caches: emptyCaches(),
|
|
status: 'ready',
|
|
total: rows.length,
|
|
parsed: rows.length,
|
|
});
|
|
return id;
|
|
}
|
|
|
|
/** فقط کش lookup برای ایمپورت JSON تکهای از مرورگر (بدون نگه داشتن کل فایل) */
|
|
export function createExcelImportCacheSession(organizationId: string): string {
|
|
purgeExpired();
|
|
const id = randomBytes(16).toString('hex');
|
|
sessions.set(id, {
|
|
organizationId,
|
|
rows: [],
|
|
expiresAt: Date.now() + SESSION_TTL_MS,
|
|
caches: emptyCaches(),
|
|
status: 'ready',
|
|
total: 0,
|
|
parsed: 0,
|
|
});
|
|
return id;
|
|
}
|
|
|
|
/**
|
|
* آپلود را فوری قبول میکند و پارس را در پسزمینه انجام میدهد
|
|
* تا پروکسی nginx با ۵۰۲ قطع نکند.
|
|
*/
|
|
export function beginExcelImportPrepare(
|
|
organizationId: string,
|
|
buffer: Buffer,
|
|
loadRows: (file: Express.Multer.File) => Promise<Array<Record<string, string>>>,
|
|
): string {
|
|
purgeExpired();
|
|
fs.mkdirSync(TMP_DIR, { recursive: true });
|
|
const id = randomBytes(16).toString('hex');
|
|
const xlsxPath = path.join(TMP_DIR, `${id}.xlsx`);
|
|
fs.writeFileSync(xlsxPath, buffer);
|
|
|
|
sessions.set(id, {
|
|
organizationId,
|
|
rows: [],
|
|
expiresAt: Date.now() + SESSION_TTL_MS,
|
|
caches: emptyCaches(),
|
|
status: 'parsing',
|
|
total: 0,
|
|
parsed: 0,
|
|
});
|
|
|
|
const fakeFile = {
|
|
fieldname: 'file',
|
|
originalname: 'import.xlsx',
|
|
encoding: '7bit',
|
|
mimetype:
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
size: buffer.length,
|
|
buffer,
|
|
destination: '',
|
|
filename: '',
|
|
path: xlsxPath,
|
|
} as Express.Multer.File;
|
|
|
|
void (async () => {
|
|
const session = sessions.get(id);
|
|
if (!session) return;
|
|
try {
|
|
const rows = await loadRows(fakeFile);
|
|
const current = sessions.get(id);
|
|
if (!current) return;
|
|
if (rows.length > MAX_ASYNC_ROWS) {
|
|
current.status = 'error';
|
|
current.error = `فایل بیش از ${MAX_ASYNC_ROWS} ردیف دارد. فایل را تقسیم کنید.`;
|
|
current.total = rows.length;
|
|
current.parsed = rows.length;
|
|
touch(current);
|
|
cleanupSessionFiles(id);
|
|
return;
|
|
}
|
|
current.rows = rows;
|
|
current.total = rows.length;
|
|
current.parsed = rows.length;
|
|
current.status = 'ready';
|
|
touch(current);
|
|
try {
|
|
fs.unlinkSync(xlsxPath);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
} catch (e) {
|
|
const current = sessions.get(id);
|
|
if (!current) return;
|
|
current.status = 'error';
|
|
current.error =
|
|
e instanceof Error ? e.message : 'خواندن فایل اکسل ناموفق بود';
|
|
touch(current);
|
|
cleanupSessionFiles(id);
|
|
}
|
|
})();
|
|
|
|
return id;
|
|
}
|
|
|
|
export function getExcelImportPrepareStatus(
|
|
sessionId: string,
|
|
organizationId: string,
|
|
): ExcelImportSessionStatus {
|
|
purgeExpired();
|
|
const session = sessions.get(sessionId);
|
|
if (!session || session.organizationId !== organizationId) {
|
|
throw new BadRequestException(
|
|
'جلسه ورود اکسل منقضی یا نامعتبر است. فایل را دوباره انتخاب کنید.',
|
|
);
|
|
}
|
|
touch(session);
|
|
return {
|
|
sessionId,
|
|
status: session.status,
|
|
total: session.total,
|
|
parsed: session.parsed,
|
|
error: session.error,
|
|
hasMore: session.status === 'ready' ? session.total > 0 : true,
|
|
nextOffset: 0,
|
|
};
|
|
}
|
|
|
|
export function getExcelImportSession(
|
|
sessionId: string,
|
|
organizationId: string,
|
|
): Array<Record<string, string>> {
|
|
purgeExpired();
|
|
const session = sessions.get(sessionId);
|
|
if (!session || session.organizationId !== organizationId) {
|
|
throw new BadRequestException(
|
|
'جلسه ورود اکسل منقضی یا نامعتبر است. فایل را دوباره انتخاب کنید.',
|
|
);
|
|
}
|
|
if (session.status === 'parsing') {
|
|
throw new BadRequestException(
|
|
'فایل هنوز در حال آمادهسازی است. چند لحظه صبر کنید.',
|
|
);
|
|
}
|
|
if (session.status === 'error') {
|
|
throw new BadRequestException(
|
|
session.error || 'آمادهسازی فایل اکسل ناموفق بود.',
|
|
);
|
|
}
|
|
touch(session);
|
|
return session.rows;
|
|
}
|
|
|
|
export function destroyExcelImportSession(sessionId: string | undefined) {
|
|
if (!sessionId) return;
|
|
sessions.delete(sessionId);
|
|
cleanupSessionFiles(sessionId);
|
|
}
|
|
|
|
export function getExcelImportSessionCaches(
|
|
sessionId: string,
|
|
organizationId: string,
|
|
): ExcelImportLookupCaches {
|
|
purgeExpired();
|
|
const session = sessions.get(sessionId);
|
|
if (!session || session.organizationId !== organizationId) {
|
|
return emptyCaches();
|
|
}
|
|
touch(session);
|
|
return session.caches;
|
|
}
|
|
|
|
export function parseChunkParams(raw: {
|
|
offset?: string | number;
|
|
limit?: string | number;
|
|
}): { offset: number; limit: number } {
|
|
const offset = Math.max(0, Number(raw.offset ?? 0) || 0);
|
|
const limit = Math.min(
|
|
250,
|
|
Math.max(1, Number(raw.limit ?? EXCEL_IMPORT_CHUNK_SIZE) || EXCEL_IMPORT_CHUNK_SIZE),
|
|
);
|
|
return { offset, limit };
|
|
}
|
|
|
|
export type ExcelImportChunkContext = {
|
|
sessionId: string;
|
|
allRows: Array<Record<string, string>>;
|
|
slice: Array<Record<string, string>>;
|
|
offset: number;
|
|
nextOffset: number;
|
|
hasMore: boolean;
|
|
total: number;
|
|
};
|
|
|
|
/**
|
|
* فایل را یکبار میخواند و در حافظه نگه میدارد؛ درخواستهای بعدی فقط با sessionId + offset.
|
|
*/
|
|
export async function resolveExcelImportChunk(opts: {
|
|
organizationId: string;
|
|
file?: Express.Multer.File | null;
|
|
sessionId?: string;
|
|
offset?: string | number;
|
|
limit?: string | number;
|
|
loadRows: () => Promise<Array<Record<string, string>>>;
|
|
}): Promise<ExcelImportChunkContext> {
|
|
const { offset, limit } = parseChunkParams({
|
|
offset: opts.offset,
|
|
limit: opts.limit,
|
|
});
|
|
|
|
let sessionId = (opts.sessionId ?? '').trim();
|
|
let allRows: Array<Record<string, string>>;
|
|
|
|
if (sessionId) {
|
|
allRows = getExcelImportSession(sessionId, opts.organizationId);
|
|
} else {
|
|
if (!opts.file?.buffer?.length) {
|
|
throw new BadRequestException('فایل ارسال نشده');
|
|
}
|
|
allRows = await opts.loadRows();
|
|
sessionId = createExcelImportSession(opts.organizationId, allRows);
|
|
}
|
|
|
|
const slice = allRows.slice(offset, offset + limit);
|
|
const nextOffset = offset + slice.length;
|
|
const hasMore = nextOffset < allRows.length;
|
|
if (!hasMore) destroyExcelImportSession(sessionId);
|
|
|
|
return {
|
|
sessionId,
|
|
allRows,
|
|
slice,
|
|
offset,
|
|
nextOffset,
|
|
hasMore,
|
|
total: allRows.length,
|
|
};
|
|
}
|
|
|
|
export function chunkImportMeta(ctx: ExcelImportChunkContext) {
|
|
return {
|
|
sessionId: ctx.hasMore ? ctx.sessionId : undefined,
|
|
total: ctx.total,
|
|
processed: ctx.nextOffset,
|
|
hasMore: ctx.hasMore,
|
|
nextOffset: ctx.hasMore ? ctx.nextOffset : undefined,
|
|
};
|
|
}
|