Improve operator CRM flows and UI

This commit is contained in:
Yera All
2026-04-03 23:47:34 +05:00
parent 8e60ce0f39
commit 78918a38fd
10 changed files with 1446 additions and 118 deletions
+16
View File
@@ -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 {
+483 -10
View File
@@ -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()
+30
View File
@@ -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)
+60 -3
View File
@@ -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
View File
@@ -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)
+3 -2
View File
@@ -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
+4 -3
View File
@@ -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
+601 -86
View File
@@ -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 = {}) {
<div class="customer-profile-meta-grid">
<article class="customer-meta-card">
<div class="customer-meta-label">Основной номер</div>
<div class="customer-meta-value">${escapeHtml(phones[0] || 'Не указан')}</div>
<div class="customer-meta-value">${escapeHtml(historySummary?.primary_phone || phones[0] || 'Не указан')}</div>
<div class="customer-meta-note">${phones.length > 1 ? `+${phones.length - 1} дополнительных номера` : 'Один контактный номер'}</div>
</article>
<article class="customer-meta-card">
@@ -942,7 +1089,7 @@ function customerSpotlightMarkup(customer, options = {}) {
<div class="customer-profile-kicker">Единая история клиента</div>
<h3>Все контакты в одной ленте</h3>
</div>
<div class="customer-history-caption">${escapeHtml(latestEvent ? `Последнее событие: ${latestEvent.title}` : 'События появятся после первого обращения')}</div>
<div class="customer-history-caption">${escapeHtml(historyStatusText)}</div>
</div>
<div class="customer-history-list">
${historyMarkup}
@@ -994,10 +1141,13 @@ function renderCustomerProfilePage() {
if (title) {
title.textContent = customer.display_name || 'Профиль клиента';
}
const historyPayload = customerHistoryPayload(customer.customer_id);
ensureCustomerHistoryLoaded(customer.customer_id).catch(() => {});
if (hint) {
const phones = customerPhones(customer);
hint.textContent = phones.length
? `Клиент ${customer.customer_id} • основной номер ${phones[0]}`
const primaryPhone = historyPayload?.summary?.primary_phone || phones[0] || '';
hint.textContent = primaryPhone
? `Клиент ${customer.customer_id} • основной номер ${primaryPhone}`
: `Клиент ${customer.customer_id} • омниканальный профиль`;
}
@@ -2037,6 +2187,7 @@ function applyTelegramCollections(threads, options = {}) {
}
renderTelegramWorkspace();
renderCustomerSpotlight();
renderUnifiedInbox();
}
async function loadTelegramThreadMessages(threadId = state.telegram.selectedThreadId, logResult = false) {
@@ -2588,6 +2739,9 @@ function renderWhatsappMessagesTimeline() {
}
function renderWhatsappWorkspace() {
if (!featureEnabled('whatsapp')) {
return;
}
ensureWhatsappSelection();
renderWhatsappChatList();
renderWhatsappContextPanel();
@@ -2634,6 +2788,9 @@ function renderWhatsappWorkspace() {
}
async function loadWhatsappThreadMessages(threadId = state.whatsapp.selectedChatId, logResult = false) {
if (!featureEnabled('whatsapp')) {
return [];
}
if (!threadId) {
state.whatsapp.selectedThreadSummary = null;
renderWhatsappWorkspace();
@@ -2655,6 +2812,9 @@ async function loadWhatsappThreadMessages(threadId = state.whatsapp.selectedChat
}
async function loadWhatsappThreadSummary(threadId = state.whatsapp.selectedChatId, options = {}) {
if (!featureEnabled('whatsapp')) {
return null;
}
const { logResult = false } = options;
if (!threadId || state.whatsapp.mode !== 'live') {
state.whatsapp.selectedThreadSummary = null;
@@ -2692,6 +2852,9 @@ async function loadWhatsappThreadSummary(threadId = state.whatsapp.selectedChatI
}
async function loadWhatsappThreads(logResult = true, options = {}) {
if (!featureEnabled('whatsapp')) {
return [];
}
const {
preserveSelection = true,
preserveOnError = false,
@@ -2912,10 +3075,11 @@ async function sendWhatsappMessage() {
}
function startWhatsappPolling() {
if (state.whatsapp.pollTimer) {
window.clearInterval(state.whatsapp.pollTimer);
state.whatsapp.pollTimer = null;
if (!featureEnabled('whatsapp')) {
stopWhatsappPolling();
return;
}
stopWhatsappPolling();
state.whatsapp.pollTimer = window.setInterval(() => {
loadWhatsappThreads(false, {
preserveSelection: true,
@@ -2926,6 +3090,9 @@ function startWhatsappPolling() {
}
function handleWhatsappUiAction(event) {
if (!featureEnabled('whatsapp')) {
return;
}
const action = event.currentTarget.dataset.whatsappUiAction || 'неизвестно';
if (action === 'chats') {
return;
@@ -3034,8 +3201,8 @@ function operatorHashState(hash = window.location.hash) {
if (raw === 'whatsapp-page' || raw === 'whatsapp') {
return { view: 'whatsapp', anchor: '', customerId: '' };
}
if (raw === 'voice-debug' || raw === 'voice-debug-page') {
return { view: 'voice-debug', anchor: '', customerId: '' };
if (raw === 'calls' || raw === 'calls-page' || raw === 'voice-debug' || raw === 'voice-debug-page') {
return { view: 'calls', anchor: '', customerId: '' };
}
if (raw === 'interactions' || raw === 'integrations') {
return { view: 'workspace', anchor: raw, customerId: '' };
@@ -3058,12 +3225,12 @@ function applyOperatorViewFromHash(options = {}) {
const { scroll = false } = options;
let { view, anchor, customerId } = operatorHashState();
const viewLink = document.querySelector(`[data-operator-view-link="${view}"]`);
if (viewLink && viewLink.style.display === 'none') {
if (!isOperatorViewEnabled(view) || (viewLink && (viewLink.hidden || viewLink.style.display === 'none'))) {
view = 'workspace';
anchor = '';
if (window.location.hash === '#voice-debug' || window.location.hash === '#voice-debug-page') {
window.history.replaceState(null, '', '#workspace');
}
window.history.replaceState(null, '', '#workspace');
} else if (view === 'calls' && (window.location.hash === '#voice-debug' || window.location.hash === '#voice-debug-page')) {
window.history.replaceState(null, '', '#calls');
}
if (customerId) {
state.customers.selectedCustomerId = customerId;
@@ -3198,6 +3365,17 @@ async function checkGateway() {
}
}
async function loadOperatorConfig() {
try {
const response = await fetch('/operator/config');
const data = await response.json();
state.features.whatsapp = Boolean(data?.features?.whatsapp);
} catch {
state.features.whatsapp = false;
}
syncWhatsappFeatureVisibility();
}
async function loadOidcConfig() {
try {
const response = await fetch('/proxy/auth/auth/oidc/config');
@@ -3304,6 +3482,7 @@ async function searchCustomers() {
state.customers.page = 1;
state.customers.selectedCustomerId = '';
renderCustomerList();
renderUnifiedInbox();
return;
}
if (!$('interactionCustomerId').value.trim()) {
@@ -3314,11 +3493,13 @@ async function searchCustomers() {
const selectedExists = data.some((item) => item.customer_id === state.customers.selectedCustomerId);
state.customers.selectedCustomerId = selectedExists ? state.customers.selectedCustomerId : data[0].customer_id;
renderCustomerList();
renderUnifiedInbox();
} catch (err) {
state.customers.items = [];
state.customers.page = 1;
state.customers.selectedCustomerId = '';
renderCustomerList();
renderUnifiedInbox();
log('Не удалось загрузить клиентов', { error: err.message });
}
}
@@ -3413,6 +3594,321 @@ function renderInteractionBoard(items) {
`;
}
function unifiedInboxSortValue(...values) {
for (const value of values) {
const parsed = new Date(value || '').getTime();
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return 0;
}
function unifiedInboxInteractionBucket(item) {
if (!item || item.status === 'closed') {
return '';
}
if (item.status === 'escalated') {
return 'escalated';
}
if (String(item.assigned_to || '').trim() === String(state.user || '').trim()) {
return 'mine';
}
if (item.status === 'new' || !String(item.assigned_to || '').trim()) {
return 'new';
}
return '';
}
function unifiedInboxTelegramBucket(thread) {
if (!thread || thread.status === 'closed') {
return '';
}
if (['handoff_required', 'human_owned'].includes(String(thread.ai_state || '').trim())) {
return 'ai';
}
if (String(thread.claimed_by_user || '').trim() === String(state.user || '').trim()) {
return 'mine';
}
return 'new';
}
function renderUnifiedInboxBadge(label, className = '') {
if (!label) {
return '';
}
return `<span class="micro-badge ${className}">${escapeHtml(label)}</span>`;
}
function unifiedInboxActionLabel(item) {
if (item.kind === 'telegram') {
return 'Открыть чат';
}
if (item.kind === 'call') {
return 'Открыть звонок';
}
return 'К обращению';
}
function buildUnifiedInboxInteractionItem(item) {
const bucket = unifiedInboxInteractionBucket(item);
if (!bucket) {
return null;
}
const badges = [
renderUnifiedInboxBadge(channelLabel(item.channel), 'channel'),
item.queue_id ? renderUnifiedInboxBadge(item.queue_id, 'queue') : '',
item.assigned_to ? renderUnifiedInboxBadge(item.assigned_to, 'owner') : '',
].filter(Boolean);
return {
key: `interaction:${item.interaction_id}`,
kind: 'interaction',
bucket,
sortValue: unifiedInboxSortValue(item.updated_at, item.created_at),
title: item.subject || 'Обращение без темы',
subtitle: item.interaction_id,
badges,
metaLines: [
`Клиент: ${customerDisplayName(item.customer_id || '')}`,
`Статус: ${statusMeta(item.status).label}`,
`Обновлено: ${formatIsoShort(item.updated_at || item.created_at)}`,
],
customerId: item.customer_id || '',
interactionId: item.interaction_id,
};
}
function buildUnifiedInboxTelegramItem(thread) {
const bucket = unifiedInboxTelegramBucket(thread);
if (!bucket) {
return null;
}
const customerId = customerIdForInteractionId(thread.interaction_id);
const aiMeta = telegramThreadAiStateMeta(thread);
const unreadCount = telegramThreadUnreadCount(thread);
const badges = [
renderUnifiedInboxBadge('Telegram', 'channel'),
unreadCount > 0 ? renderUnifiedInboxBadge(`${unreadCount} new`, 'queue') : '',
aiMeta ? renderUnifiedInboxBadge(aiMeta.label, aiMeta.tone) : '',
thread.claimed_by_user ? renderUnifiedInboxBadge(thread.claimed_by_user, 'owner') : '',
].filter(Boolean);
return {
key: `telegram:${thread.thread_id}`,
kind: 'telegram',
bucket,
sortValue: unifiedInboxSortValue(thread.last_message_at, thread.updated_at, thread.created_at),
title: telegramThreadDisplayName(thread),
subtitle: telegramThreadSummary(thread),
badges,
metaLines: [
`Клиент: ${customerDisplayName(customerId)}`,
thread.last_message_preview ? `Последнее: ${thread.last_message_preview}` : 'Последнее сообщение пока не загружено',
`Статус: ${telegramThreadHeaderPresence(thread)}`,
],
customerId,
interactionId: thread.interaction_id || '',
threadId: thread.thread_id,
};
}
function buildUnifiedInboxCallItem(item) {
const aiMeta = voiceAiStatusMeta(item);
const interaction = interactionById(item.interaction_id || '');
const customerId = interaction?.customer_id || '';
const caller = item.caller_name || item.caller_number || item.call_id || 'Неизвестный абонент';
const badges = [
renderUnifiedInboxBadge('Голос', 'channel'),
renderUnifiedInboxBadge(telephonyLabel(item.telephony_status), 'assignee'),
aiMeta ? renderUnifiedInboxBadge(aiMeta.label, aiMeta.className) : '',
item.claimed_by_user ? renderUnifiedInboxBadge(item.claimed_by_user, 'owner') : '',
].filter(Boolean);
return {
key: `call:${item.call_id}`,
kind: 'call',
bucket: 'calls',
sortValue: unifiedInboxSortValue(item.started_at, item.updated_at),
title: caller,
subtitle: item.call_id,
badges,
metaLines: [
`Клиент: ${customerDisplayName(customerId)}`,
`Обращение: ${item.interaction_id || 'не найдено'}`,
`Начат: ${formatIsoShort(item.started_at || item.connected_at)}`,
item.ai_handoff_reason ? `AI: ${item.ai_handoff_reason}` : `Статус: ${telephonyLabel(item.telephony_status)}`,
],
customerId,
interactionId: item.interaction_id || '',
callId: item.call_id,
};
}
function buildUnifiedInboxCollections() {
const collections = {
new: [],
mine: [],
ai: [],
escalated: [],
calls: [],
};
const representedInteractionIds = new Set();
state.telegram.threads.forEach((thread) => {
if (thread?.interaction_id && thread.status !== 'closed') {
representedInteractionIds.add(thread.interaction_id);
}
const item = buildUnifiedInboxTelegramItem(thread);
if (item) {
collections[item.bucket].push(item);
}
});
state.liveCalls.items.forEach((call) => {
if (call?.interaction_id) {
representedInteractionIds.add(call.interaction_id);
}
const item = buildUnifiedInboxCallItem(call);
if (item) {
collections.calls.push(item);
}
});
state.interactions.forEach((item) => {
if (representedInteractionIds.has(item.interaction_id)) {
return;
}
const inboxItem = buildUnifiedInboxInteractionItem(item);
if (inboxItem) {
collections[inboxItem.bucket].push(inboxItem);
}
});
Object.values(collections).forEach((items) => {
items.sort((left, right) => right.sortValue - left.sortValue);
});
return collections;
}
function renderUnifiedInboxCard(item) {
const actions = [
`<button type="button" data-inbox-action="open" data-inbox-kind="${escapeHtml(item.kind)}" data-thread-id="${escapeHtml(item.threadId || '')}" data-interaction-id="${escapeHtml(item.interactionId || '')}" data-call-id="${escapeHtml(item.callId || '')}" data-customer-id="${escapeHtml(item.customerId || '')}">${escapeHtml(unifiedInboxActionLabel(item))}</button>`,
item.customerId ? `<button type="button" data-inbox-action="customer" data-customer-id="${escapeHtml(item.customerId)}">К клиенту</button>` : '',
].filter(Boolean).join('');
return `
<article class="pipeline-card unified-inbox-card${item.bucket === 'escalated' ? ' escalated' : ''}">
<div class="card-badges">${item.badges.join('')}</div>
<div class="row-item-head">
<h3 class="card-title">${escapeHtml(item.title)}</h3>
</div>
<p class="card-subtitle">${escapeHtml(item.subtitle)}</p>
${item.metaLines.map((line) => `<p class="card-meta-line">${escapeHtml(line)}</p>`).join('')}
<div class="card-divider"></div>
<div class="row-actions">
${actions}
</div>
</article>
`;
}
function renderUnifiedInbox() {
const board = $('unifiedInboxBoard');
const summary = $('unifiedInboxSummary');
if (!board || !summary) {
return;
}
const collections = buildUnifiedInboxCollections();
const total = Object.values(collections).reduce((count, items) => count + items.length, 0);
summary.innerHTML = [
renderSummaryCard('В очереди', String(total), 'Все задачи без дублей между каналами'),
renderSummaryCard('Новые', String(collections.new.length), 'Свободные обращения и чаты'),
renderSummaryCard('Мои', String(collections.mine.length), 'Закреплено за текущим пользователем'),
renderSummaryCard('AI handoff', String(collections.ai.length), 'Диалоги, где AI позвал человека'),
renderSummaryCard('Звонки', String(collections.calls.length), 'Активные голосовые разговоры'),
renderSummaryCard('Эскалации', String(collections.escalated.length), 'Очередь второй линии и спорные кейсы'),
].join('');
board.innerHTML = `
<div class="pipeline-board unified-inbox-board">
${UNIFIED_INBOX_COLUMNS.map((column) => `
<section class="pipeline-column">
<div class="pipeline-head">
<div class="pipeline-title">${escapeHtml(column.title)}</div>
<div class="pipeline-count">${collections[column.key].length}</div>
</div>
<div class="pipeline-stack">
${collections[column.key].length
? collections[column.key].map((item) => renderUnifiedInboxCard(item)).join('')
: `<div class="empty-state">${escapeHtml(column.emptyMessage)}</div>`}
</div>
</section>
`).join('')}
</div>
`;
}
function focusLiveCall(callId) {
if (!callId) {
return;
}
const select = $('liveCallIdSelect');
if (select) {
select.value = callId;
}
state.liveCalls.selectedCallId = callId;
renderLiveCallsTable(state.liveCalls.items, state.liveCalls.recentItems);
syncLiveCallActionButtons();
const activeCall = state.liveCalls.items.find((item) => item.call_id === callId) || null;
ensureVoiceAiSummaryLoaded(activeCall);
}
async function openUnifiedInboxItem(button) {
const kind = button.dataset.inboxKind || '';
const interactionId = button.dataset.interactionId || '';
const threadId = button.dataset.threadId || '';
const callId = button.dataset.callId || '';
const customerId = button.dataset.customerId || '';
if (kind === 'telegram') {
window.location.hash = '#telegram-page';
await selectTelegramThread(threadId);
return;
}
if (kind === 'call') {
focusLiveCall(callId);
window.location.hash = '#calls';
return;
}
if (customerId) {
$('interactionCustomerId').value = customerId;
}
window.location.hash = '#interactions';
log('Открыто обращение из единой очереди', { interaction_id: interactionId, customer_id: customerId || '-' });
}
function handleUnifiedInboxClick(event) {
const button = event.target.closest('[data-inbox-action]');
if (!button) {
return;
}
if (button.dataset.inboxAction === 'customer') {
openCustomerProfile(button.dataset.customerId || '');
return;
}
openUnifiedInboxItem(button).catch((err) => {
log('Не удалось открыть элемент единой очереди', { error: err.message });
});
}
async function refreshUnifiedInbox() {
await Promise.all([
searchCustomers(),
loadInteractions(),
loadTelegramThreads(false, { preserveSelection: true, preserveOnError: true }),
loadLiveCalls(false, { preserveStateOnError: true }),
selectedCustomer() ? ensureCustomerHistoryLoaded(selectedCustomer().customer_id, { force: true }) : Promise.resolve(null),
]);
log('Единая очередь обновлена', { total: Object.values(buildUnifiedInboxCollections()).reduce((count, items) => count + items.length, 0) });
}
function telephonyLabel(value) {
return LIVE_TELEPHONY_LABELS[value] || value || 'неизвестно';
}
@@ -4959,6 +5455,7 @@ function applyLiveCallCollections(activeItems, recentItems, options = {}) {
renderLiveCallsTable(state.liveCalls.items, state.liveCalls.recentItems);
syncBrowserPhonePopupLifecycle();
renderCustomerSpotlight();
renderUnifiedInbox();
}
function applyClaimedLiveCall(item) {
@@ -5059,14 +5556,17 @@ async function loadInteractions() {
if (!data.length) {
setEmptyBlock('interactionTable', 'Обращений пока нет. После подготовки демо они появятся автоматически.');
renderCustomerSpotlight();
renderUnifiedInbox();
return;
}
$('interactionTable').innerHTML = renderInteractionBoard(data);
renderCustomerSpotlight();
renderUnifiedInbox();
} catch (err) {
state.interactions = [];
setEmptyBlock('interactionTable', 'Не удалось загрузить обращения.');
renderCustomerSpotlight();
renderUnifiedInbox();
log('Не удалось загрузить обращения', { error: err.message });
}
}
@@ -5171,13 +5671,18 @@ function wire() {
$('loginBtn').addEventListener('click', login);
$('corporateLoginBtn').addEventListener('click', startCorporateLogin);
$('refreshBtn').addEventListener('click', async () => {
await searchCustomers();
await loadInteractions();
await loadTelegramThreads(false, { preserveSelection: true, preserveOnError: true });
await loadWhatsappThreads(false, { preserveSelection: true, preserveOnError: true });
await loadLiveCalls(false);
await refreshUnifiedInbox();
if (featureEnabled('whatsapp')) {
await loadWhatsappThreads(false, { preserveSelection: true, preserveOnError: true });
}
log('Данные на экране обновлены');
});
$('refreshUnifiedInboxBtn').addEventListener('click', () => {
refreshUnifiedInbox().catch((err) => {
log('Не удалось обновить единую очередь', { error: err.message });
});
});
$('unifiedInboxBoard').addEventListener('click', handleUnifiedInboxClick);
$('toggleCustomerLeadFormBtn').addEventListener('click', () => toggleCustomerLeadForm());
$('customerCancelBtn').addEventListener('click', () => toggleCustomerLeadForm(false));
$('createCustomerBtn').addEventListener('click', createCustomer);
@@ -5289,54 +5794,56 @@ function wire() {
event.preventDefault();
sendTelegramReply();
});
$('whatsappSearchInput').addEventListener('input', (event) => {
state.whatsapp.searchQuery = event.target.value || '';
renderWhatsappWorkspace();
});
$('whatsappFilterBar').addEventListener('click', (event) => {
const button = event.target.closest('[data-whatsapp-filter]');
if (!button) {
return;
}
setWhatsappFilter(button.dataset.whatsappFilter || 'all');
});
$('whatsappClaimBtn').addEventListener('click', () => {
claimWhatsappThread().catch(() => {});
});
$('whatsappReturnToAiBtn').addEventListener('click', returnWhatsappThreadToAi);
$('whatsappChatList').addEventListener('click', (event) => {
const button = event.target.closest('[data-whatsapp-chat-id]');
if (!button) {
return;
}
selectWhatsappChat(button.dataset.whatsappChatId || '').catch(() => {});
});
$('whatsappChatList').addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
const button = event.target.closest('[data-whatsapp-chat-id]');
if (!button) {
return;
}
event.preventDefault();
selectWhatsappChat(button.dataset.whatsappChatId || '').catch(() => {});
});
document.querySelectorAll('[data-whatsapp-ui-action]').forEach((button) => {
button.addEventListener('click', handleWhatsappUiAction);
});
$('whatsappSendBtn').addEventListener('click', sendWhatsappMessage);
$('whatsappComposerInput').addEventListener('input', (event) => {
state.whatsapp.composerText = event.target.value || '';
syncWhatsappComposerUi();
});
$('whatsappComposerInput').addEventListener('keydown', (event) => {
if (event.key !== 'Enter' || event.shiftKey) {
return;
}
event.preventDefault();
sendWhatsappMessage();
});
if (featureEnabled('whatsapp')) {
$('whatsappSearchInput').addEventListener('input', (event) => {
state.whatsapp.searchQuery = event.target.value || '';
renderWhatsappWorkspace();
});
$('whatsappFilterBar').addEventListener('click', (event) => {
const button = event.target.closest('[data-whatsapp-filter]');
if (!button) {
return;
}
setWhatsappFilter(button.dataset.whatsappFilter || 'all');
});
$('whatsappClaimBtn').addEventListener('click', () => {
claimWhatsappThread().catch(() => {});
});
$('whatsappReturnToAiBtn').addEventListener('click', returnWhatsappThreadToAi);
$('whatsappChatList').addEventListener('click', (event) => {
const button = event.target.closest('[data-whatsapp-chat-id]');
if (!button) {
return;
}
selectWhatsappChat(button.dataset.whatsappChatId || '').catch(() => {});
});
$('whatsappChatList').addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
const button = event.target.closest('[data-whatsapp-chat-id]');
if (!button) {
return;
}
event.preventDefault();
selectWhatsappChat(button.dataset.whatsappChatId || '').catch(() => {});
});
document.querySelectorAll('[data-whatsapp-ui-action]').forEach((button) => {
button.addEventListener('click', handleWhatsappUiAction);
});
$('whatsappSendBtn').addEventListener('click', sendWhatsappMessage);
$('whatsappComposerInput').addEventListener('input', (event) => {
state.whatsapp.composerText = event.target.value || '';
syncWhatsappComposerUi();
});
$('whatsappComposerInput').addEventListener('keydown', (event) => {
if (event.key !== 'Enter' || event.shiftKey) {
return;
}
event.preventDefault();
sendWhatsappMessage();
});
}
$('createInteractionBtn').addEventListener('click', createInteraction);
$('loadInteractionsBtn').addEventListener('click', loadInteractions);
$('loadLiveCallsBtn').addEventListener('click', () => loadLiveCalls(true));
@@ -5383,6 +5890,7 @@ async function init() {
window.location.href = '/';
return;
}
await loadOperatorConfig();
wire();
applyOperatorViewFromHash();
$('defaultAssignee').value = DEMO_ASSIGNEE;
@@ -5390,7 +5898,10 @@ async function init() {
$('telegramEscalationQueue').value = DEMO_QUEUE;
updateSessionInfo();
syncLiveCallActionButtons();
renderWhatsappWorkspace();
renderUnifiedInbox();
if (featureEnabled('whatsapp')) {
renderWhatsappWorkspace();
}
renderTelegramWorkspace();
await Promise.all([checkGateway(), loadOidcConfig()]);
await fetchBrowserSoftphoneConfig();
@@ -5399,11 +5910,15 @@ async function init() {
await searchCustomers();
await loadInteractions();
await loadTelegramThreads(false, { preserveSelection: true, preserveOnError: true });
await loadWhatsappThreads(false, { preserveSelection: true, preserveOnError: true });
if (featureEnabled('whatsapp')) {
await loadWhatsappThreads(false, { preserveSelection: true, preserveOnError: true });
}
await loadLiveCalls(false);
startLiveCallsPolling();
startTelegramPolling();
startWhatsappPolling();
if (featureEnabled('whatsapp')) {
startWhatsappPolling();
}
log('Экран готов к показу', { assignee: DEMO_ASSIGNEE, queue: DEMO_QUEUE });
}
+24 -12
View File
@@ -8,7 +8,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700;800&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/operator/assets/styles.css?v=track17-whatsapp-live1" />
<link rel="stylesheet" href="/operator/assets/styles.css?v=track19-unified-inbox1" />
</head>
<body>
<div class="app-shell">
@@ -33,8 +33,8 @@
<a class="nav-link active" data-operator-view-link="workspace" href="#workspace"><span class="nav-bullet"></span>Рабочий стол</a>
<a class="nav-link" data-operator-view-link="customers" href="#customers-page"><span class="nav-bullet"></span>Клиенты</a>
<a class="nav-link" data-operator-view-link="telegram" href="#telegram-page"><span class="nav-bullet"></span>Telegram</a>
<a class="nav-link" data-operator-view-link="whatsapp" href="#whatsapp-page"><span class="nav-bullet"></span>WhatsApp</a>
<a class="nav-link" data-operator-view-link="voice-debug" data-roles="admin,operator" href="#voice-debug"><span class="nav-bullet"></span>Голосовая диагностика</a>
<a class="nav-link" data-operator-view-link="whatsapp" data-feature="whatsapp" href="#whatsapp-page" hidden><span class="nav-bullet"></span>WhatsApp</a>
<a class="nav-link" data-operator-view-link="calls" data-roles="admin,operator" href="#calls"><span class="nav-bullet"></span>Звонки</a>
</div>
</nav>
@@ -137,13 +137,25 @@
</section>
<section class="grid">
<article class="panel reveal panel-span-full unified-inbox-panel" id="unifiedInbox">
<div class="unified-inbox-head">
<div>
<h2>Единая очередь</h2>
<p class="hint">Новые задачи, мои диалоги, AI handoff, эскалации и живые звонки в одном месте.</p>
</div>
<button id="refreshUnifiedInboxBtn" class="btn ghost" type="button">Обновить очередь</button>
</div>
<div id="unifiedInboxSummary" class="summary-grid"></div>
<div id="unifiedInboxBoard" class="unified-inbox-scroll"></div>
</article>
<article class="panel reveal panel-span-full" id="interactions">
<h2>Обращения</h2>
<div class="inline-form">
<select id="interactionChannel">
<option value="voice">Голос</option>
<option value="telegram">Telegram</option>
<option value="whatsapp">WhatsApp</option>
<option value="whatsapp" data-feature-option="whatsapp" hidden disabled>WhatsApp</option>
<option value="webchat">Веб-чат</option>
<option value="email">Электронная почта</option>
</select>
@@ -362,7 +374,7 @@
</article>
</section>
<section id="whatsappView" class="operator-view hidden" data-operator-view="whatsapp">
<section id="whatsappView" class="operator-view hidden" data-operator-view="whatsapp" data-feature="whatsapp" hidden>
<article class="panel reveal panel-span-full whatsapp-panel" id="whatsappWorkspace">
<div class="whatsapp-shell">
<aside class="whatsapp-nav-rail" aria-label="WhatsApp navigation">
@@ -535,15 +547,15 @@
</article>
</section>
<section id="voiceDebugView" class="operator-view hidden" data-operator-view="voice-debug">
<section id="callsView" class="operator-view hidden" data-operator-view="calls">
<section class="page-hero reveal customers-page-hero">
<h1>Голосовая диагностика</h1>
<p class="hint">Технический экран живых и недавних звонков с трассировкой действий. Основной операторский сценарий остаётся во всплывающем окне звонка.</p>
<h1>Звонки</h1>
<p class="hint">Живые и недавние звонки, AI-handoff и журнал действий в одном месте. Основной take over остаётся во всплывающем окне браузерного телефона.</p>
</section>
<article class="panel reveal panel-span-full technical-panel" id="liveCalls">
<h2>Голосовой мониторинг</h2>
<p class="hint">Этот экран нужен только для диагностики. Для обработки звонка оператор использует всплывающее окно звонка в браузере.</p>
<h2>Мониторинг звонков</h2>
<p class="hint">Здесь видны текущий статус, недавние вызовы и трассировка действий. Ответ, перевод и завершение звонка остаются во всплывающем окне.</p>
<div class="inline-form compact">
<button id="loadLiveCallsBtn" class="btn ghost">Обновить звонки</button>
<button id="loadLiveCallActionsBtn" class="btn ghost">Действия по звонку</button>
@@ -551,7 +563,7 @@
<div class="inline-form compact live-call-inspector">
<select id="liveCallIdSelect"></select>
</div>
<p class="hint">Здесь остаётся технический слой живых и недавних звонков, а управление перенесено во всплывающее окно.</p>
<p class="hint">Здесь остаются живые и недавние звонки, а оперативные действия по ним вынесены во всплывающее окно.</p>
<p class="hint" id="liveCallStatusHint">Пока нет активных звонков.</p>
<div id="liveCallsTable" class="table"></div>
<details class="details-block">
@@ -603,6 +615,6 @@
<script src="/operator/assets/access-guards.js?v=track16-voice-transcript1"></script>
<script src="/operator/assets/sip-0.21.2.min.js?v=track16-voice-transcript1"></script>
<script src="/operator/assets/app.js?v=track36-operator-ru-polish"></script>
<script src="/operator/assets/app.js?v=track38-unified-inbox1"></script>
</body>
</html>
+32 -1
View File
@@ -2968,6 +2968,32 @@ textarea::placeholder {
align-items: start;
}
.unified-inbox-panel {
display: grid;
gap: 16px;
}
.unified-inbox-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.unified-inbox-scroll {
overflow-x: auto;
padding-bottom: 4px;
}
.unified-inbox-board {
grid-template-columns: repeat(5, minmax(240px, 1fr));
min-width: 1320px;
}
.unified-inbox-card {
min-height: 248px;
}
.live-calls-board {
grid-template-columns: repeat(2, minmax(280px, 1fr));
}
@@ -4967,7 +4993,8 @@ body.analytics-drilldown-open {
.analytics-compare-head,
.analytics-subhead,
.analytics-chart-meta,
.analytics-narrative-meta {
.analytics-narrative-meta,
.unified-inbox-head {
flex-direction: column;
align-items: stretch;
}
@@ -4985,6 +5012,10 @@ body.analytics-drilldown-open {
grid-template-columns: 1fr;
}
.unified-inbox-board {
min-width: 0;
}
.customer-spotlight-grid,
.customer-spotlight-grid.page,
.customer-profile-summary-grid,