Files
CRM_Montzar/apps/api/src/pipelines/pipelines.service.ts
T

624 lines
18 KiB
TypeScript

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 { 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;
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<PipelineDto> {
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<PipelineDto[]> {
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,
opts: { withDefaultStages?: boolean } = {},
): Promise<PipelineDto> {
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<PipelineDto> {
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<PipelineDto> {
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<PipelineDto> {
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<PipelineDto> {
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'
>,
): Promise<PipelineBoardDto> {
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 ? dealAccessWhere(actor) : {}),
},
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<string, DealDto[]>();
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<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 },
});
}
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,
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<PipelineDto> {
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,
}),
),
};
}
}