Files
call-center/services/sales_service/app.py
T
2026-05-09 09:16:06 +05:00

3320 lines
126 KiB
Python

from __future__ import annotations
import json
import os
from datetime import datetime, timezone
from typing import Any
import httpx
from fastapi import Depends, FastAPI, HTTPException, Query
from sqlalchemy import func, or_, select
from services.shared.core import Role, new_id, utc_now_iso
from services.shared.db import get_session
from services.shared.models import HealthResponse
from services.shared.sales_models import (
SalesAutomationTaskOut,
SalesCallCompleteIn,
SalesCallOut,
SalesCallWebhookIn,
SalesCommunicationBindExternalIn,
SalesChannelSwitchOut,
SalesCommunicationOut,
SalesCommunicationStartIn,
SalesCommunicationSummaryIn,
SalesCommunicationSwitchChannelIn,
SalesConditionOut,
SalesConditionUpsertIn,
SalesCounterpartyOut,
SalesCounterpartyUpsertIn,
SalesDashboardStageOut,
SalesDashboardSummaryOut,
SalesDealCloseIn,
SalesDealCreate,
SalesDealNextActionIn,
SalesDealOut,
SalesDealScenarioIn,
SalesDealStageChangeIn,
SalesDealUpdate,
SalesDocumentCreate,
SalesDocumentOut,
SalesEscalationCreateIn,
SalesEscalationOut,
SalesInvoiceCreate,
SalesInvoiceOut,
SalesLeadCreate,
SalesLeadEnrichIn,
SalesLeadOut,
SalesLeadUpdate,
SalesMessageOut,
SalesMessageSendIn,
SalesMessageWebhookIn,
SalesOfferCreate,
SalesOfferOut,
SalesPaymentOut,
SalesPaymentReconcileIn,
SalesPaymentWebhookIn,
SalesStageHistoryOut,
SalesTelegramSyncIn,
SalesTimelineEventOut,
SalesTranscriptIn,
SalesTranscriptOut,
SalesVoiceSyncIn,
SalesWorkspaceOut,
)
from services.shared.security import issue_app_token, require_roles
from services.shared.sql_init import init_sql_schema
from services.shared.sql_models import Customer
from services.shared.sales_sql_models import (
SalesAutomationTaskRow,
SalesCallRow,
SalesChannelSwitchRow,
SalesCommunicationSessionRow,
SalesConditionRow,
SalesCounterpartyRow,
SalesDealRow,
SalesDocumentRow,
SalesEscalationRow,
SalesExternalLinkRow,
SalesInvoiceRow,
SalesLeadRow,
SalesMessageRow,
SalesOfferRow,
SalesPaymentRow,
SalesStageHistoryRow,
SalesTranscriptRow,
)
# Import the models module so sales tables are registered on the shared Base metadata.
from services.shared import sales_sql_models as _sales_sql_models # noqa: F401
app = FastAPI(title="sales-service", version="1.0.0")
init_sql_schema()
DEFAULT_TENANT_ID = os.getenv("DEFAULT_TENANT_ID", "tenant_default").strip() or "tenant_default"
DEFAULT_SALES_PIPELINE_ID = os.getenv("DEFAULT_SALES_PIPELINE_ID", "sales_default").strip() or "sales_default"
DEFAULT_SALES_CURRENCY = os.getenv("DEFAULT_SALES_CURRENCY", "KZT").strip() or "KZT"
STAGE_LABELS = {
"new_qualified_lead": "Новый квалифицированный лид",
"warm_lead": "Теплый лид",
"hot_lead": "Горячий лид",
"enrichment_required": "Нужно дообогащение",
"active_text_communication": "Текстовая коммуникация",
"active_voice_communication": "Голосовая коммуникация",
"waiting_customer_reply": "Ждем ответ клиента",
"need_clarification": "Нужно уточнение",
"need_confirmed": "Потребность подтверждена",
"offer_selection": "Подбор предложения",
"offer_preparing": "Готовим предложение",
"offer_sent": "Предложение отправлено",
"conditions_negotiation": "Согласование условий",
"counterparty_data_requested": "Запрос реквизитов",
"counterparty_data_received": "Реквизиты получены",
"document_preparing": "Готовим документ",
"document_sent": "Документ отправлен",
"document_under_review": "Документ на согласовании",
"document_confirmed": "Документ подтвержден",
"invoice_preparing": "Готовим счет",
"invoice_sent": "Счет отправлен",
"payment_expected": "Ожидаем оплату",
"partially_paid": "Оплачено частично",
"paid": "Оплачено",
"payment_overdue": "Оплата просрочена",
"won": "Сделка выиграна",
"lost": "Сделка проиграна",
"postponed": "Отложено",
"follow_up_scheduled": "Запланирован follow-up",
"transferred_to_execution": "Передано в исполнение",
"transferred_to_support": "Передано человеку",
}
def _tenant_id() -> str:
return DEFAULT_TENANT_ID
def _now() -> datetime:
return datetime.now(timezone.utc).replace(microsecond=0)
def _json_dict(raw: str | None) -> dict:
try:
payload = json.loads(raw or "{}")
except json.JSONDecodeError:
return {}
return payload if isinstance(payload, dict) else {}
def _json_list(raw: str | None) -> list:
try:
payload = json.loads(raw or "[]")
except json.JSONDecodeError:
return []
return payload if isinstance(payload, list) else []
def _service_headers(subject: str, username: str, provider: str) -> dict[str, str]:
token = issue_app_token(
subject=subject,
username=username,
role="admin",
auth_source="service",
provider=provider,
ttl_seconds=300,
)
return {"Authorization": f"Bearer {token}"}
def _bool_env(name: str, default: bool = False) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def _voice_runtime_enabled() -> bool:
return _bool_env("SALES_VOICE_RUNTIME_ENABLED", False)
def _telegram_bridge_enabled() -> bool:
return _bool_env("SALES_TELEGRAM_BRIDGE_ENABLED", False)
def _voice_runtime_url() -> str:
return os.getenv("AI_VOICE_RUNTIME_SERVICE_URL", "http://localhost:8018").rstrip("/")
def _telegram_adapter_url() -> str:
return os.getenv("TELEGRAM_ADAPTER_SERVICE_URL", "http://localhost:8007").rstrip("/")
def _default_handoff_queue_id() -> str:
return os.getenv("SALES_DEFAULT_HANDOFF_QUEUE_ID", "sales_line").strip() or "sales_line"
def _infer_text_channel(preferred_channel: str | None) -> str:
return "voice" if (preferred_channel or "").strip().lower() == "voice" else "text"
def _infer_stage_for_channel(channel: str) -> str:
return "active_voice_communication" if channel == "voice" else "active_text_communication"
def _stage_label(stage_id: str | None) -> str:
return STAGE_LABELS.get(stage_id or "", stage_id or "Неизвестный этап")
def _lead_to_out(row: SalesLeadRow) -> SalesLeadOut:
return SalesLeadOut(
lead_id=row.lead_id,
tenant_id=row.tenant_id,
source_type=row.source_type,
source_channel=row.source_channel,
source_campaign_id=row.source_campaign_id,
full_name=row.full_name,
company_name=row.company_name,
phone=row.phone,
email=row.email,
messenger_handles=_json_dict(row.messenger_handles_json),
lead_temperature=row.lead_temperature, # type: ignore[arg-type]
lead_score=row.lead_score,
customer_type=row.customer_type,
segment_type=row.segment_type,
initial_need_summary=row.initial_need_summary,
preferred_channel=row.preferred_channel, # type: ignore[arg-type]
assigned_agent_type=row.assigned_agent_type, # type: ignore[arg-type]
status=row.status, # type: ignore[arg-type]
crm_customer_id=row.crm_customer_id,
created_at=row.created_at,
updated_at=row.updated_at,
)
def _deal_to_out(row: SalesDealRow) -> SalesDealOut:
return SalesDealOut(
deal_id=row.deal_id,
tenant_id=row.tenant_id,
lead_id=row.lead_id,
customer_id=row.customer_id,
pipeline_id=row.pipeline_id,
stage_id=row.stage_id,
scenario_type=row.scenario_type, # type: ignore[arg-type]
priority=row.priority,
title=row.title,
need_summary=row.need_summary,
product_context=_json_dict(row.product_context_json),
estimated_amount=row.estimated_amount,
final_amount=row.final_amount,
currency=row.currency,
payment_model=row.payment_model,
document_required=row.document_required,
payment_required=row.payment_required,
assigned_human_user_id=row.assigned_human_user_id,
assigned_ai_orchestrator_id=row.assigned_ai_orchestrator_id,
preferred_channel=row.preferred_channel,
current_channel=row.current_channel,
status=row.status, # type: ignore[arg-type]
won_reason=row.won_reason,
lost_reason=row.lost_reason,
close_reason=row.close_reason,
next_action_type=row.next_action_type,
next_action_at=row.next_action_at,
last_contact_at=row.last_contact_at,
closed_at=row.closed_at,
created_at=row.created_at,
updated_at=row.updated_at,
)
def _communication_to_out(row: SalesCommunicationSessionRow) -> SalesCommunicationOut:
return SalesCommunicationOut(
communication_id=row.communication_id,
tenant_id=row.tenant_id,
deal_id=row.deal_id,
lead_id=row.lead_id,
customer_id=row.customer_id,
channel_type=row.channel_type, # type: ignore[arg-type]
direction=row.direction, # type: ignore[arg-type]
agent_type=row.agent_type, # type: ignore[arg-type]
started_at=row.started_at,
ended_at=row.ended_at,
duration_sec=row.duration_sec,
subject=row.subject,
status=row.status,
summary=row.summary,
transcript_id=row.transcript_id,
next_action_type=row.next_action_type,
next_action_at=row.next_action_at,
sentiment=row.sentiment,
result_code=row.result_code,
metadata=_json_dict(row.metadata_json),
created_at=row.created_at,
updated_at=row.updated_at,
)
def _message_to_out(row: SalesMessageRow) -> SalesMessageOut:
return SalesMessageOut(
message_id=row.message_id,
deal_id=row.deal_id,
communication_id=row.communication_id,
sender_type=row.sender_type,
sender_id=row.sender_id,
channel_provider=row.channel_provider,
external_message_id=row.external_message_id,
body=row.body,
attachments=_json_list(row.attachments_json),
delivery_status=row.delivery_status,
read_status=row.read_status,
metadata=_json_dict(row.message_metadata_json),
sent_at=row.sent_at,
created_at=row.created_at,
)
def _call_to_out(row: SalesCallRow) -> SalesCallOut:
return SalesCallOut(
call_id=row.call_id,
deal_id=row.deal_id,
communication_id=row.communication_id,
phone_number=row.phone_number,
direction=row.direction, # type: ignore[arg-type]
provider=row.provider,
external_call_id=row.external_call_id,
recording_url=row.recording_url,
transcript_status=row.transcript_status, # type: ignore[arg-type]
transcript_id=row.transcript_id,
call_status=row.call_status,
started_at=row.started_at,
ended_at=row.ended_at,
duration_sec=row.duration_sec,
summary=row.summary,
created_at=row.created_at,
updated_at=row.updated_at,
)
def _transcript_to_out(row: SalesTranscriptRow) -> SalesTranscriptOut:
return SalesTranscriptOut(
transcript_id=row.transcript_id,
call_id=row.call_id,
language=row.language,
transcript_text=row.transcript_text,
diarization=_json_dict(row.diarization_json),
extracted_entities=_json_dict(row.extracted_entities_json),
created_at=row.created_at,
updated_at=row.updated_at,
)
def _offer_to_out(row: SalesOfferRow) -> SalesOfferOut:
return SalesOfferOut(
offer_id=row.offer_id,
deal_id=row.deal_id,
offer_type=row.offer_type, # type: ignore[arg-type]
title=row.title,
description=row.description,
line_items=_json_list(row.line_items_json),
pricing=_json_dict(row.pricing_json),
total_amount=row.total_amount,
currency=row.currency,
validity_until=row.validity_until,
status=row.status, # type: ignore[arg-type]
rendered_document_url=row.rendered_document_url,
created_by_type=row.created_by_type,
created_at=row.created_at,
updated_at=row.updated_at,
)
def _condition_to_out(row: SalesConditionRow) -> SalesConditionOut:
return SalesConditionOut(
condition_id=row.condition_id,
deal_id=row.deal_id,
product_name=row.product_name,
service_name=row.service_name,
quantity=row.quantity,
unit=row.unit,
delivery_mode=row.delivery_mode,
execution_date=row.execution_date,
start_date=row.start_date,
end_date=row.end_date,
payment_terms=row.payment_terms,
custom_terms=_json_dict(row.custom_terms_json),
agreed_price=row.agreed_price,
currency=row.currency,
confirmed_at=row.confirmed_at,
created_at=row.created_at,
updated_at=row.updated_at,
)
def _counterparty_to_out(row: SalesCounterpartyRow) -> SalesCounterpartyOut:
return SalesCounterpartyOut(
counterparty_id=row.counterparty_id,
deal_id=row.deal_id,
customer_id=row.customer_id,
company_name=row.company_name,
full_name=row.full_name,
bin_iin=row.bin_iin,
address=row.address,
bank_details=_json_dict(row.bank_details_json),
signer_name=row.signer_name,
signer_role=row.signer_role,
signer_basis=row.signer_basis,
email_for_docs=row.email_for_docs,
phone_for_docs=row.phone_for_docs,
completeness_status=row.completeness_status,
created_at=row.created_at,
updated_at=row.updated_at,
)
def _document_to_out(row: SalesDocumentRow) -> SalesDocumentOut:
return SalesDocumentOut(
document_id=row.document_id,
deal_id=row.deal_id,
customer_id=row.customer_id,
document_type=row.document_type, # type: ignore[arg-type]
template_id=row.template_id,
version=row.version,
status=row.status, # type: ignore[arg-type]
file_url=row.file_url,
rendered_payload=_json_dict(row.rendered_payload_json),
external_sign_provider_id=row.external_sign_provider_id,
signed_at=row.signed_at,
created_at=row.created_at,
updated_at=row.updated_at,
)
def _invoice_to_out(row: SalesInvoiceRow) -> SalesInvoiceOut:
return SalesInvoiceOut(
invoice_id=row.invoice_id,
deal_id=row.deal_id,
customer_id=row.customer_id,
invoice_number=row.invoice_number,
basis_document_id=row.basis_document_id,
amount=row.amount,
currency=row.currency,
due_date=row.due_date,
status=row.status, # type: ignore[arg-type]
payment_link=row.payment_link,
line_items=_json_list(row.line_items_json),
metadata=_json_dict(row.metadata_json),
issued_at=row.issued_at,
paid_at=row.paid_at,
created_at=row.created_at,
updated_at=row.updated_at,
)
def _payment_to_out(row: SalesPaymentRow) -> SalesPaymentOut:
return SalesPaymentOut(
payment_id=row.payment_id,
deal_id=row.deal_id,
invoice_id=row.invoice_id,
payment_provider=row.payment_provider,
external_payment_id=row.external_payment_id,
amount=row.amount,
currency=row.currency,
status=row.status, # type: ignore[arg-type]
paid_at=row.paid_at,
payment_method=row.payment_method,
failure_reason=row.failure_reason,
metadata=_json_dict(row.metadata_json),
created_at=row.created_at,
updated_at=row.updated_at,
)
def _escalation_to_out(row: SalesEscalationRow) -> SalesEscalationOut:
return SalesEscalationOut(
escalation_id=row.escalation_id,
deal_id=row.deal_id,
escalation_type=row.escalation_type,
reason=row.reason,
severity=row.severity, # type: ignore[arg-type]
status=row.status, # type: ignore[arg-type]
assigned_to_user_id=row.assigned_to_user_id,
created_at=row.created_at,
resolved_at=row.resolved_at,
)
def _task_to_out(row: SalesAutomationTaskRow) -> SalesAutomationTaskOut:
return SalesAutomationTaskOut(
task_id=row.task_id,
deal_id=row.deal_id,
task_type=row.task_type,
payload=_json_dict(row.payload_json),
run_at=row.run_at,
status=row.status, # type: ignore[arg-type]
retry_count=row.retry_count,
last_error=row.last_error,
created_at=row.created_at,
updated_at=row.updated_at,
)
def _stage_history_to_out(row: SalesStageHistoryRow) -> SalesStageHistoryOut:
return SalesStageHistoryOut(
history_id=row.history_id,
deal_id=row.deal_id,
from_stage_id=row.from_stage_id,
to_stage_id=row.to_stage_id,
changed_by_type=row.changed_by_type,
changed_by_id=row.changed_by_id,
reason=row.reason,
changed_at=row.changed_at,
)
def _channel_switch_to_out(row: SalesChannelSwitchRow) -> SalesChannelSwitchOut:
return SalesChannelSwitchOut(
switch_id=row.switch_id,
deal_id=row.deal_id,
communication_id=row.communication_id,
from_channel=row.from_channel,
to_channel=row.to_channel,
reason_for_channel_switch=row.reason_for_channel_switch,
switched_at=row.switched_at,
)
def _get_lead(session, lead_id: str) -> SalesLeadRow:
row = session.execute(select(SalesLeadRow).where(SalesLeadRow.lead_id == lead_id)).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="Lead not found")
return row
def _get_deal(session, deal_id: str) -> SalesDealRow:
row = session.execute(select(SalesDealRow).where(SalesDealRow.deal_id == deal_id)).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="Deal not found")
return row
def _get_communication(session, communication_id: str) -> SalesCommunicationSessionRow:
row = session.execute(
select(SalesCommunicationSessionRow).where(SalesCommunicationSessionRow.communication_id == communication_id)
).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="Communication not found")
return row
def _find_external_link(
session,
*,
thread_id: str | None = None,
voice_session_id: str | None = None,
external_call_id: str | None = None,
interaction_id: str | None = None,
) -> SalesExternalLinkRow | None:
conditions = []
if str(thread_id or "").strip():
conditions.append(SalesExternalLinkRow.external_thread_id == str(thread_id).strip())
if str(voice_session_id or "").strip():
conditions.append(SalesExternalLinkRow.voice_session_id == str(voice_session_id).strip())
if str(external_call_id or "").strip():
conditions.append(SalesExternalLinkRow.external_call_id == str(external_call_id).strip())
if str(interaction_id or "").strip():
conditions.append(SalesExternalLinkRow.interaction_id == str(interaction_id).strip())
if not conditions:
return None
return session.execute(
select(SalesExternalLinkRow)
.where(or_(*conditions))
.order_by(SalesExternalLinkRow.id.desc())
).scalars().first()
def _find_customer_by_customer_id(session, customer_id: str | None) -> Customer | None:
normalized = str(customer_id or "").strip()
if not normalized:
return None
return session.execute(select(Customer).where(Customer.customer_id == normalized)).scalar_one_or_none()
def _create_lead_and_deal_for_contact(
session,
*,
source_channel: str,
preferred_channel: str,
full_name: str,
phone: str | None,
customer_id: str | None = None,
subject: str | None = None,
) -> tuple[SalesLeadRow, SalesDealRow]:
now = _now().isoformat()
lead = SalesLeadRow(
lead_id=new_id("sld"),
tenant_id=_tenant_id(),
source_type="autosync",
source_channel=source_channel,
source_campaign_id=None,
full_name=full_name,
company_name=None,
phone=phone,
email=None,
messenger_handles_json="{}",
lead_temperature="warm",
lead_score=60.0,
customer_type=None,
segment_type=None,
initial_need_summary=subject,
preferred_channel=preferred_channel,
assigned_agent_type="voice_ai" if preferred_channel == "voice" else "text_ai",
status="new_qualified_lead",
crm_customer_id=customer_id,
created_at=now,
updated_at=now,
)
deal = SalesDealRow(
deal_id=new_id("sde"),
tenant_id=_tenant_id(),
lead_id=lead.lead_id,
customer_id=customer_id,
pipeline_id=DEFAULT_SALES_PIPELINE_ID,
stage_id="new_qualified_lead",
scenario_type="quick_sale",
priority=3,
title=subject or f"Сделка {full_name}",
need_summary=subject,
product_context_json="{}",
estimated_amount=None,
final_amount=None,
currency=DEFAULT_SALES_CURRENCY,
payment_model=None,
document_required=True,
payment_required=True,
assigned_human_user_id=None,
assigned_ai_orchestrator_id=None,
preferred_channel=preferred_channel,
current_channel=preferred_channel,
status="active",
won_reason=None,
lost_reason=None,
close_reason=None,
next_action_type=None,
next_action_at=None,
last_contact_at=None,
closed_at=None,
created_at=now,
updated_at=now,
)
session.add(lead)
session.add(deal)
_record_stage_change(
session,
deal=deal,
from_stage_id=None,
to_stage_id="new_qualified_lead",
changed_by_type="system",
changed_by_id=None,
reason="autosync.created",
)
return lead, deal
def _resolve_or_create_sync_deal(
session,
*,
source_channel: str,
preferred_channel: str,
customer_id: str | None = None,
phone: str | None = None,
display_name: str | None = None,
interaction_id: str | None = None,
thread_id: str | None = None,
voice_session_id: str | None = None,
external_call_id: str | None = None,
subject: str | None = None,
) -> tuple[SalesLeadRow | None, SalesDealRow]:
link = _find_external_link(
session,
thread_id=thread_id,
voice_session_id=voice_session_id,
external_call_id=external_call_id,
interaction_id=interaction_id,
)
if link is not None:
deal = _get_deal(session, link.deal_id)
lead = _get_lead(session, deal.lead_id) if deal.lead_id else None
return lead, deal
normalized_customer_id = str(customer_id or "").strip() or None
if normalized_customer_id:
deal = session.execute(
select(SalesDealRow)
.where(SalesDealRow.customer_id == normalized_customer_id)
.where(SalesDealRow.status == "active")
.order_by(SalesDealRow.updated_at.desc(), SalesDealRow.id.desc())
).scalars().first()
if deal is not None:
lead = _get_lead(session, deal.lead_id) if deal.lead_id else None
return lead, deal
normalized_phone = str(phone or "").strip() or None
if normalized_phone:
lead, deal = _resolve_deal_for_inbound(
session,
deal_id=None,
lead_id=None,
phone=normalized_phone,
subject=subject,
source_channel=source_channel,
preferred_channel=preferred_channel,
)
if normalized_customer_id and deal.customer_id != normalized_customer_id:
deal.customer_id = normalized_customer_id
deal.updated_at = utc_now_iso()
return lead, deal
customer = _find_customer_by_customer_id(session, normalized_customer_id)
full_name = str(display_name or (customer.display_name if customer else "") or "").strip() or (
"Telegram customer" if source_channel == "telegram" else "Voice customer"
)
lead, deal = _create_lead_and_deal_for_contact(
session,
source_channel=source_channel,
preferred_channel=preferred_channel,
full_name=full_name,
phone=normalized_phone,
customer_id=normalized_customer_id,
subject=subject,
)
return lead, deal
def _find_customer_by_phone(session, phone: str | None) -> Customer | None:
normalized = str(phone or "").strip()
if not normalized:
return None
direct = session.execute(select(Customer).where(Customer.preferred_phone == normalized)).scalar_one_or_none()
if direct is not None:
return direct
rows = session.execute(select(Customer).order_by(Customer.id.desc())).scalars().all()
for row in rows:
phones = _json_list(row.phones_json)
if normalized in phones:
return row
return None
def _ensure_crm_customer(session, *, lead: SalesLeadRow | None, deal: SalesDealRow, counterparty: SalesCounterpartyRow | None) -> str | None:
if deal.customer_id:
return deal.customer_id
phone = None
display_name = None
tags = ["sales"]
if counterparty:
phone = counterparty.phone_for_docs
display_name = counterparty.full_name or counterparty.company_name
if counterparty.company_name:
tags.append("b2b")
if not display_name and lead:
display_name = lead.full_name
phone = phone or lead.phone
if not display_name:
return None
existing = _find_customer_by_phone(session, phone)
if existing is not None:
deal.customer_id = existing.customer_id
if lead and not lead.crm_customer_id:
lead.crm_customer_id = existing.customer_id
lead.updated_at = utc_now_iso()
return existing.customer_id
now = utc_now_iso()
customer = Customer(
customer_id=new_id("cus"),
display_name=display_name,
phones_json=json.dumps([phone] if phone else [], ensure_ascii=False),
preferred_phone=phone,
tags_json=json.dumps(tags, ensure_ascii=False),
created_at=now,
)
session.add(customer)
deal.customer_id = customer.customer_id
if lead:
lead.crm_customer_id = customer.customer_id
lead.updated_at = now
return customer.customer_id
def _record_stage_change(session, *, deal: SalesDealRow, from_stage_id: str | None, to_stage_id: str, changed_by_type: str, changed_by_id: str | None, reason: str | None = None) -> None:
session.add(
SalesStageHistoryRow(
history_id=new_id("sth"),
tenant_id=deal.tenant_id,
deal_id=deal.deal_id,
from_stage_id=from_stage_id,
to_stage_id=to_stage_id,
changed_by_type=changed_by_type,
changed_by_id=changed_by_id,
reason=reason,
changed_at=utc_now_iso(),
)
)
def _schedule_task(session, *, deal: SalesDealRow, task_type: str, run_at: str | None, payload: dict | None = None) -> SalesAutomationTaskRow:
now = utc_now_iso()
task = SalesAutomationTaskRow(
task_id=new_id("tsk"),
tenant_id=deal.tenant_id,
deal_id=deal.deal_id,
task_type=task_type,
payload_json=json.dumps(payload or {}, ensure_ascii=False),
run_at=run_at or now,
status="pending",
retry_count=0,
last_error=None,
created_at=now,
updated_at=now,
)
session.add(task)
return task
def _apply_stage(session, deal: SalesDealRow, stage_id: str, *, actor_type: str, actor_id: str | None, reason: str | None = None) -> None:
if deal.stage_id != stage_id:
previous = deal.stage_id
deal.stage_id = stage_id
_record_stage_change(
session,
deal=deal,
from_stage_id=previous,
to_stage_id=stage_id,
changed_by_type=actor_type,
changed_by_id=actor_id,
reason=reason,
)
deal.updated_at = utc_now_iso()
def _touch_deal_contact(deal: SalesDealRow) -> None:
now = utc_now_iso()
deal.last_contact_at = now
deal.updated_at = now
def _start_communication(session, *, deal: SalesDealRow, lead: SalesLeadRow | None, payload: SalesCommunicationStartIn, actor_user: str | None = None, metadata: dict | None = None) -> SalesCommunicationSessionRow:
now = utc_now_iso()
agent_type = payload.agent_type or ("voice_ai" if payload.channel_type == "voice" else "text_ai")
communication = SalesCommunicationSessionRow(
communication_id=new_id("com"),
tenant_id=deal.tenant_id,
deal_id=deal.deal_id,
lead_id=lead.lead_id if lead else deal.lead_id,
customer_id=deal.customer_id,
channel_type=payload.channel_type,
direction=payload.direction,
agent_type=agent_type,
started_at=now,
ended_at=None,
duration_sec=None,
subject=payload.subject,
status="active",
summary=payload.summary,
transcript_id=None,
next_action_type=payload.next_action_type,
next_action_at=payload.next_action_at,
sentiment=None,
result_code=None,
metadata_json=json.dumps(metadata or {}, ensure_ascii=False),
created_at=now,
updated_at=now,
)
session.add(communication)
deal.current_channel = "voice" if payload.channel_type == "voice" else (lead.preferred_channel if lead else deal.current_channel)
_touch_deal_contact(deal)
_apply_stage(
session,
deal,
_infer_stage_for_channel(payload.channel_type),
actor_type="system" if agent_type.endswith("_ai") else "human",
actor_id=actor_user,
reason=payload.subject or "communication.started",
)
if payload.next_action_type:
_schedule_task(
session,
deal=deal,
task_type=payload.next_action_type,
run_at=payload.next_action_at,
payload={"source": "communication.start", "communication_id": communication.communication_id},
)
return communication
def _resolve_deal_for_inbound(session, *, deal_id: str | None, lead_id: str | None, phone: str | None, subject: str | None, source_channel: str, preferred_channel: str) -> tuple[SalesLeadRow | None, SalesDealRow]:
if deal_id:
return None, _get_deal(session, deal_id)
if lead_id:
lead = _get_lead(session, lead_id)
deal = session.execute(
select(SalesDealRow)
.where(SalesDealRow.lead_id == lead.lead_id)
.order_by(SalesDealRow.id.desc())
).scalars().first()
if deal is None:
raise HTTPException(status_code=404, detail="Lead has no deal")
return lead, deal
normalized_phone = str(phone or "").strip()
if normalized_phone:
lead = session.execute(
select(SalesLeadRow)
.where(SalesLeadRow.phone == normalized_phone)
.order_by(SalesLeadRow.updated_at.desc(), SalesLeadRow.id.desc())
).scalars().first()
if lead is not None:
deal = session.execute(
select(SalesDealRow)
.where(SalesDealRow.lead_id == lead.lead_id, SalesDealRow.status == "active")
.order_by(SalesDealRow.updated_at.desc(), SalesDealRow.id.desc())
).scalars().first()
if deal is not None:
return lead, deal
crm_customer = _find_customer_by_phone(session, normalized_phone)
now = utc_now_iso()
lead = SalesLeadRow(
lead_id=new_id("sld"),
tenant_id=_tenant_id(),
source_type="inbound",
source_channel=source_channel,
source_campaign_id=None,
full_name=crm_customer.display_name if crm_customer else "Новый лид",
company_name=None,
phone=normalized_phone,
email=None,
messenger_handles_json="{}",
lead_temperature="warm",
lead_score=55,
customer_type=None,
segment_type=None,
initial_need_summary=subject,
preferred_channel=preferred_channel,
assigned_agent_type="voice_ai" if preferred_channel == "voice" else "text_ai",
status="new_qualified_lead",
crm_customer_id=crm_customer.customer_id if crm_customer else None,
created_at=now,
updated_at=now,
)
deal = SalesDealRow(
deal_id=new_id("sde"),
tenant_id=_tenant_id(),
lead_id=lead.lead_id,
customer_id=crm_customer.customer_id if crm_customer else None,
pipeline_id=DEFAULT_SALES_PIPELINE_ID,
stage_id="new_qualified_lead",
scenario_type="quick_sale",
priority=3,
title=subject or f"Новый лид {normalized_phone}",
need_summary=subject,
product_context_json="{}",
estimated_amount=None,
final_amount=None,
currency=DEFAULT_SALES_CURRENCY,
payment_model=None,
document_required=True,
payment_required=True,
assigned_human_user_id=None,
assigned_ai_orchestrator_id=None,
preferred_channel=preferred_channel,
current_channel=preferred_channel,
status="active",
won_reason=None,
lost_reason=None,
close_reason=None,
next_action_type=None,
next_action_at=None,
last_contact_at=None,
closed_at=None,
created_at=now,
updated_at=now,
)
session.add(lead)
session.add(deal)
_record_stage_change(
session,
deal=deal,
from_stage_id=None,
to_stage_id="new_qualified_lead",
changed_by_type="system",
changed_by_id=None,
reason="lead.entered_crm",
)
return lead, deal
raise HTTPException(status_code=400, detail="Cannot resolve inbound contact without deal_id, lead_id or phone")
def _bridge_voice_runtime(call: SalesCallRow, communication: SalesCommunicationSessionRow, deal: SalesDealRow) -> None:
if not _voice_runtime_enabled():
return
metadata = _json_dict(communication.metadata_json)
if metadata.get("voice_session_id"):
return
payload = {
"call_id": call.external_call_id or call.call_id,
"interaction_id": None,
"customer_id": deal.customer_id,
"queue_id": _default_handoff_queue_id(),
"ai_session_id": None,
"agent_profile": "voice_support",
"language": "ru",
"handoff_queue_id": _default_handoff_queue_id(),
"greeting_text": f"Здравствуйте! Продолжим работу по сделке: {deal.title}.",
}
try:
with httpx.Client(timeout=8.0) as client:
response = client.post(
f"{_voice_runtime_url()}/internal/voice-ai/sessions",
json=payload,
headers=_service_headers("svc:sales-service", "sales-service", "sales-service"),
)
response.raise_for_status()
data = response.json()
metadata["voice_session_id"] = data.get("voice_session_id")
metadata["voice_runtime"] = "attached"
communication.metadata_json = json.dumps(metadata, ensure_ascii=False)
communication.updated_at = utc_now_iso()
except Exception as exc: # noqa: BLE001
metadata["voice_runtime"] = "degraded"
metadata["voice_runtime_error"] = str(exc)
communication.metadata_json = json.dumps(metadata, ensure_ascii=False)
communication.updated_at = utc_now_iso()
def _bridge_telegram_outbound(communication: SalesCommunicationSessionRow, message: SalesMessageRow) -> None:
if not _telegram_bridge_enabled():
return
metadata = _json_dict(communication.metadata_json)
thread_id = str(metadata.get("telegram_thread_id") or "").strip()
if not thread_id or message.channel_provider != "telegram":
return
payload = {"text": message.body}
try:
with httpx.Client(timeout=8.0) as client:
response = client.post(
f"{_telegram_adapter_url()}/integrations/telegram/threads/{thread_id}/messages",
json=payload,
headers=_service_headers("svc:sales-service", "sales-service", "sales-service"),
)
response.raise_for_status()
message.delivery_status = "sent"
except Exception as exc: # noqa: BLE001
message.delivery_status = "queued"
metadata["telegram_bridge_error"] = str(exc)
communication.metadata_json = json.dumps(metadata, ensure_ascii=False)
communication.updated_at = utc_now_iso()
def _counterparty_status(payload: SalesCounterpartyUpsertIn) -> str:
required_hits = sum(
1
for value in [
payload.company_name or payload.full_name,
payload.email_for_docs or payload.phone_for_docs,
payload.bin_iin or payload.signer_name,
]
if str(value or "").strip()
)
return "completed" if required_hits >= 2 else "requested"
def _invoice_number() -> str:
return f"INV-{_now().strftime('%Y%m%d')}-{new_id('num').split('_', 1)[1].upper()}"
def _merge_communication_metadata(communication: SalesCommunicationSessionRow, values: dict[str, Any]) -> dict:
metadata = _json_dict(communication.metadata_json)
changed = False
for key, value in values.items():
if value is None:
if key in metadata:
metadata.pop(key, None)
changed = True
continue
if isinstance(value, str):
normalized = value.strip()
if not normalized:
if key in metadata:
metadata.pop(key, None)
changed = True
continue
value = normalized
if metadata.get(key) != value:
metadata[key] = value
changed = True
if changed:
communication.metadata_json = json.dumps(metadata, ensure_ascii=False)
communication.updated_at = utc_now_iso()
return metadata
def _upsert_external_link(
session,
*,
deal: SalesDealRow,
channel_provider: str,
communication_id: str | None = None,
sales_call_id: str | None = None,
external_thread_id: str | None = None,
external_chat_id: str | None = None,
external_call_id: str | None = None,
voice_session_id: str | None = None,
ai_session_id: str | None = None,
interaction_id: str | None = None,
customer_id: str | None = None,
phone_number: str | None = None,
external_status: str | None = None,
metadata: dict[str, Any] | None = None,
) -> SalesExternalLinkRow:
row = _find_external_link(
session,
thread_id=external_thread_id,
voice_session_id=voice_session_id,
external_call_id=external_call_id,
interaction_id=interaction_id,
)
now = utc_now_iso()
if row is None:
row = SalesExternalLinkRow(
link_id=new_id("xlk"),
tenant_id=deal.tenant_id,
deal_id=deal.deal_id,
communication_id=communication_id,
sales_call_id=sales_call_id,
channel_provider=channel_provider,
external_thread_id=external_thread_id,
external_chat_id=external_chat_id,
external_call_id=external_call_id,
voice_session_id=voice_session_id,
ai_session_id=ai_session_id,
interaction_id=interaction_id,
customer_id=customer_id or deal.customer_id,
phone_number=phone_number,
external_status=external_status,
link_metadata_json=json.dumps(metadata or {}, ensure_ascii=False),
created_at=now,
updated_at=now,
last_sync_at=now,
)
session.add(row)
return row
row.deal_id = deal.deal_id
row.communication_id = communication_id or row.communication_id
row.sales_call_id = sales_call_id or row.sales_call_id
row.channel_provider = channel_provider or row.channel_provider
row.external_thread_id = str(external_thread_id or "").strip() or row.external_thread_id
row.external_chat_id = str(external_chat_id or "").strip() or row.external_chat_id
row.external_call_id = str(external_call_id or "").strip() or row.external_call_id
row.voice_session_id = str(voice_session_id or "").strip() or row.voice_session_id
row.ai_session_id = str(ai_session_id or "").strip() or row.ai_session_id
row.interaction_id = str(interaction_id or "").strip() or row.interaction_id
row.customer_id = str(customer_id or "").strip() or row.customer_id or deal.customer_id
row.phone_number = str(phone_number or "").strip() or row.phone_number
row.external_status = str(external_status or "").strip() or row.external_status
merged_meta = _json_dict(row.link_metadata_json)
merged_meta.update(metadata or {})
row.link_metadata_json = json.dumps(merged_meta, ensure_ascii=False)
row.updated_at = now
row.last_sync_at = now
return row
def _get_or_create_text_communication(
session,
*,
deal: SalesDealRow,
lead: SalesLeadRow | None,
direction: str,
subject: str | None,
metadata: dict | None = None,
thread_id: str | None = None,
) -> SalesCommunicationSessionRow:
if str(thread_id or "").strip():
link = _find_external_link(session, thread_id=thread_id)
if link and link.communication_id:
existing = session.execute(
select(SalesCommunicationSessionRow).where(
SalesCommunicationSessionRow.communication_id == link.communication_id
)
).scalar_one_or_none()
if existing is not None:
_merge_communication_metadata(existing, metadata or {})
return existing
existing = session.execute(
select(SalesCommunicationSessionRow)
.where(SalesCommunicationSessionRow.deal_id == deal.deal_id)
.where(SalesCommunicationSessionRow.channel_type == "text")
.order_by(SalesCommunicationSessionRow.started_at.desc(), SalesCommunicationSessionRow.id.desc())
).scalars().first()
if existing is not None:
_merge_communication_metadata(existing, metadata or {})
return existing
return _start_communication(
session,
deal=deal,
lead=lead,
payload=SalesCommunicationStartIn(
channel_type="text",
direction=direction, # type: ignore[arg-type]
agent_type="human" if direction == "outbound" else "text_ai",
subject=subject,
),
metadata=metadata,
)
def _get_or_create_voice_communication(
session,
*,
deal: SalesDealRow,
lead: SalesLeadRow | None,
subject: str | None,
metadata: dict | None = None,
voice_session_id: str | None = None,
) -> SalesCommunicationSessionRow:
if str(voice_session_id or "").strip():
link = _find_external_link(session, voice_session_id=voice_session_id)
if link and link.communication_id:
existing = session.execute(
select(SalesCommunicationSessionRow).where(
SalesCommunicationSessionRow.communication_id == link.communication_id
)
).scalar_one_or_none()
if existing is not None:
_merge_communication_metadata(existing, metadata or {})
return existing
existing = session.execute(
select(SalesCommunicationSessionRow)
.where(SalesCommunicationSessionRow.deal_id == deal.deal_id)
.where(SalesCommunicationSessionRow.channel_type == "voice")
.order_by(SalesCommunicationSessionRow.started_at.desc(), SalesCommunicationSessionRow.id.desc())
).scalars().first()
if existing is not None:
_merge_communication_metadata(existing, metadata or {})
return existing
return _start_communication(
session,
deal=deal,
lead=lead,
payload=SalesCommunicationStartIn(
channel_type="voice",
direction="inbound",
agent_type="voice_ai",
subject=subject,
),
metadata=metadata,
)
def _build_workspace(session, deal: SalesDealRow) -> SalesWorkspaceOut:
lead = _get_lead(session, deal.lead_id) if deal.lead_id else None
communications = session.execute(
select(SalesCommunicationSessionRow)
.where(SalesCommunicationSessionRow.deal_id == deal.deal_id)
.order_by(SalesCommunicationSessionRow.started_at.desc(), SalesCommunicationSessionRow.id.desc())
).scalars().all()
messages = session.execute(
select(SalesMessageRow)
.where(SalesMessageRow.deal_id == deal.deal_id)
.order_by(SalesMessageRow.sent_at.desc(), SalesMessageRow.id.desc())
).scalars().all()
calls = session.execute(
select(SalesCallRow)
.where(SalesCallRow.deal_id == deal.deal_id)
.order_by(SalesCallRow.started_at.desc(), SalesCallRow.id.desc())
).scalars().all()
offers = session.execute(
select(SalesOfferRow)
.where(SalesOfferRow.deal_id == deal.deal_id)
.order_by(SalesOfferRow.updated_at.desc(), SalesOfferRow.id.desc())
).scalars().all()
conditions = session.execute(
select(SalesConditionRow)
.where(SalesConditionRow.deal_id == deal.deal_id)
.order_by(SalesConditionRow.updated_at.desc(), SalesConditionRow.id.desc())
).scalars().all()
counterparty = session.execute(
select(SalesCounterpartyRow).where(SalesCounterpartyRow.deal_id == deal.deal_id)
).scalar_one_or_none()
documents = session.execute(
select(SalesDocumentRow)
.where(SalesDocumentRow.deal_id == deal.deal_id)
.order_by(SalesDocumentRow.updated_at.desc(), SalesDocumentRow.id.desc())
).scalars().all()
invoices = session.execute(
select(SalesInvoiceRow)
.where(SalesInvoiceRow.deal_id == deal.deal_id)
.order_by(SalesInvoiceRow.updated_at.desc(), SalesInvoiceRow.id.desc())
).scalars().all()
payments = session.execute(
select(SalesPaymentRow)
.where(SalesPaymentRow.deal_id == deal.deal_id)
.order_by(SalesPaymentRow.updated_at.desc(), SalesPaymentRow.id.desc())
).scalars().all()
escalations = session.execute(
select(SalesEscalationRow)
.where(SalesEscalationRow.deal_id == deal.deal_id)
.order_by(SalesEscalationRow.created_at.desc(), SalesEscalationRow.id.desc())
).scalars().all()
tasks = session.execute(
select(SalesAutomationTaskRow)
.where(SalesAutomationTaskRow.deal_id == deal.deal_id)
.order_by(SalesAutomationTaskRow.run_at.desc(), SalesAutomationTaskRow.id.desc())
).scalars().all()
stage_history = session.execute(
select(SalesStageHistoryRow)
.where(SalesStageHistoryRow.deal_id == deal.deal_id)
.order_by(SalesStageHistoryRow.changed_at.desc(), SalesStageHistoryRow.id.desc())
).scalars().all()
channel_switches = session.execute(
select(SalesChannelSwitchRow)
.where(SalesChannelSwitchRow.deal_id == deal.deal_id)
.order_by(SalesChannelSwitchRow.switched_at.desc(), SalesChannelSwitchRow.id.desc())
).scalars().all()
timeline: list[SalesTimelineEventOut] = []
for row in stage_history:
timeline.append(
SalesTimelineEventOut(
ts=row.changed_at,
kind="stage",
title=f"Этап: {_stage_label(row.to_stage_id)}",
body=row.reason or "Изменение стадии сделки",
meta={"from_stage_id": row.from_stage_id, "to_stage_id": row.to_stage_id},
)
)
for row in communications:
timeline.append(
SalesTimelineEventOut(
ts=row.started_at,
kind="communication",
title=f"Коммуникация: {'звонок' if row.channel_type == 'voice' else 'текст'}",
body=row.summary or row.subject or "Открыта новая сессия коммуникации",
meta={"communication_id": row.communication_id, "agent_type": row.agent_type},
)
)
for row in offers:
timeline.append(
SalesTimelineEventOut(
ts=row.updated_at,
kind="offer",
title=f"Оффер: {row.title}",
body=f"Статус: {row.status}. Сумма: {row.total_amount:.2f} {row.currency}",
meta={"offer_id": row.offer_id},
)
)
for row in documents:
timeline.append(
SalesTimelineEventOut(
ts=row.updated_at,
kind="document",
title=f"Документ: {row.document_type}",
body=f"Статус: {row.status}",
meta={"document_id": row.document_id},
)
)
for row in invoices:
timeline.append(
SalesTimelineEventOut(
ts=row.updated_at,
kind="invoice",
title=f"Счет: {row.invoice_number}",
body=f"Статус: {row.status}. Сумма: {row.amount:.2f} {row.currency}",
meta={"invoice_id": row.invoice_id},
)
)
for row in payments:
timeline.append(
SalesTimelineEventOut(
ts=row.updated_at,
kind="payment",
title=f"Оплата: {row.status}",
body=f"Сумма: {row.amount:.2f} {row.currency}",
meta={"payment_id": row.payment_id},
)
)
for row in channel_switches:
timeline.append(
SalesTimelineEventOut(
ts=row.switched_at,
kind="channel_switch",
title=f"Смена канала: {row.from_channel} -> {row.to_channel}",
body=row.reason_for_channel_switch,
meta={"switch_id": row.switch_id},
)
)
timeline.sort(key=lambda item: item.ts, reverse=True)
return SalesWorkspaceOut(
lead=_lead_to_out(lead) if lead else None,
deal=_deal_to_out(deal),
communications=[_communication_to_out(row) for row in communications],
messages=[_message_to_out(row) for row in messages],
calls=[_call_to_out(row) for row in calls],
offers=[_offer_to_out(row) for row in offers],
conditions=[_condition_to_out(row) for row in conditions],
counterparty=_counterparty_to_out(counterparty) if counterparty else None,
documents=[_document_to_out(row) for row in documents],
invoices=[_invoice_to_out(row) for row in invoices],
payments=[_payment_to_out(row) for row in payments],
escalations=[_escalation_to_out(row) for row in escalations],
tasks=[_task_to_out(row) for row in tasks],
stage_history=[_stage_history_to_out(row) for row in stage_history],
channel_switches=[_channel_switch_to_out(row) for row in channel_switches],
timeline=timeline[:40],
)
@app.get("/health", response_model=HealthResponse)
def health() -> HealthResponse:
return HealthResponse(status="ok", service="sales-service")
@app.get("/api/v1/dashboard", response_model=SalesDashboardSummaryOut)
def sales_dashboard(_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST))) -> SalesDashboardSummaryOut:
session = get_session()
try:
leads_total = session.execute(select(func.count()).select_from(SalesLeadRow)).scalar_one()
deals_active = session.execute(
select(func.count()).select_from(SalesDealRow).where(SalesDealRow.status == "active")
).scalar_one()
deals_won = session.execute(
select(func.count()).select_from(SalesDealRow).where(SalesDealRow.status == "won")
).scalar_one()
deals_lost = session.execute(
select(func.count()).select_from(SalesDealRow).where(SalesDealRow.status == "lost")
).scalar_one()
overdue_invoices = session.execute(
select(func.count()).select_from(SalesInvoiceRow).where(SalesInvoiceRow.status == "overdue")
).scalar_one()
payment_expected = session.execute(
select(func.coalesce(func.sum(SalesInvoiceRow.amount), 0.0)).where(
SalesInvoiceRow.status.in_(["issued", "sent", "partially_paid", "overdue"])
)
).scalar_one()
payment_received = session.execute(
select(func.coalesce(func.sum(SalesPaymentRow.amount), 0.0)).where(SalesPaymentRow.status.in_(["success", "partial"]))
).scalar_one()
voice_sessions = session.execute(
select(func.count()).select_from(SalesCommunicationSessionRow).where(SalesCommunicationSessionRow.channel_type == "voice")
).scalar_one()
text_sessions = session.execute(
select(func.count()).select_from(SalesCommunicationSessionRow).where(SalesCommunicationSessionRow.channel_type == "text")
).scalar_one()
channel_switches = session.execute(select(func.count()).select_from(SalesChannelSwitchRow)).scalar_one()
human_escalations = session.execute(select(func.count()).select_from(SalesEscalationRow)).scalar_one()
stage_rows = session.execute(
select(SalesDealRow.stage_id, func.count(), func.coalesce(func.sum(SalesDealRow.final_amount), func.sum(SalesDealRow.estimated_amount), 0.0))
.group_by(SalesDealRow.stage_id)
).all()
stage_counts = [
SalesDashboardStageOut(stage_id=stage_id, label=_stage_label(stage_id), count=int(count or 0), amount=float(amount or 0.0))
for stage_id, count, amount in stage_rows
]
hottest_rows = session.execute(
select(SalesDealRow)
.order_by(SalesDealRow.priority.asc(), SalesDealRow.updated_at.desc(), SalesDealRow.id.desc())
.limit(5)
).scalars().all()
return SalesDashboardSummaryOut(
leads_total=int(leads_total or 0),
deals_active=int(deals_active or 0),
deals_won=int(deals_won or 0),
deals_lost=int(deals_lost or 0),
overdue_invoices=int(overdue_invoices or 0),
payment_expected=float(payment_expected or 0.0),
payment_received=float(payment_received or 0.0),
voice_sessions=int(voice_sessions or 0),
text_sessions=int(text_sessions or 0),
channel_switches=int(channel_switches or 0),
human_escalations=int(human_escalations or 0),
stage_counts=stage_counts,
hottest_deals=[_deal_to_out(row) for row in hottest_rows],
)
finally:
session.close()
@app.post("/api/v1/leads", response_model=SalesLeadOut)
def create_lead(
payload: SalesLeadCreate,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesLeadOut:
session = get_session()
try:
now = utc_now_iso()
crm_customer = _find_customer_by_phone(session, payload.phone)
lead = SalesLeadRow(
lead_id=new_id("sld"),
tenant_id=_tenant_id(),
source_type=payload.source_type,
source_channel=payload.source_channel,
source_campaign_id=payload.source_campaign_id,
full_name=payload.full_name,
company_name=payload.company_name,
phone=payload.phone,
email=payload.email,
messenger_handles_json=json.dumps(payload.messenger_handles, ensure_ascii=False),
lead_temperature=payload.lead_temperature,
lead_score=payload.lead_score,
customer_type=payload.customer_type,
segment_type=payload.segment_type,
initial_need_summary=payload.initial_need_summary,
preferred_channel=payload.preferred_channel,
assigned_agent_type=payload.assigned_agent_type,
status=payload.status,
crm_customer_id=crm_customer.customer_id if crm_customer else None,
created_at=now,
updated_at=now,
)
deal = SalesDealRow(
deal_id=new_id("sde"),
tenant_id=_tenant_id(),
lead_id=lead.lead_id,
customer_id=crm_customer.customer_id if crm_customer else None,
pipeline_id=DEFAULT_SALES_PIPELINE_ID,
stage_id=payload.status,
scenario_type="quick_sale",
priority=payload.priority,
title=payload.title or f"{payload.full_name}{payload.source_channel}",
need_summary=payload.initial_need_summary,
product_context_json="{}",
estimated_amount=payload.estimated_amount,
final_amount=None,
currency=payload.currency or DEFAULT_SALES_CURRENCY,
payment_model=None,
document_required=True,
payment_required=True,
assigned_human_user_id=None,
assigned_ai_orchestrator_id=None,
preferred_channel=payload.preferred_channel,
current_channel=payload.preferred_channel,
status="active",
won_reason=None,
lost_reason=None,
close_reason=None,
next_action_type=None,
next_action_at=None,
last_contact_at=None,
closed_at=None,
created_at=now,
updated_at=now,
)
session.add(lead)
session.add(deal)
_record_stage_change(
session,
deal=deal,
from_stage_id=None,
to_stage_id=payload.status,
changed_by_type="human",
changed_by_id=actor["user"],
reason="lead.entered_crm",
)
session.commit()
return _lead_to_out(lead)
finally:
session.close()
@app.get("/api/v1/leads", response_model=list[SalesLeadOut])
def list_leads(
query: str | None = None,
status: str | None = None,
limit: int = Query(default=100, ge=1, le=200),
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> list[SalesLeadOut]:
session = get_session()
try:
stmt = select(SalesLeadRow).order_by(SalesLeadRow.updated_at.desc(), SalesLeadRow.id.desc())
if status:
stmt = stmt.where(SalesLeadRow.status == status)
if query:
pattern = f"%{query.strip().lower()}%"
stmt = stmt.where(
or_(
func.lower(func.coalesce(SalesLeadRow.full_name, "")).like(pattern),
func.lower(func.coalesce(SalesLeadRow.company_name, "")).like(pattern),
func.lower(func.coalesce(SalesLeadRow.phone, "")).like(pattern),
func.lower(func.coalesce(SalesLeadRow.email, "")).like(pattern),
)
)
rows = session.execute(stmt.limit(limit)).scalars().all()
return [_lead_to_out(row) for row in rows]
finally:
session.close()
@app.get("/api/v1/leads/{lead_id}", response_model=SalesLeadOut)
def get_lead(
lead_id: str,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> SalesLeadOut:
session = get_session()
try:
return _lead_to_out(_get_lead(session, lead_id))
finally:
session.close()
@app.patch("/api/v1/leads/{lead_id}", response_model=SalesLeadOut)
def update_lead(
lead_id: str,
payload: SalesLeadUpdate,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesLeadOut:
session = get_session()
try:
lead = _get_lead(session, lead_id)
updates = payload.model_dump(exclude_unset=True)
if "messenger_handles" in updates:
lead.messenger_handles_json = json.dumps(updates.pop("messenger_handles") or {}, ensure_ascii=False)
for key, value in updates.items():
setattr(lead, key, value)
lead.updated_at = utc_now_iso()
session.commit()
return _lead_to_out(lead)
finally:
session.close()
@app.post("/api/v1/leads/{lead_id}/enrich", response_model=SalesWorkspaceOut)
def enrich_lead(
lead_id: str,
payload: SalesLeadEnrichIn,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesWorkspaceOut:
session = get_session()
try:
lead = _get_lead(session, lead_id)
if payload.need_summary:
lead.initial_need_summary = payload.need_summary
if payload.customer_type:
lead.customer_type = payload.customer_type
if payload.preferred_channel:
lead.preferred_channel = payload.preferred_channel
lead.updated_at = utc_now_iso()
deal = session.execute(
select(SalesDealRow).where(SalesDealRow.lead_id == lead.lead_id).order_by(SalesDealRow.id.desc())
).scalars().first()
if deal is None:
raise HTTPException(status_code=404, detail="Deal not found for lead")
if payload.need_summary:
deal.need_summary = payload.need_summary
if payload.product_context:
deal.product_context_json = json.dumps(payload.product_context, ensure_ascii=False)
if payload.preferred_channel:
deal.preferred_channel = payload.preferred_channel
deal.current_channel = payload.preferred_channel
deal.updated_at = utc_now_iso()
next_stage = _infer_stage_for_channel(_infer_text_channel(lead.preferred_channel))
_apply_stage(
session,
deal,
next_stage,
actor_type="human",
actor_id=actor["user"],
reason="lead.enrichment_completed",
)
session.commit()
return _build_workspace(session, deal)
finally:
session.close()
@app.post("/api/v1/leads/{lead_id}/convert-to-deal", response_model=SalesDealOut)
def convert_lead_to_deal(
lead_id: str,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesDealOut:
session = get_session()
try:
lead = _get_lead(session, lead_id)
lead.status = "converted"
lead.updated_at = utc_now_iso()
deal = session.execute(
select(SalesDealRow).where(SalesDealRow.lead_id == lead.lead_id).order_by(SalesDealRow.id.desc())
).scalars().first()
if deal is None:
raise HTTPException(status_code=404, detail="Deal not found for lead")
session.commit()
return _deal_to_out(deal)
finally:
session.close()
@app.post("/api/v1/deals", response_model=SalesDealOut)
def create_deal(
payload: SalesDealCreate,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesDealOut:
session = get_session()
try:
now = utc_now_iso()
deal = SalesDealRow(
deal_id=new_id("sde"),
tenant_id=_tenant_id(),
lead_id=payload.lead_id,
customer_id=payload.customer_id,
pipeline_id=DEFAULT_SALES_PIPELINE_ID,
stage_id=payload.stage_id,
scenario_type=payload.scenario_type,
priority=payload.priority,
title=payload.title,
need_summary=payload.need_summary,
product_context_json=json.dumps(payload.product_context, ensure_ascii=False),
estimated_amount=payload.estimated_amount,
final_amount=payload.final_amount,
currency=payload.currency or DEFAULT_SALES_CURRENCY,
payment_model=payload.payment_model,
document_required=payload.document_required,
payment_required=payload.payment_required,
assigned_human_user_id=None,
assigned_ai_orchestrator_id=None,
preferred_channel=payload.preferred_channel,
current_channel=payload.current_channel,
status="active",
won_reason=None,
lost_reason=None,
close_reason=None,
next_action_type=None,
next_action_at=None,
last_contact_at=None,
closed_at=None,
created_at=now,
updated_at=now,
)
session.add(deal)
_record_stage_change(
session,
deal=deal,
from_stage_id=None,
to_stage_id=payload.stage_id,
changed_by_type="human",
changed_by_id=actor["user"],
reason="deal.created",
)
session.commit()
return _deal_to_out(deal)
finally:
session.close()
@app.get("/api/v1/deals", response_model=list[SalesDealOut])
def list_deals(
query: str | None = None,
stage_id: str | None = None,
status: str | None = None,
limit: int = Query(default=100, ge=1, le=300),
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> list[SalesDealOut]:
session = get_session()
try:
stmt = select(SalesDealRow).order_by(SalesDealRow.updated_at.desc(), SalesDealRow.id.desc())
if stage_id:
stmt = stmt.where(SalesDealRow.stage_id == stage_id)
if status:
stmt = stmt.where(SalesDealRow.status == status)
if query:
normalized_query = query.strip().lower()
pattern = f"%{normalized_query}%"
matching_lead_ids = session.execute(
select(SalesLeadRow.lead_id).where(
or_(
func.lower(func.coalesce(SalesLeadRow.full_name, "")).like(pattern),
func.lower(func.coalesce(SalesLeadRow.company_name, "")).like(pattern),
func.lower(func.coalesce(SalesLeadRow.phone, "")).like(pattern),
func.lower(func.coalesce(SalesLeadRow.email, "")).like(pattern),
)
)
).scalars().all()
matching_deal_ids = session.execute(
select(SalesExternalLinkRow.deal_id).where(
or_(
func.lower(func.coalesce(SalesExternalLinkRow.external_thread_id, "")).like(pattern),
func.lower(func.coalesce(SalesExternalLinkRow.external_chat_id, "")).like(pattern),
func.lower(func.coalesce(SalesExternalLinkRow.voice_session_id, "")).like(pattern),
func.lower(func.coalesce(SalesExternalLinkRow.external_call_id, "")).like(pattern),
func.lower(func.coalesce(SalesExternalLinkRow.interaction_id, "")).like(pattern),
func.lower(func.coalesce(SalesExternalLinkRow.phone_number, "")).like(pattern),
func.lower(func.coalesce(SalesExternalLinkRow.customer_id, "")).like(pattern),
)
)
).scalars().all()
stmt = stmt.where(
or_(
func.lower(func.coalesce(SalesDealRow.title, "")).like(pattern),
func.lower(func.coalesce(SalesDealRow.need_summary, "")).like(pattern),
func.lower(func.coalesce(SalesDealRow.customer_id, "")).like(pattern),
SalesDealRow.lead_id.in_(matching_lead_ids or [""]),
SalesDealRow.deal_id.in_(matching_deal_ids or [""]),
)
)
rows = session.execute(stmt.limit(limit)).scalars().all()
return [_deal_to_out(row) for row in rows]
finally:
session.close()
@app.get("/api/v1/deals/{deal_id}", response_model=SalesDealOut)
def get_deal(
deal_id: str,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> SalesDealOut:
session = get_session()
try:
return _deal_to_out(_get_deal(session, deal_id))
finally:
session.close()
@app.get("/api/v1/deals/{deal_id}/workspace", response_model=SalesWorkspaceOut)
def get_workspace(
deal_id: str,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> SalesWorkspaceOut:
session = get_session()
try:
return _build_workspace(session, _get_deal(session, deal_id))
finally:
session.close()
@app.patch("/api/v1/deals/{deal_id}", response_model=SalesDealOut)
def update_deal(
deal_id: str,
payload: SalesDealUpdate,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesDealOut:
session = get_session()
try:
deal = _get_deal(session, deal_id)
updates = payload.model_dump(exclude_unset=True)
if "product_context" in updates:
deal.product_context_json = json.dumps(updates.pop("product_context") or {}, ensure_ascii=False)
for key, value in updates.items():
setattr(deal, key, value)
deal.updated_at = utc_now_iso()
session.commit()
return _deal_to_out(deal)
finally:
session.close()
@app.post("/api/v1/deals/{deal_id}/change-stage", response_model=SalesDealOut)
def change_stage(
deal_id: str,
payload: SalesDealStageChangeIn,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesDealOut:
session = get_session()
try:
deal = _get_deal(session, deal_id)
_apply_stage(session, deal, payload.stage_id, actor_type="human", actor_id=actor["user"], reason=payload.reason)
session.commit()
return _deal_to_out(deal)
finally:
session.close()
@app.post("/api/v1/deals/{deal_id}/select-scenario", response_model=SalesDealOut)
def select_scenario(
deal_id: str,
payload: SalesDealScenarioIn,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesDealOut:
session = get_session()
try:
deal = _get_deal(session, deal_id)
deal.scenario_type = payload.scenario_type
deal.updated_at = utc_now_iso()
session.commit()
return _deal_to_out(deal)
finally:
session.close()
@app.post("/api/v1/deals/{deal_id}/schedule-next-action", response_model=SalesDealOut)
def schedule_next_action(
deal_id: str,
payload: SalesDealNextActionIn,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesDealOut:
session = get_session()
try:
deal = _get_deal(session, deal_id)
deal.next_action_type = payload.next_action_type
deal.next_action_at = payload.next_action_at
deal.updated_at = utc_now_iso()
_schedule_task(
session,
deal=deal,
task_type=payload.next_action_type,
run_at=payload.next_action_at,
payload={**payload.payload, "scheduled_by": actor["user"]},
)
_apply_stage(
session,
deal,
"follow_up_scheduled",
actor_type="human",
actor_id=actor["user"],
reason="deal.next_action_scheduled",
)
session.commit()
return _deal_to_out(deal)
finally:
session.close()
@app.post("/api/v1/deals/{deal_id}/close", response_model=SalesDealOut)
def close_deal(
deal_id: str,
payload: SalesDealCloseIn,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesDealOut:
session = get_session()
try:
deal = _get_deal(session, deal_id)
deal.status = payload.status
deal.closed_at = utc_now_iso()
deal.close_reason = payload.reason
if payload.status == "won":
deal.won_reason = payload.reason
elif payload.status == "lost":
deal.lost_reason = payload.reason
_apply_stage(
session,
deal,
payload.status,
actor_type="human",
actor_id=actor["user"],
reason=payload.reason or "deal.closed",
)
session.commit()
return _deal_to_out(deal)
finally:
session.close()
@app.post("/api/v1/deals/{deal_id}/escalate", response_model=SalesEscalationOut)
def escalate_deal(
deal_id: str,
payload: SalesEscalationCreateIn,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesEscalationOut:
session = get_session()
try:
deal = _get_deal(session, deal_id)
now = utc_now_iso()
row = SalesEscalationRow(
escalation_id=new_id("esc"),
tenant_id=deal.tenant_id,
deal_id=deal.deal_id,
escalation_type=payload.escalation_type,
reason=payload.reason,
severity=payload.severity,
status="open",
assigned_to_user_id=payload.assigned_to_user_id,
created_at=now,
resolved_at=None,
)
session.add(row)
deal.assigned_human_user_id = payload.assigned_to_user_id or actor["user"]
deal.scenario_type = "custom_human_escalation"
_apply_stage(
session,
deal,
"transferred_to_support",
actor_type="human",
actor_id=actor["user"],
reason=payload.reason,
)
session.commit()
return _escalation_to_out(row)
finally:
session.close()
@app.post("/api/v1/deals/{deal_id}/communications/text", response_model=SalesCommunicationOut)
def start_text_communication(
deal_id: str,
payload: SalesCommunicationStartIn,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesCommunicationOut:
payload = payload.model_copy(update={"channel_type": "text"})
session = get_session()
try:
deal = _get_deal(session, deal_id)
lead = _get_lead(session, deal.lead_id) if deal.lead_id else None
row = _start_communication(session, deal=deal, lead=lead, payload=payload, actor_user=actor["user"])
session.commit()
return _communication_to_out(row)
finally:
session.close()
@app.post("/api/v1/deals/{deal_id}/communications/voice", response_model=SalesCommunicationOut)
def start_voice_communication(
deal_id: str,
payload: SalesCommunicationStartIn,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesCommunicationOut:
payload = payload.model_copy(update={"channel_type": "voice"})
session = get_session()
try:
deal = _get_deal(session, deal_id)
lead = _get_lead(session, deal.lead_id) if deal.lead_id else None
row = _start_communication(session, deal=deal, lead=lead, payload=payload, actor_user=actor["user"])
session.commit()
return _communication_to_out(row)
finally:
session.close()
@app.get("/api/v1/deals/{deal_id}/communications", response_model=list[SalesCommunicationOut])
def list_communications(
deal_id: str,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> list[SalesCommunicationOut]:
session = get_session()
try:
_get_deal(session, deal_id)
rows = session.execute(
select(SalesCommunicationSessionRow)
.where(SalesCommunicationSessionRow.deal_id == deal_id)
.order_by(SalesCommunicationSessionRow.started_at.desc(), SalesCommunicationSessionRow.id.desc())
).scalars().all()
return [_communication_to_out(row) for row in rows]
finally:
session.close()
@app.post("/api/v1/communications/{communication_id}/summary", response_model=SalesCommunicationOut)
def summarize_communication(
communication_id: str,
payload: SalesCommunicationSummaryIn,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesCommunicationOut:
session = get_session()
try:
row = session.execute(
select(SalesCommunicationSessionRow).where(SalesCommunicationSessionRow.communication_id == communication_id)
).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="Communication not found")
row.summary = payload.summary
row.result_code = payload.result_code
row.sentiment = payload.sentiment
row.next_action_type = payload.next_action_type
row.next_action_at = payload.next_action_at
row.updated_at = utc_now_iso()
deal = _get_deal(session, row.deal_id)
_touch_deal_contact(deal)
if payload.next_action_type:
deal.next_action_type = payload.next_action_type
deal.next_action_at = payload.next_action_at
_schedule_task(
session,
deal=deal,
task_type=payload.next_action_type,
run_at=payload.next_action_at,
payload={"source": "communication.summary", "communication_id": row.communication_id},
)
session.commit()
return _communication_to_out(row)
finally:
session.close()
@app.post("/api/v1/communications/{communication_id}/switch-channel", response_model=SalesChannelSwitchOut)
def switch_channel(
communication_id: str,
payload: SalesCommunicationSwitchChannelIn,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesChannelSwitchOut:
session = get_session()
try:
communication = _get_communication(session, communication_id)
deal = _get_deal(session, communication.deal_id)
from_channel = "voice" if communication.channel_type == "voice" else deal.current_channel
now = utc_now_iso()
row = SalesChannelSwitchRow(
switch_id=new_id("swc"),
tenant_id=deal.tenant_id,
deal_id=deal.deal_id,
communication_id=communication.communication_id,
from_channel=from_channel,
to_channel=payload.to_channel,
reason_for_channel_switch=payload.reason_for_channel_switch,
switched_at=now,
)
session.add(row)
deal.current_channel = payload.to_channel
deal.updated_at = now
_apply_stage(
session,
deal,
_infer_stage_for_channel(_infer_text_channel(payload.to_channel)),
actor_type="human",
actor_id=actor["user"],
reason=payload.reason_for_channel_switch,
)
_schedule_task(
session,
deal=deal,
task_type=f"switch_to_{payload.to_channel}",
run_at=now,
payload={"reason": payload.reason_for_channel_switch, "communication_id": communication.communication_id},
)
session.commit()
return _channel_switch_to_out(row)
finally:
session.close()
@app.post("/api/v1/communications/{communication_id}/bind-external", response_model=SalesCommunicationOut)
def bind_external_communication(
communication_id: str,
payload: SalesCommunicationBindExternalIn,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesCommunicationOut:
session = get_session()
try:
communication = _get_communication(session, communication_id)
deal = _get_deal(session, communication.deal_id)
metadata_updates = dict(payload.metadata or {})
if payload.channel_provider:
metadata_updates["channel_provider"] = payload.channel_provider
if payload.telegram_thread_id:
metadata_updates["telegram_thread_id"] = payload.telegram_thread_id
if payload.telegram_chat_id:
metadata_updates["telegram_chat_id"] = payload.telegram_chat_id
if payload.voice_session_id:
metadata_updates["voice_session_id"] = payload.voice_session_id
if payload.external_call_id:
metadata_updates["external_call_id"] = payload.external_call_id
_merge_communication_metadata(communication, metadata_updates)
text_channels = {"telegram", "whatsapp", "webchat", "email"}
channel_provider = str(payload.channel_provider or "").strip().lower()
if payload.telegram_thread_id or channel_provider in text_channels:
next_channel = channel_provider if channel_provider in text_channels else "telegram"
deal.current_channel = next_channel
deal.preferred_channel = next_channel
if communication.channel_type != "voice":
_apply_stage(
session,
deal,
"active_text_communication",
actor_type="human",
actor_id=actor["user"],
reason="external.channel_bound",
)
if payload.voice_session_id:
deal.current_channel = "voice"
deal.preferred_channel = "voice"
_apply_stage(
session,
deal,
"active_voice_communication",
actor_type="human",
actor_id=actor["user"],
reason="voice.session_bound",
)
if payload.external_call_id:
call = session.execute(
select(SalesCallRow)
.where(SalesCallRow.communication_id == communication.communication_id)
.order_by(SalesCallRow.id.desc())
.limit(1)
).scalar_one_or_none()
if call is not None:
call.external_call_id = payload.external_call_id.strip()
call.updated_at = utc_now_iso()
session.commit()
return _communication_to_out(communication)
finally:
session.close()
@app.post("/internal/sales-sync/telegram", response_model=SalesWorkspaceOut)
def sync_telegram_thread(
payload: SalesTelegramSyncIn,
_: dict = Depends(require_roles(Role.ADMIN)),
) -> SalesWorkspaceOut:
session = get_session()
try:
lead, deal = _resolve_or_create_sync_deal(
session,
source_channel="telegram",
preferred_channel="telegram",
customer_id=payload.customer_id,
phone=payload.phone_number,
display_name=payload.display_name,
interaction_id=payload.interaction_id,
thread_id=payload.thread_id,
subject=payload.text or payload.display_name or "Telegram inquiry",
)
communication = _get_or_create_text_communication(
session,
deal=deal,
lead=lead,
direction=payload.direction,
subject=(payload.text or payload.display_name or "Telegram conversation")[:120],
metadata={
"telegram_thread_id": payload.thread_id,
"telegram_chat_id": payload.chat_id,
"interaction_id": payload.interaction_id,
"queue_id": payload.queue_id,
"ai_state": payload.ai_state,
"ai_handoff_reason": payload.ai_handoff_reason,
**(payload.metadata or {}),
},
thread_id=payload.thread_id,
)
event_ts = str(payload.happened_at or utc_now_iso()).strip() or utc_now_iso()
dedupe_external_message_id = str(payload.external_message_id or payload.message_id or "").strip() or None
if dedupe_external_message_id:
message = session.execute(
select(SalesMessageRow)
.where(SalesMessageRow.channel_provider == "telegram")
.where(SalesMessageRow.external_message_id == dedupe_external_message_id)
).scalar_one_or_none()
else:
message = None
if message is None and str(payload.text or "").strip():
message = SalesMessageRow(
message_id=new_id("msg"),
tenant_id=deal.tenant_id,
deal_id=deal.deal_id,
communication_id=communication.communication_id,
sender_type=payload.author_type or ("customer" if payload.direction == "inbound" else "human"),
sender_id=payload.author_id,
channel_provider="telegram",
external_message_id=dedupe_external_message_id,
body=payload.text.strip(),
attachments_json="[]",
delivery_status="sent" if payload.direction == "outbound" else "received",
read_status="unread" if payload.direction == "inbound" else None,
message_metadata_json=json.dumps(payload.metadata or {}, ensure_ascii=False),
sent_at=event_ts,
created_at=event_ts,
)
session.add(message)
_upsert_external_link(
session,
deal=deal,
communication_id=communication.communication_id,
channel_provider="telegram",
external_thread_id=payload.thread_id,
external_chat_id=payload.chat_id,
interaction_id=payload.interaction_id,
customer_id=payload.customer_id or deal.customer_id,
phone_number=payload.phone_number,
external_status=payload.status or payload.ai_state,
metadata={
"display_name": payload.display_name,
"queue_id": payload.queue_id,
"last_direction": payload.direction,
"last_author_type": payload.author_type,
"ai_state": payload.ai_state,
"ai_handoff_reason": payload.ai_handoff_reason,
**(payload.metadata or {}),
},
)
deal.current_channel = "telegram"
deal.preferred_channel = "telegram"
deal.updated_at = utc_now_iso()
session.commit()
return _build_workspace(session, deal)
finally:
session.close()
@app.post("/internal/sales-sync/voice", response_model=SalesWorkspaceOut)
def sync_voice_session(
payload: SalesVoiceSyncIn,
_: dict = Depends(require_roles(Role.ADMIN)),
) -> SalesWorkspaceOut:
session = get_session()
try:
lead, deal = _resolve_or_create_sync_deal(
session,
source_channel="voice",
preferred_channel="voice",
phone=payload.caller_number,
display_name=payload.caller_name,
interaction_id=payload.interaction_id,
voice_session_id=payload.voice_session_id,
external_call_id=payload.call_id,
subject=payload.summary or payload.caller_name or "Voice inquiry",
)
communication = _get_or_create_voice_communication(
session,
deal=deal,
lead=lead,
subject=(payload.summary or payload.caller_name or f"Voice call {payload.call_id}")[:120],
metadata={
"voice_session_id": payload.voice_session_id,
"ai_session_id": payload.ai_session_id,
"interaction_id": payload.interaction_id,
"queue_id": payload.queue_id,
"queue_code": payload.queue_code,
"ai_state": payload.ai_state,
"handoff_reason": payload.handoff_reason,
**(payload.metadata or {}),
},
voice_session_id=payload.voice_session_id,
)
call = None
existing_link = _find_external_link(
session,
voice_session_id=payload.voice_session_id,
external_call_id=payload.call_id,
interaction_id=payload.interaction_id,
)
if existing_link and existing_link.sales_call_id:
call = session.execute(
select(SalesCallRow).where(SalesCallRow.call_id == existing_link.sales_call_id)
).scalar_one_or_none()
if call is None:
call = session.execute(
select(SalesCallRow)
.where(SalesCallRow.external_call_id == payload.call_id)
.order_by(SalesCallRow.id.desc())
).scalars().first()
started_at = str(payload.started_at or utc_now_iso()).strip() or utc_now_iso()
ended_at = str(payload.ended_at or "").strip() or None
if call is None:
call = SalesCallRow(
call_id=new_id("cal"),
tenant_id=deal.tenant_id,
deal_id=deal.deal_id,
communication_id=communication.communication_id,
phone_number=str(payload.caller_number or "").strip() or (lead.phone if lead else "") or "unknown",
direction="inbound",
provider="voice_runtime",
external_call_id=payload.call_id,
recording_url=None,
transcript_status="ready" if str(payload.transcript_text or "").strip() else "pending",
transcript_id=None,
call_status=payload.call_status or payload.ai_state or "started",
summary=payload.summary,
started_at=started_at,
ended_at=ended_at,
duration_sec=None,
created_at=started_at,
updated_at=utc_now_iso(),
)
session.add(call)
else:
call.communication_id = communication.communication_id
call.phone_number = str(payload.caller_number or "").strip() or call.phone_number
call.provider = "voice_runtime"
call.external_call_id = payload.call_id
call.call_status = payload.call_status or payload.ai_state or call.call_status
call.summary = payload.summary or call.summary
call.started_at = started_at or call.started_at
call.ended_at = ended_at or call.ended_at
call.transcript_status = "ready" if str(payload.transcript_text or "").strip() else call.transcript_status
call.updated_at = utc_now_iso()
if str(payload.transcript_text or "").strip():
transcript = None
if call.transcript_id:
transcript = session.execute(
select(SalesTranscriptRow).where(SalesTranscriptRow.transcript_id == call.transcript_id)
).scalar_one_or_none()
if transcript is None:
transcript = SalesTranscriptRow(
transcript_id=new_id("trn"),
tenant_id=deal.tenant_id,
call_id=call.call_id,
language="ru",
transcript_text=payload.transcript_text.strip(),
diarization_json="{}",
extracted_entities_json=json.dumps(payload.metadata or {}, ensure_ascii=False),
created_at=utc_now_iso(),
updated_at=utc_now_iso(),
)
session.add(transcript)
call.transcript_id = transcript.transcript_id
else:
transcript.transcript_text = payload.transcript_text.strip()
transcript.extracted_entities_json = json.dumps(payload.metadata or {}, ensure_ascii=False)
transcript.updated_at = utc_now_iso()
_upsert_external_link(
session,
deal=deal,
communication_id=communication.communication_id,
sales_call_id=call.call_id,
channel_provider="voice",
external_call_id=payload.call_id,
voice_session_id=payload.voice_session_id,
ai_session_id=payload.ai_session_id,
interaction_id=payload.interaction_id,
customer_id=deal.customer_id,
phone_number=payload.caller_number,
external_status=payload.call_status or payload.ai_state or payload.telephony_status,
metadata={
"caller_name": payload.caller_name,
"queue_id": payload.queue_id,
"queue_code": payload.queue_code,
"telephony_status": payload.telephony_status,
"ai_state": payload.ai_state,
"handoff_reason": payload.handoff_reason,
**(payload.metadata or {}),
},
)
deal.current_channel = "voice"
deal.preferred_channel = "voice"
deal.updated_at = utc_now_iso()
session.commit()
return _build_workspace(session, deal)
finally:
session.close()
@app.post("/api/v1/messages/inbound-webhook", response_model=SalesMessageOut)
def inbound_message(
payload: SalesMessageWebhookIn,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesMessageOut:
session = get_session()
try:
lead, deal = _resolve_deal_for_inbound(
session,
deal_id=payload.deal_id,
lead_id=payload.lead_id,
phone=payload.phone,
subject=payload.body[:120],
source_channel=payload.channel_provider,
preferred_channel=payload.channel_provider if payload.channel_provider in {"telegram", "whatsapp", "webchat", "email"} else "telegram",
)
communication = _start_communication(
session,
deal=deal,
lead=lead,
payload=SalesCommunicationStartIn(
channel_type="text",
direction="inbound",
agent_type="text_ai",
subject=payload.body[:120],
summary=None,
),
metadata=payload.metadata,
)
message = SalesMessageRow(
message_id=new_id("msg"),
tenant_id=deal.tenant_id,
deal_id=deal.deal_id,
communication_id=communication.communication_id,
sender_type="customer",
sender_id=payload.sender_id,
channel_provider=payload.channel_provider,
external_message_id=payload.external_message_id,
body=payload.body,
attachments_json=json.dumps(payload.attachments, ensure_ascii=False),
delivery_status="received",
read_status="unread",
message_metadata_json=json.dumps(payload.metadata, ensure_ascii=False),
sent_at=utc_now_iso(),
created_at=utc_now_iso(),
)
session.add(message)
session.commit()
return _message_to_out(message)
finally:
session.close()
@app.post("/api/v1/messages/outbound", response_model=SalesMessageOut)
def outbound_message(
payload: SalesMessageSendIn,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesMessageOut:
session = get_session()
try:
deal = _get_deal(session, payload.deal_id)
communication = None
if payload.communication_id:
communication = session.execute(
select(SalesCommunicationSessionRow).where(SalesCommunicationSessionRow.communication_id == payload.communication_id)
).scalar_one_or_none()
if communication is None:
lead = _get_lead(session, deal.lead_id) if deal.lead_id else None
communication = _start_communication(
session,
deal=deal,
lead=lead,
payload=SalesCommunicationStartIn(
channel_type="text",
direction="outbound",
agent_type=payload.sender_type if payload.sender_type == "human" else "text_ai",
subject=payload.body[:120],
),
metadata=payload.metadata,
)
message = SalesMessageRow(
message_id=new_id("msg"),
tenant_id=deal.tenant_id,
deal_id=deal.deal_id,
communication_id=communication.communication_id,
sender_type=payload.sender_type,
sender_id=payload.sender_id,
channel_provider=payload.channel_provider,
external_message_id=None,
body=payload.body,
attachments_json=json.dumps(payload.attachments, ensure_ascii=False),
delivery_status="queued",
read_status=None,
message_metadata_json=json.dumps(payload.metadata, ensure_ascii=False),
sent_at=utc_now_iso(),
created_at=utc_now_iso(),
)
session.add(message)
_touch_deal_contact(deal)
_bridge_telegram_outbound(communication, message)
session.commit()
return _message_to_out(message)
finally:
session.close()
@app.get("/api/v1/deals/{deal_id}/messages", response_model=list[SalesMessageOut])
def list_messages(
deal_id: str,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> list[SalesMessageOut]:
session = get_session()
try:
_get_deal(session, deal_id)
rows = session.execute(
select(SalesMessageRow)
.where(SalesMessageRow.deal_id == deal_id)
.order_by(SalesMessageRow.sent_at.desc(), SalesMessageRow.id.desc())
).scalars().all()
return [_message_to_out(row) for row in rows]
finally:
session.close()
@app.post("/api/v1/calls/inbound-webhook", response_model=SalesCallOut)
def inbound_call(
payload: SalesCallWebhookIn,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesCallOut:
session = get_session()
try:
lead, deal = _resolve_deal_for_inbound(
session,
deal_id=payload.deal_id,
lead_id=payload.lead_id,
phone=payload.phone_number,
subject=payload.subject,
source_channel="voice",
preferred_channel="voice",
)
communication = _start_communication(
session,
deal=deal,
lead=lead,
payload=SalesCommunicationStartIn(
channel_type="voice",
direction="inbound",
agent_type="voice_ai",
subject=payload.subject or f"Входящий звонок {payload.phone_number}",
),
metadata={"provider": payload.provider},
)
row = SalesCallRow(
call_id=new_id("cal"),
tenant_id=deal.tenant_id,
deal_id=deal.deal_id,
communication_id=communication.communication_id,
phone_number=payload.phone_number,
direction="inbound",
provider=payload.provider,
external_call_id=payload.external_call_id,
recording_url=None,
transcript_status="pending",
transcript_id=None,
call_status="started",
summary=None,
started_at=utc_now_iso(),
ended_at=None,
duration_sec=None,
created_at=utc_now_iso(),
updated_at=utc_now_iso(),
)
session.add(row)
_bridge_voice_runtime(row, communication, deal)
session.commit()
return _call_to_out(row)
finally:
session.close()
@app.post("/api/v1/calls/outbound", response_model=SalesCallOut)
def outbound_call(
payload: SalesCallWebhookIn,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesCallOut:
if not payload.deal_id:
raise HTTPException(status_code=400, detail="deal_id is required for outbound calls")
session = get_session()
try:
deal = _get_deal(session, payload.deal_id)
lead = _get_lead(session, deal.lead_id) if deal.lead_id else None
communication = _start_communication(
session,
deal=deal,
lead=lead,
payload=SalesCommunicationStartIn(
channel_type="voice",
direction="outbound",
agent_type="voice_ai",
subject=payload.subject or f"Исходящий звонок {payload.phone_number}",
),
metadata={"provider": payload.provider},
)
row = SalesCallRow(
call_id=new_id("cal"),
tenant_id=deal.tenant_id,
deal_id=deal.deal_id,
communication_id=communication.communication_id,
phone_number=payload.phone_number,
direction="outbound",
provider=payload.provider,
external_call_id=payload.external_call_id,
recording_url=None,
transcript_status="pending",
transcript_id=None,
call_status="started",
summary=None,
started_at=utc_now_iso(),
ended_at=None,
duration_sec=None,
created_at=utc_now_iso(),
updated_at=utc_now_iso(),
)
session.add(row)
_bridge_voice_runtime(row, communication, deal)
session.commit()
return _call_to_out(row)
finally:
session.close()
@app.post("/api/v1/calls/{call_id}/complete", response_model=SalesCallOut)
def complete_call(
call_id: str,
payload: SalesCallCompleteIn,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesCallOut:
session = get_session()
try:
row = session.execute(select(SalesCallRow).where(SalesCallRow.call_id == call_id)).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="Call not found")
row.summary = payload.summary
row.recording_url = payload.recording_url
row.call_status = payload.result_code or "completed"
row.ended_at = utc_now_iso()
if row.started_at:
started = datetime.fromisoformat(row.started_at.replace("Z", "+00:00"))
row.duration_sec = max(int((_now() - started).total_seconds()), 0)
row.updated_at = utc_now_iso()
communication = session.execute(
select(SalesCommunicationSessionRow).where(SalesCommunicationSessionRow.communication_id == row.communication_id)
).scalar_one_or_none()
if communication is not None:
communication.ended_at = row.ended_at
communication.duration_sec = row.duration_sec
communication.summary = payload.summary or communication.summary
communication.result_code = payload.result_code
communication.status = "completed"
communication.updated_at = row.updated_at
deal = _get_deal(session, row.deal_id)
_touch_deal_contact(deal)
session.commit()
return _call_to_out(row)
finally:
session.close()
@app.get("/api/v1/deals/{deal_id}/calls", response_model=list[SalesCallOut])
def list_calls(
deal_id: str,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> list[SalesCallOut]:
session = get_session()
try:
_get_deal(session, deal_id)
rows = session.execute(
select(SalesCallRow)
.where(SalesCallRow.deal_id == deal_id)
.order_by(SalesCallRow.started_at.desc(), SalesCallRow.id.desc())
).scalars().all()
return [_call_to_out(row) for row in rows]
finally:
session.close()
@app.post("/api/v1/calls/{call_id}/transcript", response_model=SalesTranscriptOut)
def attach_transcript(
call_id: str,
payload: SalesTranscriptIn,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesTranscriptOut:
session = get_session()
try:
call = session.execute(select(SalesCallRow).where(SalesCallRow.call_id == call_id)).scalar_one_or_none()
if call is None:
raise HTTPException(status_code=404, detail="Call not found")
now = utc_now_iso()
row = SalesTranscriptRow(
transcript_id=new_id("trn"),
tenant_id=_tenant_id(),
call_id=call.call_id,
language=payload.language,
transcript_text=payload.transcript_text,
diarization_json=json.dumps(payload.diarization, ensure_ascii=False),
extracted_entities_json=json.dumps(payload.extracted_entities, ensure_ascii=False),
created_at=now,
updated_at=now,
)
session.add(row)
call.transcript_id = row.transcript_id
call.transcript_status = "ready"
call.updated_at = now
communication = session.execute(
select(SalesCommunicationSessionRow).where(SalesCommunicationSessionRow.communication_id == call.communication_id)
).scalar_one_or_none()
if communication is not None:
communication.transcript_id = row.transcript_id
communication.updated_at = now
session.commit()
return _transcript_to_out(row)
finally:
session.close()
@app.post("/api/v1/deals/{deal_id}/offers", response_model=SalesOfferOut)
def create_offer(
deal_id: str,
payload: SalesOfferCreate,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesOfferOut:
session = get_session()
try:
deal = _get_deal(session, deal_id)
now = utc_now_iso()
row = SalesOfferRow(
offer_id=new_id("off"),
tenant_id=deal.tenant_id,
deal_id=deal.deal_id,
offer_type=payload.offer_type,
title=payload.title,
description=payload.description,
line_items_json=json.dumps(payload.line_items, ensure_ascii=False),
pricing_json=json.dumps(payload.pricing, ensure_ascii=False),
total_amount=payload.total_amount,
currency=payload.currency,
validity_until=payload.validity_until,
status="draft",
rendered_document_url=payload.rendered_document_url,
created_by_type=actor["user"],
created_at=now,
updated_at=now,
)
session.add(row)
_apply_stage(session, deal, "offer_preparing", actor_type="human", actor_id=actor["user"], reason="offer.created")
session.commit()
return _offer_to_out(row)
finally:
session.close()
@app.get("/api/v1/offers/{offer_id}", response_model=SalesOfferOut)
def get_offer(
offer_id: str,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> SalesOfferOut:
session = get_session()
try:
row = session.execute(select(SalesOfferRow).where(SalesOfferRow.offer_id == offer_id)).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="Offer not found")
return _offer_to_out(row)
finally:
session.close()
@app.post("/api/v1/offers/{offer_id}/send", response_model=SalesOfferOut)
def send_offer(
offer_id: str,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesOfferOut:
session = get_session()
try:
row = session.execute(select(SalesOfferRow).where(SalesOfferRow.offer_id == offer_id)).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="Offer not found")
row.status = "sent"
row.updated_at = utc_now_iso()
deal = _get_deal(session, row.deal_id)
_apply_stage(session, deal, "offer_sent", actor_type="system", actor_id=None, reason="offer.sent")
session.commit()
return _offer_to_out(row)
finally:
session.close()
@app.post("/api/v1/offers/{offer_id}/accept", response_model=SalesOfferOut)
def accept_offer(
offer_id: str,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesOfferOut:
session = get_session()
try:
row = session.execute(select(SalesOfferRow).where(SalesOfferRow.offer_id == offer_id)).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="Offer not found")
row.status = "accepted"
row.updated_at = utc_now_iso()
deal = _get_deal(session, row.deal_id)
_apply_stage(session, deal, "conditions_negotiation", actor_type="system", actor_id=None, reason="offer.accepted")
session.commit()
return _offer_to_out(row)
finally:
session.close()
@app.post("/api/v1/offers/{offer_id}/reject", response_model=SalesOfferOut)
def reject_offer(
offer_id: str,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesOfferOut:
session = get_session()
try:
row = session.execute(select(SalesOfferRow).where(SalesOfferRow.offer_id == offer_id)).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="Offer not found")
row.status = "rejected"
row.updated_at = utc_now_iso()
deal = _get_deal(session, row.deal_id)
_apply_stage(session, deal, "need_clarification", actor_type="system", actor_id=None, reason="offer.rejected")
session.commit()
return _offer_to_out(row)
finally:
session.close()
@app.post("/api/v1/deals/{deal_id}/conditions", response_model=SalesConditionOut)
def upsert_conditions(
deal_id: str,
payload: SalesConditionUpsertIn,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesConditionOut:
session = get_session()
try:
deal = _get_deal(session, deal_id)
row = session.execute(select(SalesConditionRow).where(SalesConditionRow.deal_id == deal.deal_id)).scalar_one_or_none()
now = utc_now_iso()
if row is None:
row = SalesConditionRow(
condition_id=new_id("con"),
tenant_id=deal.tenant_id,
deal_id=deal.deal_id,
product_name=payload.product_name,
service_name=payload.service_name,
quantity=payload.quantity,
unit=payload.unit,
delivery_mode=payload.delivery_mode,
execution_date=payload.execution_date,
start_date=payload.start_date,
end_date=payload.end_date,
payment_terms=payload.payment_terms,
custom_terms_json=json.dumps(payload.custom_terms, ensure_ascii=False),
agreed_price=payload.agreed_price,
currency=payload.currency,
confirmed_at=now,
created_at=now,
updated_at=now,
)
session.add(row)
else:
row.product_name = payload.product_name
row.service_name = payload.service_name
row.quantity = payload.quantity
row.unit = payload.unit
row.delivery_mode = payload.delivery_mode
row.execution_date = payload.execution_date
row.start_date = payload.start_date
row.end_date = payload.end_date
row.payment_terms = payload.payment_terms
row.custom_terms_json = json.dumps(payload.custom_terms, ensure_ascii=False)
row.agreed_price = payload.agreed_price
row.currency = payload.currency
row.confirmed_at = now
row.updated_at = now
if payload.agreed_price is not None:
deal.final_amount = payload.agreed_price
_apply_stage(session, deal, "conditions_negotiation", actor_type="human", actor_id=actor["user"], reason="deal.conditions_confirmed")
session.commit()
return _condition_to_out(row)
finally:
session.close()
@app.post("/api/v1/deals/{deal_id}/counterparty", response_model=SalesCounterpartyOut)
def create_counterparty(
deal_id: str,
payload: SalesCounterpartyUpsertIn,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesCounterpartyOut:
return patch_counterparty(deal_id, payload, actor)
@app.patch("/api/v1/deals/{deal_id}/counterparty", response_model=SalesCounterpartyOut)
def patch_counterparty(
deal_id: str,
payload: SalesCounterpartyUpsertIn,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesCounterpartyOut:
session = get_session()
try:
deal = _get_deal(session, deal_id)
row = session.execute(select(SalesCounterpartyRow).where(SalesCounterpartyRow.deal_id == deal.deal_id)).scalar_one_or_none()
now = utc_now_iso()
status = _counterparty_status(payload)
if row is None:
row = SalesCounterpartyRow(
counterparty_id=new_id("ctp"),
tenant_id=deal.tenant_id,
deal_id=deal.deal_id,
customer_id=deal.customer_id,
company_name=payload.company_name,
full_name=payload.full_name,
bin_iin=payload.bin_iin,
address=payload.address,
bank_details_json=json.dumps(payload.bank_details, ensure_ascii=False),
signer_name=payload.signer_name,
signer_role=payload.signer_role,
signer_basis=payload.signer_basis,
email_for_docs=payload.email_for_docs,
phone_for_docs=payload.phone_for_docs,
completeness_status=status,
created_at=now,
updated_at=now,
)
session.add(row)
else:
row.company_name = payload.company_name
row.full_name = payload.full_name
row.bin_iin = payload.bin_iin
row.address = payload.address
row.bank_details_json = json.dumps(payload.bank_details, ensure_ascii=False)
row.signer_name = payload.signer_name
row.signer_role = payload.signer_role
row.signer_basis = payload.signer_basis
row.email_for_docs = payload.email_for_docs
row.phone_for_docs = payload.phone_for_docs
row.completeness_status = status
row.updated_at = now
row.customer_id = _ensure_crm_customer(session, lead=_get_lead(session, deal.lead_id) if deal.lead_id else None, deal=deal, counterparty=row)
_apply_stage(
session,
deal,
"counterparty_data_received" if status == "completed" else "counterparty_data_requested",
actor_type="human",
actor_id=actor["user"],
reason="counterparty.completed" if status == "completed" else "counterparty.requested",
)
session.commit()
return _counterparty_to_out(row)
finally:
session.close()
@app.get("/api/v1/deals/{deal_id}/counterparty", response_model=SalesCounterpartyOut)
def get_counterparty(
deal_id: str,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> SalesCounterpartyOut:
session = get_session()
try:
row = session.execute(select(SalesCounterpartyRow).where(SalesCounterpartyRow.deal_id == deal_id)).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="Counterparty not found")
return _counterparty_to_out(row)
finally:
session.close()
@app.post("/api/v1/deals/{deal_id}/documents", response_model=SalesDocumentOut)
def create_document(
deal_id: str,
payload: SalesDocumentCreate,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesDocumentOut:
session = get_session()
try:
deal = _get_deal(session, deal_id)
now = utc_now_iso()
row = SalesDocumentRow(
document_id=new_id("doc"),
tenant_id=deal.tenant_id,
deal_id=deal.deal_id,
customer_id=deal.customer_id,
document_type=payload.document_type,
template_id=payload.template_id,
version=payload.version,
status="draft",
file_url=payload.file_url,
rendered_payload_json=json.dumps(payload.rendered_payload, ensure_ascii=False),
external_sign_provider_id=None,
signed_at=None,
created_at=now,
updated_at=now,
)
session.add(row)
_apply_stage(session, deal, "document_preparing", actor_type="human", actor_id=actor["user"], reason="document.created")
session.commit()
return _document_to_out(row)
finally:
session.close()
@app.get("/api/v1/documents/{document_id}", response_model=SalesDocumentOut)
def get_document(
document_id: str,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> SalesDocumentOut:
session = get_session()
try:
row = session.execute(select(SalesDocumentRow).where(SalesDocumentRow.document_id == document_id)).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="Document not found")
return _document_to_out(row)
finally:
session.close()
@app.post("/api/v1/documents/{document_id}/send", response_model=SalesDocumentOut)
def send_document(
document_id: str,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesDocumentOut:
session = get_session()
try:
row = session.execute(select(SalesDocumentRow).where(SalesDocumentRow.document_id == document_id)).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="Document not found")
row.status = "sent"
row.updated_at = utc_now_iso()
deal = _get_deal(session, row.deal_id)
_apply_stage(session, deal, "document_sent", actor_type="system", actor_id=None, reason="document.sent")
session.commit()
return _document_to_out(row)
finally:
session.close()
@app.post("/api/v1/documents/{document_id}/confirm", response_model=SalesDocumentOut)
def confirm_document(
document_id: str,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesDocumentOut:
session = get_session()
try:
row = session.execute(select(SalesDocumentRow).where(SalesDocumentRow.document_id == document_id)).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="Document not found")
row.status = "confirmed"
row.updated_at = utc_now_iso()
deal = _get_deal(session, row.deal_id)
_apply_stage(session, deal, "document_confirmed", actor_type="system", actor_id=None, reason="document.confirmed")
session.commit()
return _document_to_out(row)
finally:
session.close()
@app.post("/api/v1/documents/{document_id}/sign-status-webhook", response_model=SalesDocumentOut)
def sign_document(
document_id: str,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesDocumentOut:
session = get_session()
try:
row = session.execute(select(SalesDocumentRow).where(SalesDocumentRow.document_id == document_id)).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="Document not found")
row.status = "signed"
row.signed_at = utc_now_iso()
row.updated_at = row.signed_at
session.commit()
return _document_to_out(row)
finally:
session.close()
@app.post("/api/v1/deals/{deal_id}/invoices", response_model=SalesInvoiceOut)
def create_invoice(
deal_id: str,
payload: SalesInvoiceCreate,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesInvoiceOut:
session = get_session()
try:
deal = _get_deal(session, deal_id)
now = utc_now_iso()
row = SalesInvoiceRow(
invoice_id=new_id("inv"),
tenant_id=deal.tenant_id,
deal_id=deal.deal_id,
customer_id=deal.customer_id,
invoice_number=_invoice_number(),
basis_document_id=payload.basis_document_id,
amount=payload.amount,
currency=payload.currency,
due_date=payload.due_date,
status="draft",
payment_link=payload.payment_link,
line_items_json=json.dumps(payload.line_items, ensure_ascii=False),
metadata_json=json.dumps(payload.metadata, ensure_ascii=False),
issued_at=None,
paid_at=None,
created_at=now,
updated_at=now,
)
session.add(row)
_apply_stage(session, deal, "invoice_preparing", actor_type="human", actor_id=actor["user"], reason="invoice.created")
session.commit()
return _invoice_to_out(row)
finally:
session.close()
@app.get("/api/v1/invoices/{invoice_id}", response_model=SalesInvoiceOut)
def get_invoice(
invoice_id: str,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> SalesInvoiceOut:
session = get_session()
try:
row = session.execute(select(SalesInvoiceRow).where(SalesInvoiceRow.invoice_id == invoice_id)).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="Invoice not found")
return _invoice_to_out(row)
finally:
session.close()
@app.post("/api/v1/invoices/{invoice_id}/send", response_model=SalesInvoiceOut)
def send_invoice(
invoice_id: str,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesInvoiceOut:
session = get_session()
try:
row = session.execute(select(SalesInvoiceRow).where(SalesInvoiceRow.invoice_id == invoice_id)).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="Invoice not found")
row.status = "sent"
row.issued_at = utc_now_iso()
row.updated_at = row.issued_at
deal = _get_deal(session, row.deal_id)
deal.next_action_type = "payment_follow_up"
deal.next_action_at = row.due_date
_schedule_task(
session,
deal=deal,
task_type="invoice_follow_up",
run_at=row.due_date,
payload={"invoice_id": row.invoice_id, "invoice_number": row.invoice_number},
)
_apply_stage(session, deal, "invoice_sent", actor_type="system", actor_id=None, reason="invoice.sent")
session.commit()
return _invoice_to_out(row)
finally:
session.close()
@app.post("/api/v1/invoices/{invoice_id}/mark-overdue", response_model=SalesInvoiceOut)
def mark_invoice_overdue(
invoice_id: str,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesInvoiceOut:
session = get_session()
try:
row = session.execute(select(SalesInvoiceRow).where(SalesInvoiceRow.invoice_id == invoice_id)).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="Invoice not found")
row.status = "overdue"
row.updated_at = utc_now_iso()
deal = _get_deal(session, row.deal_id)
_apply_stage(session, deal, "payment_overdue", actor_type="system", actor_id=None, reason="invoice.overdue")
session.commit()
return _invoice_to_out(row)
finally:
session.close()
@app.post("/api/v1/payments/webhook", response_model=SalesPaymentOut)
def payment_webhook(
payload: SalesPaymentWebhookIn,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesPaymentOut:
session = get_session()
try:
deal = _get_deal(session, payload.deal_id)
invoice = None
if payload.invoice_id:
invoice = session.execute(select(SalesInvoiceRow).where(SalesInvoiceRow.invoice_id == payload.invoice_id)).scalar_one_or_none()
now = utc_now_iso()
row = SalesPaymentRow(
payment_id=new_id("pay"),
tenant_id=deal.tenant_id,
deal_id=deal.deal_id,
invoice_id=payload.invoice_id,
payment_provider=payload.payment_provider,
external_payment_id=payload.external_payment_id,
amount=payload.amount,
currency=payload.currency,
status=payload.status,
paid_at=payload.paid_at or (now if payload.status in {"success", "partial"} else None),
payment_method=payload.payment_method,
failure_reason=payload.failure_reason,
metadata_json=json.dumps(payload.metadata, ensure_ascii=False),
created_at=now,
updated_at=now,
)
session.add(row)
lead = _get_lead(session, deal.lead_id) if deal.lead_id else None
counterparty = session.execute(select(SalesCounterpartyRow).where(SalesCounterpartyRow.deal_id == deal.deal_id)).scalar_one_or_none()
if invoice is not None:
if payload.status == "success":
invoice.status = "paid"
invoice.paid_at = row.paid_at
invoice.updated_at = now
deal.status = "won"
deal.closed_at = row.paid_at
deal.final_amount = payload.amount
_apply_stage(session, deal, "won", actor_type="system", actor_id=None, reason="payment.received")
_ensure_crm_customer(session, lead=lead, deal=deal, counterparty=counterparty)
elif payload.status == "partial":
invoice.status = "partially_paid"
invoice.updated_at = now
_apply_stage(session, deal, "partially_paid", actor_type="system", actor_id=None, reason="payment.partial")
elif payload.status == "failed":
invoice.updated_at = now
session.commit()
return _payment_to_out(row)
finally:
session.close()
@app.get("/api/v1/deals/{deal_id}/payments", response_model=list[SalesPaymentOut])
def list_payments(
deal_id: str,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> list[SalesPaymentOut]:
session = get_session()
try:
_get_deal(session, deal_id)
rows = session.execute(
select(SalesPaymentRow)
.where(SalesPaymentRow.deal_id == deal_id)
.order_by(SalesPaymentRow.updated_at.desc(), SalesPaymentRow.id.desc())
).scalars().all()
return [_payment_to_out(row) for row in rows]
finally:
session.close()
@app.post("/api/v1/payments/{payment_id}/reconcile", response_model=SalesPaymentOut)
def reconcile_payment(
payment_id: str,
payload: SalesPaymentReconcileIn,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesPaymentOut:
session = get_session()
try:
row = session.execute(select(SalesPaymentRow).where(SalesPaymentRow.payment_id == payment_id)).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="Payment not found")
row.status = payload.status
row.failure_reason = payload.failure_reason
row.metadata_json = json.dumps(payload.metadata, ensure_ascii=False)
row.updated_at = utc_now_iso()
session.commit()
return _payment_to_out(row)
finally:
session.close()