import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import type { DealDto, PipelineBoardDto, PipelineDto, PipelineStageDto, } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { asCustomFields } from '../common/custom-fields'; import { type Actor, ownerWhere } from '../common/record-scope'; import { decimalToBase, loadOrgRates } from '../common/currency'; 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; 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; 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' }], }); return rows.map((row) => this.toPipelineDto(row)); } async create(organizationId: string, name: string): Promise { await this.ensureDefaultPipeline(organizationId); 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, })), }, }, 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 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, ): 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); } const deals = await this.prisma.deal.findMany({ where: { organizationId, pipelineId: resolved.id, ...(actor ? ownerWhere(actor) : {}), }, include: { person: { select: { id: true, firstName: true, lastName: true } }, company: { select: { id: true, name: true } }, owner: { 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, ), }; }), }; } 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, 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, 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, }), ), }; } }