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