Files
call-center/services/sales_service/app.py
T

4856 lines
187 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, Header, HTTPException, Query
from sqlalchemy import func, or_, select
from sqlalchemy.exc import IntegrityError
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,
SalesPipelineCreate,
SalesPipelineOut,
SalesPipelineStageCreate,
SalesPipelineStageOut,
SalesPipelineStageUpdate,
SalesPipelineUpdate,
SalesStageHistoryOut,
SalesStageRefOut,
SalesTelegramSyncIn,
SalesTimelineEventOut,
SalesTranscriptIn,
SalesTranscriptOut,
SalesVoiceSyncIn,
SalesWorkspaceOut,
)
from services.shared.security import get_actor, 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,
SalesPipelineRow,
SalesPipelineStageRow,
SalesStageHistoryRow,
SalesTranscriptRow,
TenantIntegrationRow,
TenantSalesSettingsRow,
)
from services.sales_service import sales_events as sales_event_types
from services.sales_service.event_publisher import SalesEventPublisher
# 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_SALES_CURRENCY = os.getenv("DEFAULT_SALES_CURRENCY", "KZT").strip() or "KZT"
DEFAULT_PIPELINE_CODE = "default_sales"
DEFAULT_PIPELINE_NAME = "Базовая воронка продаж"
DEFAULT_STAGE_CODE = "new_qualified_lead"
LEGACY_STAGE_ALIASES = {
"new": "new_qualified_lead",
"active_text": "active_text_communication",
"invoice": "invoice_sent",
"paid": "paid",
"won": "won",
}
DEFAULT_PIPELINE_STAGES = [
{"code": "new_qualified_lead", "name": "Новый квалифицированный лид", "category": "entry", "sort_order": 10},
{"code": "warm_lead", "name": "Теплый лид", "category": "entry", "sort_order": 20},
{"code": "hot_lead", "name": "Горячий лид", "category": "entry", "sort_order": 30},
{"code": "enrichment_required", "name": "Нужно дообогащение", "category": "entry", "sort_order": 40},
{"code": "active_text_communication", "name": "Текстовая коммуникация", "category": "communication", "sort_order": 50},
{"code": "active_voice_communication", "name": "Голосовая коммуникация", "category": "communication", "sort_order": 60},
{"code": "waiting_customer_reply", "name": "Ждем ответ клиента", "category": "communication", "sort_order": 70},
{"code": "need_clarification", "name": "Нужно уточнение", "category": "communication", "sort_order": 80},
{"code": "need_confirmed", "name": "Потребность подтверждена", "category": "communication", "sort_order": 90},
{"code": "offer_selection", "name": "Подбор предложения", "category": "commercial", "sort_order": 100},
{"code": "offer_preparing", "name": "Готовим предложение", "category": "commercial", "sort_order": 110},
{"code": "offer_sent", "name": "Предложение отправлено", "category": "commercial", "sort_order": 120},
{"code": "conditions_negotiation", "name": "Согласование условий", "category": "commercial", "sort_order": 130},
{"code": "counterparty_data_requested", "name": "Запрос реквизитов", "category": "paperwork", "sort_order": 140},
{"code": "counterparty_data_received", "name": "Реквизиты получены", "category": "paperwork", "sort_order": 150},
{"code": "document_preparing", "name": "Готовим документ", "category": "paperwork", "sort_order": 160},
{"code": "document_sent", "name": "Документ отправлен", "category": "paperwork", "sort_order": 170},
{"code": "document_under_review", "name": "Документ на согласовании", "category": "paperwork", "sort_order": 180},
{"code": "document_confirmed", "name": "Документ подтвержден", "category": "paperwork", "sort_order": 190},
{"code": "invoice_preparing", "name": "Готовим счет", "category": "finance", "sort_order": 200},
{"code": "invoice_sent", "name": "Счет отправлен", "category": "finance", "sort_order": 210},
{"code": "payment_expected", "name": "Ожидаем оплату", "category": "finance", "sort_order": 220},
{"code": "partially_paid", "name": "Оплачено частично", "category": "finance", "sort_order": 230},
{"code": "paid", "name": "Оплачено", "category": "finance", "sort_order": 240},
{"code": "payment_overdue", "name": "Оплата просрочена", "category": "finance", "sort_order": 250},
{"code": "won", "name": "Сделка выиграна", "category": "closing", "sort_order": 260, "is_terminal": True},
{"code": "lost", "name": "Сделка проиграна", "category": "closing", "sort_order": 270, "is_terminal": True},
{"code": "postponed", "name": "Отложено", "category": "closing", "sort_order": 280, "is_terminal": True},
{"code": "follow_up_scheduled", "name": "Запланирован follow-up", "category": "closing", "sort_order": 290},
{"code": "transferred_to_execution", "name": "Передано в исполнение", "category": "closing", "sort_order": 300, "is_terminal": True},
{"code": "transferred_to_support", "name": "Передано человеку", "category": "closing", "sort_order": 310, "is_terminal": True},
]
def _tenant_id(actor: dict) -> str:
tenant_id = str(actor.get("tenant_id") or "").strip()
if not tenant_id:
raise HTTPException(status_code=400, detail="Tenant context is required")
return tenant_id
def _optional_tenant_id(actor: dict) -> str | None:
tenant_id = str(actor.get("tenant_id") or "").strip()
return tenant_id or None
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, tenant_id: str | None = None) -> dict[str, str]:
token = issue_app_token(
subject=subject,
username=username,
role="admin",
auth_source="service",
provider=provider,
tenant_id=tenant_id,
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 _normalize_stage_code(value: str | None) -> str:
code = str(value or DEFAULT_STAGE_CODE).strip()
return LEGACY_STAGE_ALIASES.get(code, code)
def _pipeline_to_out(row: SalesPipelineRow) -> SalesPipelineOut:
return SalesPipelineOut(
pipeline_id=row.pipeline_id,
tenant_id=row.tenant_id,
code=row.code,
name=row.name,
description=row.description,
is_default=row.is_default,
is_active=row.is_active,
created_at=row.created_at,
updated_at=row.updated_at,
)
def _stage_to_ref(row: SalesPipelineStageRow | None) -> SalesStageRefOut | None:
if row is None:
return None
return SalesStageRefOut(
id=row.stage_id,
code=row.code,
name=row.name,
category=row.category, # type: ignore[arg-type]
sort_order=row.sort_order,
)
def _stage_to_out(row: SalesPipelineStageRow) -> SalesPipelineStageOut:
return SalesPipelineStageOut(
stage_id=row.stage_id,
tenant_id=row.tenant_id,
pipeline_id=row.pipeline_id,
code=row.code,
name=row.name,
category=row.category, # type: ignore[arg-type]
sort_order=row.sort_order,
is_terminal=row.is_terminal,
is_system=row.is_system,
is_active=row.is_active,
created_at=row.created_at,
updated_at=row.updated_at,
)
def _get_pipeline(session, pipeline_id: str, tenant_id: str) -> SalesPipelineRow:
row = session.execute(
select(SalesPipelineRow).where(
SalesPipelineRow.pipeline_id == pipeline_id,
SalesPipelineRow.tenant_id == tenant_id,
)
).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="Pipeline not found")
return row
def _get_stage(session, stage_id: str, tenant_id: str, pipeline_id: str | None = None) -> SalesPipelineStageRow:
stmt = select(SalesPipelineStageRow).where(
SalesPipelineStageRow.stage_id == stage_id,
SalesPipelineStageRow.tenant_id == tenant_id,
)
row = session.execute(stmt).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="Stage not found")
if pipeline_id and row.pipeline_id != pipeline_id:
raise HTTPException(status_code=400, detail="Stage does not belong to pipeline")
return row
def _set_default_pipeline(session, pipeline: SalesPipelineRow) -> None:
now = utc_now_iso()
rows = session.execute(
select(SalesPipelineRow).where(
SalesPipelineRow.tenant_id == pipeline.tenant_id,
SalesPipelineRow.is_default == True, # noqa: E712
SalesPipelineRow.pipeline_id != pipeline.pipeline_id,
)
).scalars().all()
for row in rows:
row.is_default = False
row.updated_at = now
pipeline.is_default = True
pipeline.is_active = True
pipeline.updated_at = now
settings = session.execute(
select(TenantSalesSettingsRow).where(TenantSalesSettingsRow.tenant_id == pipeline.tenant_id)
).scalar_one_or_none()
if settings is None:
settings = TenantSalesSettingsRow(
tenant_id=pipeline.tenant_id,
default_pipeline_id=pipeline.pipeline_id,
default_language="ru",
default_currency=DEFAULT_SALES_CURRENCY,
enabled_text_channels_json="[]",
enabled_voice_channels_json="[]",
payment_provider_settings_json="{}",
document_settings_json="{}",
created_at=now,
updated_at=now,
)
session.add(settings)
else:
settings.default_pipeline_id = pipeline.pipeline_id
settings.updated_at = now
def ensure_default_stages(session, tenant_id: str, pipeline_id: str) -> list[SalesPipelineStageRow]:
now = utc_now_iso()
existing = {
row.code: row
for row in session.execute(
select(SalesPipelineStageRow).where(
SalesPipelineStageRow.tenant_id == tenant_id,
SalesPipelineStageRow.pipeline_id == pipeline_id,
)
).scalars().all()
}
for spec in DEFAULT_PIPELINE_STAGES:
if spec["code"] in existing:
continue
row = SalesPipelineStageRow(
stage_id=new_id("pst"),
tenant_id=tenant_id,
pipeline_id=pipeline_id,
code=spec["code"],
name=spec["name"],
category=spec["category"],
sort_order=int(spec["sort_order"]),
is_terminal=bool(spec.get("is_terminal", False)),
is_system=True,
is_active=True,
created_at=now,
updated_at=now,
)
session.add(row)
existing[row.code] = row
session.flush()
return list(existing.values())
def _backfill_legacy_stage_values(
session,
*,
tenant_id: str,
pipeline: SalesPipelineRow,
stages: list[SalesPipelineStageRow],
) -> None:
stage_by_code = {row.code: row for row in stages}
tenant_stages_by_id = {
row.stage_id: row
for row in session.execute(
select(SalesPipelineStageRow).where(SalesPipelineStageRow.tenant_id == tenant_id)
).scalars().all()
}
tenant_stage_ids = set(tenant_stages_by_id)
default_stage = stage_by_code[DEFAULT_STAGE_CODE]
legacy_pipeline_values = {"", "sales_default", DEFAULT_PIPELINE_CODE}
for deal in session.execute(select(SalesDealRow).where(SalesDealRow.tenant_id == tenant_id)).scalars().all():
if deal.stage_id in tenant_stage_ids:
stage = tenant_stages_by_id[deal.stage_id]
if deal.pipeline_id != stage.pipeline_id:
deal.pipeline_id = stage.pipeline_id
deal.updated_at = utc_now_iso()
continue
code = _normalize_stage_code(deal.stage_id)
stage = stage_by_code.get(code, default_stage)
deal.stage_id = stage.stage_id
if deal.pipeline_id in legacy_pipeline_values or deal.pipeline_id != pipeline.pipeline_id:
deal.pipeline_id = pipeline.pipeline_id
deal.updated_at = utc_now_iso()
for history in session.execute(select(SalesStageHistoryRow).where(SalesStageHistoryRow.tenant_id == tenant_id)).scalars().all():
if history.from_stage_id and history.from_stage_id not in tenant_stage_ids:
from_code = _normalize_stage_code(history.from_stage_id)
history.from_stage_id = stage_by_code.get(from_code, default_stage).stage_id
if history.to_stage_id not in tenant_stage_ids:
to_code = _normalize_stage_code(history.to_stage_id)
history.to_stage_id = stage_by_code.get(to_code, default_stage).stage_id
def ensure_default_pipeline(session, tenant_id: str) -> SalesPipelineRow:
pipeline = session.execute(
select(SalesPipelineRow)
.where(
SalesPipelineRow.tenant_id == tenant_id,
SalesPipelineRow.is_default == True, # noqa: E712
SalesPipelineRow.is_active == True, # noqa: E712
)
.order_by(SalesPipelineRow.id.asc())
).scalars().first()
if pipeline is None:
pipeline = session.execute(
select(SalesPipelineRow).where(
SalesPipelineRow.tenant_id == tenant_id,
SalesPipelineRow.code == DEFAULT_PIPELINE_CODE,
)
).scalar_one_or_none()
if pipeline is None:
now = utc_now_iso()
pipeline = SalesPipelineRow(
pipeline_id=new_id("pip"),
tenant_id=tenant_id,
code=DEFAULT_PIPELINE_CODE,
name=DEFAULT_PIPELINE_NAME,
description=None,
is_default=True,
is_active=True,
created_at=now,
updated_at=now,
)
session.add(pipeline)
session.flush()
if not pipeline.is_default or not pipeline.is_active:
_set_default_pipeline(session, pipeline)
stages = ensure_default_stages(session, tenant_id, pipeline.pipeline_id)
_backfill_legacy_stage_values(session, tenant_id=tenant_id, pipeline=pipeline, stages=stages)
return pipeline
def get_default_pipeline(session, tenant_id: str) -> SalesPipelineRow:
return ensure_default_pipeline(session, tenant_id)
def resolve_stage_by_code_or_id(
session,
*,
tenant_id: str,
pipeline_id: str,
stage_id: str | None = None,
stage_code: str | None = None,
require_active: bool = True,
) -> SalesPipelineStageRow:
ensure_default_pipeline(session, tenant_id)
def resolve_one(raw: str | None) -> SalesPipelineStageRow:
value = str(raw or DEFAULT_STAGE_CODE).strip()
stage = session.execute(
select(SalesPipelineStageRow).where(
SalesPipelineStageRow.tenant_id == tenant_id,
SalesPipelineStageRow.stage_id == value,
)
).scalar_one_or_none()
if stage is not None:
if stage.pipeline_id != pipeline_id:
raise HTTPException(status_code=400, detail="Stage does not belong to pipeline")
if require_active and not stage.is_active:
raise HTTPException(status_code=400, detail="Stage is inactive")
return stage
code = _normalize_stage_code(value)
stage = session.execute(
select(SalesPipelineStageRow).where(
SalesPipelineStageRow.tenant_id == tenant_id,
SalesPipelineStageRow.pipeline_id == pipeline_id,
SalesPipelineStageRow.code == code,
)
).scalar_one_or_none()
if stage is None:
raise HTTPException(status_code=404, detail="Stage not found")
if require_active and not stage.is_active:
raise HTTPException(status_code=400, detail="Stage is inactive")
return stage
by_id_or_code = resolve_one(stage_id or stage_code or DEFAULT_STAGE_CODE)
if stage_id and stage_code:
by_code = resolve_one(stage_code)
if by_id_or_code.stage_id != by_code.stage_id:
raise HTTPException(status_code=400, detail="stage_id and stage_code point to different stages")
return by_id_or_code
def _stage_filter_ids(session, tenant_id: str, value: str) -> list[str]:
normalized = str(value or "").strip()
if not normalized:
return []
direct = session.execute(
select(SalesPipelineStageRow.stage_id).where(
SalesPipelineStageRow.tenant_id == tenant_id,
SalesPipelineStageRow.stage_id == normalized,
)
).scalars().all()
if direct:
return list(direct)
code = _normalize_stage_code(normalized)
return list(
session.execute(
select(SalesPipelineStageRow.stage_id).where(
SalesPipelineStageRow.tenant_id == tenant_id,
SalesPipelineStageRow.code == code,
)
).scalars().all()
)
def _stage_refs_by_id(session, tenant_id: str, stage_ids: list[str | None]) -> dict[str, SalesPipelineStageRow]:
ids = [stage_id for stage_id in stage_ids if stage_id]
if not ids:
return {}
rows = session.execute(
select(SalesPipelineStageRow).where(
SalesPipelineStageRow.tenant_id == tenant_id,
SalesPipelineStageRow.stage_id.in_(ids),
)
).scalars().all()
return {row.stage_id: row for row in rows}
def _deal_pipeline_stage(session, deal: SalesDealRow) -> tuple[SalesPipelineRow | None, SalesPipelineStageRow | None]:
pipeline = session.execute(
select(SalesPipelineRow).where(
SalesPipelineRow.tenant_id == deal.tenant_id,
SalesPipelineRow.pipeline_id == deal.pipeline_id,
)
).scalar_one_or_none()
stage = session.execute(
select(SalesPipelineStageRow).where(
SalesPipelineStageRow.tenant_id == deal.tenant_id,
SalesPipelineStageRow.stage_id == deal.stage_id,
)
).scalar_one_or_none()
return pipeline, stage
def _deal_to_out_with_refs(session, deal: SalesDealRow) -> SalesDealOut:
pipeline, stage = _deal_pipeline_stage(session, deal)
return _deal_to_out(deal, pipeline=pipeline, stage=stage)
def _publish_sales_event(
session,
*,
tenant_id: str,
event_type: str,
aggregate_type: str,
aggregate_id: str,
payload: dict[str, Any],
actor_type: str | None = None,
actor_id: str | None = None,
correlation_id: str | None = None,
causation_id: str | None = None,
) -> None:
SalesEventPublisher.publish_sales_event(
session,
tenant_id=tenant_id,
event_type=event_type,
aggregate_type=aggregate_type,
aggregate_id=aggregate_id,
payload=payload,
actor_type=actor_type,
actor_id=actor_id,
correlation_id=correlation_id,
causation_id=causation_id,
)
def _stage_by_id_or_none(session, tenant_id: str, stage_id: str | None) -> SalesPipelineStageRow | None:
if not stage_id:
return None
return session.execute(
select(SalesPipelineStageRow).where(
SalesPipelineStageRow.tenant_id == tenant_id,
SalesPipelineStageRow.stage_id == stage_id,
)
).scalar_one_or_none()
def _publish_lead_entered_event(
session,
*,
lead: SalesLeadRow,
deal: SalesDealRow,
stage: SalesPipelineStageRow,
actor_type: str,
actor_id: str | None,
) -> None:
_publish_sales_event(
session,
tenant_id=lead.tenant_id,
event_type=sales_event_types.LEAD_ENTERED_CRM,
aggregate_type="lead",
aggregate_id=lead.lead_id,
actor_type=actor_type,
actor_id=actor_id,
payload={
"lead_id": lead.lead_id,
"deal_id": deal.deal_id,
"source_type": lead.source_type,
"source_channel": lead.source_channel,
"lead_temperature": lead.lead_temperature,
"initial_stage_id": stage.stage_id,
"initial_stage_code": stage.code,
},
)
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,
*,
pipeline: SalesPipelineRow | None = None,
stage: SalesPipelineStageRow | None = None,
) -> 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,
pipeline=_pipeline_to_out(pipeline) if pipeline else None,
stage=_stage_to_ref(stage),
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, stage_refs: dict[str, SalesPipelineStageRow] | None = None) -> SalesStageHistoryOut:
refs = stage_refs or {}
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,
from_stage=_stage_to_ref(refs.get(row.from_stage_id or "")),
to_stage=_stage_to_ref(refs.get(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, tenant_id: str) -> SalesLeadRow:
row = session.execute(
select(SalesLeadRow).where(SalesLeadRow.lead_id == lead_id, SalesLeadRow.tenant_id == tenant_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, tenant_id: str) -> SalesDealRow:
row = session.execute(
select(SalesDealRow).where(SalesDealRow.deal_id == deal_id, SalesDealRow.tenant_id == tenant_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, tenant_id: str) -> SalesCommunicationSessionRow:
row = session.execute(
select(SalesCommunicationSessionRow).where(
SalesCommunicationSessionRow.communication_id == communication_id,
SalesCommunicationSessionRow.tenant_id == tenant_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,
*,
tenant_id: str,
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(SalesExternalLinkRow.tenant_id == tenant_id)
.where(or_(*conditions))
.order_by(SalesExternalLinkRow.id.desc())
).scalars().first()
def _metadata_value(metadata: dict | None, *keys: str) -> str | None:
payload = metadata or {}
for key in keys:
value = payload.get(key)
normalized = str(value or "").strip()
if normalized:
return normalized
return None
def _resolve_provider_tenant_id(
session,
actor: dict,
*,
provider_type: str,
provider_name: str,
metadata: dict | None = None,
provider_account_id: str | None = None,
external_identifier: str | None = None,
) -> str:
tenant_id = _optional_tenant_id(actor)
if tenant_id:
return tenant_id
normalized_provider_name = str(provider_name or "").strip().lower()
normalized_provider_type = str(provider_type or "").strip().lower()
normalized_account = str(provider_account_id or "").strip() or _metadata_value(
metadata,
"provider_account_id",
"payment_provider_account_id",
"merchant_id",
"account_id",
"integration_id",
)
normalized_external = str(external_identifier or "").strip() or _metadata_value(
metadata,
"external_identifier",
"channel_id",
"phone_number",
"merchant_id",
)
base_stmt = select(TenantIntegrationRow).where(
func.lower(TenantIntegrationRow.provider_type) == normalized_provider_type,
func.lower(TenantIntegrationRow.provider_name) == normalized_provider_name,
TenantIntegrationRow.is_active == True, # noqa: E712
)
if normalized_account:
row = session.execute(
base_stmt.where(TenantIntegrationRow.provider_account_id == normalized_account).order_by(TenantIntegrationRow.id.desc())
).scalars().first()
if row is not None:
return row.tenant_id
if normalized_external:
row = session.execute(
base_stmt.where(TenantIntegrationRow.external_identifier == normalized_external).order_by(TenantIntegrationRow.id.desc())
).scalars().first()
if row is not None:
return row.tenant_id
raise HTTPException(status_code=400, detail="Tenant context is required")
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,
*,
tenant_id: str,
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()
pipeline = get_default_pipeline(session, tenant_id)
stage = resolve_stage_by_code_or_id(
session,
tenant_id=tenant_id,
pipeline_id=pipeline.pipeline_id,
stage_id=DEFAULT_STAGE_CODE,
)
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=pipeline.pipeline_id,
stage_id=stage.stage_id,
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=stage.stage_id,
changed_by_type="system",
changed_by_id=None,
reason="autosync.created",
)
_publish_lead_entered_event(session, lead=lead, deal=deal, stage=stage, actor_type="system", actor_id=None)
return lead, deal
def _resolve_or_create_sync_deal(
session,
*,
tenant_id: str,
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,
tenant_id=tenant_id,
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, tenant_id)
lead = _get_lead(session, deal.lead_id, tenant_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.tenant_id == tenant_id)
.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, tenant_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,
tenant_id=tenant_id,
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,
tenant_id=tenant_id,
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_by_id(session, deal: SalesDealRow, stage_id: str, *, actor_type: str, actor_id: str | None, reason: str | None = None) -> SalesPipelineStageRow:
stage = _get_stage(session, stage_id, deal.tenant_id, deal.pipeline_id)
if not stage.is_active:
raise HTTPException(status_code=400, detail="Stage is inactive")
if deal.stage_id != stage.stage_id:
previous = deal.stage_id
previous_stage = _stage_by_id_or_none(session, deal.tenant_id, previous)
deal.stage_id = stage.stage_id
_record_stage_change(
session,
deal=deal,
from_stage_id=previous,
to_stage_id=stage.stage_id,
changed_by_type=actor_type,
changed_by_id=actor_id,
reason=reason,
)
_publish_sales_event(
session,
tenant_id=deal.tenant_id,
event_type=sales_event_types.DEAL_STAGE_CHANGED,
aggregate_type="deal",
aggregate_id=deal.deal_id,
actor_type=actor_type,
actor_id=actor_id,
payload={
"deal_id": deal.deal_id,
"pipeline_id": deal.pipeline_id,
"from_stage_id": previous,
"from_stage_code": previous_stage.code if previous_stage else None,
"to_stage_id": stage.stage_id,
"to_stage_code": stage.code,
"reason": reason,
},
)
deal.updated_at = utc_now_iso()
return stage
def apply_stage_by_code(session, deal: SalesDealRow, stage_code: str, *, actor_type: str, actor_id: str | None, reason: str | None = None) -> SalesPipelineStageRow:
ensure_default_pipeline(session, deal.tenant_id)
stage = resolve_stage_by_code_or_id(
session,
tenant_id=deal.tenant_id,
pipeline_id=deal.pipeline_id,
stage_id=stage_code,
)
return apply_stage_by_id(session, deal, stage.stage_id, actor_type=actor_type, actor_id=actor_id, reason=reason)
def _apply_stage(session, deal: SalesDealRow, stage_code: str, *, actor_type: str, actor_id: str | None, reason: str | None = None) -> None:
apply_stage_by_code(session, deal, stage_code, actor_type=actor_type, actor_id=actor_id, reason=reason)
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)
communication_actor_type = "system" if agent_type.endswith("_ai") else "human"
_publish_sales_event(
session,
tenant_id=deal.tenant_id,
event_type=sales_event_types.COMMUNICATION_STARTED,
aggregate_type="communication",
aggregate_id=communication.communication_id,
actor_type=communication_actor_type,
actor_id=actor_user,
payload={
"deal_id": deal.deal_id,
"lead_id": communication.lead_id,
"communication_session_id": communication.communication_id,
"channel_type": communication.channel_type,
"direction": communication.direction,
"agent_type": communication.agent_type,
"subject": communication.subject,
},
)
_apply_stage(
session,
deal,
_infer_stage_for_channel(payload.channel_type),
actor_type=communication_actor_type,
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,
*,
tenant_id: str,
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, tenant_id)
if lead_id:
lead = _get_lead(session, lead_id, tenant_id)
deal = session.execute(
select(SalesDealRow)
.where(SalesDealRow.lead_id == lead.lead_id, SalesDealRow.tenant_id == tenant_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, SalesLeadRow.tenant_id == tenant_id)
.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",
SalesDealRow.tenant_id == tenant_id,
)
.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()
pipeline = get_default_pipeline(session, tenant_id)
stage = resolve_stage_by_code_or_id(
session,
tenant_id=tenant_id,
pipeline_id=pipeline.pipeline_id,
stage_id=DEFAULT_STAGE_CODE,
)
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=pipeline.pipeline_id,
stage_id=stage.stage_id,
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=stage.stage_id,
changed_by_type="system",
changed_by_id=None,
reason="lead.entered_crm",
)
_publish_lead_entered_event(session, lead=lead, deal=deal, stage=stage, actor_type="system", actor_id=None)
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", deal.tenant_id),
)
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, deal: SalesDealRow) -> 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", deal.tenant_id),
)
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,
tenant_id=deal.tenant_id,
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, tenant_id=deal.tenant_id, thread_id=thread_id)
if link and link.communication_id:
existing = session.execute(
select(SalesCommunicationSessionRow).where(
SalesCommunicationSessionRow.communication_id == link.communication_id,
SalesCommunicationSessionRow.tenant_id == deal.tenant_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, SalesCommunicationSessionRow.tenant_id == deal.tenant_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, tenant_id=deal.tenant_id, voice_session_id=voice_session_id)
if link and link.communication_id:
existing = session.execute(
select(SalesCommunicationSessionRow).where(
SalesCommunicationSessionRow.communication_id == link.communication_id,
SalesCommunicationSessionRow.tenant_id == deal.tenant_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, SalesCommunicationSessionRow.tenant_id == deal.tenant_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, deal.tenant_id) if deal.lead_id else None
pipeline, stage = _deal_pipeline_stage(session, deal)
communications = session.execute(
select(SalesCommunicationSessionRow)
.where(SalesCommunicationSessionRow.deal_id == deal.deal_id, SalesCommunicationSessionRow.tenant_id == deal.tenant_id)
.order_by(SalesCommunicationSessionRow.started_at.desc(), SalesCommunicationSessionRow.id.desc())
).scalars().all()
messages = session.execute(
select(SalesMessageRow)
.where(SalesMessageRow.deal_id == deal.deal_id, SalesMessageRow.tenant_id == deal.tenant_id)
.order_by(SalesMessageRow.sent_at.desc(), SalesMessageRow.id.desc())
).scalars().all()
calls = session.execute(
select(SalesCallRow)
.where(SalesCallRow.deal_id == deal.deal_id, SalesCallRow.tenant_id == deal.tenant_id)
.order_by(SalesCallRow.started_at.desc(), SalesCallRow.id.desc())
).scalars().all()
offers = session.execute(
select(SalesOfferRow)
.where(SalesOfferRow.deal_id == deal.deal_id, SalesOfferRow.tenant_id == deal.tenant_id)
.order_by(SalesOfferRow.updated_at.desc(), SalesOfferRow.id.desc())
).scalars().all()
conditions = session.execute(
select(SalesConditionRow)
.where(SalesConditionRow.deal_id == deal.deal_id, SalesConditionRow.tenant_id == deal.tenant_id)
.order_by(SalesConditionRow.updated_at.desc(), SalesConditionRow.id.desc())
).scalars().all()
counterparty = session.execute(
select(SalesCounterpartyRow).where(SalesCounterpartyRow.deal_id == deal.deal_id, SalesCounterpartyRow.tenant_id == deal.tenant_id)
).scalar_one_or_none()
documents = session.execute(
select(SalesDocumentRow)
.where(SalesDocumentRow.deal_id == deal.deal_id, SalesDocumentRow.tenant_id == deal.tenant_id)
.order_by(SalesDocumentRow.updated_at.desc(), SalesDocumentRow.id.desc())
).scalars().all()
invoices = session.execute(
select(SalesInvoiceRow)
.where(SalesInvoiceRow.deal_id == deal.deal_id, SalesInvoiceRow.tenant_id == deal.tenant_id)
.order_by(SalesInvoiceRow.updated_at.desc(), SalesInvoiceRow.id.desc())
).scalars().all()
payments = session.execute(
select(SalesPaymentRow)
.where(SalesPaymentRow.deal_id == deal.deal_id, SalesPaymentRow.tenant_id == deal.tenant_id)
.order_by(SalesPaymentRow.updated_at.desc(), SalesPaymentRow.id.desc())
).scalars().all()
escalations = session.execute(
select(SalesEscalationRow)
.where(SalesEscalationRow.deal_id == deal.deal_id, SalesEscalationRow.tenant_id == deal.tenant_id)
.order_by(SalesEscalationRow.created_at.desc(), SalesEscalationRow.id.desc())
).scalars().all()
tasks = session.execute(
select(SalesAutomationTaskRow)
.where(SalesAutomationTaskRow.deal_id == deal.deal_id, SalesAutomationTaskRow.tenant_id == deal.tenant_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, SalesStageHistoryRow.tenant_id == deal.tenant_id)
.order_by(SalesStageHistoryRow.changed_at.desc(), SalesStageHistoryRow.id.desc())
).scalars().all()
stage_refs = _stage_refs_by_id(
session,
deal.tenant_id,
[stage.stage_id if stage else None]
+ [row.from_stage_id for row in stage_history]
+ [row.to_stage_id for row in stage_history],
)
channel_switches = session.execute(
select(SalesChannelSwitchRow)
.where(SalesChannelSwitchRow.deal_id == deal.deal_id, SalesChannelSwitchRow.tenant_id == deal.tenant_id)
.order_by(SalesChannelSwitchRow.switched_at.desc(), SalesChannelSwitchRow.id.desc())
).scalars().all()
timeline: list[SalesTimelineEventOut] = []
for row in stage_history:
to_stage = stage_refs.get(row.to_stage_id)
timeline.append(
SalesTimelineEventOut(
ts=row.changed_at,
kind="stage",
title=f"Этап: {to_stage.name if to_stage else 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, pipeline=pipeline, stage=stage),
pipeline=_pipeline_to_out(pipeline) if pipeline else None,
stage=_stage_to_ref(stage),
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, stage_refs) 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(actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST))) -> SalesDashboardSummaryOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
leads_total = session.execute(
select(func.count()).select_from(SalesLeadRow).where(SalesLeadRow.tenant_id == tenant_id)
).scalar_one()
deals_active = session.execute(
select(func.count()).select_from(SalesDealRow).where(SalesDealRow.tenant_id == tenant_id, SalesDealRow.status == "active")
).scalar_one()
deals_won = session.execute(
select(func.count()).select_from(SalesDealRow).where(SalesDealRow.tenant_id == tenant_id, SalesDealRow.status == "won")
).scalar_one()
deals_lost = session.execute(
select(func.count()).select_from(SalesDealRow).where(SalesDealRow.tenant_id == tenant_id, SalesDealRow.status == "lost")
).scalar_one()
overdue_invoices = session.execute(
select(func.count()).select_from(SalesInvoiceRow).where(SalesInvoiceRow.tenant_id == tenant_id, SalesInvoiceRow.status == "overdue")
).scalar_one()
payment_expected = session.execute(
select(func.coalesce(func.sum(SalesInvoiceRow.amount), 0.0)).where(
SalesInvoiceRow.tenant_id == tenant_id,
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.tenant_id == tenant_id,
SalesPaymentRow.status.in_(["success", "partial"]),
)
).scalar_one()
voice_sessions = session.execute(
select(func.count()).select_from(SalesCommunicationSessionRow).where(
SalesCommunicationSessionRow.tenant_id == tenant_id,
SalesCommunicationSessionRow.channel_type == "voice",
)
).scalar_one()
text_sessions = session.execute(
select(func.count()).select_from(SalesCommunicationSessionRow).where(
SalesCommunicationSessionRow.tenant_id == tenant_id,
SalesCommunicationSessionRow.channel_type == "text",
)
).scalar_one()
channel_switches = session.execute(
select(func.count()).select_from(SalesChannelSwitchRow).where(SalesChannelSwitchRow.tenant_id == tenant_id)
).scalar_one()
human_escalations = session.execute(
select(func.count()).select_from(SalesEscalationRow).where(SalesEscalationRow.tenant_id == tenant_id)
).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))
.where(SalesDealRow.tenant_id == tenant_id)
.group_by(SalesDealRow.stage_id)
).all()
stage_refs = _stage_refs_by_id(session, tenant_id, [stage_id for stage_id, _count, _amount in stage_rows])
stage_counts = [
SalesDashboardStageOut(
stage_id=stage_id,
label=stage_refs[stage_id].name if stage_id in stage_refs else stage_id,
stage=_stage_to_ref(stage_refs.get(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)
.where(SalesDealRow.tenant_id == tenant_id)
.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_with_refs(session, row) for row in hottest_rows],
)
finally:
session.close()
@app.get("/api/v1/pipelines", response_model=list[SalesPipelineOut])
def list_pipelines(actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST))) -> list[SalesPipelineOut]:
session = get_session()
try:
tenant_id = _tenant_id(actor)
ensure_default_pipeline(session, tenant_id)
rows = session.execute(
select(SalesPipelineRow)
.where(SalesPipelineRow.tenant_id == tenant_id)
.order_by(SalesPipelineRow.is_default.desc(), SalesPipelineRow.created_at.asc(), SalesPipelineRow.id.asc())
).scalars().all()
session.commit()
return [_pipeline_to_out(row) for row in rows]
finally:
session.close()
@app.get("/api/v1/pipelines/{pipeline_id}", response_model=SalesPipelineOut)
def get_pipeline(pipeline_id: str, actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST))) -> SalesPipelineOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
ensure_default_pipeline(session, tenant_id)
return _pipeline_to_out(_get_pipeline(session, pipeline_id, tenant_id))
finally:
session.close()
@app.post("/api/v1/pipelines", response_model=SalesPipelineOut)
def create_pipeline(
payload: SalesPipelineCreate,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesPipelineOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
ensure_default_pipeline(session, tenant_id)
now = utc_now_iso()
pipeline = SalesPipelineRow(
pipeline_id=new_id("pip"),
tenant_id=tenant_id,
code=payload.code.strip(),
name=payload.name,
description=payload.description,
is_default=False,
is_active=payload.is_active,
created_at=now,
updated_at=now,
)
session.add(pipeline)
if payload.is_default:
_set_default_pipeline(session, pipeline)
try:
session.commit()
except IntegrityError as exc:
session.rollback()
raise HTTPException(status_code=409, detail="Pipeline code already exists") from exc
return _pipeline_to_out(pipeline)
finally:
session.close()
@app.patch("/api/v1/pipelines/{pipeline_id}", response_model=SalesPipelineOut)
def update_pipeline(
pipeline_id: str,
payload: SalesPipelineUpdate,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesPipelineOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
pipeline = _get_pipeline(session, pipeline_id, tenant_id)
updates = payload.model_dump(exclude_unset=True)
if "name" in updates:
pipeline.name = updates["name"]
if "description" in updates:
pipeline.description = updates["description"]
if updates.get("is_active") is False and pipeline.is_default:
raise HTTPException(status_code=400, detail="Default pipeline cannot be deactivated")
if "is_active" in updates:
pipeline.is_active = bool(updates["is_active"])
if updates.get("is_default") is True:
if not pipeline.is_active:
raise HTTPException(status_code=400, detail="Inactive pipeline cannot be default")
_set_default_pipeline(session, pipeline)
elif updates.get("is_default") is False and pipeline.is_default:
raise HTTPException(status_code=400, detail="Use another pipeline set-default action")
pipeline.updated_at = utc_now_iso()
session.commit()
return _pipeline_to_out(pipeline)
finally:
session.close()
@app.post("/api/v1/pipelines/{pipeline_id}/set-default", response_model=SalesPipelineOut)
def set_default_pipeline(
pipeline_id: str,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesPipelineOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
pipeline = _get_pipeline(session, pipeline_id, tenant_id)
if not pipeline.is_active:
raise HTTPException(status_code=400, detail="Inactive pipeline cannot be default")
_set_default_pipeline(session, pipeline)
session.commit()
return _pipeline_to_out(pipeline)
finally:
session.close()
@app.get("/api/v1/pipelines/{pipeline_id}/stages", response_model=list[SalesPipelineStageOut])
def list_pipeline_stages(
pipeline_id: str,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> list[SalesPipelineStageOut]:
session = get_session()
try:
tenant_id = _tenant_id(actor)
pipeline = _get_pipeline(session, pipeline_id, tenant_id)
if pipeline.code == DEFAULT_PIPELINE_CODE:
ensure_default_stages(session, tenant_id, pipeline.pipeline_id)
rows = session.execute(
select(SalesPipelineStageRow)
.where(SalesPipelineStageRow.tenant_id == tenant_id, SalesPipelineStageRow.pipeline_id == pipeline_id)
.order_by(SalesPipelineStageRow.sort_order.asc(), SalesPipelineStageRow.id.asc())
).scalars().all()
session.commit()
return [_stage_to_out(row) for row in rows]
finally:
session.close()
@app.post("/api/v1/pipelines/{pipeline_id}/stages", response_model=SalesPipelineStageOut)
def create_pipeline_stage(
pipeline_id: str,
payload: SalesPipelineStageCreate,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesPipelineStageOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
_get_pipeline(session, pipeline_id, tenant_id)
now = utc_now_iso()
stage = SalesPipelineStageRow(
stage_id=new_id("pst"),
tenant_id=tenant_id,
pipeline_id=pipeline_id,
code=payload.code.strip(),
name=payload.name,
category=payload.category,
sort_order=payload.sort_order,
is_terminal=payload.is_terminal,
is_system=payload.is_system,
is_active=payload.is_active,
created_at=now,
updated_at=now,
)
session.add(stage)
try:
session.commit()
except IntegrityError as exc:
session.rollback()
raise HTTPException(status_code=409, detail="Stage code already exists") from exc
return _stage_to_out(stage)
finally:
session.close()
@app.patch("/api/v1/pipeline-stages/{stage_id}", response_model=SalesPipelineStageOut)
def update_pipeline_stage(
stage_id: str,
payload: SalesPipelineStageUpdate,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesPipelineStageOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
stage = _get_stage(session, stage_id, tenant_id)
updates = payload.model_dump(exclude_unset=True)
if updates.get("is_active") is False and stage.is_active:
active_deals = session.execute(
select(func.count())
.select_from(SalesDealRow)
.where(
SalesDealRow.tenant_id == tenant_id,
SalesDealRow.stage_id == stage.stage_id,
SalesDealRow.status == "active",
)
).scalar_one()
if int(active_deals or 0) > 0:
raise HTTPException(status_code=400, detail="Stage has active deals")
if "name" in updates:
stage.name = updates["name"]
if "category" in updates:
stage.category = updates["category"]
if "sort_order" in updates:
stage.sort_order = int(updates["sort_order"])
if "is_active" in updates:
stage.is_active = bool(updates["is_active"])
stage.updated_at = utc_now_iso()
session.commit()
return _stage_to_out(stage)
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:
tenant_id = _tenant_id(actor)
now = utc_now_iso()
crm_customer = _find_customer_by_phone(session, payload.phone)
pipeline = get_default_pipeline(session, tenant_id)
initial_stage_code = payload.status if payload.status != "converted" else DEFAULT_STAGE_CODE
try:
stage = resolve_stage_by_code_or_id(
session,
tenant_id=tenant_id,
pipeline_id=pipeline.pipeline_id,
stage_id=initial_stage_code,
)
except HTTPException:
stage = resolve_stage_by_code_or_id(
session,
tenant_id=tenant_id,
pipeline_id=pipeline.pipeline_id,
stage_id=DEFAULT_STAGE_CODE,
)
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=pipeline.pipeline_id,
stage_id=stage.stage_id,
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=stage.stage_id,
changed_by_type="human",
changed_by_id=actor["user"],
reason="lead.entered_crm",
)
_publish_lead_entered_event(session, lead=lead, deal=deal, stage=stage, actor_type="human", actor_id=actor["user"])
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),
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> list[SalesLeadOut]:
session = get_session()
try:
tenant_id = _tenant_id(actor)
stmt = (
select(SalesLeadRow)
.where(SalesLeadRow.tenant_id == tenant_id)
.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,
actor: 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, _tenant_id(actor)))
finally:
session.close()
@app.patch("/api/v1/leads/{lead_id}", response_model=SalesLeadOut)
def update_lead(
lead_id: str,
payload: SalesLeadUpdate,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesLeadOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
lead = _get_lead(session, lead_id, tenant_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:
tenant_id = _tenant_id(actor)
lead = _get_lead(session, lead_id, tenant_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, SalesDealRow.tenant_id == tenant_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",
)
_publish_sales_event(
session,
tenant_id=tenant_id,
event_type=sales_event_types.LEAD_ENRICHMENT_COMPLETED,
aggregate_type="lead",
aggregate_id=lead.lead_id,
actor_type="human",
actor_id=actor["user"],
payload={
"lead_id": lead.lead_id,
"deal_id": deal.deal_id,
"need_summary": lead.initial_need_summary,
"customer_type": lead.customer_type,
"preferred_channel": lead.preferred_channel,
},
)
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,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesDealOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
lead = _get_lead(session, lead_id, tenant_id)
lead.status = "converted"
lead.updated_at = utc_now_iso()
deal = session.execute(
select(SalesDealRow)
.where(SalesDealRow.lead_id == lead.lead_id, SalesDealRow.tenant_id == tenant_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_with_refs(session, 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:
tenant_id = _tenant_id(actor)
now = utc_now_iso()
if payload.lead_id:
_get_lead(session, payload.lead_id, tenant_id)
pipeline = get_default_pipeline(session, tenant_id)
stage_id_input = payload.stage_id
if not stage_id_input and not payload.stage_code:
stage_id_input = DEFAULT_STAGE_CODE
stage = resolve_stage_by_code_or_id(
session,
tenant_id=tenant_id,
pipeline_id=pipeline.pipeline_id,
stage_id=stage_id_input,
stage_code=payload.stage_code,
)
deal = SalesDealRow(
deal_id=new_id("sde"),
tenant_id=tenant_id,
lead_id=payload.lead_id,
customer_id=payload.customer_id,
pipeline_id=pipeline.pipeline_id,
stage_id=stage.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=stage.stage_id,
changed_by_type="human",
changed_by_id=actor["user"],
reason="deal.created",
)
session.commit()
return _deal_to_out(deal, pipeline=pipeline, stage=stage)
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),
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> list[SalesDealOut]:
session = get_session()
try:
tenant_id = _tenant_id(actor)
stmt = (
select(SalesDealRow)
.where(SalesDealRow.tenant_id == tenant_id)
.order_by(SalesDealRow.updated_at.desc(), SalesDealRow.id.desc())
)
if stage_id:
matching_stage_ids = _stage_filter_ids(session, tenant_id, stage_id)
stmt = stmt.where(SalesDealRow.stage_id.in_(matching_stage_ids or [""]))
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(
SalesLeadRow.tenant_id == tenant_id,
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(
SalesExternalLinkRow.tenant_id == tenant_id,
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_with_refs(session, row) for row in rows]
finally:
session.close()
@app.get("/api/v1/deals/{deal_id}", response_model=SalesDealOut)
def get_deal(
deal_id: str,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> SalesDealOut:
session = get_session()
try:
return _deal_to_out_with_refs(session, _get_deal(session, deal_id, _tenant_id(actor)))
finally:
session.close()
@app.get("/api/v1/deals/{deal_id}/workspace", response_model=SalesWorkspaceOut)
def get_workspace(
deal_id: str,
actor: 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, _tenant_id(actor)))
finally:
session.close()
@app.patch("/api/v1/deals/{deal_id}", response_model=SalesDealOut)
def update_deal(
deal_id: str,
payload: SalesDealUpdate,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesDealOut:
session = get_session()
try:
deal = _get_deal(session, deal_id, _tenant_id(actor))
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_with_refs(session, 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, _tenant_id(actor))
if not payload.stage_id and not payload.stage_code:
raise HTTPException(status_code=400, detail="stage_id or stage_code is required")
ensure_default_pipeline(session, deal.tenant_id)
stage = resolve_stage_by_code_or_id(
session,
tenant_id=deal.tenant_id,
pipeline_id=deal.pipeline_id,
stage_id=payload.stage_id,
stage_code=payload.stage_code,
)
apply_stage_by_id(session, deal, stage.stage_id, actor_type="human", actor_id=actor["user"], reason=payload.reason)
session.commit()
return _deal_to_out_with_refs(session, deal)
finally:
session.close()
@app.post("/api/v1/deals/{deal_id}/select-scenario", response_model=SalesDealOut)
def select_scenario(
deal_id: str,
payload: SalesDealScenarioIn,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesDealOut:
session = get_session()
try:
deal = _get_deal(session, deal_id, _tenant_id(actor))
previous_scenario_type = deal.scenario_type
deal.scenario_type = payload.scenario_type
deal.updated_at = utc_now_iso()
_publish_sales_event(
session,
tenant_id=deal.tenant_id,
event_type=sales_event_types.DEAL_SCENARIO_SELECTED,
aggregate_type="deal",
aggregate_id=deal.deal_id,
actor_type="human",
actor_id=actor["user"],
payload={
"deal_id": deal.deal_id,
"scenario_type": deal.scenario_type,
"previous_scenario_type": previous_scenario_type,
},
)
session.commit()
return _deal_to_out_with_refs(session, 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, _tenant_id(actor))
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",
)
_publish_sales_event(
session,
tenant_id=deal.tenant_id,
event_type=sales_event_types.DEAL_NEXT_ACTION_SCHEDULED,
aggregate_type="deal",
aggregate_id=deal.deal_id,
actor_type="human",
actor_id=actor["user"],
payload={
"deal_id": deal.deal_id,
"next_action_type": deal.next_action_type,
"next_action_at": deal.next_action_at,
},
)
session.commit()
return _deal_to_out_with_refs(session, 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, _tenant_id(actor))
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",
)
_publish_sales_event(
session,
tenant_id=deal.tenant_id,
event_type=sales_event_types.DEAL_CLOSED,
aggregate_type="deal",
aggregate_id=deal.deal_id,
actor_type="human",
actor_id=actor["user"],
payload={
"deal_id": deal.deal_id,
"status": deal.status,
"reason": payload.reason,
"closed_at": deal.closed_at,
},
)
if payload.status == "won":
_publish_sales_event(
session,
tenant_id=deal.tenant_id,
event_type=sales_event_types.DEAL_WON,
aggregate_type="deal",
aggregate_id=deal.deal_id,
actor_type="human",
actor_id=actor["user"],
payload={
"deal_id": deal.deal_id,
"final_amount": deal.final_amount,
"currency": deal.currency,
"won_reason": payload.reason or "deal.closed",
},
)
elif payload.status == "lost":
_publish_sales_event(
session,
tenant_id=deal.tenant_id,
event_type=sales_event_types.DEAL_LOST,
aggregate_type="deal",
aggregate_id=deal.deal_id,
actor_type="human",
actor_id=actor["user"],
payload={
"deal_id": deal.deal_id,
"lost_reason": payload.reason or "deal.closed",
"currency": deal.currency,
},
)
session.commit()
return _deal_to_out_with_refs(session, 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, _tenant_id(actor))
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:
tenant_id = _tenant_id(actor)
deal = _get_deal(session, deal_id, tenant_id)
lead = _get_lead(session, deal.lead_id, tenant_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:
tenant_id = _tenant_id(actor)
deal = _get_deal(session, deal_id, tenant_id)
lead = _get_lead(session, deal.lead_id, tenant_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,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> list[SalesCommunicationOut]:
session = get_session()
try:
tenant_id = _tenant_id(actor)
_get_deal(session, deal_id, tenant_id)
rows = session.execute(
select(SalesCommunicationSessionRow)
.where(SalesCommunicationSessionRow.deal_id == deal_id, SalesCommunicationSessionRow.tenant_id == tenant_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,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesCommunicationOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
row = _get_communication(session, communication_id, tenant_id)
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, tenant_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},
)
_publish_sales_event(
session,
tenant_id=tenant_id,
event_type=sales_event_types.DEAL_NEXT_ACTION_SCHEDULED,
aggregate_type="deal",
aggregate_id=deal.deal_id,
actor_type="human",
actor_id=actor["user"],
payload={
"deal_id": deal.deal_id,
"next_action_type": payload.next_action_type,
"next_action_at": payload.next_action_at,
"communication_session_id": row.communication_id,
},
)
_publish_sales_event(
session,
tenant_id=tenant_id,
event_type=sales_event_types.COMMUNICATION_SUMMARY_CREATED,
aggregate_type="communication",
aggregate_id=row.communication_id,
actor_type="human",
actor_id=actor["user"],
payload={
"deal_id": deal.deal_id,
"communication_session_id": row.communication_id,
"summary": row.summary,
"result_code": row.result_code,
"sentiment": row.sentiment,
},
)
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:
tenant_id = _tenant_id(actor)
communication = _get_communication(session, communication_id, tenant_id)
deal = _get_deal(session, communication.deal_id, tenant_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:
tenant_id = _tenant_id(actor)
communication = _get_communication(session, communication_id, tenant_id)
deal = _get_deal(session, communication.deal_id, tenant_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, SalesCallRow.tenant_id == tenant_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,
actor: dict = Depends(require_roles(Role.ADMIN)),
) -> SalesWorkspaceOut:
session = get_session()
try:
tenant_id = _resolve_provider_tenant_id(
session,
actor,
provider_type="message",
provider_name="telegram",
metadata=payload.metadata,
external_identifier=payload.thread_id or payload.chat_id,
)
lead, deal = _resolve_or_create_sync_deal(
session,
tenant_id=tenant_id,
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.tenant_id == tenant_id)
.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,
actor: dict = Depends(require_roles(Role.ADMIN)),
) -> SalesWorkspaceOut:
session = get_session()
try:
tenant_id = _resolve_provider_tenant_id(
session,
actor,
provider_type="voice",
provider_name="voice_runtime",
metadata=payload.metadata,
external_identifier=payload.voice_session_id or payload.call_id or payload.caller_number,
)
lead, deal = _resolve_or_create_sync_deal(
session,
tenant_id=tenant_id,
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,
tenant_id=tenant_id,
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, SalesCallRow.tenant_id == tenant_id)
).scalar_one_or_none()
if call is None:
call = session.execute(
select(SalesCallRow)
.where(SalesCallRow.external_call_id == payload.call_id, SalesCallRow.tenant_id == tenant_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,
SalesTranscriptRow.tenant_id == tenant_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,
actor: dict = Depends(get_actor),
) -> SalesMessageOut:
session = get_session()
try:
tenant_id = _resolve_provider_tenant_id(
session,
actor,
provider_type="message",
provider_name=payload.channel_provider,
metadata=payload.metadata,
external_identifier=payload.phone,
)
lead, deal = _resolve_deal_for_inbound(
session,
tenant_id=tenant_id,
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)
_publish_sales_event(
session,
tenant_id=deal.tenant_id,
event_type=sales_event_types.MESSAGE_RECEIVED,
aggregate_type="message",
aggregate_id=message.message_id,
actor_type="system",
actor_id=None,
payload={
"deal_id": deal.deal_id,
"communication_session_id": communication.communication_id,
"message_id": message.message_id,
"channel_type": communication.channel_type,
"channel_provider": message.channel_provider,
"external_message_id": message.external_message_id,
},
)
session.commit()
return _message_to_out(message)
finally:
session.close()
@app.post("/api/v1/messages/outbound", response_model=SalesMessageOut)
def outbound_message(
payload: SalesMessageSendIn,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesMessageOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
deal = _get_deal(session, payload.deal_id, tenant_id)
communication = None
if payload.communication_id:
communication = session.execute(
select(SalesCommunicationSessionRow).where(
SalesCommunicationSessionRow.communication_id == payload.communication_id,
SalesCommunicationSessionRow.tenant_id == tenant_id,
)
).scalar_one_or_none()
if communication is not None and communication.deal_id != deal.deal_id:
raise HTTPException(status_code=400, detail="Communication does not belong to deal")
if communication is None:
lead = _get_lead(session, deal.lead_id, tenant_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, deal)
_publish_sales_event(
session,
tenant_id=deal.tenant_id,
event_type=sales_event_types.MESSAGE_SENT,
aggregate_type="message",
aggregate_id=message.message_id,
actor_type="human" if payload.sender_type == "human" else "system",
actor_id=payload.sender_id or actor["user"],
payload={
"deal_id": deal.deal_id,
"communication_session_id": communication.communication_id,
"message_id": message.message_id,
"channel_type": communication.channel_type,
"channel_provider": message.channel_provider,
"delivery_status": message.delivery_status,
},
)
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,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> list[SalesMessageOut]:
session = get_session()
try:
tenant_id = _tenant_id(actor)
_get_deal(session, deal_id, tenant_id)
rows = session.execute(
select(SalesMessageRow)
.where(SalesMessageRow.deal_id == deal_id, SalesMessageRow.tenant_id == tenant_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,
actor: dict = Depends(get_actor),
) -> SalesCallOut:
session = get_session()
try:
tenant_id = _resolve_provider_tenant_id(
session,
actor,
provider_type="voice",
provider_name=payload.provider,
metadata={"external_identifier": payload.external_call_id, "phone_number": payload.phone_number},
external_identifier=payload.external_call_id or payload.phone_number,
)
lead, deal = _resolve_deal_for_inbound(
session,
tenant_id=tenant_id,
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)
_publish_sales_event(
session,
tenant_id=deal.tenant_id,
event_type=sales_event_types.CALL_RECEIVED,
aggregate_type="call",
aggregate_id=row.call_id,
actor_type="system",
actor_id=None,
payload={
"deal_id": deal.deal_id,
"communication_session_id": communication.communication_id,
"call_id": row.call_id,
"phone_number": row.phone_number,
"provider": row.provider,
"external_call_id": row.external_call_id,
},
)
session.commit()
return _call_to_out(row)
finally:
session.close()
@app.post("/api/v1/calls/outbound", response_model=SalesCallOut)
def outbound_call(
payload: SalesCallWebhookIn,
actor: 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:
tenant_id = _tenant_id(actor)
deal = _get_deal(session, payload.deal_id, tenant_id)
lead = _get_lead(session, deal.lead_id, tenant_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,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesCallOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
row = session.execute(
select(SalesCallRow).where(SalesCallRow.call_id == call_id, SalesCallRow.tenant_id == tenant_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,
SalesCommunicationSessionRow.tenant_id == tenant_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, tenant_id)
_touch_deal_contact(deal)
_publish_sales_event(
session,
tenant_id=tenant_id,
event_type=sales_event_types.CALL_COMPLETED,
aggregate_type="call",
aggregate_id=row.call_id,
actor_type="human",
actor_id=actor["user"],
payload={
"deal_id": deal.deal_id,
"call_id": row.call_id,
"communication_session_id": row.communication_id,
"duration_sec": row.duration_sec,
"result_code": payload.result_code,
"transcript_id": row.transcript_id,
},
)
if payload.summary:
_publish_sales_event(
session,
tenant_id=tenant_id,
event_type=sales_event_types.COMMUNICATION_SUMMARY_CREATED,
aggregate_type="communication",
aggregate_id=row.communication_id,
actor_type="human",
actor_id=actor["user"],
payload={
"deal_id": deal.deal_id,
"call_id": row.call_id,
"communication_session_id": row.communication_id,
"duration_sec": row.duration_sec,
"transcript_id": row.transcript_id,
"summary": payload.summary,
},
)
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,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> list[SalesCallOut]:
session = get_session()
try:
tenant_id = _tenant_id(actor)
_get_deal(session, deal_id, tenant_id)
rows = session.execute(
select(SalesCallRow)
.where(SalesCallRow.deal_id == deal_id, SalesCallRow.tenant_id == tenant_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,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesTranscriptOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
call = session.execute(
select(SalesCallRow).where(SalesCallRow.call_id == call_id, SalesCallRow.tenant_id == tenant_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,
SalesCommunicationSessionRow.tenant_id == tenant_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, _tenant_id(actor))
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")
_publish_sales_event(
session,
tenant_id=deal.tenant_id,
event_type=sales_event_types.OFFER_CREATED,
aggregate_type="offer",
aggregate_id=row.offer_id,
actor_type="human",
actor_id=actor["user"],
payload={
"deal_id": deal.deal_id,
"offer_id": row.offer_id,
"offer_type": row.offer_type,
"total_amount": row.total_amount,
"currency": row.currency,
},
)
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,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> SalesOfferOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
row = session.execute(
select(SalesOfferRow).where(SalesOfferRow.offer_id == offer_id, SalesOfferRow.tenant_id == tenant_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,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesOfferOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
row = session.execute(
select(SalesOfferRow).where(SalesOfferRow.offer_id == offer_id, SalesOfferRow.tenant_id == tenant_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, tenant_id)
_apply_stage(session, deal, "offer_sent", actor_type="system", actor_id=None, reason="offer.sent")
_publish_sales_event(
session,
tenant_id=tenant_id,
event_type=sales_event_types.OFFER_SENT,
aggregate_type="offer",
aggregate_id=row.offer_id,
actor_type="system",
actor_id=None,
payload={
"deal_id": deal.deal_id,
"offer_id": row.offer_id,
"offer_type": row.offer_type,
"total_amount": row.total_amount,
"currency": row.currency,
},
)
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,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesOfferOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
row = session.execute(
select(SalesOfferRow).where(SalesOfferRow.offer_id == offer_id, SalesOfferRow.tenant_id == tenant_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, tenant_id)
_apply_stage(session, deal, "conditions_negotiation", actor_type="system", actor_id=None, reason="offer.accepted")
_publish_sales_event(
session,
tenant_id=tenant_id,
event_type=sales_event_types.OFFER_ACCEPTED,
aggregate_type="offer",
aggregate_id=row.offer_id,
actor_type="system",
actor_id=None,
payload={"deal_id": deal.deal_id, "offer_id": row.offer_id, "offer_type": row.offer_type},
)
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,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesOfferOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
row = session.execute(
select(SalesOfferRow).where(SalesOfferRow.offer_id == offer_id, SalesOfferRow.tenant_id == tenant_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, tenant_id)
_apply_stage(session, deal, "need_clarification", actor_type="system", actor_id=None, reason="offer.rejected")
_publish_sales_event(
session,
tenant_id=tenant_id,
event_type=sales_event_types.OFFER_REJECTED,
aggregate_type="offer",
aggregate_id=row.offer_id,
actor_type="system",
actor_id=None,
payload={"deal_id": deal.deal_id, "offer_id": row.offer_id, "offer_type": row.offer_type},
)
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:
tenant_id = _tenant_id(actor)
deal = _get_deal(session, deal_id, tenant_id)
row = session.execute(
select(SalesConditionRow).where(SalesConditionRow.deal_id == deal.deal_id, SalesConditionRow.tenant_id == tenant_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")
_publish_sales_event(
session,
tenant_id=tenant_id,
event_type=sales_event_types.DEAL_CONDITIONS_CONFIRMED,
aggregate_type="deal",
aggregate_id=deal.deal_id,
actor_type="human",
actor_id=actor["user"],
payload={
"deal_id": deal.deal_id,
"condition_id": row.condition_id,
"agreed_price": row.agreed_price,
"currency": row.currency,
"payment_terms": row.payment_terms,
},
)
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:
tenant_id = _tenant_id(actor)
deal = _get_deal(session, deal_id, tenant_id)
row = session.execute(
select(SalesCounterpartyRow).where(SalesCounterpartyRow.deal_id == deal.deal_id, SalesCounterpartyRow.tenant_id == tenant_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, tenant_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",
)
if status == "completed":
_publish_sales_event(
session,
tenant_id=tenant_id,
event_type=sales_event_types.COUNTERPARTY_COMPLETED,
aggregate_type="counterparty",
aggregate_id=row.counterparty_id,
actor_type="human",
actor_id=actor["user"],
payload={
"deal_id": deal.deal_id,
"counterparty_id": row.counterparty_id,
"customer_id": row.customer_id,
"completeness_status": row.completeness_status,
},
)
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,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> SalesCounterpartyOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
_get_deal(session, deal_id, tenant_id)
row = session.execute(
select(SalesCounterpartyRow).where(SalesCounterpartyRow.deal_id == deal_id, SalesCounterpartyRow.tenant_id == tenant_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, _tenant_id(actor))
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")
_publish_sales_event(
session,
tenant_id=deal.tenant_id,
event_type=sales_event_types.DOCUMENT_CREATED,
aggregate_type="document",
aggregate_id=row.document_id,
actor_type="human",
actor_id=actor["user"],
payload={
"deal_id": deal.deal_id,
"document_id": row.document_id,
"document_type": row.document_type,
"status": row.status,
},
)
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,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> SalesDocumentOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
row = session.execute(
select(SalesDocumentRow).where(SalesDocumentRow.document_id == document_id, SalesDocumentRow.tenant_id == tenant_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,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesDocumentOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
row = session.execute(
select(SalesDocumentRow).where(SalesDocumentRow.document_id == document_id, SalesDocumentRow.tenant_id == tenant_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, tenant_id)
_apply_stage(session, deal, "document_sent", actor_type="system", actor_id=None, reason="document.sent")
_publish_sales_event(
session,
tenant_id=tenant_id,
event_type=sales_event_types.DOCUMENT_SENT,
aggregate_type="document",
aggregate_id=row.document_id,
actor_type="system",
actor_id=None,
payload={
"deal_id": deal.deal_id,
"document_id": row.document_id,
"document_type": row.document_type,
"status": row.status,
},
)
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,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesDocumentOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
row = session.execute(
select(SalesDocumentRow).where(SalesDocumentRow.document_id == document_id, SalesDocumentRow.tenant_id == tenant_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, tenant_id)
_apply_stage(session, deal, "document_confirmed", actor_type="system", actor_id=None, reason="document.confirmed")
_publish_sales_event(
session,
tenant_id=tenant_id,
event_type=sales_event_types.DOCUMENT_CONFIRMED,
aggregate_type="document",
aggregate_id=row.document_id,
actor_type="system",
actor_id=None,
payload={
"deal_id": deal.deal_id,
"document_id": row.document_id,
"document_type": row.document_type,
"status": row.status,
},
)
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,
x_provider_account_id: str | None = Header(default=None, alias="X-Provider-Account-ID"),
x_provider_name: str | None = Header(default=None, alias="X-Provider-Name"),
actor: dict = Depends(get_actor),
) -> SalesDocumentOut:
session = get_session()
try:
tenant_id = _resolve_provider_tenant_id(
session,
actor,
provider_type="document_sign",
provider_name=x_provider_name or "generic",
provider_account_id=x_provider_account_id,
)
row = session.execute(
select(SalesDocumentRow).where(SalesDocumentRow.document_id == document_id, SalesDocumentRow.tenant_id == tenant_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
_publish_sales_event(
session,
tenant_id=tenant_id,
event_type=sales_event_types.DOCUMENT_SIGNED,
aggregate_type="document",
aggregate_id=row.document_id,
actor_type="system",
actor_id=None,
payload={
"deal_id": row.deal_id,
"document_id": row.document_id,
"document_type": row.document_type,
"status": row.status,
"signed_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, _tenant_id(actor))
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")
_publish_sales_event(
session,
tenant_id=deal.tenant_id,
event_type=sales_event_types.INVOICE_CREATED,
aggregate_type="invoice",
aggregate_id=row.invoice_id,
actor_type="human",
actor_id=actor["user"],
payload={
"deal_id": deal.deal_id,
"invoice_id": row.invoice_id,
"invoice_number": row.invoice_number,
"amount": row.amount,
"currency": row.currency,
"due_date": row.due_date,
},
)
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,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> SalesInvoiceOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
row = session.execute(
select(SalesInvoiceRow).where(SalesInvoiceRow.invoice_id == invoice_id, SalesInvoiceRow.tenant_id == tenant_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,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesInvoiceOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
row = session.execute(
select(SalesInvoiceRow).where(SalesInvoiceRow.invoice_id == invoice_id, SalesInvoiceRow.tenant_id == tenant_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, tenant_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")
_publish_sales_event(
session,
tenant_id=tenant_id,
event_type=sales_event_types.INVOICE_SENT,
aggregate_type="invoice",
aggregate_id=row.invoice_id,
actor_type="system",
actor_id=None,
payload={
"deal_id": deal.deal_id,
"invoice_id": row.invoice_id,
"invoice_number": row.invoice_number,
"amount": row.amount,
"currency": row.currency,
"due_date": row.due_date,
},
)
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,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesInvoiceOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
row = session.execute(
select(SalesInvoiceRow).where(SalesInvoiceRow.invoice_id == invoice_id, SalesInvoiceRow.tenant_id == tenant_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, tenant_id)
_apply_stage(session, deal, "payment_overdue", actor_type="system", actor_id=None, reason="invoice.overdue")
_publish_sales_event(
session,
tenant_id=tenant_id,
event_type=sales_event_types.INVOICE_OVERDUE,
aggregate_type="invoice",
aggregate_id=row.invoice_id,
actor_type="system",
actor_id=None,
payload={
"deal_id": deal.deal_id,
"invoice_id": row.invoice_id,
"invoice_number": row.invoice_number,
"amount": row.amount,
"currency": row.currency,
"due_date": row.due_date,
},
)
session.commit()
return _invoice_to_out(row)
finally:
session.close()
@app.post("/api/v1/payments/webhook", response_model=SalesPaymentOut)
def payment_webhook(
payload: SalesPaymentWebhookIn,
actor: dict = Depends(get_actor),
) -> SalesPaymentOut:
session = get_session()
try:
tenant_id = _resolve_provider_tenant_id(
session,
actor,
provider_type="payment",
provider_name=payload.payment_provider,
metadata=payload.metadata,
external_identifier=payload.external_payment_id,
)
deal = _get_deal(session, payload.deal_id, tenant_id)
invoice = None
if payload.invoice_id:
invoice = session.execute(
select(SalesInvoiceRow).where(SalesInvoiceRow.invoice_id == payload.invoice_id, SalesInvoiceRow.tenant_id == tenant_id)
).scalar_one_or_none()
if invoice is None:
raise HTTPException(status_code=404, detail="Invoice not found")
if invoice.deal_id != deal.deal_id:
raise HTTPException(status_code=400, detail="Invoice does not belong to deal")
now = utc_now_iso()
row = None
previous_payment_status = None
if payload.external_payment_id:
row = session.execute(
select(SalesPaymentRow).where(
SalesPaymentRow.tenant_id == tenant_id,
SalesPaymentRow.payment_provider == payload.payment_provider,
SalesPaymentRow.external_payment_id == payload.external_payment_id,
)
).scalar_one_or_none()
previous_payment_status = row.status if row is not None else None
previous_invoice_status = invoice.status if invoice is not None else None
previous_deal_status = deal.status
if row is None:
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)
else:
row.deal_id = deal.deal_id
row.invoice_id = payload.invoice_id
row.amount = payload.amount
row.currency = payload.currency
row.status = payload.status
row.paid_at = payload.paid_at or row.paid_at or (now if payload.status in {"success", "partial"} else None)
row.payment_method = payload.payment_method
row.failure_reason = payload.failure_reason
row.metadata_json = json.dumps(payload.metadata, ensure_ascii=False)
row.updated_at = now
should_publish_payment_received = payload.status in {"success", "partial"} and previous_payment_status != payload.status
if should_publish_payment_received:
_publish_sales_event(
session,
tenant_id=tenant_id,
event_type=sales_event_types.PAYMENT_RECEIVED,
aggregate_type="payment",
aggregate_id=row.payment_id,
actor_type="system",
actor_id=None,
payload={
"deal_id": deal.deal_id,
"invoice_id": row.invoice_id,
"payment_id": row.payment_id,
"payment_provider": row.payment_provider,
"external_payment_id": row.external_payment_id,
"amount": row.amount,
"currency": row.currency,
"status": row.status,
},
)
lead = _get_lead(session, deal.lead_id, tenant_id) if deal.lead_id else None
counterparty = session.execute(
select(SalesCounterpartyRow).where(SalesCounterpartyRow.deal_id == deal.deal_id, SalesCounterpartyRow.tenant_id == tenant_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)
if previous_invoice_status != "paid":
_publish_sales_event(
session,
tenant_id=tenant_id,
event_type=sales_event_types.INVOICE_PAID,
aggregate_type="invoice",
aggregate_id=invoice.invoice_id,
actor_type="system",
actor_id=None,
payload={
"deal_id": deal.deal_id,
"invoice_id": invoice.invoice_id,
"paid_amount": payload.amount,
"currency": payload.currency,
"paid_at": row.paid_at,
},
)
if previous_deal_status != "won":
_publish_sales_event(
session,
tenant_id=tenant_id,
event_type=sales_event_types.DEAL_WON,
aggregate_type="deal",
aggregate_id=deal.deal_id,
actor_type="system",
actor_id=None,
payload={
"deal_id": deal.deal_id,
"invoice_id": invoice.invoice_id,
"payment_id": row.payment_id,
"final_amount": deal.final_amount,
"currency": deal.currency,
"won_reason": "payment_received",
},
)
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,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> list[SalesPaymentOut]:
session = get_session()
try:
tenant_id = _tenant_id(actor)
_get_deal(session, deal_id, tenant_id)
rows = session.execute(
select(SalesPaymentRow)
.where(SalesPaymentRow.deal_id == deal_id, SalesPaymentRow.tenant_id == tenant_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,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesPaymentOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
row = session.execute(
select(SalesPaymentRow).where(SalesPaymentRow.payment_id == payment_id, SalesPaymentRow.tenant_id == tenant_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()