diff --git a/apps/api/prisma/migrations/20261006170000_deal_didar_fields/migration.sql b/apps/api/prisma/migrations/20261006170000_deal_didar_fields/migration.sql new file mode 100644 index 0000000..bfae96c --- /dev/null +++ b/apps/api/prisma/migrations/20261006170000_deal_didar_fields/migration.sql @@ -0,0 +1,20 @@ +ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "code" TEXT; +ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "importSource" TEXT NOT NULL DEFAULT 'manual'; +ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "sourceRef" TEXT; +ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "probability" INTEGER; +ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "sourceDetail" TEXT; +ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "referrer" TEXT; +ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "lostNote" TEXT; +ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "closedAt" TIMESTAMP(3); +ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "createdById" TEXT; + +DO $$ BEGIN + ALTER TABLE "Deal" ADD CONSTRAINT "Deal_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +CREATE INDEX IF NOT EXISTS "Deal_organizationId_sourceRef_idx" ON "Deal"("organizationId", "sourceRef"); + +UPDATE "Deal" +SET "importSource" = 'didar', "sourceRef" = "customFields"->>'didarId' +WHERE "sourceRef" IS NULL AND COALESCE("customFields"->>'didarId', '') <> ''; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index db52248..37cf83c 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -240,6 +240,7 @@ model User { organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) ownedDeals Deal[] @relation("DealOwner") assignedDeals Deal[] @relation("DealAssignees") + createdDeals Deal[] @relation("DealCreator") ownedQuotes Quote[] @relation("QuoteOwner") ownedInvoices Invoice[] @relation("InvoiceOwner") ownedPeople Person[] @relation("PersonOwner") @@ -557,11 +558,26 @@ model Deal { stageId String stage PipelineStage @relation(fields: [stageId], references: [id], onDelete: Restrict) title String + /// کد دستی معامله + code String? + /// منبع ورود: manual | didar | excel + importSource String @default("manual") + /// شناسه در مبدأ (کد دیدار معامله) + sourceRef String? amount Decimal? @db.Decimal(18, 2) currency String @default("IRR") status DealStatus @default(open) + /// احتمال موفقیت (۰ تا ۱۰۰) + probability Int? + /// شیوه آشنایی source String? + /// شیوه آشنایی دلخواه + sourceDetail String? + referrer String? lostReason String? + lostNote String? + /// تاریخ انجام (موفق/ناموفق شدن) + closedAt DateTime? notes String? tags String[] @default([]) isStale Boolean @default(false) @@ -574,6 +590,8 @@ model Deal { company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) ownerId String? owner User? @relation("DealOwner", fields: [ownerId], references: [id], onDelete: SetNull) + createdById String? + createdBy User? @relation("DealCreator", fields: [createdById], references: [id], onDelete: SetNull) assignees User[] @relation("DealAssignees") position Int @default(0) activities Activity[] @@ -592,6 +610,7 @@ model Deal { @@index([personId]) @@index([companyId]) @@index([ownerId]) + @@index([organizationId, sourceRef]) } model Activity { diff --git a/apps/api/src/pipelines/deals-excel.service.ts b/apps/api/src/pipelines/deals-excel.service.ts index 5b98f7c..0a9cc6f 100644 --- a/apps/api/src/pipelines/deals-excel.service.ts +++ b/apps/api/src/pipelines/deals-excel.service.ts @@ -1,10 +1,18 @@ import { Injectable } from '@nestjs/common'; -import type { DealStatus, ImportContactsResult } from '@mento/shared'; +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 { toJsonCustomFields } from '../common/custom-fields'; +import { asCustomFields, toJsonCustomFields } from '../common/custom-fields'; import { normalizePhone } from '../contacts/normalize'; import { excelFieldsFromColumns, @@ -14,78 +22,33 @@ import { 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 { PipelinesService } from './pipelines.service'; -const DEAL_COLUMNS = [ - { header: '_type', key: '_type', width: 12 }, - { header: 'کد دیدار معامله', key: 'didarId', width: 16 }, - { header: 'عنوان معامله', key: 'title', width: 28 }, - { header: 'مبلغ معامله', key: 'amount', width: 16 }, - { header: 'ارز معامله', key: 'currency', width: 12 }, - { header: 'وضعیت معامله', key: 'status', width: 14 }, - { header: 'مسئول معامله', key: 'ownerName', width: 18 }, - { header: 'مجوز مشاهده معامله', key: 'visibility', width: 18 }, - { header: 'تاریخ ثبت معامله', key: 'createdAt', width: 16 }, - { header: 'نام خانوادگی شخص معامله', key: 'personName', width: 22 }, - { header: 'کد دیدار شخص', key: 'personCode', width: 14 }, - { header: 'موبایل شخص معامله', key: 'personMobile', width: 16 }, - { header: 'نام شرکت معامله', key: 'companyName', width: 22 }, - { header: 'کاریز معامله', key: 'pipelineName', width: 20 }, - { header: 'مرحله کاریز معامله', key: 'stageName', width: 18 }, - { header: 'تاریخ انجام معامله', key: 'expectedCloseAt', width: 16 }, - { header: 'منبع معامله', key: 'source', width: 16 }, - { header: 'توضیحات معامله', key: 'notes', width: 32 }, - { header: 'برچسب', key: 'tags', width: 16 }, -]; - -const DEAL_ALIASES: Record = { - _type: '_type', - type: '_type', - کددیدارمعامله: 'didarId', - عنوانمعامله: 'title', - عنوان: 'title', - مبلغمعامله: 'amount', - مبلغ: 'amount', - ارزمعامله: 'currency', - ارز: 'currency', - وضعیتمعامله: 'status', - مسئولمعامله: 'ownerName', - مجوزمشاهدهمعامله: 'visibility', - تاریخثبتمعامله: 'createdAt', - نامخانوادگیشخصمعامله: 'personName', - کددیدارشخص: 'personCode', - موبایلشخصمعامله: 'personMobile', - نامشرکتمعامله: 'companyName', - شرکت: 'companyName', - کاریزمعامله: 'pipelineName', - کاریز: 'pipelineName', - مرحلهکاریزمعامله: 'stageName', - مرحله: 'stageName', - تاریخانجاممعامله: 'expectedCloseAt', - منبعمعامله: 'source', - منبع: 'source', - توضیحاتمعامله: 'notes', - توضیحات: 'notes', - برچسب: 'tags', -}; +const MAX_ROWS_PER_REQUEST = 80; function statusLabel(status: DealStatus) { - if (status === 'won') return 'برنده'; - if (status === 'lost') return 'از دست‌رفته'; - return 'باز'; + if (status === 'won') return 'موفق'; + if (status === 'lost') return 'ناموفق'; + return 'جاری'; } function parseStatus(raw?: string): DealStatus { const v = (raw ?? '').trim(); - if (/برنده|won|موفق/i.test(v)) return 'won'; - if (/از\s*دست|باخت|lost|ناموفق/i.test(v)) return 'lost'; + if (/ناموفق|از\s*دست|باخت|شکست|lost/i.test(v)) return 'lost'; + if (/موفق|برنده|won/i.test(v)) return 'won'; return 'open'; } @@ -97,6 +60,45 @@ function parseTags(raw?: string) { .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; +}; + @Injectable() export class DealsExcelService { constructor( @@ -105,57 +107,111 @@ export class DealsExcelService { ) {} async export(actor: Actor): Promise { - const rows = await this.prisma.deal.findMany({ - where: { organizationId: actor.organizationId, ...dealAccessWhere(actor) }, - include: { - person: { - select: { id: true, firstName: true, lastName: true, mobile: true }, + const [rows, defs] = await Promise.all([ + this.prisma.deal.findMany({ + where: { organizationId: actor.organizationId, ...dealAccessWhere(actor) }, + 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 } } }, + }, }, - company: { select: { name: true } }, - owner: { select: { fullName: true } }, - pipeline: { select: { name: true } }, - stage: { select: { name: true } }, - }, - orderBy: [{ updatedAt: 'desc' }], - take: 10000, - }); - - return writeXlsx( - 'Deal', - DEAL_COLUMNS, - rows.map((row) => { - const extra = (row.customFields ?? {}) as Record; - return { - _type: 'Deal', - didarId: extra.didarId ?? row.id, - title: row.title, - amount: row.amount == null ? '' : Number(row.amount), - currency: row.currency, - status: statusLabel(row.status), - ownerName: row.owner?.fullName ?? '', - visibility: 'همه افراد شرکت', - createdAt: formatJalaliDate(row.createdAt), - personName: row.person - ? `${row.person.firstName} ${row.person.lastName}`.trim() - : '', - personCode: row.person?.id ?? '', - personMobile: row.person?.mobile ?? '', - companyName: row.company?.name ?? '', - pipelineName: row.pipeline.name, - stageName: row.stage.name, - expectedCloseAt: formatJalaliDate(row.expectedCloseAt), - source: row.source ?? '', - notes: row.notes ?? '', - tags: (row.tags ?? []).join('، '), - }; + 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_ALIASES, excelFieldsFromColumns(DEAL_COLUMNS, ['title'])); + return previewExcel(file, DEAL_EXCEL_ALIASES, excelFieldsFromColumns(DEAL_EXCEL_COLUMNS, ['title'])); } + /** ایمپورت با آپلود فایل (فایل‌های استاندارد؛ خروجی دیدار از مسیر importRows می‌آید) */ async import( actor: Actor, file: Express.Multer.File | undefined, @@ -169,72 +225,81 @@ export class DealsExcelService { offset: chunk?.offset, limit: chunk?.limit, loadRows: () => - readMappedRows(file!, DEAL_ALIASES, parseExcelMapping(mappingRaw)), + 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), + }; - 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() !== 'deal') { - continue; - } - const title = (row.title || '').trim(); - if (title.length < 2) { - result.errors.push({ row: rowNum, message: 'عنوان معامله الزامی است' }); - continue; - } - + 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 status = parseStatus(row.status); - const { pipelineId, stageId, pipelineName } = await this.resolvePipelineAndStage( - actor.organizationId, - row.pipelineName, - row.stageName, - status, - ); - affected.set(pipelineId, pipelineName); - - const duplicate = await this.prisma.deal.findFirst({ - where: { organizationId: actor.organizationId, pipelineId, title }, - select: { id: true }, - }); - if (duplicate) { - result.skipped += 1; - continue; - } - - const personId = await this.findPersonId(actor.organizationId, row); - const companyId = await this.findCompanyId(actor.organizationId, row.companyName); - const ownerId = - (await this.findUserId(actor.organizationId, row.ownerName)) ?? - resolveOwnerId(actor, null); - const amount = parseAmount(row.amount); - const expectedCloseAt = parseJalaliDateTime(row.expectedCloseAt); - const customFields: Record = {}; - if (row.didarId?.trim()) customFields.didarId = row.didarId.trim(); - - await this.prisma.deal.create({ - data: { - organizationId: actor.organizationId, - pipelineId, - stageId, - title, - amount: amount == null ? null : new Prisma.Decimal(amount), - currency: (row.currency || 'IRR').trim() || 'IRR', - status, - source: row.source?.trim() || null, - notes: row.notes?.trim() || null, - tags: parseTags(row.tags), - customFields: toJsonCustomFields(customFields), - expectedCloseAt, - personId, - companyId, - ownerId, - }, - }); - result.created += 1; + const outcome = await this.processRow(actor, row, rowNum, result, caches, ctx); + if (outcome) affected.set(outcome.pipelineId, outcome.pipelineName); } catch (e) { result.errors.push({ row: rowNum, @@ -242,35 +307,322 @@ export class DealsExcelService { }); } } + return affected; + } - const entries = [...affected.entries()]; - if (entries[0]) { - result.boardId = entries[0][0]; - result.boards = entries.map(([, n]) => n); + 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}` : ''; } - return { ...result, ...chunkImportMeta(ctx) }; + 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) { + await this.mergeIntoExisting(existingId, customFields, lineItem); + 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: { + customFields: true, + currency: true, + lineItems: { select: { name: true, position: true } }, + }, + }); + if (!deal) return; + 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) }, + }); + } + } + + 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, - stageName?: string, - status: DealStatus = 'open', + pipelineName: string | undefined, + stageName: string | undefined, + status: DealStatus, + ctx: RowContext, ) { const name = pipelineName?.trim() || 'کاریز فروش'; - const wanted = stageName?.trim(); - let pipeline = await this.prisma.pipeline.findFirst({ - where: { organizationId, name: { equals: name, mode: 'insensitive' } }, - include: { stages: { orderBy: { position: 'asc' } } }, - }); + const cacheKey = name.toLowerCase(); + let pipeline = ctx.pipelines.get(cacheKey); if (!pipeline) { - const created = await this.pipelines.create(organizationId, name); - pipeline = await this.prisma.pipeline.findFirstOrThrow({ - where: { id: created.id }, + 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); + 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 wanted = stageName?.trim(); if (wanted) { const existing = pipeline.stages.find( (s) => s.name.trim().toLowerCase() === wanted.toLowerCase(), @@ -281,6 +633,12 @@ export class DealsExcelService { 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 }; } @@ -295,46 +653,120 @@ export class DealsExcelService { return { pipelineId: pipeline.id, stageId: stage.id, pipelineName: pipeline.name }; } - private async findPersonId(organizationId: string, row: Record) { + /** شخص معامله: کد دیدار ← موبایل ← نام دقیق؛ در غیر این صورت ساخته می‌شود */ + private async findOrCreatePerson( + actor: Actor, + row: Record, + caches: ExcelImportLookupCaches, + ): Promise { + const orgId = actor.organizationId; + const code = cleanCode(row.personCode); const mobile = normalizePhone(row.personMobile); - if (mobile) { - const needle = mobile.replace(/^\+98|^98|^0/, ''); - const byMobile = await this.prisma.person.findFirst({ - where: { organizationId, mobile: { contains: needle } }, + 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 (byMobile) return byMobile.id; } - const name = (row.personName ?? '').trim(); - if (name) { - const byName = await this.prisma.person.findFirst({ + 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, + organizationId: orgId, OR: [ - { lastName: { contains: name, mode: 'insensitive' } }, - { firstName: { contains: name.split(/\s+/)[0]!, mode: 'insensitive' } }, + { firstName: first, lastName: last }, + { firstName: '', lastName: fullName }, ], }, select: { id: true }, }); - if (byName) return byName.id; } - return null; + 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 findCompanyId(organizationId: string, name?: string) { - const n = name?.trim(); - if (!n) return null; - const row = await this.prisma.company.findFirst({ - where: { organizationId, name: { equals: n, mode: 'insensitive' } }, + 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 }, }); - return row?.id ?? null; + 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) { - const n = name?.trim(); + 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' }, @@ -342,6 +774,15 @@ export class DealsExcelService { }, 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; +}; diff --git a/apps/api/src/pipelines/deals.controller.ts b/apps/api/src/pipelines/deals.controller.ts index 7c10d5d..0f90ce6 100644 --- a/apps/api/src/pipelines/deals.controller.ts +++ b/apps/api/src/pipelines/deals.controller.ts @@ -20,7 +20,7 @@ import { CurrentUser } from '../auth/current-user.decorator'; import type { Actor } from '../common/record-scope'; import { OrgModuleGuard } from '../billing/org-module.guard'; import { RequireOrgModule } from '../billing/org-module.decorator'; -import { CreateDealDto, MoveDealDto, UpdateDealDto } from './dto'; +import { CreateDealDto, ImportDealRowsDto, MoveDealDto, UpdateDealDto } from './dto'; import { DealsService } from './deals.service'; import { DealsExcelService } from './deals-excel.service'; import { brand } from '@mento/shared'; @@ -83,6 +83,11 @@ export class DealsController { }); } + @Post('import/rows') + importRows(@CurrentUser() user: Actor, @Body() dto: ImportDealRowsDto) { + return this.excelService.importRows(user, dto); + } + @Get(':id') get(@CurrentUser() user: Actor, @Param('id') id: string) { return this.dealsService.get(user.organizationId, id, user); diff --git a/apps/api/src/pipelines/deals.service.ts b/apps/api/src/pipelines/deals.service.ts index 5ac88b8..0a42fe1 100644 --- a/apps/api/src/pipelines/deals.service.ts +++ b/apps/api/src/pipelines/deals.service.ts @@ -30,6 +30,7 @@ const dealInclude = { person: { select: { id: true, firstName: true, lastName: true } }, company: { select: { id: true, name: true } }, owner: { select: { id: true, fullName: true } }, + createdBy: { select: { id: true, fullName: true } }, assignees: { select: { id: true, fullName: true } }, lineItems: { orderBy: { position: 'asc' as const } }, } satisfies Prisma.DealInclude; @@ -138,6 +139,18 @@ export class DealsService { expectedCloseAt: dto.expectedCloseAt ? new Date(dto.expectedCloseAt) : null, + code: emptyToNull(dto.code), + probability: normalizeProbability(dto.probability), + sourceDetail: emptyToNull(dto.sourceDetail), + referrer: emptyToNull(dto.referrer), + lostReason: emptyToNull(dto.lostReason), + lostNote: emptyToNull(dto.lostNote), + closedAt: dto.closedAt + ? new Date(dto.closedAt) + : status !== 'open' + ? new Date() + : null, + createdById: actor.userId, personId: emptyToNull(dto.personId ?? null), companyId: emptyToNull(dto.companyId ?? null), ownerId: resolveOwnerId(actor, dto.ownerId), @@ -220,6 +233,12 @@ export class DealsService { data.customFields = toJsonCustomFields(dto.customFields); } if ('lostReason' in dto) data.lostReason = emptyToNull(dto.lostReason); + if ('lostNote' in dto) data.lostNote = emptyToNull(dto.lostNote); + if ('code' in dto) data.code = emptyToNull(dto.code); + if ('probability' in dto) data.probability = normalizeProbability(dto.probability); + if ('sourceDetail' in dto) data.sourceDetail = emptyToNull(dto.sourceDetail); + if ('referrer' in dto) data.referrer = emptyToNull(dto.referrer); + if ('closedAt' in dto) data.closedAt = dto.closedAt ? new Date(dto.closedAt) : null; if ('expectedCloseAt' in dto) { data.expectedCloseAt = dto.expectedCloseAt ? new Date(dto.expectedCloseAt) @@ -338,6 +357,9 @@ export class DealsService { position, status, lostReason: stage.isLost ? undefined : null, + ...(status !== deal.status + ? { closedAt: status === 'open' ? null : new Date() } + : {}), }, include: dealInclude, }); @@ -494,6 +516,11 @@ function emptyToNull(value?: string | null): string | null { return trimmed.length ? trimmed : null; } +function normalizeProbability(value?: number | null): number | null { + if (value == null || Number.isNaN(Number(value))) return null; + return Math.max(0, Math.min(100, Math.round(Number(value)))); +} + function normalizeTags(tags?: string[]): string[] { if (!tags?.length) return []; const seen = new Set(); diff --git a/apps/api/src/pipelines/dto.ts b/apps/api/src/pipelines/dto.ts index 575d4e7..5f6eaf6 100644 --- a/apps/api/src/pipelines/dto.ts +++ b/apps/api/src/pipelines/dto.ts @@ -1,12 +1,15 @@ import { Type } from 'class-transformer'; import { + Allow, IsArray, IsBoolean, IsDateString, + IsInt, IsNumber, IsObject, IsOptional, IsString, + Max, MaxLength, Min, MinLength, @@ -118,6 +121,43 @@ export class CreateDealDto { @IsBoolean() isRotten?: boolean; + @IsOptional() + @ValidateIf((_, v) => v !== '' && v != null) + @IsString() + @MaxLength(80) + code?: string | null; + + @IsOptional() + @ValidateIf((_, v) => v !== '' && v != null) + @Type(() => Number) + @IsInt() + @Min(0) + @Max(100) + probability?: number | null; + + @IsOptional() + @ValidateIf((_, v) => v !== '' && v != null) + @IsString() + @MaxLength(200) + sourceDetail?: string | null; + + @IsOptional() + @ValidateIf((_, v) => v !== '' && v != null) + @IsString() + @MaxLength(200) + referrer?: string | null; + + @IsOptional() + @ValidateIf((_, v) => v !== '' && v != null) + @IsString() + @MaxLength(2000) + lostNote?: string | null; + + @IsOptional() + @ValidateIf((_, v) => v !== '' && v != null) + @IsDateString() + closedAt?: string | null; + @IsOptional() @ValidateIf((_, v) => v !== '' && v != null) @IsDateString() @@ -152,6 +192,12 @@ export class CreateDealDto { @ValidateNested({ each: true }) @Type(() => DealLineItemInputDto) lineItems?: DealLineItemInputDto[]; + + @IsOptional() + @ValidateIf((_, v) => v !== '' && v != null) + @IsString() + @MaxLength(400) + lostReason?: string | null; } export class UpdateDealDto { @@ -201,6 +247,43 @@ export class UpdateDealDto { @MaxLength(400) lostReason?: string | null; + @IsOptional() + @ValidateIf((_, v) => v !== '' && v != null) + @IsString() + @MaxLength(80) + code?: string | null; + + @IsOptional() + @ValidateIf((_, v) => v !== '' && v != null) + @Type(() => Number) + @IsInt() + @Min(0) + @Max(100) + probability?: number | null; + + @IsOptional() + @ValidateIf((_, v) => v !== '' && v != null) + @IsString() + @MaxLength(200) + sourceDetail?: string | null; + + @IsOptional() + @ValidateIf((_, v) => v !== '' && v != null) + @IsString() + @MaxLength(200) + referrer?: string | null; + + @IsOptional() + @ValidateIf((_, v) => v !== '' && v != null) + @IsString() + @MaxLength(2000) + lostNote?: string | null; + + @IsOptional() + @ValidateIf((_, v) => v !== '' && v != null) + @IsDateString() + closedAt?: string | null; + @IsOptional() @ValidateIf((_, v) => v !== '' && v != null) @IsDateString() @@ -237,6 +320,27 @@ export class UpdateDealDto { lineItems?: DealLineItemInputDto[]; } +/** تکه‌های JSON از ایمپورت مرورگرگرا */ +export class ImportDealRowsDto { + @IsArray() + @Allow() + rows!: Array>; + + @Type(() => Number) + @IsInt() + @Min(0) + offset!: number; + + @Type(() => Number) + @IsInt() + @Min(0) + total!: number; + + @IsOptional() + @IsString() + sessionId?: string; +} + export class MoveDealDto { @IsString() stageId!: string; diff --git a/apps/api/src/pipelines/pipelines.service.ts b/apps/api/src/pipelines/pipelines.service.ts index ca6b036..54d6d6d 100644 --- a/apps/api/src/pipelines/pipelines.service.ts +++ b/apps/api/src/pipelines/pipelines.service.ts @@ -38,6 +38,14 @@ type DealRow = { isRotten: boolean; customFields: unknown; expectedCloseAt: Date | null; + code: string | null; + importSource: string; + sourceRef: string | null; + probability: number | null; + sourceDetail: string | null; + referrer: string | null; + lostNote: string | null; + closedAt: Date | null; position: number; pipelineId: string; stageId: string; @@ -46,6 +54,7 @@ type DealRow = { person: { id: string; firstName: string; lastName: string } | null; company: { id: string; name: string } | null; owner: { id: string; fullName: string } | null; + createdBy?: { id: string; fullName: string } | null; assignees?: Array<{ id: string; fullName: string }>; lineItems?: Array<{ id: string; @@ -326,6 +335,15 @@ export class PipelinesService { isRotten: row.isRotten, customFields: asCustomFields(row.customFields), expectedCloseAt: row.expectedCloseAt?.toISOString() ?? null, + code: row.code, + importSource: row.importSource, + sourceRef: row.sourceRef, + probability: row.probability, + sourceDetail: row.sourceDetail, + referrer: row.referrer, + lostNote: row.lostNote, + closedAt: row.closedAt?.toISOString() ?? null, + createdBy: row.createdBy ?? null, position: row.position, pipelineId: row.pipelineId, stageId: row.stageId, diff --git a/apps/web/src/lib/excel-client.ts b/apps/web/src/lib/excel-client.ts index a9c5551..4e7f201 100644 --- a/apps/web/src/lib/excel-client.ts +++ b/apps/web/src/lib/excel-client.ts @@ -39,17 +39,24 @@ export type ClientExcelPositionalColumn = { key: string; label: string }; export type ClientExcelReadOptions = { positionalColumns?: ClientExcelPositionalColumn[]; detectHeaderless?: (firstRowCells: string[]) => boolean; + /** + * مقدار خام سلول به‌جای متن قالب‌بندی‌شده؛ خروجی دیدار به کدهای عددی قالب تاریخ می‌دهد + * و بدون این گزینه «4231» به‌صورت «8/1/11» خوانده می‌شود. + */ + rawValues?: boolean; + /** ستون‌های ناشناخته با کلید `${prefix}${عنوان ستون}` ارسال می‌شوند (فیلد سفارشی) */ + customFieldPrefix?: string; }; -function readMatrix(fileBuffer: ArrayBuffer) { - const wb = XLSX.read(fileBuffer, { type: 'array', cellDates: true }); +function readMatrix(fileBuffer: ArrayBuffer, rawValues = false) { + const wb = XLSX.read(fileBuffer, { type: 'array', cellDates: !rawValues }); const sheetName = wb.SheetNames[0]; if (!sheetName) throw new Error('شیت اکسل پیدا نشد'); const sheet = wb.Sheets[sheetName]!; return XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '', - raw: false, + raw: rawValues, }) as unknown[][]; } @@ -60,11 +67,13 @@ export async function previewExcelClient( fields: ExcelFieldOption[], options?: ClientExcelReadOptions, ): Promise { - const matrix = readMatrix(await file.arrayBuffer()); + const matrix = readMatrix(await file.arrayBuffer(), options?.rawValues); const headerRow = (matrix[0] ?? []).map((c) => cellToString(c)); const headerless = Boolean(options?.positionalColumns?.length) && Boolean(options?.detectHeaderless?.(headerRow)); + const prefix = options?.customFieldPrefix; + const allFields = [...fields]; const columns: ExcelPreviewResult['columns'] = []; const suggested: Record = {}; @@ -96,6 +105,11 @@ export async function previewExcelClient( if (key && !used.has(key)) { suggested[header] = key; used.add(key); + } else if (!key && prefix) { + const cfKey = `${prefix}${header}`; + allFields.push({ key: cfKey, label: `فیلد سفارشی: ${header}` }); + suggested[header] = cfKey; + used.add(cfKey); } }); } @@ -116,7 +130,7 @@ export async function previewExcelClient( return { columns, - fields, + fields: allFields, suggested, needsMapping: headerless ? false @@ -132,7 +146,7 @@ export async function readMappedRowsClient( mapping?: Record, options?: ClientExcelReadOptions, ): Promise[]> { - const matrix = readMatrix(await file.arrayBuffer()); + const matrix = readMatrix(await file.arrayBuffer(), options?.rawValues); const headerRow = (matrix[0] ?? []).map((c) => cellToString(c)); const headerless = !mapping && @@ -152,6 +166,9 @@ export async function readMappedRowsClient( if (!header) return; const key = resolveFieldKey(header, aliases, mapping); if (key) colMap.set(idx, key); + else if (!mapping && options?.customFieldPrefix) { + colMap.set(idx, `${options.customFieldPrefix}${header}`); + } }); } diff --git a/apps/web/src/views/DealFormView.vue b/apps/web/src/views/DealFormView.vue index 0c6a4d6..ffcb730 100644 --- a/apps/web/src/views/DealFormView.vue +++ b/apps/web/src/views/DealFormView.vue @@ -96,6 +96,14 @@ const pipelineId = ref(''); const ownerId = ref(''); const selectedAssigneeIds = ref([]); const lostReason = ref(''); +const lostNote = ref(''); +const dealCode = ref(''); +const probability = ref(''); +const sourceDetail = ref(''); +const referrer = ref(''); +const closedAt = ref(''); +const createdByName = ref(''); +const sourceRef = ref(''); const status = ref(''); const customFields = ref>({}); const lines = ref([]); @@ -251,6 +259,14 @@ async function load() { ownerId.value = data.owner?.id ?? auth.user?.id ?? ''; selectedAssigneeIds.value = (data.assignees ?? []).map((a) => a.id); lostReason.value = data.lostReason ?? ''; + lostNote.value = data.lostNote ?? ''; + dealCode.value = data.code ?? ''; + probability.value = data.probability == null ? '' : String(data.probability); + sourceDetail.value = data.sourceDetail ?? ''; + referrer.value = data.referrer ?? ''; + closedAt.value = data.closedAt ? data.closedAt.slice(0, 10) : ''; + createdByName.value = data.createdBy?.fullName ?? ''; + sourceRef.value = data.importSource === 'didar' && data.sourceRef ? data.sourceRef : ''; status.value = data.status; customFields.value = { ...(data.customFields ?? {}) }; lines.value = (data.lineItems ?? []).map((item) => ({ @@ -302,6 +318,10 @@ function payload(): CreateDealRequest { ? null : Number(amount.value), source: source.value || undefined, + sourceDetail: sourceDetail.value.trim() || null, + referrer: referrer.value.trim() || null, + code: dealCode.value.trim() || null, + probability: probability.value === '' ? null : Number(probability.value), notes: notes.value || undefined, tags: parseTags(tagsRaw.value), isStale: health.value === 'stale', @@ -374,6 +394,8 @@ async function submit() { await api.patch(`/deals/${id.value}`, { ...payload(), lostReason: lostReason.value || null, + lostNote: lostNote.value.trim() || null, + closedAt: closedAt.value || null, }); if (stageId.value) { await api.patch(`/deals/${id.value}/move`, { stageId: stageId.value }); @@ -679,13 +701,43 @@ onMounted(load); + + + + + + + + + + + + + + + + + + + + + + + + + +

+ ثبت‌کننده: {{ createdByName }} + · + کد دیدار: {{ toFaDigits(sourceRef) }} +

diff --git a/apps/web/src/views/PipelineBoardView.vue b/apps/web/src/views/PipelineBoardView.vue index 7aa2aa6..efacd92 100644 --- a/apps/web/src/views/PipelineBoardView.vue +++ b/apps/web/src/views/PipelineBoardView.vue @@ -24,7 +24,22 @@ import UiModal from '@/components/ui/UiModal.vue'; import DealFormView from '@/views/DealFormView.vue'; import SavedFiltersBar from '@/components/SavedFiltersBar.vue'; import type { ImportContactsResult, PipelineBoardDto, PipelineDto } from '@mento/shared'; -import { brand } from '@mento/shared'; +import { + DEAL_EXCEL_ALIASES, + DEAL_EXCEL_COLUMNS, + EXCEL_CUSTOM_FIELD_PREFIX, + brand, +} from '@mento/shared'; + +const dealExcelFields = DEAL_EXCEL_COLUMNS.filter((c) => c.key !== '_type').map((c) => ({ + key: c.key, + label: c.header, + required: c.key === 'title', +})); +const dealClientReadOptions = { + rawValues: true, + customFieldPrefix: EXCEL_CUSTOM_FIELD_PREFIX, +}; import { useAuthStore } from '@/stores/auth'; import { canManageSettings } from '@/lib/roles'; @@ -506,6 +521,10 @@ defineExpose({ updateDealStage: applyUpdateDealStage }); = [ + { header: "_type", key: "_type", width: 8 }, + { header: "کد دیدار معامله", key: "didarId", width: 14 }, + { header: "کد دستی معامله", key: "code", width: 14 }, + { header: "عنوان معامله", key: "title", width: 30 }, + { header: "ارزش معامله", key: "amount", width: 16 }, + { header: "ارز معامله", key: "currency", width: 10 }, + { header: "توضیحات معامله", key: "notes", width: 32 }, + { header: "وضعیت معامله", key: "status", width: 12 }, + { header: "تاریخ انجام معامله", key: "closedAt", width: 14 }, + { header: "تاریخ ایجاد معامله", key: "createdAt", width: 14 }, + { header: "تاریخ احتمالی انجام معامله", key: "expectedCloseAt", width: 16 }, + { header: "مسئول معامله", key: "ownerName", width: 18 }, + { header: "ثبت کننده معامله", key: "creatorName", width: 18 }, + { header: "احتمال موفقیت معامله", key: "probability", width: 12 }, + { header: "دلیل شکست معامله", key: "lostReason", width: 18 }, + { header: "یادداشت شکست معامله", key: "lostNote", width: 24 }, + { header: "مجوز مشاهده معامله", key: "visibility", width: 18 }, + { header: "شیوه آشنایی معامله", key: "source", width: 16 }, + { header: "معرف معامله", key: "referrer", width: 16 }, + { header: "شیوه آشنایی دلخواه", key: "sourceDetail", width: 16 }, + { header: "برچسب", key: "tags", width: 16 }, + { header: "کاریز معامله", key: "pipelineName", width: 20 }, + { header: "مرحله کاریز معامله", key: "stageName", width: 20 }, + { header: "نام شخص معامله", key: "personFirstName", width: 16 }, + { header: "نام خانوادگی شخص معامله", key: "personName", width: 22 }, + { header: "کد دیدار شخص معامله", key: "personCode", width: 14 }, + { header: "موبایل شخص معامله", key: "personMobile", width: 14 }, + { header: "شرکت معامله", key: "companyName", width: 20 }, + { header: "کد دیدار شرکت معامله", key: "companyCode", width: 14 }, + { header: "شماره تلفن شرکت", key: "companyPhone", width: 14 }, + { header: "عنوان محصول", key: "productName", width: 20 }, + { header: "کد محصول", key: "productCode", width: 12 }, + { header: "دسته بندی محصول", key: "productCategory", width: 14 }, + { header: "تعداد محصول", key: "productQty", width: 10 }, + { header: "قیمت", key: "productPrice", width: 14 }, + { header: "واحد قیمت محصول", key: "productCurrency", width: 12 }, + { header: "میزان تخفیف محصول", key: "productDiscount", width: 12 }, + { header: "مبلغ نهایی", key: "productTotal", width: 14 }, + { header: "گروه محصول", key: "productGroup", width: 14 }, +]; + +/** کلید ستون‌های ناشناخته اکسل که به‌عنوان فیلد سفارشی ذخیره می‌شوند: cf:<عنوان ستون> */ +export const EXCEL_CUSTOM_FIELD_PREFIX = "cf:"; + +export function compactExcelHeader(raw: string): string { + return raw + .trim() + .toLowerCase() + .replace(/[_-]+/g, "") + .replace(/\u200c/g, "") + .replace(/\s+/g, "") + .replace(/[()]/g, ""); +} + +export const DEAL_EXCEL_ALIASES: Record = { + ...Object.fromEntries(DEAL_EXCEL_COLUMNS.map((c) => [compactExcelHeader(c.header), c.key])), + type: "_type", + عنوان: "title", + مبلغمعامله: "amount", + مبلغ: "amount", + ارز: "currency", + وضعیت: "status", + توضیحات: "notes", + تاریخثبتمعامله: "createdAt", + کددیدارشخص: "personCode", + نامشرکتمعامله: "companyName", + شرکت: "companyName", + منبعمعامله: "source", + منبع: "source", + کاریز: "pipelineName", + مرحله: "stageName", +}; /** فیلتر ذخیره‌شدهٔ لیست اشخاص */ export interface PeopleViewFilters { q?: string;