313 lines
9.4 KiB
TypeScript
313 lines
9.4 KiB
TypeScript
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||
|
|
import ExcelJS from 'exceljs';
|
|||
|
|
import type { ImportContactsResult } from '@mento/shared';
|
|||
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|||
|
|
import type { Actor } from '../common/record-scope';
|
|||
|
|
import { ownerWhere } from '../common/record-scope';
|
|||
|
|
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 normalizeHeader(raw: string) {
|
|||
|
|
return raw.trim().toLowerCase().replace(/\s+/g, '');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const PERSON_ALIASES: Record<string, string> = {
|
|||
|
|
firstname: 'firstName',
|
|||
|
|
نام: 'firstName',
|
|||
|
|
lastname: 'lastName',
|
|||
|
|
نامخانوادگی: 'lastName',
|
|||
|
|
'نام خانوادگی': 'lastName',
|
|||
|
|
email: 'email',
|
|||
|
|
ایمیل: 'email',
|
|||
|
|
phone: 'phone',
|
|||
|
|
تلفن: 'phone',
|
|||
|
|
mobile: 'mobile',
|
|||
|
|
موبایل: 'mobile',
|
|||
|
|
position: 'position',
|
|||
|
|
سمت: 'position',
|
|||
|
|
company: 'companyName',
|
|||
|
|
companyname: 'companyName',
|
|||
|
|
شرکت: 'companyName',
|
|||
|
|
notes: 'notes',
|
|||
|
|
یادداشت: 'notes',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const COMPANY_ALIASES: Record<string, string> = {
|
|||
|
|
name: 'name',
|
|||
|
|
نام: 'name',
|
|||
|
|
نامشرکت: 'name',
|
|||
|
|
'نام شرکت': 'name',
|
|||
|
|
nationalid: 'nationalId',
|
|||
|
|
شناسهملی: 'nationalId',
|
|||
|
|
'شناسه ملی': 'nationalId',
|
|||
|
|
phone: 'phone',
|
|||
|
|
تلفن: 'phone',
|
|||
|
|
email: 'email',
|
|||
|
|
ایمیل: 'email',
|
|||
|
|
website: 'website',
|
|||
|
|
وبسایت: 'website',
|
|||
|
|
address: 'address',
|
|||
|
|
آدرس: 'address',
|
|||
|
|
notes: 'notes',
|
|||
|
|
یادداشت: 'notes',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
@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);
|
|||
|
|
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: '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 ?? '',
|
|||
|
|
notes: p.notes ?? '',
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
const buf = await wb.xlsx.writeBuffer();
|
|||
|
|
return Buffer.from(buf);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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({
|
|||
|
|
name: c.name,
|
|||
|
|
nationalId: c.nationalId ?? '',
|
|||
|
|
phone: c.phone ?? '',
|
|||
|
|
email: c.email ?? '',
|
|||
|
|
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: [],
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
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: 'نام و نام خانوادگی الزامی است',
|
|||
|
|
});
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let companyId: string | null = null;
|
|||
|
|
const companyName = row.companyName?.trim();
|
|||
|
|
if (companyName) {
|
|||
|
|
companyId = await this.findOrCreateCompanyId(actor, companyName);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await this.people.create(actor, {
|
|||
|
|
firstName,
|
|||
|
|
lastName,
|
|||
|
|
email: row.email || undefined,
|
|||
|
|
phone: row.phone || undefined,
|
|||
|
|
mobile: row.mobile || undefined,
|
|||
|
|
position: row.position || undefined,
|
|||
|
|
notes: row.notes || undefined,
|
|||
|
|
companyId,
|
|||
|
|
});
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async importCompanies(
|
|||
|
|
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: [],
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
for (let i = 0; i < rows.length; i++) {
|
|||
|
|
const row = rows[i]!;
|
|||
|
|
const rowNum = 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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 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;
|
|||
|
|
}
|
|||
|
|
}
|