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>
840 lines
41 KiB
TypeScript
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;
|
|
}
|