diff --git a/apps/api/src/pipelines/dto.ts b/apps/api/src/pipelines/dto.ts index 5f6eaf6..5b208cb 100644 --- a/apps/api/src/pipelines/dto.ts +++ b/apps/api/src/pipelines/dto.ts @@ -351,3 +351,16 @@ export class MoveDealDto { @Min(0) position?: number; } + +export class MergePipelineDto { + @IsString() + @MinLength(1) + targetPipelineId!: string; + + @IsObject() + stageMap!: Record; + + @IsOptional() + @IsString() + leadFormMode?: 'retarget_all'; +} diff --git a/apps/api/src/pipelines/pipelines.controller.ts b/apps/api/src/pipelines/pipelines.controller.ts index d5fb47a..bd0d33b 100644 --- a/apps/api/src/pipelines/pipelines.controller.ts +++ b/apps/api/src/pipelines/pipelines.controller.ts @@ -17,6 +17,7 @@ import type { Actor } from '../common/record-scope'; import { CreatePipelineDto, CreatePipelineStageDto, + MergePipelineDto, ReorderPipelineStagesDto, UpdatePipelineStageDto, } from './dto'; @@ -49,6 +50,25 @@ export class PipelinesController { return this.pipelinesService.create(user.organizationId, dto.name); } + @Get(':pipelineId/merge-preview') + @Roles('owner', 'admin') + mergePreview( + @CurrentUser() user: { organizationId: string }, + @Param('pipelineId') pipelineId: string, + ) { + return this.pipelinesService.mergePreview(user.organizationId, pipelineId); + } + + @Post(':pipelineId/merge') + @Roles('owner', 'admin') + merge( + @CurrentUser() user: { organizationId: string }, + @Param('pipelineId') pipelineId: string, + @Body() dto: MergePipelineDto, + ) { + return this.pipelinesService.merge(user.organizationId, pipelineId, dto); + } + @Post(':pipelineId/stages') @Roles('owner', 'admin') createStage( diff --git a/apps/api/src/pipelines/pipelines.service.ts b/apps/api/src/pipelines/pipelines.service.ts index b40039b..82aed65 100644 --- a/apps/api/src/pipelines/pipelines.service.ts +++ b/apps/api/src/pipelines/pipelines.service.ts @@ -5,8 +5,10 @@ import { } from '@nestjs/common'; import type { DealDto, + MergePipelineResultDto, PipelineBoardDto, PipelineDto, + PipelineMergePreviewDto, PipelineStageDto, } from '@mento/shared'; import { Prisma } from '@prisma/client'; @@ -14,6 +16,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { asCustomFields } from '../common/custom-fields'; import { type Actor, dealAccessWhere } from '../common/record-scope'; import { decimalToBase, loadOrgRates } from '../common/currency'; +import { assertStageMap, dealStatusForStage } from './pipeline-merge.util'; export const DEFAULT_STAGES = [ { name: 'سرنخ', isWon: false, isLost: false }, @@ -329,6 +332,181 @@ export class PipelinesService { }; } + 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 }, + }); + } + + await tx.pipeline.delete({ where: { id: source.id } }); + }); + + return { + ok: true, + targetPipelineId: target.id, + movedDeals, + retargetedLeadForms, + deletedPipelineId: source.id, + }; + } + toDealDto(row: DealRow): DealDto { return { id: row.id,