Import large activity Excel in the browser via JSON chunks to avoid 502.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-03 18:28:47 +03:30
co-authored by Cursor
parent ac57b8ac33
commit cbfe470f33
11 changed files with 6451 additions and 31 deletions
@@ -19,12 +19,15 @@ import {
import {
beginExcelImportPrepare,
chunkImportMeta,
createExcelImportCacheSession,
destroyExcelImportSession,
getExcelImportPrepareStatus,
getExcelImportSessionCaches,
resolveExcelImportChunk,
type ExcelImportLookupCaches,
type ExcelImportSessionStatus,
} from '../common/excel-import-session';
import type { ImportActivityRowsDto } from './dto';
const ACTIVITY_COLUMNS = [
{ header: '_type', key: '_type', width: 12 },
@@ -239,6 +242,71 @@ export class ActivitiesExcelService {
}
const caches = getExcelImportSessionCaches(ctx.sessionId, actor.organizationId);
await this.processActivityRows(
actor,
ctx.slice,
ctx.offset,
result,
caches,
{ skipDuplicateCheck: false },
);
return { ...result, ...chunkImportMeta(ctx) };
}
/**
* ایمپورت تکه‌ای JSON از مرورگر — فایل اکسل روی سرور آپلود/پارس نمی‌شود.
*/
async importRows(
actor: Actor,
dto: ImportActivityRowsDto,
): Promise<ImportContactsResult> {
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
const rows = Array.isArray(dto.rows) ? dto.rows : [];
if (!rows.length) {
result.errors.push({ row: 1, message: 'ردیف ارسال‌شده خالی است' });
return result;
}
if (rows.length > 80) {
result.errors.push({ row: 1, message: 'حداکثر ۸۰ ردیف در هر درخواست' });
return result;
}
const total = Math.max(0, Number(dto.total) || 0);
const offset = Math.max(0, Number(dto.offset) || 0);
let sessionId = (dto.sessionId ?? '').trim();
if (!sessionId) {
sessionId = createExcelImportCacheSession(actor.organizationId);
}
const caches = getExcelImportSessionCaches(sessionId, actor.organizationId);
await this.processActivityRows(actor, rows, offset, result, caches, {
// برای فایل‌های خیلی بزرگ، بررسی تکراری هر ردیف درخواست را طولانی و ۵۰۲ می‌کند
skipDuplicateCheck: total > 2000,
});
const processed = offset + rows.length;
const hasMore = total > 0 ? processed < total : false;
if (!hasMore) destroyExcelImportSession(sessionId);
return {
...result,
sessionId: hasMore ? sessionId : undefined,
total,
processed,
hasMore,
nextOffset: hasMore ? processed : undefined,
};
}
private async processActivityRows(
actor: Actor,
slice: Array<Record<string, string>>,
offset: number,
result: ImportContactsResult,
caches: ExcelImportLookupCaches,
opts: { skipDuplicateCheck: boolean },
) {
const resolveUser = async (name?: string) => {
const key = (name ?? '').trim().toLowerCase();
if (!key) return null;
@@ -274,9 +342,9 @@ export class ActivitiesExcelService {
return id;
};
for (let i = 0; i < ctx.slice.length; i++) {
const row = ctx.slice[i]!;
const rowNum = ctx.offset + i + 2;
for (let i = 0; i < slice.length; i++) {
const row = slice[i]!;
const rowNum = offset + i + 2;
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'activity') {
continue;
}
@@ -311,20 +379,20 @@ export class ActivitiesExcelService {
const creatorId =
(await resolveUser(row.creator)) ?? actor.userId;
const duplicate = dueAt
? await this.prisma.activity.findFirst({
where: {
organizationId: actor.organizationId,
title,
dueAt,
personId: personId ?? null,
},
select: { id: true },
})
: null;
if (duplicate) {
result.skipped += 1;
continue;
if (!opts.skipDuplicateCheck && dueAt) {
const duplicate = await this.prisma.activity.findFirst({
where: {
organizationId: actor.organizationId,
title,
dueAt,
personId: personId ?? null,
},
select: { id: true },
});
if (duplicate) {
result.skipped += 1;
continue;
}
}
const created = await this.prisma.activity.create({
@@ -346,8 +414,7 @@ export class ActivitiesExcelService {
},
select: { id: true },
});
// M2M مسئولان — اگر مایگریشن روی سرور نباشد، ایمپورت نباید بترکد
if (assigneeIds.length) {
if (assigneeIds.length > 1) {
try {
await this.prisma.activity.update({
where: { id: created.id },
@@ -367,8 +434,6 @@ export class ActivitiesExcelService {
});
}
}
return { ...result, ...chunkImportMeta(ctx) };
}
private async findPersonId(organizationId: string, row: Record<string, string>) {
@@ -24,6 +24,7 @@ import { ActivitiesService } from './activities.service';
import { ActivitiesExcelService } from './activities-excel.service';
import {
CreateActivityDto,
ImportActivityRowsDto,
ListActivitiesQueryDto,
UpdateActivityDto,
} from './dto';
@@ -121,6 +122,14 @@ export class ActivitiesController {
return this.excelService.prepareStatus(user, sessionId);
}
@Post('import/rows')
importRows(
@CurrentUser() user: Actor,
@Body() dto: ImportActivityRowsDto,
) {
return this.excelService.importRows(user, dto);
}
@Post('import')
@UseInterceptors(excelUpload)
import(
+22
View File
@@ -1,5 +1,6 @@
import { Type } from 'class-transformer';
import {
Allow,
IsArray,
IsBoolean,
IsDateString,
@@ -14,6 +15,27 @@ import {
ValidateIf,
} from 'class-validator';
/** تکه‌های JSON از ایمپورت مرورگرگرا */
export class ImportActivityRowsDto {
@IsArray()
@Allow()
rows!: Array<Record<string, string>>;
@Type(() => Number)
@IsInt()
@Min(0)
offset!: number;
@Type(() => Number)
@IsInt()
@Min(0)
total!: number;
@IsOptional()
@IsString()
sessionId?: string;
}
const ACTIVITY_TYPES = ['call', 'meeting', 'task', 'note'] as const;
const ACTIVITY_STATUSES = ['open', 'done', 'canceled'] as const;
const ACTIVITY_PRIORITIES = ['very_low', 'low', 'medium', 'high', 'very_high'] as const;
@@ -102,6 +102,22 @@ export function createExcelImportSession(
return id;
}
/** فقط کش lookup برای ایمپورت JSON تکه‌ای از مرورگر (بدون نگه داشتن کل فایل) */
export function createExcelImportCacheSession(organizationId: string): string {
purgeExpired();
const id = randomBytes(16).toString('hex');
sessions.set(id, {
organizationId,
rows: [],
expiresAt: Date.now() + SESSION_TTL_MS,
caches: emptyCaches(),
status: 'ready',
total: 0,
parsed: 0,
});
return id;
}
/**
* آپلود را فوری قبول می‌کند و پارس را در پس‌زمینه انجام می‌دهد
* تا پروکسی nginx با ۵۰۲ قطع نکند.
+2 -1
View File
@@ -16,7 +16,8 @@
"pinia": "^3.0.1",
"vue": "^3.5.13",
"vue-echarts": "^8.3.0",
"vue-router": "^4.5.0"
"vue-router": "^4.5.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.3",
+86 -7
View File
@@ -3,10 +3,12 @@ import { computed, reactive, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
ExcelFieldOption,
ExcelImportPrepareStatus,
ExcelPreviewResult,
ImportContactsResult,
} from '@mento/shared';
import { previewExcelClient, readMappedRowsClient } from '@/lib/excel-client';
import { UiBusyOverlay, UiButton, UiModal, UiSelect } from '@/components/ui';
const props = defineProps<{
@@ -14,9 +16,25 @@ const props = defineProps<{
importPath: string;
/** اگر باشد: ابتدا فقط فایل خوانده می‌شود، بعد تکه‌تکه وارد می‌شود (برای فایل‌های خیلی بزرگ) */
preparePath?: string;
/**
* ایمپورت مرورگرگرا: اکسل در مرورگر خوانده می‌شود و فقط JSON کوچک به API می‌رود.
* برای فایل‌های ده‌ها هزار ردیفی (جلوگیری از ۵۰۲ nginx).
*/
rowsImportPath?: string;
clientAliases?: Record<string, string>;
clientFields?: ExcelFieldOption[];
disabled?: boolean;
}>();
const useClientImport = computed(
() =>
Boolean(
props.rowsImportPath &&
props.clientAliases &&
props.clientFields?.length,
),
);
const emit = defineEmits<{
start: [];
done: [result: ImportContactsResult];
@@ -182,7 +200,62 @@ async function waitPrepareReady(sessionId: string) {
throw new Error('آماده‌سازی فایل بیش از حد طول کشید. دوباره تلاش کنید.');
}
async function runClientRowsImport(file: File, map?: Record<string, string>) {
if (!props.rowsImportPath || !props.clientAliases) {
throw new Error('پیکربندی ایمپورت مرورگر ناقص است');
}
phase.value = 'import';
progressLabel.value = 'در حال خواندن فایل در مرورگر…';
progressPct.value = 0;
const rows = await readMappedRowsClient(file, props.clientAliases, map);
const total = rows.length;
if (total > 100_000) {
return {
created: 0,
skipped: 0,
errors: [{ row: 1, message: 'فایل بیش از ۱۰۰۰۰۰ ردیف دارد. فایل را تقسیم کنید.' }],
total,
processed: 0,
hasMore: false,
};
}
updateImportProgress(0, total);
let acc: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
let sessionId: string | undefined;
const chunkSize = 60;
for (let offset = 0; offset < total; offset += chunkSize) {
const slice = rows.slice(offset, offset + chunkSize);
const { data: part } = await api.post<ImportContactsResult>(
props.rowsImportPath,
{
rows: slice,
offset,
total,
sessionId,
},
{ timeout: 120_000 },
);
acc = mergeImportResults(acc, part);
updateImportProgress(part.processed ?? offset + slice.length, total);
if (part.errors?.some((e) => e.row === 1 && /حداکثر|بیش از/.test(e.message))) {
break;
}
sessionId = part.sessionId;
if (!part.hasMore) break;
}
updateImportProgress(total, total);
return acc;
}
async function runChunkedImport(file: File, map?: Record<string, string>) {
if (useClientImport.value) {
return runClientRowsImport(file, map);
}
phase.value = 'import';
progressLabel.value = 'در حال ارسال فایل…';
progressPct.value = 0;
@@ -229,7 +302,6 @@ async function runChunkedImport(file: File, map?: Record<string, string>) {
const processed = part.processed ?? 0;
updateImportProgress(processed, total);
// خطای سراسری (مثلاً سقف ردیف) — ادامه نده
if (
part.errors?.some((e) => e.row === 1 && /بیش از .* ردیف/.test(e.message))
) {
@@ -293,12 +365,19 @@ async function onFile(ev: Event) {
submitting.value = true;
phase.value = 'preview';
try {
const form = new FormData();
form.append('file', file);
const { data } = await api.post<ExcelPreviewResult>(props.previewPath, form, {
headers: { 'Content-Type': undefined },
timeout: 5 * 60 * 1000,
});
let data: ExcelPreviewResult;
if (useClientImport.value && props.clientAliases && props.clientFields) {
progressLabel.value = 'در حال خواندن فایل در مرورگر…';
data = await previewExcelClient(file, props.clientAliases, props.clientFields);
} else {
const form = new FormData();
form.append('file', file);
const res = await api.post<ExcelPreviewResult>(props.previewPath, form, {
headers: { 'Content-Type': undefined },
timeout: 5 * 60 * 1000,
});
data = res.data;
}
if (!data.needsMapping) {
try {
emit('done', await runChunkedImport(file));
+48
View File
@@ -0,0 +1,48 @@
import type { ExcelFieldOption } from '@mento/shared';
/** هم‌تراز با aliases سمت API برای ایمپورت فعالیت */
export const ACTIVITY_EXCEL_ALIASES: Record<string, string> = {
_type: '_type',
type: '_type',
نوعفعالیت: 'kind',
عنوانفعالیت: 'title',
عنوان: 'title',
متنفعالیت: 'body',
نتیجهفعالیت: 'result',
مسئولاجرافعالیت: 'assignee',
ثبتکنندهفعالیت: 'creator',
تاریخبرنامهریزیشدهاجرافعالیت: 'dueDate',
زمانبرنامهریزیشدهاجرافعالیت: 'dueTime',
وضعیتاجرایفعالیت: 'status',
تاریخانجامشدنفعالیت: 'doneDate',
زمانانجامشدنفعالیت: 'doneTime',
کدمعاملهفعالیت: 'dealCode',
کدکارتفعالیت: 'cardCode',
اشخاصفعالیت: 'personName',
کداشخاصفعالیت: 'personCode',
موبایلاشخاصفعالیت: 'personMobile',
شرکتهایفعالیت: 'companyName',
کدشرکتهایفعالیت: 'companyCode',
معاملهفعالیت1: 'dealTitle',
کارتفعالیت1: 'cardTitle',
هشتگ: 'hashtag',
};
export const ACTIVITY_EXCEL_FIELDS: ExcelFieldOption[] = [
{ key: 'title', label: 'عنوان فعالیت', required: true },
{ key: 'kind', label: 'نوع فعالیت' },
{ key: 'body', label: 'متن فعالیت' },
{ key: 'result', label: 'نتیجه فعالیت' },
{ key: 'assignee', label: 'مسئول اجرا فعالیت' },
{ key: 'creator', label: 'ثبت کننده فعالیت' },
{ key: 'dueDate', label: 'تاریخ برنامه ریزی شده اجرا فعالیت' },
{ key: 'dueTime', label: 'زمان برنامه ریزی شده اجرا فعالیت' },
{ key: 'status', label: 'وضعیت اجرای فعالیت' },
{ key: 'doneDate', label: 'تاریخ انجام شدن فعالیت' },
{ key: 'doneTime', label: 'زمان انجام شدن فعالیت' },
{ key: 'personName', label: 'اشخاص فعالیت' },
{ key: 'personMobile', label: 'موبایل اشخاص فعالیت' },
{ key: 'companyName', label: 'شرکت های فعالیت' },
{ key: 'dealTitle', label: 'معامله فعالیت_1' },
{ key: 'hashtag', label: 'هشتگ' },
];
+138
View File
@@ -0,0 +1,138 @@
import * as XLSX from 'xlsx';
import type { ExcelFieldOption, ExcelPreviewResult } from '@mento/shared';
function normalizeHeader(raw: string) {
return raw
.trim()
.toLowerCase()
.replace(/[_-]+/g, '')
.replace(/‌/g, '')
.replace(/\s+/g, '')
.replace(/[()]/g, '');
}
function cellToString(value: unknown): string {
if (value == null) return '';
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value).trim();
}
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? '' : value.toISOString();
}
return String(value).trim();
}
function resolveFieldKey(
raw: string,
aliases: Record<string, string>,
mapping?: Record<string, string>,
) {
if (mapping) {
return mapping[raw] || mapping[normalizeHeader(raw)] || '';
}
const compact = normalizeHeader(raw);
return aliases[compact] ?? aliases[raw.trim()] ?? '';
}
/** خواندن اکسل در مرورگر — بدون آپلود فایل سنگین به API */
export async function previewExcelClient(
file: File,
aliases: Record<string, string>,
fields: ExcelFieldOption[],
): Promise<ExcelPreviewResult> {
const buffer = await file.arrayBuffer();
const wb = XLSX.read(buffer, { type: 'array', cellDates: true });
const sheetName = wb.SheetNames[0];
if (!sheetName) throw new Error('شیت اکسل پیدا نشد');
const sheet = wb.Sheets[sheetName]!;
const matrix = XLSX.utils.sheet_to_json<(string | number | boolean | Date | null)[]>(
sheet,
{ header: 1, defval: '', raw: false },
) as unknown[][];
const headerRow = (matrix[0] ?? []).map((c) => cellToString(c));
const columns: ExcelPreviewResult['columns'] = [];
const suggested: Record<string, string> = {};
const used = new Set<string>();
headerRow.forEach((header, idx) => {
if (!header) return;
const samples: string[] = [];
for (let r = 1; r < Math.min(matrix.length, 6); r += 1) {
const value = cellToString(matrix[r]?.[idx]);
if (value) samples.push(value);
if (samples.length >= 3) break;
}
columns.push({ header, index: idx + 1, samples });
const key = resolveFieldKey(header, aliases);
if (key && !used.has(key)) {
suggested[header] = key;
used.add(key);
}
});
if (!columns.length) throw new Error('ستون عنوان در ردیف اول فایل پیدا نشد');
let rowCount = 0;
for (let r = 1; r < matrix.length; r += 1) {
const row = matrix[r] ?? [];
if (row.some((c) => cellToString(c))) rowCount += 1;
}
const required = fields.filter((f) => f.required).map((f) => f.key);
const mapped = new Set(Object.values(suggested));
const missingRequired = required.some((key) => !mapped.has(key));
const unrecognized = columns.some((col) => !suggested[col.header]);
return {
columns,
fields,
suggested,
needsMapping: unrecognized || missingRequired || !mapped.size,
rowCount,
largeFile: rowCount >= 80,
};
}
export async function readMappedRowsClient(
file: File,
aliases: Record<string, string>,
mapping?: Record<string, string>,
): Promise<Record<string, string>[]> {
const buffer = await file.arrayBuffer();
const wb = XLSX.read(buffer, { type: 'array', cellDates: true });
const sheetName = wb.SheetNames[0];
if (!sheetName) throw new Error('شیت اکسل پیدا نشد');
const sheet = wb.Sheets[sheetName]!;
const matrix = XLSX.utils.sheet_to_json<unknown[]>(sheet, {
header: 1,
defval: '',
raw: false,
}) as unknown[][];
const headerRow = (matrix[0] ?? []).map((c) => cellToString(c));
const colMap = new Map<number, string>();
headerRow.forEach((header, idx) => {
if (!header) return;
const key = resolveFieldKey(header, aliases, mapping);
if (key) colMap.set(idx, key);
});
if (!colMap.size) {
throw new Error('ستون‌های فایل شناسایی نشد. ستون‌های فایل را با فیلدهای سیستم تطبیق دهید.');
}
const rows: Record<string, string>[] = [];
for (let r = 1; r < matrix.length; r += 1) {
const line = matrix[r] ?? [];
const item: Record<string, string> = {};
let any = false;
for (const [idx, key] of colMap) {
const value = cellToString(line[idx]);
if (value) any = true;
item[key] = value;
}
if (any) rows.push(item);
}
if (!rows.length) throw new Error('هیچ ردیف داده‌ای در فایل نیست');
return rows;
}
+4 -1
View File
@@ -20,6 +20,7 @@ import {
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
import { ACTIVITY_EXCEL_ALIASES, ACTIVITY_EXCEL_FIELDS } from '@/lib/activity-excel';
import ActivityFormView from '@/views/ActivityFormView.vue';
import type {
ActivityDto,
@@ -499,8 +500,10 @@ onUnmounted(() => {
</UiButton>
<ExcelImportFlow
preview-path="/activities/import/preview"
prepare-path="/activities/import/prepare"
import-path="/activities/import"
rows-import-path="/activities/import/rows"
:client-aliases="ACTIVITY_EXCEL_ALIASES"
:client-fields="ACTIVITY_EXCEL_FIELDS"
:disabled="busy"
@start="onImportStart"
@done="onImportDone"