sales fix
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Compatibility package for real-time voice runtime service."""
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Sales service package."""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user