Leads can upload/change/remove a logo shown in the list; stats use fixed stage labels with accurate non-archived counts.
1078 lines
31 KiB
TypeScript
1078 lines
31 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { Prisma } from '@prisma/client';
|
|
import * as path from 'node:path';
|
|
import type {
|
|
ConvertLeadResult,
|
|
LeadBoardColumnDto,
|
|
LeadDto,
|
|
LeadDuplicateCheckResult,
|
|
LeadEventDto,
|
|
LeadStatsDto,
|
|
PaginatedResponse,
|
|
} from '@mento/shared';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import {
|
|
type Actor,
|
|
elevated,
|
|
} from '../common/record-scope';
|
|
import { emptyToNull, normalizePhone } from '../contacts/normalize';
|
|
import { LocalFileStorage } from '../storage/local-file.storage';
|
|
import { LeadStagesService } from './lead-stages.service';
|
|
|
|
const LOGO_MAX_BYTES = 2 * 1024 * 1024;
|
|
const LOGO_EXT: Record<string, string> = {
|
|
'image/jpeg': 'jpg',
|
|
'image/jpg': 'jpg',
|
|
'image/png': 'png',
|
|
'image/webp': 'webp',
|
|
'image/gif': 'gif',
|
|
};
|
|
|
|
function mobileKey(raw: string | null | undefined): string | null {
|
|
const n = normalizePhone(raw);
|
|
if (!n) return null;
|
|
const d = n.replace(/\D/g, '');
|
|
if (!d) return null;
|
|
if (d.startsWith('98') && d.length >= 12) return `0${d.slice(2)}`;
|
|
if (d.length === 10 && d.startsWith('9')) return `0${d}`;
|
|
return d.startsWith('0') ? d : d;
|
|
}
|
|
|
|
function leadAccessWhere(actor: Actor): Prisma.LeadWhereInput {
|
|
if (elevated(actor.role)) return {};
|
|
if (actor.dataVisibility === 'org') return {};
|
|
if (
|
|
actor.dataVisibility === 'team' &&
|
|
actor.visibleUserIds &&
|
|
actor.visibleUserIds.length
|
|
) {
|
|
return { assigneeId: { in: actor.visibleUserIds } };
|
|
}
|
|
return { assigneeId: actor.userId };
|
|
}
|
|
|
|
const leadInclude = {
|
|
stage: true,
|
|
assignee: { select: { id: true, fullName: true } },
|
|
} as const;
|
|
|
|
type LeadRow = Prisma.LeadGetPayload<{ include: typeof leadInclude }>;
|
|
|
|
@Injectable()
|
|
export class LeadsService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly stages: LeadStagesService,
|
|
private readonly files: LocalFileStorage,
|
|
) {}
|
|
|
|
async list(
|
|
actor: Actor,
|
|
opts: {
|
|
q?: string;
|
|
page?: number;
|
|
limit?: number;
|
|
stageId?: string;
|
|
assigneeId?: string;
|
|
city?: string;
|
|
trade?: string;
|
|
source?: string;
|
|
unassigned?: boolean;
|
|
archived?: boolean;
|
|
sort?: string;
|
|
},
|
|
): Promise<PaginatedResponse<LeadDto>> {
|
|
await this.stages.ensureDefaults(actor.organizationId);
|
|
const page = Math.max(1, opts.page ?? 1);
|
|
const limit = Math.min(100, Math.max(1, opts.limit ?? 20));
|
|
const q = opts.q?.trim();
|
|
|
|
const where: Prisma.LeadWhereInput = {
|
|
organizationId: actor.organizationId,
|
|
...leadAccessWhere(actor),
|
|
archivedAt: opts.archived ? { not: null } : null,
|
|
...(opts.stageId ? { stageId: opts.stageId } : {}),
|
|
...(opts.assigneeId ? { assigneeId: opts.assigneeId } : {}),
|
|
...(opts.unassigned ? { assigneeId: null } : {}),
|
|
...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}),
|
|
...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}),
|
|
...(opts.source
|
|
? { source: { contains: opts.source, mode: 'insensitive' } }
|
|
: {}),
|
|
...(q
|
|
? {
|
|
OR: [
|
|
{ title: { contains: q, mode: 'insensitive' } },
|
|
{ contactName: { contains: q, mode: 'insensitive' } },
|
|
{ mobile: { contains: q } },
|
|
{ city: { contains: q, mode: 'insensitive' } },
|
|
{ trade: { contains: q, mode: 'insensitive' } },
|
|
],
|
|
}
|
|
: {}),
|
|
};
|
|
|
|
const orderBy: Prisma.LeadOrderByWithRelationInput =
|
|
opts.sort === 'follow_up'
|
|
? { nextFollowUpAt: 'asc' }
|
|
: opts.sort === 'last_contact'
|
|
? { lastContactAt: 'desc' }
|
|
: opts.sort === 'newest'
|
|
? { createdAt: 'desc' }
|
|
: { updatedAt: 'desc' };
|
|
|
|
const [total, rows] = await Promise.all([
|
|
this.prisma.lead.count({ where }),
|
|
this.prisma.lead.findMany({
|
|
where,
|
|
include: leadInclude,
|
|
orderBy,
|
|
skip: (page - 1) * limit,
|
|
take: limit,
|
|
}),
|
|
]);
|
|
|
|
return {
|
|
items: rows.map((r) => this.toDto(r)),
|
|
total,
|
|
page,
|
|
limit,
|
|
};
|
|
}
|
|
|
|
async board(actor: Actor): Promise<LeadBoardColumnDto[]> {
|
|
const stages = await this.stages.list(actor.organizationId);
|
|
const boardStages = stages.filter((s) => s.showOnBoard);
|
|
const leads = await this.prisma.lead.findMany({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
...leadAccessWhere(actor),
|
|
archivedAt: null,
|
|
stageId: { in: boardStages.map((s) => s.id) },
|
|
},
|
|
include: leadInclude,
|
|
orderBy: [{ stageId: 'asc' }, { position: 'asc' }, { updatedAt: 'desc' }],
|
|
take: 500,
|
|
});
|
|
const byStage = new Map<string, LeadDto[]>();
|
|
for (const s of boardStages) byStage.set(s.id, []);
|
|
for (const row of leads) {
|
|
const list = byStage.get(row.stageId);
|
|
if (list) list.push(this.toDto(row));
|
|
}
|
|
return boardStages.map((stage) => ({
|
|
stage,
|
|
leads: byStage.get(stage.id) ?? [],
|
|
}));
|
|
}
|
|
|
|
async get(actor: Actor, id: string): Promise<LeadDto & { events: LeadEventDto[] }> {
|
|
const row = await this.findAccessible(actor, id);
|
|
const events = await this.prisma.leadEvent.findMany({
|
|
where: { leadId: id },
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 100,
|
|
});
|
|
return {
|
|
...this.toDto(row),
|
|
events: events.map((e) => ({
|
|
id: e.id,
|
|
type: e.type,
|
|
title: e.title,
|
|
body: e.body,
|
|
actorId: e.actorId,
|
|
createdAt: e.createdAt.toISOString(),
|
|
})),
|
|
};
|
|
}
|
|
|
|
async checkDuplicate(
|
|
actor: Actor,
|
|
mobileRaw: string,
|
|
): Promise<LeadDuplicateCheckResult> {
|
|
const mobile = mobileKey(mobileRaw);
|
|
if (!mobile) {
|
|
return { exists: false, kind: null, id: null, title: null };
|
|
}
|
|
const lead = await this.prisma.lead.findFirst({
|
|
where: { organizationId: actor.organizationId, mobile },
|
|
select: { id: true, title: true },
|
|
});
|
|
if (lead) {
|
|
return {
|
|
exists: true,
|
|
kind: 'lead',
|
|
id: lead.id,
|
|
title: lead.title,
|
|
};
|
|
}
|
|
const person = await this.prisma.person.findFirst({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
OR: [{ mobile }, { phone: mobile }],
|
|
},
|
|
select: { id: true, firstName: true, lastName: true },
|
|
});
|
|
if (person) {
|
|
return {
|
|
exists: true,
|
|
kind: 'person',
|
|
id: person.id,
|
|
title: `${person.firstName} ${person.lastName}`.trim(),
|
|
};
|
|
}
|
|
return { exists: false, kind: null, id: null, title: null };
|
|
}
|
|
|
|
async create(
|
|
actor: Actor,
|
|
dto: {
|
|
title: string;
|
|
mobile: string;
|
|
contactName?: string | null;
|
|
phone?: string | null;
|
|
city?: string | null;
|
|
region?: string | null;
|
|
trade?: string | null;
|
|
source?: string | null;
|
|
notes?: string | null;
|
|
estimatedValue?: number | null;
|
|
stageId?: string;
|
|
assigneeId?: string | null;
|
|
nextFollowUpAt?: string | null;
|
|
},
|
|
): Promise<LeadDto> {
|
|
await this.stages.ensureDefaults(actor.organizationId);
|
|
const mobile = mobileKey(dto.mobile);
|
|
if (!mobile) throw new BadRequestException('شماره موبایل معتبر نیست');
|
|
|
|
const dup = await this.checkDuplicate(actor, mobile);
|
|
if (dup.exists) {
|
|
throw new ConflictException(
|
|
dup.kind === 'person'
|
|
? `این شماره قبلاً بهعنوان مشتری ثبت شده است (${dup.title})`
|
|
: `این شماره قبلاً بهعنوان لید ثبت شده است (${dup.title})`,
|
|
);
|
|
}
|
|
|
|
const stages = await this.stages.list(actor.organizationId);
|
|
const stageId = dto.stageId || stages[0]?.id;
|
|
if (!stageId) throw new BadRequestException('مرحلهای تعریف نشده');
|
|
|
|
let assigneeId: string | null = null;
|
|
if (elevated(actor.role) && dto.assigneeId) {
|
|
assigneeId = dto.assigneeId;
|
|
} else if (!elevated(actor.role)) {
|
|
assigneeId = actor.userId;
|
|
}
|
|
|
|
const row = await this.prisma.lead.create({
|
|
data: {
|
|
organizationId: actor.organizationId,
|
|
title: dto.title.trim(),
|
|
contactName: emptyToNull(dto.contactName),
|
|
mobile,
|
|
phone: emptyToNull(dto.phone),
|
|
city: emptyToNull(dto.city),
|
|
region: emptyToNull(dto.region),
|
|
trade: emptyToNull(dto.trade),
|
|
source: emptyToNull(dto.source),
|
|
notes: emptyToNull(dto.notes),
|
|
estimatedValue:
|
|
dto.estimatedValue != null
|
|
? new Prisma.Decimal(dto.estimatedValue)
|
|
: null,
|
|
stageId,
|
|
assigneeId,
|
|
createdById: actor.userId,
|
|
nextFollowUpAt: dto.nextFollowUpAt
|
|
? new Date(dto.nextFollowUpAt)
|
|
: null,
|
|
},
|
|
include: leadInclude,
|
|
});
|
|
|
|
await this.addEvent(actor, row.id, 'created', 'لید ایجاد شد', null);
|
|
if (assigneeId) {
|
|
await this.addEvent(
|
|
actor,
|
|
row.id,
|
|
'assigned',
|
|
`به کارشناس اختصاص داده شد`,
|
|
null,
|
|
{ assigneeId },
|
|
);
|
|
}
|
|
return this.toDto(row);
|
|
}
|
|
|
|
async update(
|
|
actor: Actor,
|
|
id: string,
|
|
dto: Record<string, unknown>,
|
|
): Promise<LeadDto> {
|
|
const existing = await this.findAccessible(actor, id);
|
|
if (existing.archivedAt) {
|
|
throw new BadRequestException('لید بایگانیشده قابل ویرایش نیست');
|
|
}
|
|
|
|
let mobile = existing.mobile;
|
|
if (typeof dto.mobile === 'string') {
|
|
const next = mobileKey(dto.mobile);
|
|
if (!next) throw new BadRequestException('شماره موبایل معتبر نیست');
|
|
if (next !== existing.mobile) {
|
|
const dup = await this.checkDuplicate(actor, next);
|
|
if (dup.exists && dup.id !== id) {
|
|
throw new ConflictException(
|
|
dup.kind === 'person'
|
|
? 'این شماره متعلق به یک مشتری است'
|
|
: 'این شماره متعلق به لید دیگری است',
|
|
);
|
|
}
|
|
mobile = next;
|
|
}
|
|
}
|
|
|
|
const data: Prisma.LeadUpdateInput = {
|
|
...(typeof dto.title === 'string' ? { title: dto.title.trim() } : {}),
|
|
mobile,
|
|
...(dto.contactName !== undefined
|
|
? { contactName: emptyToNull(dto.contactName as string) }
|
|
: {}),
|
|
...(dto.phone !== undefined
|
|
? { phone: emptyToNull(dto.phone as string) }
|
|
: {}),
|
|
...(dto.city !== undefined ? { city: emptyToNull(dto.city as string) } : {}),
|
|
...(dto.region !== undefined
|
|
? { region: emptyToNull(dto.region as string) }
|
|
: {}),
|
|
...(dto.trade !== undefined
|
|
? { trade: emptyToNull(dto.trade as string) }
|
|
: {}),
|
|
...(dto.source !== undefined
|
|
? { source: emptyToNull(dto.source as string) }
|
|
: {}),
|
|
...(dto.notes !== undefined
|
|
? { notes: emptyToNull(dto.notes as string) }
|
|
: {}),
|
|
...(dto.estimatedValue !== undefined
|
|
? {
|
|
estimatedValue:
|
|
dto.estimatedValue == null
|
|
? null
|
|
: new Prisma.Decimal(Number(dto.estimatedValue)),
|
|
}
|
|
: {}),
|
|
...(dto.nextFollowUpAt !== undefined
|
|
? {
|
|
nextFollowUpAt: dto.nextFollowUpAt
|
|
? new Date(String(dto.nextFollowUpAt))
|
|
: null,
|
|
}
|
|
: {}),
|
|
...(dto.lastContactAt !== undefined
|
|
? {
|
|
lastContactAt: dto.lastContactAt
|
|
? new Date(String(dto.lastContactAt))
|
|
: null,
|
|
}
|
|
: {}),
|
|
};
|
|
|
|
if (typeof dto.stageId === 'string' && dto.stageId !== existing.stageId) {
|
|
data.stage = { connect: { id: dto.stageId } };
|
|
}
|
|
if (elevated(actor.role) && dto.assigneeId !== undefined) {
|
|
const aid = dto.assigneeId ? String(dto.assigneeId) : null;
|
|
data.assignee = aid
|
|
? { connect: { id: aid } }
|
|
: { disconnect: true };
|
|
}
|
|
|
|
const row = await this.prisma.lead.update({
|
|
where: { id },
|
|
data,
|
|
include: leadInclude,
|
|
});
|
|
|
|
if (typeof dto.stageId === 'string' && dto.stageId !== existing.stageId) {
|
|
await this.addEvent(
|
|
actor,
|
|
id,
|
|
'stage_changed',
|
|
`وضعیت به «${row.stage.name}» تغییر کرد`,
|
|
);
|
|
}
|
|
return this.toDto(row);
|
|
}
|
|
|
|
async move(
|
|
actor: Actor,
|
|
id: string,
|
|
stageId: string,
|
|
position?: number,
|
|
): Promise<LeadDto> {
|
|
await this.findAccessible(actor, id);
|
|
const stage = await this.prisma.leadStage.findFirst({
|
|
where: { id: stageId, organizationId: actor.organizationId },
|
|
});
|
|
if (!stage) throw new NotFoundException('مرحله پیدا نشد');
|
|
|
|
const row = await this.prisma.lead.update({
|
|
where: { id },
|
|
data: {
|
|
stageId,
|
|
position: position ?? 0,
|
|
...(stage.isConverted
|
|
? {}
|
|
: {}),
|
|
},
|
|
include: leadInclude,
|
|
});
|
|
await this.addEvent(
|
|
actor,
|
|
id,
|
|
'stage_changed',
|
|
`به مرحله «${stage.name}» منتقل شد`,
|
|
);
|
|
return this.toDto(row);
|
|
}
|
|
|
|
async assign(
|
|
actor: Actor,
|
|
leadIds: string[],
|
|
assigneeId: string,
|
|
): Promise<number> {
|
|
if (!elevated(actor.role)) {
|
|
throw new BadRequestException('فقط مدیر میتواند تخصیص دهد');
|
|
}
|
|
const user = await this.prisma.user.findFirst({
|
|
where: { id: assigneeId, organizationId: actor.organizationId },
|
|
});
|
|
if (!user) throw new NotFoundException('کارشناس پیدا نشد');
|
|
|
|
const result = await this.prisma.lead.updateMany({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
id: { in: leadIds },
|
|
archivedAt: null,
|
|
},
|
|
data: { assigneeId },
|
|
});
|
|
|
|
for (const leadId of leadIds) {
|
|
await this.addEvent(
|
|
actor,
|
|
leadId,
|
|
'assigned',
|
|
`به «${user.fullName}» اختصاص داده شد`,
|
|
null,
|
|
{ assigneeId },
|
|
);
|
|
}
|
|
return result.count;
|
|
}
|
|
|
|
async bulkStage(
|
|
actor: Actor,
|
|
leadIds: string[],
|
|
stageId: string,
|
|
): Promise<number> {
|
|
if (!elevated(actor.role)) {
|
|
throw new BadRequestException('فقط مدیر میتواند وضعیت گروهی تغییر دهد');
|
|
}
|
|
const stage = await this.prisma.leadStage.findFirst({
|
|
where: { id: stageId, organizationId: actor.organizationId },
|
|
});
|
|
if (!stage) throw new NotFoundException('مرحله پیدا نشد');
|
|
const result = await this.prisma.lead.updateMany({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
id: { in: leadIds },
|
|
archivedAt: null,
|
|
},
|
|
data: { stageId },
|
|
});
|
|
for (const leadId of leadIds) {
|
|
await this.addEvent(
|
|
actor,
|
|
leadId,
|
|
'stage_changed',
|
|
`وضعیت گروهی به «${stage.name}»`,
|
|
);
|
|
}
|
|
return result.count;
|
|
}
|
|
|
|
async autoAssign(
|
|
actor: Actor,
|
|
opts: {
|
|
rule?: string;
|
|
leadIds?: string[];
|
|
assigneeIds?: string[];
|
|
onlyUnassigned?: boolean;
|
|
},
|
|
): Promise<{ assigned: number }> {
|
|
if (!elevated(actor.role)) {
|
|
throw new BadRequestException('فقط مدیر میتواند تخصیص خودکار کند');
|
|
}
|
|
const rule = opts.rule === 'equal_split' ? 'equal_split' : 'round_robin';
|
|
let assignees = opts.assigneeIds?.length
|
|
? await this.prisma.user.findMany({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
id: { in: opts.assigneeIds },
|
|
isActive: true,
|
|
},
|
|
select: { id: true, fullName: true },
|
|
})
|
|
: await this.prisma.user.findMany({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
isActive: true,
|
|
memberships: {
|
|
some: {
|
|
organizationId: actor.organizationId,
|
|
isActive: true,
|
|
role: { in: ['sales', 'admin', 'owner'] },
|
|
},
|
|
},
|
|
},
|
|
select: { id: true, fullName: true },
|
|
});
|
|
|
|
assignees = assignees.filter((u) => u.id);
|
|
if (!assignees.length) {
|
|
throw new BadRequestException('کارشناسی برای تخصیص پیدا نشد');
|
|
}
|
|
|
|
const leads = await this.prisma.lead.findMany({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
archivedAt: null,
|
|
...(opts.leadIds?.length ? { id: { in: opts.leadIds } } : {}),
|
|
...(opts.onlyUnassigned !== false ? { assigneeId: null } : {}),
|
|
},
|
|
orderBy: { createdAt: 'asc' },
|
|
select: { id: true },
|
|
});
|
|
|
|
if (!leads.length) return { assigned: 0 };
|
|
|
|
let assigned = 0;
|
|
if (rule === 'equal_split') {
|
|
const chunk = Math.ceil(leads.length / assignees.length);
|
|
for (let i = 0; i < assignees.length; i += 1) {
|
|
const slice = leads.slice(i * chunk, (i + 1) * chunk);
|
|
if (!slice.length) continue;
|
|
await this.assign(
|
|
actor,
|
|
slice.map((l) => l.id),
|
|
assignees[i].id,
|
|
);
|
|
assigned += slice.length;
|
|
}
|
|
} else {
|
|
for (let i = 0; i < leads.length; i += 1) {
|
|
const user = assignees[i % assignees.length];
|
|
await this.prisma.lead.update({
|
|
where: { id: leads[i].id },
|
|
data: { assigneeId: user.id },
|
|
});
|
|
await this.addEvent(
|
|
actor,
|
|
leads[i].id,
|
|
'assigned',
|
|
`تخصیص خودکار (نوبتی) به «${user.fullName}»`,
|
|
null,
|
|
{ assigneeId: user.id },
|
|
);
|
|
assigned += 1;
|
|
}
|
|
}
|
|
return { assigned };
|
|
}
|
|
|
|
async logCall(
|
|
actor: Actor,
|
|
id: string,
|
|
dto: {
|
|
outcome?: string;
|
|
body?: string;
|
|
nextFollowUpAt?: string | null;
|
|
stageId?: string;
|
|
},
|
|
): Promise<LeadDto> {
|
|
await this.findAccessible(actor, id);
|
|
const title = dto.outcome
|
|
? `تماس ثبت شد — ${dto.outcome}`
|
|
: 'تماس ثبت شد';
|
|
await this.addEvent(actor, id, 'call', title, dto.body ?? null);
|
|
const data: Prisma.LeadUpdateInput = {
|
|
lastContactAt: new Date(),
|
|
...(dto.nextFollowUpAt !== undefined
|
|
? {
|
|
nextFollowUpAt: dto.nextFollowUpAt
|
|
? new Date(dto.nextFollowUpAt)
|
|
: null,
|
|
}
|
|
: {}),
|
|
};
|
|
if (dto.stageId) data.stage = { connect: { id: dto.stageId } };
|
|
const row = await this.prisma.lead.update({
|
|
where: { id },
|
|
data,
|
|
include: leadInclude,
|
|
});
|
|
return this.toDto(row);
|
|
}
|
|
|
|
async addNote(actor: Actor, id: string, body: string): Promise<LeadDto> {
|
|
await this.findAccessible(actor, id);
|
|
await this.addEvent(actor, id, 'note', 'یادداشت', body);
|
|
const row = await this.findAccessible(actor, id);
|
|
return this.toDto(row);
|
|
}
|
|
|
|
async setFollowUp(
|
|
actor: Actor,
|
|
id: string,
|
|
nextFollowUpAt: string,
|
|
body?: string,
|
|
): Promise<LeadDto> {
|
|
await this.findAccessible(actor, id);
|
|
const at = new Date(nextFollowUpAt);
|
|
await this.addEvent(
|
|
actor,
|
|
id,
|
|
'follow_up',
|
|
'پیگیری بعدی ثبت شد',
|
|
body ?? null,
|
|
{ nextFollowUpAt: at.toISOString() },
|
|
);
|
|
const row = await this.prisma.lead.update({
|
|
where: { id },
|
|
data: { nextFollowUpAt: at },
|
|
include: leadInclude,
|
|
});
|
|
return this.toDto(row);
|
|
}
|
|
|
|
async archive(actor: Actor, ids: string[]): Promise<number> {
|
|
if (!elevated(actor.role) && ids.length > 1) {
|
|
throw new BadRequestException('حذف گروهی فقط برای مدیر');
|
|
}
|
|
for (const id of ids) {
|
|
await this.findAccessible(actor, id);
|
|
await this.addEvent(actor, id, 'archived', 'لید بایگانی شد');
|
|
}
|
|
const result = await this.prisma.lead.updateMany({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
id: { in: ids },
|
|
},
|
|
data: { archivedAt: new Date() },
|
|
});
|
|
return result.count;
|
|
}
|
|
|
|
async convert(
|
|
actor: Actor,
|
|
id: string,
|
|
dto: { firstName?: string; lastName?: string },
|
|
): Promise<ConvertLeadResult> {
|
|
const lead = await this.findAccessible(actor, id);
|
|
if (lead.convertedPersonId) {
|
|
return {
|
|
lead: this.toDto(lead),
|
|
personId: lead.convertedPersonId,
|
|
createdPerson: false,
|
|
};
|
|
}
|
|
|
|
let person = await this.prisma.person.findFirst({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
OR: [{ mobile: lead.mobile }, { phone: lead.mobile }],
|
|
},
|
|
});
|
|
let createdPerson = false;
|
|
|
|
if (!person) {
|
|
const rawName = dto.firstName || dto.lastName
|
|
? `${dto.firstName ?? ''} ${dto.lastName ?? ''}`.trim()
|
|
: (lead.contactName || lead.title).trim();
|
|
const nameParts = rawName.split(/\s+/).filter(Boolean);
|
|
const firstName = nameParts[0] || lead.title;
|
|
const lastName = nameParts.slice(1).join(' ') || '—';
|
|
person = await this.prisma.person.create({
|
|
data: {
|
|
organizationId: actor.organizationId,
|
|
firstName,
|
|
lastName,
|
|
mobile: lead.mobile,
|
|
phone: lead.phone,
|
|
notes: [
|
|
lead.notes,
|
|
lead.city ? `شهر: ${lead.city}` : null,
|
|
lead.trade ? `صنف: ${lead.trade}` : null,
|
|
lead.source ? `منبع لید: ${lead.source}` : null,
|
|
`تبدیل از لید: ${lead.title}`,
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n'),
|
|
ownerId: lead.assigneeId ?? actor.userId,
|
|
},
|
|
});
|
|
createdPerson = true;
|
|
}
|
|
|
|
const convertedStage = await this.prisma.leadStage.findFirst({
|
|
where: { organizationId: actor.organizationId, isConverted: true },
|
|
orderBy: { position: 'asc' },
|
|
});
|
|
|
|
const row = await this.prisma.lead.update({
|
|
where: { id },
|
|
data: {
|
|
convertedPersonId: person.id,
|
|
convertedAt: new Date(),
|
|
...(convertedStage ? { stageId: convertedStage.id } : {}),
|
|
},
|
|
include: leadInclude,
|
|
});
|
|
|
|
await this.addEvent(
|
|
actor,
|
|
id,
|
|
'converted',
|
|
`تبدیل به مشتری انجام شد`,
|
|
null,
|
|
{ personId: person.id },
|
|
);
|
|
|
|
await this.prisma.activity
|
|
.create({
|
|
data: {
|
|
organizationId: actor.organizationId,
|
|
title: `لید تبدیلشده: ${lead.title}`,
|
|
type: 'note',
|
|
status: 'done',
|
|
priority: 'medium',
|
|
description: `این مشتری از لید «${lead.title}» تبدیل شده است.`,
|
|
personId: person.id,
|
|
assigneeId: lead.assigneeId ?? actor.userId,
|
|
createdById: actor.userId,
|
|
tags: ['lead-convert'],
|
|
dueAt: new Date(),
|
|
},
|
|
})
|
|
.catch(() => undefined);
|
|
|
|
return {
|
|
lead: this.toDto(row),
|
|
personId: person.id,
|
|
createdPerson,
|
|
};
|
|
}
|
|
|
|
async stats(actor: Actor): Promise<LeadStatsDto> {
|
|
await this.stages.ensureDefaults(actor.organizationId);
|
|
const base: Prisma.LeadWhereInput = {
|
|
organizationId: actor.organizationId,
|
|
...leadAccessWhere(actor),
|
|
archivedAt: null,
|
|
};
|
|
const stages = await this.stages.list(actor.organizationId);
|
|
const convertedIds = stages.filter((s) => s.isConverted).map((s) => s.id);
|
|
const lostIds = stages.filter((s) => s.isLost).map((s) => s.id);
|
|
const interested = stages.find((s) => s.name.includes('علاقهمند'));
|
|
const newStage = stages.find((s) => s.name === 'جدید');
|
|
|
|
const [
|
|
total,
|
|
newCount,
|
|
unassigned,
|
|
converted,
|
|
lost,
|
|
interestedCount,
|
|
withFollowUp,
|
|
callsLogged,
|
|
byAssigneeRaw,
|
|
] = await Promise.all([
|
|
this.prisma.lead.count({ where: base }),
|
|
newStage
|
|
? this.prisma.lead.count({ where: { ...base, stageId: newStage.id } })
|
|
: Promise.resolve(0),
|
|
this.prisma.lead.count({ where: { ...base, assigneeId: null } }),
|
|
convertedIds.length
|
|
? this.prisma.lead.count({
|
|
where: { ...base, stageId: { in: convertedIds } },
|
|
})
|
|
: Promise.resolve(0),
|
|
lostIds.length
|
|
? this.prisma.lead.count({
|
|
where: { ...base, stageId: { in: lostIds } },
|
|
})
|
|
: Promise.resolve(0),
|
|
interested
|
|
? this.prisma.lead.count({
|
|
where: { ...base, stageId: interested.id },
|
|
})
|
|
: Promise.resolve(0),
|
|
this.prisma.lead.count({
|
|
where: { ...base, nextFollowUpAt: { not: null } },
|
|
}),
|
|
this.prisma.leadEvent.count({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
type: 'call',
|
|
},
|
|
}),
|
|
this.prisma.lead.groupBy({
|
|
by: ['assigneeId'],
|
|
where: { ...base, assigneeId: { not: null } },
|
|
_count: { _all: true },
|
|
}),
|
|
]);
|
|
|
|
const userIds = byAssigneeRaw
|
|
.map((r) => r.assigneeId)
|
|
.filter((id): id is string => Boolean(id));
|
|
const users = userIds.length
|
|
? await this.prisma.user.findMany({
|
|
where: { id: { in: userIds } },
|
|
select: { id: true, fullName: true },
|
|
})
|
|
: [];
|
|
const nameById = new Map(users.map((u) => [u.id, u.fullName]));
|
|
|
|
const denom = total || 1;
|
|
return {
|
|
total,
|
|
newCount,
|
|
unassigned,
|
|
byAssignee: byAssigneeRaw
|
|
.filter((r) => r.assigneeId)
|
|
.map((r) => ({
|
|
userId: r.assigneeId!,
|
|
fullName: nameById.get(r.assigneeId!) ?? '—',
|
|
count: r._count._all,
|
|
}))
|
|
.sort((a, b) => b.count - a.count),
|
|
callsLogged,
|
|
withFollowUp,
|
|
interested: interestedCount,
|
|
converted,
|
|
lost,
|
|
conversionRate: Math.round((converted / denom) * 1000) / 10,
|
|
};
|
|
}
|
|
|
|
async createFromImportRow(
|
|
organizationId: string,
|
|
createdById: string | null,
|
|
row: {
|
|
title?: string;
|
|
contactName?: string;
|
|
mobile?: string;
|
|
phone?: string;
|
|
city?: string;
|
|
region?: string;
|
|
trade?: string;
|
|
source?: string;
|
|
notes?: string;
|
|
},
|
|
defaultStageId: string,
|
|
): Promise<'ok' | 'duplicate' | 'error'> {
|
|
const mobile = mobileKey(row.mobile);
|
|
const title = String(row.title ?? row.contactName ?? '').trim();
|
|
if (!mobile || !title) return 'error';
|
|
|
|
const existingLead = await this.prisma.lead.findFirst({
|
|
where: { organizationId, mobile },
|
|
select: { id: true },
|
|
});
|
|
if (existingLead) return 'duplicate';
|
|
|
|
const existingPerson = await this.prisma.person.findFirst({
|
|
where: {
|
|
organizationId,
|
|
OR: [{ mobile }, { phone: mobile }],
|
|
},
|
|
select: { id: true },
|
|
});
|
|
if (existingPerson) return 'duplicate';
|
|
|
|
try {
|
|
const lead = await this.prisma.lead.create({
|
|
data: {
|
|
organizationId,
|
|
title,
|
|
contactName: emptyToNull(row.contactName),
|
|
mobile,
|
|
phone: emptyToNull(row.phone),
|
|
city: emptyToNull(row.city),
|
|
region: emptyToNull(row.region),
|
|
trade: emptyToNull(row.trade),
|
|
source: emptyToNull(row.source) ?? 'ایمپورت اکسل',
|
|
notes: emptyToNull(row.notes),
|
|
stageId: defaultStageId,
|
|
createdById,
|
|
},
|
|
});
|
|
await this.prisma.leadEvent.create({
|
|
data: {
|
|
organizationId,
|
|
leadId: lead.id,
|
|
type: 'import',
|
|
title: 'ورود از فایل اکسل',
|
|
actorId: createdById,
|
|
},
|
|
});
|
|
return 'ok';
|
|
} catch {
|
|
return 'error';
|
|
}
|
|
}
|
|
|
|
private async findAccessible(actor: Actor, id: string): Promise<LeadRow> {
|
|
const row = await this.prisma.lead.findFirst({
|
|
where: {
|
|
id,
|
|
organizationId: actor.organizationId,
|
|
...leadAccessWhere(actor),
|
|
},
|
|
include: leadInclude,
|
|
});
|
|
if (!row) throw new NotFoundException('لید پیدا نشد');
|
|
return row;
|
|
}
|
|
|
|
private async addEvent(
|
|
actor: Actor,
|
|
leadId: string,
|
|
type: string,
|
|
title: string,
|
|
body?: string | null,
|
|
meta?: Record<string, unknown> | null,
|
|
) {
|
|
await this.prisma.leadEvent.create({
|
|
data: {
|
|
organizationId: actor.organizationId,
|
|
leadId,
|
|
type,
|
|
title,
|
|
body: body ?? null,
|
|
meta: meta ? (meta as Prisma.InputJsonValue) : undefined,
|
|
actorId: actor.userId,
|
|
},
|
|
});
|
|
}
|
|
|
|
private toDto(row: LeadRow): LeadDto {
|
|
const daysInStage = Math.max(
|
|
0,
|
|
Math.floor(
|
|
(Date.now() - row.updatedAt.getTime()) / (24 * 60 * 60 * 1000),
|
|
),
|
|
);
|
|
return {
|
|
id: row.id,
|
|
title: row.title,
|
|
contactName: row.contactName,
|
|
mobile: row.mobile,
|
|
phone: row.phone,
|
|
city: row.city,
|
|
region: row.region,
|
|
trade: row.trade,
|
|
source: row.source,
|
|
notes: row.notes,
|
|
estimatedValue:
|
|
row.estimatedValue != null ? Number(row.estimatedValue) : null,
|
|
stageId: row.stageId,
|
|
stageName: row.stage.name,
|
|
position: row.position,
|
|
assigneeId: row.assigneeId,
|
|
assigneeName: row.assignee?.fullName ?? null,
|
|
lastContactAt: row.lastContactAt?.toISOString() ?? null,
|
|
nextFollowUpAt: row.nextFollowUpAt?.toISOString() ?? null,
|
|
convertedPersonId: row.convertedPersonId,
|
|
convertedAt: row.convertedAt?.toISOString() ?? null,
|
|
archivedAt: row.archivedAt?.toISOString() ?? null,
|
|
daysInStage,
|
|
hasLogo: Boolean(row.logoStorageKey),
|
|
createdAt: row.createdAt.toISOString(),
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
async saveLogo(
|
|
actor: Actor,
|
|
id: string,
|
|
file: Express.Multer.File | undefined,
|
|
): Promise<LeadDto> {
|
|
if (!file?.buffer?.length) {
|
|
throw new BadRequestException('فایل تصویر ارسال نشده');
|
|
}
|
|
if (file.size > LOGO_MAX_BYTES) {
|
|
throw new BadRequestException('حجم تصویر باید کمتر از ۲ مگابایت باشد');
|
|
}
|
|
const mime = (file.mimetype || '').toLowerCase();
|
|
const ext = LOGO_EXT[mime];
|
|
if (!ext) {
|
|
throw new BadRequestException('فقط تصویر JPG، PNG، WebP یا GIF مجاز است');
|
|
}
|
|
const existing = await this.findAccessible(actor, id);
|
|
const prevKey = existing.logoStorageKey;
|
|
const storageKey = path
|
|
.join(actor.organizationId, 'leads', id, `logo.${ext}`)
|
|
.replace(/\\/g, '/');
|
|
await this.files.save(storageKey, file.buffer);
|
|
if (prevKey && prevKey !== storageKey) {
|
|
await this.files.remove(prevKey);
|
|
}
|
|
const row = await this.prisma.lead.update({
|
|
where: { id },
|
|
data: { logoStorageKey: storageKey },
|
|
include: leadInclude,
|
|
});
|
|
await this.addEvent(actor, id, 'note', 'لوگو بهروز شد', null);
|
|
return this.toDto(row);
|
|
}
|
|
|
|
async removeLogo(actor: Actor, id: string): Promise<LeadDto> {
|
|
const existing = await this.findAccessible(actor, id);
|
|
if (existing.logoStorageKey) {
|
|
await this.files.remove(existing.logoStorageKey);
|
|
}
|
|
const row = await this.prisma.lead.update({
|
|
where: { id },
|
|
data: { logoStorageKey: null },
|
|
include: leadInclude,
|
|
});
|
|
return this.toDto(row);
|
|
}
|
|
|
|
async openLogo(actor: Actor, id: string) {
|
|
const existing = await this.findAccessible(actor, id);
|
|
if (!existing.logoStorageKey) {
|
|
throw new NotFoundException('لوگو تنظیم نشده');
|
|
}
|
|
const storageKey = existing.logoStorageKey;
|
|
const ext = path.extname(storageKey).slice(1).toLowerCase();
|
|
const mime =
|
|
Object.entries(LOGO_EXT).find(([, value]) => value === ext)?.[0] ??
|
|
'image/jpeg';
|
|
return {
|
|
stream: this.files.openReadStream(storageKey),
|
|
mimeType: mime,
|
|
};
|
|
}
|
|
}
|