Tahesab import still replaces the name and keeps the previous name. Co-authored-by: Cursor <cursoragent@cursor.com>
543 lines
18 KiB
TypeScript
543 lines
18 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 { 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<string, string> = {
|
|
_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<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']),
|
|
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<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),
|
|
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<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;
|
|
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 = 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<string, string>) {
|
|
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;
|
|
}
|
|
}
|