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
+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) {