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 organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String name String
/// کد داخلی شرکت (مثل «کد شرکت» در دیدار)
code String?
/// منبع ورود: manual | didar | tahesab | excel | other
importSource String @default("manual")
/// شناسه در مبدأ (مثلاً کد دیدار شرکت)
sourceRef String?
nationalId String? nationalId String?
economicCode String?
registrationNumber String?
phone String? phone String?
otherPhones String?
fax String?
email String? email String?
otherEmails String?
website String? website String?
province String?
city String?
postalCode String?
address String? address String?
groupName String?
notes String? notes String?
registeredAt DateTime?
tags String[] @default([]) tags String[] @default([])
customFields Json @default("{}") customFields Json @default("{}")
people Person[] people Person[]
@@ -423,6 +439,8 @@ model Company {
@@index([organizationId, name]) @@index([organizationId, name])
@@index([organizationId, email]) @@index([organizationId, email])
@@index([organizationId, phone]) @@index([organizationId, phone])
@@index([organizationId, sourceRef])
@@index([organizationId, code])
@@index([ownerId]) @@index([ownerId])
} }
@@ -24,6 +24,13 @@ import {
resolveOwnerId, resolveOwnerId,
} from '../common/record-scope'; } from '../common/record-scope';
import { normalizeTags } from '../common/tags'; 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 = { const companyInclude = {
_count: { select: { people: true } }, _count: { select: { people: true } },
@@ -33,12 +40,25 @@ const companyInclude = {
type CompanyRow = { type CompanyRow = {
id: string; id: string;
name: string; name: string;
code: string | null;
importSource: string;
sourceRef: string | null;
nationalId: string | null; nationalId: string | null;
economicCode: string | null;
registrationNumber: string | null;
phone: string | null; phone: string | null;
otherPhones: string | null;
fax: string | null;
email: string | null; email: string | null;
otherEmails: string | null;
website: string | null; website: string | null;
province: string | null;
city: string | null;
postalCode: string | null;
address: string | null; address: string | null;
groupName: string | null;
notes: string | null; notes: string | null;
registeredAt: Date | null;
tags: string[]; tags: string[];
customFields: unknown; customFields: unknown;
createdAt: Date; createdAt: Date;
@@ -67,6 +87,9 @@ export class CompaniesService {
{ email: { contains: q.trim(), mode: 'insensitive' } }, { email: { contains: q.trim(), mode: 'insensitive' } },
{ phone: { contains: q.trim() } }, { phone: { contains: q.trim() } },
{ nationalId: { 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 = { const data: Prisma.CompanyUncheckedCreateInput = {
organizationId: actor.organizationId, organizationId: actor.organizationId,
name: dto.name.trim(), name: dto.name.trim(),
code: emptyToNull(dto.code),
importSource: normalizePersonImportSource(dto.importSource),
sourceRef: emptyToNull(dto.sourceRef),
nationalId: emptyToNull(dto.nationalId), nationalId: emptyToNull(dto.nationalId),
economicCode: emptyToNull(dto.economicCode),
registrationNumber: emptyToNull(dto.registrationNumber),
phone: normalizePhone(dto.phone), phone: normalizePhone(dto.phone),
otherPhones: emptyToNull(dto.otherPhones),
fax: emptyToNull(dto.fax),
email: normalizeEmail(dto.email), email: normalizeEmail(dto.email),
otherEmails: emptyToNull(dto.otherEmails),
website: emptyToNull(dto.website), website: emptyToNull(dto.website),
province: emptyToNull(dto.province),
city: emptyToNull(dto.city),
postalCode: emptyToNull(dto.postalCode),
address: emptyToNull(dto.address), address: emptyToNull(dto.address),
groupName: emptyToNull(dto.groupName),
notes: emptyToNull(dto.notes), notes: emptyToNull(dto.notes),
registeredAt: parseDateOrNull(dto.registeredAt),
tags: normalizeTags(dto.tags), tags: normalizeTags(dto.tags),
ownerId: resolveOwnerId(actor, dto.ownerId), ownerId: resolveOwnerId(actor, dto.ownerId),
customFields: toJsonCustomFields(dto.customFields), customFields: toJsonCustomFields(dto.customFields),
@@ -131,12 +167,29 @@ export class CompaniesService {
await this.ensureExists(actor.organizationId, id, actor); await this.ensureExists(actor.organizationId, id, actor);
const data: Prisma.CompanyUncheckedUpdateInput = {}; const data: Prisma.CompanyUncheckedUpdateInput = {};
if (dto.name !== undefined) data.name = dto.name.trim(); 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 ('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 ('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 ('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 ('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 ('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 ('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 ('tags' in dto) data.tags = normalizeTags(dto.tags);
if ('customFields' in dto) { if ('customFields' in dto) {
data.customFields = toJsonCustomFields(dto.customFields); data.customFields = toJsonCustomFields(dto.customFields);
@@ -226,12 +279,25 @@ export class CompaniesService {
return { return {
id: row.id, id: row.id,
name: row.name, name: row.name,
code: row.code,
importSource: row.importSource || 'manual',
sourceRef: row.sourceRef,
nationalId: row.nationalId, nationalId: row.nationalId,
economicCode: row.economicCode,
registrationNumber: row.registrationNumber,
phone: row.phone, phone: row.phone,
otherPhones: row.otherPhones,
fax: row.fax,
email: row.email, email: row.email,
otherEmails: row.otherEmails,
website: row.website, website: row.website,
province: row.province,
city: row.city,
postalCode: row.postalCode,
address: row.address, address: row.address,
groupName: row.groupName,
notes: row.notes, notes: row.notes,
registeredAt: row.registeredAt ? row.registeredAt.toISOString() : null,
tags: row.tags ?? [], tags: row.tags ?? [],
customFields: asCustomFields(row.customFields), customFields: asCustomFields(row.customFields),
peopleCount: row._count.people, peopleCount: row._count.people,
+265 -39
View File
@@ -167,34 +167,116 @@ const PERSON_FIELDS = [
]; ];
const COMPANY_ALIASES: Record<string, string> = { const COMPANY_ALIASES: Record<string, string> = {
type: '_type',
_type: '_type',
name: 'name', name: 'name',
نام: 'name', نام: 'name',
نامشرکت: 'name', نامشرکت: 'name',
کددیدارشرکت: 'didarId',
didarid: 'didarId',
کدشرکت: 'code',
code: 'code',
استانفیلدپیشفرض: 'province',
استان: 'province',
province: 'province',
شهرستان: 'city',
شهر: 'city',
city: 'city',
nationalid: 'nationalId', nationalid: 'nationalId',
شناسهملی: 'nationalId', شناسهملی: 'nationalId',
شمارهاقتصادی: 'economicCode',
کداقتصادی: 'economicCode',
شمارهثبت: 'registrationNumber',
phone: 'phone', phone: 'phone',
تلفن: 'phone', تلفن: 'phone',
تلفنشرکت: 'phone',
تلفنثابتشرکت: 'phone', تلفنثابتشرکت: 'phone',
تلفنهایدیگرشرکت: 'otherPhones',
شمارهفکس: 'fax',
فکس: 'fax',
fax: 'fax',
email: 'email', email: 'email',
ایمیل: 'email', ایمیل: 'email',
ایمیلشرکت: 'email',
ایمیلهایدیگرشرکت: 'otherEmails',
website: 'website', website: 'website',
وبسایت: 'website', وبسایت: 'website',
سایتهایشرکت: 'website',
کدپستی: 'postalCode',
postalcode: 'postalCode',
address: 'address', address: 'address',
آدرس: 'address', آدرس: 'address',
notes: 'notes', notes: 'notes',
یادداشت: 'notes', یادداشت: 'notes',
توضیحاتشرکت: 'notes',
مسئولشرکت: 'ownerName',
مسئول: 'ownerName',
owner: 'ownerName',
مجوزمشاهدهشرکت: 'visibility',
تاریخثبتشرکت: 'registeredAt',
تاریخثبت: 'registeredAt',
گروهبندیشرکت: 'groupName',
گروه: 'groupName',
}; };
const COMPANY_FIELDS = [ const COMPANY_FIELDS = [
{ key: 'name', label: 'نام شرکت', required: true }, { 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: 'nationalId', label: 'شناسه ملی' },
{ key: 'phone', label: 'تلفن' }, { key: 'fax', label: 'شماره فکس' },
{ key: 'email', label: 'ایمیل' }, { key: 'postalCode', label: 'کد پستی' },
{ key: 'website', label: 'وب‌سایت' }, { key: 'notes', label: 'توضیحات شرکت' },
{ key: 'ownerName', label: 'مسئول شرکت' },
{ key: 'visibility', label: 'مجوز مشاهده شرکت' },
{ key: 'registeredAt', label: 'تاریخ ثبت شرکت' },
{ key: 'groupName', label: 'گروه بندی شرکت' },
{ key: 'otherPhones', label: 'تلفن های دیگر شرکت' },
{ key: 'address', 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 = [ const CONTACT_COLUMNS = [
{ header: '_type', key: '_type', width: 12 }, { header: '_type', key: '_type', width: 12 },
{ header: 'کد دیدار مشتری', key: 'didarId', width: 16 }, { header: 'کد دیدار مشتری', key: 'didarId', width: 16 },
@@ -342,25 +424,37 @@ export class ContactsExcelService {
async exportCompanies(actor: Actor, q?: string): Promise<Buffer> { async exportCompanies(actor: Actor, q?: string): Promise<Buffer> {
const { items } = await this.companies.list(actor, q, 1, 5000); const { items } = await this.companies.list(actor, q, 1, 5000);
return writeXlsx( return writeXlsx(
'شرکت‌ها', 'Company',
[ COMPANY_COLUMNS,
{ header: 'نام شرکت', key: 'name', width: 28 }, items.map((c) => {
{ header: 'شناسه ملی', key: 'nationalId', width: 16 }, const extra = (c.customFields ?? {}) as Record<string, string>;
{ header: 'تلفن', key: 'phone', width: 16 }, return {
{ header: 'ایمیل', key: 'email', width: 28 }, _type: 'Company',
{ header: 'وب‌سایت', key: 'website', width: 24 }, didarId: c.importSource === 'didar' && c.sourceRef ? c.sourceRef : '',
{ header: 'آدرس', key: 'address', width: 32 }, code: c.code ?? '',
{ header: 'یادداشت', key: 'notes', width: 32 }, name: c.name,
], province: c.province ?? '',
items.map((c) => ({ city: c.city ?? '',
name: c.name, phone: c.phone ?? '',
nationalId: c.nationalId ?? '', email: c.email ?? '',
phone: c.phone ?? '', economicCode: c.economicCode ?? '',
email: c.email ?? '', registrationNumber: c.registrationNumber ?? '',
website: c.website ?? '', nationalId: c.nationalId ?? '',
address: c.address ?? '', fax: c.fax ?? '',
notes: c.notes ?? '', 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; let companyId: string | null = null;
const companyName = row.companyName?.trim(); const companyName = row.companyName?.trim();
if (companyName) { if (companyName) {
companyId = await this.findOrCreateCompanyId(actor, companyName); companyId = await this.findOrCreateCompanyId(
actor,
companyName,
row.companyDidarId,
row.companyPhone,
);
} }
const ownerName = row.ownerName?.trim() ?? ''; const ownerName = row.ownerName?.trim() ?? '';
@@ -527,26 +626,91 @@ export class ContactsExcelService {
readMappedRows(file!, COMPANY_ALIASES, parseExcelMapping(mappingRaw)), readMappedRows(file!, COMPANY_ALIASES, parseExcelMapping(mappingRaw)),
}); });
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] }; const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
const ownerCache = new Map<string, string | null>();
for (let i = 0; i < ctx.slice.length; i++) { for (let i = 0; i < ctx.slice.length; i++) {
const row = ctx.slice[i]!; const row = ctx.slice[i]!;
const rowNum = ctx.offset + i + 2; const rowNum = ctx.offset + i + 2;
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'company') {
continue;
}
const name = row.name?.trim() ?? ''; const name = row.name?.trim() ?? '';
if (!name) { if (!name) {
result.errors.push({ row: rowNum, message: 'نام شرکت الزامی است' }); result.errors.push({ row: rowNum, message: 'نام شرکت الزامی است' });
continue; 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 { try {
await this.companies.create(actor, { const existing = await this.findImportedCompany(actor, didarId, name);
name, if (existing) {
nationalId: row.nationalId || undefined, const merged: Record<string, unknown> = {};
phone: row.phone || undefined, for (const [k, v] of Object.entries(payload)) {
email: row.email || undefined, if (k === 'customFields') continue;
website: row.website || undefined, if (v != null && v !== '') merged[k] = v;
address: row.address || undefined, }
notes: row.notes || undefined, merged.customFields = {
}); ...((existing.customFields ?? {}) as Record<string, unknown>),
result.created += 1; ...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) { } catch (e) {
if (e instanceof ConflictException) { if (e instanceof ConflictException) {
result.skipped += 1; result.skipped += 1;
@@ -562,18 +726,80 @@ export class ContactsExcelService {
return { ...result, ...chunkImportMeta(ctx) }; 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({ const existing = await this.prisma.company.findFirst({
where: { where: {
organizationId: actor.organizationId, organizationId: actor.organizationId,
name: { equals: name, mode: 'insensitive' }, name: { equals: name, mode: 'insensitive' },
...ownerWhere(actor), ...ownerWhere(actor),
}, },
select: { id: true }, select: { id: true, sourceRef: true },
}); });
if (existing) return existing.id; if (existing) {
const created = await this.companies.create(actor, { name }); if (ref && !existing.sourceRef) {
return created.id; 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) { private async findUserId(organizationId: string, name?: string) {
+69 -2
View File
@@ -58,7 +58,74 @@ export class ListQueryDto {
importSource?: string; 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() @IsString()
@MinLength(2) @MinLength(2)
@MaxLength(160) @MaxLength(160)
@@ -109,7 +176,7 @@ export class CreateCompanyDto {
ownerId?: string | null; ownerId?: string | null;
} }
export class UpdateCompanyDto { export class UpdateCompanyDto extends CompanyExtraFieldsDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MinLength(2) @MinLength(2)
+116 -39
View File
@@ -6,6 +6,8 @@ import { api } from '@/lib/api';
import type { CompanyDto, CreateCompanyRequest } from '@mento/shared'; import type { CompanyDto, CreateCompanyRequest } from '@mento/shared';
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'; import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import EntityTimeline from '@/components/EntityTimeline.vue'; import EntityTimeline from '@/components/EntityTimeline.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { formatLocalDateInput, parseLocalInput } from '@/lib/jalali';
import { import {
UiButton, UiButton,
UiCard, UiCard,
@@ -39,11 +41,24 @@ const saving = ref(false);
const error = ref(''); const error = ref('');
const name = ref(''); const name = ref('');
const code = ref('');
const sourceRef = ref('');
const importSource = ref('manual');
const nationalId = ref(''); const nationalId = ref('');
const economicCode = ref('');
const registrationNumber = ref('');
const phone = ref(''); const phone = ref('');
const otherPhones = ref('');
const fax = ref('');
const email = ref(''); const email = ref('');
const otherEmails = ref('');
const website = ref(''); const website = ref('');
const province = ref('');
const city = ref('');
const postalCode = ref('');
const address = ref(''); const address = ref('');
const groupName = ref('');
const registeredAt = ref('');
const notes = ref(''); const notes = ref('');
const tagsRaw = ref(''); const tagsRaw = ref('');
const customFields = ref<Record<string, unknown>>({}); const customFields = ref<Record<string, unknown>>({});
@@ -61,11 +76,24 @@ function parseTags(raw: string): string[] {
function fill(c: CompanyDto) { function fill(c: CompanyDto) {
name.value = c.name; name.value = c.name;
code.value = c.code ?? '';
sourceRef.value = c.sourceRef ?? '';
importSource.value = c.importSource || 'manual';
nationalId.value = c.nationalId ?? ''; nationalId.value = c.nationalId ?? '';
economicCode.value = c.economicCode ?? '';
registrationNumber.value = c.registrationNumber ?? '';
phone.value = c.phone ?? ''; phone.value = c.phone ?? '';
otherPhones.value = c.otherPhones ?? '';
fax.value = c.fax ?? '';
email.value = c.email ?? ''; email.value = c.email ?? '';
otherEmails.value = c.otherEmails ?? '';
website.value = c.website ?? ''; website.value = c.website ?? '';
province.value = c.province ?? '';
city.value = c.city ?? '';
postalCode.value = c.postalCode ?? '';
address.value = c.address ?? ''; address.value = c.address ?? '';
groupName.value = c.groupName ?? '';
registeredAt.value = c.registeredAt ? formatLocalDateInput(new Date(c.registeredAt)) : '';
notes.value = c.notes ?? ''; notes.value = c.notes ?? '';
tagsRaw.value = (c.tags ?? []).join('، '); tagsRaw.value = (c.tags ?? []).join('، ');
customFields.value = { ...(c.customFields ?? {}) }; customFields.value = { ...(c.customFields ?? {}) };
@@ -88,11 +116,24 @@ async function load() {
function payload(): CreateCompanyRequest { function payload(): CreateCompanyRequest {
return { return {
name: name.value, name: name.value,
code: code.value || null,
sourceRef: sourceRef.value || null,
...(sourceRef.value && importSource.value === 'manual' ? { importSource: 'didar' } : {}),
nationalId: nationalId.value || undefined, nationalId: nationalId.value || undefined,
economicCode: economicCode.value || null,
registrationNumber: registrationNumber.value || null,
phone: phone.value || undefined, phone: phone.value || undefined,
otherPhones: otherPhones.value || null,
fax: fax.value || null,
email: email.value || undefined, email: email.value || undefined,
otherEmails: otherEmails.value || null,
website: website.value || undefined, website: website.value || undefined,
province: province.value || null,
city: city.value || null,
postalCode: postalCode.value || null,
address: address.value || undefined, address: address.value || undefined,
groupName: groupName.value || null,
registeredAt: parseLocalInput(registeredAt.value)?.toISOString() ?? null,
notes: notes.value || undefined, notes: notes.value || undefined,
tags: parseTags(tagsRaw.value), tags: parseTags(tagsRaw.value),
customFields: customFields.value, customFields: customFields.value,
@@ -204,50 +245,20 @@ onMounted(load);
/> />
</UiField> </UiField>
<UiField label="شناسه ملی"> <UiField label="کد شرکت">
<UiInput <UiInput id="code" v-model="code" placeholder="کد داخلی" dir="ltr" />
id="nationalId"
v-model="nationalId"
placeholder="شناسه ملی ۱۱ رقمی"
dir="ltr"
/>
</UiField> </UiField>
<UiField label="شماره تلفن شرکت"> <UiField label="کد دیدار شرکت">
<UiInput <UiInput id="sourceRef" v-model="sourceRef" placeholder="مثال: 5277" dir="ltr" />
id="phone"
v-model="phone"
type="tel"
placeholder="۰۲۱..."
dir="ltr"
/>
</UiField> </UiField>
<UiField label="پست الکترونیکی (ایمیل)"> <UiField label="گروه بندی شرکت">
<UiInput <UiInput id="groupName" v-model="groupName" placeholder="مثال: همکار، عمده‌فروش" />
id="email"
v-model="email"
type="email"
placeholder="info@company.ir"
dir="ltr"
/>
</UiField> </UiField>
<UiField label="وب‌سایت"> <UiField label="تاریخ ثبت شرکت">
<UiInput <JalaliDateInput id="registeredAt" v-model="registeredAt" />
id="website"
v-model="website"
placeholder="https://..."
dir="ltr"
/>
</UiField>
<UiField label="آدرس پستی" class="col-span-2">
<UiInput
id="address"
v-model="address"
placeholder="نشانی، طبقه، پلاک..."
/>
</UiField> </UiField>
<UiField label="برچسب‌ها" class="col-span-2"> <UiField label="برچسب‌ها" class="col-span-2">
@@ -258,7 +269,7 @@ onMounted(load);
/> />
</UiField> </UiField>
<UiField label="یادداشت و توضیحات تکمیلی" class="col-span-2"> <UiField label="توضیحات شرکت" class="col-span-2">
<UiTextarea <UiTextarea
id="notes" id="notes"
v-model="notes" v-model="notes"
@@ -269,6 +280,72 @@ onMounted(load);
</div> </div>
</UiCard> </UiCard>
<UiCard title="اطلاعات تماس و آدرس" class="section-card">
<div class="crm-form-grid-2">
<UiField label="تلفن شرکت">
<UiInput id="phone" v-model="phone" type="tel" placeholder="۰۲۱..." dir="ltr" />
</UiField>
<UiField label="تلفن های دیگر شرکت">
<UiInput id="otherPhones" v-model="otherPhones" placeholder="با کاما جدا کنید" dir="ltr" />
</UiField>
<UiField label="ایمیل شرکت">
<UiInput
id="email"
v-model="email"
type="email"
placeholder="info@company.ir"
dir="ltr"
/>
</UiField>
<UiField label="ایمیل های دیگر شرکت">
<UiInput id="otherEmails" v-model="otherEmails" placeholder="با کاما جدا کنید" dir="ltr" />
</UiField>
<UiField label="شماره فکس">
<UiInput id="fax" v-model="fax" type="tel" dir="ltr" />
</UiField>
<UiField label="سایت های شرکت">
<UiInput id="website" v-model="website" placeholder="https://..." dir="ltr" />
</UiField>
<UiField label="استان">
<UiInput id="province" v-model="province" placeholder="مثال: تهران" />
</UiField>
<UiField label="شهرستان">
<UiInput id="city" v-model="city" placeholder="مثال: تهران" />
</UiField>
<UiField label="کد پستی">
<UiInput id="postalCode" v-model="postalCode" dir="ltr" />
</UiField>
<UiField label="آدرس" class="col-span-2">
<UiInput id="address" v-model="address" placeholder="نشانی، طبقه، پلاک..." />
</UiField>
</div>
</UiCard>
<UiCard title="اطلاعات حقوقی و ثبتی" class="section-card">
<div class="crm-form-grid-2">
<UiField label="شناسه ملی">
<UiInput id="nationalId" v-model="nationalId" placeholder="شناسه ملی ۱۱ رقمی" dir="ltr" />
</UiField>
<UiField label="شماره اقتصادی">
<UiInput id="economicCode" v-model="economicCode" dir="ltr" />
</UiField>
<UiField label="شماره ثبت">
<UiInput id="registrationNumber" v-model="registrationNumber" dir="ltr" />
</UiField>
</div>
</UiCard>
<CustomFieldsSection entity-type="company" v-model="customFields" /> <CustomFieldsSection entity-type="company" v-model="customFields" />
<EntityTimeline v-if="!isNew && id" entity-type="company" :entity-id="id" /> <EntityTimeline v-if="!isNew && id" entity-type="company" :entity-id="id" />
+35 -5
View File
@@ -733,7 +733,17 @@ onMounted(load);
<UiIcon name="mail" :size="13" /> <UiIcon name="mail" :size="13" />
<span>{{ c.email }}</span> <span>{{ c.email }}</span>
</p> </p>
<p class="role">افراد: {{ toFaDigits(c.peopleCount) }}</p> <p v-if="c.sourceRef || c.code" class="line">
<span>
{{ c.sourceRef ? `کد دیدار: ${toFaDigits(c.sourceRef)}` : '' }}
{{ c.sourceRef && c.code ? ' • ' : '' }}
{{ c.code ? `کد: ${toFaDigits(c.code)}` : '' }}
</span>
</p>
<p class="role">
{{ [c.city, c.owner?.fullName].filter(Boolean).join(' • ') }}
{{ c.city || c.owner ? ' • ' : '' }}افراد: {{ toFaDigits(c.peopleCount) }}
</p>
</div> </div>
</article> </article>
</div> </div>
@@ -743,8 +753,18 @@ onMounted(load);
<tr> <tr>
<th class="num-col">#</th> <th class="num-col">#</th>
<th>نام شرکت</th> <th>نام شرکت</th>
<th>تلفن</th> <th>کد دیدار شرکت</th>
<th>ایمیل</th> <th>کد شرکت</th>
<th>منبع</th>
<th>گروه بندی</th>
<th>استان</th>
<th>شهرستان</th>
<th>تلفن شرکت</th>
<th>ایمیل شرکت</th>
<th>شناسه ملی</th>
<th>شماره اقتصادی</th>
<th>مسئول شرکت</th>
<th>تاریخ ثبت</th>
<th>افراد</th> <th>افراد</th>
</tr> </tr>
</thead> </thead>
@@ -756,8 +776,18 @@ onMounted(load);
{{ c.name }} {{ c.name }}
</RouterLink> </RouterLink>
</td> </td>
<td>{{ c.phone || '—' }}</td> <td dir="ltr">{{ c.sourceRef ? toFaDigits(c.sourceRef) : '—' }}</td>
<td>{{ c.email || '—' }}</td> <td dir="ltr">{{ c.code ? toFaDigits(c.code) : '—' }}</td>
<td>{{ sourceLabel(c.importSource || 'manual') }}</td>
<td>{{ c.groupName || '—' }}</td>
<td>{{ c.province || '—' }}</td>
<td>{{ c.city || '—' }}</td>
<td dir="ltr">{{ c.phone || '—' }}</td>
<td><span class="mail">{{ c.email || '—' }}</span></td>
<td dir="ltr">{{ c.nationalId || '—' }}</td>
<td dir="ltr">{{ c.economicCode || '—' }}</td>
<td>{{ c.owner?.fullName || '—' }}</td>
<td>{{ formatDate(c.registeredAt || c.createdAt) }}</td>
<td>{{ toFaDigits(c.peopleCount) }}</td> <td>{{ toFaDigits(c.peopleCount) }}</td>
</tr> </tr>
</tbody> </tbody>
+28
View File
@@ -664,12 +664,27 @@ export interface CompanySummaryDto {
export interface CompanyDto { export interface CompanyDto {
id: string; id: string;
name: string; name: string;
/** کد داخلی شرکت */
code: string | null;
importSource: PersonImportSource | string;
/** کد شرکت در مبدأ (مثلاً کد دیدار) */
sourceRef: string | null;
nationalId: string | null; nationalId: string | null;
economicCode: string | null;
registrationNumber: string | null;
phone: string | null; phone: string | null;
otherPhones: string | null;
fax: string | null;
email: string | null; email: string | null;
otherEmails: string | null;
website: string | null; website: string | null;
province: string | null;
city: string | null;
postalCode: string | null;
address: string | null; address: string | null;
groupName: string | null;
notes: string | null; notes: string | null;
registeredAt: string | null;
tags: string[]; tags: string[];
customFields: Record<string, unknown>; customFields: Record<string, unknown>;
peopleCount: number; peopleCount: number;
@@ -680,12 +695,25 @@ export interface CompanyDto {
export interface CreateCompanyRequest { export interface CreateCompanyRequest {
name: string; name: string;
code?: string | null;
importSource?: PersonImportSource | string;
sourceRef?: string | null;
nationalId?: string; nationalId?: string;
economicCode?: string | null;
registrationNumber?: string | null;
phone?: string; phone?: string;
otherPhones?: string | null;
fax?: string | null;
email?: string; email?: string;
otherEmails?: string | null;
website?: string; website?: string;
province?: string | null;
city?: string | null;
postalCode?: string | null;
address?: string; address?: string;
groupName?: string | null;
notes?: string; notes?: string;
registeredAt?: string | null;
tags?: string[]; tags?: string[];
customFields?: Record<string, unknown>; customFields?: Record<string, unknown>;
ownerId?: string | null; ownerId?: string | null;