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

600 lines
20 KiB
TypeScript
Raw Normal View History

import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
import type { ImportContactsResult, PersonMood } from '@mento/shared';
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
import { ownerWhere, resolveOwnerId } from '../common/record-scope';
import {
collectAddresses,
} from '../common/custom-fields';
2026-09-15 17:33:05 +03:30
import {
formatJalaliDate,
2026-09-19 15:30:31 +03:30
parseExcelMapping,
parseJalaliDateTime,
2026-09-19 15:30:31 +03:30
previewExcel,
2026-09-15 17:33:05 +03:30
readMappedRows,
writeXlsx,
2026-09-19 15:30:31 +03:30
type ExcelPreviewResult,
2026-09-15 17:33:05 +03:30
} from '../common/excel';
import {
chunkImportMeta,
resolveExcelImportChunk,
} from '../common/excel-import-session';
import { CompaniesService } from './companies.service';
import { PeopleService } from './people.service';
import { normalizePersonImportSource } from './person-source';
type ImportChunkOpts = {
sessionId?: string;
offset?: string | number;
limit?: string | number;
};
function parseMood(raw?: string): PersonMood | undefined {
const value = raw?.trim();
if (!value) return undefined;
if (PERSON_MOODS.includes(value as PersonMood)) return value as PersonMood;
return PERSON_MOODS.find((mood) => PERSON_MOOD_LABELS[mood] === value);
}
2026-09-15 17:33:05 +03:30
function splitDidarName(firstName: string, lastName: string, title?: string) {
const first = firstName.trim();
const last = lastName.trim();
if (first && last) return { firstName: first.slice(0, 80), lastName: last.slice(0, 80) };
const full = (first || last || title || '').trim();
const parts = full.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return {
firstName: parts[0]!.slice(0, 80),
lastName: parts.slice(1).join(' ').slice(0, 80),
};
}
if (full) return { firstName: full.slice(0, 80), lastName: full.slice(0, 80) };
return null;
}
const PERSON_ALIASES: Record<string, string> = {
firstname: 'firstName',
نام: 'firstName',
2026-09-15 17:33:05 +03:30
ناممشتری: 'firstName',
lastname: 'lastName',
نامخانوادگی: 'lastName',
2026-09-15 17:33:05 +03:30
نامخانوادگیمشتری: 'lastName',
عنوانمشتری: 'title',
email: 'email',
ایمیل: 'email',
2026-09-15 17:33:05 +03:30
ایمیلمشتری: 'email',
phone: 'phone',
تلفن: 'phone',
2026-09-15 17:33:05 +03:30
تلفنثابت: 'phone',
تلفنثابتمشتری: 'phone',
mobile: 'mobile',
موبایل: 'mobile',
2026-09-15 17:33:05 +03:30
تلفنهمراهمشتری: 'mobile',
position: 'position',
سمت: 'position',
2026-09-15 17:33:05 +03:30
سمت1: 'position',
company: 'companyName',
companyname: 'companyName',
2026-09-15 17:33:05 +03:30
نامشرکت: 'companyName',
شرکت: 'companyName',
notes: 'notes',
یادداشت: 'notes',
2026-09-15 17:33:05 +03:30
توضیحاتشخص: 'notes',
mood: 'mood',
وضعیت: 'mood',
وضعیتمشتری: 'mood',
2026-09-15 17:33:05 +03:30
گروهبندیشخص: 'mood',
گروهمشتری: 'group',
آدرس: 'address',
آدرس1: 'address',
آدرساصلی: 'address',
address: 'address',
address2: 'address2',
آدرس۲: 'address2',
آدرس2: 'address2',
آدرس_1: 'address2',
آدرسدوم: 'address2',
2026-09-15 17:33:05 +03:30
آدرسمنزل: 'homeAddress',
آدرمنزل: 'homeAddress',
آدرسخانه: 'homeAddress',
homeaddress: 'homeAddress',
2026-09-15 17:33:05 +03:30
کدپستی: 'postalCode',
کدپستی1: 'postalCode',
استانفیلدپیشفرض: 'province',
استان: 'province',
شهرستان: 'city',
شهر1: 'city',
کدمشتری: 'customerCode',
کددیدارمشتری: 'didarId',
کدملیمشتری: 'nationalId',
تاریخثبتمشتری: 'registeredAt',
تاریختولدمشتری: 'birthDate',
تاریختولد1: 'birthDate',
تاریختولد: 'birthDate',
منبعاطلاعات: 'source',
نوعمشتری: 'customerType',
type: '_type',
_type: '_type',
آیدیاینستاگرام: 'instagram',
اینستاگرام: 'instagram',
پیجاینستاگرام: 'instagram',
instagram: 'instagram',
2026-09-15 17:33:05 +03:30
معرف: 'referrer',
سایتهایمشتری: 'website',
تاریخثبت: 'registeredAt',
registeredat: 'registeredAt',
2026-09-15 17:33:05 +03:30
شمارهفکس: 'fax',
تلفن2: 'phone2',
تلفنهایدیگرمشتری: 'otherPhones',
مسئولمشتری: 'ownerName',
مسئول: 'ownerName',
مسئولفروش: 'ownerName',
کارشناسفروش: 'ownerName',
کارشناس: 'ownerName',
owner: 'ownerName',
ownername: 'ownerName',
2026-09-15 17:33:05 +03:30
تلفنثابتشرکت: 'companyPhone',
کددیدارشرکت: 'companyDidarId',
مجوزمشاهدهمشتری: 'visibility',
ایمیلهایدیگرمشتری: 'otherEmails',
کد1: 'customerCode',
گروه1: 'group',
تماسهمکار: 'colleagueContact',
شرکتقبلی: 'previousCompany',
ستاره: 'star',
};
2026-09-19 15:30:31 +03:30
const PERSON_FIELDS = [
{ key: 'firstName', label: 'نام' },
{ key: 'lastName', label: 'نام خانوادگی' },
{ key: 'title', label: 'نام کامل / عنوان' },
{ key: 'didarId', label: 'کد مبدأ (کد دیدار)' },
{ key: 'customerCode', label: 'کد مشتری' },
2026-09-19 15:30:31 +03:30
{ key: 'mobile', label: 'موبایل' },
{ key: 'phone', label: 'تلفن' },
{ key: 'email', label: 'ایمیل' },
{ key: 'position', label: 'سمت' },
{ key: 'companyName', label: 'شرکت' },
{ key: 'ownerName', label: 'کارشناس فروش' },
{ key: 'address', label: 'آدرس' },
{ key: 'address2', label: 'آدرس ۲' },
{ key: 'homeAddress', label: 'آدرس منزل' },
{ key: 'instagram', label: 'اینستاگرام' },
{ key: 'registeredAt', label: 'تاریخ ثبت' },
2026-09-19 15:30:31 +03:30
{ key: 'notes', label: 'یادداشت' },
{ key: 'mood', label: 'وضعیت مشتری' },
];
const COMPANY_ALIASES: Record<string, string> = {
name: 'name',
نام: 'name',
نامشرکت: 'name',
nationalid: 'nationalId',
شناسهملی: 'nationalId',
phone: 'phone',
تلفن: 'phone',
2026-09-15 17:33:05 +03:30
تلفنثابتشرکت: 'phone',
email: 'email',
ایمیل: 'email',
website: 'website',
وبسایت: 'website',
address: 'address',
آدرس: 'address',
notes: 'notes',
یادداشت: 'notes',
};
2026-09-19 15:30:31 +03:30
const COMPANY_FIELDS = [
{ key: 'name', label: 'نام شرکت', required: true },
{ key: 'nationalId', label: 'شناسه ملی' },
{ key: 'phone', label: 'تلفن' },
{ key: 'email', label: 'ایمیل' },
{ key: 'website', label: 'وب‌سایت' },
{ key: 'address', label: 'آدرس' },
{ key: 'notes', label: 'یادداشت' },
];
2026-09-15 17:33:05 +03:30
const CONTACT_COLUMNS = [
{ header: '_type', key: '_type', width: 12 },
{ header: 'کد دیدار مشتری', key: 'didarId', width: 16 },
{ header: 'کد مشتری', key: 'customerCode', width: 14 },
{ header: 'عنوان مشتری', key: 'title', width: 16 },
{ header: 'نام مشتری', key: 'firstName', width: 16 },
{ header: 'نام خانوادگی مشتری', key: 'lastName', width: 22 },
{ header: 'ایمیل مشتری', key: 'email', width: 24 },
{ header: 'شماره فکس', key: 'fax', width: 14 },
{ header: 'سمت', key: 'position', width: 16 },
{ header: 'کد پستی', key: 'postalCode', width: 12 },
{ header: 'توضیحات شخص', key: 'notes', width: 32 },
{ header: 'تلفن همراه مشتری', key: 'mobile', width: 16 },
{ header: 'استان (فیلد پیش فرض)', key: 'province', width: 16 },
{ header: 'شهرستان', key: 'city', width: 14 },
{ header: 'تلفن ثابت مشتری', key: 'phone', width: 16 },
{ header: 'تلفن ثابت شرکت', key: 'companyPhone', width: 16 },
{ header: 'نام شرکت', key: 'companyName', width: 24 },
{ header: 'کد دیدار شرکت', key: 'companyDidarId', width: 14 },
{ header: 'مسئول مشتری', key: 'ownerName', width: 16 },
{ header: 'مجوز مشاهده مشتری', key: 'visibility', width: 18 },
{ header: 'کد ملی مشتری', key: 'nationalId', width: 16 },
{ header: 'تاریخ ثبت مشتری', key: 'registeredAt', width: 16 },
{ header: 'تاریخ تولد مشتری', key: 'birthDate', width: 16 },
{ header: 'گروه بندی شخص', key: 'mood', width: 16 },
{ header: 'تلفن های دیگر مشتری', key: 'otherPhones', width: 18 },
{ header: 'آدرس', key: 'address', width: 28 },
{ header: 'سایت های مشتری', key: 'website', width: 20 },
{ header: 'ایمیل های دیگر مشتری', key: 'otherEmails', width: 20 },
{ header: 'سمت_1', key: 'position2', width: 14 },
{ header: 'شهر_1', key: 'city2', width: 12 },
{ header: 'کد_1', key: 'code2', width: 12 },
{ header: 'گروه_1', key: 'group2', width: 12 },
{ header: 'آدرس_1', key: 'address2', width: 20 },
{ header: 'تلفن2', key: 'phone2', width: 14 },
{ header: 'کدپستی', key: 'postalCode2', width: 12 },
{ header: 'معرف', key: 'referrer', width: 14 },
{ header: 'آدرس منزل', key: 'homeAddress', width: 20 },
{ header: 'تاریخ تولد_1', key: 'birthDate2', width: 14 },
{ header: 'آی دی اینستاگرام', key: 'instagram', width: 16 },
{ header: 'گروه مشتری', key: 'group', width: 16 },
{ header: 'تماس همکار', key: 'colleagueContact', width: 16 },
{ header: 'شرکت قبلی', key: 'previousCompany', width: 16 },
{ header: 'نوع مشتری', key: 'customerType', width: 14 },
{ header: 'ستاره', key: 'star', width: 10 },
{ header: 'منبع اطلاعات', key: 'source', width: 16 },
];
const CUSTOM_KEYS = [
'didarId',
'customerCode',
'title',
'fax',
'postalCode',
'province',
'city',
'address',
'address2',
2026-09-15 17:33:05 +03:30
'nationalId',
'birthDate',
'group',
'otherPhones',
'website',
'otherEmails',
'instagram',
'referrer',
'source',
'customerType',
'homeAddress',
'colleagueContact',
'previousCompany',
'star',
'phone2',
'companyDidarId',
'companyPhone',
'visibility',
] as const;
@Injectable()
export class ContactsExcelService {
constructor(
private readonly prisma: PrismaService,
private readonly people: PeopleService,
private readonly companies: CompaniesService,
) {}
async exportPeople(actor: Actor, q?: string): Promise<Buffer> {
const { items } = await this.people.list(actor, q, 1, 5000);
2026-09-15 17:33:05 +03:30
return writeXlsx(
'Contact',
CONTACT_COLUMNS,
items.map((p) => {
const extra = (p.customFields ?? {}) as Record<string, string>;
return {
_type: 'Contact',
didarId: extra.didarId ?? p.id,
customerCode: extra.customerCode ?? '',
title: extra.title ?? '',
firstName: p.firstName,
lastName: p.lastName,
email: p.email ?? '',
fax: extra.fax ?? '',
position: p.position ?? extra.position ?? '',
postalCode: extra.postalCode ?? '',
notes: p.notes ?? '',
mobile: p.mobile ?? '',
province: extra.province ?? '',
city: extra.city ?? '',
phone: p.phone ?? '',
companyPhone: extra.companyPhone ?? '',
companyName: p.company?.name ?? '',
companyDidarId: extra.companyDidarId ?? '',
ownerName: p.owner?.fullName ?? '',
visibility: 'همه افراد شرکت',
nationalId: extra.nationalId ?? '',
registeredAt: formatJalaliDate(p.createdAt),
birthDate: extra.birthDate ?? '',
mood: extra.group || PERSON_MOOD_LABELS[p.mood || 'newcomer'],
otherPhones: extra.otherPhones ?? '',
address: p.addresses[0] ?? extra.address ?? '',
website: p.website ?? extra.website ?? '',
2026-09-15 17:33:05 +03:30
otherEmails: extra.otherEmails ?? '',
position2: p.position ?? extra.position ?? '',
city2: extra.city ?? '',
code2: extra.customerCode ?? '',
group2: extra.group ?? '',
address2: p.addresses[1] ?? extra.address ?? '',
2026-09-15 17:33:05 +03:30
phone2: extra.phone2 ?? p.phone ?? '',
postalCode2: extra.postalCode ?? '',
referrer: extra.referrer ?? p.referrer ?? '',
homeAddress: p.addresses[2] ?? extra.homeAddress ?? '',
2026-09-15 17:33:05 +03:30
birthDate2: extra.birthDate ?? '',
instagram: p.instagram ?? extra.instagram ?? '',
2026-09-15 17:33:05 +03:30
group: extra.group ?? '',
colleagueContact: extra.colleagueContact ?? '',
previousCompany: extra.previousCompany ?? '',
customerType: extra.customerType ?? '',
star: extra.star ?? '',
source: extra.source ?? '',
};
}),
);
}
async exportCompanies(actor: Actor, q?: string): Promise<Buffer> {
const { items } = await this.companies.list(actor, q, 1, 5000);
2026-09-15 17:33:05 +03:30
return writeXlsx(
'شرکت‌ها',
[
{ header: 'نام شرکت', key: 'name', width: 28 },
{ header: 'شناسه ملی', key: 'nationalId', width: 16 },
{ header: 'تلفن', key: 'phone', width: 16 },
{ header: 'ایمیل', key: 'email', width: 28 },
{ header: 'وب‌سایت', key: 'website', width: 24 },
{ header: 'آدرس', key: 'address', width: 32 },
{ header: 'یادداشت', key: 'notes', width: 32 },
],
items.map((c) => ({
name: c.name,
nationalId: c.nationalId ?? '',
phone: c.phone ?? '',
email: c.email ?? '',
website: c.website ?? '',
address: c.address ?? '',
notes: c.notes ?? '',
2026-09-15 17:33:05 +03:30
})),
);
}
2026-09-19 15:30:31 +03:30
previewPeople(file: Express.Multer.File): Promise<ExcelPreviewResult> {
return previewExcel(file, PERSON_ALIASES, PERSON_FIELDS);
}
previewCompanies(file: Express.Multer.File): Promise<ExcelPreviewResult> {
return previewExcel(file, COMPANY_ALIASES, COMPANY_FIELDS);
}
async importPeople(
actor: Actor,
file: Express.Multer.File | undefined,
2026-09-19 15:30:31 +03:30
mappingRaw?: string,
chunk?: ImportChunkOpts,
): Promise<ImportContactsResult> {
const ctx = await resolveExcelImportChunk({
organizationId: actor.organizationId,
file,
sessionId: chunk?.sessionId,
offset: chunk?.offset,
limit: chunk?.limit,
loadRows: () =>
readMappedRows(file!, PERSON_ALIASES, parseExcelMapping(mappingRaw)),
});
2026-09-15 17:33:05 +03:30
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
const ownerCache = new Map<string, string | null>();
for (let i = 0; i < ctx.slice.length; i++) {
const row = ctx.slice[i]!;
const rowNum = ctx.offset + i + 2;
2026-09-15 17:33:05 +03:30
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'contact') {
continue;
}
2026-09-19 15:30:31 +03:30
const names = splitDidarName(row.firstName ?? '', row.lastName ?? '', row.title) ?? {
firstName: (row.firstName ?? '').trim().slice(0, 80),
lastName: (row.lastName ?? '').trim().slice(0, 80),
};
if (
!names.firstName &&
!names.lastName &&
!row.mobile?.trim() &&
!row.email?.trim() &&
!row.phone?.trim()
) {
result.errors.push({ row: rowNum, message: 'حداقل نام، موبایل، تلفن یا ایمیل لازم است' });
continue;
}
let companyId: string | null = null;
const companyName = row.companyName?.trim();
if (companyName) {
companyId = await this.findOrCreateCompanyId(actor, companyName);
}
const ownerName = row.ownerName?.trim() ?? '';
let ownerId: string | undefined;
let ownerMissing = false;
if (ownerName) {
const key = ownerName.toLowerCase();
if (!ownerCache.has(key)) {
ownerCache.set(key, await this.findUserId(actor.organizationId, ownerName));
}
const matched = ownerCache.get(key);
if (matched) ownerId = matched;
else ownerMissing = true;
}
2026-09-15 17:33:05 +03:30
const customFields: Record<string, string> = {};
for (const key of CUSTOM_KEYS) {
if (
key === 'address' ||
key === 'address2' ||
key === 'homeAddress' ||
key === 'instagram' ||
key === 'website'
) {
continue;
}
2026-09-15 17:33:05 +03:30
const value = row[key]?.trim();
if (value) customFields[key] = value;
}
const addresses = collectAddresses(
row.address,
row.address2,
row.homeAddress,
);
const registeredAt =
parseJalaliDateTime(row.registeredAt) ??
(() => {
const raw = row.registeredAt?.trim();
if (!raw) return null;
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? null : d;
})();
try {
const didarId = customFields.didarId?.trim() || '';
const customerCode = customFields.customerCode?.trim() || '';
const sourceCol = customFields.source?.trim() || '';
const importSource = didarId
? 'didar'
: sourceCol
? normalizePersonImportSource(sourceCol)
: 'excel';
const sourceRef = didarId || customerCode || null;
await this.people.create(actor, {
2026-09-15 17:33:05 +03:30
firstName: names.firstName,
lastName: names.lastName,
email: row.email || undefined,
2026-09-15 17:33:05 +03:30
phone: row.phone || row.phone2 || undefined,
mobile: row.mobile || undefined,
position: row.position || undefined,
website: row.website || undefined,
instagram: row.instagram || undefined,
addresses,
notes: row.notes || undefined,
2026-09-15 17:33:05 +03:30
mood: parseMood(row.mood) ?? parseMood(row.group),
companyId,
ownerId: resolveOwnerId(actor, ownerId ?? null),
2026-09-15 17:33:05 +03:30
customFields,
importSource,
sourceRef,
registeredAt: registeredAt?.toISOString() ?? null,
});
result.created += 1;
if (ownerMissing) {
result.errors.push({
row: rowNum,
message: `کارشناس فروش «${ownerName}» در تیم پیدا نشد؛ به کاربر ایمپورتر اختصاص داده شد`,
});
}
} catch (e) {
if (e instanceof ConflictException) {
result.skipped += 1;
} else {
result.errors.push({
row: rowNum,
message: e instanceof Error ? e.message : 'خطای ناشناخته',
});
}
}
}
return { ...result, ...chunkImportMeta(ctx) };
}
async importCompanies(
actor: Actor,
file: Express.Multer.File | undefined,
2026-09-19 15:30:31 +03:30
mappingRaw?: string,
chunk?: ImportChunkOpts,
): Promise<ImportContactsResult> {
const ctx = await resolveExcelImportChunk({
organizationId: actor.organizationId,
file,
sessionId: chunk?.sessionId,
offset: chunk?.offset,
limit: chunk?.limit,
loadRows: () =>
readMappedRows(file!, COMPANY_ALIASES, parseExcelMapping(mappingRaw)),
});
2026-09-15 17:33:05 +03:30
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
for (let i = 0; i < ctx.slice.length; i++) {
const row = ctx.slice[i]!;
const rowNum = ctx.offset + i + 2;
const name = row.name?.trim() ?? '';
if (!name) {
result.errors.push({ row: rowNum, message: 'نام شرکت الزامی است' });
continue;
}
try {
await this.companies.create(actor, {
name,
nationalId: row.nationalId || undefined,
phone: row.phone || undefined,
email: row.email || undefined,
website: row.website || undefined,
address: row.address || undefined,
notes: row.notes || undefined,
});
result.created += 1;
} catch (e) {
if (e instanceof ConflictException) {
result.skipped += 1;
} else {
result.errors.push({
row: rowNum,
message: e instanceof Error ? e.message : 'خطای ناشناخته',
});
}
}
}
return { ...result, ...chunkImportMeta(ctx) };
}
2026-09-15 17:33:05 +03:30
private async findOrCreateCompanyId(actor: Actor, name: string): Promise<string> {
const existing = await this.prisma.company.findFirst({
where: {
organizationId: actor.organizationId,
name: { equals: name, mode: 'insensitive' },
...ownerWhere(actor),
},
select: { id: true },
});
if (existing) return existing.id;
const created = await this.companies.create(actor, { name });
return created.id;
}
private async findUserId(organizationId: string, name?: string) {
const n = name?.trim();
if (!n) return null;
const exact = await this.prisma.user.findFirst({
where: {
fullName: { equals: n, mode: 'insensitive' },
memberships: { some: { organizationId } },
},
select: { id: true },
});
if (exact) return exact.id;
const partial = await this.prisma.user.findFirst({
where: {
fullName: { contains: n, mode: 'insensitive' },
memberships: { some: { organizationId } },
},
select: { id: true },
});
return partial?.id ?? null;
}
}