Align companies with the Didar export: new fields, Didar-format Excel import/export, and people linked to companies by Didar code

This commit is contained in:
2026-10-06 12:25:20 +03:30
parent f88786ef61
commit f71f09c85d
8 changed files with 630 additions and 85 deletions
@@ -24,6 +24,13 @@ import {
resolveOwnerId,
} from '../common/record-scope';
import { normalizeTags } from '../common/tags';
import { normalizePersonImportSource } from './person-source';
function parseDateOrNull(raw?: string | null): Date | null {
if (!raw?.trim()) return null;
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? null : d;
}
const companyInclude = {
_count: { select: { people: true } },
@@ -33,12 +40,25 @@ const companyInclude = {
type CompanyRow = {
id: string;
name: string;
code: string | null;
importSource: string;
sourceRef: string | null;
nationalId: string | null;
economicCode: string | null;
registrationNumber: string | null;
phone: string | null;
otherPhones: string | null;
fax: string | null;
email: string | null;
otherEmails: string | null;
website: string | null;
province: string | null;
city: string | null;
postalCode: string | null;
address: string | null;
groupName: string | null;
notes: string | null;
registeredAt: Date | null;
tags: string[];
customFields: unknown;
createdAt: Date;
@@ -67,6 +87,9 @@ export class CompaniesService {
{ email: { contains: q.trim(), mode: 'insensitive' } },
{ phone: { contains: q.trim() } },
{ nationalId: { contains: q.trim() } },
{ code: { contains: q.trim() } },
{ sourceRef: { contains: q.trim() } },
{ city: { contains: q.trim(), mode: 'insensitive' } },
],
}
: {}),
@@ -104,12 +127,25 @@ export class CompaniesService {
const data: Prisma.CompanyUncheckedCreateInput = {
organizationId: actor.organizationId,
name: dto.name.trim(),
code: emptyToNull(dto.code),
importSource: normalizePersonImportSource(dto.importSource),
sourceRef: emptyToNull(dto.sourceRef),
nationalId: emptyToNull(dto.nationalId),
economicCode: emptyToNull(dto.economicCode),
registrationNumber: emptyToNull(dto.registrationNumber),
phone: normalizePhone(dto.phone),
otherPhones: emptyToNull(dto.otherPhones),
fax: emptyToNull(dto.fax),
email: normalizeEmail(dto.email),
otherEmails: emptyToNull(dto.otherEmails),
website: emptyToNull(dto.website),
province: emptyToNull(dto.province),
city: emptyToNull(dto.city),
postalCode: emptyToNull(dto.postalCode),
address: emptyToNull(dto.address),
groupName: emptyToNull(dto.groupName),
notes: emptyToNull(dto.notes),
registeredAt: parseDateOrNull(dto.registeredAt),
tags: normalizeTags(dto.tags),
ownerId: resolveOwnerId(actor, dto.ownerId),
customFields: toJsonCustomFields(dto.customFields),
@@ -131,12 +167,29 @@ export class CompaniesService {
await this.ensureExists(actor.organizationId, id, actor);
const data: Prisma.CompanyUncheckedUpdateInput = {};
if (dto.name !== undefined) data.name = dto.name.trim();
if ('code' in dto) data.code = emptyToNull(dto.code);
if (dto.importSource !== undefined) {
data.importSource = normalizePersonImportSource(dto.importSource);
}
if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef);
if ('nationalId' in dto) data.nationalId = emptyToNull(dto.nationalId);
if ('economicCode' in dto) data.economicCode = emptyToNull(dto.economicCode);
if ('registrationNumber' in dto) {
data.registrationNumber = emptyToNull(dto.registrationNumber);
}
if ('phone' in dto) data.phone = normalizePhone(dto.phone);
if ('otherPhones' in dto) data.otherPhones = emptyToNull(dto.otherPhones);
if ('fax' in dto) data.fax = emptyToNull(dto.fax);
if ('email' in dto) data.email = normalizeEmail(dto.email);
if ('otherEmails' in dto) data.otherEmails = emptyToNull(dto.otherEmails);
if ('website' in dto) data.website = emptyToNull(dto.website);
if ('province' in dto) data.province = emptyToNull(dto.province);
if ('city' in dto) data.city = emptyToNull(dto.city);
if ('postalCode' in dto) data.postalCode = emptyToNull(dto.postalCode);
if ('address' in dto) data.address = emptyToNull(dto.address);
if ('groupName' in dto) data.groupName = emptyToNull(dto.groupName);
if ('notes' in dto) data.notes = emptyToNull(dto.notes);
if ('registeredAt' in dto) data.registeredAt = parseDateOrNull(dto.registeredAt);
if ('tags' in dto) data.tags = normalizeTags(dto.tags);
if ('customFields' in dto) {
data.customFields = toJsonCustomFields(dto.customFields);
@@ -226,12 +279,25 @@ export class CompaniesService {
return {
id: row.id,
name: row.name,
code: row.code,
importSource: row.importSource || 'manual',
sourceRef: row.sourceRef,
nationalId: row.nationalId,
economicCode: row.economicCode,
registrationNumber: row.registrationNumber,
phone: row.phone,
otherPhones: row.otherPhones,
fax: row.fax,
email: row.email,
otherEmails: row.otherEmails,
website: row.website,
province: row.province,
city: row.city,
postalCode: row.postalCode,
address: row.address,
groupName: row.groupName,
notes: row.notes,
registeredAt: row.registeredAt ? row.registeredAt.toISOString() : null,
tags: row.tags ?? [],
customFields: asCustomFields(row.customFields),
peopleCount: row._count.people,
+265 -39
View File
@@ -167,34 +167,116 @@ const PERSON_FIELDS = [
];
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: 'phone', label: 'تلفن' },
{ key: 'email', label: 'ایمیل' },
{ key: 'website', 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: 'notes', 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 },
@@ -342,25 +424,37 @@ export class ContactsExcelService {
async exportCompanies(actor: Actor, q?: string): Promise<Buffer> {
const { items } = await this.companies.list(actor, q, 1, 5000);
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 ?? '',
})),
'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 ?? '',
};
}),
);
}
@@ -414,7 +508,12 @@ export class ContactsExcelService {
let companyId: string | null = null;
const companyName = row.companyName?.trim();
if (companyName) {
companyId = await this.findOrCreateCompanyId(actor, companyName);
companyId = await this.findOrCreateCompanyId(
actor,
companyName,
row.companyDidarId,
row.companyPhone,
);
}
const ownerName = row.ownerName?.trim() ?? '';
@@ -527,26 +626,91 @@ export class ContactsExcelService {
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 {
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;
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;
@@ -562,18 +726,80 @@ export class ContactsExcelService {
return { ...result, ...chunkImportMeta(ctx) };
}
private async findOrCreateCompanyId(actor: Actor, name: string): Promise<string> {
/** شرکت موجود برای ورود مجدد: اول با کد دیدار، بعد با نام (اگر کد مبدأ نداشته باشد). */
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 },
select: { id: true, sourceRef: true },
});
if (existing) return existing.id;
const created = await this.companies.create(actor, { name });
return created.id;
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) {
+69 -2
View File
@@ -58,7 +58,74 @@ export class ListQueryDto {
importSource?: string;
}
export class CreateCompanyDto {
class CompanyExtraFieldsDto {
@IsOptional()
@IsString()
@MaxLength(60)
code?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
importSource?: string;
@IsOptional()
@IsString()
@MaxLength(80)
sourceRef?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
economicCode?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
registrationNumber?: string | null;
@IsOptional()
@IsString()
@MaxLength(400)
otherPhones?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
fax?: string | null;
@IsOptional()
@IsString()
@MaxLength(400)
otherEmails?: string | null;
@IsOptional()
@IsString()
@MaxLength(80)
province?: string | null;
@IsOptional()
@IsString()
@MaxLength(80)
city?: string | null;
@IsOptional()
@IsString()
@MaxLength(20)
postalCode?: string | null;
@IsOptional()
@IsString()
@MaxLength(120)
groupName?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
registeredAt?: string | null;
}
export class CreateCompanyDto extends CompanyExtraFieldsDto {
@IsString()
@MinLength(2)
@MaxLength(160)
@@ -109,7 +176,7 @@ export class CreateCompanyDto {
ownerId?: string | null;
}
export class UpdateCompanyDto {
export class UpdateCompanyDto extends CompanyExtraFieldsDto {
@IsOptional()
@IsString()
@MinLength(2)