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:
2026-10-18 21:04:26 +03:30
co-authored by Cursor
parent 96befd3867
commit 002f697dfe
27 changed files with 2838 additions and 26 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"prisma:seed": "tsx prisma/seed.ts",
"lint": "echo \"api lint placeholder\"",
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json",
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts test/ask-count.test.ts test/prior-archive.test.ts test/organizations.test.ts test/promote-from-prior.test.ts"
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts test/ask-count.test.ts test/prior-archive.test.ts test/organizations.test.ts test/promote-from-prior.test.ts test/behavior.test.ts"
},
"dependencies": {
"@mento/shared": "*",
@@ -0,0 +1,97 @@
-- AlterTable
ALTER TABLE "Person" ADD COLUMN "discLabel" TEXT,
ADD COLUMN "discReliable" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "discAssessedAt" TIMESTAMP(3);
-- CreateIndex
CREATE INDEX "Person_organizationId_discLabel_idx" ON "Person"("organizationId", "discLabel");
-- CreateTable
CREATE TABLE "DiscAssessment" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"personId" TEXT NOT NULL,
"assessorId" TEXT NOT NULL,
"answers" JSONB NOT NULL,
"scoreD" INTEGER NOT NULL,
"scoreI" INTEGER NOT NULL,
"scoreS" INTEGER NOT NULL,
"scoreC" INTEGER NOT NULL,
"percentD" INTEGER NOT NULL,
"percentI" INTEGER NOT NULL,
"percentS" INTEGER NOT NULL,
"percentC" INTEGER NOT NULL,
"validCount" INTEGER NOT NULL,
"primaryTypes" TEXT[] DEFAULT ARRAY[]::TEXT[],
"secondaryTypes" TEXT[] DEFAULT ARRAY[]::TEXT[],
"label" TEXT NOT NULL,
"secondaryLabel" TEXT,
"reliable" BOOLEAN NOT NULL,
"confidence" TEXT NOT NULL,
"note" TEXT,
"status" TEXT NOT NULL DEFAULT 'confirmed',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DiscAssessment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SalesCopilotSession" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"personId" TEXT,
"leadId" TEXT,
"dealId" TEXT,
"userId" TEXT NOT NULL,
"topic" TEXT NOT NULL,
"goal" TEXT NOT NULL,
"customerStatement" TEXT NOT NULL,
"behaviorNotes" TEXT,
"analysis" JSONB NOT NULL,
"confirmedResponse" TEXT,
"nextAction" TEXT,
"followUpAt" TIMESTAMP(3),
"callOutcome" TEXT,
"agentNote" TEXT,
"status" TEXT NOT NULL,
"messageDraft" TEXT,
"activityId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SalesCopilotSession_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "DiscAssessment_organizationId_personId_createdAt_idx" ON "DiscAssessment"("organizationId", "personId", "createdAt");
CREATE INDEX "DiscAssessment_personId_idx" ON "DiscAssessment"("personId");
CREATE INDEX "SalesCopilotSession_organizationId_personId_createdAt_idx" ON "SalesCopilotSession"("organizationId", "personId", "createdAt");
CREATE INDEX "SalesCopilotSession_organizationId_leadId_idx" ON "SalesCopilotSession"("organizationId", "leadId");
CREATE INDEX "SalesCopilotSession_dealId_idx" ON "SalesCopilotSession"("dealId");
-- AddForeignKey
ALTER TABLE "DiscAssessment" ADD CONSTRAINT "DiscAssessment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "DiscAssessment" ADD CONSTRAINT "DiscAssessment_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "DiscAssessment" ADD CONSTRAINT "DiscAssessment_assessorId_fkey" FOREIGN KEY ("assessorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- ماژول برای محیط‌هایی که از قبل ماژول دارند روشن می‌شود و در سوپرادمین قابل خاموش کردن است
UPDATE "Organization"
SET "enabledModules" = array_append("enabledModules", 'behavior')
WHERE cardinality("enabledModules") > 0
AND NOT ('behavior' = ANY("enabledModules"));
-- مجوز رفتار برای نقش‌هایی که فروش دارند؛ پشتیبانی بدون این کلید می‌ماند
UPDATE "OrgRole"
SET "permissions" = array_append("permissions", 'behavior')
WHERE NOT ('behavior' = ANY("permissions"))
AND (
"systemKey" IN ('owner', 'admin', 'sales')
OR 'sales' = ANY("permissions")
);
+80
View File
@@ -161,6 +161,8 @@ model Organization {
teams Team[]
companies Company[]
people Person[]
discAssessments DiscAssessment[]
salesCopilotSessions SalesCopilotSession[]
pipelines Pipeline[]
deals Deal[]
activities Activity[]
@@ -311,6 +313,8 @@ model User {
routineTaskEvents RoutineTaskEvent[]
routineSettings RoutineUserSettings[]
deletionArchives DeletionArchive[] @relation("DeletionActor")
discAssessments DiscAssessment[] @relation("DiscAssessor")
salesCopilotSessions SalesCopilotSession[] @relation("CopilotAuthor")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -554,6 +558,11 @@ model Person {
routineTasks RoutineTask[]
outboundSmsLogs OutboundSmsLog[]
financialDocuments FinancialDocument[]
discLabel String?
discReliable Boolean @default(false)
discAssessedAt DateTime?
discAssessments DiscAssessment[]
salesCopilotSessions SalesCopilotSession[]
financialReceivables FinancialReceivable[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -569,6 +578,7 @@ model Person {
@@index([companyId])
@@index([ownerId])
@@index([loyaltyTierId])
@@index([organizationId, discLabel])
}
model Pipeline {
@@ -640,6 +650,7 @@ model Deal {
expectedCloseAt DateTime?
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
salesCopilotSessions SalesCopilotSession[]
companyId String?
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
ownerId String?
@@ -2766,6 +2777,7 @@ model Lead {
convertedAt DateTime?
archivedAt DateTime?
events LeadEvent[]
salesCopilotSessions SalesCopilotSession[]
trackingLinks TrackingLink[]
trackingEvents TrackingEvent[]
routineTasks RoutineTask[]
@@ -3337,3 +3349,71 @@ model GamificationBonus {
@@index([organizationId, isActive])
}
/// ارزیابی رفتاری DISC؛ هر بار یک رکورد تازه است و تاریخچه پاک نمی‌شود.
model DiscAssessment {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
personId String
person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
assessorId String
assessor User @relation("DiscAssessor", fields: [assessorId], references: [id], onDelete: Restrict)
answers Json
scoreD Int
scoreI Int
scoreS Int
scoreC Int
percentD Int
percentI Int
percentS Int
percentC Int
validCount Int
primaryTypes String[] @default([])
secondaryTypes String[] @default([])
label String
secondaryLabel String?
reliable Boolean
confidence String
note String?
status String @default("confirmed")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, personId, createdAt])
@@index([personId])
}
/// نتیجهٔ تأییدشدهٔ دستیار فروش. پیشنهاد خام بدون تأیید کارشناس اینجا ذخیره نمی‌شود.
model SalesCopilotSession {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: Cascade)
leadId String?
lead Lead? @relation(fields: [leadId], references: [id], onDelete: SetNull)
dealId String?
deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull)
userId String
user User @relation("CopilotAuthor", fields: [userId], references: [id], onDelete: Restrict)
topic String
goal String
customerStatement String
behaviorNotes String?
analysis Json
confirmedResponse String?
nextAction String?
followUpAt DateTime?
callOutcome String?
agentNote String?
status String
messageDraft String?
activityId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, personId, createdAt])
@@index([organizationId, leadId])
@@index([dealId])
}
+2
View File
@@ -52,6 +52,7 @@ import { RoutinesModule } from './routines/routines.module';
import { GamificationModule } from './gamification/gamification.module';
import { DeletionsModule } from './deletions/deletions.module';
import { OutboundSmsModule } from './outbound-sms/outbound-sms.module';
import { BehaviorModule } from './behavior/behavior.module';
@Module({
imports: [
@@ -111,6 +112,7 @@ import { OutboundSmsModule } from './outbound-sms/outbound-sms.module';
RoutinesModule,
GamificationModule,
DeletionsModule,
BehaviorModule,
],
})
export class AppModule {}
@@ -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);
}
}
+14
View File
@@ -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 {}
+345
View File
@@ -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;
}
+242
View File
@@ -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(),
};
}
}
+106
View File
@@ -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;
}
+2
View File
@@ -9,6 +9,7 @@ import { PeopleController } from './people.controller';
import { PeopleService } from './people.service';
import { ContactsExcelService } from './contacts-excel.service';
import { SmsService } from '../common/sms.service';
import { BehaviorModule } from '../behavior/behavior.module';
@Module({
imports: [
@@ -16,6 +17,7 @@ import { SmsService } from '../common/sms.service';
AutomationsModule,
MessageAutomationsModule,
PublicApiModule,
BehaviorModule,
],
controllers: [PeopleController, CompaniesController],
providers: [PeopleService, CompaniesService, ContactsExcelService, SmsService],
+6
View File
@@ -80,6 +80,12 @@ export class ListQueryDto {
@IsOptional()
@IsIn(['1', 'true', '0', 'false'])
hasMobile?: string;
/** فیلتر تیپ DISC غالب یا ترکیبی، مثل D یا D/C */
@IsOptional()
@IsString()
@MaxLength(12)
disc?: string;
}
class CompanyExtraFieldsDto {
@@ -59,6 +59,7 @@ export class PeopleController {
province: query.province,
city: query.city,
hasMobile,
disc: query.disc,
},
);
}
@@ -75,6 +76,7 @@ export class PeopleController {
province: query.province,
city: query.city,
hasMobile,
disc: query.disc,
});
}
+39 -7
View File
@@ -50,6 +50,7 @@ import {
normalizePersonImportSource,
personImportSourceLabel,
} from './person-source';
import { BehaviorAccessService } from '../behavior/behavior-access.service';
const personInclude = {
company: { select: { id: true, name: true, province: true, city: true } },
@@ -98,8 +99,27 @@ type PersonRow = {
minPoints: number;
color: string | null;
} | null;
discLabel: string | null;
discReliable: boolean;
discAssessedAt: Date | null;
};
function discFilter(raw: string | undefined): Prisma.PersonWhereInput | null {
const value = raw?.trim().toUpperCase();
if (!value || !/^[DISC](\/[DISC]){0,3}$/.test(value)) return null;
if (value.includes('/')) {
return { discReliable: true, discLabel: value };
}
return {
discReliable: true,
OR: [
{ discLabel: value },
{ discLabel: { startsWith: `${value}/` } },
{ discLabel: { contains: `/${value}` } },
],
};
}
@Injectable()
export class PeopleService implements OnModuleInit {
private readonly logger = new Logger(PeopleService.name);
@@ -113,6 +133,7 @@ export class PeopleService implements OnModuleInit {
private readonly game: GamificationService,
private readonly sms: SmsService,
private readonly outboundSms: OutboundSmsLogService,
private readonly behaviorAccess: BehaviorAccessService,
) {}
private personListWhere(
@@ -124,6 +145,7 @@ export class PeopleService implements OnModuleInit {
province?: string;
city?: string;
hasMobile?: boolean;
disc?: string;
},
): Prisma.PersonWhereInput {
const sourceFilter = opts?.importSource?.trim()
@@ -166,6 +188,8 @@ export class PeopleService implements OnModuleInit {
if (opts?.hasMobile) {
and.push({ mobile: { not: null } }, { NOT: { mobile: '' } });
}
const disc = discFilter(opts?.disc);
if (disc) and.push(disc);
return {
organizationId: actor.organizationId,
...ownerWhere(actor),
@@ -187,9 +211,11 @@ export class PeopleService implements OnModuleInit {
province?: string;
city?: string;
hasMobile?: boolean;
disc?: string;
},
): Promise<PaginatedResponse<PersonDto>> {
const where = this.personListWhere(actor, q, opts);
const seeDisc = await this.behaviorAccess.canView(actor);
const where = this.personListWhere(actor, q, seeDisc ? opts : { ...opts, disc: undefined });
const [rows, total] = await this.prisma.$transaction([
this.prisma.person.findMany({
@@ -206,7 +232,7 @@ export class PeopleService implements OnModuleInit {
]);
return {
items: rows.map((row) => this.toDto(row)),
items: rows.map((row) => this.toDto(row, seeDisc)),
total,
page,
limit,
@@ -222,10 +248,12 @@ export class PeopleService implements OnModuleInit {
province?: string;
city?: string;
hasMobile?: boolean;
disc?: string;
},
max = FILTERED_IDS_MAX,
): Promise<FilteredIdsResponse> {
const where = this.personListWhere(actor, q, opts);
const seeDisc = await this.behaviorAccess.canView(actor);
const where = this.personListWhere(actor, q, seeDisc ? opts : { ...opts, disc: undefined });
const total = await this.prisma.person.count({ where });
if (total > max) {
throw new BadRequestException(
@@ -330,7 +358,7 @@ export class PeopleService implements OnModuleInit {
include: personInclude,
});
if (!row) throw new NotFoundException('شخص پیدا نشد');
return this.toDto(row);
return this.toDto(row, await this.behaviorAccess.canView(actor));
}
async create(actor: Actor, dto: CreatePersonRequest): Promise<PersonDto> {
@@ -407,7 +435,7 @@ export class PeopleService implements OnModuleInit {
where: { id: row.id },
include: personInclude,
});
return this.toDto(fresh ?? row);
return this.toDto(fresh ?? row, await this.behaviorAccess.canView(actor));
}
async update(
@@ -473,7 +501,7 @@ export class PeopleService implements OnModuleInit {
email: row.email,
companyId: row.companyId,
});
return this.toDto(row);
return this.toDto(row, await this.behaviorAccess.canView(actor));
}
async remove(actor: Actor, id: string): Promise<void> {
@@ -697,7 +725,7 @@ export class PeopleService implements OnModuleInit {
);
}
private toDto(row: PersonRow): PersonDto {
private toDto(row: PersonRow, includeDisc: boolean): PersonDto {
const previousFullName = `${row.previousFirstName ?? ''} ${row.previousLastName ?? ''}`.trim();
const importSource = row.importSource || 'manual';
return {
@@ -736,6 +764,10 @@ export class PeopleService implements OnModuleInit {
customFields: asCustomFields(row.customFields),
loyaltyPoints: row.loyaltyPoints ?? 0,
loyaltyTier: row.loyaltyTier,
discLabel: includeDisc && row.discReliable ? row.discLabel : null,
discReliable: includeDisc ? row.discReliable : false,
discAssessedAt:
includeDisc && row.discAssessedAt ? row.discAssessedAt.toISOString() : null,
company: row.company,
owner: row.owner,
createdAt: row.createdAt.toISOString(),
+104
View File
@@ -0,0 +1,104 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
analyzeCopilot,
followUpMessage,
scoreDisc,
type DiscAnswers,
} from '../../../packages/shared/src/behavior';
function all(type: DiscAnswers[string]): DiscAnswers {
return { '1': type, '2': type, '3': type, '4': type, '5': type, '6': type, '7': type, '8': type };
}
describe('امتیاز DISC', () => {
it('گزینه D فقط امتیاز D را زیاد می‌کند', () => {
const result = scoreDisc(all('D'));
assert.equal(result.scores.D, 8);
assert.equal(result.scores.I, 0);
assert.equal(result.scores.S, 0);
assert.equal(result.scores.C, 0);
assert.equal(result.label, 'D');
assert.equal(result.percents.D, 100);
});
it('هر چهار تیپ را جدا جمع می‌کند', () => {
const result = scoreDisc({ '1': 'D', '2': 'I', '3': 'S', '4': 'C', '5': 'D', '6': 'I' });
assert.deepEqual(result.scores, { D: 2, I: 2, S: 1, C: 1 });
assert.equal(result.validCount, 6);
assert.equal(result.label, 'D/I');
});
it('در تساوی نتیجه ترکیبی می‌دهد', () => {
const result = scoreDisc({ '1': 'S', '2': 'C', '3': 'S', '4': 'C' });
assert.equal(result.reliable, true);
assert.equal(result.label, 'S/C');
});
it('با کمتر از چهار پاسخ نتیجه قابل اتکا نمی‌دهد', () => {
const result = scoreDisc({ '1': 'D', '2': 'D', '3': 'D' });
assert.equal(result.reliable, false);
assert.equal(result.label, 'اطلاعات ناکافی برای نتیجه قابل اتکا');
assert.deepEqual(result.primaryTypes, []);
});
});
describe('دستیار فروش', () => {
it('برای نیاز ندارم سؤال کشف نیاز می‌دهد و قیمت نمی‌سازد', () => {
const analysis = analyzeCopilot({
topic: 'objection',
goal: 'discover_need',
customerStatement: 'فعلاً نیازی ندارم؛ همین روش فعلی برای من جواب می‌دهد.',
facts: { customerName: 'مهدی', stageName: 'سرنخ جدید' },
});
assert.equal(analysis.objection_category, 'no_need');
assert.match(analysis.recommended_question, /روش فعلی/);
assert.doesNotMatch(analysis.suggested_response, /\d{3,}/);
assert.equal(analysis.disc_context, null);
});
it('DISC تازه را راهنما می‌کند نه تشخیص قطعی', () => {
const analysis = analyzeCopilot({
topic: 'pricing',
goal: 'follow_price',
customerStatement: 'قیمتتان بالاست.',
facts: {
customerName: 'مهدی',
priceOffered: null,
disc: {
label: 'D',
primaryTypes: ['D'],
reliable: true,
assessedAt: new Date().toISOString(),
},
},
});
assert.equal(analysis.disc_context?.used, true);
assert.match(analysis.suggested_response, /کوتاه می‌گویم/);
assert.match(analysis.confidence_note, /قطعی/);
assert.match(analysis.suggested_response, /عدد یا تخفیف جدیدی پیشنهاد نمی‌شود/);
});
it('حرف کوتاه را قطعی تحلیل نمی‌کند', () => {
const analysis = analyzeCopilot({
topic: 'intro',
goal: 'discover_need',
customerStatement: 'نه',
facts: {},
});
assert.equal(analysis.needs_more_input, true);
assert.equal(analysis.suggested_response, '');
});
it('متن پیگیری قیمت را از خودش نمی‌سازد', () => {
const analysis = analyzeCopilot({
topic: 'pricing',
goal: 'follow_price',
customerStatement: 'باید فکر کنم.',
facts: { customerName: 'سارا', productName: 'منتکت' },
});
const text = followUpMessage('price_followup', { customerName: 'سارا', productName: 'منتکت' }, analysis);
assert.match(text, /منتکت/);
assert.match(text, /عدد قیمت را اینجا تکرار نمی‌کنم/);
});
});
@@ -0,0 +1,27 @@
<script setup lang="ts">
defineProps<{
label: string | null | undefined;
}>();
</script>
<template>
<span v-if="label" class="disc-badge" :title="`تیپ رفتاری احتمالی ${label}`">{{ label }}</span>
</template>
<style scoped>
.disc-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.6rem;
padding: 0.05rem 0.4rem;
border-radius: 999px;
background: #ecfeff;
color: #0f766e;
border: 1px solid #99f6e4;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.04em;
line-height: 1.4;
}
</style>
@@ -0,0 +1,254 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import {
DISC_CONFIDENCE_LABELS,
DISC_GUIDES,
DISC_QUESTIONS,
DISC_TYPE_SHORT,
DISC_TYPES,
discGuideFor,
scoreDisc,
type DiscAnswers,
type DiscAssessmentDto,
type DiscType,
} from '@mento/shared';
import { api } from '@/lib/api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { UiButton } from '@/components/ui';
const props = defineProps<{ personId: string }>();
const emit = defineEmits<{ saved: [] }>();
const history = ref<DiscAssessmentDto[]>([]);
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const editing = ref(false);
const answers = ref<DiscAnswers>({});
const note = ref('');
const compareA = ref('');
const compareB = ref('');
const preview = computed(() => scoreDisc(answers.value));
const guide = computed(() =>
preview.value.reliable ? discGuideFor(preview.value.primaryTypes) : null,
);
const latest = computed(() => history.value[0] ?? null);
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<DiscAssessmentDto[]>(`/behavior/people/${props.personId}/disc`);
history.value = data;
compareA.value = data[0]?.id ?? '';
compareB.value = data[1]?.id ?? '';
} catch {
error.value = 'تاریخچهٔ ارزیابی بارگذاری نشد.';
} finally {
loading.value = false;
}
}
function start() {
answers.value = {};
note.value = '';
editing.value = true;
error.value = '';
}
function choose(questionId: string, type: DiscType) {
answers.value = { ...answers.value, [questionId]: type };
}
function skip(questionId: string) {
const next = { ...answers.value };
delete next[questionId];
answers.value = next;
}
async function save() {
saving.value = true;
error.value = '';
try {
await api.post(`/behavior/people/${props.personId}/disc`, {
answers: answers.value,
note: note.value,
});
editing.value = false;
await load();
emit('saved');
} catch (err: unknown) {
const message = (err as { response?: { data?: { message?: string } } }).response?.data?.message;
error.value = typeof message === 'string' ? message : 'ذخیرهٔ ارزیابی ناموفق بود.';
} finally {
saving.value = false;
}
}
function row(id: string) {
return history.value.find((item) => item.id === id) ?? null;
}
watch(() => props.personId, () => void load(), { immediate: true });
function when(value: string) {
return formatJalaliNumeric(new Date(value));
}
</script>
<template>
<section class="disc">
<p class="banner">
این ابزار برای کمک به فروش است و آزمون روان‌شناختی یا تشخیص شخصیت نیست. درصدها فقط سهم پاسخ‌های همین فرم هستند.
</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="loading" class="muted">در حال بارگذاری ارزیابی‌ها…</p>
<article v-else-if="latest && !editing" class="card">
<header class="head">
<div>
<h3>تیپ غالب احتمالی: {{ latest.reliable ? latest.label : 'نامشخص' }}</h3>
<p v-if="latest.secondaryLabel">تیپ دوم: {{ latest.secondaryLabel }}</p>
<p class="muted">
اطمینان تخمینی: {{ DISC_CONFIDENCE_LABELS[latest.confidence] }}
· {{ when(latest.createdAt) }}
· {{ latest.assessorName }}
</p>
</div>
<UiButton size="sm" variant="primary" @click="start">شروع ارزیابی DISC</UiButton>
</header>
<ul class="bars">
<li v-for="type in DISC_TYPES" :key="type">
<span>{{ type }} · {{ DISC_TYPE_SHORT[type] }}</span>
<i><b :style="{ width: `${latest.percents[type]}%` }" /></i>
<em>{{ toFaDigits(latest.scores[type]) }} · {{ toFaDigits(latest.percents[type]) }}٪</em>
</li>
</ul>
<p v-if="latest.note" class="note">{{ latest.note }}</p>
</article>
<div v-else-if="!editing" class="card empty">
<p>هنوز ارزیابی رفتاری برای این مشتری ثبت نشده است.</p>
<UiButton size="sm" variant="primary" @click="start">شروع ارزیابی DISC</UiButton>
</div>
<form v-if="editing" class="card" @submit.prevent="save">
<header class="head">
<h3>ارزیابی جدید</h3>
<button type="button" class="link" @click="editing = false">انصراف</button>
</header>
<p class="muted">اگر شواهد کافی ندارید سؤال را رد کنید. با کمتر از چهار پاسخ، نتیجهٔ قابل اتکا ذخیره نمی‌شود ولی سابقه می‌ماند.</p>
<fieldset v-for="question in DISC_QUESTIONS" :key="question.id">
<legend>{{ toFaDigits(question.id) }}. {{ question.prompt }}</legend>
<label v-for="option in question.options" :key="option.key">
<input
type="radio"
:name="`q-${question.id}`"
:checked="answers[question.id] === option.type"
@change="choose(question.id, option.type)"
/>
<span><b>{{ option.key }}.</b> {{ option.text }}</span>
</label>
<button type="button" class="link" @click="skip(question.id)">رد کردن این سؤال</button>
</fieldset>
<div class="preview">
<h4>{{ preview.reliable ? `نتیجهٔ پیش‌نویس: ${preview.label}` : preview.label }}</h4>
<p v-if="preview.secondaryLabel">تیپ دوم احتمالی: {{ preview.secondaryLabel }}</p>
<ul class="bars">
<li v-for="type in DISC_TYPES" :key="type">
<span>{{ type }}</span>
<i><b :style="{ width: `${preview.percents[type]}%` }" /></i>
<em>{{ toFaDigits(preview.scores[type]) }}</em>
</li>
</ul>
<p class="muted">{{ preview.shareNote }}</p>
<template v-if="guide">
<h4>راهنمای احتمالی فروش</h4>
<p class="muted">این پیشنهادها را با رفتار واقعی مشتری تطبیق دهید.</p>
<ul>
<li v-for="line in guide.negotiation" :key="line">{{ line }}</li>
</ul>
<h4>ارائه</h4>
<ul>
<li v-for="line in guide.presentation" :key="line">{{ line }}</li>
</ul>
<h4>پیگیری</h4>
<ul>
<li v-for="line in guide.followUp" :key="line">{{ line }}</li>
</ul>
</template>
<label class="note-field">
یادداشت رفتاری و شواهد
<textarea v-model="note" rows="3" maxlength="4000" placeholder="چه رفتاری را واقعاً دیدید؟" />
</label>
</div>
<UiButton type="submit" variant="primary" :disabled="saving">
{{ saving ? 'در حال ذخیره…' : 'تأیید و ذخیره در پرونده' }}
</UiButton>
</form>
<article v-if="history.length" class="card">
<h3>تاریخچه</h3>
<ul class="history">
<li v-for="item in history" :key="item.id">
<strong>{{ item.reliable ? item.label : 'ناکافی' }}</strong>
<span>{{ when(item.createdAt) }} · {{ item.assessorName }}</span>
<span>D{{ toFaDigits(item.scores.D) }} I{{ toFaDigits(item.scores.I) }} S{{ toFaDigits(item.scores.S) }} C{{ toFaDigits(item.scores.C) }}</span>
</li>
</ul>
<div v-if="history.length > 1" class="compare">
<label>
مقایسه
<select v-model="compareA">
<option v-for="item in history" :key="item.id" :value="item.id">{{ when(item.createdAt) }} · {{ item.label }}</option>
</select>
</label>
<label>
با
<select v-model="compareB">
<option v-for="item in history" :key="item.id" :value="item.id">{{ when(item.createdAt) }} · {{ item.label }}</option>
</select>
</label>
<table v-if="row(compareA) && row(compareB)">
<thead>
<tr><th>تیپ</th><th>اول</th><th>دوم</th></tr>
</thead>
<tbody>
<tr v-for="type in DISC_TYPES" :key="type">
<td>{{ type }}</td>
<td>{{ toFaDigits(row(compareA)!.scores[type]) }}</td>
<td>{{ toFaDigits(row(compareB)!.scores[type]) }}</td>
</tr>
</tbody>
</table>
</div>
</article>
<p v-if="latest" class="muted tiny">نقاط قوت احتمالی آخرین تیپ: {{ latest.primaryTypes.map((type) => DISC_GUIDES[type].strengths.join('، ')).join(' / ') || '—' }}</p>
</section>
</template>
<style scoped>
.disc { display: flex; flex-direction: column; gap: 0.8rem; }
.banner, .card { background: #fff; border: 1px solid #e2e8f0; border-radius: 14px; padding: 0.9rem 1rem; }
.banner { background: #f8fafc; color: #475569; font-size: 0.86rem; }
.head, .history li, .compare { display: flex; justify-content: space-between; gap: 0.75rem; align-items: flex-start; flex-wrap: wrap; }
h3, h4 { margin: 0 0 0.35rem; }
.muted, .tiny { color: #64748b; font-size: 0.82rem; }
.error { color: #b91c1c; }
fieldset { border: 0; margin: 0.8rem 0; padding: 0; display: flex; flex-direction: column; gap: 0.4rem; }
legend { font-weight: 700; margin-bottom: 0.25rem; }
label { display: flex; gap: 0.45rem; align-items: flex-start; }
.bars { list-style: none; padding: 0; margin: 0.5rem 0; display: flex; flex-direction: column; gap: 0.35rem; }
.bars li { display: grid; grid-template-columns: 7.5rem 1fr auto; gap: 0.45rem; align-items: center; font-size: 0.82rem; }
.bars i { display: block; height: 8px; background: #e2e8f0; border-radius: 99px; overflow: hidden; }
.bars b { display: block; height: 100%; background: #0f766e; }
.note-field, .compare { display: flex; flex-direction: column; gap: 0.35rem; margin-top: 0.6rem; }
textarea, select { width: 100%; border: 1px solid #cbd5e1; border-radius: 10px; padding: 0.5rem; font: inherit; }
.link { border: 0; background: transparent; color: #0f766e; cursor: pointer; padding: 0; }
.history { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.45rem; }
table { width: 100%; border-collapse: collapse; }
td, th { border-bottom: 1px solid #e2e8f0; text-align: right; padding: 0.3rem; }
.empty { display: flex; justify-content: space-between; gap: 0.75rem; align-items: center; }
</style>
@@ -0,0 +1,332 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import {
COPILOT_GOAL_LABELS,
COPILOT_GOALS,
COPILOT_TOPIC_LABELS,
COPILOT_TOPICS,
FOLLOW_UP_MESSAGE_KINDS,
FOLLOW_UP_MESSAGE_LABELS,
followUpMessage,
type CopilotAnalysis,
type CopilotFacts,
type CopilotGoal,
type CopilotTopic,
type FollowUpMessageKind,
type SalesCopilotSessionDto,
} from '@mento/shared';
import { api } from '@/lib/api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { UiButton } from '@/components/ui';
import SmsComposeModal from '@/components/SmsComposeModal.vue';
const props = defineProps<{
personId?: string | null;
leadId?: string | null;
dealId?: string | null;
customerName?: string | null;
discLabel?: string | null;
stageName?: string | null;
phone?: string | null;
smsEndpoint?: string | null;
smsHistoryEndpoint?: string | null;
}>();
const topic = ref<CopilotTopic>('objection');
const goal = ref<CopilotGoal>('find_objection');
const statement = ref('');
const behaviorNotes = ref('');
const analysis = ref<CopilotAnalysis | null>(null);
const responseText = ref('');
const agentNote = ref('');
const callOutcome = ref('');
const followUpAt = ref('');
const messageKind = ref<FollowUpMessageKind>('whatsapp_after_call');
const messageText = ref('');
const busy = ref(false);
const error = ref('');
const savedId = ref('');
const history = ref<SalesCopilotSessionDto[]>([]);
const smsOpen = ref(false);
const step = ref<'form' | 'result'>('form');
const facts = computed<CopilotFacts>(() => ({
customerName: props.customerName,
stageName: props.stageName,
disc: props.discLabel
? {
label: props.discLabel,
primaryTypes: props.discLabel.split('/').filter((part): part is 'D' | 'I' | 'S' | 'C' =>
part === 'D' || part === 'I' || part === 'S' || part === 'C',
),
reliable: true,
assessedAt: new Date().toISOString(),
}
: null,
}));
const localMessage = computed(() =>
analysis.value ? followUpMessage(messageKind.value, facts.value, analysis.value) : '',
);
watch(localMessage, (value) => {
messageText.value = value;
});
async function loadHistory() {
try {
const { data } = await api.get<SalesCopilotSessionDto[]>('/behavior/copilot/sessions', {
params: {
personId: props.personId || undefined,
leadId: props.leadId || undefined,
dealId: props.dealId || undefined,
},
});
history.value = data;
} catch {
history.value = [];
}
}
async function analyze() {
busy.value = true;
error.value = '';
try {
const { data } = await api.post<CopilotAnalysis>('/behavior/copilot/analyze', payload());
analysis.value = data;
responseText.value = data.suggested_response;
followUpAt.value = data.suggested_follow_up_date ?? '';
step.value = 'result';
messageText.value = followUpMessage(messageKind.value, facts.value, data);
} catch (err: unknown) {
const message = (err as { response?: { data?: { message?: string } } }).response?.data?.message;
error.value = typeof message === 'string' ? message : 'تحلیل مکالمه ناموفق بود.';
} finally {
busy.value = false;
}
}
function payload() {
return {
personId: props.personId || undefined,
leadId: props.leadId || undefined,
dealId: props.dealId || undefined,
topic: topic.value,
goal: goal.value,
customerStatement: statement.value,
behaviorNotes: behaviorNotes.value,
};
}
async function save(status: 'confirmed' | 'dismissed') {
if (!analysis.value) return;
busy.value = true;
error.value = '';
try {
const { data } = await api.post<SalesCopilotSessionDto>('/behavior/copilot/sessions', {
...payload(),
status,
confirmedResponse: responseText.value,
nextAction: analysis.value.next_best_action,
followUpAt: followUpAt.value || null,
callOutcome: callOutcome.value,
agentNote: agentNote.value,
messageDraft: messageText.value,
});
savedId.value = status === 'confirmed' ? data.id : '';
await loadHistory();
} catch {
error.value = 'ثبت نتیجه ناموفق بود.';
} finally {
busy.value = false;
}
}
async function createTask() {
if (!savedId.value) {
await save('confirmed');
}
if (!savedId.value) return;
busy.value = true;
try {
await api.post(`/behavior/copilot/sessions/${savedId.value}/follow-up`, {
dueAt: followUpAt.value || undefined,
});
error.value = '';
await loadHistory();
} catch {
error.value = 'ساخت وظیفهٔ پیگیری ناموفق بود.';
} finally {
busy.value = false;
}
}
async function copy(text: string) {
try {
await navigator.clipboard.writeText(text);
} catch {
error.value = 'کپی در این مرورگر ممکن نشد.';
}
}
watch(
() => [props.personId, props.leadId, props.dealId],
() => void loadHistory(),
{ immediate: true },
);
</script>
<template>
<section class="copilot">
<p class="banner">
دستیار مربی فروش است. پیشنهادش تا وقتی تأیید نکنید در پرونده به‌عنوان واقعیت ذخیره نمی‌شود و پیام بیرونی هم بدون تأیید شما ارسال نمی‌شود.
</p>
<article class="card summary">
<div>
<strong>{{ customerName || 'مخاطب' }}</strong>
<span v-if="stageName">مرحله: {{ stageName }}</span>
</div>
<span v-if="discLabel" class="pill">DISC احتمالی {{ discLabel }}</span>
<span v-else class="muted">پروفایل DISC تازه و معتبر نیست</span>
</article>
<form v-show="step === 'form'" class="card" @submit.prevent="analyze">
<label>
موضوع مکالمه
<select v-model="topic">
<option v-for="item in COPILOT_TOPICS" :key="item" :value="item">{{ COPILOT_TOPIC_LABELS[item] }}</option>
</select>
</label>
<label>
هدف شما
<select v-model="goal">
<option v-for="item in COPILOT_GOALS" :key="item" :value="item">{{ COPILOT_GOAL_LABELS[item] }}</option>
</select>
</label>
<label>
حرف مشتری
<textarea v-model="statement" rows="4" required placeholder="جملهٔ دقیق مشتری، اعتراض یا دغدغه‌اش" />
</label>
<label>
نشانه‌های رفتاری مشاهده‌شده
<textarea v-model="behaviorNotes" rows="2" placeholder="مثلاً عجله داشت، جزئیات خواست، یا خواست مشورت کند" />
</label>
<UiButton type="submit" variant="primary" :disabled="busy">تحلیل مکالمه و پیشنهاد راهکار</UiButton>
</form>
<div v-if="analysis && step === 'result'" class="stack">
<article class="card">
<header class="head">
<h3>تحلیل</h3>
<button type="button" class="link" @click="step = 'form'">ویرایش ورودی</button>
</header>
<p>{{ analysis.customer_statement_summary }}</p>
<p v-if="analysis.objection_label"><b>دستهٔ احتمالی:</b> {{ analysis.objection_label }}</p>
<ul>
<li v-for="reason in analysis.possible_underlying_reasons" :key="reason">{{ reason }}</li>
</ul>
<p v-if="analysis.missing_information.length" class="warn">
اطلاعات ناموجود: {{ analysis.missing_information.join('؛ ') }}
</p>
<p class="muted">{{ analysis.confidence_note }}</p>
<p v-if="analysis.disc_context" class="muted">{{ analysis.disc_context.note }}</p>
</article>
<article class="card">
<h3>سؤال پیشنهادی</h3>
<p>{{ analysis.recommended_question }}</p>
<button type="button" class="link" @click="copy(analysis.recommended_question)">کپی سؤال</button>
</article>
<article class="card">
<h3>پاسخ پیشنهادی</h3>
<textarea v-model="responseText" rows="5" />
<div class="actions">
<UiButton size="sm" variant="outline" type="button" @click="copy(responseText)">کپی پاسخ</UiButton>
<UiButton size="sm" variant="outline" type="button" :disabled="busy" @click="analyze">تولید پاسخ جدید</UiButton>
</div>
<p class="warn">خطای رایج: {{ analysis.common_mistake }}</p>
</article>
<article class="card">
<h3>راهبرد</h3>
<ul>
<li v-for="line in analysis.sales_strategy" :key="line">{{ line }}</li>
</ul>
<h3>اقدام بعدی</h3>
<p>{{ analysis.next_best_action }}</p>
<label>
تاریخ پیگیری
<input v-model="followUpAt" type="date" />
</label>
<label>
نتیجهٔ تماس
<input v-model="callOutcome" type="text" maxlength="500" />
</label>
<label>
یادداشت کارشناس
<textarea v-model="agentNote" rows="2" />
</label>
</article>
<article class="card">
<h3>متن پیگیری</h3>
<select v-model="messageKind">
<option v-for="kind in FOLLOW_UP_MESSAGE_KINDS" :key="kind" :value="kind">{{ FOLLOW_UP_MESSAGE_LABELS[kind] }}</option>
</select>
<textarea v-model="messageText" rows="4" />
<div class="actions">
<UiButton size="sm" variant="outline" type="button" @click="copy(messageText)">کپی متن</UiButton>
<UiButton v-if="smsEndpoint && phone" size="sm" variant="outline" type="button" @click="smsOpen = true">
ارسال پیامک با تأیید
</UiButton>
</div>
</article>
<div class="actions">
<UiButton variant="primary" type="button" :disabled="busy" @click="save('confirmed')">ثبت نتیجه</UiButton>
<UiButton variant="outline" type="button" :disabled="busy" @click="createTask">ایجاد وظیفه پیگیری</UiButton>
<UiButton variant="ghost" type="button" :disabled="busy" @click="save('dismissed')">رد پیشنهاد</UiButton>
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<article v-if="history.length" class="card">
<h3>نتایج ثبت‌شده</h3>
<ul class="history">
<li v-for="item in history" :key="item.id">
<strong>{{ item.status === 'confirmed' ? 'تأییدشده' : 'ردشده' }}</strong>
<span>{{ formatJalaliNumeric(new Date(item.createdAt)) }} · {{ item.userName }}</span>
<span>{{ item.analysis.objection_label || 'بدون دسته' }}</span>
<span v-if="item.activityId">وظیفه ساخته شد</span>
</li>
</ul>
<p class="muted">{{ toFaDigits(history.length) }} نتیجه در تاریخچه مانده است.</p>
</article>
<SmsComposeModal
v-if="smsEndpoint && phone"
:open="smsOpen"
:endpoint="smsEndpoint"
:history-endpoint="smsHistoryEndpoint || ''"
:name="customerName || ''"
:phone="phone"
:default-text="messageText"
title="ارسال پیامک پیگیری"
@close="smsOpen = false"
/>
</section>
</template>
<style scoped>
.copilot, .stack { display: flex; flex-direction: column; gap: 0.75rem; }
.banner, .card { background: #fff; border: 1px solid #e2e8f0; border-radius: 14px; padding: 0.9rem 1rem; }
.banner { background: #f8fafc; color: #475569; font-size: 0.86rem; }
.summary, .head, .actions, .history li { display: flex; justify-content: space-between; gap: 0.6rem; align-items: center; flex-wrap: wrap; }
label { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 0.55rem; font-weight: 700; }
textarea, select, input { font: inherit; font-weight: 400; border: 1px solid #cbd5e1; border-radius: 10px; padding: 0.5rem; width: 100%; }
.pill { background: #ecfeff; color: #0f766e; border-radius: 999px; padding: 0.1rem 0.55rem; font-size: 0.78rem; font-weight: 800; }
.muted { color: #64748b; font-size: 0.82rem; font-weight: 400; }
.warn { color: #9a3412; }
.error { color: #b91c1c; }
.link { border: 0; background: transparent; color: #0f766e; cursor: pointer; }
.history { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.4rem; }
h3 { margin: 0 0 0.4rem; }
ul { margin: 0.3rem 0 0; padding-inline-start: 1.1rem; }
</style>
+10
View File
@@ -0,0 +1,10 @@
import type { AuthUserDto } from '@mento/shared';
export function canUseBehavior(user: AuthUserDto | null | undefined): boolean {
if (!user) return false;
const modules = user.enabledModules;
if (modules && !modules.includes('behavior')) return false;
if (user.role === 'owner' || user.role === 'admin') return true;
const permissions = user.permissions ?? [];
return permissions.includes('behavior') || permissions.includes('sales');
}
+9
View File
@@ -82,6 +82,11 @@ export const router = createRouter({
name: 'reports',
component: () => import('@/views/ReportsView.vue'),
},
{
path: 'behavior/disc',
name: 'disc-report',
component: () => import('@/views/DiscReportView.vue'),
},
{
path: 'sales/forecast',
name: 'sales-forecast',
@@ -563,6 +568,10 @@ router.beforeEach((to) => {
) {
return { name: 'home' };
}
if (String(to.path).startsWith('/behavior')) {
const modules = auth.user?.enabledModules;
if (modules && !modules.includes('behavior')) return { name: 'home' };
}
if (String(to.path).startsWith('/finance')) {
const modules = auth.user?.enabledModules;
if (modules && !modules.includes('treasury')) return { name: 'home' };
+32 -5
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
@@ -36,6 +37,8 @@ import {
PERSON_IMPORT_SOURCES,
} from '@mento/shared';
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
import { canUseBehavior } from '@/lib/behavior-access';
import DiscBadge from '@/components/behavior/DiscBadge.vue';
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
type Tab = 'people' | 'companies';
@@ -58,6 +61,12 @@ const moodFilter = computed<PersonMood | 'all'>(() => {
const value = typeof route.query.mood === 'string' ? route.query.mood : 'all';
return PERSON_MOODS.includes(value as PersonMood) ? (value as PersonMood) : 'all';
});
const auth = useAuthStore();
const canBehavior = computed(() => canUseBehavior(auth.user));
const discFilter = computed(() => {
const value = typeof route.query.disc === 'string' ? route.query.disc.toUpperCase() : '';
return /^[DISC](\/[DISC]){0,3}$/.test(value) ? value : '';
});
const sourceFilter = computed<PersonImportSource | 'all'>(() => {
const value = typeof route.query.source === 'string' ? route.query.source : 'all';
return PERSON_IMPORT_SOURCES.includes(value as PersonImportSource)
@@ -135,6 +144,7 @@ const peopleSavedFilters = computed(() => ({
sort: sort.value === 'name' ? undefined : sort.value,
mood: moodFilter.value === 'all' ? undefined : moodFilter.value,
source: sourceFilter.value === 'all' ? undefined : sourceFilter.value,
disc: discFilter.value || undefined,
}));
function applyPeopleSavedFilters(filters: Record<string, unknown>) {
@@ -223,6 +233,7 @@ async function load() {
mood: moodFilter.value === 'all' ? undefined : moodFilter.value,
importSource:
sourceFilter.value === 'all' ? undefined : sourceFilter.value,
disc: discFilter.value || undefined,
},
}),
api.get<PersonImportSourceStatDto[]>('/people/source-stats').catch(() => ({
@@ -280,6 +291,10 @@ function setMood(next: PersonMood | 'all') {
patchQuery({ mood: next === 'all' ? undefined : next, page: undefined });
}
function setDisc(next: string) {
patchQuery({ disc: next || undefined, page: undefined });
}
function setSource(next: PersonImportSource | 'all') {
patchQuery({
source: next === 'all' ? undefined : next,
@@ -394,7 +409,7 @@ function onImportSettled() {
}
watch(
() => [route.query.tab, route.query.q, route.query.page, route.query.sort, route.query.mood],
() => [route.query.tab, route.query.q, route.query.page, route.query.sort, route.query.mood, route.query.disc, route.query.source],
() => {
q.value = typeof route.query.q === 'string' ? route.query.q : '';
void load();
@@ -412,6 +427,9 @@ onMounted(load);
lead="مدیریت و مشاهده اشخاص و شرکت‌ها"
>
<template #actions>
<RouterLink v-if="canBehavior" :to="{ name: 'disc-report' }">
<UiButton variant="outline" size="sm">گزارش DISC</UiButton>
</RouterLink>
<UiButton variant="ghost" size="sm" :disabled="busy" :loading="pageBusy && busyMessage.includes('خروجی')" @click.stop="exportExcel">
<span class="btn-full">خروجی اکسل</span>
<span class="btn-short">خروجی</span>
@@ -538,6 +556,13 @@ onMounted(load);
</button>
</div>
<div v-if="tab === 'people' && canBehavior" class="source-stats">
<button type="button" class="source-chip" :class="{ on: !discFilter }" @click="setDisc('')">همه تیپ‌ها</button>
<button v-for="type in ['D', 'I', 'S', 'C']" :key="type" type="button" class="source-chip" :class="{ on: discFilter === type }" @click="setDisc(type)">
{{ type }}
</button>
</div>
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
@@ -551,6 +576,7 @@ onMounted(load);
<RouterLink class="name" :to="{ name: 'person-detail', params: { id: p.id } }">
{{ p.fullName }}
</RouterLink>
<DiscBadge v-if="canBehavior" :label="p.discLabel" />
<UiDropdownMenu
:open="menuId === `m:${p.id}`"
align="end"
@@ -648,10 +674,11 @@ onMounted(load);
</div>
</td>
<td>
<RouterLink class="name" :to="{ name: 'person-detail', params: { id: p.id } }">
{{ p.fullName }}
</RouterLink>
<div v-if="p.previousFullName" class="muted tiny">
<RouterLink class="name" :to="{ name: 'person-detail', params: { id: p.id } }">
{{ p.fullName }}
</RouterLink>
<DiscBadge v-if="canBehavior" :label="p.discLabel" />
<div v-if="p.previousFullName" class="muted tiny">
قبلی: {{ p.previousFullName }}
</div>
</td>
+8
View File
@@ -4,6 +4,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { canUseBehavior } from '@/lib/behavior-access';
import { toFaDigits } from '@/lib/jalali';
import { currencySuffix, formatMoney } from '@/lib/money';
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
@@ -60,6 +61,7 @@ const emit = defineEmits<{
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const canBehavior = computed(() => canUseBehavior(auth.user));
const id = computed(() => {
if (props.embedded) return props.dealId ?? '';
return typeof route.params.id === 'string' ? route.params.id : '';
@@ -552,6 +554,12 @@ onMounted(load);
>
<UiButton variant="outline">صدور فاکتور</UiButton>
</RouterLink>
<RouterLink
v-if="!isNew && personId && canBehavior"
:to="{ name: 'person-detail', params: { id: personId }, query: { tab: 'copilot', dealId: id } }"
>
<UiButton variant="outline">کمک برای ادامه مذاکره</UiButton>
</RouterLink>
<UiButton variant="primary" :disabled="saving" @click="submit">
<UiIcon name="check" :size="16" />
{{ saving ? 'در حال ذخیره…' : 'ذخیره معامله' }}
+75
View File
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import type { DiscDistributionRow } from '@mento/shared';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { UiButton, UiPageHeader } from '@/components/ui';
const rows = ref<DiscDistributionRow[]>([]);
const error = ref('');
const loading = ref(true);
const exporting = ref(false);
onMounted(async () => {
try {
const { data } = await api.get<DiscDistributionRow[]>('/behavior/disc/distribution');
rows.value = data;
} catch {
error.value = 'گزارش توزیع تیپ‌ها بارگذاری نشد.';
} finally {
loading.value = false;
}
});
async function exportExcel() {
exporting.value = true;
error.value = '';
try {
const { data } = await api.get<Blob>('/behavior/disc/export', { responseType: 'blob' });
const url = URL.createObjectURL(data);
const link = document.createElement('a');
link.href = url;
link.download = 'disc.xlsx';
link.click();
URL.revokeObjectURL(url);
} catch {
error.value = 'خروجی اکسل ناموفق بود.';
} finally {
exporting.value = false;
}
}
const total = () => rows.value.reduce((sum, row) => sum + row.count, 0);
</script>
<template>
<section class="page">
<UiPageHeader title="توزیع تیپ‌های DISC" lead="فقط ارزیابی‌های تأییدشده و قابل اتکا، در محدودهٔ دسترسی شما" :back-to="{ name: 'contacts' }">
<template #actions>
<UiButton variant="outline" :disabled="exporting" @click="exportExcel">خروجی اکسل</UiButton>
</template>
</UiPageHeader>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<p v-else-if="error" class="error">{{ error }}</p>
<p v-else-if="!rows.length" class="muted">هنوز تیپ قابل اتکایی ثبت نشده است.</p>
<ul v-else class="list">
<li v-for="row in rows" :key="row.label">
<RouterLink :to="{ name: 'contacts', query: { disc: row.label } }">{{ row.label }}</RouterLink>
<span>{{ toFaDigits(row.count) }}</span>
<i><b :style="{ width: `${total() ? Math.round((row.count / total()) * 100) : 0}%` }" /></i>
</li>
</ul>
<p class="muted">درصدها سهم مشتریان دارای ارزیابی معتبر هستند، نه دقت یک آزمون روان‌شناسی.</p>
</section>
</template>
<style scoped>
.page { display: flex; flex-direction: column; gap: 0.8rem; }
.list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.55rem; }
.list li { display: grid; grid-template-columns: 6rem auto 1fr; gap: 0.6rem; align-items: center; }
.list i { height: 8px; background: #e2e8f0; border-radius: 99px; overflow: hidden; }
.list b { display: block; height: 100%; background: #0f766e; }
.muted { color: #64748b; }
.error { color: #b91c1c; }
</style>
+25
View File
@@ -30,6 +30,8 @@ import type {
PaginatedResponse,
} from '@mento/shared';
import { ACTIVITY_TYPE_LABELS } from '@mento/shared';
import { canUseBehavior } from '@/lib/behavior-access';
import SalesCopilotPanel from '@/components/behavior/SalesCopilotPanel.vue';
type LeadDetail = LeadDto & { events: LeadEventDto[] };
@@ -37,6 +39,8 @@ const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const isAdmin = computed(() => canManageSettings(auth.user?.role));
const canBehavior = computed(() => canUseBehavior(auth.user));
const copilotOpen = ref(false);
const id = computed(() => String(route.params.id));
const loading = ref(false);
@@ -382,6 +386,14 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
>
پیامک
</UiButton>
<UiButton
v-if="lead && !editing && canBehavior"
variant="secondary"
size="sm"
@click="copilotOpen = !copilotOpen"
>
کمک برای ادامه مذاکره
</UiButton>
<UiButton
v-if="lead && !editing"
variant="secondary"
@@ -412,6 +424,16 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
</template>
</UiPageHeader>
</div>
<SalesCopilotPanel
v-if="lead && copilotOpen && canBehavior"
:person-id="lead.convertedPersonId"
:lead-id="lead.id"
:customer-name="lead.contactName || lead.title"
:stage-name="lead.stageName"
:phone="lead.mobile"
:sms-endpoint="`/leads/${lead.id}/sms`"
:sms-history-endpoint="`/leads/${lead.id}/sms-history`"
/>
<button
v-if="moreOpen"
@@ -719,6 +741,9 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
<aside class="side">
<button class="sheet-close" type="button" @click="moreOpen = false">بستن</button>
<UiCard v-if="canBehavior" title="مذاکره">
<UiButton size="sm" @click="copilotOpen = true; moreOpen = false">کمک برای ادامه مذاکره</UiButton>
</UiCard>
<UiCard title="پیامک">
<UiButton size="sm" :disabled="!lead.mobile" @click="smsOpen = true; moreOpen = false">
ارسال پیامک
+50 -11
View File
@@ -48,8 +48,12 @@ import type {
TicketDto,
} from '@mento/shared';
import { PERSON_MOOD_LABELS } from '@/lib/person-mood';
import { canUseBehavior } from '@/lib/behavior-access';
import DiscBadge from '@/components/behavior/DiscBadge.vue';
import DiscPanel from '@/components/behavior/DiscPanel.vue';
import SalesCopilotPanel from '@/components/behavior/SalesCopilotPanel.vue';
type TabKey = 'activities' | 'tasks' | 'deals' | 'cards' | 'assets' | 'info';
type TabKey = 'activities' | 'tasks' | 'deals' | 'cards' | 'assets' | 'info' | 'disc' | 'copilot';
type TimelineTone = 'call' | 'quote' | 'note' | 'meeting' | 'task';
type HistoryFilter = 'all' | TimelineTone;
@@ -71,14 +75,25 @@ type AttachmentRow = {
kind: 'pdf' | 'doc' | 'img' | 'file';
};
const TABS: Array<{ key: TabKey; label: string }> = [
{ key: 'activities', label: 'فعالیت‌ها' },
{ key: 'tasks', label: 'کارهای مرتبط' },
{ key: 'deals', label: 'معاملات' },
{ key: 'cards', label: 'کارت‌ها' },
{ key: 'assets', label: 'دارایی' },
{ key: 'info', label: 'اطلاعات' },
];
const auth = useAuthStore();
const canBehavior = computed(() => canUseBehavior(auth.user));
const TABS = computed<Array<{ key: TabKey; label: string }>>(() => {
const items: Array<{ key: TabKey; label: string }> = [
{ key: 'activities', label: 'فعالیت‌ها' },
{ key: 'tasks', label: 'کارهای مرتبط' },
{ key: 'deals', label: 'معاملات' },
{ key: 'cards', label: 'کارت‌ها' },
{ key: 'assets', label: 'دارایی' },
{ key: 'info', label: 'اطلاعات' },
];
if (canBehavior.value) {
items.push(
{ key: 'disc', label: 'تحلیل رفتاری DISC' },
{ key: 'copilot', label: 'دستیار هوشمند فروش' },
);
}
return items;
});
const HISTORY_FILTERS: Array<{ key: HistoryFilter; label: string }> = [
{ key: 'all', label: 'همه' },
@@ -182,7 +197,6 @@ const previousSourceLabel = computed(() => {
return SOURCE_LABELS[src] || src;
});
const auth = useAuthStore();
const priorArchiveOn = computed(() => Boolean(auth.user?.priorArchive));
const priorLens = computed(() => auth.user?.priorArchive?.lens ?? null);
@@ -451,8 +465,14 @@ function readTab() {
q === 'deals' ||
q === 'cards' ||
q === 'assets' ||
q === 'info'
q === 'info' ||
q === 'disc' ||
q === 'copilot'
) {
if ((q === 'disc' || q === 'copilot') && !canBehavior.value) {
tab.value = 'activities';
return;
}
tab.value = q;
} else {
tab.value = 'activities';
@@ -696,6 +716,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
<h2>{{ fullName }}</h2>
<span class="mood-under" :class="mood">{{ moodLabel }}</span>
<span v-if="vip" class="vip-mark">VIP</span>
<DiscBadge v-if="canBehavior && person.discReliable" :label="person.discLabel" />
</div>
<p v-if="subtitle" class="sub">{{ subtitle }}</p>
<div class="meta-chips">
@@ -787,6 +808,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
>
</h2>
<p v-if="subtitle" class="sub">{{ subtitle }}</p>
<DiscBadge v-if="canBehavior && person.discReliable" :label="person.discLabel" />
</div>
</div>
<div class="contact-rows">
@@ -1018,6 +1040,23 @@ watch(() => route.query.tab, readTab, { immediate: true });
<PersonAssetsPanel :person-id="id" @link-changed="onTahesabLinkChanged" />
</div>
<div v-if="canBehavior && tab === 'disc'" class="panel">
<DiscPanel :person-id="id" @saved="load" />
</div>
<div v-if="canBehavior && tab === 'copilot'" class="panel">
<SalesCopilotPanel
:person-id="id"
:deal-id="typeof route.query.dealId === 'string' ? route.query.dealId : null"
:customer-name="fullName"
:disc-label="person.discReliable ? person.discLabel : null"
:stage-name="null"
:phone="phoneNumber"
:sms-endpoint="`/people/${id}/sms`"
:sms-history-endpoint="`/people/${id}/sms-history`"
/>
</div>
<div v-show="tab === 'info'" class="panel info-panel" :class="{ 'is-empty': infoEmpty }">
<article class="smart">
<header class="smart-head">
+839
View File
@@ -0,0 +1,839 @@
/** تحلیل رفتاری DISC و دستیار فروش — راهنمای مذاکره، نه تشخیص بالینی. */
export const DISC_TYPES = ["D", "I", "S", "C"] as const;
export type DiscType = (typeof DISC_TYPES)[number];
export const DISC_TYPE_LABELS: Record<DiscType, string> = {
D: "سلطه‌گری (نتیجه‌گرا)",
I: "تأثیرگذاری (ارتباط‌گرا)",
S: "ثبات (رابطه‌محور)",
C: "وظیفه‌شناسی (دقیق)",
};
export const DISC_TYPE_SHORT: Record<DiscType, string> = {
D: "نتیجه‌گرا",
I: "ارتباط‌گرا",
S: "باثبات",
C: "دقیق",
};
export const DISC_MIN_VALID_ANSWERS = 4;
export const DISC_QUESTION_COUNT = 8;
/** پروفایل قدیمی‌تر از این روز در دستیار فقط به‌عنوان سرنخ ضعیف استفاده نمی‌شود. */
export const DISC_FRESH_DAYS = 180;
export const DISC_INSUFFICIENT_LABEL = "اطلاعات ناکافی برای نتیجه قابل اتکا";
export type DiscConfidence = "insufficient" | "low" | "medium" | "high";
export const DISC_CONFIDENCE_LABELS: Record<DiscConfidence, string> = {
insufficient: "ناکافی",
low: "کم",
medium: "متوسط",
high: "نسبتاً روشن",
};
export type DiscAnswers = Partial<Record<string, DiscType | null>>;
export interface DiscOption {
key: "A" | "B" | "C" | "D";
type: DiscType;
text: string;
}
export interface DiscQuestion {
id: string;
prompt: string;
options: DiscOption[];
}
export const DISC_QUESTIONS: DiscQuestion[] = [
{
id: "1",
prompt: "مشتری در ابتدای مکالمه چگونه رفتار می‌کند؟",
options: [
{ key: "A", type: "D", text: "سریع سراغ اصل موضوع، نتیجه و قیمت می‌رود." },
{ key: "B", type: "I", text: "گرم و صمیمی صحبت می‌کند و ارتباط برقرار می‌کند." },
{ key: "C", type: "S", text: "با آرامش گوش می‌دهد و برای صحبت‌کردن فرصت می‌خواهد." },
{ key: "D", type: "C", text: "سؤال‌های دقیق درباره جزئیات، قابلیت‌ها و شرایط می‌پرسد." },
],
},
{
id: "2",
prompt: "هنگام تصمیم‌گیری چه رفتاری دارد؟",
options: [
{ key: "A", type: "D", text: "سریع تصمیم می‌گیرد و دوست دارد کنترل انتخاب دست خودش باشد." },
{ key: "B", type: "I", text: "با توجه به حس مثبتش نسبت به محصول و ارتباط با فروشنده تصمیم می‌گیرد." },
{ key: "C", type: "S", text: "ترجیح می‌دهد فرصت کافی برای بررسی و مشورت داشته باشد." },
{ key: "D", type: "C", text: "اطلاعات، اعداد و شواهد را بررسی می‌کند و سپس تصمیم می‌گیرد." },
],
},
{
id: "3",
prompt: "در برابر پیشنهاد فروش چه واکنشی نشان می‌دهد؟",
options: [
{ key: "A", type: "D", text: "می‌پرسد این محصول دقیقاً چه نتیجه‌ای برای من دارد؟" },
{ key: "B", type: "I", text: "درباره تجربه استفاده، مزایا و جذابیت محصول صحبت می‌کند." },
{ key: "C", type: "S", text: "می‌خواهد مطمئن شود استفاده از محصول برایش راحت و بدون دردسر است." },
{ key: "D", type: "C", text: "درباره جزئیات فنی، شرایط قرارداد، محدودیت‌ها و مقایسه گزینه‌ها می‌پرسد." },
],
},
{
id: "4",
prompt: "وقتی با مخالفت یا ابهام روبه‌رو می‌شود، چه می‌کند؟",
options: [
{ key: "A", type: "D", text: "مستقیم مخالفت می‌کند و به دنبال پاسخ روشن است." },
{ key: "B", type: "I", text: "با صحبت و تعامل تلاش می‌کند موضوع را حل کند." },
{ key: "C", type: "S", text: "از تنش دوری می‌کند و ترجیح می‌دهد با آرامش بررسی کند." },
{ key: "D", type: "C", text: "برای رفع ابهام، دلیل و مدرک مشخص می‌خواهد." },
],
},
{
id: "5",
prompt: "کدام عامل بیشتر روی تصمیم خرید او اثر می‌گذارد؟",
options: [
{ key: "A", type: "D", text: "سرعت، نتیجه، مزیت رقابتی و بازدهی." },
{ key: "B", type: "I", text: "تجربه مثبت، اعتماد ارتباطی و جذابیت پیشنهاد." },
{ key: "C", type: "S", text: "اطمینان، پشتیبانی و ثبات در همکاری." },
{ key: "D", type: "C", text: "دقت، کیفیت، شواهد و شفافیت شرایط." },
],
},
{
id: "6",
prompt: "در مکالمه بیشتر چگونه صحبت می‌کند؟",
options: [
{ key: "A", type: "D", text: "کوتاه، مستقیم و هدفمند." },
{ key: "B", type: "I", text: "پرانرژی، صمیمی و همراه با مثال و داستان." },
{ key: "C", type: "S", text: "آرام، شنونده و با ملاحظه." },
{ key: "D", type: "C", text: "ساختارمند، دقیق و همراه با سؤال‌های مشخص." },
],
},
{
id: "7",
prompt: "هنگام بررسی قیمت چه رفتاری دارد؟",
options: [
{ key: "A", type: "D", text: "سریع درباره ارزش پیشنهادی و نتیجه نهایی مذاکره می‌کند." },
{ key: "B", type: "I", text: "با فروشنده وارد گفت‌وگو می‌شود و درباره مزایا و تجربه دیگران صحبت می‌کند." },
{ key: "C", type: "S", text: "درباره شرایط پرداخت، پشتیبانی و اطمینان از خرید سؤال می‌کند." },
{ key: "D", type: "C", text: "قیمت را با امکانات، هزینه‌های جانبی و گزینه‌های دیگر مقایسه می‌کند." },
],
},
{
id: "8",
prompt: "چه چیزی باعث می‌شود اعتماد بیشتری پیدا کند؟",
options: [
{ key: "A", type: "D", text: "پاسخ قاطع، شفاف و نتیجه‌محور." },
{ key: "B", type: "I", text: "ارتباط خوب، تعامل صمیمانه و تجربه مثبت." },
{ key: "C", type: "S", text: "پیگیری منظم، خوش‌قولی و پشتیبانی قابل اتکا." },
{ key: "D", type: "C", text: "مستندات، اطلاعات دقیق و پاسخ‌های قابل بررسی." },
],
},
];
export interface DiscGuide {
strengths: string[];
negotiation: string[];
presentation: string[];
followUp: string[];
}
export const DISC_GUIDES: Record<DiscType, DiscGuide> = {
D: {
strengths: ["تمرکز روی نتیجه", "تصمیم سریع", "گفت‌وگوی مستقیم"],
negotiation: [
"مکالمه کوتاه و هدفمند باشد.",
"ابتدا نتیجه و ارزش اقتصادی پیشنهاد بیان شود.",
"گزینه‌ها و مراحل تصمیم‌گیری شفاف باشند.",
"از توضیحات طولانی و حاشیه‌ای پرهیز شود.",
],
presentation: ["نتیجه را قبل از جزئیات بگویید.", "یک گزینه اصلی و یک جایگزین کافی است."],
followUp: ["پیگیری کوتاه و با زمان مشخص.", "روی تصمیم بعدی تمرکز کنید، نه رابطهٔ طولانی."],
},
I: {
strengths: ["تعامل گرم", "علاقه به تجربه و داستان", "تأثیرپذیری از حس مثبت"],
negotiation: [
"ارتباط صمیمانه و تعاملی ایجاد شود.",
"مزایا با مثال و تجربه واقعی توضیح داده شوند.",
"فرصت سؤال و گفت‌وگو داده شود.",
"پیگیری محترمانه و رابطه‌محور باشد.",
],
presentation: ["از مثال مشتریان مشابه استفاده کنید، بدون اغراق.", "فضای گفت‌وگو باز بماند."],
followUp: ["لحن گرم و شخصی باشد.", "به ادامهٔ گفت‌وگو دعوت کنید، نه فشار برای خرید."],
},
S: {
strengths: ["صبر", "توجه به راحتی اجرا", "اهمیت ثبات و پشتیبانی"],
negotiation: [
"درباره پشتیبانی، آموزش و روند اجرا توضیح داده شود.",
"به مشتری فرصت کافی برای تصمیم‌گیری داده شود.",
"مراحل همکاری و تعهدات روشن باشند.",
"پیگیری منظم و بدون فشار انجام شود.",
],
presentation: ["مسیر اجرا را قدم‌به‌قدم بگویید.", "ریسک تغییر را کم نشان دهید، فقط اگر واقعاً کم است."],
followUp: ["زمان پیگیری را با خود مشتری هماهنگ کنید.", "یادآوری آرام و منظم باشد."],
},
C: {
strengths: ["دقت", "مقایسه", "نیاز به مدرک"],
negotiation: [
"اطلاعات دقیق و قابل بررسی ارائه شود.",
"امکانات، محدودیت‌ها و هزینه‌ها شفاف باشند.",
"مقایسه مستند و پاسخ فنی آماده باشد.",
"برای بررسی پیشنهاد فرصت داده شود.",
],
presentation: ["ادعا را بدون عدد یا سند ثبت‌شده مطرح نکنید.", "محدودیت‌ها را هم بگویید."],
followUp: ["مستندات را بفرستید و زمان بررسی را بپرسید.", "پیگیری برای پاسخ به سؤال باشد، نه تعجیل."],
},
};
export interface DiscScoreResult {
scores: Record<DiscType, number>;
percents: Record<DiscType, number>;
validCount: number;
skippedCount: number;
reliable: boolean;
primaryTypes: DiscType[];
secondaryTypes: DiscType[];
label: string;
secondaryLabel: string | null;
confidence: DiscConfidence;
/** سهم پاسخ‌هاست، نه احتمال علمی. */
shareNote: string;
}
function emptyScores(): Record<DiscType, number> {
return { D: 0, I: 0, S: 0, C: 0 };
}
export function isDiscType(value: unknown): value is DiscType {
return value === "D" || value === "I" || value === "S" || value === "C";
}
export function scoreDisc(answers: DiscAnswers | null | undefined): DiscScoreResult {
const scores = emptyScores();
const known = new Set(DISC_QUESTIONS.map((q) => q.id));
let validCount = 0;
let skippedCount = 0;
for (const question of DISC_QUESTIONS) {
const value = answers?.[question.id];
if (value == null || value === undefined) {
skippedCount += 1;
continue;
}
if (!known.has(question.id) || !isDiscType(value)) continue;
scores[value] += 1;
validCount += 1;
}
const percents = emptyScores();
if (validCount > 0) {
for (const type of DISC_TYPES) {
percents[type] = Math.round((scores[type] / validCount) * 100);
}
}
const ranked = DISC_TYPES.map((type) => ({ type, score: scores[type] })).sort(
(a, b) => b.score - a.score || DISC_TYPES.indexOf(a.type) - DISC_TYPES.indexOf(b.type),
);
const top = ranked[0]?.score ?? 0;
const reliable = validCount >= DISC_MIN_VALID_ANSWERS && top > 0;
if (!reliable) {
return {
scores,
percents,
validCount,
skippedCount,
reliable: false,
primaryTypes: [],
secondaryTypes: [],
label: DISC_INSUFFICIENT_LABEL,
secondaryLabel: null,
confidence: "insufficient",
shareNote: "با کمتر از چهار پاسخ معتبر، سهم پاسخ‌ها برای یک نتیجهٔ قابل اتکا کافی نیست.",
};
}
const primaryTypes = ranked.filter((row) => row.score === top).map((row) => row.type);
const nextScore = ranked.find((row) => row.score < top && row.score > 0)?.score ?? 0;
const secondaryTypes =
nextScore > 0 ? ranked.filter((row) => row.score === nextScore).map((row) => row.type) : [];
const label = primaryTypes.join("/");
const secondaryLabel = secondaryTypes.length ? secondaryTypes.join("/") : null;
const gap = top - (secondaryTypes.length ? nextScore : 0);
const share = percents[primaryTypes[0]!] ?? 0;
let confidence: DiscConfidence = "low";
if (primaryTypes.length === 1 && gap >= 3 && share >= 50) confidence = "high";
else if (primaryTypes.length === 1 && gap >= 2) confidence = "medium";
return {
scores,
percents,
validCount,
skippedCount,
reliable: true,
primaryTypes,
secondaryTypes,
label,
secondaryLabel,
confidence,
shareNote: "درصدها فقط سهم پاسخ‌های همین پرسش‌نامه هستند و احتمال علمی یا دقت تشخیص نیستند.",
};
}
export function discGuideFor(types: DiscType[]): DiscGuide {
const merged: DiscGuide = { strengths: [], negotiation: [], presentation: [], followUp: [] };
const seen = new Set<string>();
for (const type of types) {
const guide = DISC_GUIDES[type];
for (const key of ["strengths", "negotiation", "presentation", "followUp"] as const) {
for (const line of guide[key]) {
if (seen.has(line)) continue;
seen.add(line);
merged[key].push(line);
}
}
}
return merged;
}
export function discProfileIsFresh(assessedAt: string | null | undefined, now = Date.now()): boolean {
if (!assessedAt) return false;
const at = new Date(assessedAt).getTime();
if (!Number.isFinite(at)) return false;
return now - at <= DISC_FRESH_DAYS * 24 * 60 * 60 * 1000;
}
export const COPILOT_TOPICS = [
"intro",
"discovery",
"pricing",
"follow_up",
"objection",
"closing",
"renewal",
] as const;
export type CopilotTopic = (typeof COPILOT_TOPICS)[number];
export const COPILOT_TOPIC_LABELS: Record<CopilotTopic, string> = {
intro: "معرفی اولیه",
discovery: "بررسی نیاز",
pricing: "ارائه قیمت",
follow_up: "پیگیری پیشنهاد",
objection: "پاسخ به اعتراض",
closing: "مذاکره نهایی",
renewal: "تمدید یا فروش مجدد",
};
export const COPILOT_GOALS = [
"discover_need",
"find_objection",
"book_next",
"fit_offer",
"follow_price",
"close",
"keep_relation",
] as const;
export type CopilotGoal = (typeof COPILOT_GOALS)[number];
export const COPILOT_GOAL_LABELS: Record<CopilotGoal, string> = {
discover_need: "کشف نیاز واقعی",
find_objection: "شناخت علت مخالفت",
book_next: "تعیین جلسه بعدی",
fit_offer: "ارائه پیشنهاد مناسب",
follow_price: "پیگیری قیمت",
close: "نهایی‌کردن فروش",
keep_relation: "حفظ ارتباط برای آینده",
};
export const OBJECTION_CATEGORIES = [
"price_high",
"no_need",
"need_time",
"consult",
"have_similar",
"no_trust",
"bad_timing",
"no_time_to_review",
"bad_past",
"send_info",
"not_interested",
"unclear",
] as const;
export type ObjectionCategory = (typeof OBJECTION_CATEGORIES)[number];
export const OBJECTION_LABELS: Record<ObjectionCategory, string> = {
price_high: "قیمت بالاست",
no_need: "فعلاً نیاز ندارم",
need_time: "باید فکر کنم",
consult: "باید مشورت کنم",
have_similar: "محصول مشابه دارم",
no_trust: "به سیستم جدید اعتماد ندارم",
bad_timing: "الان زمان مناسبی نیست",
no_time_to_review: "فرصت بررسی ندارم",
bad_past: "قبلاً تجربه ناموفق داشته‌ام",
send_info: "لطفاً اطلاعات را ارسال کنید",
not_interested: "فعلاً تمایلی به ادامه ندارم",
unclear: "اعتراض هنوز روشن نیست",
};
export const FOLLOW_UP_MESSAGE_KINDS = [
"whatsapp_after_call",
"sms",
"price_followup",
"send_proposal",
"meeting",
"after_demo",
"hesitant",
"keep_in_touch",
] as const;
export type FollowUpMessageKind = (typeof FOLLOW_UP_MESSAGE_KINDS)[number];
export const FOLLOW_UP_MESSAGE_LABELS: Record<FollowUpMessageKind, string> = {
whatsapp_after_call: "واتس‌اپ پس از تماس",
sms: "پیامک کوتاه",
price_followup: "پیگیری قیمت",
send_proposal: "ارسال پیشنهاد",
meeting: "هماهنگی جلسه",
after_demo: "پیگیری پس از دمو",
hesitant: "پیگیری مشتری مردد",
keep_in_touch: "حفظ ارتباط برای آینده",
};
export interface CopilotDiscContext {
label: string;
primaryTypes: DiscType[];
reliable: boolean;
assessedAt: string | null;
fresh: boolean;
used: boolean;
note: string;
}
export interface CopilotAnalysis {
customer_statement_summary: string;
objection_category: ObjectionCategory | null;
objection_label: string | null;
possible_underlying_reasons: string[];
evidence: string[];
missing_information: string[];
recommended_question: string;
suggested_response: string;
common_mistake: string;
sales_strategy: string[];
next_best_action: string;
suggested_follow_up_date: string | null;
disc_context: CopilotDiscContext | null;
confidence_note: string;
needs_more_input: boolean;
source: "rules" | "rules+ai";
}
export interface CopilotFacts {
customerName?: string | null;
businessType?: string | null;
productName?: string | null;
stageName?: string | null;
/** فقط اگر در CRM ثبت شده باشد؛ هرگز ساخته نمی‌شود. */
priceOffered?: string | null;
lastAction?: string | null;
disc?: {
label: string;
primaryTypes: DiscType[];
reliable: boolean;
assessedAt: string | null;
} | null;
}
export interface CopilotInput {
topic: CopilotTopic;
goal: CopilotGoal;
customerStatement: string;
behaviorNotes?: string | null;
facts: CopilotFacts;
now?: Date;
}
interface ObjectionPlay {
category: ObjectionCategory;
patterns: RegExp[];
reasons: string[];
question: string;
response: string;
mistake: string;
strategy: string;
next: string;
followUpDays: number | null;
}
const OBJECTION_PLAYS: ObjectionPlay[] = [
{
category: "price_high",
patterns: [/گرونه/, /گران/, /قیمت.{0,12}بالا/, /بودجه/, /تخفیف/, /ارزون‌تر/, /ارزان‌تر/],
reasons: [
"ممکن است قیمت نسبت به بودجه زیاد به نظر برسد.",
"ممکن است ارزش تغییر هنوز برایش روشن نباشد.",
"ممکن است هزینهٔ پنهان یا مقایسه با گزینهٔ دیگر در ذهن باشد.",
],
question: "اگر قیمت را کنار بگذاریم، خودِ راهکار چقدر به کار امروزتان می‌خورد؟",
response:
"متوجه‌ام که عدد مهم است. قبل از حرف زدن دربارهٔ تخفیف، دوست دارم ببینم کدام بخش کار برایتان خروجی مشخص دارد تا مقایسه منصفانه باشد.",
mistake: "تخفیف فوری دادن قبل از فهمیدن اینکه اعتراض واقعاً به قیمت است یا به ارزش.",
strategy: "اگر اعتراض به هزینه است، هزینه و منفعت را فقط با اعداد ثبت‌شده مقایسه کنید.",
next: "شفاف‌کردن ارزش مورد انتظار، سپس در صورت وجود قیمت ثبت‌شده همان را مرور کنید.",
followUpDays: 2,
},
{
category: "no_need",
patterns: [/نیاز ندار/, /لازم ندار/, /جواب می‌ده/, /روش فعلی/, /لازم نیست/],
reasons: [
"ممکن است واقعاً نیازی نباشد.",
"ممکن است ارزش تغییر هنوز دیده نشده باشد.",
"ممکن است هزینهٔ عوض کردن روش فعلی بالا به نظر برسد.",
],
question:
"کاملاً متوجه‌ام. فقط برای اینکه بهتر شرایطتان را درک کنم، در روش فعلی بیشتر کدام بخش کار برایتان زمان‌بر است یا نیاز به پیگیری بیشتری دارد؟",
response:
"اگر روش فعلی واقعاً کارتان را راه می‌اندازد، اصراری به تغییر نیست. فقط اگر یک بخش از کار هنوز دست‌وپاگیر است، همان را جدا کنیم و ببینیم ارزش تغییر دارد یا نه.",
mistake: "فرض کردن اینکه «نیاز ندارم» یعنی درِ فروش برای همیشه بسته است.",
strategy: "ابتدا نیاز را دقیق‌تر بررسی کنید و بدون اثبات نیاز، به خرید سوق ندهید.",
next: "یک سؤال کشف نیاز؛ اگر نیاز واقعی نبود، ارتباط را با اجازهٔ مشتری برای آینده حفظ کنید.",
followUpDays: 7,
},
{
category: "need_time",
patterns: [/فکر کنم/, /فکر می‌کنم/, /بررسی کنم/, /بعداً/, /فرصت بده/],
reasons: ["نیاز به زمان برای تصمیم دارد.", "ممکن است اطلاعات کم باشد.", "ممکن است نخواهد در لحظه متعهد شود."],
question: "برای اینکه بررسی‌تان دقیق باشد، تا چه تاریخی و با کدام سؤال برگردم؟",
response: "حق دارید فرصت بررسی داشته باشید. همان سؤال‌هایی که مانده را جدا می‌کنم و در تاریخی که خودتان بگویید پیگیری می‌کنم.",
mistake: "فشار برای جواب همان لحظه.",
strategy: "زمان مناسب پیگیری را با مشتری مشخص کنید.",
next: "ثبت تاریخ پیگیری مورد توافق.",
followUpDays: 3,
},
{
category: "consult",
patterns: [/مشورت/, /شریک/, /مدیر/, /باید بپرسم/, /تصمیم با/],
reasons: ["تصمیم فردی نیست.", "باید معیار تصمیم‌گیرندهٔ دیگر را دانست."],
question: "کسی که قرار است مشورت کنید بیشتر روی نتیجه، قیمت یا جزئیات حساس است؟",
response: "منطقی است با شریک یا مدیرتان هماهنگ کنید. اگر بگویید معیار اصلی آن‌ها چیست، همان را شفاف می‌فرستم تا جلسهٔ داخلی‌تان کوتاه‌تر شود.",
mistake: "دور زدن تصمیم‌گیرنده یا اصرار به بستن فروش با کسی که اختیار ندارد.",
strategy: "ابزار تصمیم را برای نفر بعدی آماده کنید، نه فشار روی واسطه.",
next: "ارسال خلاصهٔ قابل ارائه به تصمیم‌گیرنده و هماهنگی پیگیری بعد از مشورت.",
followUpDays: 3,
},
{
category: "have_similar",
patterns: [/مشابه/, /قبلاً داریم/, /نرم‌افزار دیگه/, /سیستم دیگه/, /در حال استفاده/],
reasons: ["جایگزینی باید بهتر از وضع موجود باشد.", "ممکن است مقایسه هنوز منصفانه نباشد."],
question: "در ابزاری که الان دارید، کدام کار هنوز دستی می‌ماند یا دوباره‌کاری دارد؟",
response: "اگر ابزار فعلی بخش مهمی از کار را پوشش می‌دهد، تغییر فقط وقتی معنی دارد که یک درد مشخص را کم کند. همان تفاوت را با هم محدود کنیم.",
mistake: "بدگویی از ابزار فعلی بدون دانستن کار واقعی مشتری.",
strategy: "مقایسه را به یک درد مشخص محدود کنید.",
next: "کشف شکاف ابزار فعلی؛ اگر شکافی نیست، پیگیری را متوقف یا خیلی سبک کنید.",
followUpDays: 5,
},
{
category: "no_trust",
patterns: [/اعتماد/, /مطمئن نیستم/, /نگرانم/, /ریسک/],
reasons: ["ریسک تغییر یا اجرای بد را بالا می‌بیند.", "تعهد پشتیبانی برایش مبهم است."],
question: "کدام بخش بیشتر نگران‌تان می‌کند: راه‌اندازی، داده، یا پشتیبانی بعد از شروع؟",
response: "نگرانی به‌جا است. همان بخش را مشخص کنیم و فقط دربارهٔ تعهدی حرف بزنم که واقعاً در شرایط همکاری هست، نه قول کلی.",
mistake: "اطمینان‌بخشی شعاری بدون محدودهٔ تعهد.",
strategy: "تعهد، محدودیت و پشتیبانی را شفاف بگویید.",
next: "پاسخ به همان نگرانی مشخص، سپس تصمیم دربارهٔ قدم بعدی.",
followUpDays: 2,
},
{
category: "bad_timing",
patterns: [/زمان مناسب نیست/, /سرت شلوغ/, /سرم شلوغ/, /این ماه نه/, /فصل بعد/, /سال بعد/],
reasons: ["اولویت فعلی جای دیگری است.", "ممکن است بهانهٔ مؤدبانه برای توقف باشد."],
question: "اگر زمان مناسب نیست، چه اتفاقی باید بیفتد که برگشتن به این موضوع برایتان معنی داشته باشد؟",
response: "اگر الان اولویت نیست، اصرار نمی‌کنم. فقط مشخص کنیم پیگیری بعدی را می‌خواهید یا پرونده را همین‌جا ببندیم.",
mistake: "تقویم پیگیری شلوغ بدون اجازهٔ مشتری.",
strategy: "یا تاریخ واقعی بگیرید یا پیگیری را متوقف کنید.",
next: "ثبت اجازهٔ پیگیری بعدی یا توقف محترمانه.",
followUpDays: 14,
},
{
category: "no_time_to_review",
patterns: [/وقت ندار/, /فرصت ندار/, /فرصت بررسی/, /سرم شلوغ است/],
reasons: ["بار کاری بالاست.", "ممکن است بررسی را به تعویق بیندازد چون اولویت ندارد."],
question: "اگر یک خلاصهٔ خیلی کوتاه بفرستم، چه روزی پنج دقیقه برای نگاه کردنش دارید؟",
response: "وقت‌تان را با توضیح طولانی نمی‌گیرم. اگر بخواهید یک جمع‌بندی کوتاه می‌فرستم و خودتان زمان نگاه کردن را می‌گویید.",
mistake: "فرستادن پروندهٔ سنگین وقتی مشتری گفته وقت ندارد.",
strategy: "حجم را کم کنید و زمان را از مشتری بگیرید.",
next: "ارسال خلاصهٔ کوتاه فقط در صورت تمایل، با تاریخ مشخص.",
followUpDays: 4,
},
{
category: "bad_past",
patterns: [/تجربه بد/, /دفعه قبل/, /قبلاً شکست/, /ناموفق/, /سوختم/],
reasons: ["حافظهٔ یک اجرای بد هنوز فعال است.", "باید فرق این همکاری با تجربهٔ قبلی روشن شود."],
question: "در تجربهٔ قبلی دقیقاً کدام بخش خراب شد: قول فروش، اجرا، یا پشتیبانی؟",
response: "حق دارید با تجربهٔ قبلی محتاط باشید. اگر بگویید کدام بخش خراب شده، فقط همان را با شرایط واقعی این همکاری مقایسه می‌کنم و چیزی را که تضمین نیست وعده نمی‌دهم.",
mistake: "گفتن اینکه «این بار فرق دارد» بدون شنیدن جزئیات تجربهٔ قبلی.",
strategy: "علت شکست قبلی را بشنوید و فقط تعهد واقعی را جدا کنید.",
next: "ثبت علت تجربهٔ قبلی و پاسخ محدود به همان علت.",
followUpDays: 3,
},
{
category: "send_info",
patterns: [/بفرست/, /ارسال کن/, /اطلاعات بده/, /کاتالوگ/, /پیام بده/],
reasons: ["ممکن است واقعاً بخواهد مطالعه کند.", "ممکن است بخواهد مکالمه را کوتاه کند."],
question: "کدام بخش را بفرستم که به تصمیم‌تان کمک کند: محدودهٔ کار، شرایط قیمت ثبت‌شده، یا مراحل اجرا؟",
response: "حتماً. به‌جای یک فایل کلی، همان بخشی را می‌فرستم که گفتید برایتان مهم است و بعد می‌پرسم کی به آن نگاه می‌کنید.",
mistake: "فرستادن معرفی عمومی و قطع کردن کشف نیاز.",
strategy: "محتوا را به سؤال باز مشتری محدود کنید.",
next: "ارسال اطلاعات درخواستی و ثبت زمان بازگشت.",
followUpDays: 2,
},
{
category: "not_interested",
patterns: [/تمایل ندار/, /نمی‌خوام/, /نمیخوام/, /منصرف/, /پیگیری نکن/, /دیگر زنگ نزن/, /دیگه زنگ نزن/],
reasons: ["تمایل به ادامه نیست.", "باید احترام به توقف رعایت شود."],
question: "اگر مایل باشید فقط دلیل اصلی را در یک جمله بگویید تا دیگر مزاحم همین موضوع نشوم.",
response: "حتماً پیگیری را متوقف می‌کنم. اگر روزی خودتان خواستید برگردیم، در خدمتم.",
mistake: "ادامهٔ تماس بعد از اعلام عدم تمایل.",
strategy: "پیگیری را متوقف کنید و فقط در صورت اجازه، دلیل را ثبت کنید.",
next: "توقف پیگیری.",
followUpDays: null,
},
];
const DISC_OPENERS: Record<DiscType, string> = {
D: "کوتاه می‌گویم: ",
I: "حق دارید این‌طور احساس کنید. ",
S: "عجله‌ای نیست. ",
C: "فقط بر اساس چیزی که ثبت شده: ",
};
function subjectName(facts: CopilotFacts): string {
const name = facts.productName?.trim() || facts.businessType?.trim();
return name || "موضوع همین گفتگو";
}
function customerName(facts: CopilotFacts): string {
return facts.customerName?.trim() || "مشتری";
}
function discContext(facts: CopilotFacts, now: Date): CopilotDiscContext | null {
const disc = facts.disc;
if (!disc) return null;
const fresh = disc.reliable && discProfileIsFresh(disc.assessedAt, now.getTime());
const used = fresh && disc.primaryTypes.length > 0;
return {
label: disc.label,
primaryTypes: disc.primaryTypes,
reliable: disc.reliable,
assessedAt: disc.assessedAt,
fresh,
used,
note: used
? "تیپ DISC فقط راهنمای لحن است و تشخیص قطعی شخصیت نیست."
: "پروفایل DISC معتبر و تازه نیست؛ از روی این جمله تیپ تعیین نمی‌شود.",
};
}
function matchObjection(text: string): ObjectionPlay | null {
for (const play of OBJECTION_PLAYS) {
if (play.patterns.some((pattern) => pattern.test(text))) return play;
}
return null;
}
function stageHint(stageName: string | null | undefined): string | null {
const stage = (stageName ?? "").trim();
if (!stage) return null;
if (/از دست|ناموفق|باخت/.test(stage)) {
return "فروش ازدست‌رفته: علت واقعی را ثبت کنید و فقط با تمایل مشتری به پیگیری بعدی فکر کنید.";
}
if (/نهایی|بستن|قرارداد/.test(stage)) {
return "نزدیک نهایی‌شدن: مانع باقی‌مانده را نام ببرید و قدم بعدی را تاریخ‌دار کنید.";
}
if (/مذاکر/.test(stage)) return "مذاکره: اعتراض را دسته‌بندی کنید و شرایط را شفاف کنید.";
if (/پیشنهاد|قیمت/.test(stage)) return "پیشنهاد ارسال شده: ابهام باقی‌مانده را بپرسید، قیمت جدید نسازید.";
if (/نیاز/.test(stage)) return "نیاز شناسایی شده: راهکار را به همان نیاز محدود کنید.";
if (/سرنخ|جدید|تماس اول/.test(stage)) return "سرنخ جدید: کشف نیاز و صلاحیت‌سنجی مقدم بر ارائه است.";
return null;
}
function addDays(from: Date, days: number): string {
const next = new Date(from.getTime());
next.setDate(next.getDate() + days);
const y = next.getFullYear();
const m = String(next.getMonth() + 1).padStart(2, "0");
const d = String(next.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
export function analyzeCopilot(input: CopilotInput): CopilotAnalysis {
const now = input.now ?? new Date();
const statement = input.customerStatement.replace(/\s+/g, " ").trim();
const notes = input.behaviorNotes?.trim() || "";
const facts = input.facts;
const disc = discContext(facts, now);
const evidence = [statement, notes].filter(Boolean);
const stage = stageHint(facts.stageName);
const primary = disc?.used ? disc.primaryTypes[0]! : null;
if (statement.length < 8) {
return {
customer_statement_summary: "حرف دقیق مشتری هنوز ثبت نشده است.",
objection_category: null,
objection_label: null,
possible_underlying_reasons: [],
evidence,
missing_information: ["متن حرف، اعتراض یا دغدغهٔ مشتری"],
recommended_question: "ممکن است همان جمله‌ای که گفتید را دقیق‌تر بگویید تا ببینم مانع اصلی چیست؟",
suggested_response: "",
common_mistake: "ساختن پاسخ تبلیغاتی وقتی هنوز حرف مشتری ثبت نشده است.",
sales_strategy: ["تا شواهد مکالمه کم است، سؤال تکمیلی بپرسید و نتیجهٔ قطعی اعلام نکنید."],
next_best_action: "ثبت حرف مشتری",
suggested_follow_up_date: null,
disc_context: disc,
confidence_note: "اطلاعات کافی نیست. دستیار به‌جای پاسخ قطعی، سؤال تکمیلی پیشنهاد می‌دهد.",
needs_more_input: true,
source: "rules",
};
}
const play = matchObjection(statement);
const missing: string[] = [];
if (!facts.productName?.trim()) missing.push("نام محصول یا خدمت مورد مذاکره در پرونده خالی است");
if (input.topic === "pricing" && !facts.priceOffered?.trim()) {
missing.push("قیمت یا شرایط ثبت‌شده در پرونده نیست و نباید ساخته شود");
}
if (!facts.stageName?.trim()) missing.push("مرحلهٔ قیف فروش مشخص نیست");
const reasons = play
? play.reasons
: ["معنی این جمله هنوز چندوجهی است و بدون سؤال بعدی نباید یک علت را قطعی دانست."];
const category = play?.category ?? "unclear";
const question = play?.question ?? "دقیقاً کدام بخش از پیشنهاد برایتان مبهم یا غیرضروری است؟";
let response = play
? play.response
: "برای اینکه پاسخ کلی ندهم، دوست دارم همان بخش از حرف‌تان را که مانع اصلی است جدا کنیم.";
if (primary) response = `${DISC_OPENERS[primary]}${response}`;
if (facts.priceOffered?.trim() && category === "price_high") {
response += ` قیمت ثبت‌شده در پرونده ${facts.priceOffered.trim()} است و عدد دیگری ساخته نمی‌شود.`;
}
if (category === "price_high" && !facts.priceOffered?.trim()) {
response += " چون قیمتی در پرونده ثبت نشده، عدد یا تخفیف جدیدی پیشنهاد نمی‌شود.";
}
const strategy = [
play?.strategy ?? "قبل از پاسخ قطعی، اطلاعات نامشخص را با یک سؤال بپرسید.",
`هدف این تماس: ${COPILOT_GOAL_LABELS[input.goal]}.`,
];
if (stage) strategy.push(stage);
if (primary) {
const guide = DISC_GUIDES[primary].negotiation[0];
if (guide) strategy.push(`با توجه به تیپ احتمالی ${primary}: ${guide}`);
}
if (notes) strategy.push("نشانه‌های رفتاری ثبت‌شده را جدا از تیپ شخصیتی ببینید و با رفتار واقعی تطبیق دهید.");
const summary = play
? `مشتری چیزی نزدیک به «${OBJECTION_LABELS[play.category]}» گفته است: ${statement}`
: `خلاصهٔ حرف مشتری: ${statement}`;
return {
customer_statement_summary: summary,
objection_category: category,
objection_label: OBJECTION_LABELS[category],
possible_underlying_reasons: reasons,
evidence,
missing_information: missing,
recommended_question: question,
suggested_response: response,
common_mistake: play?.mistake ?? "اعلام یک معنی قطعی برای جمله‌ای که هنوز مبهم است.",
sales_strategy: strategy,
next_best_action: play?.next ?? "یک سؤال تکمیلی برای روشن شدن مانع.",
suggested_follow_up_date: play?.followUpDays ? addDays(now, play.followUpDays) : null,
disc_context: disc,
confidence_note: play
? "این دسته‌بندی احتمالی است و تا وقتی مشتری علت را تأیید نکرده، حقیقت قطعی پرونده نیست."
: "اعتراض در دسته‌های شناخته‌شده جا نگرفت. پاسخ قطعی داده نشد.",
needs_more_input: !play,
source: "rules",
};
}
export function followUpMessage(kind: FollowUpMessageKind, facts: CopilotFacts, analysis: CopilotAnalysis): string {
const name = customerName(facts);
const subject = subjectName(facts);
const price = facts.priceOffered?.trim();
const disc = analysis.disc_context?.used ? analysis.disc_context.primaryTypes[0] : null;
const warm = disc === "I" || disc === "S";
const hello = warm ? `${name} جان سلام` : `${name} سلام`;
const priceLine = price
? `قیمت ثبت‌شده همان ${price} است.`
: "عدد قیمت را اینجا تکرار نمی‌کنم چون در این گفتگو قیمت تازه‌ای ثبت نشده است.";
switch (kind) {
case "sms":
return `${hello}. پیرو صحبت دربارهٔ ${subject}، هر وقت جمع‌بندی‌تان آماده بود همین‌جا بفرمایید.`;
case "price_followup":
return `${hello}. خواستم پیگیری قیمت ${subject} را کوتاه بپرسم. ${priceLine} اگر سؤال مشخصی مانده بگویید تا همان را جواب بدهم.`;
case "send_proposal":
return `${hello}. خلاصهٔ ${subject} را می‌فرستم تا با آرامش ببینید. اگر بخشی اضافی است بگویید تا همان را حذف کنم.`;
case "meeting":
return `${hello}. برای ادامهٔ صحبت دربارهٔ ${subject} چه زمانی برایتان مناسب است؟`;
case "after_demo":
return `${hello}. بعد از دموی ${subject} کدام بخش برایتان روشن شد و کدام سؤال هنوز باز است؟`;
case "hesitant":
return `${hello}. اگر هنوز برای ${subject} مردد هستید، لازم نیست عجله کنید. فقط بگویید مانع اصلی زمان است، قیمت، یا خودِ نیاز.`;
case "keep_in_touch":
return `${hello}. اگر الان قصد ادامهٔ ${subject} را ندارید، مزاحم نمی‌شوم. هر زمان خودتان خواستید در خدمتم.`;
default:
return `${hello}. پیرو مکالمه دربارهٔ ${subject}: ${analysis.customer_statement_summary.slice(0, 180)}. سؤال بعدی من این است: ${analysis.recommended_question}`;
}
}
export interface DiscAssessmentDto {
id: string;
personId: string;
assessorId: string;
assessorName: string;
answers: DiscAnswers;
scores: Record<DiscType, number>;
percents: Record<DiscType, number>;
validCount: number;
primaryTypes: DiscType[];
secondaryTypes: DiscType[];
label: string;
secondaryLabel: string | null;
reliable: boolean;
confidence: DiscConfidence;
note: string | null;
status: "confirmed";
createdAt: string;
updatedAt: string;
}
export interface DiscDistributionRow {
label: string;
count: number;
}
export interface SalesCopilotSessionDto {
id: string;
personId: string | null;
leadId: string | null;
dealId: string | null;
userId: string;
userName: string;
topic: CopilotTopic;
goal: CopilotGoal;
customerStatement: string;
behaviorNotes: string | null;
analysis: CopilotAnalysis;
confirmedResponse: string | null;
nextAction: string | null;
followUpAt: string | null;
callOutcome: string | null;
agentNote: string | null;
status: "confirmed" | "dismissed";
messageDraft: string | null;
activityId: string | null;
createdAt: string;
updatedAt: string;
}
+13 -2
View File
@@ -157,7 +157,8 @@ export type OrgModuleKey =
| "routines"
| "gamification"
| "prior_data_archive"
| "treasury";
| "treasury"
| "behavior";
export const ORG_MODULE_KEYS: OrgModuleKey[] = [
"customers",
@@ -200,6 +201,7 @@ export const ORG_MODULE_KEYS: OrgModuleKey[] = [
"gamification",
"prior_data_archive",
"treasury",
"behavior",
];
export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
@@ -243,6 +245,7 @@ export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
gamification: "باشگاه امتیازات",
prior_data_archive: "بایگانی اطلاعات قبل",
treasury: "مدیریت مالی",
behavior: "شخصیت‌شناسی و دستیار فروش",
};
export const ORG_MODULE_GROUPS: Array<{
@@ -255,6 +258,7 @@ export const ORG_MODULE_GROUPS: Array<{
title: "فروش",
keys: [
"pipeline",
"behavior",
"products",
"invoices",
"quotes",
@@ -442,7 +446,8 @@ export type RolePermissionKey =
| "conversations"
| "conversations_groups"
| "treasury"
| "treasury_payroll";
| "treasury_payroll"
| "behavior";
export const ROLE_PERMISSION_ITEMS: Array<{
key: RolePermissionKey;
@@ -460,6 +465,7 @@ export const ROLE_PERMISSION_ITEMS: Array<{
{ key: "conversations_groups", label: "ساخت گروه گفتگو" },
{ key: "treasury", label: "مدیریت مالی" },
{ key: "treasury_payroll", label: "حقوق و دستمزد" },
{ key: "behavior", label: "شخصیت‌شناسی و دستیار فروش" },
];
export interface OrgRoleMemberDto {
@@ -904,6 +910,10 @@ export interface PersonDto {
customFields: Record<string, unknown>;
loyaltyPoints: number;
loyaltyTier: LoyaltyTierSummaryDto | null;
/** تیپ غالب یا ترکیبی تأییدشده؛ بدون مجوز رفتار خالی برمی‌گردد */
discLabel: string | null;
discReliable: boolean;
discAssessedAt: string | null;
company: CompanySummaryDto | null;
owner: UserSummaryDto | null;
createdAt: string;
@@ -5725,3 +5735,4 @@ export interface TreasuryDashboardDto {
/* ───────── باشگاه امتیازات (گیمیفیکیشن کارکنان) ───────── */
export * from "./gamification";
export * from "./behavior";