feat: add DISC profiles and a sales copilot as an org module
Help reps capture a customer's likely behavior style and get negotiation guidance, with the feature gated from super admin. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user