feat: باشگاه امتیازات کارکنان (موتور امتیاز، جدول رتبه، فروشگاه پاداش، نشانها، پنل مدیریت و گزارش ROI)
- موتور رویدادمحور با شرط، محدودیت، تأیید مدیر و idempotency - اتصال به لید، مشتری، معامله، فعالیت، روتین، پیشفاکتور، فاکتور و تیکت - صفحه /club و /club/admin، اعلان زنده امتیاز، آیتم منو - انتقال فرمساز سرنخ به ابزارها Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import * as path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type {
|
||||
ConvertLeadResult,
|
||||
LeadBoardColumnDto,
|
||||
@@ -30,6 +31,7 @@ import { emptyToNull, normalizePhone } from '../contacts/normalize';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import { LeadStagesService } from './lead-stages.service';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
|
||||
const LOGO_MAX_BYTES = 2 * 1024 * 1024;
|
||||
const LOGO_EXT: Record<string, string> = {
|
||||
@@ -77,8 +79,27 @@ export class LeadsService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly stages: LeadStagesService,
|
||||
private readonly files: LocalFileStorage,
|
||||
private readonly game: GamificationService,
|
||||
) {}
|
||||
|
||||
private gameEvent(
|
||||
actor: Actor,
|
||||
event: Parameters<GamificationService['emit']>[0]['event'],
|
||||
lead: { id: string; title: string },
|
||||
extra: { userId?: string | null; eventKey?: string; facts?: Record<string, unknown> } = {},
|
||||
) {
|
||||
this.game.emit({
|
||||
organizationId: actor.organizationId,
|
||||
userId: extra.userId ?? actor.userId,
|
||||
event,
|
||||
entityType: 'lead',
|
||||
entityId: lead.id,
|
||||
entityTitle: lead.title,
|
||||
eventKey: extra.eventKey,
|
||||
facts: extra.facts,
|
||||
});
|
||||
}
|
||||
|
||||
async list(
|
||||
actor: Actor,
|
||||
opts: {
|
||||
@@ -325,6 +346,12 @@ export class LeadsService {
|
||||
{ assigneeId },
|
||||
);
|
||||
}
|
||||
this.gameEvent(actor, 'LEAD_CREATED', row, {
|
||||
facts: {
|
||||
source: row.source,
|
||||
amount: row.estimatedValue == null ? null : Number(row.estimatedValue),
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
@@ -436,6 +463,10 @@ export class LeadsService {
|
||||
'stage_changed',
|
||||
`وضعیت به «${row.stage.name}» تغییر کرد`,
|
||||
);
|
||||
this.gameEvent(actor, 'LEAD_STAGE_CHANGED', row, {
|
||||
eventKey: `${id}:${row.stageId}`,
|
||||
facts: { stageName: row.stage.name, source: row.source },
|
||||
});
|
||||
}
|
||||
return this.toDto(row);
|
||||
}
|
||||
@@ -446,7 +477,7 @@ export class LeadsService {
|
||||
stageId: string,
|
||||
position?: number,
|
||||
): Promise<LeadDto> {
|
||||
await this.findAccessible(actor, id);
|
||||
const before = await this.findAccessible(actor, id);
|
||||
const stage = await this.prisma.leadStage.findFirst({
|
||||
where: { id: stageId, organizationId: actor.organizationId },
|
||||
});
|
||||
@@ -469,6 +500,12 @@ export class LeadsService {
|
||||
'stage_changed',
|
||||
`به مرحله «${stage.name}» منتقل شد`,
|
||||
);
|
||||
if (before.stageId !== stageId) {
|
||||
this.gameEvent(actor, 'LEAD_STAGE_CHANGED', row, {
|
||||
eventKey: `${id}:${stageId}`,
|
||||
facts: { stageName: stage.name, source: row.source },
|
||||
});
|
||||
}
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
@@ -637,7 +674,7 @@ export class LeadsService {
|
||||
stageId?: string;
|
||||
},
|
||||
): Promise<LeadDto> {
|
||||
await this.findAccessible(actor, id);
|
||||
const before = await this.findAccessible(actor, id);
|
||||
const title = dto.outcome
|
||||
? `تماس ثبت شد — ${dto.outcome}`
|
||||
: 'تماس ثبت شد';
|
||||
@@ -658,6 +695,16 @@ export class LeadsService {
|
||||
data,
|
||||
include: leadInclude,
|
||||
});
|
||||
this.gameEvent(actor, 'CALL_LOGGED', row, {
|
||||
eventKey: randomUUID(),
|
||||
facts: { hasDescription: !!dto.body?.trim() },
|
||||
});
|
||||
if (dto.stageId && dto.stageId !== before.stageId) {
|
||||
this.gameEvent(actor, 'LEAD_STAGE_CHANGED', row, {
|
||||
eventKey: `${id}:${dto.stageId}`,
|
||||
facts: { stageName: row.stage.name, source: row.source },
|
||||
});
|
||||
}
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
@@ -665,6 +712,10 @@ export class LeadsService {
|
||||
await this.findAccessible(actor, id);
|
||||
await this.addEvent(actor, id, 'note', 'یادداشت', body);
|
||||
const row = await this.findAccessible(actor, id);
|
||||
this.gameEvent(actor, 'NOTE_CREATED', row, {
|
||||
eventKey: randomUUID(),
|
||||
facts: { hasDescription: body.trim().length > 0 },
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
@@ -689,6 +740,10 @@ export class LeadsService {
|
||||
data: { nextFollowUpAt: at },
|
||||
include: leadInclude,
|
||||
});
|
||||
this.gameEvent(actor, 'FOLLOW_UP_CREATED', row, {
|
||||
eventKey: randomUUID(),
|
||||
facts: { hasDescription: !!body?.trim() },
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
@@ -808,6 +863,14 @@ export class LeadsService {
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
this.gameEvent(actor, 'LEAD_CONVERTED', row, {
|
||||
userId: lead.assigneeId ?? actor.userId,
|
||||
facts: {
|
||||
source: lead.source,
|
||||
amount: lead.estimatedValue == null ? null : Number(lead.estimatedValue),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
lead: this.toDto(row),
|
||||
personId: person.id,
|
||||
|
||||
Reference in New Issue
Block a user