2026-10-03 15:01:01 +03:30
|
|
|
import { randomBytes } from 'node:crypto';
|
|
|
|
|
import { BadRequestException } from '@nestjs/common';
|
|
|
|
|
|
2026-10-03 17:42:11 +03:30
|
|
|
export const EXCEL_IMPORT_CHUNK_SIZE = 150;
|
2026-10-03 15:01:01 +03:30
|
|
|
/** بعد از این تعداد ردیف، کلاینت حتماً چندمرحلهای میرود (پیشنمایش هم نشان میدهد) */
|
|
|
|
|
export const EXCEL_IMPORT_LARGE_THRESHOLD = 80;
|
|
|
|
|
const SESSION_TTL_MS = 45 * 60 * 1000;
|
|
|
|
|
const MAX_SESSIONS = 40;
|
|
|
|
|
|
2026-10-03 17:42:11 +03:30
|
|
|
export type ExcelImportLookupCaches = {
|
|
|
|
|
user: Map<string, string | null>;
|
|
|
|
|
person: Map<string, string | null>;
|
|
|
|
|
company: Map<string, string | null>;
|
|
|
|
|
deal: Map<string, string | null>;
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-03 15:01:01 +03:30
|
|
|
type ExcelImportSession = {
|
|
|
|
|
organizationId: string;
|
|
|
|
|
rows: Array<Record<string, string>>;
|
|
|
|
|
expiresAt: number;
|
2026-10-03 17:42:11 +03:30
|
|
|
caches: ExcelImportLookupCaches;
|
2026-10-03 15:01:01 +03:30
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const sessions = new Map<string, ExcelImportSession>();
|
|
|
|
|
|
|
|
|
|
function purgeExpired() {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
for (const [id, session] of sessions) {
|
|
|
|
|
if (session.expiresAt <= now) sessions.delete(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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
2026-10-03 17:42:11 +03:30
|
|
|
caches: {
|
|
|
|
|
user: new Map(),
|
|
|
|
|
person: new Map(),
|
|
|
|
|
company: new Map(),
|
|
|
|
|
deal: new Map(),
|
|
|
|
|
},
|
2026-10-03 15:01:01 +03:30
|
|
|
});
|
|
|
|
|
return id;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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(
|
|
|
|
|
'جلسه ورود اکسل منقضی یا نامعتبر است. فایل را دوباره انتخاب کنید.',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
session.expiresAt = Date.now() + SESSION_TTL_MS;
|
|
|
|
|
return session.rows;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function destroyExcelImportSession(sessionId: string | undefined) {
|
|
|
|
|
if (!sessionId) return;
|
|
|
|
|
sessions.delete(sessionId);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:42:11 +03:30
|
|
|
export function getExcelImportSessionCaches(
|
|
|
|
|
sessionId: string,
|
|
|
|
|
organizationId: string,
|
|
|
|
|
): ExcelImportLookupCaches {
|
|
|
|
|
purgeExpired();
|
|
|
|
|
const session = sessions.get(sessionId);
|
|
|
|
|
if (!session || session.organizationId !== organizationId) {
|
|
|
|
|
return {
|
|
|
|
|
user: new Map(),
|
|
|
|
|
person: new Map(),
|
|
|
|
|
company: new Map(),
|
|
|
|
|
deal: new Map(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
session.expiresAt = Date.now() + SESSION_TTL_MS;
|
|
|
|
|
return session.caches;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 15:01:01 +03:30
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|