diff --git a/apps/api/src/activities/activities-excel.service.ts b/apps/api/src/activities/activities-excel.service.ts index 71c78d1..ee1a475 100644 --- a/apps/api/src/activities/activities-excel.service.ts +++ b/apps/api/src/activities/activities-excel.service.ts @@ -18,6 +18,7 @@ import { } from '../common/excel'; import { chunkImportMeta, + createExcelImportSession, destroyExcelImportSession, getExcelImportSessionCaches, resolveExcelImportChunk, @@ -160,6 +161,49 @@ export class ActivitiesExcelService { return previewExcel(file, ACTIVITY_ALIASES, excelFieldsFromColumns(ACTIVITY_COLUMNS, ['title'])); } + /** فقط خواندن و جلسه؛ بدون درج — برای فایل‌های خیلی بزرگ قبل از تکه‌تکه */ + async prepare( + actor: Actor, + file: Express.Multer.File, + mappingRaw?: string, + ): Promise { + const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] }; + let rows: Record[]; + 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 MAX_ROWS = 100_000; + if (rows.length > MAX_ROWS) { + result.errors.push({ + row: 1, + message: `فایل بیش از ${MAX_ROWS} ردیف دارد. فایل را تقسیم کنید.`, + }); + return { ...result, total: rows.length, processed: 0, hasMore: false }; + } + + const sessionId = createExcelImportSession(actor.organizationId, rows); + return { + ...result, + sessionId, + total: rows.length, + processed: 0, + hasMore: rows.length > 0, + nextOffset: 0, + }; + } + async import( actor: Actor, file: Express.Multer.File | undefined, diff --git a/apps/api/src/activities/activities.controller.ts b/apps/api/src/activities/activities.controller.ts index 7b507ae..8a39e4b 100644 --- a/apps/api/src/activities/activities.controller.ts +++ b/apps/api/src/activities/activities.controller.ts @@ -100,6 +100,19 @@ export class ActivitiesController { return this.excelService.preview(file); } + @Post('import/prepare') + @UseInterceptors(excelUpload) + importPrepare( + @CurrentUser() user: Actor, + @UploadedFile() file?: Express.Multer.File, + @Body('mapping') mapping?: string, + ) { + if (!file?.buffer?.length) { + throw new BadRequestException('فایل ارسال نشده'); + } + return this.excelService.prepare(user, file, mapping); + } + @Post('import') @UseInterceptors(excelUpload) import( diff --git a/apps/web/src/components/ExcelImportFlow.vue b/apps/web/src/components/ExcelImportFlow.vue index 21d9b22..b0e4d74 100644 --- a/apps/web/src/components/ExcelImportFlow.vue +++ b/apps/web/src/components/ExcelImportFlow.vue @@ -8,6 +8,8 @@ import { UiBusyOverlay, UiButton, UiModal, UiSelect } from '@/components/ui'; const props = defineProps<{ previewPath: string; importPath: string; + /** اگر باشد: ابتدا فقط فایل خوانده می‌شود، بعد تکه‌تکه وارد می‌شود (برای فایل‌های خیلی بزرگ) */ + preparePath?: string; disabled?: boolean; }>(); @@ -129,6 +131,22 @@ function updateImportProgress(processed: number, total: number) { progressLabel.value = `ورود فایل: ${processed.toLocaleString('fa-IR')} از ${total.toLocaleString('fa-IR')} ردیف`; } +async function postPrepare(file: File, map?: Record) { + if (!props.preparePath) { + throw new Error('preparePath تنظیم نشده'); + } + const form = new FormData(); + form.append('file', file); + if (map && Object.values(map).some(Boolean)) { + form.append('mapping', JSON.stringify(map)); + } + const { data } = await api.post(props.preparePath, form, { + headers: { 'Content-Type': undefined }, + timeout: 10 * 60 * 1000, + }); + return data; +} + async function runChunkedImport(file: File, map?: Record) { phase.value = 'import'; progressLabel.value = 'در حال آماده‌سازی و خواندن فایل…'; @@ -140,6 +158,23 @@ async function runChunkedImport(file: File, map?: Record) { let guard = 0; const maxChunks = 2000; + if (props.preparePath) { + 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.total) return acc; + throw new Error(prep.errors[0]?.message || 'آماده‌سازی فایل ناموفق بود'); + } + sessionId = prep.sessionId; + updateImportProgress(0, prep.total ?? 0); + if (!prep.hasMore && (prep.total ?? 0) === 0) return acc; + } + while (guard < maxChunks) { guard += 1; const part = await postImportChunk({ @@ -183,11 +218,30 @@ async function runChunkedImport(file: File, map?: Record) { } function apiMessage(e: unknown, fallback: string) { - if (axios.isAxiosError(e)) { - const msg = (e.response?.data as { message?: string | string[] })?.message; - return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback); + if (!axios.isAxiosError(e)) { + return e instanceof Error && e.message ? e.message : fallback; } - return fallback; + if (e.code === 'ECONNABORTED' || /timeout/i.test(e.message)) { + return 'مهلت درخواست تمام شد — فایل خیلی بزرگ است یا سرور دیر جواب داد. دوباره تلاش کنید.'; + } + if (!e.response) { + return 'ارتباط با سرور برقرار نشد. اتصال را بررسی کنید و دوباره تلاش کنید.'; + } + const status = e.response.status; + const data = e.response.data as { message?: string | string[] } | string | undefined; + let msg: string | undefined; + if (typeof data === 'string' && data.trim() && !data.trim().startsWith('<')) { + msg = data.trim().slice(0, 280); + } else if (data && typeof data === 'object') { + const m = data.message; + msg = Array.isArray(m) ? m.join('، ') : m; + } + if (msg) return msg; + if (status === 413) return 'حجم فایل بیش از حد مجاز سرور است.'; + if (status === 502 || status === 504) { + return 'سرور میانی قطع شد (معمولاً به‌خاطر حجم/زمان فایل). چند دقیقه بعد دوباره تلاش کنید.'; + } + return `${fallback} (کد ${status})`; } async function onFile(ev: Event) { diff --git a/apps/web/src/views/ActivitiesView.vue b/apps/web/src/views/ActivitiesView.vue index 0f3ba43..11737b6 100644 --- a/apps/web/src/views/ActivitiesView.vue +++ b/apps/web/src/views/ActivitiesView.vue @@ -499,6 +499,7 @@ onUnmounted(() => {