Split large activity Excel prepare from chunk import and clarify errors.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '../common/excel';
|
} from '../common/excel';
|
||||||
import {
|
import {
|
||||||
chunkImportMeta,
|
chunkImportMeta,
|
||||||
|
createExcelImportSession,
|
||||||
destroyExcelImportSession,
|
destroyExcelImportSession,
|
||||||
getExcelImportSessionCaches,
|
getExcelImportSessionCaches,
|
||||||
resolveExcelImportChunk,
|
resolveExcelImportChunk,
|
||||||
@@ -160,6 +161,49 @@ export class ActivitiesExcelService {
|
|||||||
return previewExcel(file, ACTIVITY_ALIASES, excelFieldsFromColumns(ACTIVITY_COLUMNS, ['title']));
|
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(
|
async import(
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
file: Express.Multer.File | undefined,
|
file: Express.Multer.File | undefined,
|
||||||
|
|||||||
@@ -100,6 +100,19 @@ export class ActivitiesController {
|
|||||||
return this.excelService.preview(file);
|
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')
|
@Post('import')
|
||||||
@UseInterceptors(excelUpload)
|
@UseInterceptors(excelUpload)
|
||||||
import(
|
import(
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { UiBusyOverlay, UiButton, UiModal, UiSelect } from '@/components/ui';
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
previewPath: string;
|
previewPath: string;
|
||||||
importPath: string;
|
importPath: string;
|
||||||
|
/** اگر باشد: ابتدا فقط فایل خوانده میشود، بعد تکهتکه وارد میشود (برای فایلهای خیلی بزرگ) */
|
||||||
|
preparePath?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -129,6 +131,22 @@ 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')} ردیف`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>) {
|
async function runChunkedImport(file: File, map?: Record<string, string>) {
|
||||||
phase.value = 'import';
|
phase.value = 'import';
|
||||||
progressLabel.value = 'در حال آمادهسازی و خواندن فایل…';
|
progressLabel.value = 'در حال آمادهسازی و خواندن فایل…';
|
||||||
@@ -140,6 +158,23 @@ async function runChunkedImport(file: File, map?: Record<string, string>) {
|
|||||||
let guard = 0;
|
let guard = 0;
|
||||||
const maxChunks = 2000;
|
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) {
|
while (guard < maxChunks) {
|
||||||
guard += 1;
|
guard += 1;
|
||||||
const part = await postImportChunk({
|
const part = await postImportChunk({
|
||||||
@@ -183,11 +218,30 @@ async function runChunkedImport(file: File, map?: Record<string, string>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function apiMessage(e: unknown, fallback: string) {
|
function apiMessage(e: unknown, fallback: string) {
|
||||||
if (axios.isAxiosError(e)) {
|
if (!axios.isAxiosError(e)) {
|
||||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
return e instanceof Error && e.message ? e.message : fallback;
|
||||||
return Array.isArray(msg) ? msg.join('، ') : (msg ?? 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) {
|
async function onFile(ev: Event) {
|
||||||
|
|||||||
@@ -499,6 +499,7 @@ onUnmounted(() => {
|
|||||||
</UiButton>
|
</UiButton>
|
||||||
<ExcelImportFlow
|
<ExcelImportFlow
|
||||||
preview-path="/activities/import/preview"
|
preview-path="/activities/import/preview"
|
||||||
|
prepare-path="/activities/import/prepare"
|
||||||
import-path="/activities/import"
|
import-path="/activities/import"
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
@start="onImportStart"
|
@start="onImportStart"
|
||||||
|
|||||||
Reference in New Issue
Block a user