546 lines
17 KiB
TypeScript
546 lines
17 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
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 {
|
|
type Actor,
|
|
canReassignOwner,
|
|
dealAccessWhere,
|
|
ownerWhere,
|
|
resolveOwnerId,
|
|
} from '../common/record-scope';
|
|
|
|
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'
|
|
>;
|
|
|
|
@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,
|
|
) {}
|
|
|
|
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 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: resolveOwnerId(actor, dto.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.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 = {};
|
|
if (dto.title !== undefined) data.title = dto.title.trim();
|
|
if ('amount' in dto && dto.lineItems === undefined) {
|
|
data.amount =
|
|
dto.amount == null || Number.isNaN(Number(dto.amount))
|
|
? null
|
|
: new Prisma.Decimal(dto.amount);
|
|
}
|
|
if (dto.currency !== undefined && dto.lineItems === undefined) {
|
|
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);
|
|
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
|
|
data.ownerId = resolveOwnerId(actor, dto.ownerId);
|
|
}
|
|
|
|
let assigneeConnect: { id: string }[] | undefined;
|
|
if (dto.assigneeIds !== undefined) {
|
|
const assigneeIds = await this.normalizeAssigneeIds(
|
|
organizationId,
|
|
dto.assigneeIds,
|
|
);
|
|
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
|
|
}
|
|
|
|
if (dto.lineItems !== undefined) {
|
|
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';
|
|
const position =
|
|
dto.position ??
|
|
((
|
|
await this.prisma.deal.aggregate({
|
|
where: { organizationId, stageId: stage.id, NOT: { id } },
|
|
_max: { position: true },
|
|
})
|
|
)._max.position ?? -1) + 1;
|
|
|
|
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 (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);
|
|
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 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: { organizationId, id: { in: ids }, 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,
|
|
);
|
|
}
|