feat: باشگاه امتیازات کارکنان (موتور امتیاز، جدول رتبه، فروشگاه پاداش، نشان‌ها، پنل مدیریت و گزارش ROI)

- موتور رویدادمحور با شرط، محدودیت، تأیید مدیر و idempotency
- اتصال به لید، مشتری، معامله، فعالیت، روتین، پیش‌فاکتور، فاکتور و تیکت
- صفحه /club و /club/admin، اعلان زنده امتیاز، آیتم منو
- انتقال فرم‌ساز سرنخ به ابزارها

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-10 21:28:49 +03:30
co-authored by Cursor
parent b87ebb22e0
commit 60d797f184
51 changed files with 11298 additions and 806 deletions
+65 -2
View File
@@ -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,