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
@@ -0,0 +1,33 @@
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "code" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "importSource" TEXT NOT NULL DEFAULT 'manual';
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "sourceRef" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "economicCode" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "registrationNumber" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "otherPhones" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "fax" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "otherEmails" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "province" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "city" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "postalCode" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "groupName" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "registeredAt" TIMESTAMP(3);
CREATE INDEX IF NOT EXISTS "Company_organizationId_sourceRef_idx" ON "Company"("organizationId", "sourceRef");
CREATE INDEX IF NOT EXISTS "Company_organizationId_code_idx" ON "Company"("organizationId", "code");
-- شرکت‌هایی که از ورود اشخاص دیدار ساخته شده‌اند: کد دیدار شرکت را از customFields اشخاص بازیابی کن
UPDATE "Company" c
SET "sourceRef" = sub.didar_code,
"importSource" = 'didar'
FROM (
SELECT DISTINCT ON (p."companyId")
p."companyId" AS company_id,
TRIM(p."customFields"->>'companyDidarId') AS didar_code
FROM "Person" p
WHERE p."companyId" IS NOT NULL
AND jsonb_typeof(p."customFields") = 'object'
AND COALESCE(TRIM(p."customFields"->>'companyDidarId'), '') <> ''
ORDER BY p."companyId", p."createdAt"
) sub
WHERE c."id" = sub.company_id
AND c."sourceRef" IS NULL;
+18
View File
@@ -398,12 +398,28 @@ model Company {
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
/// کد داخلی شرکت (مثل «کد شرکت» در دیدار)
code String?
/// منبع ورود: manual | didar | tahesab | excel | other
importSource String @default("manual")
/// شناسه در مبدأ (مثلاً کد دیدار شرکت)
sourceRef String?
nationalId String?
economicCode String?
registrationNumber String?
phone String?
otherPhones String?
fax String?
email String?
otherEmails String?
website String?
province String?
city String?
postalCode String?
address String?
groupName String?
notes String?
registeredAt DateTime?
tags String[] @default([])
customFields Json @default("{}")
people Person[]
@@ -423,6 +439,8 @@ model Company {
@@index([organizationId, name])
@@index([organizationId, email])
@@index([organizationId, phone])
@@index([organizationId, sourceRef])
@@index([organizationId, code])
@@index([ownerId])
}
@@ -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)