import { 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'; 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 } }, 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.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 import( actor: Actor, file: Express.Multer.File, mappingRaw?: string, ): Promise { const rows = await readMappedRows(file, ACTIVITY_ALIASES, parseExcelMapping(mappingRaw)); const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] }; for (let i = 0; i < rows.length; i++) { const row = rows[i]!; const rowNum = 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 this.findPersonId(actor.organizationId, row); const companyId = await this.findCompanyId(actor.organizationId, row.companyName); const dealId = await this.findDealId(actor.organizationId, row.dealTitle); const assigneeId = (await this.findUserId(actor.organizationId, row.assignee)) ?? actor.userId; const creatorId = (await this.findUserId(actor.organizationId, 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; } 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, }, }); result.created += 1; } catch (e) { result.errors.push({ row: rowNum, message: e instanceof Error ? e.message : 'خطای ناشناخته', }); } } return result; } 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; } }