Parse large Excel imports asynchronously to avoid gateway 502 timeouts.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,11 +17,13 @@ import {
|
|||||||
type ExcelPreviewResult,
|
type ExcelPreviewResult,
|
||||||
} from '../common/excel';
|
} from '../common/excel';
|
||||||
import {
|
import {
|
||||||
|
beginExcelImportPrepare,
|
||||||
chunkImportMeta,
|
chunkImportMeta,
|
||||||
createExcelImportSession,
|
|
||||||
destroyExcelImportSession,
|
destroyExcelImportSession,
|
||||||
|
getExcelImportPrepareStatus,
|
||||||
getExcelImportSessionCaches,
|
getExcelImportSessionCaches,
|
||||||
resolveExcelImportChunk,
|
resolveExcelImportChunk,
|
||||||
|
type ExcelImportSessionStatus,
|
||||||
} from '../common/excel-import-session';
|
} from '../common/excel-import-session';
|
||||||
|
|
||||||
const ACTIVITY_COLUMNS = [
|
const ACTIVITY_COLUMNS = [
|
||||||
@@ -161,49 +163,45 @@ export class ActivitiesExcelService {
|
|||||||
return previewExcel(file, ACTIVITY_ALIASES, excelFieldsFromColumns(ACTIVITY_COLUMNS, ['title']));
|
return previewExcel(file, ACTIVITY_ALIASES, excelFieldsFromColumns(ACTIVITY_COLUMNS, ['title']));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** فقط خواندن و جلسه؛ بدون درج — برای فایلهای خیلی بزرگ قبل از تکهتکه */
|
/**
|
||||||
async prepare(
|
* آپلود فوری + پارس در پسزمینه (جلوگیری از ۵۰۲ nginx روی فایلهای بزرگ).
|
||||||
|
* وضعیت را با prepareStatus پول کنید.
|
||||||
|
*/
|
||||||
|
prepare(
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
file: Express.Multer.File,
|
file: Express.Multer.File,
|
||||||
mappingRaw?: string,
|
mappingRaw?: string,
|
||||||
): Promise<ImportContactsResult> {
|
): ImportContactsResult {
|
||||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
if (!file?.buffer?.length) {
|
||||||
let rows: Record<string, string>[];
|
throw new BadRequestException('فایل ارسال نشده');
|
||||||
try {
|
|
||||||
rows = await readMappedRows(
|
|
||||||
file,
|
|
||||||
ACTIVITY_ALIASES,
|
|
||||||
parseExcelMapping(mappingRaw),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof BadRequestException) throw e;
|
|
||||||
result.errors.push({
|
|
||||||
row: 1,
|
|
||||||
message: e instanceof Error ? e.message : 'خواندن فایل اکسل ناموفق بود',
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
const mapping = parseExcelMapping(mappingRaw);
|
||||||
const MAX_ROWS = 100_000;
|
const sessionId = beginExcelImportPrepare(
|
||||||
if (rows.length > MAX_ROWS) {
|
actor.organizationId,
|
||||||
result.errors.push({
|
file.buffer,
|
||||||
row: 1,
|
(f) => readMappedRows(f, ACTIVITY_ALIASES, mapping),
|
||||||
message: `فایل بیش از ${MAX_ROWS} ردیف دارد. فایل را تقسیم کنید.`,
|
);
|
||||||
});
|
|
||||||
return { ...result, total: rows.length, processed: 0, hasMore: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = createExcelImportSession(actor.organizationId, rows);
|
|
||||||
return {
|
return {
|
||||||
...result,
|
created: 0,
|
||||||
|
skipped: 0,
|
||||||
|
errors: [],
|
||||||
sessionId,
|
sessionId,
|
||||||
total: rows.length,
|
status: 'parsing',
|
||||||
|
total: 0,
|
||||||
|
parsed: 0,
|
||||||
processed: 0,
|
processed: 0,
|
||||||
hasMore: rows.length > 0,
|
hasMore: true,
|
||||||
nextOffset: 0,
|
nextOffset: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prepareStatus(
|
||||||
|
actor: Actor,
|
||||||
|
sessionId: string,
|
||||||
|
): ExcelImportSessionStatus {
|
||||||
|
return getExcelImportPrepareStatus(sessionId, actor.organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
async import(
|
async import(
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
file: Express.Multer.File | undefined,
|
file: Express.Multer.File | undefined,
|
||||||
|
|||||||
@@ -113,6 +113,14 @@ export class ActivitiesController {
|
|||||||
return this.excelService.prepare(user, file, mapping);
|
return this.excelService.prepare(user, file, mapping);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('import/prepare/:sessionId')
|
||||||
|
importPrepareStatus(
|
||||||
|
@CurrentUser() user: Actor,
|
||||||
|
@Param('sessionId') sessionId: string,
|
||||||
|
) {
|
||||||
|
return this.excelService.prepareStatus(user, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('import')
|
@Post('import')
|
||||||
@UseInterceptors(excelUpload)
|
@UseInterceptors(excelUpload)
|
||||||
import(
|
import(
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { randomBytes } from 'node:crypto';
|
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';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
export const EXCEL_IMPORT_CHUNK_SIZE = 150;
|
export const EXCEL_IMPORT_CHUNK_SIZE = 150;
|
||||||
@@ -6,6 +9,7 @@ export const EXCEL_IMPORT_CHUNK_SIZE = 150;
|
|||||||
export const EXCEL_IMPORT_LARGE_THRESHOLD = 80;
|
export const EXCEL_IMPORT_LARGE_THRESHOLD = 80;
|
||||||
const SESSION_TTL_MS = 45 * 60 * 1000;
|
const SESSION_TTL_MS = 45 * 60 * 1000;
|
||||||
const MAX_SESSIONS = 40;
|
const MAX_SESSIONS = 40;
|
||||||
|
const MAX_ASYNC_ROWS = 100_000;
|
||||||
|
|
||||||
export type ExcelImportLookupCaches = {
|
export type ExcelImportLookupCaches = {
|
||||||
user: Map<string, string | null>;
|
user: Map<string, string | null>;
|
||||||
@@ -14,19 +18,46 @@ export type ExcelImportLookupCaches = {
|
|||||||
deal: 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 = {
|
type ExcelImportSession = {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
rows: Array<Record<string, string>>;
|
rows: Array<Record<string, string>>;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
caches: ExcelImportLookupCaches;
|
caches: ExcelImportLookupCaches;
|
||||||
|
status: 'parsing' | 'ready' | 'error';
|
||||||
|
error?: string;
|
||||||
|
total: number;
|
||||||
|
parsed: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sessions = new Map<string, ExcelImportSession>();
|
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() {
|
function purgeExpired() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [id, session] of sessions) {
|
for (const [id, session] of sessions) {
|
||||||
if (session.expiresAt <= now) sessions.delete(id);
|
if (session.expiresAt <= now) {
|
||||||
|
sessions.delete(id);
|
||||||
|
cleanupSessionFiles(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (sessions.size <= MAX_SESSIONS) return;
|
if (sessions.size <= MAX_SESSIONS) return;
|
||||||
const ordered = [...sessions.entries()].sort(
|
const ordered = [...sessions.entries()].sort(
|
||||||
@@ -35,9 +66,24 @@ function purgeExpired() {
|
|||||||
while (sessions.size > MAX_SESSIONS && ordered.length) {
|
while (sessions.size > MAX_SESSIONS && ordered.length) {
|
||||||
const [id] = ordered.shift()!;
|
const [id] = ordered.shift()!;
|
||||||
sessions.delete(id);
|
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(
|
export function createExcelImportSession(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
rows: Array<Record<string, string>>,
|
rows: Array<Record<string, string>>,
|
||||||
@@ -48,16 +94,115 @@ export function createExcelImportSession(
|
|||||||
organizationId,
|
organizationId,
|
||||||
rows,
|
rows,
|
||||||
expiresAt: Date.now() + SESSION_TTL_MS,
|
expiresAt: Date.now() + SESSION_TTL_MS,
|
||||||
caches: {
|
caches: emptyCaches(),
|
||||||
user: new Map(),
|
status: 'ready',
|
||||||
person: new Map(),
|
total: rows.length,
|
||||||
company: new Map(),
|
parsed: rows.length,
|
||||||
deal: new Map(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return id;
|
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(
|
export function getExcelImportSession(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
@@ -69,13 +214,24 @@ export function getExcelImportSession(
|
|||||||
'جلسه ورود اکسل منقضی یا نامعتبر است. فایل را دوباره انتخاب کنید.',
|
'جلسه ورود اکسل منقضی یا نامعتبر است. فایل را دوباره انتخاب کنید.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
session.expiresAt = Date.now() + SESSION_TTL_MS;
|
if (session.status === 'parsing') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'فایل هنوز در حال آمادهسازی است. چند لحظه صبر کنید.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (session.status === 'error') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
session.error || 'آمادهسازی فایل اکسل ناموفق بود.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
touch(session);
|
||||||
return session.rows;
|
return session.rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function destroyExcelImportSession(sessionId: string | undefined) {
|
export function destroyExcelImportSession(sessionId: string | undefined) {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
sessions.delete(sessionId);
|
sessions.delete(sessionId);
|
||||||
|
cleanupSessionFiles(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getExcelImportSessionCaches(
|
export function getExcelImportSessionCaches(
|
||||||
@@ -85,14 +241,9 @@ export function getExcelImportSessionCaches(
|
|||||||
purgeExpired();
|
purgeExpired();
|
||||||
const session = sessions.get(sessionId);
|
const session = sessions.get(sessionId);
|
||||||
if (!session || session.organizationId !== organizationId) {
|
if (!session || session.organizationId !== organizationId) {
|
||||||
return {
|
return emptyCaches();
|
||||||
user: new Map(),
|
|
||||||
person: new Map(),
|
|
||||||
company: new Map(),
|
|
||||||
deal: new Map(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
session.expiresAt = Date.now() + SESSION_TTL_MS;
|
touch(session);
|
||||||
return session.caches;
|
return session.caches;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
import { computed, reactive, ref } from 'vue';
|
import { computed, reactive, ref } from 'vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { ExcelPreviewResult, ImportContactsResult } from '@mento/shared';
|
import type {
|
||||||
|
ExcelImportPrepareStatus,
|
||||||
|
ExcelPreviewResult,
|
||||||
|
ImportContactsResult,
|
||||||
|
} from '@mento/shared';
|
||||||
import { UiBusyOverlay, UiButton, UiModal, UiSelect } from '@/components/ui';
|
import { UiBusyOverlay, UiButton, UiModal, UiSelect } from '@/components/ui';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -131,6 +135,10 @@ function updateImportProgress(processed: number, total: number) {
|
|||||||
progressLabel.value = `ورود فایل: ${processed.toLocaleString('fa-IR')} از ${total.toLocaleString('fa-IR')} ردیف`;
|
progressLabel.value = `ورود فایل: ${processed.toLocaleString('fa-IR')} از ${total.toLocaleString('fa-IR')} ردیف`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
async function postPrepare(file: File, map?: Record<string, string>) {
|
async function postPrepare(file: File, map?: Record<string, string>) {
|
||||||
if (!props.preparePath) {
|
if (!props.preparePath) {
|
||||||
throw new Error('preparePath تنظیم نشده');
|
throw new Error('preparePath تنظیم نشده');
|
||||||
@@ -140,16 +148,43 @@ async function postPrepare(file: File, map?: Record<string, string>) {
|
|||||||
if (map && Object.values(map).some(Boolean)) {
|
if (map && Object.values(map).some(Boolean)) {
|
||||||
form.append('mapping', JSON.stringify(map));
|
form.append('mapping', JSON.stringify(map));
|
||||||
}
|
}
|
||||||
|
// فقط آپلود — پارس روی سرور ناهمگام است؛ تایماوت کوتاه کافی است
|
||||||
const { data } = await api.post<ImportContactsResult>(props.preparePath, form, {
|
const { data } = await api.post<ImportContactsResult>(props.preparePath, form, {
|
||||||
headers: { 'Content-Type': undefined },
|
headers: { 'Content-Type': undefined },
|
||||||
timeout: 10 * 60 * 1000,
|
timeout: 3 * 60 * 1000,
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitPrepareReady(sessionId: string) {
|
||||||
|
if (!props.preparePath) throw new Error('preparePath تنظیم نشده');
|
||||||
|
const statusUrl = `${props.preparePath}/${sessionId}`;
|
||||||
|
const started = Date.now();
|
||||||
|
const maxMs = 15 * 60 * 1000;
|
||||||
|
while (Date.now() - started < maxMs) {
|
||||||
|
const { data } = await api.get<ExcelImportPrepareStatus>(statusUrl, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
if (data.status === 'ready') {
|
||||||
|
progressLabel.value = `فایل آماده شد — ${data.total.toLocaleString('fa-IR')} ردیف`;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
if (data.status === 'error') {
|
||||||
|
throw new Error(data.error || 'آمادهسازی فایل اکسل ناموفق بود');
|
||||||
|
}
|
||||||
|
const parsed = data.parsed || 0;
|
||||||
|
progressPct.value = null;
|
||||||
|
progressLabel.value = parsed
|
||||||
|
? `در حال خواندن فایل روی سرور… ${parsed.toLocaleString('fa-IR')} ردیف`
|
||||||
|
: 'در حال خواندن فایل روی سرور… لطفاً صبر کنید';
|
||||||
|
await sleep(1200);
|
||||||
|
}
|
||||||
|
throw new Error('آمادهسازی فایل بیش از حد طول کشید. دوباره تلاش کنید.');
|
||||||
|
}
|
||||||
|
|
||||||
async function runChunkedImport(file: File, map?: Record<string, string>) {
|
async function runChunkedImport(file: File, map?: Record<string, string>) {
|
||||||
phase.value = 'import';
|
phase.value = 'import';
|
||||||
progressLabel.value = 'در حال آمادهسازی و خواندن فایل…';
|
progressLabel.value = 'در حال ارسال فایل…';
|
||||||
progressPct.value = 0;
|
progressPct.value = 0;
|
||||||
|
|
||||||
let acc: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
let acc: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||||
@@ -160,19 +195,24 @@ async function runChunkedImport(file: File, map?: Record<string, string>) {
|
|||||||
|
|
||||||
if (props.preparePath) {
|
if (props.preparePath) {
|
||||||
const prep = await postPrepare(file, map);
|
const prep = await postPrepare(file, map);
|
||||||
acc = mergeImportResults(acc, prep);
|
|
||||||
if (
|
|
||||||
prep.errors?.some((e) => e.row === 1 && /بیش از .* ردیف|خواندن فایل/.test(e.message))
|
|
||||||
) {
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
if (!prep.sessionId) {
|
if (!prep.sessionId) {
|
||||||
if (!prep.total) return acc;
|
|
||||||
throw new Error(prep.errors[0]?.message || 'آمادهسازی فایل ناموفق بود');
|
throw new Error(prep.errors[0]?.message || 'آمادهسازی فایل ناموفق بود');
|
||||||
}
|
}
|
||||||
sessionId = prep.sessionId;
|
sessionId = prep.sessionId;
|
||||||
updateImportProgress(0, prep.total ?? 0);
|
progressLabel.value = 'در حال خواندن فایل روی سرور…';
|
||||||
if (!prep.hasMore && (prep.total ?? 0) === 0) return acc;
|
const ready = await waitPrepareReady(sessionId);
|
||||||
|
if (!ready.total) {
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
total: 0,
|
||||||
|
processed: 0,
|
||||||
|
hasMore: false,
|
||||||
|
errors: ready.error
|
||||||
|
? [{ row: 1, message: ready.error }]
|
||||||
|
: [{ row: 1, message: 'هیچ ردیف دادهای در فایل نیست' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
updateImportProgress(0, ready.total);
|
||||||
}
|
}
|
||||||
|
|
||||||
while (guard < maxChunks) {
|
while (guard < maxChunks) {
|
||||||
|
|||||||
@@ -1260,6 +1260,19 @@ export interface ImportContactsResult {
|
|||||||
/** مرحله بعدی وجود دارد */
|
/** مرحله بعدی وجود دارد */
|
||||||
hasMore?: boolean;
|
hasMore?: boolean;
|
||||||
nextOffset?: number;
|
nextOffset?: number;
|
||||||
|
/** وضعیت آمادهسازی ناهمگام فایل بزرگ */
|
||||||
|
status?: "parsing" | "ready" | "error";
|
||||||
|
parsed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExcelImportPrepareStatus {
|
||||||
|
sessionId: string;
|
||||||
|
status: "parsing" | "ready" | "error";
|
||||||
|
total: number;
|
||||||
|
parsed: number;
|
||||||
|
error?: string;
|
||||||
|
hasMore?: boolean;
|
||||||
|
nextOffset?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExcelFieldOption {
|
export interface ExcelFieldOption {
|
||||||
|
|||||||
Reference in New Issue
Block a user