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:
@@ -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(
|
||||
|
||||
@@ -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 با ۵۰۲ قطع نکند.
|
||||
|
||||
Reference in New Issue
Block a user