/** تحلیل رفتاری DISC و دستیار فروش — راهنمای مذاکره، نه تشخیص بالینی. */ export const DISC_TYPES = ["D", "I", "S", "C"] as const; export type DiscType = (typeof DISC_TYPES)[number]; export const DISC_TYPE_LABELS: Record = { D: "سلطه‌گری (نتیجه‌گرا)", I: "تأثیرگذاری (ارتباط‌گرا)", S: "ثبات (رابطه‌محور)", C: "وظیفه‌شناسی (دقیق)", }; export const DISC_TYPE_SHORT: Record = { 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 = { insufficient: "ناکافی", low: "کم", medium: "متوسط", high: "نسبتاً روشن", }; export type DiscAnswers = Partial>; 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 = { 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; percents: Record; validCount: number; skippedCount: number; reliable: boolean; primaryTypes: DiscType[]; secondaryTypes: DiscType[]; label: string; secondaryLabel: string | null; confidence: DiscConfidence; /** سهم پاسخ‌هاست، نه احتمال علمی. */ shareNote: string; } function emptyScores(): Record { 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(); 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 = { 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 = { 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 = { 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 = { 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 = { 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; percents: Record; 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; }