Files
CRM_Montzar/apps/api/src/activities/activities-excel.service.ts
T

436 lines
15 KiB
TypeScript

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<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',
};
const TYPE_LABEL: Record<ActivityType, string> = {
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<Buffer> {
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<ExcelPreviewResult> {
return previewExcel(file, ACTIVITY_ALIASES, excelFieldsFromColumns(ACTIVITY_COLUMNS, ['title']));
}
/** فقط خواندن و جلسه؛ بدون درج — برای فایل‌های خیلی بزرگ قبل از تکه‌تکه */
async prepare(
actor: Actor,
file: Express.Multer.File,
mappingRaw?: string,
): Promise<ImportContactsResult> {
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
let rows: Record<string, string>[];
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<ImportContactsResult> {
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<string, string>) => {
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<string, string>) {
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;
}
}