feat(api): pipeline merge preview and merge endpoints
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -351,3 +351,16 @@ export class MoveDealDto {
|
||||
@Min(0)
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export class MergePipelineDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
targetPipelineId!: string;
|
||||
|
||||
@IsObject()
|
||||
stageMap!: Record<string, string>;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
leadFormMode?: 'retarget_all';
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<PipelineMergePreviewDto> {
|
||||
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<string, string>;
|
||||
leadFormMode?: 'retarget_all';
|
||||
},
|
||||
): Promise<MergePipelineResultDto> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user