feat: add DISC profiles and a sales copilot as an org module
Help reps capture a customer's likely behavior style and get negotiation guidance, with the feature gated from super admin. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class BehaviorAccessService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
) {}
|
||||
|
||||
async canView(actor: Actor): Promise<boolean> {
|
||||
const org = await this.prisma.organization.findUnique({
|
||||
where: { id: actor.organizationId },
|
||||
select: { enabledModules: true },
|
||||
});
|
||||
if (!this.billing.normalizeModules(org?.enabledModules).includes('behavior')) {
|
||||
return false;
|
||||
}
|
||||
if (actor.role === 'owner' || actor.role === 'admin') return true;
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: actor.userId,
|
||||
organizationId: actor.organizationId,
|
||||
isActive: true,
|
||||
},
|
||||
select: { orgRole: { select: { permissions: true } } },
|
||||
});
|
||||
const permissions = membership?.orgRole?.permissions ?? [];
|
||||
return permissions.includes('behavior') || permissions.includes('sales');
|
||||
}
|
||||
|
||||
async assert(actor: Actor): Promise<void> {
|
||||
if (!(await this.canView(actor))) {
|
||||
throw new ForbiddenException('دسترسی به تحلیل رفتاری و دستیار فروش ندارید');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Header,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
StreamableFile,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||
import { DiscService } from './disc.service';
|
||||
import { CopilotService } from './copilot.service';
|
||||
import { AnalyzeCopilotDto, CopilotFollowUpDto, SaveCopilotDto, SaveDiscDto } from './dto';
|
||||
|
||||
@Controller('behavior')
|
||||
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
||||
@RequireOrgModule('behavior')
|
||||
export class BehaviorController {
|
||||
constructor(
|
||||
private readonly disc: DiscService,
|
||||
private readonly copilot: CopilotService,
|
||||
) {}
|
||||
|
||||
@Get('disc/distribution')
|
||||
distribution(@CurrentUser() user: Actor) {
|
||||
return this.disc.distribution(user);
|
||||
}
|
||||
|
||||
@Get('disc/export')
|
||||
@Header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
@Header('Content-Disposition', 'attachment; filename="disc.xlsx"')
|
||||
async exportDisc(@CurrentUser() user: Actor) {
|
||||
const buf = await this.disc.exportXlsx(user);
|
||||
return new StreamableFile(buf);
|
||||
}
|
||||
|
||||
@Get('people/:personId/disc')
|
||||
listDisc(@CurrentUser() user: Actor, @Param('personId') personId: string) {
|
||||
return this.disc.list(user, personId);
|
||||
}
|
||||
|
||||
@Post('people/:personId/disc')
|
||||
saveDisc(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('personId') personId: string,
|
||||
@Body() dto: SaveDiscDto,
|
||||
) {
|
||||
return this.disc.save(user, personId, dto);
|
||||
}
|
||||
|
||||
@Post('copilot/analyze')
|
||||
analyze(@CurrentUser() user: Actor, @Body() dto: AnalyzeCopilotDto) {
|
||||
return this.copilot.analyze(user, dto);
|
||||
}
|
||||
|
||||
@Get('copilot/sessions')
|
||||
sessions(
|
||||
@CurrentUser() user: Actor,
|
||||
@Query('personId') personId?: string,
|
||||
@Query('leadId') leadId?: string,
|
||||
@Query('dealId') dealId?: string,
|
||||
) {
|
||||
return this.copilot.list(user, { personId, leadId, dealId });
|
||||
}
|
||||
|
||||
@Post('copilot/sessions')
|
||||
saveSession(@CurrentUser() user: Actor, @Body() dto: SaveCopilotDto) {
|
||||
return this.copilot.save(user, dto);
|
||||
}
|
||||
|
||||
@Post('copilot/sessions/:id/follow-up')
|
||||
followUp(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CopilotFollowUpDto,
|
||||
) {
|
||||
return this.copilot.createFollowUp(user, id, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ActivitiesModule } from '../activities/activities.module';
|
||||
import { BehaviorAccessService } from './behavior-access.service';
|
||||
import { BehaviorController } from './behavior.controller';
|
||||
import { CopilotService } from './copilot.service';
|
||||
import { DiscService } from './disc.service';
|
||||
|
||||
@Module({
|
||||
imports: [ActivitiesModule],
|
||||
controllers: [BehaviorController],
|
||||
providers: [BehaviorAccessService, DiscService, CopilotService],
|
||||
exports: [BehaviorAccessService],
|
||||
})
|
||||
export class BehaviorModule {}
|
||||
@@ -0,0 +1,345 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
analyzeCopilot,
|
||||
isDiscType,
|
||||
type CopilotAnalysis,
|
||||
type CopilotFacts,
|
||||
type SalesCopilotSessionDto,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { dealAccessWhere, ownerWhere, scopedOwnerIds, seesAllRecords } from '../common/record-scope';
|
||||
import { ActivitiesService } from '../activities/activities.service';
|
||||
import { AiService } from '../ai/ai.service';
|
||||
import { BehaviorAccessService } from './behavior-access.service';
|
||||
import type { AnalyzeCopilotDto, CopilotFollowUpDto, SaveCopilotDto } from './dto';
|
||||
|
||||
@Injectable()
|
||||
export class CopilotService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly access: BehaviorAccessService,
|
||||
private readonly activities: ActivitiesService,
|
||||
private readonly ai: AiService,
|
||||
) {}
|
||||
|
||||
async analyze(actor: Actor, dto: AnalyzeCopilotDto): Promise<CopilotAnalysis> {
|
||||
await this.access.assert(actor);
|
||||
const facts = await this.facts(actor, dto);
|
||||
const analysis = analyzeCopilot({
|
||||
topic: dto.topic,
|
||||
goal: dto.goal,
|
||||
customerStatement: dto.customerStatement,
|
||||
behaviorNotes: dto.behaviorNotes,
|
||||
facts,
|
||||
});
|
||||
return this.polish(analysis, facts, dto.customerStatement);
|
||||
}
|
||||
|
||||
async list(
|
||||
actor: Actor,
|
||||
filter: { personId?: string; leadId?: string; dealId?: string },
|
||||
): Promise<SalesCopilotSessionDto[]> {
|
||||
await this.access.assert(actor);
|
||||
if (!filter.personId && !filter.leadId && !filter.dealId) {
|
||||
throw new BadRequestException('پرونده، سرنخ یا معامله را مشخص کنید');
|
||||
}
|
||||
const rows = await this.prisma.salesCopilotSession.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
...(filter.personId ? { personId: filter.personId } : {}),
|
||||
...(filter.leadId ? { leadId: filter.leadId } : {}),
|
||||
...(filter.dealId ? { dealId: filter.dealId } : {}),
|
||||
},
|
||||
include: { user: { select: { fullName: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 30,
|
||||
});
|
||||
return rows.map((row) => this.toDto(row));
|
||||
}
|
||||
|
||||
async save(actor: Actor, dto: SaveCopilotDto): Promise<SalesCopilotSessionDto> {
|
||||
await this.access.assert(actor);
|
||||
const facts = await this.facts(actor, dto);
|
||||
const analysis = await this.polish(
|
||||
analyzeCopilot({
|
||||
topic: dto.topic,
|
||||
goal: dto.goal,
|
||||
customerStatement: dto.customerStatement,
|
||||
behaviorNotes: dto.behaviorNotes,
|
||||
facts,
|
||||
}),
|
||||
facts,
|
||||
dto.customerStatement,
|
||||
);
|
||||
const row = await this.prisma.salesCopilotSession.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
personId: facts.personId,
|
||||
leadId: facts.leadId,
|
||||
dealId: facts.dealId,
|
||||
userId: actor.userId,
|
||||
topic: dto.topic,
|
||||
goal: dto.goal,
|
||||
customerStatement: dto.customerStatement.trim(),
|
||||
behaviorNotes: dto.behaviorNotes?.trim() || null,
|
||||
analysis: analysis as unknown as Prisma.InputJsonValue,
|
||||
confirmedResponse: dto.confirmedResponse?.trim() || null,
|
||||
nextAction: dto.nextAction?.trim() || analysis.next_best_action,
|
||||
followUpAt: dto.followUpAt ? new Date(dto.followUpAt) : null,
|
||||
callOutcome: dto.callOutcome?.trim() || null,
|
||||
agentNote: dto.agentNote?.trim() || null,
|
||||
status: dto.status,
|
||||
messageDraft: dto.messageDraft?.trim() || null,
|
||||
},
|
||||
include: { user: { select: { fullName: true } } },
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async createFollowUp(actor: Actor, sessionId: string, dto: CopilotFollowUpDto) {
|
||||
await this.access.assert(actor);
|
||||
const session = await this.prisma.salesCopilotSession.findFirst({
|
||||
where: { id: sessionId, organizationId: actor.organizationId, status: 'confirmed' },
|
||||
});
|
||||
if (!session) throw new NotFoundException('نتیجهٔ تأییدشده پیدا نشد');
|
||||
if (!session.personId && !session.dealId && !session.leadId) {
|
||||
throw new BadRequestException('این نتیجه به پروندهای وصل نیست');
|
||||
}
|
||||
const due = dto.dueAt || (session.followUpAt ? session.followUpAt.toISOString() : null);
|
||||
const activity = await this.activities.create(
|
||||
actor.organizationId,
|
||||
actor.userId,
|
||||
{
|
||||
title: session.nextAction?.trim() || 'پیگیری مذاکره',
|
||||
type: 'task',
|
||||
description: session.confirmedResponse || session.customerStatement,
|
||||
dueAt: due,
|
||||
personId: session.personId,
|
||||
dealId: session.dealId,
|
||||
assigneeId: dto.assigneeId || actor.userId,
|
||||
},
|
||||
actor,
|
||||
);
|
||||
await this.prisma.salesCopilotSession.update({
|
||||
where: { id: session.id },
|
||||
data: { activityId: activity.id },
|
||||
});
|
||||
return { activityId: activity.id };
|
||||
}
|
||||
|
||||
private async facts(
|
||||
actor: Actor,
|
||||
dto: { personId?: string; leadId?: string; dealId?: string },
|
||||
): Promise<CopilotFacts & { personId: string | null; leadId: string | null; dealId: string | null }> {
|
||||
if (!dto.personId && !dto.leadId && !dto.dealId) {
|
||||
throw new BadRequestException('پرونده، سرنخ یا معامله را مشخص کنید');
|
||||
}
|
||||
const deal = dto.dealId
|
||||
? await this.prisma.deal.findFirst({
|
||||
where: { id: dto.dealId, organizationId: actor.organizationId, ...dealAccessWhere(actor) },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
amount: true,
|
||||
currency: true,
|
||||
personId: true,
|
||||
stage: { select: { name: true } },
|
||||
},
|
||||
})
|
||||
: null;
|
||||
if (dto.dealId && !deal) throw new NotFoundException('معامله پیدا نشد');
|
||||
|
||||
const lead = dto.leadId
|
||||
? await this.prisma.lead.findFirst({
|
||||
where: { id: dto.leadId, organizationId: actor.organizationId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
trade: true,
|
||||
estimatedValue: true,
|
||||
convertedPersonId: true,
|
||||
assigneeId: true,
|
||||
stage: { select: { name: true } },
|
||||
},
|
||||
})
|
||||
: null;
|
||||
if (dto.leadId && !lead) throw new NotFoundException('سرنخ پیدا نشد');
|
||||
if (lead && !seesAllRecords(actor, 'leads')) {
|
||||
const ids = scopedOwnerIds(actor, 'leads') ?? [actor.userId];
|
||||
if (lead.assigneeId && !ids.includes(lead.assigneeId)) {
|
||||
throw new NotFoundException('سرنخ پیدا نشد');
|
||||
}
|
||||
}
|
||||
|
||||
const personId = dto.personId || deal?.personId || lead?.convertedPersonId || null;
|
||||
const person = personId
|
||||
? await this.prisma.person.findFirst({
|
||||
where: { id: personId, organizationId: actor.organizationId, ...ownerWhere(actor) },
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
discLabel: true,
|
||||
discReliable: true,
|
||||
discAssessedAt: true,
|
||||
company: { select: { name: true } },
|
||||
},
|
||||
})
|
||||
: null;
|
||||
if (personId && !person) throw new NotFoundException('شخص پیدا نشد');
|
||||
|
||||
const recent = person
|
||||
? await this.prisma.activity.findMany({
|
||||
where: { organizationId: actor.organizationId, personId: person.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 3,
|
||||
select: { title: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
const price = deal?.amount
|
||||
? `${deal.amount.toString()} ${deal.currency}`
|
||||
: lead?.estimatedValue
|
||||
? `${lead.estimatedValue.toString()}`
|
||||
: null;
|
||||
const primaryTypes = (person?.discLabel ?? '')
|
||||
.split('/')
|
||||
.map((part) => part.trim())
|
||||
.filter(isDiscType);
|
||||
|
||||
return {
|
||||
personId: person?.id ?? null,
|
||||
leadId: lead?.id ?? null,
|
||||
dealId: deal?.id ?? null,
|
||||
customerName: person ? `${person.firstName} ${person.lastName}`.trim() : lead?.title ?? null,
|
||||
businessType: person?.company?.name || lead?.trade || null,
|
||||
productName: deal?.title || lead?.title || null,
|
||||
stageName: deal?.stage.name || lead?.stage.name || null,
|
||||
priceOffered: price,
|
||||
lastAction: recent.map((row) => row.title).join('؛ ') || null,
|
||||
disc:
|
||||
person && person.discLabel
|
||||
? {
|
||||
label: person.discLabel,
|
||||
primaryTypes,
|
||||
reliable: person.discReliable,
|
||||
assessedAt: person.discAssessedAt?.toISOString() ?? null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async polish(
|
||||
analysis: CopilotAnalysis,
|
||||
facts: CopilotFacts,
|
||||
statement: string,
|
||||
): Promise<CopilotAnalysis> {
|
||||
if (!this.ai.isEnabled() || analysis.needs_more_input || !analysis.suggested_response) {
|
||||
return analysis;
|
||||
}
|
||||
const factText = [
|
||||
statement,
|
||||
facts.customerName,
|
||||
facts.businessType,
|
||||
facts.productName,
|
||||
facts.stageName,
|
||||
facts.priceOffered,
|
||||
facts.lastAction,
|
||||
analysis.objection_label,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
try {
|
||||
const raw = await this.ai.completeJson<{
|
||||
recommended_question?: string;
|
||||
suggested_response?: string;
|
||||
}>(
|
||||
`تو مربی فروش فارسیزبان هستی. فقط JSON برگردان:
|
||||
{"recommended_question": string, "suggested_response": string}
|
||||
قواعد:
|
||||
- چیزی را که در اطلاعات داده نشده نساز؛ بهخصوص قیمت، تخفیف، قابلیت محصول و تعهد پشتیبانی.
|
||||
- اگر عددی در اطلاعات نیست، عدد جدید ننویس.
|
||||
- DISC را تشخیص قطعی شخصیت ندان.
|
||||
- پاسخ کوتاه، محترمانه و قابل گفتن پای تلفن باشد.`,
|
||||
factText,
|
||||
);
|
||||
const question = keepGrounded(raw.recommended_question, factText);
|
||||
const response = keepGrounded(raw.suggested_response, factText);
|
||||
if (!question && !response) return analysis;
|
||||
return {
|
||||
...analysis,
|
||||
recommended_question: question || analysis.recommended_question,
|
||||
suggested_response: response || analysis.suggested_response,
|
||||
source: 'rules+ai',
|
||||
confidence_note: `${analysis.confidence_note} متن با مدل زبانی بازنویسی شد و هنوز باید توسط کارشناس تأیید شود.`,
|
||||
};
|
||||
} catch {
|
||||
return analysis;
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: {
|
||||
id: string;
|
||||
personId: string | null;
|
||||
leadId: string | null;
|
||||
dealId: string | null;
|
||||
userId: string;
|
||||
user: { fullName: string };
|
||||
topic: string;
|
||||
goal: string;
|
||||
customerStatement: string;
|
||||
behaviorNotes: string | null;
|
||||
analysis: unknown;
|
||||
confirmedResponse: string | null;
|
||||
nextAction: string | null;
|
||||
followUpAt: Date | null;
|
||||
callOutcome: string | null;
|
||||
agentNote: string | null;
|
||||
status: string;
|
||||
messageDraft: string | null;
|
||||
activityId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): SalesCopilotSessionDto {
|
||||
return {
|
||||
id: row.id,
|
||||
personId: row.personId,
|
||||
leadId: row.leadId,
|
||||
dealId: row.dealId,
|
||||
userId: row.userId,
|
||||
userName: row.user.fullName,
|
||||
topic: row.topic as SalesCopilotSessionDto['topic'],
|
||||
goal: row.goal as SalesCopilotSessionDto['goal'],
|
||||
customerStatement: row.customerStatement,
|
||||
behaviorNotes: row.behaviorNotes,
|
||||
analysis: row.analysis as CopilotAnalysis,
|
||||
confirmedResponse: row.confirmedResponse,
|
||||
nextAction: row.nextAction,
|
||||
followUpAt: row.followUpAt?.toISOString() ?? null,
|
||||
callOutcome: row.callOutcome,
|
||||
agentNote: row.agentNote,
|
||||
status: row.status === 'dismissed' ? 'dismissed' : 'confirmed',
|
||||
messageDraft: row.messageDraft,
|
||||
activityId: row.activityId,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function keepGrounded(text: string | undefined, facts: string): string {
|
||||
const trimmed = text?.trim() ?? '';
|
||||
if (!trimmed || trimmed.length > 900) return '';
|
||||
const numbers = trimmed.match(/\d[\d۰-۹,،.]*/g) ?? [];
|
||||
for (const token of numbers) {
|
||||
const digits = token.replace(/\D/g, '');
|
||||
if (digits.length >= 2 && !facts.includes(digits)) return '';
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
type DiscAnswers,
|
||||
type DiscAssessmentDto,
|
||||
type DiscDistributionRow,
|
||||
type DiscType,
|
||||
isDiscType,
|
||||
scoreDisc,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { ownerWhere } from '../common/record-scope';
|
||||
import { writeXlsx } from '../common/excel';
|
||||
import { BehaviorAccessService } from './behavior-access.service';
|
||||
import type { SaveDiscDto } from './dto';
|
||||
|
||||
function asAnswers(value: unknown): DiscAnswers {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
const out: DiscAnswers = {};
|
||||
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (raw == null || raw === '') out[key] = null;
|
||||
else if (isDiscType(raw)) out[key] = raw;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function asTypes(values: string[]): DiscType[] {
|
||||
return values.filter(isDiscType);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DiscService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly access: BehaviorAccessService,
|
||||
) {}
|
||||
|
||||
async list(actor: Actor, personId: string): Promise<DiscAssessmentDto[]> {
|
||||
await this.access.assert(actor);
|
||||
await this.ensurePerson(actor, personId);
|
||||
const rows = await this.prisma.discAssessment.findMany({
|
||||
where: { organizationId: actor.organizationId, personId },
|
||||
include: { assessor: { select: { id: true, fullName: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return rows.map((row) => this.toDto(row));
|
||||
}
|
||||
|
||||
async save(actor: Actor, personId: string, dto: SaveDiscDto): Promise<DiscAssessmentDto> {
|
||||
await this.access.assert(actor);
|
||||
await this.ensurePerson(actor, personId);
|
||||
const answers = asAnswers(dto.answers);
|
||||
const scored = scoreDisc(answers);
|
||||
if (scored.validCount === 0) {
|
||||
throw new BadRequestException('حداقل یک پاسخ معتبر لازم است');
|
||||
}
|
||||
const note = dto.note?.trim() || null;
|
||||
const row = await this.prisma.discAssessment.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
personId,
|
||||
assessorId: actor.userId,
|
||||
answers: answers as Prisma.InputJsonValue,
|
||||
scoreD: scored.scores.D,
|
||||
scoreI: scored.scores.I,
|
||||
scoreS: scored.scores.S,
|
||||
scoreC: scored.scores.C,
|
||||
percentD: scored.percents.D,
|
||||
percentI: scored.percents.I,
|
||||
percentS: scored.percents.S,
|
||||
percentC: scored.percents.C,
|
||||
validCount: scored.validCount,
|
||||
primaryTypes: scored.primaryTypes,
|
||||
secondaryTypes: scored.secondaryTypes,
|
||||
label: scored.label,
|
||||
secondaryLabel: scored.secondaryLabel,
|
||||
reliable: scored.reliable,
|
||||
confidence: scored.confidence,
|
||||
note,
|
||||
status: 'confirmed',
|
||||
},
|
||||
include: { assessor: { select: { id: true, fullName: true } } },
|
||||
});
|
||||
if (scored.reliable) {
|
||||
await this.prisma.person.update({
|
||||
where: { id: personId },
|
||||
data: {
|
||||
discLabel: scored.label,
|
||||
discReliable: true,
|
||||
discAssessedAt: row.createdAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async distribution(actor: Actor): Promise<DiscDistributionRow[]> {
|
||||
await this.access.assert(actor);
|
||||
const rows = await this.prisma.person.groupBy({
|
||||
by: ['discLabel'],
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
discReliable: true,
|
||||
discLabel: { not: null },
|
||||
...ownerWhere(actor),
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
return rows
|
||||
.flatMap((row) =>
|
||||
row.discLabel
|
||||
? [{ label: row.discLabel, count: row._count._all }]
|
||||
: [],
|
||||
)
|
||||
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
async exportXlsx(actor: Actor): Promise<Buffer> {
|
||||
await this.access.assert(actor);
|
||||
const people = await this.prisma.person.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
discReliable: true,
|
||||
...ownerWhere(actor),
|
||||
},
|
||||
select: { id: true, firstName: true, lastName: true, mobile: true, discLabel: true },
|
||||
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
|
||||
});
|
||||
const latest = await this.prisma.discAssessment.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
personId: { in: people.map((p) => p.id) },
|
||||
reliable: true,
|
||||
},
|
||||
include: { assessor: { select: { fullName: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
const byPerson = new Map<string, (typeof latest)[number]>();
|
||||
for (const row of latest) {
|
||||
if (!byPerson.has(row.personId)) byPerson.set(row.personId, row);
|
||||
}
|
||||
return writeXlsx(
|
||||
'DISC',
|
||||
[
|
||||
{ header: 'مشتری', key: 'name', width: 28 },
|
||||
{ header: 'موبایل', key: 'mobile', width: 16 },
|
||||
{ header: 'تیپ غالب', key: 'label', width: 16 },
|
||||
{ header: 'تیپ دوم', key: 'secondary', width: 16 },
|
||||
{ header: 'D', key: 'd', width: 8 },
|
||||
{ header: 'I', key: 'i', width: 8 },
|
||||
{ header: 'S', key: 's', width: 8 },
|
||||
{ header: 'C', key: 'c', width: 8 },
|
||||
{ header: 'اطمینان', key: 'confidence', width: 14 },
|
||||
{ header: 'کارشناس', key: 'assessor', width: 22 },
|
||||
{ header: 'تاریخ', key: 'at', width: 22 },
|
||||
{ header: 'یادداشت', key: 'note', width: 40 },
|
||||
],
|
||||
people.map((person) => {
|
||||
const row = byPerson.get(person.id);
|
||||
return {
|
||||
name: `${person.firstName} ${person.lastName}`.trim(),
|
||||
mobile: person.mobile,
|
||||
label: person.discLabel,
|
||||
secondary: row?.secondaryLabel,
|
||||
d: row?.scoreD,
|
||||
i: row?.scoreI,
|
||||
s: row?.scoreS,
|
||||
c: row?.scoreC,
|
||||
confidence: row?.confidence,
|
||||
assessor: row?.assessor.fullName,
|
||||
at: row?.createdAt.toISOString(),
|
||||
note: row?.note,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async ensurePerson(actor: Actor, personId: string) {
|
||||
const found = await this.prisma.person.findFirst({
|
||||
where: { id: personId, organizationId: actor.organizationId, ...ownerWhere(actor) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!found) throw new NotFoundException('شخص پیدا نشد');
|
||||
}
|
||||
|
||||
private toDto(row: {
|
||||
id: string;
|
||||
personId: string;
|
||||
assessorId: string;
|
||||
assessor: { fullName: string };
|
||||
answers: unknown;
|
||||
scoreD: number;
|
||||
scoreI: number;
|
||||
scoreS: number;
|
||||
scoreC: number;
|
||||
percentD: number;
|
||||
percentI: number;
|
||||
percentS: number;
|
||||
percentC: number;
|
||||
validCount: number;
|
||||
primaryTypes: string[];
|
||||
secondaryTypes: string[];
|
||||
label: string;
|
||||
secondaryLabel: string | null;
|
||||
reliable: boolean;
|
||||
confidence: string;
|
||||
note: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): DiscAssessmentDto {
|
||||
const confidence = (['insufficient', 'low', 'medium', 'high'] as const).includes(
|
||||
row.confidence as 'insufficient',
|
||||
)
|
||||
? (row.confidence as DiscAssessmentDto['confidence'])
|
||||
: 'low';
|
||||
return {
|
||||
id: row.id,
|
||||
personId: row.personId,
|
||||
assessorId: row.assessorId,
|
||||
assessorName: row.assessor.fullName,
|
||||
answers: asAnswers(row.answers),
|
||||
scores: { D: row.scoreD, I: row.scoreI, S: row.scoreS, C: row.scoreC },
|
||||
percents: { D: row.percentD, I: row.percentI, S: row.percentS, C: row.percentC },
|
||||
validCount: row.validCount,
|
||||
primaryTypes: asTypes(row.primaryTypes),
|
||||
secondaryTypes: asTypes(row.secondaryTypes),
|
||||
label: row.label,
|
||||
secondaryLabel: row.secondaryLabel,
|
||||
reliable: row.reliable,
|
||||
confidence,
|
||||
note: row.note,
|
||||
status: 'confirmed',
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
COPILOT_GOALS,
|
||||
COPILOT_TOPICS,
|
||||
type CopilotGoal,
|
||||
type CopilotTopic,
|
||||
} from '@mento/shared';
|
||||
|
||||
export class SaveDiscDto {
|
||||
@IsObject()
|
||||
answers!: Record<string, string | null>;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export class AnalyzeCopilotDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
personId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
leadId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dealId?: string;
|
||||
|
||||
@IsIn([...COPILOT_TOPICS])
|
||||
topic!: CopilotTopic;
|
||||
|
||||
@IsIn([...COPILOT_GOALS])
|
||||
goal!: CopilotGoal;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
customerStatement!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
behaviorNotes?: string;
|
||||
}
|
||||
|
||||
export class SaveCopilotDto extends AnalyzeCopilotDto {
|
||||
@IsIn(['confirmed', 'dismissed'])
|
||||
status!: 'confirmed' | 'dismissed';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
confirmedResponse?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
nextAction?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, value) => value != null && value !== '')
|
||||
@IsDateString()
|
||||
followUpAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
callOutcome?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
agentNote?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
messageDraft?: string;
|
||||
}
|
||||
|
||||
export class CopilotFollowUpDto {
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dueAt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assigneeId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Type(() => Boolean)
|
||||
createTask?: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user