Improve operator CRM flows and UI
This commit is contained in:
@@ -14,6 +14,13 @@ def test_operator_ui_route_exists():
|
||||
assert 'id="browserPhoneCallOverlay"' in response.text
|
||||
|
||||
|
||||
def test_operator_ui_config_disables_whatsapp_by_default():
|
||||
client = TestClient(app)
|
||||
response = client.get("/operator/config")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["features"]["whatsapp"] is False
|
||||
|
||||
|
||||
def test_login_ui_route_exists():
|
||||
client = TestClient(app)
|
||||
response = client.get("/")
|
||||
@@ -132,6 +139,36 @@ def test_operator_ui_keeps_webchat_channel_option_without_demo_block():
|
||||
assert "function sendWebchatMessage()" not in app_js
|
||||
|
||||
|
||||
def test_operator_ui_contains_unified_inbox_panel():
|
||||
app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8")
|
||||
index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8")
|
||||
assert 'id="unifiedInbox"' in index_html
|
||||
assert 'id="refreshUnifiedInboxBtn"' in index_html
|
||||
assert 'id="unifiedInboxSummary"' in index_html
|
||||
assert 'id="unifiedInboxBoard"' in index_html
|
||||
assert "const UNIFIED_INBOX_COLUMNS = [" in app_js
|
||||
assert "function buildUnifiedInboxCollections()" in app_js
|
||||
assert "function renderUnifiedInbox()" in app_js
|
||||
assert "function focusLiveCall(callId)" in app_js
|
||||
assert "function openUnifiedInboxItem(button)" in app_js
|
||||
assert "function handleUnifiedInboxClick(event)" in app_js
|
||||
assert "function refreshUnifiedInbox()" in app_js
|
||||
assert "window.location.hash = '#telegram-page';" in app_js
|
||||
assert "window.location.hash = '#calls';" in app_js
|
||||
assert "window.location.hash = '#interactions';" in app_js
|
||||
|
||||
|
||||
def test_operator_ui_loads_backend_customer_history_for_profile():
|
||||
app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8")
|
||||
assert "historyById: {}" in app_js
|
||||
assert "pendingHistoryById: {}" in app_js
|
||||
assert "function customerHistoryPayload(customerId)" in app_js
|
||||
assert "function ensureCustomerHistoryLoaded(customerId, options = {})" in app_js
|
||||
assert "api('customer', `customers/${encodeURIComponent(customerId)}/history`)" in app_js
|
||||
assert "const historyPayload = customerHistoryPayload(customer.customer_id);" in app_js
|
||||
assert "ensureCustomerHistoryLoaded(customer.customer_id).catch(() => {});" in app_js
|
||||
|
||||
|
||||
def test_operator_ui_contains_leads_table_front():
|
||||
app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8")
|
||||
index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8")
|
||||
@@ -186,9 +223,13 @@ def test_operator_ui_contains_live_call_control_block():
|
||||
app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8")
|
||||
index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8")
|
||||
assert 'id="liveCalls"' in index_html
|
||||
assert 'data-operator-view="voice-debug"' in index_html
|
||||
assert 'data-operator-view-link="voice-debug"' in index_html
|
||||
assert 'href="#voice-debug"' in index_html
|
||||
assert 'id="callsView"' in index_html
|
||||
assert 'data-operator-view="calls"' in index_html
|
||||
assert 'data-operator-view-link="calls"' in index_html
|
||||
assert 'href="#calls"' in index_html
|
||||
assert 'data-operator-view="voice-debug"' not in index_html
|
||||
assert 'data-operator-view-link="voice-debug"' not in index_html
|
||||
assert 'href="#voice-debug"' not in index_html
|
||||
assert 'id="loadLiveCallsBtn"' in index_html
|
||||
assert 'id="loadLiveCallActionsBtn"' in index_html
|
||||
assert 'id="liveCallIdSelect"' in index_html
|
||||
@@ -204,6 +245,8 @@ def test_operator_ui_contains_live_call_control_block():
|
||||
assert "Только что завершённые" in app_js
|
||||
assert "function terminalActionLabel" in app_js
|
||||
assert "function fallbackTelephonyStatus" not in app_js
|
||||
assert "raw === 'calls' || raw === 'calls-page' || raw === 'voice-debug' || raw === 'voice-debug-page'" in app_js
|
||||
assert "window.history.replaceState(null, '', '#calls');" in app_js
|
||||
assert 'data-operator-anchor-link="liveCalls"' not in index_html
|
||||
assert 'data-operator-anchor-link="interactions"' not in index_html
|
||||
assert 'data-operator-anchor-link="integrations"' not in index_html
|
||||
@@ -243,6 +286,20 @@ def test_operator_ui_contains_browser_softphone_popup_controls():
|
||||
assert "function stopBrowserPhoneRingtone" in app_js
|
||||
|
||||
|
||||
def test_operator_ui_hides_whatsapp_behind_feature_flag():
|
||||
app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8")
|
||||
index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8")
|
||||
assert 'data-operator-view-link="whatsapp" data-feature="whatsapp"' in index_html
|
||||
assert 'data-feature-option="whatsapp"' in index_html
|
||||
assert 'data-operator-view="whatsapp" data-feature="whatsapp" hidden' in index_html
|
||||
assert "function loadOperatorConfig()" in app_js
|
||||
assert "const response = await fetch('/operator/config');" in app_js
|
||||
assert "state.features.whatsapp = Boolean(data?.features?.whatsapp);" in app_js
|
||||
assert "document.querySelectorAll('[data-feature=\"whatsapp\"]')" in app_js
|
||||
assert "document.querySelectorAll('[data-feature-option=\"whatsapp\"]')" in app_js
|
||||
assert "if (featureEnabled('whatsapp')) {" in app_js
|
||||
|
||||
|
||||
def test_operator_ui_browser_softphone_has_fast_answer_and_hangup_states():
|
||||
app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8")
|
||||
assert "function browserPhonePopupPhase" in app_js
|
||||
|
||||
+193
-1
@@ -5,7 +5,14 @@ from services.interaction_service.app import app as interaction_app
|
||||
from services.ivr_service.app import app as ivr_app
|
||||
from services.routing_service.app import app as routing_app
|
||||
from services.shared.db import get_session
|
||||
from services.shared.sql_models import Queue
|
||||
from services.shared.sql_models import (
|
||||
AsteriskCallLinkRow,
|
||||
CallRecordingRow,
|
||||
Queue,
|
||||
TelegramMessageRow,
|
||||
TelegramThreadRow,
|
||||
VoiceTranscriptSegmentRow,
|
||||
)
|
||||
|
||||
|
||||
def test_customer_and_interaction_lifecycle():
|
||||
@@ -62,6 +69,191 @@ def test_customer_and_interaction_lifecycle():
|
||||
assert len(timeline.json()["events"]) >= 4
|
||||
|
||||
|
||||
def test_customer_history_aggregates_telegram_and_voice_context():
|
||||
customer_client = TestClient(customer_app)
|
||||
interaction_client = TestClient(interaction_app)
|
||||
|
||||
customer = customer_client.post(
|
||||
"/customers",
|
||||
json={
|
||||
"display_name": "History Client",
|
||||
"phones": ["+77010000019"],
|
||||
"preferred_phone": "+77010000019",
|
||||
"tags": ["priority"],
|
||||
},
|
||||
)
|
||||
assert customer.status_code == 200
|
||||
customer_id = customer.json()["customer_id"]
|
||||
|
||||
voice_interaction = interaction_client.post(
|
||||
"/interactions",
|
||||
json={
|
||||
"channel": "voice",
|
||||
"subject": "Need callback after AI handoff",
|
||||
"customer_id": customer_id,
|
||||
"queue_id": "q_voice",
|
||||
"priority": 2,
|
||||
},
|
||||
headers={"X-User": "operator", "X-Role": "operator"},
|
||||
)
|
||||
assert voice_interaction.status_code == 200
|
||||
voice_interaction_id = voice_interaction.json()["interaction_id"]
|
||||
|
||||
telegram_interaction = interaction_client.post(
|
||||
"/interactions",
|
||||
json={
|
||||
"channel": "telegram",
|
||||
"subject": "Order issue in Telegram",
|
||||
"customer_id": customer_id,
|
||||
"queue_id": "q_chat",
|
||||
"priority": 3,
|
||||
},
|
||||
headers={"X-User": "operator", "X-Role": "operator"},
|
||||
)
|
||||
assert telegram_interaction.status_code == 200
|
||||
telegram_interaction_id = telegram_interaction.json()["interaction_id"]
|
||||
|
||||
assigned = interaction_client.patch(
|
||||
f"/interactions/{telegram_interaction_id}/assign",
|
||||
json={"assignee": "operator_a"},
|
||||
headers={"X-User": "supervisor", "X-Role": "supervisor"},
|
||||
)
|
||||
assert assigned.status_code == 200
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
session.add(
|
||||
TelegramThreadRow(
|
||||
thread_id="tgt_history_customer",
|
||||
chat_id="chat_history_customer",
|
||||
interaction_id=telegram_interaction_id,
|
||||
telegram_user_id="tg_hist_1",
|
||||
username="history_client",
|
||||
display_name="History Client",
|
||||
queue_id="q_chat",
|
||||
status="in_progress",
|
||||
claimed_by_user="operator_a",
|
||||
claimed_at="2026-04-03T09:05:00+00:00",
|
||||
ai_session_id="ais_hist_1",
|
||||
ai_state="human_owned",
|
||||
ai_handoff_reason="Нужно проверить статус заказа вручную",
|
||||
ai_last_model_at="2026-04-03T09:04:00+00:00",
|
||||
last_message_at="2026-04-03T09:06:00+00:00",
|
||||
last_message_preview="Клиент просит уточнить статус заказа",
|
||||
created_at="2026-04-03T09:00:00+00:00",
|
||||
updated_at="2026-04-03T09:06:00+00:00",
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
TelegramMessageRow(
|
||||
message_id="tgm_history_customer_in",
|
||||
thread_id="tgt_history_customer",
|
||||
interaction_id=telegram_interaction_id,
|
||||
chat_id="chat_history_customer",
|
||||
text="Клиент просит уточнить статус заказа",
|
||||
customer_external_id="telegram:history_client",
|
||||
direction="inbound",
|
||||
telegram_message_id_external="501",
|
||||
operator_user=None,
|
||||
author_type="customer",
|
||||
author_id="tg_hist_1",
|
||||
delivery_status=None,
|
||||
payload_json="{}",
|
||||
created_at="2026-04-03T09:06:00+00:00",
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
AsteriskCallLinkRow(
|
||||
call_id="call_history_customer",
|
||||
linked_id="linked_history_customer",
|
||||
queue_code="voice_support",
|
||||
queue_id="q_voice",
|
||||
interaction_id=voice_interaction_id,
|
||||
caller_number="+77010000019",
|
||||
caller_name="History Client",
|
||||
status="active",
|
||||
telephony_status="connected",
|
||||
claimed_by_user="operator_a",
|
||||
claimed_at="2026-04-03T09:11:00+00:00",
|
||||
operator_extension="2001",
|
||||
channel_name="PJSIP/2001-000001",
|
||||
voice_session_id="vas_history_1",
|
||||
ai_session_id="ais_voice_1",
|
||||
ai_state="handoff_required",
|
||||
ai_handoff_reason="AI не смог подтвердить оплату по заказу",
|
||||
ai_last_model_at="2026-04-03T09:12:00+00:00",
|
||||
started_at="2026-04-03T09:10:00+00:00",
|
||||
connected_at="2026-04-03T09:11:00+00:00",
|
||||
ended_at=None,
|
||||
updated_at="2026-04-03T09:12:00+00:00",
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
CallRecordingRow(
|
||||
recording_id="rec_history_customer",
|
||||
channel="voice",
|
||||
call_id="call_history_customer",
|
||||
interaction_id=voice_interaction_id,
|
||||
source_event_id="evt_history_customer",
|
||||
file_name="history-call.wav",
|
||||
storage_backend="local_fs",
|
||||
storage_path="history/history-call.wav",
|
||||
mime_type="audio/wav",
|
||||
size_bytes=1024,
|
||||
duration_seconds=48,
|
||||
checksum_sha256="abc123",
|
||||
status="ready",
|
||||
recorded_at="2026-04-03T09:13:00+00:00",
|
||||
created_at="2026-04-03T09:13:00+00:00",
|
||||
updated_at="2026-04-03T09:13:00+00:00",
|
||||
archived_at=None,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
VoiceTranscriptSegmentRow(
|
||||
segment_id="seg_history_customer",
|
||||
session_id="vas_history_1",
|
||||
call_id="call_history_customer",
|
||||
interaction_id=voice_interaction_id,
|
||||
speaker="caller",
|
||||
source_type="asr",
|
||||
sequence_no=1,
|
||||
text="Мне нужно, чтобы оператор подтвердил оплату по счёту",
|
||||
confidence=0.93,
|
||||
is_final=True,
|
||||
barge_in_interrupted=False,
|
||||
payload_json="{}",
|
||||
created_at="2026-04-03T09:11:30+00:00",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
history = customer_client.get(f"/customers/{customer_id}/history")
|
||||
|
||||
assert history.status_code == 200
|
||||
payload = history.json()
|
||||
assert payload["customer"]["customer_id"] == customer_id
|
||||
assert payload["summary"]["open_cases"] == 2
|
||||
assert payload["summary"]["primary_phone"] == "+77010000019"
|
||||
assert payload["summary"]["primary_telegram_thread_id"] == "tgt_history_customer"
|
||||
assert set(payload["summary"]["active_channels"]) == {"telegram", "voice"}
|
||||
assert {item["interaction_id"] for item in payload["interactions"]} == {
|
||||
voice_interaction_id,
|
||||
telegram_interaction_id,
|
||||
}
|
||||
assert payload["telegram_threads"][0]["thread_id"] == "tgt_history_customer"
|
||||
assert payload["live_calls"][0]["call_id"] == "call_history_customer"
|
||||
assert payload["live_calls"][0]["has_recording"] is True
|
||||
titles = [item["title"] for item in payload["history"]]
|
||||
assert "Telegram диалог" in titles
|
||||
assert "Обращение назначено" in titles
|
||||
assert any(title.startswith("Звонок •") for title in titles)
|
||||
assert any(item["body"] == "Клиент просит уточнить статус заказа" for item in payload["history"])
|
||||
assert any("оператор" in (item["note"] or "") for item in payload["history"])
|
||||
|
||||
|
||||
def test_queue_rules_and_route():
|
||||
routing_client = TestClient(routing_app)
|
||||
|
||||
|
||||
@@ -75,8 +75,9 @@ def test_manual_webhook_creates_thread_and_linked_interaction():
|
||||
|
||||
thread_list = telegram_client.get("/integrations/telegram/threads", headers=admin_headers())
|
||||
assert thread_list.status_code == 200
|
||||
assert thread_list.json()[0]["chat_id"] == "chat_1"
|
||||
assert thread_list.json()[0]["interaction_id"] == payload["interaction_id"]
|
||||
listed_thread = next(item for item in thread_list.json() if item["thread_id"] == payload["thread_id"])
|
||||
assert listed_thread["chat_id"] == "chat_1"
|
||||
assert listed_thread["interaction_id"] == payload["interaction_id"]
|
||||
|
||||
interaction = interaction_client.get(f"/interactions/{payload['interaction_id']}")
|
||||
assert interaction.status_code == 200
|
||||
|
||||
@@ -85,9 +85,10 @@ def test_manual_webhook_creates_thread_and_linked_interaction():
|
||||
|
||||
thread_list = whatsapp_client.get("/integrations/whatsapp/threads", headers=admin_headers())
|
||||
assert thread_list.status_code == 200
|
||||
assert thread_list.json()[0]["chat_id"] == "wa_chat_1"
|
||||
assert thread_list.json()[0]["phone_number"] == "+77015550001"
|
||||
assert thread_list.json()[0]["unread_count"] == 1
|
||||
listed_thread = next(item for item in thread_list.json() if item["thread_id"] == payload["thread_id"])
|
||||
assert listed_thread["chat_id"] == "wa_chat_1"
|
||||
assert listed_thread["phone_number"] == "+77015550001"
|
||||
assert listed_thread["unread_count"] == 1
|
||||
|
||||
interaction = interaction_client.get(f"/interactions/{payload['interaction_id']}")
|
||||
assert interaction.status_code == 200
|
||||
|
||||
Reference in New Issue
Block a user