Split large activity Excel prepare from chunk import and clarify errors.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-03 17:58:41 +03:30
co-authored by Cursor
parent 07bdc4a3b6
commit d3fb92a5e2
4 changed files with 116 additions and 4 deletions
@@ -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<ImportContactsResult> {
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
let rows: Record<string, string>[];
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,
@@ -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(
+58 -4
View File
@@ -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<string, string>) {
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<ImportContactsResult>(props.preparePath, form, {
headers: { 'Content-Type': undefined },
timeout: 10 * 60 * 1000,
});
return data;
}
async function runChunkedImport(file: File, map?: Record<string, string>) {
phase.value = 'import';
progressLabel.value = 'در حال آماده‌سازی و خواندن فایل…';
@@ -140,6 +158,23 @@ async function runChunkedImport(file: File, map?: Record<string, string>) {
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<string, string>) {
}
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) {
+1
View File
@@ -499,6 +499,7 @@ onUnmounted(() => {
</UiButton>
<ExcelImportFlow
preview-path="/activities/import/preview"
prepare-path="/activities/import/prepare"
import-path="/activities/import"
:disabled="busy"
@start="onImportStart"