826 lines
29 KiB
TypeScript
826 lines
29 KiB
TypeScript
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';
|
|
import {
|
|
formatJalaliDate,
|
|
parseExcelMapping,
|
|
parseJalaliDateTime,
|
|
previewExcel,
|
|
readMappedRows,
|
|
writeXlsx,
|
|
type ExcelPreviewResult,
|
|
} 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);
|
|
}
|
|
|
|
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',
|
|
ناممشتری: 'firstName',
|
|
lastname: 'lastName',
|
|
نامخانوادگی: 'lastName',
|
|
نامخانوادگیمشتری: 'lastName',
|
|
عنوانمشتری: 'title',
|
|
email: 'email',
|
|
ایمیل: 'email',
|
|
ایمیلمشتری: 'email',
|
|
phone: 'phone',
|
|
تلفن: 'phone',
|
|
تلفنثابت: 'phone',
|
|
تلفنثابتمشتری: 'phone',
|
|
mobile: 'mobile',
|
|
موبایل: 'mobile',
|
|
تلفنهمراهمشتری: 'mobile',
|
|
position: 'position',
|
|
سمت: 'position',
|
|
سمت1: 'position',
|
|
company: 'companyName',
|
|
companyname: 'companyName',
|
|
نامشرکت: 'companyName',
|
|
شرکت: 'companyName',
|
|
notes: 'notes',
|
|
یادداشت: 'notes',
|
|
توضیحاتشخص: 'notes',
|
|
mood: 'mood',
|
|
وضعیت: 'mood',
|
|
وضعیتمشتری: 'mood',
|
|
گروهبندیشخص: 'mood',
|
|
گروهمشتری: 'group',
|
|
آدرس: 'address',
|
|
آدرس1: 'address',
|
|
آدرساصلی: 'address',
|
|
address: 'address',
|
|
address2: 'address2',
|
|
آدرس۲: 'address2',
|
|
آدرس2: 'address2',
|
|
آدرس_1: 'address2',
|
|
آدرسدوم: 'address2',
|
|
آدرسمنزل: 'homeAddress',
|
|
آدرمنزل: 'homeAddress',
|
|
آدرسخانه: 'homeAddress',
|
|
homeaddress: 'homeAddress',
|
|
کدپستی: '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',
|
|
معرف: 'referrer',
|
|
سایتهایمشتری: 'website',
|
|
تاریخثبت: 'registeredAt',
|
|
registeredat: 'registeredAt',
|
|
شمارهفکس: 'fax',
|
|
تلفن2: 'phone2',
|
|
تلفنهایدیگرمشتری: 'otherPhones',
|
|
مسئولمشتری: 'ownerName',
|
|
مسئول: 'ownerName',
|
|
مسئولفروش: 'ownerName',
|
|
کارشناسفروش: 'ownerName',
|
|
کارشناس: 'ownerName',
|
|
owner: 'ownerName',
|
|
ownername: 'ownerName',
|
|
تلفنثابتشرکت: 'companyPhone',
|
|
کددیدارشرکت: 'companyDidarId',
|
|
مجوزمشاهدهمشتری: 'visibility',
|
|
ایمیلهایدیگرمشتری: 'otherEmails',
|
|
کد1: 'customerCode',
|
|
گروه1: 'group',
|
|
تماسهمکار: 'colleagueContact',
|
|
شرکتقبلی: 'previousCompany',
|
|
ستاره: 'star',
|
|
};
|
|
|
|
const PERSON_FIELDS = [
|
|
{ key: 'firstName', label: 'نام' },
|
|
{ key: 'lastName', label: 'نام خانوادگی' },
|
|
{ key: 'title', label: 'نام کامل / عنوان' },
|
|
{ key: 'didarId', label: 'کد مبدأ (کد دیدار)' },
|
|
{ key: 'customerCode', label: 'کد مشتری' },
|
|
{ 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: 'تاریخ ثبت' },
|
|
{ key: 'notes', label: 'یادداشت' },
|
|
{ key: 'mood', label: 'وضعیت مشتری' },
|
|
];
|
|
|
|
const COMPANY_ALIASES: Record<string, string> = {
|
|
type: '_type',
|
|
_type: '_type',
|
|
name: 'name',
|
|
نام: 'name',
|
|
نامشرکت: 'name',
|
|
کددیدارشرکت: 'didarId',
|
|
didarid: 'didarId',
|
|
کدشرکت: 'code',
|
|
code: 'code',
|
|
استانفیلدپیشفرض: 'province',
|
|
استان: 'province',
|
|
province: 'province',
|
|
شهرستان: 'city',
|
|
شهر: 'city',
|
|
city: 'city',
|
|
nationalid: 'nationalId',
|
|
شناسهملی: 'nationalId',
|
|
شمارهاقتصادی: 'economicCode',
|
|
کداقتصادی: 'economicCode',
|
|
شمارهثبت: 'registrationNumber',
|
|
phone: 'phone',
|
|
تلفن: 'phone',
|
|
تلفنشرکت: 'phone',
|
|
تلفنثابتشرکت: 'phone',
|
|
تلفنهایدیگرشرکت: 'otherPhones',
|
|
شمارهفکس: 'fax',
|
|
فکس: 'fax',
|
|
fax: 'fax',
|
|
email: 'email',
|
|
ایمیل: 'email',
|
|
ایمیلشرکت: 'email',
|
|
ایمیلهایدیگرشرکت: 'otherEmails',
|
|
website: 'website',
|
|
وبسایت: 'website',
|
|
سایتهایشرکت: 'website',
|
|
کدپستی: 'postalCode',
|
|
postalcode: 'postalCode',
|
|
address: 'address',
|
|
آدرس: 'address',
|
|
notes: 'notes',
|
|
یادداشت: 'notes',
|
|
توضیحاتشرکت: 'notes',
|
|
مسئولشرکت: 'ownerName',
|
|
مسئول: 'ownerName',
|
|
owner: 'ownerName',
|
|
مجوزمشاهدهشرکت: 'visibility',
|
|
تاریخثبتشرکت: 'registeredAt',
|
|
تاریخثبت: 'registeredAt',
|
|
گروهبندیشرکت: 'groupName',
|
|
گروه: 'groupName',
|
|
};
|
|
|
|
const COMPANY_FIELDS = [
|
|
{ key: 'name', label: 'نام شرکت', required: true },
|
|
{ key: 'didarId', label: 'کد دیدار شرکت' },
|
|
{ key: 'code', label: 'کد شرکت' },
|
|
{ key: 'province', label: 'استان' },
|
|
{ key: 'city', label: 'شهرستان' },
|
|
{ key: 'phone', label: 'تلفن شرکت' },
|
|
{ key: 'email', label: 'ایمیل شرکت' },
|
|
{ key: 'economicCode', label: 'شماره اقتصادی' },
|
|
{ key: 'registrationNumber', label: 'شماره ثبت' },
|
|
{ key: 'nationalId', label: 'شناسه ملی' },
|
|
{ key: 'fax', label: 'شماره فکس' },
|
|
{ key: 'postalCode', label: 'کد پستی' },
|
|
{ key: 'notes', label: 'توضیحات شرکت' },
|
|
{ key: 'ownerName', label: 'مسئول شرکت' },
|
|
{ key: 'visibility', label: 'مجوز مشاهده شرکت' },
|
|
{ key: 'registeredAt', label: 'تاریخ ثبت شرکت' },
|
|
{ key: 'groupName', label: 'گروه بندی شرکت' },
|
|
{ key: 'otherPhones', label: 'تلفن های دیگر شرکت' },
|
|
{ key: 'address', label: 'آدرس' },
|
|
{ key: 'website', label: 'سایت های شرکت' },
|
|
{ key: 'otherEmails', label: 'ایمیل های دیگر شرکت' },
|
|
];
|
|
|
|
const COMPANY_COLUMNS = [
|
|
{ header: '_type', key: '_type', width: 12 },
|
|
{ header: 'کد دیدار شرکت', key: 'didarId', width: 14 },
|
|
{ header: 'کد شرکت', key: 'code', width: 12 },
|
|
{ header: 'نام شرکت', key: 'name', width: 28 },
|
|
{ header: 'استان (فیلد پیش فرض)', key: 'province', width: 16 },
|
|
{ header: 'شهرستان', key: 'city', width: 14 },
|
|
{ header: 'تلفن شرکت', key: 'phone', width: 16 },
|
|
{ header: 'ایمیل شرکت', key: 'email', width: 24 },
|
|
{ header: 'شماره اقتصادی', key: 'economicCode', width: 16 },
|
|
{ header: 'شماره ثبت', key: 'registrationNumber', width: 14 },
|
|
{ header: 'شناسه ملی', key: 'nationalId', width: 16 },
|
|
{ header: 'شماره فکس', key: 'fax', width: 14 },
|
|
{ header: 'کد پستی', key: 'postalCode', width: 12 },
|
|
{ header: 'توضیحات شرکت', key: 'notes', width: 32 },
|
|
{ header: 'مسئول شرکت', key: 'ownerName', width: 16 },
|
|
{ header: 'مجوز مشاهده شرکت', key: 'visibility', width: 18 },
|
|
{ header: 'تاریخ ثبت شرکت', key: 'registeredAt', width: 16 },
|
|
{ header: 'گروه بندی شرکت', key: 'groupName', width: 16 },
|
|
{ header: 'تلفن های دیگر شرکت', key: 'otherPhones', width: 18 },
|
|
{ header: 'آدرس', key: 'address', width: 28 },
|
|
{ header: 'سایت های شرکت', key: 'website', width: 20 },
|
|
{ header: 'ایمیل های دیگر شرکت', key: 'otherEmails', width: 20 },
|
|
];
|
|
|
|
function parseLooseDate(raw?: string): Date | null {
|
|
const jalali = parseJalaliDateTime(raw);
|
|
if (jalali) return jalali;
|
|
const v = raw?.trim();
|
|
if (!v) return null;
|
|
const d = new Date(v);
|
|
return Number.isNaN(d.getTime()) ? null : d;
|
|
}
|
|
|
|
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',
|
|
'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);
|
|
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 ?? '',
|
|
otherEmails: extra.otherEmails ?? '',
|
|
position2: p.position ?? extra.position ?? '',
|
|
city2: extra.city ?? '',
|
|
code2: extra.customerCode ?? '',
|
|
group2: extra.group ?? '',
|
|
address2: p.addresses[1] ?? extra.address ?? '',
|
|
phone2: extra.phone2 ?? p.phone ?? '',
|
|
postalCode2: extra.postalCode ?? '',
|
|
referrer: extra.referrer ?? p.referrer ?? '',
|
|
homeAddress: p.addresses[2] ?? extra.homeAddress ?? '',
|
|
birthDate2: extra.birthDate ?? '',
|
|
instagram: p.instagram ?? extra.instagram ?? '',
|
|
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);
|
|
return writeXlsx(
|
|
'Company',
|
|
COMPANY_COLUMNS,
|
|
items.map((c) => {
|
|
const extra = (c.customFields ?? {}) as Record<string, string>;
|
|
return {
|
|
_type: 'Company',
|
|
didarId: c.importSource === 'didar' && c.sourceRef ? c.sourceRef : '',
|
|
code: c.code ?? '',
|
|
name: c.name,
|
|
province: c.province ?? '',
|
|
city: c.city ?? '',
|
|
phone: c.phone ?? '',
|
|
email: c.email ?? '',
|
|
economicCode: c.economicCode ?? '',
|
|
registrationNumber: c.registrationNumber ?? '',
|
|
nationalId: c.nationalId ?? '',
|
|
fax: c.fax ?? '',
|
|
postalCode: c.postalCode ?? '',
|
|
notes: c.notes ?? '',
|
|
ownerName: c.owner?.fullName ?? '',
|
|
visibility: extra.visibility || 'همه افراد شرکت',
|
|
registeredAt: formatJalaliDate(
|
|
c.registeredAt ? new Date(c.registeredAt) : new Date(c.createdAt),
|
|
),
|
|
groupName: c.groupName ?? '',
|
|
otherPhones: c.otherPhones ?? '',
|
|
address: c.address ?? '',
|
|
website: c.website ?? '',
|
|
otherEmails: c.otherEmails ?? '',
|
|
};
|
|
}),
|
|
);
|
|
}
|
|
|
|
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,
|
|
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)),
|
|
});
|
|
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;
|
|
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'contact') {
|
|
continue;
|
|
}
|
|
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,
|
|
row.companyDidarId,
|
|
row.companyPhone,
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const customFields: Record<string, string> = {};
|
|
for (const key of CUSTOM_KEYS) {
|
|
if (
|
|
key === 'address' ||
|
|
key === 'address2' ||
|
|
key === 'homeAddress' ||
|
|
key === 'instagram' ||
|
|
key === 'website'
|
|
) {
|
|
continue;
|
|
}
|
|
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, {
|
|
firstName: names.firstName,
|
|
lastName: names.lastName,
|
|
email: row.email || undefined,
|
|
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,
|
|
mood: parseMood(row.mood) ?? parseMood(row.group),
|
|
companyId,
|
|
ownerId: resolveOwnerId(actor, ownerId ?? null),
|
|
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,
|
|
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)),
|
|
});
|
|
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;
|
|
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'company') {
|
|
continue;
|
|
}
|
|
const name = row.name?.trim() ?? '';
|
|
if (!name) {
|
|
result.errors.push({ row: rowNum, message: 'نام شرکت الزامی است' });
|
|
continue;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const didarId = row.didarId?.trim() || '';
|
|
const customFields: Record<string, string> = {};
|
|
const visibility = row.visibility?.trim();
|
|
if (visibility) customFields.visibility = visibility;
|
|
const registeredAt = parseLooseDate(row.registeredAt);
|
|
|
|
const payload = {
|
|
name,
|
|
code: row.code?.trim() || null,
|
|
importSource: didarId ? 'didar' : 'excel',
|
|
sourceRef: didarId || null,
|
|
nationalId: row.nationalId || undefined,
|
|
economicCode: row.economicCode?.trim() || null,
|
|
registrationNumber: row.registrationNumber?.trim() || null,
|
|
phone: row.phone || undefined,
|
|
otherPhones: row.otherPhones?.trim() || null,
|
|
fax: row.fax?.trim() || null,
|
|
email: row.email || undefined,
|
|
otherEmails: row.otherEmails?.trim() || null,
|
|
website: row.website || undefined,
|
|
province: row.province?.trim() || null,
|
|
city: row.city?.trim() || null,
|
|
postalCode: row.postalCode?.trim() || null,
|
|
address: row.address || undefined,
|
|
groupName: row.groupName?.trim() || null,
|
|
notes: row.notes || undefined,
|
|
registeredAt: registeredAt?.toISOString() ?? null,
|
|
customFields,
|
|
};
|
|
|
|
try {
|
|
const existing = await this.findImportedCompany(actor, didarId, name);
|
|
if (existing) {
|
|
const merged: Record<string, unknown> = {};
|
|
for (const [k, v] of Object.entries(payload)) {
|
|
if (k === 'customFields') continue;
|
|
if (v != null && v !== '') merged[k] = v;
|
|
}
|
|
merged.customFields = {
|
|
...((existing.customFields ?? {}) as Record<string, unknown>),
|
|
...customFields,
|
|
};
|
|
if (ownerId) merged.ownerId = ownerId;
|
|
await this.companies.update(actor, existing.id, merged);
|
|
result.skipped += 1;
|
|
} else {
|
|
await this.companies.create(actor, {
|
|
...payload,
|
|
ownerId: resolveOwnerId(actor, ownerId ?? 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) };
|
|
}
|
|
|
|
/** شرکت موجود برای ورود مجدد: اول با کد دیدار، بعد با نام (اگر کد مبدأ نداشته باشد). */
|
|
private async findImportedCompany(actor: Actor, didarId: string, name: string) {
|
|
if (didarId) {
|
|
const byRef = await this.prisma.company.findFirst({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
sourceRef: didarId,
|
|
...ownerWhere(actor),
|
|
},
|
|
select: { id: true, customFields: true },
|
|
});
|
|
if (byRef) return byRef;
|
|
}
|
|
return this.prisma.company.findFirst({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
name: { equals: name, mode: 'insensitive' },
|
|
...(didarId ? { sourceRef: null } : {}),
|
|
...ownerWhere(actor),
|
|
},
|
|
select: { id: true, customFields: true },
|
|
});
|
|
}
|
|
|
|
private async findOrCreateCompanyId(
|
|
actor: Actor,
|
|
name: string,
|
|
didarId?: string,
|
|
phone?: string,
|
|
): Promise<string> {
|
|
const ref = didarId?.trim() || '';
|
|
if (ref) {
|
|
const byRef = await this.prisma.company.findFirst({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
sourceRef: ref,
|
|
...ownerWhere(actor),
|
|
},
|
|
select: { id: true },
|
|
});
|
|
if (byRef) return byRef.id;
|
|
}
|
|
const existing = await this.prisma.company.findFirst({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
name: { equals: name, mode: 'insensitive' },
|
|
...ownerWhere(actor),
|
|
},
|
|
select: { id: true, sourceRef: true },
|
|
});
|
|
if (existing) {
|
|
if (ref && !existing.sourceRef) {
|
|
await this.prisma.company.update({
|
|
where: { id: existing.id },
|
|
data: { sourceRef: ref, importSource: 'didar' },
|
|
});
|
|
}
|
|
return existing.id;
|
|
}
|
|
try {
|
|
const created = await this.companies.create(actor, {
|
|
name,
|
|
...(ref ? { sourceRef: ref, importSource: 'didar' } : {}),
|
|
...(phone?.trim() ? { phone: phone.trim() } : {}),
|
|
});
|
|
return created.id;
|
|
} catch (e) {
|
|
if (!(e instanceof ConflictException) || !phone) throw e;
|
|
const created = await this.companies.create(actor, {
|
|
name,
|
|
...(ref ? { sourceRef: ref, importSource: 'didar' } : {}),
|
|
});
|
|
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;
|
|
}
|
|
}
|