sales fix

This commit is contained in:
arys
2026-05-09 09:16:06 +05:00
parent d617b6908c
commit f53af1f7dc
35 changed files with 7495 additions and 6 deletions
+173
View File
@@ -83,6 +83,10 @@ def _interaction_service_url() -> str:
return os.getenv("INTERACTION_SERVICE_URL", "http://localhost:8004").rstrip("/")
def _sales_service_url() -> str:
return os.getenv("SALES_SERVICE_URL", "http://localhost:8020").rstrip("/")
def _asr_provider_name() -> str:
return os.getenv("AI_VOICE_ASR_PROVIDER", "yandex").strip() or "yandex"
@@ -266,6 +270,10 @@ def _interaction_request(method: str, path: str, *, payload: dict[str, Any] | No
return _request(method, f"{_interaction_service_url()}{path}", payload=payload, timeout=timeout)
def _sales_request(method: str, path: str, *, payload: dict[str, Any] | None = None, timeout: float = 8.0) -> dict[str, Any]:
return _request(method, f"{_sales_service_url()}{path}", payload=payload, timeout=timeout)
def _resolved_voice_language(language_hint: str | None, fallback: str | None = None) -> str:
return str(language_hint or fallback or "ru").strip() or "ru"
@@ -302,6 +310,136 @@ def _load_voice_session(session, session_id: str) -> VoiceAISessionRow:
return row
def _load_call_link(session, call_id: str) -> AsteriskCallLinkRow | None:
return session.execute(
select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == call_id)
).scalar_one_or_none()
def _latest_caller_transcript_text(session, session_id: str) -> str | None:
row = session.execute(
select(VoiceTranscriptSegmentRow)
.where(VoiceTranscriptSegmentRow.session_id == session_id)
.where(VoiceTranscriptSegmentRow.speaker == "caller")
.where(VoiceTranscriptSegmentRow.is_final.is_(True))
.order_by(VoiceTranscriptSegmentRow.sequence_no.desc())
.limit(1)
).scalar_one_or_none()
if row is None:
return None
text = str(row.text or "").strip()
return text or None
def _sales_transcript_snapshot(session, session_id: str, *, max_chars: int = 6000) -> str | None:
rows = session.execute(
select(VoiceTranscriptSegmentRow)
.where(VoiceTranscriptSegmentRow.session_id == session_id)
.where(VoiceTranscriptSegmentRow.is_final.is_(True))
.order_by(VoiceTranscriptSegmentRow.sequence_no.asc())
).scalars().all()
lines: list[str] = []
for row in rows:
text = str(row.text or "").strip()
if not text:
continue
speaker = "customer" if str(row.speaker or "").strip() == "caller" else str(row.speaker or "assistant").strip()
lines.append(f"{speaker}: {text}")
if not lines:
return None
selected: list[str] = []
total = 0
for line in reversed(lines):
line_len = len(line) + (1 if selected else 0)
if selected and total + line_len > max_chars:
break
if not selected and len(line) > max_chars:
selected.append(line[-max_chars:])
total = len(selected[0])
break
selected.append(line)
total += line_len
return "\n".join(reversed(selected)) or None
def _sync_sales_voice_session(
session_id: str,
*,
caller_number: str | None = None,
caller_name: str | None = None,
queue_code: str | None = None,
summary: str | None = None,
include_transcript: bool = False,
metadata: dict[str, Any] | None = None,
) -> None:
session = get_session()
try:
voice_session = _load_voice_session(session, session_id)
link = _load_call_link(session, voice_session.call_id)
resolved_summary = (
str(summary or "").strip()
or _latest_caller_transcript_text(session, session_id)
or str(voice_session.handoff_reason or "").strip()
or None
)
transcript_text = _sales_transcript_snapshot(session, session_id) if include_transcript else None
payload = {
"call_id": voice_session.call_id,
"interaction_id": str(voice_session.interaction_id or getattr(link, "interaction_id", "") or "").strip() or None,
"queue_id": str(voice_session.queue_id or getattr(link, "queue_id", "") or "").strip() or None,
"queue_code": str(getattr(link, "queue_code", "") or queue_code or "").strip() or None,
"caller_number": str(getattr(link, "caller_number", "") or caller_number or "").strip() or None,
"caller_name": str(getattr(link, "caller_name", "") or caller_name or "").strip() or None,
"voice_session_id": voice_session.session_id,
"ai_session_id": voice_session.ai_session_id,
"ai_state": voice_session.status,
"handoff_reason": voice_session.handoff_reason,
"telephony_status": str(getattr(link, "telephony_status", "") or voice_session.media_status or "").strip() or None,
"call_status": str(getattr(link, "status", "") or voice_session.status or "").strip() or None,
"started_at": voice_session.started_at,
"ended_at": voice_session.ended_at,
"summary": resolved_summary,
"transcript_text": transcript_text,
"metadata": {
"linked_id": voice_session.linked_id,
"agent_profile": voice_session.agent_profile,
"language": voice_session.language,
"asr_provider": voice_session.asr_provider,
"tts_provider": voice_session.tts_provider,
"handoff_target_queue_id": voice_session.handoff_target_queue_id,
"media_status": voice_session.media_status,
"media_uuid": voice_session.media_uuid,
"media_connected_at": voice_session.media_connected_at,
"media_ended_at": voice_session.media_ended_at,
"last_media_frame_at": voice_session.last_media_frame_at,
"last_user_utterance_at": voice_session.last_user_utterance_at,
"last_ai_reply_at": voice_session.last_ai_reply_at,
"voice_start_language": voice_session.voice_start_language,
"customer_name_status": voice_session.customer_name_status,
"customer_name_value": voice_session.customer_name_value,
"customer_name_source": voice_session.customer_name_source,
"customer_name_resolved_at": voice_session.customer_name_resolved_at,
"claimed_by_user": getattr(link, "claimed_by_user", None),
"claimed_at": getattr(link, "claimed_at", None),
"operator_extension": getattr(link, "operator_extension", None),
"channel_name": getattr(link, "channel_name", None),
**(metadata or {}),
},
}
finally:
session.close()
try:
_sales_request("POST", "/internal/sales-sync/voice", payload=payload, timeout=8.0)
except Exception:
LOGGER.exception(
"voice_runtime.sales_sync_failed session_id=%s call_id=%s ai_state=%s",
payload.get("voice_session_id"),
payload.get("call_id"),
payload.get("ai_state"),
)
def _next_sequence(session, session_id: str) -> int:
return next_transcript_sequence(session, session_id)
@@ -1007,6 +1145,11 @@ def _set_voice_session_state(
handoff_reason=handoff_reason,
metadata=metadata,
)
_sync_sales_voice_session(
voice_session.session_id,
include_transcript=ai_state in {"handoff_requested", "human_owned", "completed", "closed", "error"},
metadata=metadata,
)
if ai_state == "error":
_append_interaction_timeline(
voice_session.interaction_id,
@@ -1150,6 +1293,11 @@ def _handle_media_error(session_id: str, message: str, metadata: dict[str, Any]
)
except Exception:
pass
_sync_sales_voice_session(
voice_session.session_id,
include_transcript=True,
metadata=metadata,
)
finally:
session.close()
@@ -1235,6 +1383,13 @@ def _process_voice_ai_turn_sync(
session.commit()
if auto_handoff and decision.needs_handoff and not early_plan_only:
_request_runtime_handoff(session_id, payload.transcript_text, decision)
if not early_plan_only:
_sync_sales_voice_session(
session_id,
include_transcript=True,
summary=decision.summary_text or decision.reply_text or payload.transcript_text,
metadata=decision.metadata,
)
return decision
except HTTPException:
raise
@@ -1331,6 +1486,11 @@ def _complete_voice_ai_session_start(session_id: str, start_payload: VoiceAIStar
handoff_reason=voice_session.handoff_reason,
metadata=handoff_metadata,
)
_sync_sales_voice_session(
voice_session.session_id,
include_transcript=False,
metadata=handoff_metadata,
)
if needs_handoff:
handoff_summary = {
"customer_request_text": "voice_start",
@@ -1502,6 +1662,14 @@ def create_voice_ai_session(
)
_register_voice_start_thread(voice_start_thread)
voice_start_thread.start()
_sync_sales_voice_session(
voice_session.session_id,
caller_number=payload.caller_number,
caller_name=payload.caller_name,
queue_code=str(payload_metadata.get("queue_code") or "").strip() or None,
include_transcript=False,
metadata=payload_metadata,
)
return VoiceAISessionOut(
voice_session_id=voice_session.session_id,
ai_session_id=voice_session.ai_session_id,
@@ -1574,6 +1742,11 @@ def push_voice_ai_telephony_event(
)
except Exception:
pass
_sync_sales_voice_session(
session_id,
include_transcript=payload.event_type in {"operator.connected", "call.ended"},
metadata={"telephony_event_type": payload.event_type, **(payload.payload or {})},
)
return VoiceAISessionOut(
voice_session_id=session_id,
@@ -1,13 +1,14 @@
from __future__ import annotations
import asyncio
import audioop
import io
import struct
import uuid
import wave
from dataclasses import dataclass
from services.shared.audioop_compat import audioop
AUDIO_SOCKET_PACKET_HANGUP = 0x00
AUDIO_SOCKET_PACKET_UUID = 0x01
@@ -1,7 +1,6 @@
from __future__ import annotations
import asyncio
import audioop
import contextlib
import hashlib
import logging
@@ -33,6 +32,7 @@ from services.ai_voice_runtime_service.providers.asr import (
StreamingASRProvider,
StreamingASRUnavailable,
)
from services.shared.audioop_compat import audioop
from services.ai_voice_runtime_service.providers.tts import TTSProvider
from services.shared.models import VoiceAITurnDecisionOut
@@ -1,6 +1,5 @@
from __future__ import annotations
import audioop
import base64
import io
import json
@@ -17,6 +16,7 @@ from dataclasses import dataclass, field
import httpx
from services.ai_voice_runtime_service.audiosocket import pcm16le_to_wav_bytes, resample_pcm16le
from services.shared.audioop_compat import audioop
from services.shared.security import issue_app_token
+2
View File
@@ -0,0 +1,2 @@
"""Compatibility package for real-time voice runtime service."""
+15
View File
@@ -0,0 +1,15 @@
from __future__ import annotations
"""
Compatibility entrypoint for the real-time voice service.
This module allows running the voice runtime under:
`services.real_time_voice.app:app`
while reusing the canonical implementation from:
`services.ai_voice_runtime_service.app:app`.
"""
from services.ai_voice_runtime_service.app import app
__all__ = ["app"]
+1
View File
@@ -0,0 +1 @@
"""Sales service package."""
File diff suppressed because it is too large Load Diff
+102
View File
@@ -0,0 +1,102 @@
from __future__ import annotations
import math
import struct
from array import array
try:
import audioop as _audioop
except ModuleNotFoundError:
try:
import audioop_lts as _audioop # type: ignore[import-not-found]
except ModuleNotFoundError:
_audioop = None
def _native_is_little_endian() -> bool:
return struct.pack("=h", 1) == struct.pack("<h", 1)
def _read_int16_samples(fragment: bytes, width: int) -> array:
if width != 2:
raise NotImplementedError("audioop_compat fallback currently supports only 16-bit PCM")
samples = array("h")
samples.frombytes(fragment)
if not _native_is_little_endian():
samples.byteswap()
return samples
def _write_int16_samples(samples: array) -> bytes:
output = array("h", samples)
if not _native_is_little_endian():
output.byteswap()
return output.tobytes()
def _clip_int16(value: float) -> int:
return max(-32768, min(32767, int(round(value))))
class _AudioopFallback:
@staticmethod
def rms(fragment: bytes, width: int) -> int:
samples = _read_int16_samples(fragment, width)
if not samples:
return 0
mean_square = sum(sample * sample for sample in samples) / len(samples)
return int(math.sqrt(mean_square))
@staticmethod
def tomono(fragment: bytes, width: int, lfactor: float, rfactor: float) -> bytes:
samples = _read_int16_samples(fragment, width)
if len(samples) % 2 != 0:
raise ValueError("Stereo PCM must contain an even number of samples")
mono = array("h")
for index in range(0, len(samples), 2):
left = samples[index]
right = samples[index + 1]
mono.append(_clip_int16((left * lfactor) + (right * rfactor)))
return _write_int16_samples(mono)
@staticmethod
def ratecv(
fragment: bytes,
width: int,
nchannels: int,
inrate: int,
outrate: int,
state,
weightA: int = 1,
weightB: int = 0,
) -> tuple[bytes, None]:
if nchannels <= 0:
raise ValueError("nchannels must be positive")
if inrate <= 0 or outrate <= 0:
raise ValueError("Sample rates must be positive")
samples = _read_int16_samples(fragment, width)
if not samples or inrate == outrate:
return fragment, None
if len(samples) % nchannels != 0:
raise ValueError("PCM fragment size does not match channel count")
frame_count = len(samples) // nchannels
output_frame_count = max(1, int(round(frame_count * outrate / inrate)))
output = array("h")
for out_index in range(output_frame_count):
position = out_index * inrate / outrate
left_index = min(int(position), frame_count - 1)
right_index = min(left_index + 1, frame_count - 1)
fraction = max(0.0, min(1.0, position - left_index))
for channel_index in range(nchannels):
left_sample = samples[(left_index * nchannels) + channel_index]
right_sample = samples[(right_index * nchannels) + channel_index]
interpolated = left_sample + ((right_sample - left_sample) * fraction)
output.append(_clip_int16(interpolated))
return _write_int16_samples(output), None
audioop = _audioop or _AudioopFallback()
__all__ = ["audioop"]
+685
View File
@@ -0,0 +1,685 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
SalesLeadTemperature = Literal["warm", "hot"]
SalesChannelType = Literal["text", "voice"]
SalesPreferredChannel = Literal["telegram", "whatsapp", "webchat", "email", "voice"]
SalesDirection = Literal["inbound", "outbound"]
SalesAgentType = Literal["text_ai", "voice_ai", "human"]
SalesLeadStatus = Literal["new_qualified_lead", "warm_lead", "hot_lead", "enrichment_required", "converted", "lost"]
SalesDealStatus = Literal["active", "won", "lost", "postponed", "closed"]
SalesScenarioType = Literal[
"quick_sale",
"consultative_sale",
"quotation_based_sale",
"booking_based_sale",
"subscription_sale",
"custom_human_escalation",
]
SalesOfferType = Literal["offer", "quotation", "estimate", "order", "booking", "appointment", "subscription_plan"]
SalesOfferStatus = Literal["draft", "sent", "viewed", "accepted", "rejected"]
SalesTranscriptStatus = Literal["pending", "ready"]
SalesDocumentType = Literal[
"contract",
"offer_acceptance",
"appendix",
"specification",
"invoice_basis_doc",
"order_confirmation",
"booking_confirmation",
"appointment_confirmation",
"additional_agreement",
]
SalesDocumentStatus = Literal["draft", "sent", "under_review", "confirmed", "signed"]
SalesInvoiceStatus = Literal["draft", "issued", "sent", "partially_paid", "paid", "overdue", "canceled"]
SalesPaymentStatus = Literal["pending", "success", "failed", "canceled", "partial"]
SalesEscalationSeverity = Literal["low", "medium", "high", "critical"]
SalesEscalationStatus = Literal["open", "in_progress", "resolved"]
SalesAutomationStatus = Literal["pending", "running", "completed", "failed", "canceled"]
class SalesLeadCreate(BaseModel):
source_type: str = Field(default="crm")
source_channel: str = Field(default="webchat")
source_campaign_id: str | None = None
full_name: str = Field(min_length=2)
company_name: str | None = None
phone: str | None = None
email: str | None = None
messenger_handles: dict = Field(default_factory=dict)
lead_temperature: SalesLeadTemperature = "warm"
lead_score: float = Field(default=50.0, ge=0, le=100)
customer_type: str | None = None
segment_type: str | None = None
initial_need_summary: str | None = None
preferred_channel: SalesPreferredChannel = "telegram"
assigned_agent_type: SalesAgentType = "text_ai"
status: SalesLeadStatus = "new_qualified_lead"
priority: int = Field(default=3, ge=1, le=5)
title: str | None = None
estimated_amount: float | None = Field(default=None, ge=0)
currency: str = "KZT"
class SalesLeadUpdate(BaseModel):
full_name: str | None = Field(default=None, min_length=2)
company_name: str | None = None
phone: str | None = None
email: str | None = None
messenger_handles: dict | None = None
lead_temperature: SalesLeadTemperature | None = None
lead_score: float | None = Field(default=None, ge=0, le=100)
customer_type: str | None = None
segment_type: str | None = None
initial_need_summary: str | None = None
preferred_channel: SalesPreferredChannel | None = None
assigned_agent_type: SalesAgentType | None = None
status: SalesLeadStatus | None = None
class SalesLeadEnrichIn(BaseModel):
need_summary: str | None = None
product_context: dict = Field(default_factory=dict)
customer_type: str | None = None
preferred_channel: SalesPreferredChannel | None = None
scenario_type: SalesScenarioType | None = None
required_fields: dict = Field(default_factory=dict)
class SalesLeadOut(BaseModel):
lead_id: str
tenant_id: str
source_type: str
source_channel: str
source_campaign_id: str | None = None
full_name: str
company_name: str | None = None
phone: str | None = None
email: str | None = None
messenger_handles: dict = Field(default_factory=dict)
lead_temperature: SalesLeadTemperature
lead_score: float
customer_type: str | None = None
segment_type: str | None = None
initial_need_summary: str | None = None
preferred_channel: SalesPreferredChannel
assigned_agent_type: SalesAgentType
status: SalesLeadStatus
crm_customer_id: str | None = None
created_at: str
updated_at: str
class SalesDealCreate(BaseModel):
lead_id: str | None = None
customer_id: str | None = None
stage_id: str = "new_qualified_lead"
scenario_type: SalesScenarioType = "quick_sale"
priority: int = Field(default=3, ge=1, le=5)
title: str = Field(min_length=3)
need_summary: str | None = None
product_context: dict = Field(default_factory=dict)
estimated_amount: float | None = Field(default=None, ge=0)
final_amount: float | None = Field(default=None, ge=0)
currency: str = "KZT"
payment_model: str | None = None
document_required: bool = True
payment_required: bool = True
preferred_channel: SalesPreferredChannel = "telegram"
current_channel: SalesPreferredChannel = "telegram"
class SalesDealUpdate(BaseModel):
title: str | None = Field(default=None, min_length=3)
need_summary: str | None = None
product_context: dict | None = None
estimated_amount: float | None = Field(default=None, ge=0)
final_amount: float | None = Field(default=None, ge=0)
currency: str | None = None
payment_model: str | None = None
document_required: bool | None = None
payment_required: bool | None = None
preferred_channel: SalesPreferredChannel | None = None
current_channel: SalesPreferredChannel | None = None
assigned_human_user_id: str | None = None
next_action_type: str | None = None
next_action_at: str | None = None
class SalesDealStageChangeIn(BaseModel):
stage_id: str = Field(min_length=2)
reason: str | None = None
class SalesDealScenarioIn(BaseModel):
scenario_type: SalesScenarioType
class SalesDealNextActionIn(BaseModel):
next_action_type: str = Field(min_length=2)
next_action_at: str | None = None
payload: dict = Field(default_factory=dict)
class SalesDealCloseIn(BaseModel):
status: Literal["won", "lost", "postponed"]
reason: str | None = None
class SalesDealOut(BaseModel):
deal_id: str
tenant_id: str
lead_id: str | None = None
customer_id: str | None = None
pipeline_id: str = "sales_default"
stage_id: str
scenario_type: SalesScenarioType
priority: int
title: str
need_summary: str | None = None
product_context: dict = Field(default_factory=dict)
estimated_amount: float | None = None
final_amount: float | None = None
currency: str
payment_model: str | None = None
document_required: bool = True
payment_required: bool = True
assigned_human_user_id: str | None = None
assigned_ai_orchestrator_id: str | None = None
preferred_channel: str
current_channel: str
status: SalesDealStatus
won_reason: str | None = None
lost_reason: str | None = None
close_reason: str | None = None
next_action_type: str | None = None
next_action_at: str | None = None
last_contact_at: str | None = None
closed_at: str | None = None
created_at: str
updated_at: str
class SalesCommunicationStartIn(BaseModel):
channel_type: SalesChannelType
direction: SalesDirection = "outbound"
agent_type: SalesAgentType | None = None
subject: str | None = None
summary: str | None = None
next_action_type: str | None = None
next_action_at: str | None = None
class SalesCommunicationSummaryIn(BaseModel):
summary: str = Field(min_length=2)
result_code: str | None = None
sentiment: str | None = None
next_action_type: str | None = None
next_action_at: str | None = None
class SalesCommunicationSwitchChannelIn(BaseModel):
to_channel: SalesPreferredChannel
reason_for_channel_switch: str = Field(min_length=3)
class SalesCommunicationBindExternalIn(BaseModel):
channel_provider: str | None = None
telegram_thread_id: str | None = None
telegram_chat_id: str | None = None
voice_session_id: str | None = None
external_call_id: str | None = None
metadata: dict = Field(default_factory=dict)
class SalesCommunicationOut(BaseModel):
communication_id: str
tenant_id: str
deal_id: str
lead_id: str | None = None
customer_id: str | None = None
channel_type: SalesChannelType
direction: SalesDirection
agent_type: SalesAgentType
started_at: str
ended_at: str | None = None
duration_sec: int | None = None
subject: str | None = None
status: str
summary: str | None = None
transcript_id: str | None = None
next_action_type: str | None = None
next_action_at: str | None = None
sentiment: str | None = None
result_code: str | None = None
metadata: dict = Field(default_factory=dict)
created_at: str
updated_at: str
class SalesMessageWebhookIn(BaseModel):
deal_id: str | None = None
lead_id: str | None = None
phone: str | None = None
channel_provider: str = "telegram"
external_message_id: str | None = None
sender_id: str | None = None
body: str = Field(min_length=1)
attachments: list[dict] = Field(default_factory=list)
metadata: dict = Field(default_factory=dict)
class SalesMessageSendIn(BaseModel):
deal_id: str
communication_id: str | None = None
channel_provider: str = "telegram"
sender_type: Literal["text_ai", "human"] = "text_ai"
sender_id: str | None = None
body: str = Field(min_length=1)
attachments: list[dict] = Field(default_factory=list)
metadata: dict = Field(default_factory=dict)
class SalesMessageOut(BaseModel):
message_id: str
deal_id: str
communication_id: str
sender_type: str
sender_id: str | None = None
channel_provider: str
external_message_id: str | None = None
body: str
attachments: list[dict] = Field(default_factory=list)
delivery_status: str | None = None
read_status: str | None = None
metadata: dict = Field(default_factory=dict)
sent_at: str
created_at: str
class SalesCallWebhookIn(BaseModel):
deal_id: str | None = None
lead_id: str | None = None
phone_number: str
provider: str = "asterisk"
external_call_id: str | None = None
subject: str | None = None
class SalesCallCompleteIn(BaseModel):
summary: str | None = None
recording_url: str | None = None
result_code: str | None = None
class SalesTranscriptIn(BaseModel):
language: str = "ru"
transcript_text: str = Field(min_length=1)
diarization: dict = Field(default_factory=dict)
extracted_entities: dict = Field(default_factory=dict)
class SalesTranscriptOut(BaseModel):
transcript_id: str
call_id: str
language: str
transcript_text: str
diarization: dict = Field(default_factory=dict)
extracted_entities: dict = Field(default_factory=dict)
created_at: str
updated_at: str
class SalesCallOut(BaseModel):
call_id: str
deal_id: str
communication_id: str
phone_number: str
direction: SalesDirection
provider: str
external_call_id: str | None = None
recording_url: str | None = None
transcript_status: SalesTranscriptStatus
transcript_id: str | None = None
call_status: str
started_at: str
ended_at: str | None = None
duration_sec: int | None = None
summary: str | None = None
created_at: str
updated_at: str
class SalesOfferCreate(BaseModel):
offer_type: SalesOfferType = "offer"
title: str = Field(min_length=3)
description: str | None = None
line_items: list[dict] = Field(default_factory=list)
pricing: dict = Field(default_factory=dict)
total_amount: float = Field(ge=0)
currency: str = "KZT"
validity_until: str | None = None
rendered_document_url: str | None = None
class SalesOfferOut(BaseModel):
offer_id: str
deal_id: str
offer_type: SalesOfferType
title: str
description: str | None = None
line_items: list[dict] = Field(default_factory=list)
pricing: dict = Field(default_factory=dict)
total_amount: float
currency: str
validity_until: str | None = None
status: SalesOfferStatus
rendered_document_url: str | None = None
created_by_type: str
created_at: str
updated_at: str
class SalesConditionUpsertIn(BaseModel):
product_name: str | None = None
service_name: str | None = None
quantity: float | None = Field(default=None, ge=0)
unit: str | None = None
delivery_mode: str | None = None
execution_date: str | None = None
start_date: str | None = None
end_date: str | None = None
payment_terms: str | None = None
custom_terms: dict = Field(default_factory=dict)
agreed_price: float | None = Field(default=None, ge=0)
currency: str = "KZT"
class SalesConditionOut(BaseModel):
condition_id: str
deal_id: str
product_name: str | None = None
service_name: str | None = None
quantity: float | None = None
unit: str | None = None
delivery_mode: str | None = None
execution_date: str | None = None
start_date: str | None = None
end_date: str | None = None
payment_terms: str | None = None
custom_terms: dict = Field(default_factory=dict)
agreed_price: float | None = None
currency: str
confirmed_at: str | None = None
created_at: str
updated_at: str
class SalesCounterpartyUpsertIn(BaseModel):
company_name: str | None = None
full_name: str | None = None
bin_iin: str | None = None
address: str | None = None
bank_details: dict = Field(default_factory=dict)
signer_name: str | None = None
signer_role: str | None = None
signer_basis: str | None = None
email_for_docs: str | None = None
phone_for_docs: str | None = None
class SalesCounterpartyOut(BaseModel):
counterparty_id: str
deal_id: str
customer_id: str | None = None
company_name: str | None = None
full_name: str | None = None
bin_iin: str | None = None
address: str | None = None
bank_details: dict = Field(default_factory=dict)
signer_name: str | None = None
signer_role: str | None = None
signer_basis: str | None = None
email_for_docs: str | None = None
phone_for_docs: str | None = None
completeness_status: str
created_at: str
updated_at: str
class SalesDocumentCreate(BaseModel):
document_type: SalesDocumentType
template_id: str | None = None
version: int = Field(default=1, ge=1)
rendered_payload: dict = Field(default_factory=dict)
file_url: str | None = None
class SalesDocumentOut(BaseModel):
document_id: str
deal_id: str
customer_id: str | None = None
document_type: SalesDocumentType
template_id: str | None = None
version: int
status: SalesDocumentStatus
file_url: str | None = None
rendered_payload: dict = Field(default_factory=dict)
external_sign_provider_id: str | None = None
signed_at: str | None = None
created_at: str
updated_at: str
class SalesInvoiceCreate(BaseModel):
basis_document_id: str | None = None
amount: float = Field(ge=0)
currency: str = "KZT"
due_date: str | None = None
payment_link: str | None = None
line_items: list[dict] = Field(default_factory=list)
metadata: dict = Field(default_factory=dict)
class SalesInvoiceOut(BaseModel):
invoice_id: str
deal_id: str
customer_id: str | None = None
invoice_number: str
basis_document_id: str | None = None
amount: float
currency: str
due_date: str | None = None
status: SalesInvoiceStatus
payment_link: str | None = None
line_items: list[dict] = Field(default_factory=list)
metadata: dict = Field(default_factory=dict)
issued_at: str | None = None
paid_at: str | None = None
created_at: str
updated_at: str
class SalesPaymentWebhookIn(BaseModel):
deal_id: str
invoice_id: str | None = None
payment_provider: str = "manual"
external_payment_id: str | None = None
amount: float = Field(ge=0)
currency: str = "KZT"
status: SalesPaymentStatus = "success"
paid_at: str | None = None
payment_method: str | None = None
failure_reason: str | None = None
metadata: dict = Field(default_factory=dict)
class SalesPaymentReconcileIn(BaseModel):
status: SalesPaymentStatus
failure_reason: str | None = None
metadata: dict = Field(default_factory=dict)
class SalesPaymentOut(BaseModel):
payment_id: str
deal_id: str
invoice_id: str | None = None
payment_provider: str | None = None
external_payment_id: str | None = None
amount: float
currency: str
status: SalesPaymentStatus
paid_at: str | None = None
payment_method: str | None = None
failure_reason: str | None = None
metadata: dict = Field(default_factory=dict)
created_at: str
updated_at: str
class SalesEscalationCreateIn(BaseModel):
escalation_type: str = Field(min_length=2)
reason: str = Field(min_length=3)
severity: SalesEscalationSeverity = "medium"
assigned_to_user_id: str | None = None
class SalesEscalationOut(BaseModel):
escalation_id: str
deal_id: str
escalation_type: str
reason: str
severity: SalesEscalationSeverity
status: SalesEscalationStatus
assigned_to_user_id: str | None = None
created_at: str
resolved_at: str | None = None
class SalesAutomationTaskOut(BaseModel):
task_id: str
deal_id: str
task_type: str
payload: dict = Field(default_factory=dict)
run_at: str
status: SalesAutomationStatus
retry_count: int = 0
last_error: str | None = None
created_at: str
updated_at: str
class SalesStageHistoryOut(BaseModel):
history_id: str
deal_id: str
from_stage_id: str | None = None
to_stage_id: str
changed_by_type: str
changed_by_id: str | None = None
reason: str | None = None
changed_at: str
class SalesChannelSwitchOut(BaseModel):
switch_id: str
deal_id: str
communication_id: str | None = None
from_channel: str
to_channel: str
reason_for_channel_switch: str
switched_at: str
class SalesTimelineEventOut(BaseModel):
ts: str
kind: str
title: str
body: str
meta: dict = Field(default_factory=dict)
class SalesWorkspaceOut(BaseModel):
lead: SalesLeadOut | None = None
deal: SalesDealOut
communications: list[SalesCommunicationOut] = Field(default_factory=list)
messages: list[SalesMessageOut] = Field(default_factory=list)
calls: list[SalesCallOut] = Field(default_factory=list)
offers: list[SalesOfferOut] = Field(default_factory=list)
conditions: list[SalesConditionOut] = Field(default_factory=list)
counterparty: SalesCounterpartyOut | None = None
documents: list[SalesDocumentOut] = Field(default_factory=list)
invoices: list[SalesInvoiceOut] = Field(default_factory=list)
payments: list[SalesPaymentOut] = Field(default_factory=list)
escalations: list[SalesEscalationOut] = Field(default_factory=list)
tasks: list[SalesAutomationTaskOut] = Field(default_factory=list)
stage_history: list[SalesStageHistoryOut] = Field(default_factory=list)
channel_switches: list[SalesChannelSwitchOut] = Field(default_factory=list)
timeline: list[SalesTimelineEventOut] = Field(default_factory=list)
class SalesDashboardStageOut(BaseModel):
stage_id: str
label: str
count: int = 0
amount: float = 0.0
class SalesDashboardSummaryOut(BaseModel):
leads_total: int = 0
deals_active: int = 0
deals_won: int = 0
deals_lost: int = 0
overdue_invoices: int = 0
payment_expected: float = 0.0
payment_received: float = 0.0
voice_sessions: int = 0
text_sessions: int = 0
channel_switches: int = 0
human_escalations: int = 0
stage_counts: list[SalesDashboardStageOut] = Field(default_factory=list)
hottest_deals: list[SalesDealOut] = Field(default_factory=list)
class SalesTelegramSyncIn(BaseModel):
thread_id: str = Field(min_length=1)
chat_id: str = Field(min_length=1)
interaction_id: str | None = None
customer_id: str | None = None
phone_number: str | None = None
display_name: str | None = None
queue_id: str | None = None
status: str | None = None
ai_state: str | None = None
ai_handoff_reason: str | None = None
message_id: str | None = None
external_message_id: str | None = None
text: str | None = None
direction: str = "inbound"
author_type: str | None = None
author_id: str | None = None
happened_at: str | None = None
metadata: dict = Field(default_factory=dict)
class SalesVoiceSyncIn(BaseModel):
call_id: str = Field(min_length=1)
interaction_id: str | None = None
queue_id: str | None = None
queue_code: str | None = None
caller_number: str | None = None
caller_name: str | None = None
voice_session_id: str | None = None
ai_session_id: str | None = None
ai_state: str | None = None
handoff_reason: str | None = None
telephony_status: str | None = None
call_status: str | None = None
started_at: str | None = None
ended_at: str | None = None
summary: str | None = None
transcript_text: str | None = None
metadata: dict = Field(default_factory=dict)
+445
View File
@@ -0,0 +1,445 @@
from __future__ import annotations
from sqlalchemy import Boolean, Float, Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from services.shared.sql_models import Base
class SalesLeadRow(Base):
__tablename__ = "sales_leads"
__table_args__ = (
Index("idx_sales_leads_tenant_status_score", "tenant_id", "status", "lead_score"),
Index("idx_sales_leads_phone_email", "phone", "email"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
lead_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
source_type: Mapped[str] = mapped_column(String(64), index=True)
source_channel: Mapped[str] = mapped_column(String(32), index=True)
source_campaign_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
full_name: Mapped[str] = mapped_column(String(256), index=True)
company_name: Mapped[str | None] = mapped_column(String(256), nullable=True, index=True)
phone: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
email: Mapped[str | None] = mapped_column(String(256), nullable=True, index=True)
messenger_handles_json: Mapped[str] = mapped_column(Text, default="{}")
lead_temperature: Mapped[str] = mapped_column(String(16), index=True)
lead_score: Mapped[float] = mapped_column(Float, default=50.0, index=True)
customer_type: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
segment_type: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
initial_need_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
preferred_channel: Mapped[str] = mapped_column(String(32), index=True)
assigned_agent_type: Mapped[str] = mapped_column(String(32), index=True)
status: Mapped[str] = mapped_column(String(32), index=True)
crm_customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesDealRow(Base):
__tablename__ = "sales_deals"
__table_args__ = (
Index("idx_sales_deals_tenant_stage_status", "tenant_id", "stage_id", "status"),
Index("idx_sales_deals_customer_channel", "customer_id", "current_channel"),
Index("idx_sales_deals_next_action", "next_action_at", "status"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
deal_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
lead_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
pipeline_id: Mapped[str] = mapped_column(String(64), default="sales_default", index=True)
stage_id: Mapped[str] = mapped_column(String(64), index=True)
scenario_type: Mapped[str] = mapped_column(String(64), index=True)
priority: Mapped[int] = mapped_column(Integer, default=3, index=True)
title: Mapped[str] = mapped_column(String(512))
need_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
product_context_json: Mapped[str] = mapped_column(Text, default="{}")
estimated_amount: Mapped[float | None] = mapped_column(Float, nullable=True)
final_amount: Mapped[float | None] = mapped_column(Float, nullable=True)
currency: Mapped[str] = mapped_column(String(16), default="KZT")
payment_model: Mapped[str | None] = mapped_column(String(64), nullable=True)
document_required: Mapped[bool] = mapped_column(Boolean, default=True)
payment_required: Mapped[bool] = mapped_column(Boolean, default=True)
assigned_human_user_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
assigned_ai_orchestrator_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
preferred_channel: Mapped[str] = mapped_column(String(32), index=True)
current_channel: Mapped[str] = mapped_column(String(32), index=True)
status: Mapped[str] = mapped_column(String(32), index=True, default="active")
won_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
lost_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
close_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
next_action_type: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
next_action_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
last_contact_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
closed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesCommunicationSessionRow(Base):
__tablename__ = "sales_communication_sessions"
__table_args__ = (
Index("idx_sales_comm_deal_started", "deal_id", "started_at"),
Index("idx_sales_comm_channel_status", "channel_type", "status"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
communication_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
lead_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
channel_type: Mapped[str] = mapped_column(String(16), index=True)
direction: Mapped[str] = mapped_column(String(16), index=True)
agent_type: Mapped[str] = mapped_column(String(32), index=True)
started_at: Mapped[str] = mapped_column(String(64), index=True)
ended_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
duration_sec: Mapped[int | None] = mapped_column(Integer, nullable=True)
subject: Mapped[str | None] = mapped_column(String(512), nullable=True)
status: Mapped[str] = mapped_column(String(32), index=True, default="active")
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
transcript_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
next_action_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
next_action_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
sentiment: Mapped[str | None] = mapped_column(String(32), nullable=True)
result_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
metadata_json: Mapped[str] = mapped_column(Text, default="{}")
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesMessageRow(Base):
__tablename__ = "sales_messages"
__table_args__ = (
Index("idx_sales_messages_deal_sent", "deal_id", "sent_at"),
Index("idx_sales_messages_ext_id", "channel_provider", "external_message_id"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
message_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
communication_id: Mapped[str] = mapped_column(String(64), index=True)
sender_type: Mapped[str] = mapped_column(String(32), index=True)
sender_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
channel_provider: Mapped[str] = mapped_column(String(32), index=True)
external_message_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
body: Mapped[str] = mapped_column(Text)
attachments_json: Mapped[str] = mapped_column(Text, default="[]")
delivery_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
read_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
message_metadata_json: Mapped[str] = mapped_column(Text, default="{}")
sent_at: Mapped[str] = mapped_column(String(64), index=True)
created_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesCallRow(Base):
__tablename__ = "sales_calls"
__table_args__ = (
Index("idx_sales_calls_deal_started", "deal_id", "started_at"),
Index("idx_sales_calls_ext_provider", "provider", "external_call_id"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
call_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
communication_id: Mapped[str] = mapped_column(String(64), index=True)
phone_number: Mapped[str] = mapped_column(String(64), index=True)
direction: Mapped[str] = mapped_column(String(16), index=True)
provider: Mapped[str] = mapped_column(String(64), index=True)
external_call_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
recording_url: Mapped[str | None] = mapped_column(Text, nullable=True)
transcript_status: Mapped[str] = mapped_column(String(32), index=True, default="pending")
transcript_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
call_status: Mapped[str] = mapped_column(String(32), index=True, default="started")
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
started_at: Mapped[str] = mapped_column(String(64), index=True)
ended_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
duration_sec: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesTranscriptRow(Base):
__tablename__ = "sales_transcripts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
transcript_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
call_id: Mapped[str] = mapped_column(String(64), index=True)
language: Mapped[str] = mapped_column(String(16), index=True, default="ru")
transcript_text: Mapped[str] = mapped_column(Text)
diarization_json: Mapped[str] = mapped_column(Text, default="{}")
extracted_entities_json: Mapped[str] = mapped_column(Text, default="{}")
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesNoteRow(Base):
__tablename__ = "sales_notes"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
note_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
author_type: Mapped[str] = mapped_column(String(32), index=True)
author_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
note_type: Mapped[str] = mapped_column(String(64), index=True)
content: Mapped[str] = mapped_column(Text)
created_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesOfferRow(Base):
__tablename__ = "sales_offers"
__table_args__ = (
Index("idx_sales_offers_deal_status", "deal_id", "status"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
offer_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
offer_type: Mapped[str] = mapped_column(String(32), index=True)
title: Mapped[str] = mapped_column(String(256))
description: Mapped[str | None] = mapped_column(Text, nullable=True)
line_items_json: Mapped[str] = mapped_column(Text, default="[]")
pricing_json: Mapped[str] = mapped_column(Text, default="{}")
total_amount: Mapped[float] = mapped_column(Float, default=0)
currency: Mapped[str] = mapped_column(String(16), default="KZT")
validity_until: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
status: Mapped[str] = mapped_column(String(32), index=True, default="draft")
rendered_document_url: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by_type: Mapped[str] = mapped_column(String(32), default="text_ai")
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesConditionRow(Base):
__tablename__ = "sales_conditions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
condition_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
product_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
service_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
quantity: Mapped[float | None] = mapped_column(Float, nullable=True)
unit: Mapped[str | None] = mapped_column(String(32), nullable=True)
delivery_mode: Mapped[str | None] = mapped_column(String(64), nullable=True)
execution_date: Mapped[str | None] = mapped_column(String(64), nullable=True)
start_date: Mapped[str | None] = mapped_column(String(64), nullable=True)
end_date: Mapped[str | None] = mapped_column(String(64), nullable=True)
payment_terms: Mapped[str | None] = mapped_column(Text, nullable=True)
custom_terms_json: Mapped[str] = mapped_column(Text, default="{}")
agreed_price: Mapped[float | None] = mapped_column(Float, nullable=True)
currency: Mapped[str] = mapped_column(String(16), default="KZT")
confirmed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesCounterpartyRow(Base):
__tablename__ = "sales_counterparties"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
counterparty_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
company_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
full_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
bin_iin: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
address: Mapped[str | None] = mapped_column(Text, nullable=True)
bank_details_json: Mapped[str] = mapped_column(Text, default="{}")
signer_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
signer_role: Mapped[str | None] = mapped_column(String(128), nullable=True)
signer_basis: Mapped[str | None] = mapped_column(String(256), nullable=True)
email_for_docs: Mapped[str | None] = mapped_column(String(256), nullable=True)
phone_for_docs: Mapped[str | None] = mapped_column(String(64), nullable=True)
completeness_status: Mapped[str] = mapped_column(String(32), index=True, default="draft")
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesDocumentRow(Base):
__tablename__ = "sales_documents"
__table_args__ = (
Index("idx_sales_documents_deal_status", "deal_id", "status"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
document_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
document_type: Mapped[str] = mapped_column(String(64), index=True)
template_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
version: Mapped[int] = mapped_column(Integer, default=1)
status: Mapped[str] = mapped_column(String(32), index=True, default="draft")
file_url: Mapped[str | None] = mapped_column(Text, nullable=True)
rendered_payload_json: Mapped[str] = mapped_column(Text, default="{}")
external_sign_provider_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
signed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesInvoiceRow(Base):
__tablename__ = "sales_invoices"
__table_args__ = (
Index("idx_sales_invoices_deal_status_due", "deal_id", "status", "due_date"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
invoice_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
invoice_number: Mapped[str] = mapped_column(String(64), unique=True, index=True)
basis_document_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
amount: Mapped[float] = mapped_column(Float, default=0)
currency: Mapped[str] = mapped_column(String(16), default="KZT")
due_date: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
status: Mapped[str] = mapped_column(String(32), index=True, default="draft")
payment_link: Mapped[str | None] = mapped_column(Text, nullable=True)
line_items_json: Mapped[str] = mapped_column(Text, default="[]")
metadata_json: Mapped[str] = mapped_column(Text, default="{}")
issued_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
paid_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesPaymentRow(Base):
__tablename__ = "sales_payments"
__table_args__ = (
Index("idx_sales_payments_deal_status", "deal_id", "status"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
payment_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
invoice_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
payment_provider: Mapped[str | None] = mapped_column(String(64), nullable=True)
external_payment_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
amount: Mapped[float] = mapped_column(Float, default=0)
currency: Mapped[str] = mapped_column(String(16), default="KZT")
status: Mapped[str] = mapped_column(String(32), index=True, default="pending")
paid_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
payment_method: Mapped[str | None] = mapped_column(String(64), nullable=True)
failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
metadata_json: Mapped[str] = mapped_column(Text, default="{}")
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesStageHistoryRow(Base):
__tablename__ = "sales_stage_history"
__table_args__ = (
Index("idx_sales_stage_history_deal_changed", "deal_id", "changed_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
history_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
from_stage_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
to_stage_id: Mapped[str] = mapped_column(String(64), index=True)
changed_by_type: Mapped[str] = mapped_column(String(32), index=True)
changed_by_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
changed_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesEscalationRow(Base):
__tablename__ = "sales_escalations"
__table_args__ = (
Index("idx_sales_escalations_deal_status", "deal_id", "status"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
escalation_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
escalation_type: Mapped[str] = mapped_column(String(64), index=True)
reason: Mapped[str] = mapped_column(Text)
severity: Mapped[str] = mapped_column(String(16), index=True)
status: Mapped[str] = mapped_column(String(32), index=True, default="open")
assigned_to_user_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
created_at: Mapped[str] = mapped_column(String(64), index=True)
resolved_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
class SalesAutomationTaskRow(Base):
__tablename__ = "sales_automation_tasks"
__table_args__ = (
Index("idx_sales_tasks_run_status", "run_at", "status"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
task_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
task_type: Mapped[str] = mapped_column(String(64), index=True)
payload_json: Mapped[str] = mapped_column(Text, default="{}")
run_at: Mapped[str] = mapped_column(String(64), index=True)
status: Mapped[str] = mapped_column(String(32), index=True, default="pending")
retry_count: Mapped[int] = mapped_column(Integer, default=0)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesChannelSwitchRow(Base):
__tablename__ = "sales_channel_switches"
__table_args__ = (
Index("idx_sales_channel_switches_deal_switched", "deal_id", "switched_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
switch_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
communication_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
from_channel: Mapped[str] = mapped_column(String(32), index=True)
to_channel: Mapped[str] = mapped_column(String(32), index=True)
reason_for_channel_switch: Mapped[str] = mapped_column(Text)
switched_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesExternalLinkRow(Base):
__tablename__ = "sales_external_links"
__table_args__ = (
Index("idx_sales_external_links_deal_sync", "deal_id", "last_sync_at"),
Index("idx_sales_external_links_thread", "external_thread_id"),
Index("idx_sales_external_links_voice_session", "voice_session_id"),
Index("idx_sales_external_links_external_call", "external_call_id"),
Index("idx_sales_external_links_interaction", "interaction_id"),
Index("idx_sales_external_links_customer_phone", "customer_id", "phone_number"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
link_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
communication_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
sales_call_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
channel_provider: Mapped[str] = mapped_column(String(32), index=True)
external_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
external_chat_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
external_call_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
voice_session_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
ai_session_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
phone_number: Mapped[str | None] = mapped_column(String(64), nullable=True)
external_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
link_metadata_json: Mapped[str] = mapped_column(Text, default="{}")
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
last_sync_at: Mapped[str] = mapped_column(String(64), index=True)
@@ -1,6 +1,5 @@
from __future__ import annotations
import audioop
import base64
import logging
import os
@@ -13,6 +12,7 @@ from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel
from services.shared.core import Role, new_id
from services.shared.audioop_compat import audioop
from services.shared.models import HealthResponse
from services.shared.security import require_roles
+73
View File
@@ -231,6 +231,10 @@ def _ai_service_url() -> str:
return os.getenv("AI_ORCHESTRATOR_SERVICE_URL", "http://localhost:8017").rstrip("/")
def _sales_service_url() -> str:
return os.getenv("SALES_SERVICE_URL", "http://localhost:8020").rstrip("/")
def _ai_telegram_enabled() -> bool:
return _bool_env("AI_TELEGRAM_ENABLED", False)
@@ -276,6 +280,62 @@ def _interaction_request(method: str, path: str, *, payload: dict | None = None)
return response.json()
def _sales_sync_request(payload: dict[str, Any]) -> None:
try:
with httpx.Client(timeout=5.0) as client:
response = client.post(
f"{_sales_service_url()}/internal/sales-sync/telegram",
json=payload,
headers=_internal_service_headers(
subject="svc:telegram-adapter",
username="telegram-adapter",
provider="telegram-adapter",
),
)
response.raise_for_status()
except Exception:
logger.exception(
"Telegram sales sync failed",
extra={
"thread_id": payload.get("thread_id"),
"message_id": payload.get("message_id"),
"direction": payload.get("direction"),
},
)
def _sync_sales_thread(thread: TelegramThreadRow, row: TelegramMessageRow | None = None) -> None:
payload = {
"thread_id": thread.thread_id,
"chat_id": thread.chat_id,
"interaction_id": thread.interaction_id,
"customer_id": getattr(row, "customer_id", None),
"phone_number": None,
"display_name": thread.display_name,
"queue_id": thread.queue_id,
"status": thread.status,
"ai_state": thread.ai_state,
"ai_handoff_reason": thread.ai_handoff_reason,
"message_id": getattr(row, "message_id", None),
"external_message_id": getattr(row, "telegram_message_id_external", None),
"text": getattr(row, "text", None),
"direction": getattr(row, "direction", "inbound"),
"author_type": getattr(row, "author_type", None),
"author_id": getattr(row, "author_id", None),
"happened_at": getattr(row, "created_at", None) or thread.updated_at,
"metadata": {
"telegram_user_id": thread.telegram_user_id,
"username": thread.username,
"claimed_by_user": thread.claimed_by_user,
"claimed_at": thread.claimed_at,
"ai_session_id": thread.ai_session_id,
"last_message_at": thread.last_message_at,
"last_message_preview": thread.last_message_preview,
},
}
_sales_sync_request(payload)
def _ai_enqueue_request(thread_id: str, trigger_message_id: str | None) -> None:
if not _ai_telegram_enabled():
return
@@ -1563,6 +1623,7 @@ def webhook(payload: TelegramWebhookIn) -> TelegramWebhookOut:
session.commit()
session.refresh(row)
session.refresh(thread)
_sync_sales_thread(thread, row)
_maybe_enqueue_ai_for_thread(thread, row.message_id, author_type=row.author_type)
return _to_webhook_out(row)
finally:
@@ -1614,6 +1675,9 @@ def bot_webhook(
if existing:
return _to_bot_webhook_result(existing, thread_created=False)
raise
session.refresh(row)
session.refresh(thread)
_sync_sales_thread(thread, row)
_maybe_enqueue_ai_for_thread(thread, row.message_id, author_type=row.author_type)
return _to_bot_webhook_result(row, thread_created=created)
finally:
@@ -1768,6 +1832,7 @@ def claim_thread(
)
session.commit()
session.refresh(thread)
_sync_sales_thread(thread)
return _to_thread_out(thread)
finally:
session.close()
@@ -1826,6 +1891,7 @@ def return_thread_to_ai(
)
session.commit()
session.refresh(thread)
_sync_sales_thread(thread)
return _to_thread_out(thread)
finally:
session.close()
@@ -1872,6 +1938,8 @@ def reply_thread(
)
session.commit()
session.refresh(row)
session.refresh(thread)
_sync_sales_thread(thread, row)
_start_telegram_reply_delivery(row.message_id)
return _to_message_out(row)
finally:
@@ -1947,6 +2015,8 @@ def ai_reply_thread(
)
session.commit()
session.refresh(row)
session.refresh(thread)
_sync_sales_thread(thread, row)
_start_telegram_reply_delivery(row.message_id)
return _to_message_out(row)
finally:
@@ -1996,6 +2066,7 @@ def ai_handoff_thread(
)
session.commit()
session.refresh(thread)
_sync_sales_thread(thread)
return _to_thread_out(thread)
finally:
session.close()
@@ -2032,6 +2103,7 @@ def close_thread(
)
session.commit()
session.refresh(thread)
_sync_sales_thread(thread)
return _to_thread_out(thread)
finally:
session.close()
@@ -2067,6 +2139,7 @@ def escalate_thread(
)
session.commit()
session.refresh(thread)
_sync_sales_thread(thread)
return _to_thread_out(thread)
finally:
session.close()