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

660 lines
22 KiB
TypeScript

import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreateDealRequest,
DealDto,
DealLineItemInput,
MoveDealRequest,
UpdateDealRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { PipelinesService } from './pipelines.service';
import { toJsonCustomFields } from '../common/custom-fields';
import { NotificationsService } from '../notifications/notifications.service';
import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
import { WebhooksService } from '../public-api/webhooks.service';
import { GamificationService } from '../gamification/gamification.service';
import {
type Actor,
dealAccessWhere,
ownerWhere,
resolveOwnerId,
} from '../common/record-scope';
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
import { placeId, sameIds, writePositions } from '../common/column-order';
const dealInclude = {
person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } },
owner: { select: { id: true, fullName: true } },
createdBy: { select: { id: true, fullName: true } },
assignees: { select: { id: true, fullName: true } },
lineItems: { orderBy: { position: 'asc' as const } },
} satisfies Prisma.DealInclude;
type DealActor = Pick<
Actor,
'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'priorLens' | 'priorCutoff'
>;
@Injectable()
export class DealsService {
constructor(
private readonly prisma: PrismaService,
private readonly pipelines: PipelinesService,
private readonly notifications: NotificationsService,
private readonly automations: AutomationsService,
private readonly messageAutomations: MessageAutomationsService,
private readonly webhooks: WebhooksService,
private readonly game: GamificationService,
) {}
async get(
organizationId: string,
id: string,
actor: DealActor,
): Promise<DealDto> {
const row = await this.findDeal(organizationId, id, actor);
return this.pipelines.toDealDto(row);
}
async create(
organizationId: string,
actor: DealActor,
dto: CreateDealRequest,
): Promise<DealDto> {
const pipeline =
dto.pipelineId != null
? await this.prisma.pipeline.findFirst({
where: { id: dto.pipelineId, organizationId },
include: { stages: { orderBy: { position: 'asc' } } },
})
: null;
const resolvedPipeline =
pipeline ??
(await this.prisma.pipeline.findFirst({
where: { organizationId, isDefault: true },
include: { stages: { orderBy: { position: 'asc' } } },
}));
if (!resolvedPipeline) {
await this.pipelines.ensureDefaultPipeline(organizationId);
return this.create(organizationId, actor, dto);
}
const stage =
(dto.stageId
? resolvedPipeline.stages.find((s) => s.id === dto.stageId)
: resolvedPipeline.stages.find((s) => !s.isWon && !s.isLost)) ??
resolvedPipeline.stages[0];
if (!stage) {
throw new BadRequestException('مسیر فروش مرحله‌ای ندارد');
}
await this.assertLinks(organizationId, actor, dto.personId, dto.companyId);
if (dto.lineItems?.length) {
await this.assertProducts(organizationId, dto.lineItems);
}
const assigneeIds = await this.normalizeAssigneeIds(
organizationId,
dto.assigneeIds,
);
const assignActor = { userId: actor.userId, role: actor.role, organizationId };
const allowOthers = await actorCanAssignToOthers(this.prisma, assignActor, 'sales');
const ownerId = resolveOwnerId(actor, dto.ownerId, allowOthers);
await assertAssigneeSet(this.prisma, assignActor, 'sales', [ownerId, ...assigneeIds]);
const maxPos = await this.prisma.deal.aggregate({
where: { organizationId, stageId: stage.id },
_max: { position: true },
});
const status = stage.isWon ? 'won' : stage.isLost ? 'lost' : 'open';
const amountFromLines = sumLineItems(dto.lineItems);
const currency =
dto.lineItems?.[0]?.currency?.trim() ||
(dto.currency ?? 'IRR').trim() ||
'IRR';
const created = await this.prisma.deal.create({
data: {
organizationId,
pipelineId: resolvedPipeline.id,
stageId: stage.id,
title: dto.title.trim(),
amount:
amountFromLines != null
? new Prisma.Decimal(amountFromLines)
: dto.amount == null || Number.isNaN(Number(dto.amount))
? null
: new Prisma.Decimal(dto.amount),
currency,
status,
source: emptyToNull(dto.source),
notes: emptyToNull(dto.notes),
tags: normalizeTags(dto.tags),
isStale: dto.isStale ?? false,
isRotten: dto.isRotten ?? false,
customFields: toJsonCustomFields(dto.customFields),
expectedCloseAt: dto.expectedCloseAt
? new Date(dto.expectedCloseAt)
: null,
code: emptyToNull(dto.code),
probability: normalizeProbability(dto.probability),
sourceDetail: emptyToNull(dto.sourceDetail),
referrer: emptyToNull(dto.referrer),
lostReason: emptyToNull(dto.lostReason),
lostNote: emptyToNull(dto.lostNote),
closedAt: dto.closedAt
? new Date(dto.closedAt)
: status !== 'open'
? new Date()
: null,
createdById: actor.userId,
personId: emptyToNull(dto.personId ?? null),
companyId: emptyToNull(dto.companyId ?? null),
ownerId,
position: (maxPos._max.position ?? -1) + 1,
...(assigneeIds.length
? { assignees: { connect: assigneeIds.map((id) => ({ id })) } }
: {}),
...(dto.lineItems?.length
? {
lineItems: {
create: dto.lineItems.map((item, index) => ({
productId: emptyToNull(item.productId ?? null),
name: item.name.trim(),
quantity: new Prisma.Decimal(item.quantity),
unitPrice: new Prisma.Decimal(item.unitPrice),
currency: (item.currency ?? currency).trim() || 'IRR',
position: index,
})),
},
}
: {}),
},
include: dealInclude,
});
const createdCtx = {
organizationId,
entityType: 'deal' as const,
entityId: created.id,
title: created.title,
personId: created.personId,
companyId: created.companyId,
dealId: created.id,
ownerId: created.ownerId,
};
await this.automations.runTrigger('deal_created', createdCtx);
await this.messageAutomations.onEvent('deal_created', createdCtx);
this.game.emit({
organizationId,
userId: actor.userId,
event: 'DEAL_CREATED',
entityType: 'deal',
entityId: created.id,
entityTitle: created.title,
facts: { amount: created.amount == null ? null : Number(created.amount) },
});
this.webhooks.emit(organizationId, 'deal.created', {
id: created.id,
title: created.title,
status: created.status,
amount: created.amount == null ? null : Number(created.amount),
currency: created.currency,
stageId: created.stageId,
pipelineId: created.pipelineId,
});
return this.pipelines.toDealDto(created);
}
async update(
organizationId: string,
id: string,
actor: DealActor,
dto: UpdateDealRequest,
): Promise<DealDto> {
await this.ensureExists(organizationId, id, actor);
await this.assertLinks(organizationId, actor, dto.personId, dto.companyId);
if (dto.lineItems) {
await this.assertProducts(organizationId, dto.lineItems);
}
const data: Prisma.DealUncheckedUpdateInput = {};
const pricedFromLines = (dto.lineItems?.length ?? 0) > 0;
if (dto.title !== undefined) data.title = dto.title.trim();
if ('amount' in dto && !pricedFromLines) {
data.amount =
dto.amount == null || Number.isNaN(Number(dto.amount))
? null
: new Prisma.Decimal(dto.amount);
}
if (dto.currency !== undefined && !pricedFromLines) {
data.currency = dto.currency.trim() || 'IRR';
}
if ('source' in dto) data.source = emptyToNull(dto.source);
if ('notes' in dto) data.notes = emptyToNull(dto.notes);
if (dto.tags !== undefined) data.tags = normalizeTags(dto.tags);
if (dto.isStale !== undefined) data.isStale = dto.isStale;
if (dto.isRotten !== undefined) data.isRotten = dto.isRotten;
if ('customFields' in dto) {
data.customFields = toJsonCustomFields(dto.customFields);
}
if ('lostReason' in dto) data.lostReason = emptyToNull(dto.lostReason);
if ('lostNote' in dto) data.lostNote = emptyToNull(dto.lostNote);
if ('code' in dto) data.code = emptyToNull(dto.code);
if ('probability' in dto) data.probability = normalizeProbability(dto.probability);
if ('sourceDetail' in dto) data.sourceDetail = emptyToNull(dto.sourceDetail);
if ('referrer' in dto) data.referrer = emptyToNull(dto.referrer);
if ('closedAt' in dto) data.closedAt = dto.closedAt ? new Date(dto.closedAt) : null;
if ('expectedCloseAt' in dto) {
data.expectedCloseAt = dto.expectedCloseAt
? new Date(dto.expectedCloseAt)
: null;
}
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
let assigneeConnect: { id: string }[] | undefined;
if (dto.ownerId !== undefined || dto.assigneeIds !== undefined) {
const current = await this.prisma.deal.findFirst({
where: { id, organizationId },
select: { ownerId: true, assignees: { select: { id: true } } },
});
const assignActor = { userId: actor.userId, role: actor.role, organizationId };
if (dto.ownerId !== undefined) {
const nextOwner = emptyToNull(dto.ownerId ?? null);
await assertAssigneeSet(
this.prisma,
assignActor,
'sales',
[nextOwner],
[current?.ownerId],
);
data.ownerId = nextOwner ?? actor.userId;
}
if (dto.assigneeIds !== undefined) {
const assigneeIds = await this.normalizeAssigneeIds(
organizationId,
dto.assigneeIds,
);
await assertAssigneeSet(
this.prisma,
assignActor,
'sales',
assigneeIds,
current?.assignees.map((item) => item.id) ?? [],
);
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
}
}
if (pricedFromLines && dto.lineItems) {
const total = sumLineItems(dto.lineItems);
data.amount =
total == null ? null : new Prisma.Decimal(total);
if (dto.lineItems[0]?.currency) {
data.currency = dto.lineItems[0].currency.trim() || 'IRR';
}
}
await this.prisma.$transaction(async (tx) => {
if (dto.lineItems !== undefined) {
await tx.dealLineItem.deleteMany({ where: { dealId: id } });
if (dto.lineItems.length) {
const currency =
(typeof data.currency === 'string' ? data.currency : null) ||
'IRR';
await tx.dealLineItem.createMany({
data: dto.lineItems.map((item, index) => ({
dealId: id,
productId: emptyToNull(item.productId ?? null),
name: item.name.trim(),
quantity: new Prisma.Decimal(item.quantity),
unitPrice: new Prisma.Decimal(item.unitPrice),
currency: (item.currency ?? currency).trim() || 'IRR',
position: index,
})),
});
}
}
await tx.deal.update({
where: { id },
data: {
...data,
...(assigneeConnect
? { assignees: { set: assigneeConnect } }
: {}),
},
});
});
const updated = await this.get(organizationId, id, actor);
this.webhooks.emit(organizationId, 'deal.updated', {
id: updated.id,
title: updated.title,
status: updated.status,
amount: updated.amount,
currency: updated.currency,
stageId: updated.stageId,
pipelineId: updated.pipelineId,
});
return updated;
}
async move(
organizationId: string,
id: string,
actor: DealActor,
dto: MoveDealRequest,
): Promise<DealDto> {
const deal = await this.prisma.deal.findFirst({
where: { id, organizationId, ...dealAccessWhere(actor) },
select: {
id: true,
title: true,
pipelineId: true,
stageId: true,
ownerId: true,
status: true,
personId: true,
companyId: true,
},
});
if (!deal) throw new NotFoundException('معامله پیدا نشد');
const stage = await this.prisma.pipelineStage.findFirst({
where: { id: dto.stageId, pipelineId: deal.pipelineId },
});
if (!stage) {
throw new BadRequestException('مرحله در این مسیر فروش معتبر نیست');
}
const status = stage.isWon ? 'won' : stage.isLost ? 'lost' : 'open';
let placedIds: string[] | null = null;
let position =
dto.position ??
((
await this.prisma.deal.aggregate({
where: { organizationId, stageId: stage.id, NOT: { id } },
_max: { position: true },
})
)._max.position ?? -1) + 1;
if (dto.position != null) {
const rows = await this.prisma.deal.findMany({
where: { organizationId, stageId: stage.id },
select: { id: true },
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
});
const currentIds = rows.map((row) => row.id);
placedIds = placeId(currentIds, deal.id, dto.position);
position = Math.max(0, placedIds.indexOf(deal.id));
if (sameIds(currentIds, placedIds)) placedIds = null;
}
const row = await this.prisma.deal.update({
where: { id },
data: {
stageId: stage.id,
position,
status,
lostReason: stage.isLost ? undefined : null,
...(status !== deal.status
? { closedAt: status === 'open' ? null : new Date() }
: {}),
},
include: dealInclude,
});
if (placedIds) {
await writePositions(
(ops) => this.prisma.$transaction(ops),
placedIds,
(dealId, nextPosition) =>
this.prisma.deal.update({ where: { id: dealId }, data: { position: nextPosition } }),
);
}
if (deal.ownerId && deal.stageId !== stage.id) {
await this.notifications.create({
organizationId,
userId: deal.ownerId,
type: 'deal_moved',
title: 'جابه‌جایی معامله در مسیر فروش',
body: `«${deal.title}» به مرحله «${stage.name}» منتقل شد`,
link: `/deals/${deal.id}`,
entityType: 'deal',
entityId: deal.id,
});
}
if (deal.stageId !== stage.id) {
const ctx = {
organizationId,
entityType: 'deal' as const,
entityId: deal.id,
title: deal.title,
personId: deal.personId,
companyId: deal.companyId,
dealId: deal.id,
ownerId: deal.ownerId,
};
await this.automations.runTrigger('deal_stage_changed', ctx);
await this.messageAutomations.onEvent('deal_stage_changed', ctx);
const gameBase = {
organizationId,
userId: deal.ownerId ?? actor.userId,
entityType: 'deal',
entityId: deal.id,
entityTitle: deal.title,
facts: {
stageName: stage.name,
amount: row.amount == null ? null : Number(row.amount),
},
};
this.game.emit({ ...gameBase, event: 'DEAL_STAGE_CHANGED', eventKey: `${deal.id}:${stage.id}` });
if (status === 'won' && deal.status !== 'won') {
this.game.emit({ ...gameBase, event: 'DEAL_WON' });
}
if (status === 'lost' && deal.status !== 'lost') {
this.game.emit({ ...gameBase, event: 'DEAL_LOST' });
}
if (status === 'won' && deal.status !== 'won') {
await this.automations.runTrigger('deal_won', ctx);
await this.messageAutomations.onEvent('deal_won', ctx);
this.webhooks.emit(organizationId, 'deal.won', {
id: deal.id,
title: deal.title,
stageId: dto.stageId,
});
}
if (status === 'lost' && deal.status !== 'lost') {
await this.automations.runTrigger('deal_lost', ctx);
await this.messageAutomations.onEvent('deal_lost', ctx);
this.webhooks.emit(organizationId, 'deal.lost', {
id: deal.id,
title: deal.title,
stageId: dto.stageId,
});
}
}
return this.pipelines.toDealDto(row);
}
async promoteFromPrior(actor: Actor, id: string): Promise<DealDto> {
if (
(actor.priorLens !== 'current' && actor.priorLens !== 'archive') ||
!actor.priorCutoff
) {
throw new ForbiddenException('بایگانی اطلاعات قبل برای شما فعال نیست.');
}
const row = await this.prisma.deal.findFirst({
where: {
id,
organizationId: actor.organizationId,
...dealAccessWhere(actor),
},
select: { id: true, createdAt: true, promotedFromPriorAt: true },
});
if (!row) throw new NotFoundException('معامله پیدا نشد');
if (row.promotedFromPriorAt) {
throw new BadRequestException('این معامله قبلاً منتقل شده است.');
}
if (row.createdAt >= actor.priorCutoff) {
throw new BadRequestException('این معامله در بایگانی نیست.');
}
const updated = await this.prisma.deal.update({
where: { id },
data: { promotedFromPriorAt: new Date() },
include: dealInclude,
});
return this.pipelines.toDealDto(updated);
}
async remove(
organizationId: string,
id: string,
actor: DealActor,
): Promise<void> {
await this.ensureExists(organizationId, id, actor);
await this.prisma.deal.delete({ where: { id } });
}
private async ensureExists(
organizationId: string,
id: string,
actor: DealActor,
) {
const found = await this.prisma.deal.findFirst({
where: { id, organizationId, ...dealAccessWhere(actor) },
select: { id: true },
});
if (!found) throw new NotFoundException('معامله پیدا نشد');
}
private async findDeal(
organizationId: string,
id: string,
actor: DealActor,
) {
const row = await this.prisma.deal.findFirst({
where: { id, organizationId, ...dealAccessWhere(actor) },
include: dealInclude,
});
if (!row) throw new NotFoundException('معامله پیدا نشد');
return row;
}
private async normalizeAssigneeIds(
organizationId: string,
raw?: string[] | null,
): Promise<string[]> {
const ids = [
...new Set((raw ?? []).map((id) => id.trim()).filter(Boolean)),
];
if (!ids.length) return [];
const count = await this.prisma.user.count({
where: {
id: { in: ids },
isActive: true,
memberships: { some: { organizationId, isActive: true } },
},
});
if (count !== ids.length) {
throw new BadRequestException('یکی از مسئولان انتخاب‌شده معتبر نیست');
}
return ids;
}
private async assertProducts(
organizationId: string,
items: DealLineItemInput[],
) {
const ids = [
...new Set(
items
.map((i) => emptyToNull(i.productId ?? null))
.filter((id): id is string => Boolean(id)),
),
];
if (!ids.length) return;
const count = await this.prisma.product.count({
where: { organizationId, id: { in: ids } },
});
if (count !== ids.length) {
throw new BadRequestException('یکی از محصولات انتخاب‌شده معتبر نیست');
}
}
private async assertLinks(
organizationId: string,
actor: DealActor,
personId?: string | null,
companyId?: string | null,
) {
const pId = emptyToNull(personId ?? null);
const cId = emptyToNull(companyId ?? null);
const scope = ownerWhere(actor);
if (pId) {
const person = await this.prisma.person.findFirst({
where: { id: pId, organizationId, ...scope },
select: { id: true },
});
if (!person) throw new BadRequestException('شخص انتخاب‌شده معتبر نیست');
}
if (cId) {
const company = await this.prisma.company.findFirst({
where: { id: cId, organizationId, ...scope },
select: { id: true },
});
if (!company) throw new BadRequestException('شرکت انتخاب‌شده معتبر نیست');
}
}
}
function emptyToNull(value?: string | null): string | null {
if (value == null) return null;
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}
function normalizeProbability(value?: number | null): number | null {
if (value == null || Number.isNaN(Number(value))) return null;
return Math.max(0, Math.min(100, Math.round(Number(value))));
}
function normalizeTags(tags?: string[]): string[] {
if (!tags?.length) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const raw of tags) {
const tag = raw.trim();
if (!tag) continue;
const key = tag.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(tag.slice(0, 40));
}
return out.slice(0, 20);
}
function sumLineItems(items?: DealLineItemInput[]): number | null {
if (!items?.length) return null;
return items.reduce(
(sum, item) => sum + Number(item.quantity) * Number(item.unitPrice),
0,
);
}