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 { phoneIdentityKeys, storedPhoneNumber } 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; customFieldKeys: Map; touchedPipelines: Set; }; @Injectable() export class DealsExcelService { constructor( private readonly prisma: PrismaService, private readonly pipelines: PipelinesService, ) {} async export(actor: Actor): Promise { 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> = []; for (const row of rows) { const extra = asCustomFields(row.customFields); const base: Record = { _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 { 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 { 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>; offset: number; total: number; sessionId?: string }, ): Promise { 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) { 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>, offset: number, result: ImportContactsResult, caches: ExcelImportLookupCaches, ): Promise> { const affected = new Map(); 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, 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, 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) { const defaultNames = new Set(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 { 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): Promise { 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, ctx: RowContext, ): Promise> { const out: Record = {}; 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, caches: ExcelImportLookupCaches, ): Promise { const orgId = actor.organizationId; const code = cleanCode(row.personCode); const mobile = storedPhoneNumber(row.personMobile); const mobileKeys = phoneIdentityKeys(mobile); const validMobile = (mobile ?? '').replace(/\D/g, '').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, OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }], }, 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, caches: ExcelImportLookupCaches, ): Promise { 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 { 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; };