diff --git a/gateway/app.py b/gateway/app.py index 95eb831..83c982e 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -157,6 +157,13 @@ SERVICE_URLS = { } +def _env_flag(name: str, default: bool = False) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + @app.get("/health", response_model=HealthResponse) def health() -> HealthResponse: return HealthResponse(status="ok", service="api-gateway") @@ -179,6 +186,15 @@ def registry() -> dict: return {"services": SERVICE_URLS} +@app.get("/operator/config") +def operator_ui_config() -> dict[str, Any]: + return { + "features": { + "whatsapp": _env_flag("OPERATOR_WHATSAPP_ENABLED", default=False), + } + } + + @app.get("/contracts") def contracts() -> dict: return { diff --git a/services/customer_service/app.py b/services/customer_service/app.py index d3fd912..fcac210 100644 --- a/services/customer_service/app.py +++ b/services/customer_service/app.py @@ -1,24 +1,136 @@ -from __future__ import annotations +from __future__ import annotations import json +from datetime import datetime from fastapi import FastAPI, HTTPException from sqlalchemy import select from services.shared.core import new_id, utc_now_iso from services.shared.db import get_session -from services.shared.models import CustomerCreate, CustomerOut, HealthResponse +from services.shared.models import ( + CustomerCreate, + CustomerHistoryEventOut, + CustomerHistoryOut, + CustomerHistorySummaryOut, + CustomerOut, + HealthResponse, + InteractionOut, + TelegramThreadOut, + VoiceLiveCallOut, +) from services.shared.sql_init import init_sql_schema -from services.shared.sql_models import Customer +from services.shared.sql_models import ( + AsteriskCallLinkRow, + CallRecordingRow, + Customer, + Interaction, + InteractionTimeline, + TelegramMessageRow, + TelegramThreadRow, + VoiceTranscriptSegmentRow, +) app = FastAPI(title="customer-service", version="1.0.0") init_sql_schema() +_CHANNEL_LABELS = { + "voice": "Голос", + "telegram": "Telegram", + "whatsapp": "WhatsApp", + "webchat": "Веб-чат", + "email": "Email", +} +_INTERACTION_STATUS_LABELS = { + "new": "новое", + "in_progress": "в работе", + "escalated": "эскалировано", + "closed": "закрыто", + "abandoned": "потеряно", +} +_TELEPHONY_STATUS_LABELS = { + "ringing": "Звонит", + "claimed": "Взято", + "connected": "Соединено", + "ended": "Завершено", + "failed": "Ошибка", +} +_TIMELINE_ACTION_TITLES = { + "interaction.created": "Обращение создано", + "interaction.assigned": "Обращение назначено", + "interaction.status_changed": "Статус обращения изменён", + "interaction.escalated": "Обращение эскалировано", +} + + +def _json_list(raw: str | None) -> list: + try: + value = json.loads(raw or "[]") + except json.JSONDecodeError: + return [] + return value if isinstance(value, list) else [] + + +def _json_dict(raw: str | None) -> dict: + try: + value = json.loads(raw or "{}") + except json.JSONDecodeError: + return {} + return value if isinstance(value, dict) else {} + + +def _time_value(value: str | None) -> int: + if not value: + return 0 + normalized = value.strip() + if normalized.endswith("Z"): + normalized = f"{normalized[:-1]}+00:00" + try: + return int(datetime.fromisoformat(normalized).timestamp()) + except ValueError: + return 0 + + +def _channel_label(channel: str | None) -> str: + return _CHANNEL_LABELS.get(channel or "", channel or "Канал") + + +def _interaction_status_label(status: str | None) -> str: + return _INTERACTION_STATUS_LABELS.get(status or "", status or "неизвестно") + + +def _telephony_status_label(status: str | None) -> str: + return _TELEPHONY_STATUS_LABELS.get(status or "", status or "неизвестно") + + +def _timeline_title(action: str) -> str: + return _TIMELINE_ACTION_TITLES.get(action, action) + + +def _timeline_body(action: str, metadata: dict) -> str: + if action == "interaction.assigned": + assignee = metadata.get("assignee") + return f"Обращение назначено на {assignee}." if assignee else "Обращение назначено оператору." + if action == "interaction.status_changed": + status = metadata.get("status") + return f"Новый статус: {_interaction_status_label(status)}." + if action == "interaction.escalated": + target_queue_id = metadata.get("target_queue_id") + return ( + f"Обращение передано в очередь {target_queue_id}." + if target_queue_id + else "Обращение эскалировано." + ) + if action == "interaction.created": + channel = metadata.get("channel") + return f"Создано новое обращение по каналу {_channel_label(channel)}." + return "Событие сохранено в ленте обращения." + def _to_out(row: Customer) -> CustomerOut: - phones = json.loads(row.phones_json or "[]") - tags = json.loads(row.tags_json or "[]") + phones = _json_list(row.phones_json) + tags = _json_list(row.tags_json) return CustomerOut( customer_id=row.customer_id, display_name=row.display_name, @@ -29,6 +141,296 @@ def _to_out(row: Customer) -> CustomerOut: ) +def _interaction_to_out(row: Interaction) -> InteractionOut: + return InteractionOut( + interaction_id=row.interaction_id, + channel=row.channel, + subject=row.subject, + customer_id=row.customer_id, + queue_id=row.queue_id, + priority=row.priority, + status=row.status, + assigned_to=row.assigned_to, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def _telegram_thread_to_out(row: TelegramThreadRow) -> TelegramThreadOut: + return TelegramThreadOut( + thread_id=row.thread_id, + chat_id=row.chat_id, + interaction_id=row.interaction_id, + telegram_user_id=row.telegram_user_id, + username=row.username, + display_name=row.display_name, + queue_id=row.queue_id, + status=row.status, # type: ignore[arg-type] + claimed_by_user=row.claimed_by_user, + claimed_at=row.claimed_at, + ai_session_id=row.ai_session_id, + ai_state=row.ai_state, # type: ignore[arg-type] + ai_handoff_reason=row.ai_handoff_reason, + ai_last_model_at=row.ai_last_model_at, + last_message_at=row.last_message_at, + last_message_preview=row.last_message_preview, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def _voice_call_to_out(row: AsteriskCallLinkRow, *, has_recording: bool) -> VoiceLiveCallOut: + return VoiceLiveCallOut( + call_id=row.call_id, + interaction_id=row.interaction_id, + queue_id=row.queue_id, + queue_code=row.queue_code, + caller_number=row.caller_number, + caller_name=row.caller_name, + status=row.status, + telephony_status=row.telephony_status, # type: ignore[arg-type] + claimed_by_user=row.claimed_by_user, + claimed_at=row.claimed_at, + operator_extension=row.operator_extension, + channel_name=row.channel_name, + started_at=row.started_at, + connected_at=row.connected_at, + ended_at=row.ended_at, + updated_at=row.updated_at, + last_transition_at=row.ended_at or row.connected_at or row.updated_at, + hangup_cause=None, + terminal_action=None, + terminal_target=None, + voice_session_id=row.voice_session_id, + ai_session_id=row.ai_session_id, + ai_state=row.ai_state, # type: ignore[arg-type] + ai_handoff_reason=row.ai_handoff_reason, + ai_last_model_at=row.ai_last_model_at, + has_recording=has_recording, + ) + + +def _history_event_sort_key(event: CustomerHistoryEventOut) -> tuple[int, str]: + return (_time_value(event.timestamp), event.note) + + +def _build_customer_history( + customer: Customer, + interactions: list[Interaction], + timelines: list[InteractionTimeline], + telegram_threads: list[TelegramThreadRow], + telegram_messages: list[TelegramMessageRow], + live_calls: list[AsteriskCallLinkRow], + transcript_segments: list[VoiceTranscriptSegmentRow], + recording_call_ids: set[str], +) -> CustomerHistoryOut: + customer_out = _to_out(customer) + interaction_out = [_interaction_to_out(row) for row in interactions] + telegram_thread_out = [_telegram_thread_to_out(row) for row in telegram_threads] + live_call_out = [_voice_call_to_out(row, has_recording=row.call_id in recording_call_ids) for row in live_calls] + interaction_by_id = {row.interaction_id: row for row in interactions} + thread_by_id = {row.thread_id: row for row in telegram_threads} + events: list[CustomerHistoryEventOut] = [ + CustomerHistoryEventOut( + timestamp=customer.created_at, + kind="profile", + title="Профиль клиента активен", + body=( + f"Контакт доступен по номеру {customer_out.phones[0]}." + if customer_out.phones + else "Контакт добавлен в клиентскую базу." + ), + note=" • ".join(filter(None, [customer.customer_id, customer_out.preferred_phone])), + ) + ] + + for row in interactions: + events.append( + CustomerHistoryEventOut( + timestamp=row.updated_at or row.created_at, + kind=row.channel or "case", + title=f"{_channel_label(row.channel)} • {_interaction_status_label(row.status)}", + body=row.subject or "Обращение без темы", + note=" • ".join( + filter( + None, + [ + row.interaction_id, + f"оператор {row.assigned_to}" if row.assigned_to else "", + f"queue {row.queue_id}" if row.queue_id else "", + ], + ) + ), + interaction_id=row.interaction_id, + ) + ) + + for row in timelines: + interaction = interaction_by_id.get(row.interaction_id) + metadata = _json_dict(row.metadata_json) + events.append( + CustomerHistoryEventOut( + timestamp=row.timestamp, + kind=interaction.channel if interaction else "case", + title=_timeline_title(row.action), + body=_timeline_body(row.action, metadata), + note=" • ".join( + filter( + None, + [ + row.interaction_id, + f"оператор {interaction.assigned_to}" if interaction and interaction.assigned_to else "", + ], + ) + ), + interaction_id=row.interaction_id, + ) + ) + + for row in telegram_threads: + events.append( + CustomerHistoryEventOut( + timestamp=row.last_message_at or row.updated_at or row.created_at, + kind="telegram", + title="Telegram диалог", + body=row.last_message_preview or "Последнее сообщение недоступно.", + note=" • ".join( + filter( + None, + [ + row.display_name or row.username or f"chat {row.chat_id}", + f"оператор {row.claimed_by_user}" if row.claimed_by_user else "", + _interaction_status_label(row.status), + ], + ) + ), + interaction_id=row.interaction_id, + thread_id=row.thread_id, + ) + ) + + for row in telegram_messages: + thread = thread_by_id.get(row.thread_id or "") + direction = { + "outbound": "Исходящее сообщение", + "system": "Системное сообщение", + }.get(row.direction, "Входящее сообщение") + events.append( + CustomerHistoryEventOut( + timestamp=row.created_at, + kind="telegram", + title=direction, + body=row.text or "Сообщение без текста", + note=" • ".join( + filter( + None, + [ + thread.display_name or thread.username if thread else "", + f"оператор {row.operator_user}" if row.operator_user else "", + row.delivery_status or "", + ], + ) + ), + interaction_id=row.interaction_id, + thread_id=row.thread_id, + ) + ) + + for row in live_calls: + completed = row.telephony_status == "ended" or bool(row.ended_at) + events.append( + CustomerHistoryEventOut( + timestamp=row.ended_at or row.connected_at or row.updated_at or row.started_at, + kind="voice", + title=( + f"Звонок • {_telephony_status_label('ended')}" + if completed + else f"Звонок • {_telephony_status_label(row.telephony_status)}" + ), + body=f"Номер клиента: {row.caller_number or 'не определён'}", + note=" • ".join( + filter( + None, + [ + row.call_id, + f"внутр. {row.operator_extension}" if row.operator_extension else "", + f"оператор {row.claimed_by_user}" if row.claimed_by_user else "", + ], + ) + ), + interaction_id=row.interaction_id, + call_id=row.call_id, + ) + ) + if row.ai_state or row.ai_handoff_reason: + events.append( + CustomerHistoryEventOut( + timestamp=row.ai_last_model_at or row.updated_at or row.started_at, + kind="voice", + title=f"Голосовой AI • {row.ai_state or 'active'}", + body=row.ai_handoff_reason or "AI обновил контекст разговора для оператора.", + note=" • ".join( + filter( + None, + [ + row.call_id, + f"voice {row.voice_session_id}" if row.voice_session_id else "", + f"ai {row.ai_session_id}" if row.ai_session_id else "", + ], + ) + ), + interaction_id=row.interaction_id, + call_id=row.call_id, + ) + ) + + for row in transcript_segments: + speaker = "AI" if row.speaker == "assistant" else "Клиент" + events.append( + CustomerHistoryEventOut( + timestamp=row.created_at, + kind="voice", + title=f"Транскрипт • {speaker}", + body=row.text or "Реплика без текста", + note=" • ".join(filter(None, [row.call_id, f"seq {row.sequence_no}"])), + interaction_id=row.interaction_id, + call_id=row.call_id, + ) + ) + + events.sort(key=_history_event_sort_key, reverse=True) + active_channels = sorted( + { + *[row.channel for row in interactions if row.channel], + *(["telegram"] if telegram_threads else []), + *(["voice"] if live_calls else []), + } + ) + latest_event = events[0] if events else None + primary_thread = max( + telegram_threads, + key=lambda row: _time_value(row.last_message_at or row.updated_at or row.created_at), + default=None, + ) + summary = CustomerHistorySummaryOut( + contact_points=len(interactions) + len(telegram_threads) + len(live_calls), + open_cases=sum(1 for row in interactions if row.status != "closed"), + active_channels=active_channels, + latest_event_at=latest_event.timestamp if latest_event else None, + latest_event_title=latest_event.title if latest_event else None, + primary_phone=customer_out.phones[0] if customer_out.phones else customer_out.preferred_phone, + primary_telegram_thread_id=primary_thread.thread_id if primary_thread else None, + ) + return CustomerHistoryOut( + customer=customer_out, + summary=summary, + interactions=interaction_out, + telegram_threads=telegram_thread_out, + live_calls=live_call_out, + history=events[:25], + ) + + @app.get("/health", response_model=HealthResponse) def health() -> HealthResponse: return HealthResponse(status="ok", service="customer-service") @@ -89,13 +491,84 @@ def search_customers(query: str | None = None, limit: int = 100) -> list[Custome session.close() -@app.get("/customers/{customer_id}/history") -def customer_history(customer_id: str) -> dict: +@app.get("/customers/{customer_id}/history", response_model=CustomerHistoryOut) +def customer_history(customer_id: str) -> CustomerHistoryOut: session = get_session() try: - row = session.execute(select(Customer).where(Customer.customer_id == customer_id)).scalar_one_or_none() - if not row: + customer = session.execute( + select(Customer).where(Customer.customer_id == customer_id) + ).scalar_one_or_none() + if not customer: raise HTTPException(status_code=404, detail="Customer not found") - return {"customer_id": customer_id, "interactions": []} + + interactions = session.execute( + select(Interaction) + .where(Interaction.customer_id == customer_id) + .order_by(Interaction.updated_at.desc(), Interaction.id.desc()) + ).scalars().all() + interaction_ids = [row.interaction_id for row in interactions] + + timelines: list[InteractionTimeline] = [] + telegram_threads: list[TelegramThreadRow] = [] + telegram_messages: list[TelegramMessageRow] = [] + live_calls: list[AsteriskCallLinkRow] = [] + transcript_segments: list[VoiceTranscriptSegmentRow] = [] + recording_call_ids: set[str] = set() + + if interaction_ids: + timelines = session.execute( + select(InteractionTimeline) + .where(InteractionTimeline.interaction_id.in_(interaction_ids)) + .order_by(InteractionTimeline.timestamp.desc(), InteractionTimeline.id.desc()) + ).scalars().all() + + telegram_threads = session.execute( + select(TelegramThreadRow) + .where(TelegramThreadRow.interaction_id.in_(interaction_ids)) + .order_by(TelegramThreadRow.last_message_at.desc(), TelegramThreadRow.id.desc()) + ).scalars().all() + + live_calls = session.execute( + select(AsteriskCallLinkRow) + .where(AsteriskCallLinkRow.interaction_id.in_(interaction_ids)) + .order_by(AsteriskCallLinkRow.updated_at.desc(), AsteriskCallLinkRow.id.desc()) + ).scalars().all() + + thread_ids = [row.thread_id for row in telegram_threads if row.thread_id] + if thread_ids: + telegram_messages = session.execute( + select(TelegramMessageRow) + .where(TelegramMessageRow.thread_id.in_(thread_ids)) + .order_by(TelegramMessageRow.created_at.desc(), TelegramMessageRow.id.desc()) + .limit(8) + ).scalars().all() + + call_ids = [row.call_id for row in live_calls if row.call_id] + if call_ids: + recording_call_ids = set( + session.execute( + select(CallRecordingRow.call_id).where(CallRecordingRow.call_id.in_(call_ids)) + ).scalars().all() + ) + transcript_segments = session.execute( + select(VoiceTranscriptSegmentRow) + .where( + VoiceTranscriptSegmentRow.call_id.in_(call_ids), + VoiceTranscriptSegmentRow.is_final.is_(True), + ) + .order_by(VoiceTranscriptSegmentRow.created_at.desc(), VoiceTranscriptSegmentRow.id.desc()) + .limit(8) + ).scalars().all() + + return _build_customer_history( + customer, + interactions, + timelines, + telegram_threads, + telegram_messages, + live_calls, + transcript_segments, + recording_call_ids, + ) finally: session.close() diff --git a/services/shared/models.py b/services/shared/models.py index eb4f1d5..70119e2 100644 --- a/services/shared/models.py +++ b/services/shared/models.py @@ -96,6 +96,36 @@ class CustomerOut(CustomerCreate): created_at: str +class CustomerHistoryEventOut(BaseModel): + timestamp: str + kind: str + title: str + body: str + note: str = "" + interaction_id: str | None = None + thread_id: str | None = None + call_id: str | None = None + + +class CustomerHistorySummaryOut(BaseModel): + contact_points: int = 0 + open_cases: int = 0 + active_channels: list[str] = Field(default_factory=list) + latest_event_at: str | None = None + latest_event_title: str | None = None + primary_phone: str | None = None + primary_telegram_thread_id: str | None = None + + +class CustomerHistoryOut(BaseModel): + customer: CustomerOut + summary: CustomerHistorySummaryOut + interactions: list["InteractionOut"] = Field(default_factory=list) + telegram_threads: list["TelegramThreadOut"] = Field(default_factory=list) + live_calls: list["VoiceLiveCallOut"] = Field(default_factory=list) + history: list[CustomerHistoryEventOut] = Field(default_factory=list) + + class InteractionCreate(BaseModel): channel: Channel subject: str = Field(min_length=3) diff --git a/tests/test_gateway_ui.py b/tests/test_gateway_ui.py index 60aa0f2..1b50259 100644 --- a/tests/test_gateway_ui.py +++ b/tests/test_gateway_ui.py @@ -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 diff --git a/tests/test_operator_core.py b/tests/test_operator_core.py index 5fbb595..27d91ca 100644 --- a/tests/test_operator_core.py +++ b/tests/test_operator_core.py @@ -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) diff --git a/tests/test_telegram_adapter_service.py b/tests/test_telegram_adapter_service.py index 088d925..69285fa 100644 --- a/tests/test_telegram_adapter_service.py +++ b/tests/test_telegram_adapter_service.py @@ -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 diff --git a/tests/test_whatsapp_adapter_service.py b/tests/test_whatsapp_adapter_service.py index 276e9ca..eb74810 100644 --- a/tests/test_whatsapp_adapter_service.py +++ b/tests/test_whatsapp_adapter_service.py @@ -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 diff --git a/ui/operator/app.js b/ui/operator/app.js index 2ec300e..9458c0b 100644 --- a/ui/operator/app.js +++ b/ui/operator/app.js @@ -4,6 +4,9 @@ token: null, authSource: 'local', fullName: null, + features: { + whatsapp: false, + }, logLines: [], interactions: [], oidc: { @@ -26,6 +29,9 @@ pageSize: 7, selectedCustomerId: '', leadFormOpen: false, + historyById: {}, + pendingHistoryById: {}, + historyErrorsById: {}, }, telegram: { threads: [], @@ -346,6 +352,14 @@ const BOARD_COLUMNS = [ { key: 'closed', title: 'Закрытые' }, ]; +const UNIFIED_INBOX_COLUMNS = [ + { key: 'new', title: 'Новые', emptyMessage: 'Новых задач сейчас нет.' }, + { key: 'mine', title: 'Мои', emptyMessage: 'За вами пока ничего не закреплено.' }, + { key: 'ai', title: 'AI handoff', emptyMessage: 'Передач от AI сейчас нет.' }, + { key: 'escalated', title: 'Эскалации', emptyMessage: 'Эскалаций сейчас нет.' }, + { key: 'calls', title: 'Активные звонки', emptyMessage: 'Активных звонков сейчас нет.' }, +]; + const LIVE_TELEPHONY_LABELS = { ringing: 'Звонит', claimed: 'Взято', @@ -373,9 +387,62 @@ const OPERATOR_VIEW_IDS = { 'customer-profile': 'customerProfileView', telegram: 'telegramView', whatsapp: 'whatsappView', - 'voice-debug': 'voiceDebugView', + calls: 'callsView', }; +function featureEnabled(feature) { + return Boolean(state.features?.[feature]); +} + +function isOperatorViewEnabled(view) { + if (view === 'whatsapp') { + return featureEnabled('whatsapp'); + } + return true; +} + +function stopWhatsappPolling() { + if (state.whatsapp.pollTimer) { + window.clearInterval(state.whatsapp.pollTimer); + state.whatsapp.pollTimer = null; + } +} + +function resetWhatsappState(mode = 'idle') { + state.whatsapp.chats = []; + state.whatsapp.selectedChatId = ''; + state.whatsapp.searchQuery = ''; + state.whatsapp.activeFilter = 'all'; + state.whatsapp.composerText = ''; + state.whatsapp.selectedThreadSummary = null; + state.whatsapp.pendingAction = ''; + state.whatsapp.backendError = ''; + state.whatsapp.mockFallbackLogged = false; + state.whatsapp.mode = mode; +} + +function syncWhatsappFeatureVisibility() { + const enabled = featureEnabled('whatsapp'); + document.querySelectorAll('[data-feature="whatsapp"]').forEach((element) => { + element.hidden = !enabled; + }); + document.querySelectorAll('[data-feature-option="whatsapp"]').forEach((element) => { + element.hidden = !enabled; + element.disabled = !enabled; + }); + if (!enabled) { + stopWhatsappPolling(); + resetWhatsappState('disabled'); + if ($('interactionChannel')?.value === 'whatsapp') { + $('interactionChannel').value = 'voice'; + } + return; + } + if (state.whatsapp.mode === 'disabled') { + resetWhatsappState('idle'); + } +} + function syncSessionFromInputs() { state.user = $('sessionUser').value.trim() || 'admin'; state.role = $('sessionRole').value || 'admin'; @@ -469,6 +536,7 @@ function logout() { window.clearInterval(state.telegram.pollTimer); state.telegram.pollTimer = null; } + stopWhatsappPolling(); clearStoredSession(); window.location.href = '/'; } @@ -612,9 +680,26 @@ function customerProfileHash(customerId = state.customers.selectedCustomerId) { function openCustomerProfile(customerId) { selectCustomer(customerId || ''); + ensureCustomerHistoryLoaded(customerId || state.customers.selectedCustomerId, { force: true }).catch(() => {}); window.location.hash = customerProfileHash(customerId || state.customers.selectedCustomerId); } +function interactionById(interactionId) { + return state.interactions.find((item) => item.interaction_id === interactionId) || null; +} + +function customerIdForInteractionId(interactionId) { + return interactionById(interactionId)?.customer_id || ''; +} + +function customerDisplayName(customerId) { + if (!customerId) { + return 'не привязан'; + } + const customer = state.customers.items.find((item) => item.customer_id === customerId) || null; + return customer?.display_name || customerId; +} + function customerIndex(customer) { if (!customer) { return -1; @@ -663,6 +748,43 @@ function customerLiveCalls(customer) { }); } +function customerHistoryPayload(customerId) { + if (!customerId) { + return null; + } + return state.customers.historyById[customerId] || null; +} + +function customerHistoryPending(customerId) { + return Boolean(customerId && state.customers.pendingHistoryById[customerId]); +} + +async function ensureCustomerHistoryLoaded(customerId, options = {}) { + const { force = false } = options; + if (!customerId) { + return null; + } + if (customerHistoryPending(customerId)) { + return customerHistoryPayload(customerId); + } + if (!force && customerHistoryPayload(customerId)) { + return customerHistoryPayload(customerId); + } + state.customers.pendingHistoryById[customerId] = true; + delete state.customers.historyErrorsById[customerId]; + try { + const data = await api('customer', `customers/${encodeURIComponent(customerId)}/history`); + state.customers.historyById[customerId] = data || null; + return state.customers.historyById[customerId]; + } catch (err) { + state.customers.historyErrorsById[customerId] = err.message; + return null; + } finally { + delete state.customers.pendingHistoryById[customerId]; + renderCustomerProfilePage(); + } +} + function customerPreferredChannel(customer) { const counters = new Map(); customerInteractions(customer).forEach((item) => { @@ -835,23 +957,48 @@ function customerSpotlightMarkup(customer, options = {}) { const source = customerLeadSourceMeta(customer, index); const leadStatus = customerLeadStatusMeta(customer, index); const score = customerLeadScore(customer, index); - const interactions = customerInteractions(customer); - const threads = customerTelegramThreads(customer); - const liveCalls = customerLiveCalls(customer); - const historyEvents = buildCustomerHistoryEvents(customer); + const historyPayload = customerHistoryPayload(customer.customer_id); + const historySummary = historyPayload?.summary || null; + const interactions = Array.isArray(historyPayload?.interactions) + ? historyPayload.interactions + : customerInteractions(customer); + const threads = Array.isArray(historyPayload?.telegram_threads) + ? historyPayload.telegram_threads + : customerTelegramThreads(customer); + const liveCalls = Array.isArray(historyPayload?.live_calls) + ? historyPayload.live_calls + : customerLiveCalls(customer); + const historyEvents = Array.isArray(historyPayload?.history) && historyPayload.history.length + ? historyPayload.history + : buildCustomerHistoryEvents(customer); const latestEvent = historyEvents[0] || null; - const openCases = interactions.filter((item) => item.status !== 'closed').length; - const activeChannels = [...new Set([ - ...interactions.map((item) => item.channel).filter(Boolean), - ...(threads.length ? ['telegram'] : []), - ...(liveCalls.length ? ['voice'] : []), - ])]; + const openCases = Number.isFinite(Number(historySummary?.open_cases)) + ? Number(historySummary.open_cases) + : interactions.filter((item) => item.status !== 'closed').length; + const activeChannels = Array.isArray(historySummary?.active_channels) && historySummary.active_channels.length + ? historySummary.active_channels + : [...new Set([ + ...interactions.map((item) => item.channel).filter(Boolean), + ...(threads.length ? ['telegram'] : []), + ...(liveCalls.length ? ['voice'] : []), + ])]; const tags = Array.isArray(customer.tags) ? customer.tags.filter(Boolean) : []; - const primaryThread = [...threads].sort((left, right) => { - return customerHistoryTimeValue(right.last_message_at) - customerHistoryTimeValue(left.last_message_at); - })[0] || null; + const primaryThread = historySummary?.primary_telegram_thread_id + ? threads.find((item) => item.thread_id === historySummary.primary_telegram_thread_id) || null + : [...threads].sort((left, right) => { + return customerHistoryTimeValue(right.last_message_at) - customerHistoryTimeValue(left.last_message_at); + })[0] || null; + const historyStatusText = customerHistoryPending(customer.customer_id) + ? 'Обновляем ленту клиента из backend...' + : state.customers.historyErrorsById[customer.customer_id] + ? 'Показываем локальную историю, пока backend недоступен.' + : (latestEvent ? `Последнее событие: ${latestEvent.title}` : 'События появятся после первого обращения'); const summaryCards = [ - renderSummaryCard('Контакты', String(interactions.length + threads.length + liveCalls.length), 'Все точки касания клиента'), + renderSummaryCard( + 'Контакты', + String(Number.isFinite(Number(historySummary?.contact_points)) ? Number(historySummary.contact_points) : (interactions.length + threads.length + liveCalls.length)), + 'Все точки касания клиента', + ), renderSummaryCard('Открытые кейсы', String(openCases), openCases ? 'Требуют внимания оператора' : 'Новых действий нет'), renderSummaryCard( 'Каналы', @@ -860,8 +1007,8 @@ function customerSpotlightMarkup(customer, options = {}) { ), renderSummaryCard( 'Последний контакт', - latestEvent ? formatIsoShort(latestEvent.timestamp) : '—', - latestEvent ? latestEvent.title : 'Активность ещё не зафиксирована', + historySummary?.latest_event_at ? formatIsoShort(historySummary.latest_event_at) : (latestEvent ? formatIsoShort(latestEvent.timestamp) : '—'), + historySummary?.latest_event_title || latestEvent?.title || 'Активность ещё не зафиксирована', ), ].join(''); @@ -908,7 +1055,7 @@ function customerSpotlightMarkup(customer, options = {}) {
-${escapeHtml(item.subtitle)}
+ ${item.metaLines.map((line) => ``).join('')} + +Новые задачи, мои диалоги, AI handoff, эскалации и живые звонки в одном месте.
+