diff --git a/apps/api/src/pipelines/deals-excel.service.ts b/apps/api/src/pipelines/deals-excel.service.ts index 0a9cc6f..a0abad5 100644 --- a/apps/api/src/pipelines/deals-excel.service.ts +++ b/apps/api/src/pipelines/deals-excel.service.ts @@ -35,7 +35,7 @@ import { resolveExcelImportChunk, type ExcelImportLookupCaches, } from '../common/excel-import-session'; -import { PipelinesService } from './pipelines.service'; +import { DEFAULT_STAGES, PipelinesService } from './pipelines.service'; const MAX_ROWS_PER_REQUEST = 80; @@ -97,6 +97,7 @@ type PipelineCacheEntry = { type RowContext = { pipelines: Map; customFieldKeys: Map; + touchedPipelines: Set; }; @Injectable() @@ -290,6 +291,7 @@ export class DealsExcelService { const ctx: RowContext = { pipelines: new Map(), customFieldKeys: await this.loadCustomFieldKeys(actor.organizationId), + touchedPipelines: new Set(), }; for (let i = 0; i < rows.length; i++) { @@ -299,7 +301,10 @@ export class DealsExcelService { 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); + if (outcome) { + affected.set(outcome.pipelineId, outcome.pipelineName); + ctx.touchedPipelines.add(outcome.pipelineId); + } } catch (e) { result.errors.push({ row: rowNum, @@ -307,6 +312,7 @@ export class DealsExcelService { }); } } + await this.removeUnusedDefaultStages(actor.organizationId, ctx.touchedPipelines); return affected; } @@ -342,7 +348,8 @@ export class DealsExcelService { if (didarId) { const existingId = await this.findDealByRef(orgId, didarId, caches); if (existingId) { - await this.mergeIntoExisting(existingId, customFields, lineItem); + const pipelineId = await this.mergeIntoExisting(existingId, customFields, lineItem); + if (pipelineId) ctx.touchedPipelines.add(pipelineId); result.skipped += 1; return null; } @@ -448,12 +455,13 @@ export class DealsExcelService { 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; + if (!deal) return null; const merged = { ...asCustomFields(deal.customFields) }; let changed = false; for (const [k, v] of Object.entries(customFields)) { @@ -490,6 +498,33 @@ export class DealsExcelService { 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( @@ -608,7 +643,9 @@ export class DealsExcelService { include: { stages: { orderBy: { position: 'asc' } } }, }); if (!row) { - const created = await this.pipelines.create(organizationId, name); + 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' } } }, @@ -622,7 +659,15 @@ export class DealsExcelService { ctx.pipelines.set(cacheKey, pipeline); } - const wanted = stageName?.trim(); + 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(), @@ -642,13 +687,7 @@ export class DealsExcelService { return { pipelineId: pipeline.id, stageId: stage.id, pipelineName: pipeline.name }; } - const byStatus = - 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 stage = byStatus ?? pipeline.stages[0]; + const stage = byStatusExisting ?? pipeline.stages[0]; if (!stage) throw new Error('کاریز مرحله‌ای ندارد'); return { pipelineId: pipeline.id, stageId: stage.id, pipelineName: pipeline.name }; } diff --git a/apps/api/src/pipelines/pipelines.service.ts b/apps/api/src/pipelines/pipelines.service.ts index 54d6d6d..5aecd5a 100644 --- a/apps/api/src/pipelines/pipelines.service.ts +++ b/apps/api/src/pipelines/pipelines.service.ts @@ -15,7 +15,7 @@ import { asCustomFields } from '../common/custom-fields'; import { type Actor, dealAccessWhere } from '../common/record-scope'; import { decimalToBase, loadOrgRates } from '../common/currency'; -const DEFAULT_STAGES = [ +export const DEFAULT_STAGES = [ { name: 'سرنخ', isWon: false, isLost: false }, { name: 'تماس اول', isWon: false, isLost: false }, { name: 'پیشنهاد', isWon: false, isLost: false }, @@ -107,21 +107,30 @@ export class PipelinesService { return rows.map((row) => this.toPipelineDto(row)); } - async create(organizationId: string, name: string): Promise { + async create( + organizationId: string, + name: string, + opts: { withDefaultStages?: boolean } = {}, + ): Promise { await this.ensureDefaultPipeline(organizationId); + const withDefaults = opts.withDefaultStages ?? true; const created = await this.prisma.pipeline.create({ data: { organizationId, name: name.trim(), isDefault: false, - stages: { - create: DEFAULT_STAGES.map((s, index) => ({ - name: s.name, - position: index, - isWon: s.isWon, - isLost: s.isLost, - })), - }, + ...(withDefaults + ? { + stages: { + create: DEFAULT_STAGES.map((s, index) => ({ + name: s.name, + position: index, + isWon: s.isWon, + isLost: s.isLost, + })), + }, + } + : {}), }, include: { stages: { orderBy: { position: 'asc' } } }, });