Files
CRM_Montzar/packages/shared/src/behavior.ts
T
MahdiandCursor 002f697dfe 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>
2026-10-18 21:04:26 +03:30

840 lines
41 KiB
TypeScript

/** تحلیل رفتاری 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;
}