2026-09-10 01:22:24 +03:30
|
|
|
import {
|
|
|
|
|
BadRequestException,
|
|
|
|
|
Injectable,
|
|
|
|
|
NotFoundException,
|
|
|
|
|
} from '@nestjs/common';
|
2026-08-29 21:06:30 +03:30
|
|
|
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';
|
2026-09-27 14:25:32 +03:30
|
|
|
import { type Actor, dealAccessWhere } from '../common/record-scope';
|
2026-08-29 21:06:30 +03:30
|
|
|
import { decimalToBase, loadOrgRates } from '../common/currency';
|
|
|
|
|
|
2026-10-06 16:58:25 +03:30
|
|
|
export const DEFAULT_STAGES = [
|
2026-08-29 21:06:30 +03:30
|
|
|
{ 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;
|
2026-10-06 16:36:04 +03:30
|
|
|
code: string | null;
|
|
|
|
|
importSource: string;
|
|
|
|
|
sourceRef: string | null;
|
|
|
|
|
probability: number | null;
|
|
|
|
|
sourceDetail: string | null;
|
|
|
|
|
referrer: string | null;
|
|
|
|
|
lostNote: string | null;
|
|
|
|
|
closedAt: Date | null;
|
2026-08-29 21:06:30 +03:30
|
|
|
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;
|
2026-10-06 16:36:04 +03:30
|
|
|
createdBy?: { id: string; fullName: string } | null;
|
2026-09-27 14:25:32 +03:30
|
|
|
assignees?: Array<{ id: string; fullName: string }>;
|
2026-08-29 21:06:30 +03:30
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-06 16:58:25 +03:30
|
|
|
async create(
|
|
|
|
|
organizationId: string,
|
|
|
|
|
name: string,
|
|
|
|
|
opts: { withDefaultStages?: boolean } = {},
|
|
|
|
|
): Promise<PipelineDto> {
|
2026-08-29 21:06:30 +03:30
|
|
|
await this.ensureDefaultPipeline(organizationId);
|
2026-10-06 16:58:25 +03:30
|
|
|
const withDefaults = opts.withDefaultStages ?? true;
|
2026-08-29 21:06:30 +03:30
|
|
|
const created = await this.prisma.pipeline.create({
|
|
|
|
|
data: {
|
|
|
|
|
organizationId,
|
|
|
|
|
name: name.trim(),
|
|
|
|
|
isDefault: false,
|
2026-10-06 16:58:25 +03:30
|
|
|
...(withDefaults
|
|
|
|
|
? {
|
|
|
|
|
stages: {
|
|
|
|
|
create: DEFAULT_STAGES.map((s, index) => ({
|
|
|
|
|
name: s.name,
|
|
|
|
|
position: index,
|
|
|
|
|
isWon: s.isWon,
|
|
|
|
|
isLost: s.isLost,
|
|
|
|
|
})),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
: {}),
|
2026-08-29 21:06:30 +03:30
|
|
|
},
|
|
|
|
|
include: { stages: { orderBy: { position: 'asc' } } },
|
|
|
|
|
});
|
|
|
|
|
return this.toPipelineDto(created);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 01:22:24 +03:30
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 21:06:30 +03:30
|
|
|
async getBoard(
|
|
|
|
|
organizationId: string,
|
|
|
|
|
pipelineId?: string,
|
2026-09-27 14:25:32 +03:30
|
|
|
actor?: Pick<
|
|
|
|
|
Actor,
|
|
|
|
|
'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'
|
|
|
|
|
>,
|
2026-08-29 21:06:30 +03:30
|
|
|
): 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,
|
2026-09-27 14:25:32 +03:30
|
|
|
...(actor ? dealAccessWhere(actor) : {}),
|
2026-08-29 21:06:30 +03:30
|
|
|
},
|
|
|
|
|
include: {
|
|
|
|
|
person: { select: { id: true, firstName: true, lastName: true } },
|
|
|
|
|
company: { select: { id: true, name: true } },
|
|
|
|
|
owner: { select: { id: true, fullName: true } },
|
2026-09-27 14:25:32 +03:30
|
|
|
assignees: { select: { id: true, fullName: true } },
|
2026-08-29 21:06:30 +03:30
|
|
|
},
|
|
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
2026-10-06 16:36:04 +03:30
|
|
|
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,
|
2026-08-29 21:06:30 +03:30
|
|
|
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,
|
2026-09-27 14:25:32 +03:30
|
|
|
assignees: row.assignees ?? [],
|
2026-08-29 21:06:30 +03:30
|
|
|
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(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 01:22:24 +03:30
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 21:06:30 +03:30
|
|
|
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,
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|