From 6d8f6e67f08b7f9101deb27915e491ca9d7a382d Mon Sep 17 00:00:00 2001 From: Yera All Date: Sun, 5 Apr 2026 10:46:09 +0500 Subject: [PATCH] Fill new voice name-flow mock analytics data --- ui/analyst/app.js | 135 ++++++++++++++++++++++----------- ui/analyst/mock-analytics.json | 60 ++++++++++++++- 2 files changed, 150 insertions(+), 45 deletions(-) diff --git a/ui/analyst/app.js b/ui/analyst/app.js index 8f8bd16..5a84ad0 100644 --- a/ui/analyst/app.js +++ b/ui/analyst/app.js @@ -1410,6 +1410,27 @@ function mockVoiceNameSeed() { return seed && typeof seed === 'object' ? seed : {}; } +function mockVoiceNameLabels() { + const labels = analyticsMockData().voice_name_labels; + return labels && typeof labels === 'object' ? labels : {}; +} + +function mockVoiceNameLabel(key, fallback) { + return mockVoiceNameLabels()[key] || fallback; +} + +function mockVoiceNameProfile(metric, fallback) { + const profile = mockVoiceNameSeed().trend_profiles?.[metric]; + if (!profile || typeof profile !== 'object') { + return fallback; + } + return { + base: Number.isFinite(Number(profile.base)) ? Number(profile.base) : fallback.base, + step: Number.isFinite(Number(profile.step)) ? Number(profile.step) : fallback.step, + cycle: Math.max(1, Number.isFinite(Number(profile.cycle)) ? Number(profile.cycle) : fallback.cycle), + }; +} + function voiceNameMetricRatesFromTotals(totals) { const scenarioCalls = Number(totals.scenario_calls || 0); const neededDownstream = Number(totals.needed_downstream || Math.max(0, scenarioCalls - Number(totals.start_obtained || 0))); @@ -1439,13 +1460,15 @@ function buildMockVoiceNameOverview(rangeMeta, options = {}) { const factor = mockAnalyticsScale(previous ? rangeMeta.previous : rangeMeta.current) * (previous ? 0.91 : 1) * (queueId === 'all' ? 1 : Math.max(0.48, mockAnalyticsQueueFactor(queueId))); - const scenarioCalls = Math.max(0, Math.round(Number(totalSeed.scenario_calls || 180) * factor)); - const startRatio = Number(totalSeed.start_obtained || 110) / Math.max(Number(totalSeed.scenario_calls || 180), 1); - const downstreamRatio = Number(totalSeed.downstream_ai_obtained || 36) / Math.max(Number(totalSeed.scenario_calls || 180), 1); - const followupRatio = Number(totalSeed.followup_required || 20) / Math.max(Number(totalSeed.scenario_calls || 180), 1); - const handoffConfirmedRatio = Number(totalSeed.handoff_confirmed_name || 12) / Math.max(Number(totalSeed.scenario_calls || 180), 1); - const handoffUnconfirmedRatio = Number(totalSeed.handoff_unconfirmed_name || 26) / Math.max(Number(totalSeed.scenario_calls || 180), 1); - const manualRateSeed = Number(totalSeed.manual_corrected || 10) / Math.max((Number(totalSeed.handoff_confirmed_name || 12) + Number(totalSeed.handoff_unconfirmed_name || 26)), 1); + const baseScenarioCalls = Math.max(Number(totalSeed.scenario_calls || 180), 1); + const scenarioCalls = Math.max(0, Math.round(baseScenarioCalls * factor)); + const startRatio = Number(totalSeed.start_obtained || 110) / baseScenarioCalls; + const downstreamRatio = Number(totalSeed.downstream_ai_obtained || 36) / baseScenarioCalls; + const followupRatio = Number(totalSeed.followup_required || 20) / baseScenarioCalls; + const handoffConfirmedRatio = Number(totalSeed.handoff_confirmed_name || 12) / baseScenarioCalls; + const handoffUnconfirmedRatio = Number(totalSeed.handoff_unconfirmed_name || 26) / baseScenarioCalls; + const manualRateSeed = Number(totalSeed.manual_corrected || 10) + / Math.max((Number(totalSeed.handoff_confirmed_name || 12) + Number(totalSeed.handoff_unconfirmed_name || 26)), 1); const startObtained = Math.min(scenarioCalls, Math.round(scenarioCalls * startRatio)); const downstreamAiObtained = Math.max(0, Math.round(scenarioCalls * downstreamRatio)); @@ -1505,21 +1528,28 @@ function buildMockVoiceNameOverview(rangeMeta, options = {}) { }; }).filter((item) => item.scenario_calls > 0); - const queueRows = mockAnalyticsQueueOptions() + const queueSeeds = Array.isArray(seed.queues) ? seed.queues : []; + const queueRows = (queueSeeds.length ? queueSeeds : mockAnalyticsQueueOptions().map((item, index) => ({ + queue_id: item.queue_id, + share: 0.2 + index * 0.12, + start_capture_rate: 69 - index * 4, + downstream_rescue_rate: 45 - index * 5, + handoff_unconfirmed_rate: 38 + index * 5, + manual_correction_rate: 17 + index * 3, + }))) .filter((item) => queueId === 'all' || item.queue_id === queueId) - .map((item, index) => { - const queueFactor = Math.max(0.28, mockAnalyticsQueueFactor(item.queue_id)); - const calls = Math.max(0, Math.round(scenarioCalls * (0.2 + index * 0.12) * queueFactor)); - const startCaptured = Math.min(calls, Math.round(calls * (0.69 - index * 0.04))); + .map((item) => { + const calls = Math.max(0, Math.round(scenarioCalls * Number(item.share || 0))); + const startCaptured = Math.min(calls, Math.round(calls * Number(item.start_capture_rate || 0) / 100)); const downstreamNeeded = Math.max(0, calls - startCaptured); - const downstreamCaptured = Math.min(downstreamNeeded, Math.round(downstreamNeeded * (0.45 - index * 0.05))); + const downstreamCaptured = Math.min(downstreamNeeded, Math.round(downstreamNeeded * Number(item.downstream_rescue_rate || 0) / 100)); const unresolved = Math.max(0, calls - startCaptured - downstreamCaptured); - const followup = Math.round(unresolved * 0.57); + const followup = Math.round(unresolved * 0.58); const missing = Math.max(0, unresolved - followup); - const handoffs = Math.round(unresolved * (0.64 + index * 0.04)); - const unconfirmed = Math.min(handoffs, Math.round(handoffs * (0.38 + index * 0.05))); + const handoffs = Math.round(unresolved * 0.72); + const unconfirmed = Math.min(handoffs, Math.round(handoffs * Number(item.handoff_unconfirmed_rate || 0) / 100)); const confirmed = Math.max(0, handoffs - unconfirmed); - const manual = Math.min(handoffs, Math.round(handoffs * (0.17 + index * 0.03))); + const manual = Math.min(handoffs, Math.round(handoffs * Number(item.manual_correction_rate || 0) / 100)); return { queue_id: item.queue_id, scenario_calls: calls, @@ -1543,6 +1573,23 @@ function buildMockVoiceNameOverview(rangeMeta, options = {}) { }) .filter((item) => item.scenario_calls > 0); + const coverageSeed = seed.coverage || {}; + const coverage = { + sessions_with_start_decision: Math.min( + scenarioCalls, + Math.round(scenarioCalls * (Number(coverageSeed.sessions_with_start_decision || baseScenarioCalls) / baseScenarioCalls)), + ), + sessions_with_final_ai_state: Math.min( + scenarioCalls, + Math.round(scenarioCalls * (Number(coverageSeed.sessions_with_final_ai_state || baseScenarioCalls) / baseScenarioCalls)), + ), + sessions_with_manual_overlay: Math.min( + scenarioCalls, + Math.round(scenarioCalls * (Number(coverageSeed.sessions_with_manual_overlay || manualCorrected) / baseScenarioCalls)), + ), + note: coverageSeed.note || analyticsMockData().coverage_note || 'Показаны демонстрационные данные по name-flow.', + }; + return { window: { from_ts: (previous ? rangeMeta.previous.from : rangeMeta.current.from).toISOString(), @@ -1558,56 +1605,56 @@ function buildMockVoiceNameOverview(rangeMeta, options = {}) { metrics, breakdowns: { funnel: [ - { stage: 'scenario_calls', label: 'Звонки в сценарии', sessions: scenarioCalls, share: 100 }, - { stage: 'start_obtained', label: 'Имя взято сразу', sessions: startObtained, share: scenarioCalls ? Number(((startObtained / scenarioCalls) * 100).toFixed(2)) : 0 }, - { stage: 'needed_downstream', label: 'Потребовался AI после старта', sessions: neededDownstream, share: scenarioCalls ? Number(((neededDownstream / scenarioCalls) * 100).toFixed(2)) : 0 }, - { stage: 'downstream_ai_obtained', label: 'Имя добрал AI после follow-up', sessions: downstreamAiObtained, share: scenarioCalls ? Number(((downstreamAiObtained / scenarioCalls) * 100).toFixed(2)) : 0 }, - { stage: 'handoff_confirmed_name', label: 'Передача с подтверждённым именем', sessions: handoffConfirmedName, share: scenarioCalls ? Number(((handoffConfirmedName / scenarioCalls) * 100).toFixed(2)) : 0 }, - { stage: 'handoff_unconfirmed_name', label: 'Передача без подтверждённого имени', sessions: handoffUnconfirmedName, share: scenarioCalls ? Number(((handoffUnconfirmedName / scenarioCalls) * 100).toFixed(2)) : 0 }, + { stage: 'scenario_calls', label: mockVoiceNameLabel('scenario_calls', 'Звонки в сценарии'), sessions: scenarioCalls, share: 100 }, + { stage: 'start_obtained', label: mockVoiceNameLabel('start_obtained', 'Имя взято сразу'), sessions: startObtained, share: scenarioCalls ? Number(((startObtained / scenarioCalls) * 100).toFixed(2)) : 0 }, + { stage: 'needed_downstream', label: mockVoiceNameLabel('needed_downstream', 'Потребовался AI после старта'), sessions: neededDownstream, share: scenarioCalls ? Number(((neededDownstream / scenarioCalls) * 100).toFixed(2)) : 0 }, + { stage: 'downstream_ai_obtained', label: mockVoiceNameLabel('downstream_ai_obtained', 'Имя добрал AI после follow-up'), sessions: downstreamAiObtained, share: scenarioCalls ? Number(((downstreamAiObtained / scenarioCalls) * 100).toFixed(2)) : 0 }, + { stage: 'handoff_confirmed_name', label: mockVoiceNameLabel('handoff_confirmed_name', 'Передача с подтверждённым именем'), sessions: handoffConfirmedName, share: scenarioCalls ? Number(((handoffConfirmedName / scenarioCalls) * 100).toFixed(2)) : 0 }, + { stage: 'handoff_unconfirmed_name', label: mockVoiceNameLabel('handoff_unconfirmed_name', 'Передача без подтверждённого имени'), sessions: handoffUnconfirmedName, share: scenarioCalls ? Number(((handoffUnconfirmedName / scenarioCalls) * 100).toFixed(2)) : 0 }, ], by_language: languageRows, by_queue: queueRows, handoff: [ - { outcome: 'confirmed_name', label: 'Передача с подтверждённым именем', sessions: handoffConfirmedName, share: allHandoffs ? Number(((handoffConfirmedName / allHandoffs) * 100).toFixed(2)) : 0 }, - { outcome: 'unconfirmed_name', label: 'Передача без подтверждённого имени', sessions: handoffUnconfirmedName, share: allHandoffs ? Number(((handoffUnconfirmedName / allHandoffs) * 100).toFixed(2)) : 0 }, + { outcome: 'confirmed_name', label: mockVoiceNameLabel('confirmed_name', 'Передача с подтверждённым именем'), sessions: handoffConfirmedName, share: allHandoffs ? Number(((handoffConfirmedName / allHandoffs) * 100).toFixed(2)) : 0 }, + { outcome: 'unconfirmed_name', label: mockVoiceNameLabel('unconfirmed_name', 'Передача без подтверждённого имени'), sessions: handoffUnconfirmedName, share: allHandoffs ? Number(((handoffUnconfirmedName / allHandoffs) * 100).toFixed(2)) : 0 }, ], }, - coverage: { - sessions_with_start_decision: Math.round(scenarioCalls * 0.98), - sessions_with_final_ai_state: Math.round(scenarioCalls * 0.94), - sessions_with_manual_overlay: manualCorrected, - note: analyticsMockData().coverage_note || 'Показаны демонстрационные данные по name-flow.', - }, + coverage, }; } function buildMockVoiceNameTrend(rangeMeta, metric) { const interval = analyticsTimeseriesIntervalForRange(rangeMeta); - const baseOverview = buildMockVoiceNameOverview(rangeMeta); + const scenarioProfile = mockVoiceNameProfile('scenario_calls', { base: 24, step: 2.4, cycle: 4 }); + const metricProfile = metric === 'scenario_calls' + ? scenarioProfile + : mockVoiceNameProfile(metric, { base: 60, step: 2.1, cycle: 4 }); + const queueFactor = Math.max(0.48, mockAnalyticsQueueFactor(state.analytics.queueId === 'all' ? 'sales_kz' : state.analytics.queueId)); const points = []; let cursor = new Date(interval === 'hour' ? rangeMeta.current.from.getTime() : startOfDay(rangeMeta.current.from).getTime()); while (cursor < rangeMeta.current.to) { const index = points.length; - const scenarioCalls = Math.max(4, Math.round((baseOverview.totals.scenario_calls || 0) / Math.max(5, index + 5) * (0.92 + (index % 4) * 0.06))); - const neededDownstream = Math.max(0, Math.round(scenarioCalls * 0.35)); + const scenarioCalls = Math.max( + 4, + Math.round((scenarioProfile.base + (index % scenarioProfile.cycle) * scenarioProfile.step) * queueFactor), + ); + const neededDownstream = Math.max(1, Math.round(scenarioCalls * 0.35)); const handoffs = Math.max(1, Math.round(scenarioCalls * 0.18)); let value = 0; let denominator = scenarioCalls; - if (metric === 'start_capture_rate') { - value = 61 + (index % 4) * 2.4; + if (metric === 'scenario_calls') { + value = scenarioCalls; + denominator = scenarioCalls; } else if (metric === 'downstream_rescue_rate') { - value = 42 + (index % 5) * 2.1; + value = metricProfile.base + (index % metricProfile.cycle) * metricProfile.step; denominator = neededDownstream; - } else if (metric === 'handoff_unconfirmed_rate') { - value = 34 + (index % 4) * 2.6; - denominator = handoffs; - } else if (metric === 'manual_correction_rate') { - value = 15 + (index % 3) * 1.8; + } else if (metric === 'handoff_unconfirmed_rate' || metric === 'manual_correction_rate') { + value = metricProfile.base + (index % metricProfile.cycle) * metricProfile.step; denominator = handoffs; } else { - value = scenarioCalls; + value = metricProfile.base + (index % metricProfile.cycle) * metricProfile.step; denominator = scenarioCalls; } points.push({ @@ -3716,7 +3763,7 @@ function renderVoiceNameAnalyticsEmptyState() { const total = Number(state.analytics.voiceNameOverview?.totals?.scenario_calls || 0); if (!state.analytics.loading && total === 0) { box.hidden = false; - box.textContent = 'За выбранный период не найдено звонков, прошедших через сценарий сбора имени.'; + box.textContent = mockVoiceNameSeed().empty_note || 'За выбранный период не найдено звонков, прошедших через сценарий сбора имени.'; return; } box.hidden = true; diff --git a/ui/analyst/mock-analytics.json b/ui/analyst/mock-analytics.json index ca08964..04c2422 100644 --- a/ui/analyst/mock-analytics.json +++ b/ui/analyst/mock-analytics.json @@ -115,7 +115,65 @@ "handoff_unconfirmed_rate": 46.5, "manual_correction_rate": 23.0 } - ] + ], + "queues": [ + { + "queue_id": "sales_kz", + "share": 0.31, + "start_capture_rate": 68.4, + "downstream_rescue_rate": 49.6, + "handoff_unconfirmed_rate": 24.1, + "manual_correction_rate": 11.2 + }, + { + "queue_id": "support_kz", + "share": 0.39, + "start_capture_rate": 61.2, + "downstream_rescue_rate": 46.9, + "handoff_unconfirmed_rate": 31.8, + "manual_correction_rate": 15.4 + }, + { + "queue_id": "vip_kz", + "share": 0.11, + "start_capture_rate": 73.5, + "downstream_rescue_rate": 52.8, + "handoff_unconfirmed_rate": 18.6, + "manual_correction_rate": 8.9 + }, + { + "queue_id": "retention_kz", + "share": 0.19, + "start_capture_rate": 55.1, + "downstream_rescue_rate": 39.4, + "handoff_unconfirmed_rate": 37.9, + "manual_correction_rate": 19.7 + } + ], + "coverage": { + "sessions_with_start_decision": 192, + "sessions_with_final_ai_state": 184, + "sessions_with_manual_overlay": 12, + "note": "Мок-данные по voice name-flow показывают стартовый захват имени, добор AI и ручное исправление оператором." + }, + "trend_profiles": { + "scenario_calls": { "base": 24, "step": 2.4, "cycle": 4 }, + "start_capture_rate": { "base": 60.5, "step": 2.2, "cycle": 4 }, + "downstream_rescue_rate": { "base": 40.2, "step": 1.9, "cycle": 5 }, + "handoff_unconfirmed_rate": { "base": 33.1, "step": 2.4, "cycle": 4 }, + "manual_correction_rate": { "base": 14.4, "step": 1.5, "cycle": 3 } + }, + "empty_note": "За выбранный период нет звонков, прошедших через сценарий сбора имени." + }, + "voice_name_labels": { + "scenario_calls": "Звонки в сценарии", + "start_obtained": "Имя взято сразу", + "needed_downstream": "Потребовался AI после старта", + "downstream_ai_obtained": "Имя добрал AI после follow-up", + "handoff_confirmed_name": "Передача с подтверждённым именем", + "handoff_unconfirmed_name": "Передача без подтверждённого имени", + "confirmed_name": "Передача с подтверждённым именем", + "unconfirmed_name": "Передача без подтверждённого имени" }, "agent_shift_rows": [ {