Initial Mento CRM: portal OTP, reports, SMS, and deploy for crm.montzar.ir.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
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';
|
||||
import { type Actor, ownerWhere } from '../common/record-scope';
|
||||
import { decimalToBase, loadOrgRates } from '../common/currency';
|
||||
|
||||
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;
|
||||
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;
|
||||
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): Promise<PipelineDto> {
|
||||
await this.ensureDefaultPipeline(organizationId);
|
||||
const created = await this.prisma.pipeline.create({
|
||||
data: {
|
||||
organizationId,
|
||||
name: name.trim(),
|
||||
isDefault: false,
|
||||
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 getBoard(
|
||||
organizationId: string,
|
||||
pipelineId?: string,
|
||||
actor?: Pick<Actor, 'userId' | 'role'>,
|
||||
): 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 ? ownerWhere(actor) : {}),
|
||||
},
|
||||
include: {
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
company: { select: { id: true, name: true } },
|
||||
owner: { 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,
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
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 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,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user