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 { normalizePhone } from '../contacts/normalize'; import { excelFieldsFromColumns, formatClock, formatJalaliDate, parseExcelMapping, parseJalaliDateTime, previewExcel, readMappedRows, stripHtml, writeXlsx, type ExcelPreviewResult, } from '../common/excel'; import { chunkImportMeta, createExcelImportSession, destroyExcelImportSession, getExcelImportSessionCaches, resolveExcelImportChunk, } from '../common/excel-import-session'; const ACTIVITY_COLUMNS = [ { header: '_type', key: '_type', width: 12 }, { header: 'نوع فعالیت', key: 'kind', width: 16 }, { header: 'عنوان فعالیت', key: 'title', width: 24 }, { header: 'متن فعالیت', key: 'body', width: 36 }, { header: 'نتیجه فعالیت', key: 'result', width: 20 }, { header: 'مسئول اجرا فعالیت', key: 'assignee', width: 18 }, { header: 'ثبت کننده فعالیت', key: 'creator', width: 18 }, { header: 'تاریخ برنامه ریزی شده اجرا فعالیت', key: 'dueDate', width: 18 }, { header: 'زمان برنامه ریزی شده اجرا فعالیت', key: 'dueTime', width: 14 }, { header: 'وضعیت اجرای فعالیت', key: 'status', width: 16 }, { header: 'تاریخ انجام شدن فعالیت', key: 'doneDate', width: 16 }, { header: 'کد معامله فعالیت', key: 'dealCode', width: 14 }, { header: 'کد کارت فعالیت', key: 'cardCode', width: 14 }, { header: 'زمان انجام شدن فعالیت', key: 'doneTime', width: 14 }, { header: 'اشخاص فعالیت', key: 'personName', width: 18 }, { header: 'کد اشخاص فعالیت', key: 'personCode', width: 14 }, { header: 'موبایل اشخاص فعالیت', key: 'personMobile', width: 16 }, { header: 'شرکت های فعالیت', key: 'companyName', width: 20 }, { header: 'کد شرکت های فعالیت', key: 'companyCode', width: 14 }, { header: 'معامله فعالیت_1', key: 'dealTitle', width: 24 }, { header: 'کارت فعالیت_1', key: 'cardTitle', width: 20 }, { header: 'هشتگ', key: 'hashtag', width: 16 }, ]; const ACTIVITY_ALIASES: Record = { _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', }; 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'])); } /** فقط خواندن و جلسه؛ بدون درج — برای فایل‌های خیلی بزرگ قبل از تکه‌تکه */ async prepare( actor: Actor, file: Express.Multer.File, mappingRaw?: string, ): Promise { const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] }; let rows: Record[]; 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, 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)), }); } 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); 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 = normalizePhone(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 < ctx.slice.length; i++) { const row = ctx.slice[i]!; const rowNum = ctx.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; 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; } 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 }, }); // M2M مسئولان — اگر مایگریشن روی سرور نباشد، ایمپورت نباید بترکد if (assigneeIds.length) { 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 : 'خطای ناشناخته', }); } } return { ...result, ...chunkImportMeta(ctx) }; } private async findPersonId(organizationId: string, row: Record) { const mobile = normalizePhone(row.personMobile); if (mobile) { const byMobile = await this.prisma.person.findFirst({ where: { organizationId, mobile: { contains: mobile.replace(/^\+98|^98|^0/, '') } }, 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; } }