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';
|
||||
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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user