Creating import&export excel
This commit is contained in:
@@ -1,27 +1,17 @@
|
||||
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||
import ExcelJS from 'exceljs';
|
||||
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 } from '../common/record-scope';
|
||||
import {
|
||||
formatJalaliDate,
|
||||
readMappedRows,
|
||||
writeXlsx,
|
||||
} from '../common/excel';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { PeopleService } from './people.service';
|
||||
|
||||
function cellStr(value: ExcelJS.CellValue | undefined): string {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return String(value).trim();
|
||||
}
|
||||
if (typeof value === 'object' && 'text' in value && typeof value.text === 'string') {
|
||||
return value.text.trim();
|
||||
}
|
||||
if (typeof value === 'object' && 'result' in value && value.result != null) {
|
||||
return String(value.result).trim();
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function parseMood(raw?: string): PersonMood | undefined {
|
||||
const value = raw?.trim();
|
||||
if (!value) return undefined;
|
||||
@@ -29,45 +19,103 @@ function parseMood(raw?: string): PersonMood | undefined {
|
||||
return PERSON_MOODS.find((mood) => PERSON_MOOD_LABELS[mood] === value);
|
||||
}
|
||||
|
||||
function normalizeHeader(raw: string) {
|
||||
return raw.trim().toLowerCase().replace(/\s+/g, '');
|
||||
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',
|
||||
نامخانوادگیمشتری: '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',
|
||||
گروهبندیشخص: 'mood',
|
||||
گروهمشتری: 'group',
|
||||
آدرس: 'address',
|
||||
آدرس1: 'address',
|
||||
آدرسمنزل: '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',
|
||||
معرف: 'referrer',
|
||||
سایتهایمشتری: 'website',
|
||||
شمارهفکس: 'fax',
|
||||
تلفن2: 'phone2',
|
||||
تلفنهایدیگرمشتری: 'otherPhones',
|
||||
مسئولمشتری: 'ownerName',
|
||||
تلفنثابتشرکت: 'companyPhone',
|
||||
کددیدارشرکت: 'companyDidarId',
|
||||
مجوزمشاهدهمشتری: 'visibility',
|
||||
ایمیلهایدیگرمشتری: 'otherEmails',
|
||||
کد1: 'customerCode',
|
||||
گروه1: 'group',
|
||||
تماسهمکار: 'colleagueContact',
|
||||
شرکتقبلی: 'previousCompany',
|
||||
ستاره: 'star',
|
||||
};
|
||||
|
||||
const COMPANY_ALIASES: Record<string, string> = {
|
||||
name: 'name',
|
||||
نام: 'name',
|
||||
نامشرکت: 'name',
|
||||
'نام شرکت': 'name',
|
||||
nationalid: 'nationalId',
|
||||
شناسهملی: 'nationalId',
|
||||
'شناسه ملی': 'nationalId',
|
||||
phone: 'phone',
|
||||
تلفن: 'phone',
|
||||
تلفنثابتشرکت: 'phone',
|
||||
email: 'email',
|
||||
ایمیل: 'email',
|
||||
website: 'website',
|
||||
@@ -78,6 +126,83 @@ const COMPANY_ALIASES: Record<string, string> = {
|
||||
یادداشت: 'notes',
|
||||
};
|
||||
|
||||
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',
|
||||
'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(
|
||||
@@ -88,51 +213,76 @@ export class ContactsExcelService {
|
||||
|
||||
async exportPeople(actor: Actor, q?: string): Promise<Buffer> {
|
||||
const { items } = await this.people.list(actor, q, 1, 5000);
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const sheet = wb.addWorksheet('اشخاص');
|
||||
sheet.columns = [
|
||||
{ header: 'نام', key: 'firstName', width: 16 },
|
||||
{ header: 'نام خانوادگی', key: 'lastName', width: 18 },
|
||||
{ header: 'موبایل', key: 'mobile', width: 16 },
|
||||
{ header: 'تلفن', key: 'phone', width: 16 },
|
||||
{ header: 'ایمیل', key: 'email', width: 28 },
|
||||
{ header: 'سمت', key: 'position', width: 18 },
|
||||
{ header: 'شرکت', key: 'companyName', width: 24 },
|
||||
{ header: 'وضعیت مشتری', key: 'mood', width: 18 },
|
||||
{ header: 'یادداشت', key: 'notes', width: 32 },
|
||||
];
|
||||
for (const p of items) {
|
||||
sheet.addRow({
|
||||
firstName: p.firstName,
|
||||
lastName: p.lastName,
|
||||
mobile: p.mobile ?? '',
|
||||
phone: p.phone ?? '',
|
||||
email: p.email ?? '',
|
||||
position: p.position ?? '',
|
||||
companyName: p.company?.name ?? '',
|
||||
mood: PERSON_MOOD_LABELS[p.mood || 'newcomer'],
|
||||
notes: p.notes ?? '',
|
||||
});
|
||||
}
|
||||
const buf = await wb.xlsx.writeBuffer();
|
||||
return Buffer.from(buf);
|
||||
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: extra.address ?? '',
|
||||
website: extra.website ?? '',
|
||||
otherEmails: extra.otherEmails ?? '',
|
||||
position2: p.position ?? extra.position ?? '',
|
||||
city2: extra.city ?? '',
|
||||
code2: extra.customerCode ?? '',
|
||||
group2: extra.group ?? '',
|
||||
address2: extra.address ?? '',
|
||||
phone2: extra.phone2 ?? p.phone ?? '',
|
||||
postalCode2: extra.postalCode ?? '',
|
||||
referrer: extra.referrer ?? '',
|
||||
homeAddress: extra.homeAddress ?? '',
|
||||
birthDate2: extra.birthDate ?? '',
|
||||
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);
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const sheet = wb.addWorksheet('شرکتها');
|
||||
sheet.columns = [
|
||||
{ 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 },
|
||||
];
|
||||
for (const c of items) {
|
||||
sheet.addRow({
|
||||
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 ?? '',
|
||||
@@ -140,36 +290,26 @@ export class ContactsExcelService {
|
||||
website: c.website ?? '',
|
||||
address: c.address ?? '',
|
||||
notes: c.notes ?? '',
|
||||
});
|
||||
}
|
||||
const buf = await wb.xlsx.writeBuffer();
|
||||
return Buffer.from(buf);
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async importPeople(
|
||||
actor: Actor,
|
||||
file: Express.Multer.File,
|
||||
): Promise<ImportContactsResult> {
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('فایل اکسل انتخاب نشده است');
|
||||
}
|
||||
const rows = await this.readRows(file, PERSON_ALIASES);
|
||||
const result: ImportContactsResult = {
|
||||
created: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
};
|
||||
const rows = await readMappedRows(file, PERSON_ALIASES);
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]!;
|
||||
const rowNum = i + 2;
|
||||
const firstName = row.firstName?.trim() ?? '';
|
||||
const lastName = row.lastName?.trim() ?? '';
|
||||
if (!firstName || !lastName) {
|
||||
result.errors.push({
|
||||
row: rowNum,
|
||||
message: 'نام و نام خانوادگی الزامی است',
|
||||
});
|
||||
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'contact') {
|
||||
continue;
|
||||
}
|
||||
const names = splitDidarName(row.firstName ?? '', row.lastName ?? '', row.title);
|
||||
if (!names) {
|
||||
result.errors.push({ row: rowNum, message: 'نام مشتری الزامی است' });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -179,17 +319,24 @@ export class ContactsExcelService {
|
||||
companyId = await this.findOrCreateCompanyId(actor, companyName);
|
||||
}
|
||||
|
||||
const customFields: Record<string, string> = {};
|
||||
for (const key of CUSTOM_KEYS) {
|
||||
const value = row[key]?.trim();
|
||||
if (value) customFields[key] = value;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.people.create(actor, {
|
||||
firstName,
|
||||
lastName,
|
||||
firstName: names.firstName,
|
||||
lastName: names.lastName,
|
||||
email: row.email || undefined,
|
||||
phone: row.phone || undefined,
|
||||
phone: row.phone || row.phone2 || undefined,
|
||||
mobile: row.mobile || undefined,
|
||||
position: row.position || undefined,
|
||||
notes: row.notes || undefined,
|
||||
mood: parseMood(row.mood),
|
||||
mood: parseMood(row.mood) ?? parseMood(row.group),
|
||||
companyId,
|
||||
customFields,
|
||||
});
|
||||
result.created += 1;
|
||||
} catch (e) {
|
||||
@@ -211,15 +358,8 @@ export class ContactsExcelService {
|
||||
actor: Actor,
|
||||
file: Express.Multer.File,
|
||||
): Promise<ImportContactsResult> {
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('فایل اکسل انتخاب نشده است');
|
||||
}
|
||||
const rows = await this.readRows(file, COMPANY_ALIASES);
|
||||
const result: ImportContactsResult = {
|
||||
created: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
};
|
||||
const rows = await readMappedRows(file, COMPANY_ALIASES);
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]!;
|
||||
@@ -229,7 +369,6 @@ export class ContactsExcelService {
|
||||
result.errors.push({ row: rowNum, message: 'نام شرکت الزامی است' });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.companies.create(actor, {
|
||||
name,
|
||||
@@ -256,10 +395,7 @@ export class ContactsExcelService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private async findOrCreateCompanyId(
|
||||
actor: Actor,
|
||||
name: string,
|
||||
): Promise<string> {
|
||||
private async findOrCreateCompanyId(actor: Actor, name: string): Promise<string> {
|
||||
const existing = await this.prisma.company.findFirst({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
@@ -272,56 +408,4 @@ export class ContactsExcelService {
|
||||
const created = await this.companies.create(actor, { name });
|
||||
return created.id;
|
||||
}
|
||||
|
||||
private async readRows(
|
||||
file: Express.Multer.File,
|
||||
aliases: Record<string, string>,
|
||||
): Promise<Record<string, string>[]> {
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('فایل اکسل خالی است');
|
||||
}
|
||||
|
||||
const wb = new ExcelJS.Workbook();
|
||||
// exceljs accepts Node Buffer
|
||||
await wb.xlsx.load(Uint8Array.from(file.buffer) as unknown as ExcelJS.Buffer);
|
||||
const sheet = wb.worksheets[0];
|
||||
if (!sheet) throw new BadRequestException('شیت اکسل پیدا نشد');
|
||||
|
||||
const headerRow = sheet.getRow(1);
|
||||
const map = new Map<number, string>();
|
||||
headerRow.eachCell((cell, col) => {
|
||||
const raw = cellStr(cell.value);
|
||||
if (!raw) return;
|
||||
const key =
|
||||
aliases[normalizeHeader(raw)] ??
|
||||
aliases[raw.trim()] ??
|
||||
aliases[normalizeHeader(raw.replace(//g, ''))];
|
||||
if (key) map.set(col, key);
|
||||
});
|
||||
|
||||
if (!map.size) {
|
||||
throw new BadRequestException(
|
||||
'ستونهای فایل شناسایی نشد. از قالب خروجی همین سیستم استفاده کنید.',
|
||||
);
|
||||
}
|
||||
|
||||
const rows: Record<string, string>[] = [];
|
||||
sheet.eachRow((row, rowNumber) => {
|
||||
if (rowNumber === 1) return;
|
||||
const item: Record<string, string> = {};
|
||||
let any = false;
|
||||
for (const [col, key] of map) {
|
||||
const value = cellStr(row.getCell(col).value);
|
||||
if (value) any = true;
|
||||
item[key] = value;
|
||||
}
|
||||
if (any) rows.push(item);
|
||||
});
|
||||
|
||||
if (!rows.length) {
|
||||
throw new BadRequestException('هیچ ردیف دادهای در فایل نیست');
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user