import { BadRequestException, Injectable } from '@nestjs/common'; import type { ActivityType, ImportContactsResult } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; import type { Actor } from '../common/record-scope'; import { assigneeWhere } from '../common/record-scope'; import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize'; import { excelFieldsFromColumns, formatClock, formatJalaliDate, parseExcelMapping, parseJalaliDateTime, previewExcel, readMappedRows, stripHtml, writeXlsx, type ExcelPreviewResult, } from '../common/excel'; import { beginExcelImportPrepare, chunkImportMeta, createExcelImportCacheSession, destroyExcelImportSession, getExcelImportPrepareStatus, getExcelImportSessionCaches, resolveExcelImportChunk, type ExcelImportLookupCaches, type ExcelImportSessionStatus, } from '../common/excel-import-session'; import type { ImportActivityRowsDto } from './dto'; /** ترتیب دقیق خروجی فعالیت دیدار — برای فایل‌های بدون ردیف هدر */ const DIDAR_ACTIVITY_POSITIONAL = [ { key: '_type', label: '_type' }, { key: 'kind', label: 'نوع فعالیت' }, { key: 'title', 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: 'dealCode', label: 'کد معامله فعالیت' }, { key: 'cardCode', label: 'کد کارت فعالیت' }, { key: 'doneTime', label: 'زمان انجام شدن فعالیت' }, { key: 'personName', label: 'اشخاص فعالیت' }, { key: 'personCode', label: 'کد اشخاص فعالیت' }, { key: 'personMobile', label: 'موبایل اشخاص فعالیت' }, { key: 'companyName', label: 'شرکت های فعالیت' }, { key: 'companyCode', label: 'کد شرکت های فعالیت' }, { key: 'dealTitle', label: 'معامله فعالیت_1' }, { key: 'cardTitle', label: 'کارت فعالیت_1' }, { key: 'hashtag', label: 'هشتگ' }, ] as const; const ACTIVITY_COLUMNS = DIDAR_ACTIVITY_POSITIONAL.map((c) => ({ header: c.label, key: c.key, width: c.key === 'body' || c.key === 'result' ? 36 : c.key === 'title' || c.key === 'dealTitle' ? 24 : 16, })); const ACTIVITY_ALIASES: Record = { _type: '_type', type: '_type', activity: '_type', نوعفعالیت: 'kind', عنوانفعالیت: 'title', عنوان: 'title', متنفعالیت: 'body', نتیجهفعالیت: 'result', مسئولاجرافعالیت: 'assignee', ثبتکنندهفعالیت: 'creator', تاریخبرنامهریزیشدهاجرافعالیت: 'dueDate', زمانبرنامهریزیشدهاجرافعالیت: 'dueTime', وضعیتاجرایفعالیت: 'status', تاریخانجامشدنفعالیت: 'doneDate', زمانانجامشدنفعالیت: 'doneTime', کدمعاملهفعالیت: 'dealCode', کدکارتفعالیت: 'cardCode', اشخاصفعالیت: 'personName', کداشخاصفعالیت: 'personCode', موبایلاشخاصفعالیت: 'personMobile', شرکتهایفعالیت: 'companyName', کدشرکتهایفعالیت: 'companyCode', معاملهفعالیت1: 'dealTitle', معاملهفعالیت_1: 'dealTitle', کارتفعالیت1: 'cardTitle', کارتفعالیت_1: 'cardTitle', هشتگ: 'hashtag', }; function looksLikeDidarActivityHeaderless(firstRow: string[]) { const first = (firstRow[0] ?? '').trim().toLowerCase(); if (first !== 'activity' && first !== '_type') return false; const kind = (firstRow[1] ?? '').trim(); return /تماس|پیگیری|جلسه|یادداشت|ملاقات|call|task|meeting|note|پیام/i.test(kind); } const ACTIVITY_EXCEL_OPTIONS = { positionalColumns: DIDAR_ACTIVITY_POSITIONAL.map((c) => ({ key: c.key, label: c.label, })), detectHeaderless: looksLikeDidarActivityHeaderless, }; const TYPE_LABEL: Record = { call: 'تماس با مشتری', meeting: 'جلسه', task: 'پیگیری', note: 'یادداشت', }; function parseType(raw?: string): ActivityType { const v = (raw ?? '').trim(); if (/تماس|call/i.test(v)) return 'call'; if (/جلسه|ملاقات|meeting/i.test(v)) return 'meeting'; if (/یادداشت|پیام|note/i.test(v)) return 'note'; return 'task'; } function parseStatus(raw?: string): 'open' | 'done' | 'canceled' { const v = (raw ?? '').trim(); if (/انجام\s*شده|done/i.test(v)) return 'done'; if (/لغو|canceled|کنسل/i.test(v)) return 'canceled'; return 'open'; } function statusLabel(status: string) { if (status === 'done') return 'انجام شده'; if (status === 'canceled') return 'لغو شده'; return 'انجام نشده'; } @Injectable() export class ActivitiesExcelService { constructor(private readonly prisma: PrismaService) {} async export(actor: Actor): Promise { const rows = await this.prisma.activity.findMany({ where: { organizationId: actor.organizationId, ...assigneeWhere(actor) }, include: { person: { select: { id: true, firstName: true, lastName: true, mobile: true } }, company: { select: { id: true, name: true } }, deal: { select: { id: true, title: true } }, assignee: { select: { fullName: true } }, assignees: { select: { fullName: true } }, createdBy: { select: { fullName: true } }, }, orderBy: [{ dueAt: 'desc' }, { createdAt: 'desc' }], take: 10000, }); return writeXlsx( 'Activity', ACTIVITY_COLUMNS, rows.map((row) => ({ _type: 'Activity', kind: TYPE_LABEL[row.type], title: row.title, body: row.description ?? '', result: '', assignee: row.assignees?.length ? row.assignees.map((a) => a.fullName).join('، ') : (row.assignee?.fullName ?? ''), creator: row.createdBy?.fullName ?? '', dueDate: formatJalaliDate(row.dueAt), dueTime: formatClock(row.dueAt), status: statusLabel(row.status), doneDate: formatJalaliDate(row.completedAt), dealCode: row.deal?.id ?? '', cardCode: '', doneTime: formatClock(row.completedAt), personName: row.person ? `${row.person.firstName} ${row.person.lastName}`.trim() : '', personCode: row.person?.id ?? '', personMobile: row.person?.mobile ?? '', companyName: row.company?.name ?? '', companyCode: row.company?.id ?? '', dealTitle: row.deal?.title ?? '', cardTitle: '', hashtag: '', })), ); } preview(file: Express.Multer.File): Promise { return previewExcel( file, ACTIVITY_ALIASES, excelFieldsFromColumns(ACTIVITY_COLUMNS, ['title']), ACTIVITY_EXCEL_OPTIONS, ); } /** * آپلود فوری + پارس در پس‌زمینه (جلوگیری از ۵۰۲ nginx روی فایل‌های بزرگ). * وضعیت را با prepareStatus پول کنید. */ prepare( actor: Actor, file: Express.Multer.File, mappingRaw?: string, ): ImportContactsResult { if (!file?.buffer?.length) { throw new BadRequestException('فایل ارسال نشده'); } const mapping = parseExcelMapping(mappingRaw); const sessionId = beginExcelImportPrepare( actor.organizationId, file.buffer, (f) => readMappedRows(f, ACTIVITY_ALIASES, mapping, ACTIVITY_EXCEL_OPTIONS), ); return { created: 0, skipped: 0, errors: [], sessionId, status: 'parsing', total: 0, parsed: 0, processed: 0, hasMore: true, nextOffset: 0, }; } prepareStatus( actor: Actor, sessionId: string, ): ExcelImportSessionStatus { return getExcelImportPrepareStatus(sessionId, actor.organizationId); } async import( actor: Actor, file: Express.Multer.File | undefined, mappingRaw?: string, chunk?: { sessionId?: string; offset?: string | number; limit?: string | number }, ): Promise { const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] }; let ctx; try { ctx = await resolveExcelImportChunk({ organizationId: actor.organizationId, file, sessionId: chunk?.sessionId, offset: chunk?.offset, limit: chunk?.limit, loadRows: () => readMappedRows( file!, ACTIVITY_ALIASES, parseExcelMapping(mappingRaw), ACTIVITY_EXCEL_OPTIONS, ), }); } catch (e) { if (e instanceof BadRequestException) throw e; const message = e instanceof Error ? e.message : 'خواندن فایل اکسل ناموفق بود'; result.errors.push({ row: 1, message }); return result; } const MAX_ROWS = 100_000; if (ctx.total > MAX_ROWS) { destroyExcelImportSession(ctx.sessionId); result.errors.push({ row: 1, message: `فایل بیش از ${MAX_ROWS} ردیف دارد. فایل را تقسیم کنید.`, }); return { ...result, hasMore: false, total: ctx.total, processed: 0 }; } 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 { 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>, offset: number, result: ImportContactsResult, caches: ExcelImportLookupCaches, opts: { skipDuplicateCheck: boolean }, ) { const resolveUser = async (name?: string) => { const key = (name ?? '').trim().toLowerCase(); if (!key) return null; if (caches.user.has(key)) return caches.user.get(key) ?? null; const id = await this.findUserId(actor.organizationId, name); caches.user.set(key, id); return id; }; const resolvePerson = async (row: Record) => { const mobile = storedPhoneNumber(row.personMobile); const name = (row.personName ?? '').trim().toLowerCase(); const key = mobile ? `m:${mobile}` : name ? `n:${name}` : ''; if (!key) return null; if (caches.person.has(key)) return caches.person.get(key) ?? null; const id = await this.findPersonId(actor.organizationId, row); caches.person.set(key, id); return id; }; const resolveCompany = async (name?: string) => { const key = (name ?? '').trim().toLowerCase(); if (!key) return null; if (caches.company.has(key)) return caches.company.get(key) ?? null; const id = await this.findCompanyId(actor.organizationId, name); caches.company.set(key, id); return id; }; const resolveDeal = async (title?: string) => { const key = (title ?? '').trim().toLowerCase(); if (!key) return null; if (caches.deal.has(key)) return caches.deal.get(key) ?? null; const id = await this.findDealId(actor.organizationId, title); caches.deal.set(key, id); return id; }; 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; } const title = (row.title || row.kind || '').trim(); if (title.length < 2) { result.errors.push({ row: rowNum, message: 'عنوان فعالیت الزامی است' }); continue; } const body = stripHtml([row.body, row.result, row.hashtag, row.cardTitle] .filter(Boolean) .join('\n')); const dueAt = parseJalaliDateTime(row.dueDate, row.dueTime); const doneAt = parseJalaliDateTime(row.doneDate, row.doneTime); const status = parseStatus(row.status); try { const personId = await resolvePerson(row); const companyId = await resolveCompany(row.companyName); const dealId = await resolveDeal(row.dealTitle); const assigneeNames = String(row.assignee ?? '') .split(/[,،]/) .map((s) => s.trim()) .filter(Boolean); const assigneeIds: string[] = []; for (const name of assigneeNames) { const uid = await resolveUser(name); if (uid && !assigneeIds.includes(uid)) assigneeIds.push(uid); } if (!assigneeIds.length) assigneeIds.push(actor.userId); const assigneeId = assigneeIds[0]!; const creatorId = (await resolveUser(row.creator)) ?? actor.userId; 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({ data: { organizationId: actor.organizationId, title, type: parseType(row.kind), status, description: body || null, dueAt, completedAt: status === 'done' ? doneAt ?? new Date() : null, reminderNotifyApp: false, reminderNotifySms: false, personId, companyId, dealId, assigneeId, createdById: creatorId, }, select: { id: true }, }); if (assigneeIds.length > 1) { try { await this.prisma.activity.update({ where: { id: created.id }, data: { assignees: { set: assigneeIds.map((id) => ({ id })) }, }, }); } catch { /* assigneeId به‌تنهایی کافی است */ } } result.created += 1; } catch (e) { result.errors.push({ row: rowNum, message: e instanceof Error ? e.message : 'خطای ناشناخته', }); } } } private async findPersonId(organizationId: string, row: Record) { const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile)); if (mobileKeys.length) { const byMobile = await this.prisma.person.findFirst({ where: { organizationId, OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }], }, select: { id: true }, }); if (byMobile) return byMobile.id; } const name = (row.personName ?? '').trim(); if (name) { const parts = name.split(/\s+/); const byName = await this.prisma.person.findFirst({ where: { organizationId, OR: [ { lastName: { contains: name, mode: 'insensitive' } }, { firstName: { contains: parts[0]!, mode: 'insensitive' } }, ], }, select: { id: true }, }); if (byName) return byName.id; } return null; } private async findCompanyId(organizationId: string, name?: string) { const n = name?.trim(); if (!n) return null; const row = await this.prisma.company.findFirst({ where: { organizationId, name: { equals: n, mode: 'insensitive' } }, select: { id: true }, }); return row?.id ?? null; } private async findDealId(organizationId: string, title?: string) { const t = title?.trim(); if (!t) return null; const row = await this.prisma.deal.findFirst({ where: { organizationId, title: { equals: t, mode: 'insensitive' } }, select: { id: true }, }); return row?.id ?? null; } private async findUserId(organizationId: string, name?: string) { const n = name?.trim(); if (!n) return null; const row = await this.prisma.user.findFirst({ where: { fullName: { contains: n, mode: 'insensitive' }, memberships: { some: { organizationId } }, }, select: { id: true }, }); return row?.id ?? null; } }