import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import type { DealDto, MergePipelineResultDto, PipelineBoardDto, PipelineDto, PipelineMergePreviewDto, PipelineStageDto, } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { asCustomFields } from '../common/custom-fields'; import { type Actor, dealAccessWhere } from '../common/record-scope'; import { dealPriorWhere } from '../common/prior-archive'; import { decimalToBase, loadOrgRates } from '../common/currency'; import { assertStageMap, dealStatusForStage } from './pipeline-merge.util'; export const DEFAULT_STAGES = [ { name: 'سرنخ', isWon: false, isLost: false }, { name: 'تماس اول', isWon: false, isLost: false }, { name: 'پیشنهاد', isWon: false, isLost: false }, { name: 'مذاکره', isWon: false, isLost: false }, { name: 'برنده', isWon: true, isLost: false }, { name: 'از دست‌رفته', isWon: false, isLost: true }, ] as const; type DealRow = { id: string; title: string; amount: Prisma.Decimal | null; currency: string; status: 'open' | 'won' | 'lost'; source: string | null; lostReason: string | null; notes: string | null; tags: string[]; isStale: boolean; isRotten: boolean; customFields: unknown; expectedCloseAt: Date | null; code: string | null; importSource: string; promotedFromPriorAt: Date | null; sourceRef: string | null; probability: number | null; sourceDetail: string | null; referrer: string | null; lostNote: string | null; closedAt: Date | null; position: number; pipelineId: string; stageId: string; createdAt: Date; updatedAt: Date; 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; productId: string | null; name: string; quantity: Prisma.Decimal; unitPrice: Prisma.Decimal; currency: string; position: number; }>; }; @Injectable() export class PipelinesService { constructor(private readonly prisma: PrismaService) {} async ensureDefaultPipeline(organizationId: string): Promise { const existing = await this.prisma.pipeline.findFirst({ where: { organizationId, isDefault: true }, include: { stages: { orderBy: { position: 'asc' } } }, }); if (existing) return this.toPipelineDto(existing); const created = await this.prisma.pipeline.create({ data: { organizationId, name: 'مسیر فروش', isDefault: true, stages: { create: DEFAULT_STAGES.map((s, index) => ({ name: s.name, position: index, isWon: s.isWon, isLost: s.isLost, })), }, }, include: { stages: { orderBy: { position: 'asc' } } }, }); return this.toPipelineDto(created); } async list(organizationId: string): Promise { await this.ensureDefaultPipeline(organizationId); const rows = await this.prisma.pipeline.findMany({ where: { organizationId }, include: { stages: { orderBy: { position: 'asc' } } }, orderBy: [{ isDefault: 'desc' }, { name: 'asc' }], }); const counts = await this.prisma.deal.groupBy({ by: ['pipelineId'], where: { organizationId }, _count: { _all: true }, }); const dealCountByPipeline = new Map( counts.map((row) => [row.pipelineId, row._count._all]), ); return rows.map((row) => ({ ...this.toPipelineDto(row), dealCount: dealCountByPipeline.get(row.id) ?? 0, })); } 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, ...(withDefaults ? { stages: { create: DEFAULT_STAGES.map((s, index) => ({ name: s.name, position: index, isWon: s.isWon, isLost: s.isLost, })), }, } : {}), }, include: { stages: { orderBy: { position: 'asc' } } }, }); return this.toPipelineDto(created); } async createStage( organizationId: string, pipelineId: string, name: string, ): Promise { const pipeline = await this.getOwnedPipeline(organizationId, pipelineId); const trimmed = name.trim(); if (!trimmed) throw new BadRequestException('نام ستون لازم است'); const firstClosed = pipeline.stages.findIndex((s) => s.isWon || s.isLost); const insertAt = firstClosed === -1 ? pipeline.stages.length : firstClosed; await this.prisma.$transaction(async (tx) => { await tx.pipelineStage.updateMany({ where: { pipelineId: pipeline.id, position: { gte: insertAt } }, data: { position: { increment: 1 } }, }); await tx.pipelineStage.create({ data: { pipelineId: pipeline.id, name: trimmed, position: insertAt, isWon: false, isLost: false, }, }); }); return this.getOwnedPipelineDto(organizationId, pipeline.id); } async updateStage( organizationId: string, pipelineId: string, stageId: string, name?: string, ): Promise { const pipeline = await this.getOwnedPipeline(organizationId, pipelineId); const stage = pipeline.stages.find((s) => s.id === stageId); if (!stage) throw new NotFoundException('ستون پیدا نشد'); const trimmed = name?.trim() ?? ''; if (!trimmed) throw new BadRequestException('نام ستون لازم است'); await this.prisma.pipelineStage.update({ where: { id: stage.id }, data: { name: trimmed }, }); return this.getOwnedPipelineDto(organizationId, pipeline.id); } async remove(organizationId: string, pipelineId: string): Promise { const pipeline = await this.getOwnedPipeline(organizationId, pipelineId); await this.prisma.$transaction(async (tx) => { await tx.deal.deleteMany({ where: { organizationId, pipelineId: pipeline.id }, }); if (pipeline.isDefault) { const next = await tx.pipeline.findFirst({ where: { organizationId, id: { not: pipeline.id } }, orderBy: { createdAt: 'asc' }, select: { id: true }, }); if (next) { await tx.pipeline.update({ where: { id: next.id }, data: { isDefault: true }, }); } } await tx.pipeline.delete({ where: { id: pipeline.id } }); }); } async deleteStage( organizationId: string, pipelineId: string, stageId: string, ): Promise { const pipeline = await this.getOwnedPipeline(organizationId, pipelineId); const stage = pipeline.stages.find((s) => s.id === stageId); if (!stage) throw new NotFoundException('ستون پیدا نشد'); if (pipeline.stages.length <= 1) { throw new BadRequestException('حداقل یک ستون باید باقی بماند'); } const dealCount = await this.prisma.deal.count({ where: { organizationId, stageId: stage.id }, }); if (dealCount > 0) { throw new BadRequestException( 'ابتدا معاملات این ستون را به ستون دیگری منتقل کنید', ); } const remaining = pipeline.stages.filter((s) => s.id !== stage.id); await this.prisma.$transaction([ this.prisma.pipelineStage.delete({ where: { id: stage.id } }), ...remaining.map((s, index) => this.prisma.pipelineStage.update({ where: { id: s.id }, data: { position: index }, }), ), ]); return this.getOwnedPipelineDto(organizationId, pipeline.id); } async reorderStages( organizationId: string, pipelineId: string, stageIds: string[], ): Promise { const pipeline = await this.getOwnedPipeline(organizationId, pipelineId); const currentIds = pipeline.stages.map((s) => s.id); const unique = new Set(stageIds); if ( stageIds.length !== currentIds.length || unique.size !== currentIds.length || currentIds.some((id) => !unique.has(id)) ) { throw new BadRequestException('ترتیب ستون‌ها نامعتبر است'); } await this.prisma.$transaction( stageIds.map((id, index) => this.prisma.pipelineStage.update({ where: { id }, data: { position: index }, }), ), ); return this.getOwnedPipelineDto(organizationId, pipeline.id); } async getBoard( organizationId: string, pipelineId?: string, actor?: Pick< Actor, | 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'priorLens' | 'priorCutoff' >, includePrior = false, ): Promise { const pipeline = pipelineId ? await this.prisma.pipeline.findFirst({ where: { id: pipelineId, organizationId }, include: { stages: { orderBy: { position: 'asc' } } }, }) : null; const resolved = pipeline ?? (await this.prisma.pipeline.findFirst({ where: { organizationId, isDefault: true }, include: { stages: { orderBy: { position: 'asc' } } }, })); if (!resolved) { const created = await this.ensureDefaultPipeline(organizationId); return this.getBoard(organizationId, created.id, actor, includePrior); } const deals = await this.prisma.deal.findMany({ where: { organizationId, pipelineId: resolved.id, AND: [ ...(actor ? [dealAccessWhere(actor)] : []), ...(includePrior ? [] : [dealPriorWhere(actor?.priorLens, actor?.priorCutoff)]), ], }, include: { person: { select: { id: true, firstName: true, lastName: true } }, company: { select: { id: true, name: true } }, owner: { select: { id: true, fullName: true } }, assignees: { select: { id: true, fullName: true } }, }, orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }], }); const rateMap = await loadOrgRates(this.prisma, organizationId); const byStage = new Map(); for (const stage of resolved.stages) byStage.set(stage.id, []); for (const deal of deals) { const list = byStage.get(deal.stageId); if (list) list.push(this.toDealDto(deal)); } return { pipeline: this.toPipelineDto(resolved), stages: resolved.stages.map((stage) => { const stageDeals = byStage.get(stage.id) ?? []; return { id: stage.id, name: stage.name, position: stage.position, isWon: stage.isWon, isLost: stage.isLost, deals: stageDeals, totalAmount: stageDeals.reduce( (sum, d) => sum + decimalToBase( d.amount == null ? null : new Prisma.Decimal(d.amount), d.currency, rateMap, ), 0, ), }; }), }; } async mergePreview( organizationId: string, sourceId: string, ): Promise { const source = await this.getOwnedPipeline(organizationId, sourceId); const dealCounts = await this.prisma.deal.groupBy({ by: ['stageId'], where: { organizationId, pipelineId: source.id }, _count: { _all: true }, }); const countByStage = new Map( dealCounts.map((row) => [row.stageId, row._count._all]), ); const leadForms = await this.prisma.leadForm.findMany({ where: { organizationId, pipelineId: source.id }, select: { id: true, name: true, isActive: true }, orderBy: { updatedAt: 'desc' }, }); const targets = await this.prisma.pipeline.findMany({ where: { organizationId, id: { not: source.id } }, include: { stages: { orderBy: { position: 'asc' } } }, orderBy: [{ isDefault: 'desc' }, { name: 'asc' }], }); const stages = source.stages.map((stage) => ({ id: stage.id, name: stage.name, isWon: stage.isWon, isLost: stage.isLost, dealCount: countByStage.get(stage.id) ?? 0, })); return { source: { id: source.id, name: source.name, isDefault: source.isDefault, }, stages, dealCount: stages.reduce((sum, stage) => sum + stage.dealCount, 0), leadForms, targets: targets.map((target) => ({ id: target.id, name: target.name, isDefault: target.isDefault, stages: target.stages.map((stage) => ({ id: stage.id, name: stage.name, isWon: stage.isWon, isLost: stage.isLost, })), })), }; } async merge( organizationId: string, sourceId: string, dto: { targetPipelineId: string; stageMap: Record; leadFormMode?: 'retarget_all'; }, ): Promise { if (sourceId === dto.targetPipelineId) { throw new BadRequestException('مسیر مقصد باید با مسیر مبدأ متفاوت باشد'); } if ((dto.leadFormMode ?? 'retarget_all') !== 'retarget_all') { throw new BadRequestException('روش انتقال فرم‌های جذب نامعتبر است'); } const [source, target] = await Promise.all([ this.getOwnedPipeline(organizationId, sourceId), this.getOwnedPipeline(organizationId, dto.targetPipelineId), ]); try { assertStageMap(source.stages, target.stages, dto.stageMap); } catch (error) { throw new BadRequestException( error instanceof Error ? error.message : 'نگاشت ستون‌ها نامعتبر است', ); } const targetStagesById = new Map( target.stages.map((stage) => [stage.id, stage]), ); let movedDeals = 0; let retargetedLeadForms = 0; await this.prisma.$transaction( async (tx) => { const closedAt = new Date(); for (const sourceStage of source.stages) { const targetStageId = dto.stageMap[sourceStage.id]; const targetStage = targetStagesById.get(targetStageId); if (!targetStage) { throw new BadRequestException('نگاشت ستون‌ها نامعتبر است'); } const status = dealStatusForStage(targetStage); if (status === 'open') { const result = await tx.deal.updateMany({ where: { organizationId, pipelineId: source.id, stageId: sourceStage.id, }, data: { pipelineId: target.id, stageId: targetStage.id, status, closedAt: null, }, }); movedDeals += result.count; continue; } const opened = await tx.deal.updateMany({ where: { organizationId, pipelineId: source.id, stageId: sourceStage.id, status: 'open', }, data: { pipelineId: target.id, stageId: targetStage.id, status, closedAt, }, }); const alreadyClosed = await tx.deal.updateMany({ where: { organizationId, pipelineId: source.id, stageId: sourceStage.id, status: { not: 'open' }, }, data: { pipelineId: target.id, stageId: targetStage.id, status, }, }); movedDeals += opened.count + alreadyClosed.count; } const leadForms = await tx.leadForm.updateMany({ where: { organizationId, pipelineId: source.id }, data: { pipelineId: target.id }, }); retargetedLeadForms = leadForms.count; if (source.isDefault) { await tx.pipeline.updateMany({ where: { organizationId, isDefault: true }, data: { isDefault: false }, }); await tx.pipeline.update({ where: { id: target.id }, data: { isDefault: true }, }); } const remaining = await tx.deal.count({ where: { organizationId, pipelineId: source.id }, }); if (remaining > 0) { throw new BadRequestException( 'برخی معامله‌ها منتقل نشدند؛ ادغام لغو شد.', ); } await tx.pipeline.delete({ where: { id: source.id } }); }, { timeout: 30000 }, ); return { ok: true, targetPipelineId: target.id, movedDeals, retargetedLeadForms, deletedPipelineId: source.id, }; } toDealDto(row: DealRow): DealDto { return { id: row.id, title: row.title, amount: row.amount == null ? null : Number(row.amount), currency: row.currency, status: row.status, source: row.source, lostReason: row.lostReason, notes: row.notes, tags: row.tags ?? [], isStale: row.isStale, isRotten: row.isRotten, customFields: asCustomFields(row.customFields), expectedCloseAt: row.expectedCloseAt?.toISOString() ?? null, code: row.code, importSource: row.importSource, promotedFromPriorAt: row.promotedFromPriorAt?.toISOString() ?? null, 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, person: row.person ? { id: row.person.id, fullName: `${row.person.firstName} ${row.person.lastName}`.trim(), } : null, company: row.company, owner: row.owner, assignees: row.assignees ?? [], lineItems: (row.lineItems ?? []).map((item) => { const quantity = Number(item.quantity); const unitPrice = Number(item.unitPrice); return { id: item.id, productId: item.productId, name: item.name, quantity, unitPrice, currency: item.currency, position: item.position, lineTotal: quantity * unitPrice, }; }), createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; } private async getOwnedPipeline(organizationId: string, pipelineId: string) { const pipeline = await this.prisma.pipeline.findFirst({ where: { id: pipelineId, organizationId }, include: { stages: { orderBy: { position: 'asc' } } }, }); if (!pipeline) throw new NotFoundException('مسیر فروش پیدا نشد'); return pipeline; } private async getOwnedPipelineDto( organizationId: string, pipelineId: string, ): Promise { const pipeline = await this.getOwnedPipeline(organizationId, pipelineId); return this.toPipelineDto(pipeline); } private toPipelineDto(row: { id: string; name: string; isDefault: boolean; stages: Array<{ id: string; name: string; position: number; isWon: boolean; isLost: boolean; }>; }): PipelineDto { return { id: row.id, name: row.name, isDefault: row.isDefault, stages: row.stages.map( (s): PipelineStageDto => ({ id: s.id, name: s.name, position: s.position, isWon: s.isWon, isLost: s.isLost, }), ), }; } }