Files
CRM_Montzar/apps/api/src/pipelines/deals-excel.service.ts
T
2026-10-17 17:49:51 +03:30

836 lines
29 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { createHash } from 'crypto';
import {
DEAL_EXCEL_ALIASES,
DEAL_EXCEL_COLUMNS,
EXCEL_CUSTOM_FIELD_PREFIX,
compactExcelHeader,
type DealStatus,
type ImportContactsResult,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
import { dealAccessWhere, resolveOwnerId } from '../common/record-scope';
import { dealPriorWhere } from '../common/prior-archive';
import { asCustomFields, toJsonCustomFields } from '../common/custom-fields';
import { normalizePhone } from '../contacts/normalize';
import {
excelFieldsFromColumns,
formatJalaliDate,
parseAmount,
parseExcelMapping,
parseJalaliDateTime,
previewExcel,
readMappedRows,
stripHtml,
toAsciiDigits,
writeXlsx,
type ExcelPreviewResult,
} from '../common/excel';
import {
chunkImportMeta,
createExcelImportCacheSession,
destroyExcelImportSession,
getExcelImportSessionCaches,
resolveExcelImportChunk,
type ExcelImportLookupCaches,
} from '../common/excel-import-session';
import { DEFAULT_STAGES, PipelinesService } from './pipelines.service';
const MAX_ROWS_PER_REQUEST = 80;
function statusLabel(status: DealStatus) {
if (status === 'won') return 'موفق';
if (status === 'lost') return 'ناموفق';
return 'جاری';
}
function parseStatus(raw?: string): DealStatus {
const v = (raw ?? '').trim();
if (/ناموفق|از\s*دست|باخت|شکست|lost/i.test(v)) return 'lost';
if (/موفق|برنده|won/i.test(v)) return 'won';
return 'open';
}
function parseTags(raw?: string) {
return (raw ?? '')
.split(/[،,|#]+/)
.map((t) => t.trim())
.filter(Boolean)
.slice(0, 20);
}
/** کدهای دیدار: «0» و خالی یعنی بدون کد */
function cleanCode(raw?: string): string {
const v = toAsciiDigits(raw ?? '').trim();
if (!v || /^0+$/.test(v)) return '';
return v;
}
function cleanText(raw?: string, max = 2000): string | null {
const v = (raw ?? '').trim();
return v ? v.slice(0, max) : null;
}
function parseProbability(raw?: string): number | null {
const n = parseAmount(raw);
if (n == null) return null;
return Math.max(0, Math.min(100, Math.round(n)));
}
function parseDate(raw?: string): Date | null {
const v = (raw ?? '').trim();
if (!v) return null;
return parseJalaliDateTime(v);
}
function customFieldKey(label: string) {
return `didar_${createHash('md5').update(label).digest('hex').slice(0, 10)}`;
}
type PipelineCacheEntry = {
id: string;
name: string;
stages: Array<{ id: string; name: string; isWon: boolean; isLost: boolean }>;
};
type RowContext = {
pipelines: Map<string, PipelineCacheEntry>;
customFieldKeys: Map<string, string>;
touchedPipelines: Set<string>;
};
@Injectable()
export class DealsExcelService {
constructor(
private readonly prisma: PrismaService,
private readonly pipelines: PipelinesService,
) {}
async export(actor: Actor): Promise<Buffer> {
const [rows, defs] = await Promise.all([
this.prisma.deal.findMany({
where: {
organizationId: actor.organizationId,
AND: [
dealAccessWhere(actor),
dealPriorWhere(actor.priorLens, actor.priorCutoff),
],
},
include: {
person: {
select: {
id: true,
firstName: true,
lastName: true,
mobile: true,
sourceRef: true,
},
},
company: { select: { id: true, name: true, phone: true, sourceRef: true } },
owner: { select: { fullName: true } },
createdBy: { select: { fullName: true } },
pipeline: { select: { name: true } },
stage: { select: { name: true } },
lineItems: {
orderBy: { position: 'asc' },
include: { product: { select: { sku: true, category: true } } },
},
},
orderBy: [{ updatedAt: 'desc' }],
take: 10000,
}),
this.prisma.customFieldDefinition.findMany({
where: { organizationId: actor.organizationId, entityType: 'deal', isActive: true },
orderBy: { position: 'asc' },
}),
]);
const columns = [
...DEAL_EXCEL_COLUMNS,
...defs.map((d) => ({ header: d.label, key: `${EXCEL_CUSTOM_FIELD_PREFIX}${d.key}`, width: 16 })),
];
const out: Array<Record<string, string | number | null>> = [];
for (const row of rows) {
const extra = asCustomFields(row.customFields);
const base: Record<string, string | number | null> = {
_type: 'Deal',
didarId: row.sourceRef ?? row.id,
code: row.code ?? '',
title: row.title,
amount: row.amount == null ? 0 : Number(row.amount),
currency: row.currency,
notes: row.notes ?? '',
status: statusLabel(row.status),
closedAt: formatJalaliDate(row.closedAt),
createdAt: formatJalaliDate(row.createdAt),
expectedCloseAt: formatJalaliDate(row.expectedCloseAt),
ownerName: row.owner?.fullName ?? '',
creatorName: row.createdBy?.fullName ?? '',
probability: row.probability ?? '',
lostReason: row.lostReason ?? '',
lostNote: row.lostNote ?? '',
visibility: typeof extra.visibility === 'string' ? extra.visibility : 'همه افراد شرکت',
source: row.source ?? '',
referrer: row.referrer ?? '',
sourceDetail: row.sourceDetail ?? '',
tags: (row.tags ?? []).join('، '),
pipelineName: row.pipeline.name,
stageName: row.stage.name,
personFirstName: row.person?.firstName ?? '',
personName: row.person?.lastName ?? '',
personCode: row.person ? (row.person.sourceRef ?? row.person.id) : '',
personMobile: row.person?.mobile ?? '',
companyName: row.company?.name ?? '',
companyCode: row.company ? (row.company.sourceRef ?? row.company.id) : '',
companyPhone: row.company?.phone ?? '',
};
for (const d of defs) {
const v = extra[d.key];
base[`${EXCEL_CUSTOM_FIELD_PREFIX}${d.key}`] = v == null ? '' : String(v);
}
if (!row.lineItems.length) {
out.push(base);
continue;
}
for (const item of row.lineItems) {
const qty = Number(item.quantity);
const price = Number(item.unitPrice);
out.push({
...base,
productName: item.name,
productCode: item.product?.sku ?? '',
productCategory: item.product?.category ?? '',
productQty: qty,
productPrice: price,
productCurrency: item.currency,
productDiscount: 0,
productTotal: qty * price,
});
}
}
return writeXlsx('Deal', columns, out);
}
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
return previewExcel(file, DEAL_EXCEL_ALIASES, excelFieldsFromColumns(DEAL_EXCEL_COLUMNS, ['title']));
}
/** ایمپورت با آپلود فایل (فایل‌های استاندارد؛ خروجی دیدار از مسیر importRows می‌آید) */
async import(
actor: Actor,
file: Express.Multer.File | undefined,
mappingRaw?: string,
chunk?: { sessionId?: string; offset?: string | number; limit?: string | number },
): Promise<ImportContactsResult> {
const ctx = await resolveExcelImportChunk({
organizationId: actor.organizationId,
file,
sessionId: chunk?.sessionId,
offset: chunk?.offset,
limit: chunk?.limit,
loadRows: () =>
readMappedRows(file!, DEAL_EXCEL_ALIASES, parseExcelMapping(mappingRaw)),
});
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
const caches = getExcelImportSessionCaches(ctx.sessionId, actor.organizationId);
const affected = await this.processRows(actor, ctx.slice, ctx.offset, result, caches);
this.applyBoards(result, affected);
return { ...result, ...chunkImportMeta(ctx) };
}
/** ایمپورت تکه‌ای JSON از مرورگر — فایل اکسل روی سرور پارس نمی‌شود */
async importRows(
actor: Actor,
dto: { rows: Array<Record<string, string>>; offset: number; total: number; sessionId?: string },
): Promise<ImportContactsResult> {
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
const rows = Array.isArray(dto.rows) ? dto.rows : [];
if (!rows.length) {
result.errors.push({ row: 1, message: 'ردیف ارسال‌شده خالی است' });
return result;
}
if (rows.length > MAX_ROWS_PER_REQUEST) {
result.errors.push({ row: 1, message: `حداکثر ${MAX_ROWS_PER_REQUEST} ردیف در هر درخواست` });
return result;
}
const total = Math.max(0, Number(dto.total) || 0);
const offset = Math.max(0, Number(dto.offset) || 0);
let sessionId = (dto.sessionId ?? '').trim();
if (!sessionId) sessionId = createExcelImportCacheSession(actor.organizationId);
const caches = getExcelImportSessionCaches(sessionId, actor.organizationId);
const affected = await this.processRows(actor, rows, offset, result, caches);
this.applyBoards(result, affected);
const processed = offset + rows.length;
const hasMore = total > 0 ? processed < total : false;
if (!hasMore) destroyExcelImportSession(sessionId);
return {
...result,
sessionId: hasMore ? sessionId : undefined,
total,
processed,
hasMore,
nextOffset: hasMore ? processed : undefined,
};
}
private applyBoards(result: ImportContactsResult, affected: Map<string, string>) {
const entries = [...affected.entries()];
if (entries[0]) {
result.boardId = entries[0][0];
result.boards = entries.map(([, n]) => n);
}
}
private async processRows(
actor: Actor,
rows: Array<Record<string, string>>,
offset: number,
result: ImportContactsResult,
caches: ExcelImportLookupCaches,
): Promise<Map<string, string>> {
const affected = new Map<string, string>();
const ctx: RowContext = {
pipelines: new Map(),
customFieldKeys: await this.loadCustomFieldKeys(actor.organizationId),
touchedPipelines: new Set(),
};
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
const rowNum = offset + i + 2;
const type = (row._type || '').trim().toLowerCase();
if (type && type !== 'deal') continue;
try {
const outcome = await this.processRow(actor, row, rowNum, result, caches, ctx);
if (outcome) {
affected.set(outcome.pipelineId, outcome.pipelineName);
ctx.touchedPipelines.add(outcome.pipelineId);
}
} catch (e) {
result.errors.push({
row: rowNum,
message: e instanceof Error ? e.message : 'خطای ناشناخته',
});
}
}
await this.removeUnusedDefaultStages(actor.organizationId, ctx.touchedPipelines);
return affected;
}
private async processRow(
actor: Actor,
row: Record<string, string>,
rowNum: number,
result: ImportContactsResult,
caches: ExcelImportLookupCaches,
ctx: RowContext,
): Promise<{ pipelineId: string; pipelineName: string } | null> {
const orgId = actor.organizationId;
const didarId = cleanCode(row.didarId);
const personLabel = [row.personFirstName, row.personName]
.map((s) => (s ?? '').trim())
.filter(Boolean)
.join(' ');
let title = (row.title || '').trim();
if (title.length < 2) {
title = personLabel ? `معامله ${personLabel}` : didarId ? `معامله ${didarId}` : '';
}
if (title.length < 2) {
result.errors.push({ row: rowNum, message: 'عنوان معامله الزامی است' });
return null;
}
const lineItem = await this.resolveLineItem(actor, row);
const customFields = await this.collectCustomFields(orgId, row, ctx);
if (didarId) customFields.didarId = didarId;
const visibility = row.visibility?.trim();
if (visibility) customFields.visibility = visibility;
if (didarId) {
const existingId = await this.findDealByRef(orgId, didarId, caches);
if (existingId) {
const pipelineId = await this.mergeIntoExisting(existingId, customFields, lineItem);
if (pipelineId) ctx.touchedPipelines.add(pipelineId);
result.skipped += 1;
return null;
}
}
const status = parseStatus(row.status);
const { pipelineId, stageId, pipelineName } = await this.resolvePipelineAndStage(
orgId,
row.pipelineName,
row.stageName,
status,
ctx,
);
const personId = await this.findOrCreatePerson(actor, row, caches);
const companyId = await this.findOrCreateCompany(actor, row, caches);
if (!didarId) {
const duplicate = await this.prisma.deal.findFirst({
where: { organizationId: orgId, pipelineId, title, personId },
select: { id: true },
});
if (duplicate) {
result.skipped += 1;
return null;
}
}
const ownerName = row.ownerName?.trim() ?? '';
const matchedOwner = await this.findUserId(orgId, ownerName, caches);
const warnKey = `warn:${ownerName.toLowerCase()}`;
if (ownerName && !matchedOwner && !caches.user.has(warnKey)) {
caches.user.set(warnKey, null);
result.errors.push({
row: rowNum,
message: `مسئول «${ownerName}» در تیم پیدا نشد؛ معامله‌های او به کاربر ایمپورتر اختصاص داده شد`,
});
}
const ownerId = matchedOwner ?? resolveOwnerId(actor, null);
const createdById =
(await this.findUserId(orgId, row.creatorName?.trim() ?? '', caches)) ?? actor.userId;
const rawAmount = parseAmount(row.amount);
const amount =
rawAmount && rawAmount > 0
? rawAmount
: lineItem
? lineItem.quantity * lineItem.unitPrice
: null;
const createdAt = parseDate(row.createdAt);
const closedAt = parseDate(row.closedAt) ?? (status !== 'open' && createdAt ? createdAt : null);
const currency = (row.currency || 'IRR').trim() || 'IRR';
const created = await this.prisma.deal.create({
data: {
organizationId: orgId,
pipelineId,
stageId,
title: title.slice(0, 200),
code: cleanText(row.code, 80),
importSource: didarId ? 'didar' : 'excel',
sourceRef: didarId || null,
amount: amount == null ? null : new Prisma.Decimal(amount),
currency,
status,
probability: parseProbability(row.probability),
source: cleanText(row.source, 120),
sourceDetail: cleanText(row.sourceDetail, 200),
referrer: cleanText(row.referrer, 200),
lostReason: cleanText(row.lostReason, 400),
lostNote: cleanText(row.lostNote),
notes: cleanText(stripHtml(row.notes ?? '')),
tags: parseTags(row.tags),
customFields: toJsonCustomFields(customFields),
expectedCloseAt: parseDate(row.expectedCloseAt),
closedAt,
personId,
companyId,
ownerId,
createdById,
...(createdAt ? { createdAt } : {}),
...(lineItem
? {
lineItems: {
create: [{ ...lineItem, quantity: new Prisma.Decimal(lineItem.quantity), unitPrice: new Prisma.Decimal(lineItem.unitPrice), position: 0 }],
},
}
: {}),
},
select: { id: true },
});
if (didarId) caches.deal.set(`ref:${didarId}`, created.id);
result.created += 1;
return { pipelineId, pipelineName };
}
/** ردیف تکراری با همان کد دیدار: محصول جدید اضافه و فیلدهای سفارشی تکمیل می‌شود */
private async mergeIntoExisting(
dealId: string,
customFields: Record<string, string>,
lineItem: LineItemInput | null,
) {
const deal = await this.prisma.deal.findUnique({
where: { id: dealId },
select: {
pipelineId: true,
customFields: true,
currency: true,
lineItems: { select: { name: true, position: true } },
},
});
if (!deal) return null;
const merged = { ...asCustomFields(deal.customFields) };
let changed = false;
for (const [k, v] of Object.entries(customFields)) {
if (v && merged[k] == null) {
merged[k] = v;
changed = true;
}
}
if (changed) {
await this.prisma.deal.update({
where: { id: dealId },
data: { customFields: toJsonCustomFields(merged) },
});
}
if (lineItem && !deal.lineItems.some((li) => li.name === lineItem.name)) {
await this.prisma.dealLineItem.create({
data: {
dealId,
productId: lineItem.productId,
name: lineItem.name,
quantity: new Prisma.Decimal(lineItem.quantity),
unitPrice: new Prisma.Decimal(lineItem.unitPrice),
currency: lineItem.currency,
position: deal.lineItems.length,
},
});
const items = await this.prisma.dealLineItem.findMany({
where: { dealId },
select: { quantity: true, unitPrice: true },
});
const total = items.reduce((s, it) => s + Number(it.quantity) * Number(it.unitPrice), 0);
await this.prisma.deal.update({
where: { id: dealId },
data: { amount: new Prisma.Decimal(total) },
});
}
return deal.pipelineId;
}
/**
* مراحل پیش‌فرض سیستم (سرنخ، تماس اول، …) که خالی مانده‌اند از مسیرهای فروش واردشده حذف می‌شوند
* تا فقط مراحل خود فایل دیده شوند.
*/
private async removeUnusedDefaultStages(organizationId: string, pipelineIds: Set<string>) {
const defaultNames = new Set<string>(DEFAULT_STAGES.map((s) => s.name));
for (const pipelineId of pipelineIds) {
const stages = await this.prisma.pipelineStage.findMany({
where: { pipelineId, pipeline: { organizationId } },
orderBy: { position: 'asc' },
select: { id: true, name: true, _count: { select: { deals: true } } },
});
if (!stages.some((s) => !defaultNames.has(s.name))) continue;
const removable = stages.filter((s) => defaultNames.has(s.name) && s._count.deals === 0);
if (!removable.length) continue;
const removeIds = new Set(removable.map((s) => s.id));
const remaining = stages.filter((s) => !removeIds.has(s.id));
await this.prisma.$transaction([
this.prisma.pipelineStage.deleteMany({ where: { id: { in: [...removeIds] } } }),
...remaining.map((s, index) =>
this.prisma.pipelineStage.update({ where: { id: s.id }, data: { position: index } }),
),
]);
}
}
private async findDealByRef(
organizationId: string,
ref: string,
caches: ExcelImportLookupCaches,
): Promise<string | null> {
const key = `ref:${ref}`;
if (caches.deal.has(key)) return caches.deal.get(key) ?? null;
const row = await this.prisma.deal.findFirst({
where: { organizationId, sourceRef: ref },
select: { id: true },
});
caches.deal.set(key, row?.id ?? null);
return row?.id ?? null;
}
private async resolveLineItem(actor: Actor, row: Record<string, string>): Promise<LineItemInput | null> {
const name = row.productName?.trim();
if (!name) return null;
const orgId = actor.organizationId;
const sku = row.productCode?.trim() || null;
const quantity = parseAmount(row.productQty) || 1;
const price = parseAmount(row.productPrice);
const total = parseAmount(row.productTotal);
const unitPrice = total != null && total > 0 ? total / quantity : (price ?? 0);
const currency = (row.productCurrency || row.currency || 'IRR').trim() || 'IRR';
let product = sku
? await this.prisma.product.findFirst({
where: { organizationId: orgId, sku },
select: { id: true },
})
: null;
product ??= await this.prisma.product.findFirst({
where: { organizationId: orgId, name: { equals: name, mode: 'insensitive' } },
select: { id: true },
});
if (!product) {
product = await this.prisma.product.create({
data: {
organizationId: orgId,
name: name.slice(0, 200),
sku,
category: row.productCategory?.trim() || row.productGroup?.trim() || null,
unitPrice: price == null ? null : new Prisma.Decimal(price),
currency,
},
select: { id: true },
});
}
return { productId: product.id, name: name.slice(0, 200), quantity, unitPrice, currency };
}
private async loadCustomFieldKeys(organizationId: string) {
const defs = await this.prisma.customFieldDefinition.findMany({
where: { organizationId, entityType: 'deal' },
select: { key: true, label: true },
});
return new Map(defs.map((d) => [compactExcelHeader(d.label), d.key]));
}
/** ستون‌های ناشناخته (cf:عنوان) → فیلد سفارشی معامله؛ اگر تعریف نشده باشد ساخته می‌شود */
private async collectCustomFields(
organizationId: string,
row: Record<string, string>,
ctx: RowContext,
): Promise<Record<string, string>> {
const out: Record<string, string> = {};
for (const [rawKey, rawValue] of Object.entries(row)) {
if (!rawKey.startsWith(EXCEL_CUSTOM_FIELD_PREFIX)) continue;
const value = (rawValue ?? '').trim();
if (!value) continue;
const label = rawKey.slice(EXCEL_CUSTOM_FIELD_PREFIX.length).trim();
if (!label) continue;
const compact = compactExcelHeader(label);
let key = ctx.customFieldKeys.get(compact);
if (!key) {
key = customFieldKey(compact);
const maxPos = await this.prisma.customFieldDefinition.aggregate({
where: { organizationId, entityType: 'deal' },
_max: { position: true },
});
await this.prisma.customFieldDefinition.upsert({
where: { organizationId_entityType_key: { organizationId, entityType: 'deal', key } },
create: {
organizationId,
entityType: 'deal',
key,
label: label.slice(0, 120),
fieldType: 'text',
position: (maxPos._max.position ?? -1) + 1,
},
update: {},
});
ctx.customFieldKeys.set(compact, key);
}
out[key] = value.slice(0, 1000);
}
return out;
}
private async resolvePipelineAndStage(
organizationId: string,
pipelineName: string | undefined,
stageName: string | undefined,
status: DealStatus,
ctx: RowContext,
) {
const rawName = pipelineName?.trim();
const name = !rawName || rawName === 'کاریز فروش' ? 'مسیر فروش' : rawName;
const cacheKey = name.toLowerCase();
let pipeline = ctx.pipelines.get(cacheKey);
if (!pipeline) {
let row = await this.prisma.pipeline.findFirst({
where: { organizationId, name: { equals: name, mode: 'insensitive' } },
include: { stages: { orderBy: { position: 'asc' } } },
});
if (!row) {
const created = await this.pipelines.create(organizationId, name, {
withDefaultStages: false,
});
row = await this.prisma.pipeline.findFirstOrThrow({
where: { id: created.id },
include: { stages: { orderBy: { position: 'asc' } } },
});
}
pipeline = {
id: row.id,
name: row.name,
stages: row.stages.map((s) => ({ id: s.id, name: s.name, isWon: s.isWon, isLost: s.isLost })),
};
ctx.pipelines.set(cacheKey, pipeline);
}
const fallbackName =
status === 'won' ? 'موفق' : status === 'lost' ? 'ناموفق' : 'جاری';
const byStatusExisting =
status === 'won'
? pipeline.stages.find((s) => s.isWon)
: status === 'lost'
? pipeline.stages.find((s) => s.isLost)
: pipeline.stages.find((s) => !s.isWon && !s.isLost);
const wanted = stageName?.trim() || (byStatusExisting ? '' : fallbackName);
if (wanted) {
const existing = pipeline.stages.find(
(s) => s.name.trim().toLowerCase() === wanted.toLowerCase(),
);
if (existing) {
return { pipelineId: pipeline.id, stageId: existing.id, pipelineName: pipeline.name };
}
const added = await this.pipelines.createStage(organizationId, pipeline.id, wanted);
const stage = added.stages.find((s) => s.name === wanted) ?? added.stages.at(-1);
if (!stage) throw new Error('مسیر فروش مرحله‌ای ندارد');
pipeline.stages = added.stages.map((s) => ({
id: s.id,
name: s.name,
isWon: s.isWon,
isLost: s.isLost,
}));
return { pipelineId: pipeline.id, stageId: stage.id, pipelineName: pipeline.name };
}
const stage = byStatusExisting ?? pipeline.stages[0];
if (!stage) throw new Error('مسیر فروش مرحله‌ای ندارد');
return { pipelineId: pipeline.id, stageId: stage.id, pipelineName: pipeline.name };
}
/** شخص معامله: کد دیدار ← موبایل ← نام دقیق؛ در غیر این صورت ساخته می‌شود */
private async findOrCreatePerson(
actor: Actor,
row: Record<string, string>,
caches: ExcelImportLookupCaches,
): Promise<string | null> {
const orgId = actor.organizationId;
const code = cleanCode(row.personCode);
const mobile = normalizePhone(row.personMobile);
const mobileCore = mobile ? mobile.replace(/^\+98|^98|^0/, '') : '';
const validMobile = mobileCore.length >= 8 ? mobile : null;
const first = (row.personFirstName ?? '').trim();
const last = (row.personName ?? '').trim();
const fullName = [first, last].filter(Boolean).join(' ');
if (!code && !validMobile && !fullName) return null;
const cacheKey = `p:${code}|${validMobile ?? ''}|${fullName}`;
if (caches.person.has(cacheKey)) return caches.person.get(cacheKey) ?? null;
let found: { id: string } | null = null;
if (code) {
found = await this.prisma.person.findFirst({
where: {
organizationId: orgId,
OR: [{ sourceRef: code }, { customFields: { path: ['didarId'], equals: code } }],
},
select: { id: true },
});
}
if (!found && validMobile) {
found = await this.prisma.person.findFirst({
where: { organizationId: orgId, mobile: { contains: mobileCore } },
select: { id: true },
});
}
if (!found && fullName && !code && !validMobile) {
found = await this.prisma.person.findFirst({
where: {
organizationId: orgId,
OR: [
{ firstName: first, lastName: last },
{ firstName: '', lastName: fullName },
],
},
select: { id: true },
});
}
if (!found) {
found = await this.prisma.person.create({
data: {
organizationId: orgId,
firstName: first.slice(0, 120),
lastName: (last || (first ? '' : 'بی نام')).slice(0, 120),
mobile: validMobile,
importSource: code ? 'didar' : 'excel',
sourceRef: code || null,
customFields: toJsonCustomFields(code ? { didarId: code } : {}),
ownerId: resolveOwnerId(actor, null),
},
select: { id: true },
});
}
caches.person.set(cacheKey, found.id);
return found.id;
}
private async findOrCreateCompany(
actor: Actor,
row: Record<string, string>,
caches: ExcelImportLookupCaches,
): Promise<string | null> {
const orgId = actor.organizationId;
const name = (row.companyName ?? '').trim();
const code = cleanCode(row.companyCode);
if (!name) return null;
const cacheKey = `c:${code}|${name.toLowerCase()}`;
if (caches.company.has(cacheKey)) return caches.company.get(cacheKey) ?? null;
let found = code
? await this.prisma.company.findFirst({
where: { organizationId: orgId, sourceRef: code },
select: { id: true },
})
: null;
found ??= await this.prisma.company.findFirst({
where: { organizationId: orgId, name: { equals: name, mode: 'insensitive' } },
select: { id: true },
});
if (!found) {
found = await this.prisma.company.create({
data: {
organizationId: orgId,
name: name.slice(0, 200),
phone: row.companyPhone?.trim() || null,
importSource: code ? 'didar' : 'excel',
sourceRef: code || null,
ownerId: resolveOwnerId(actor, null),
},
select: { id: true },
});
}
caches.company.set(cacheKey, found.id);
return found.id;
}
private async findUserId(
organizationId: string,
name: string,
caches: ExcelImportLookupCaches,
): Promise<string | null> {
const n = name.trim();
if (!n) return null;
const key = n.toLowerCase();
if (caches.user.has(key)) return caches.user.get(key) ?? null;
const row = await this.prisma.user.findFirst({
where: {
fullName: { contains: n, mode: 'insensitive' },
memberships: { some: { organizationId } },
},
select: { id: true },
});
caches.user.set(key, row?.id ?? null);
return row?.id ?? null;
}
}
type LineItemInput = {
productId: string;
name: string;
quantity: number;
unitPrice: number;
currency: string;
};