sales fix
This commit is contained in:
@@ -14,6 +14,7 @@ def test_local_stack_gateway_env_contains_expected_urls():
|
||||
assert env["WEBCHAT_ADAPTER_SERVICE_URL"] == "http://127.0.0.1:8011"
|
||||
assert env["EMAIL_ADAPTER_SERVICE_URL"] == "http://127.0.0.1:8012"
|
||||
assert env["AI_VOICE_RUNTIME_SERVICE_URL"] == "http://127.0.0.1:8018"
|
||||
assert env["SALES_SERVICE_URL"] == "http://127.0.0.1:8020"
|
||||
|
||||
|
||||
def test_local_stack_service_specs_include_ai_voice_runtime():
|
||||
@@ -22,6 +23,7 @@ def test_local_stack_service_specs_include_ai_voice_runtime():
|
||||
assert "ai" in names
|
||||
assert "ai-voice-runtime" in names
|
||||
assert "asterisk-bridge" in names
|
||||
assert "sales" in names
|
||||
|
||||
|
||||
def test_local_stack_manifest_payload_has_core_fields(tmp_path: Path):
|
||||
|
||||
@@ -28,6 +28,14 @@ def test_login_ui_route_exists():
|
||||
assert "login/assets/app.js" in response.text
|
||||
|
||||
|
||||
def test_sales_ui_route_exists():
|
||||
client = TestClient(app)
|
||||
response = client.get("/sales")
|
||||
assert response.status_code == 200
|
||||
assert "sales/assets/app.js" in response.text
|
||||
assert 'id="sendTelegramReplyBtn"' in response.text
|
||||
|
||||
|
||||
def test_legal_routes_exist_for_meta_review():
|
||||
client = TestClient(app)
|
||||
|
||||
@@ -60,6 +68,7 @@ def test_contracts_shape():
|
||||
assert "stage_15" in payload
|
||||
assert "stage_16" in payload
|
||||
assert "stage_17" in payload
|
||||
assert "stage_18" in payload
|
||||
assert "/integrations/webchat/messages" in payload["stage_2"]
|
||||
assert "/integrations/email/messages" in payload["stage_2"]
|
||||
assert "/integrations/whatsapp/webhook" in payload["stage_2"]
|
||||
@@ -89,6 +98,10 @@ def test_contracts_shape():
|
||||
assert "/ai/analytics/drilldown" in payload["stage_16"]
|
||||
assert "/ai/analytics/sessions/*" in payload["stage_16"]
|
||||
assert "/asterisk/live-calls/*/ai-summary" in payload["stage_17"]
|
||||
assert "/api/v1/leads" in payload["stage_18"]
|
||||
assert "/api/v1/deals" in payload["stage_18"]
|
||||
assert "/api/v1/messages/inbound-webhook" in payload["stage_18"]
|
||||
assert "/api/v1/calls/inbound-webhook" in payload["stage_18"]
|
||||
|
||||
|
||||
def test_operator_ui_includes_favicon_link():
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import services.sales_service.app as sales_module
|
||||
|
||||
|
||||
def _headers() -> dict[str, str]:
|
||||
return {"X-User": "admin", "X-Role": "admin"}
|
||||
|
||||
|
||||
def _create_lead(client: TestClient) -> tuple[dict, str]:
|
||||
created = client.post(
|
||||
"/api/v1/leads",
|
||||
json={
|
||||
"source_type": "crm",
|
||||
"source_channel": "telegram",
|
||||
"full_name": "Test Buyer",
|
||||
"company_name": "Sales QA",
|
||||
"phone": "+77001234567",
|
||||
"email": "buyer@test.local",
|
||||
"lead_temperature": "hot",
|
||||
"lead_score": 82,
|
||||
"initial_need_summary": "Need a commercial offer and a callback.",
|
||||
"preferred_channel": "telegram",
|
||||
"assigned_agent_type": "text_ai",
|
||||
"status": "new_qualified_lead",
|
||||
"priority": 3,
|
||||
"title": "Lead: Test Buyer",
|
||||
},
|
||||
headers=_headers(),
|
||||
)
|
||||
assert created.status_code == 200
|
||||
lead = created.json()
|
||||
deals = client.get("/api/v1/deals", headers=_headers())
|
||||
assert deals.status_code == 200
|
||||
deal_id = next(item["deal_id"] for item in deals.json() if item["lead_id"] == lead["lead_id"])
|
||||
return lead, deal_id
|
||||
|
||||
|
||||
class _DummySyncResponse:
|
||||
def __init__(self, payload: dict[str, str]):
|
||||
self._payload = payload
|
||||
self.status_code = 200
|
||||
self.text = ""
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def json(self) -> dict[str, str]:
|
||||
return self._payload
|
||||
|
||||
|
||||
class _DummyHttpxClient:
|
||||
requests: list[dict] = []
|
||||
response_payload: dict[str, str] = {}
|
||||
|
||||
def __init__(self, timeout: float):
|
||||
self.timeout = timeout
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return None
|
||||
|
||||
def post(self, url: str, json: dict | None = None, headers: dict | None = None):
|
||||
self.__class__.requests.append(
|
||||
{
|
||||
"url": url,
|
||||
"json": json or {},
|
||||
"headers": headers or {},
|
||||
"timeout": self.timeout,
|
||||
}
|
||||
)
|
||||
return _DummySyncResponse(self.__class__.response_payload)
|
||||
|
||||
|
||||
def test_sales_lead_creation_auto_creates_deal_and_workspace():
|
||||
client = TestClient(sales_module.app)
|
||||
|
||||
lead, deal_id = _create_lead(client)
|
||||
workspace = client.get(f"/api/v1/deals/{deal_id}/workspace", headers=_headers())
|
||||
|
||||
assert workspace.status_code == 200
|
||||
payload = workspace.json()
|
||||
assert payload["lead"]["lead_id"] == lead["lead_id"]
|
||||
assert payload["deal"]["deal_id"] == deal_id
|
||||
assert payload["deal"]["preferred_channel"] == "telegram"
|
||||
assert payload["timeline"]
|
||||
|
||||
|
||||
def test_sales_bind_telegram_and_send_outbound_message(monkeypatch):
|
||||
client = TestClient(sales_module.app)
|
||||
_, deal_id = _create_lead(client)
|
||||
_DummyHttpxClient.requests = []
|
||||
_DummyHttpxClient.response_payload = {"ok": True}
|
||||
monkeypatch.setattr(sales_module.httpx, "Client", _DummyHttpxClient)
|
||||
monkeypatch.setenv("SALES_TELEGRAM_BRIDGE_ENABLED", "1")
|
||||
|
||||
created_communication = client.post(
|
||||
f"/api/v1/deals/{deal_id}/communications/text",
|
||||
json={
|
||||
"channel_type": "text",
|
||||
"direction": "outbound",
|
||||
"agent_type": "text_ai",
|
||||
"subject": "Telegram follow-up",
|
||||
},
|
||||
headers=_headers(),
|
||||
)
|
||||
assert created_communication.status_code == 200
|
||||
communication = created_communication.json()
|
||||
|
||||
bound = client.post(
|
||||
f"/api/v1/communications/{communication['communication_id']}/bind-external",
|
||||
json={
|
||||
"channel_provider": "telegram",
|
||||
"telegram_thread_id": "tg-thread-42",
|
||||
"telegram_chat_id": "tg-chat-42",
|
||||
},
|
||||
headers=_headers(),
|
||||
)
|
||||
assert bound.status_code == 200
|
||||
assert bound.json()["metadata"]["telegram_thread_id"] == "tg-thread-42"
|
||||
|
||||
outbound = client.post(
|
||||
"/api/v1/messages/outbound",
|
||||
json={
|
||||
"deal_id": deal_id,
|
||||
"communication_id": communication["communication_id"],
|
||||
"channel_provider": "telegram",
|
||||
"sender_type": "human",
|
||||
"sender_id": "admin",
|
||||
"body": "Please check the offer in Telegram.",
|
||||
"metadata": {"telegram_thread_id": "tg-thread-42"},
|
||||
},
|
||||
headers=_headers(),
|
||||
)
|
||||
assert outbound.status_code == 200
|
||||
assert outbound.json()["delivery_status"] == "sent"
|
||||
assert _DummyHttpxClient.requests
|
||||
assert _DummyHttpxClient.requests[0]["url"].endswith("/integrations/telegram/threads/tg-thread-42/messages")
|
||||
assert _DummyHttpxClient.requests[0]["json"]["text"] == "Please check the offer in Telegram."
|
||||
|
||||
switched = client.post(
|
||||
f"/api/v1/communications/{communication['communication_id']}/switch-channel",
|
||||
json={
|
||||
"to_channel": "voice",
|
||||
"reason_for_channel_switch": "Need to clarify details by phone",
|
||||
},
|
||||
headers=_headers(),
|
||||
)
|
||||
assert switched.status_code == 200
|
||||
|
||||
workspace = client.get(f"/api/v1/deals/{deal_id}/workspace", headers=_headers())
|
||||
assert workspace.status_code == 200
|
||||
payload = workspace.json()
|
||||
assert payload["deal"]["current_channel"] == "voice"
|
||||
assert payload["channel_switches"][0]["to_channel"] == "voice"
|
||||
|
||||
|
||||
def test_sales_inbound_call_bridges_voice_runtime(monkeypatch):
|
||||
client = TestClient(sales_module.app)
|
||||
_, deal_id = _create_lead(client)
|
||||
_DummyHttpxClient.requests = []
|
||||
_DummyHttpxClient.response_payload = {"voice_session_id": "avs_sales_case_01"}
|
||||
monkeypatch.setattr(sales_module.httpx, "Client", _DummyHttpxClient)
|
||||
monkeypatch.setenv("SALES_VOICE_RUNTIME_ENABLED", "1")
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/calls/inbound-webhook",
|
||||
json={
|
||||
"deal_id": deal_id,
|
||||
"phone_number": "+77001234567",
|
||||
"provider": "asterisk",
|
||||
"external_call_id": "call-sales-01",
|
||||
"subject": "Inbound sales call",
|
||||
},
|
||||
headers=_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert _DummyHttpxClient.requests
|
||||
assert _DummyHttpxClient.requests[0]["url"].endswith("/internal/voice-ai/sessions")
|
||||
assert _DummyHttpxClient.requests[0]["json"]["call_id"] == "call-sales-01"
|
||||
|
||||
workspace = client.get(f"/api/v1/deals/{deal_id}/workspace", headers=_headers())
|
||||
assert workspace.status_code == 200
|
||||
communication = workspace.json()["communications"][0]
|
||||
assert communication["channel_type"] == "voice"
|
||||
assert communication["metadata"]["voice_session_id"] == "avs_sales_case_01"
|
||||
|
||||
|
||||
def test_sales_internal_telegram_sync_auto_creates_workspace():
|
||||
client = TestClient(sales_module.app)
|
||||
|
||||
response = client.post(
|
||||
"/internal/sales-sync/telegram",
|
||||
json={
|
||||
"thread_id": "tg-thread-auto-01",
|
||||
"chat_id": "tg-chat-auto-01",
|
||||
"interaction_id": "int_tg_auto_01",
|
||||
"phone_number": "+77005550101",
|
||||
"display_name": "Telegram Prospect",
|
||||
"queue_id": "q_sales",
|
||||
"status": "new",
|
||||
"ai_state": "queued",
|
||||
"message_id": "msg_tg_auto_01",
|
||||
"external_message_id": "ext_tg_auto_01",
|
||||
"text": "Здравствуйте, нужен расчет и коммерческое предложение.",
|
||||
"direction": "inbound",
|
||||
"author_type": "customer",
|
||||
"author_id": "tg-user-01",
|
||||
"metadata": {"username": "sales_prospect"},
|
||||
},
|
||||
headers=_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
workspace = response.json()
|
||||
assert workspace["deal"]["current_channel"] == "telegram"
|
||||
assert workspace["deal"]["preferred_channel"] == "telegram"
|
||||
assert workspace["communications"][0]["channel_provider"] == "telegram"
|
||||
assert workspace["communications"][0]["metadata"]["telegram_thread_id"] == "tg-thread-auto-01"
|
||||
assert workspace["messages"][0]["external_message_id"] == "ext_tg_auto_01"
|
||||
|
||||
|
||||
def test_sales_internal_voice_sync_creates_call_and_transcript():
|
||||
client = TestClient(sales_module.app)
|
||||
|
||||
response = client.post(
|
||||
"/internal/sales-sync/voice",
|
||||
json={
|
||||
"call_id": "call-auto-voice-01",
|
||||
"interaction_id": "int_voice_auto_01",
|
||||
"queue_id": "q_voice_sales",
|
||||
"queue_code": "voice_sales",
|
||||
"caller_number": "+77005550202",
|
||||
"caller_name": "Voice Prospect",
|
||||
"voice_session_id": "avs_auto_01",
|
||||
"ai_session_id": "ais_auto_01",
|
||||
"ai_state": "completed",
|
||||
"telephony_status": "ended",
|
||||
"call_status": "completed",
|
||||
"started_at": "2026-05-08T10:00:00Z",
|
||||
"ended_at": "2026-05-08T10:03:00Z",
|
||||
"summary": "Клиент запросил прайс и условия подключения.",
|
||||
"transcript_text": "customer: Добрый день, нужен прайс.\nassistant: Подготовлю информацию.",
|
||||
"metadata": {"agent_profile": "voice_sales"},
|
||||
},
|
||||
headers=_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
workspace = response.json()
|
||||
assert workspace["deal"]["current_channel"] == "voice"
|
||||
assert workspace["communications"][0]["channel_type"] == "voice"
|
||||
assert workspace["communications"][0]["metadata"]["voice_session_id"] == "avs_auto_01"
|
||||
assert workspace["calls"][0]["external_call_id"] == "call-auto-voice-01"
|
||||
assert workspace["transcripts"][0]["transcript_text"].startswith("customer:")
|
||||
|
||||
|
||||
def test_sales_list_deals_query_matches_external_links():
|
||||
client = TestClient(sales_module.app)
|
||||
|
||||
voice_workspace = client.post(
|
||||
"/internal/sales-sync/voice",
|
||||
json={
|
||||
"call_id": "call-search-voice-01",
|
||||
"interaction_id": "int_search_voice_01",
|
||||
"caller_number": "+77005550303",
|
||||
"caller_name": "Voice Search Prospect",
|
||||
"voice_session_id": "avs_search_01",
|
||||
"call_status": "completed",
|
||||
},
|
||||
headers=_headers(),
|
||||
)
|
||||
assert voice_workspace.status_code == 200
|
||||
|
||||
telegram_workspace = client.post(
|
||||
"/internal/sales-sync/telegram",
|
||||
json={
|
||||
"thread_id": "tg-thread-search-01",
|
||||
"chat_id": "tg-chat-search-01",
|
||||
"interaction_id": "int_search_tg_01",
|
||||
"display_name": "Telegram Search Prospect",
|
||||
"text": "Need pricing details",
|
||||
},
|
||||
headers=_headers(),
|
||||
)
|
||||
assert telegram_workspace.status_code == 200
|
||||
|
||||
by_voice_session = client.get("/api/v1/deals?query=avs_search_01", headers=_headers())
|
||||
assert by_voice_session.status_code == 200
|
||||
assert any(item["deal_id"] == voice_workspace.json()["deal"]["deal_id"] for item in by_voice_session.json())
|
||||
|
||||
by_call_id = client.get("/api/v1/deals?query=call-search-voice-01", headers=_headers())
|
||||
assert by_call_id.status_code == 200
|
||||
assert any(item["deal_id"] == voice_workspace.json()["deal"]["deal_id"] for item in by_call_id.json())
|
||||
|
||||
by_thread_id = client.get("/api/v1/deals?query=tg-thread-search-01", headers=_headers())
|
||||
assert by_thread_id.status_code == 200
|
||||
assert any(item["deal_id"] == telegram_workspace.json()["deal"]["deal_id"] for item in by_thread_id.json())
|
||||
Reference in New Issue
Block a user