Files

7141 lines
274 KiB
Python

from __future__ import annotations
import json
import os
from datetime import datetime, timedelta, timezone
from typing import Any
import httpx
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request
from fastapi.responses import JSONResponse
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,
SalesCustomerOut,
SalesCustomerUpdate,
SalesDashboardStageOut,
SalesDashboardSummaryOut,
SalesDealCloseIn,
SalesDealCreate,
SalesDealNextActionIn,
SalesDealOut,
SalesDealScenarioIn,
SalesDealStageChangeIn,
SalesDealUpdate,
SalesDocumentCreate,
SalesDocumentOut,
SalesEscalationCreateIn,
SalesEscalationAssignIn,
SalesEscalationCancelIn,
SalesEscalationOut,
SalesEscalationResolveIn,
SalesInvoiceCreate,
SalesInvoiceOut,
SalesLeadCreate,
SalesLeadEnrichIn,
SalesLeadOut,
SalesLeadUpdate,
SalesMessageOut,
SalesMessageSendIn,
SalesMessageWebhookIn,
SalesNoteCreateIn,
SalesNoteOut,
SalesNoteUpdateIn,
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,
SalesNoteRow,
SalesOfferRow,
SalesPaymentRow,
SalesPaymentWebhookEventRow,
SalesPipelineRow,
SalesPipelineStageRow,
SalesStageHistoryRow,
SalesTranscriptRow,
TenantIntegrationRow,
TenantSalesSettingsRow,
)
from services.sales_service import sales_events as sales_event_types
from services.sales_service.deal_state_machine import DealStateMachineError, transition_deal_stage
from services.sales_service.event_publisher import SalesEventPublisher
from services.sales_service.payment_webhooks import (
PaymentWebhookVerificationError,
extract_event_type,
extract_external_event_id,
extract_external_payment_id,
extract_payment_provider,
extract_provider_account_id,
get_payment_provider_adapter,
header_value,
normalize_headers,
normalize_payment_event_type,
normalize_payment_status,
raw_payload_hash,
safe_json_loads,
verify_payment_webhook_signature,
)
# 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")
@app.exception_handler(DealStateMachineError)
async def _deal_state_machine_error_handler(_request, exc: DealStateMachineError) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content=exc.response())
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},
]
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 _integer_env(name: str, default: int) -> int:
raw = os.getenv(name)
if raw is None:
return default
try:
return int(raw.strip())
except ValueError:
return default
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 _telegram_autostage_message_interval() -> int:
return max(1, _integer_env("SALES_TELEGRAM_STAGE_EVERY_MESSAGES", 3))
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, deal: SalesDealRow | None = None) -> SalesLeadOut:
return SalesLeadOut(
lead_id=row.lead_id,
tenant_id=row.tenant_id,
deal_id=deal.deal_id if deal is not None else None,
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 _customer_to_out(row: Customer, deal_count: int = 0) -> SalesCustomerOut:
return SalesCustomerOut(
customer_id=row.customer_id,
display_name=row.display_name,
phones=_json_list(row.phones_json),
preferred_phone=row.preferred_phone,
tags=_json_list(row.tags_json),
deal_count=deal_count,
created_at=row.created_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:
metadata = _json_dict(row.metadata_json)
channel_provider = row.channel_provider or metadata.get("channel_provider")
if not channel_provider:
channel_provider = "voice" if row.channel_type == "voice" else None
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]
channel_provider=channel_provider,
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=metadata,
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,
assigned_at=row.assigned_at,
started_at=row.started_at,
created_at=row.created_at,
resolved_at=row.resolved_at,
canceled_at=row.canceled_at,
resolution_code=row.resolution_code,
resolution_summary=row.resolution_summary,
sla_due_at=row.sla_due_at,
source_channel=row.source_channel,
source_communication_session_id=row.source_communication_session_id,
source_automation_task_id=row.source_automation_task_id,
updated_at=row.updated_at,
)
def _task_to_out(row: SalesAutomationTaskRow, deal: SalesDealRow | None = None) -> SalesAutomationTaskOut:
payload = _json_dict(row.payload_json)
return SalesAutomationTaskOut(
task_id=row.task_id,
deal_id=row.deal_id,
deal_title=deal.title if deal is not None else None,
deal_status=deal.status if deal is not None else None, # type: ignore[arg-type]
deal_stage_id=deal.stage_id if deal is not None else None,
deal_pipeline_id=deal.pipeline_id if deal is not None else None,
task_type=row.task_type,
payload=payload,
run_at=row.run_at,
status=row.status, # type: ignore[arg-type]
retry_count=row.retry_count,
max_retries=row.max_retries,
locked_at=row.locked_at,
locked_by=row.locked_by,
completed_at=row.completed_at,
failed_at=row.failed_at,
last_error=row.last_error,
recommended_to_channel=payload.get("recommended_to_channel") or payload.get("to_channel"),
reason_code=payload.get("reason_code") or payload.get("reason"),
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,
communication_session_id=row.communication_session_id,
previous_communication_session_id=row.previous_communication_session_id,
new_communication_session_id=row.new_communication_session_id,
from_channel=row.from_channel,
to_channel=row.to_channel,
reason_code=row.reason_code,
reason_text=row.reason_text,
reason_for_channel_switch=row.reason_for_channel_switch,
initiated_by_type=row.initiated_by_type,
initiated_by_id=row.initiated_by_id,
source_type=row.source_type,
source_id=row.source_id,
recommended_by_task_id=row.recommended_by_task_id,
switched_at=row.switched_at,
created_at=row.created_at,
)
def _channel_switch_to_out_with_session(
row: SalesChannelSwitchRow,
new_communication: SalesCommunicationSessionRow | None = None,
) -> SalesChannelSwitchOut:
out = _channel_switch_to_out(row)
if new_communication is not None:
out.new_communication = _communication_to_out(new_communication)
return out
def _note_to_out(row: SalesNoteRow) -> SalesNoteOut:
return SalesNoteOut(
note_id=row.note_id,
deal_id=row.deal_id,
author_type=row.author_type,
author_id=row.author_id,
note_type=row.note_type,
content=row.content,
source_type=row.source_type,
source_id=row.source_id,
created_at=row.created_at,
updated_at=row.updated_at,
archived_at=row.archived_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 _latest_deal_for_lead(session, lead_id: str, tenant_id: str) -> SalesDealRow | None:
return session.execute(
select(SalesDealRow)
.where(SalesDealRow.lead_id == lead_id, SalesDealRow.tenant_id == tenant_id)
.order_by(SalesDealRow.id.desc())
).scalars().first()
def _latest_deals_by_lead(session, lead_ids: list[str], tenant_id: str) -> dict[str, SalesDealRow]:
if not lead_ids:
return {}
rows = session.execute(
select(SalesDealRow)
.where(SalesDealRow.lead_id.in_(lead_ids), SalesDealRow.tenant_id == tenant_id)
.order_by(SalesDealRow.id.desc())
).scalars().all()
latest: dict[str, SalesDealRow] = {}
for row in rows:
if row.lead_id and row.lead_id not in latest:
latest[row.lead_id] = row
return latest
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 _sales_customer_ids_for_tenant(session, tenant_id: str) -> set[str]:
deal_ids = session.execute(
select(SalesDealRow.customer_id).where(
SalesDealRow.tenant_id == tenant_id,
SalesDealRow.customer_id.is_not(None),
)
).scalars().all()
lead_ids = session.execute(
select(SalesLeadRow.crm_customer_id).where(
SalesLeadRow.tenant_id == tenant_id,
SalesLeadRow.crm_customer_id.is_not(None),
)
).scalars().all()
return {str(customer_id) for customer_id in [*deal_ids, *lead_ids] if customer_id}
def _get_sales_customer(session, customer_id: str, tenant_id: str) -> Customer:
normalized = str(customer_id or "").strip()
if not normalized or normalized not in _sales_customer_ids_for_tenant(session, tenant_id):
raise HTTPException(status_code=404, detail="Customer not found")
row = _find_customer_by_customer_id(session, normalized)
if row is None:
raise HTTPException(status_code=404, detail="Customer not found")
return row
def _customer_deal_count(session, customer_id: str, tenant_id: str) -> int:
return int(
session.execute(
select(func.count()).select_from(SalesDealRow).where(
SalesDealRow.tenant_id == tenant_id,
SalesDealRow.customer_id == customer_id,
)
).scalar_one()
or 0
)
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,
max_retries=3,
locked_at=None,
locked_by=None,
completed_at=None,
failed_at=None,
last_error=None,
created_at=now,
updated_at=now,
)
session.add(task)
return task
def _automation_run_at_after(*, minutes: int = 0, days: int = 0) -> str:
return (_now() + timedelta(days=days, minutes=minutes)).isoformat()
def _parse_due_date(value: str | None) -> datetime | None:
raw = str(value or "").strip()
if not raw:
return None
try:
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc).replace(microsecond=0)
def _invoice_reminder_run_at(due_date: str | None) -> str:
parsed = _parse_due_date(due_date)
if parsed is None:
return _automation_run_at_after(days=1)
return _iso_at_or_now(parsed - timedelta(days=1))
def _invoice_overdue_run_at(due_date: str | None) -> str:
parsed = _parse_due_date(due_date)
if parsed is None:
return _automation_run_at_after(days=1)
return _iso_at_or_now(parsed + timedelta(days=1))
def _iso_at_or_now(value: datetime) -> str:
now = _now()
return (value if value > now else now).isoformat()
def _schedule_unique_task(
session,
*,
deal: SalesDealRow,
task_type: str,
run_at: str | None,
payload: dict | None = None,
match_payload: dict | None = None,
) -> SalesAutomationTaskRow:
payload_data = payload or {}
match = match_payload or payload_data
rows = session.execute(
select(SalesAutomationTaskRow)
.where(
SalesAutomationTaskRow.tenant_id == deal.tenant_id,
SalesAutomationTaskRow.deal_id == deal.deal_id,
SalesAutomationTaskRow.task_type == task_type,
SalesAutomationTaskRow.status.in_(["pending", "running"]),
)
.order_by(SalesAutomationTaskRow.id.desc())
).scalars().all()
for row in rows:
existing_payload = _json_dict(row.payload_json)
if all(existing_payload.get(key) == value for key, value in match.items()):
return row
return _schedule_task(session, deal=deal, task_type=task_type, run_at=run_at, payload=payload_data)
def _cancel_pending_automation_tasks(
session,
*,
deal: SalesDealRow,
task_types: set[str],
reason: str,
) -> None:
now = utc_now_iso()
rows = session.execute(
select(SalesAutomationTaskRow).where(
SalesAutomationTaskRow.tenant_id == deal.tenant_id,
SalesAutomationTaskRow.deal_id == deal.deal_id,
SalesAutomationTaskRow.task_type.in_(task_types),
SalesAutomationTaskRow.status == "pending",
)
).scalars().all()
for row in rows:
payload = _json_dict(row.payload_json)
payload["canceled_reason"] = reason
row.payload_json = json.dumps(payload, ensure_ascii=False)
row.status = "canceled"
row.updated_at = now
def apply_stage_by_id(
session,
deal: SalesDealRow,
stage_id: str,
*,
actor_type: str,
actor_id: str | None,
reason: str | None = None,
metadata: dict | None = None,
force: bool = False,
) -> SalesPipelineStageRow:
stage = _get_stage(session, stage_id, deal.tenant_id, deal.pipeline_id)
return transition_deal_stage(
session,
tenant_id=deal.tenant_id,
deal_id=deal.deal_id,
target_stage_code=stage.code,
actor_type=actor_type,
actor_id=actor_id,
reason=reason,
metadata=metadata,
force=force,
)
def apply_stage_by_code(
session,
deal: SalesDealRow,
stage_code: str,
*,
actor_type: str,
actor_id: str | None,
reason: str | None = None,
metadata: dict | None = None,
force: bool = False,
) -> SalesPipelineStageRow:
ensure_default_pipeline(session, deal.tenant_id)
resolve_stage_by_code_or_id(
session,
tenant_id=deal.tenant_id,
pipeline_id=deal.pipeline_id,
stage_id=stage_code,
)
return transition_deal_stage(
session,
tenant_id=deal.tenant_id,
deal_id=deal.deal_id,
target_stage_code=stage_code,
actor_type=actor_type,
actor_id=actor_id,
reason=reason,
metadata=metadata,
force=force,
)
def _apply_stage(
session,
deal: SalesDealRow,
stage_code: str,
*,
actor_type: str,
actor_id: str | None,
reason: str | None = None,
metadata: dict | None = None,
force: bool = False,
) -> None:
apply_stage_by_code(
session,
deal,
stage_code,
actor_type=actor_type,
actor_id=actor_id,
reason=reason,
metadata=metadata,
force=force,
)
def _apply_stage_if_needed(
session,
deal: SalesDealRow,
stage_code: str,
*,
actor_type: str,
actor_id: str | None,
reason: str | None = None,
metadata: dict | None = None,
force: bool = False,
) -> None:
_, current_stage = _deal_pipeline_stage(session, deal)
if current_stage is not None and current_stage.code == stage_code:
deal.updated_at = utc_now_iso()
return
_apply_stage(
session,
deal,
stage_code,
actor_type=actor_type,
actor_id=actor_id,
reason=reason,
metadata=metadata,
force=force,
)
def _is_customer_inbound_telegram_message(payload: SalesTelegramSyncIn) -> bool:
direction = str(payload.direction or "").strip().lower()
author_type = str(payload.author_type or "").strip().lower()
return direction == "inbound" and author_type in {"", "customer"}
def _next_autostage_pipeline_stage(session, deal: SalesDealRow) -> SalesPipelineStageRow | None:
_, current_stage = _deal_pipeline_stage(session, deal)
if current_stage is None or current_stage.is_terminal:
return None
return session.execute(
select(SalesPipelineStageRow)
.where(
SalesPipelineStageRow.tenant_id == deal.tenant_id,
SalesPipelineStageRow.pipeline_id == deal.pipeline_id,
SalesPipelineStageRow.is_active == True, # noqa: E712
SalesPipelineStageRow.is_terminal == False, # noqa: E712
SalesPipelineStageRow.sort_order > current_stage.sort_order,
)
.order_by(SalesPipelineStageRow.sort_order.asc(), SalesPipelineStageRow.id.asc())
.limit(1)
).scalar_one_or_none()
def _telegram_customer_message_count(session, deal: SalesDealRow) -> int:
return int(
session.execute(
select(func.count())
.select_from(SalesMessageRow)
.where(
SalesMessageRow.tenant_id == deal.tenant_id,
SalesMessageRow.deal_id == deal.deal_id,
SalesMessageRow.channel_provider == "telegram",
SalesMessageRow.sender_type == "customer",
SalesMessageRow.delivery_status == "received",
)
).scalar_one()
or 0
)
def _maybe_advance_telegram_deal_stage(
session,
*,
deal: SalesDealRow,
lead: SalesLeadRow | None,
payload: SalesTelegramSyncIn,
) -> None:
if not _is_customer_inbound_telegram_message(payload):
return
interval = _telegram_autostage_message_interval()
message_count = _telegram_customer_message_count(session, deal)
if message_count == 0 or message_count % interval != 0:
return
next_stage = _next_autostage_pipeline_stage(session, deal)
if next_stage is None:
return
_apply_stage(
session,
deal,
next_stage.code,
actor_type="system",
actor_id="telegram-autostage",
reason="telegram.autostage_every_3_messages",
metadata={
"telegram_message_count": message_count,
"telegram_thread_id": payload.thread_id,
"telegram_chat_id": payload.chat_id,
"interval": interval,
"mode": "deterministic",
},
force=True,
)
if lead is not None:
if next_stage.code in {"warm_lead", "hot_lead", "enrichment_required"}:
lead.status = next_stage.code
if next_stage.code == "hot_lead":
lead.lead_temperature = "hot"
elif next_stage.code == "warm_lead":
lead.lead_temperature = "warm"
lead.updated_at = utc_now_iso()
def _resolve_stage_change_target_code(session, deal: SalesDealRow, payload: SalesDealStageChangeIn) -> str:
target_stage_code = str(payload.target_stage_code or payload.stage_code or "").strip() or None
target_stage_id = str(payload.target_stage_id or payload.stage_id or "").strip() or None
if not target_stage_code and not target_stage_id:
raise DealStateMachineError(
status_code=400,
error="stage_required",
message="target_stage_code or target_stage_id is required",
details={"deal_id": deal.deal_id},
)
if not target_stage_id:
return target_stage_code or ""
stage = session.execute(
select(SalesPipelineStageRow).where(SalesPipelineStageRow.stage_id == target_stage_id)
).scalar_one_or_none()
if stage is None:
# Backward compatibility: old clients used stage_id for stable stage codes.
return target_stage_code or target_stage_id
if stage.tenant_id != deal.tenant_id:
raise DealStateMachineError(
status_code=403,
error="cross_tenant_stage_forbidden",
message="Stage belongs to another tenant",
details={"stage_id": stage.stage_id, "deal_id": deal.deal_id},
)
if stage.pipeline_id != deal.pipeline_id:
raise DealStateMachineError(
status_code=400,
error="invalid_stage_pipeline",
message="Stage does not belong to deal pipeline",
details={
"stage_id": stage.stage_id,
"stage_pipeline_id": stage.pipeline_id,
"deal_pipeline_id": deal.pipeline_id,
},
)
if target_stage_code and _normalize_stage_code(target_stage_code) != stage.code:
raise DealStateMachineError(
status_code=400,
error="stage_target_mismatch",
message="target_stage_id and target_stage_code point to different stages",
details={"stage_id": stage.stage_id, "stage_code": stage.code, "target_stage_code": target_stage_code},
)
return stage.code
def _get_deal_for_state_change(session, deal_id: str, tenant_id: str) -> SalesDealRow:
try:
return _get_deal(session, deal_id, tenant_id)
except HTTPException as exc:
if exc.status_code == 404:
raise DealStateMachineError(
status_code=404,
error="deal_not_found",
message="Deal not found",
details={"deal_id": deal_id},
) from exc
raise
def _apply_payment_stage(session, deal: SalesDealRow, stage_code: str, *, reason: str, metadata: dict) -> None:
_, current_stage = _deal_pipeline_stage(session, deal)
if current_stage is not None and current_stage.code == stage_code:
return
try:
_apply_stage(
session,
deal,
stage_code,
actor_type="system",
actor_id=None,
reason=reason,
metadata=metadata,
)
except DealStateMachineError as exc:
if exc.error != "invalid_stage_transition":
raise
_apply_stage(
session,
deal,
stage_code,
actor_type="system",
actor_id=None,
reason=reason,
metadata={**metadata, "force_reason": "verified_payment_webhook"},
force=True,
)
def _payment_to_dict(row: SalesPaymentRow) -> dict[str, Any]:
payload = _payment_to_out(row)
if hasattr(payload, "model_dump"):
return payload.model_dump()
return payload.dict()
def _integration_settings(integration: TenantIntegrationRow | None) -> dict:
return _json_dict(integration.settings_json if integration is not None else "{}")
def _payment_webhook_secret(integration: TenantIntegrationRow | None) -> str | None:
settings = _integration_settings(integration)
for key in ("webhook_secret", "payment_webhook_secret", "secret"):
value = str(settings.get(key) or "").strip()
if value:
return value
return None
def _payment_replay_window_seconds(integration: TenantIntegrationRow | None) -> int:
settings = _integration_settings(integration)
try:
value = int(settings.get("webhook_replay_window_seconds") or settings.get("replay_window_seconds") or 600)
except (TypeError, ValueError):
value = 600
return max(value, 60)
def _payment_allows_overpayment(integration: TenantIntegrationRow | None, metadata: dict | None) -> bool:
settings = _integration_settings(integration)
raw = (metadata or {}).get("allow_overpayment", settings.get("allow_overpayment", False))
if isinstance(raw, bool):
return raw
return str(raw or "").strip().lower() in {"1", "true", "yes", "on"}
def _resolve_payment_integration(
session,
actor: dict,
*,
provider: str,
provider_account_id: str | None,
external_identifier: str | None = None,
) -> tuple[str, TenantIntegrationRow | None]:
normalized_provider = str(provider or "").strip().lower() or "manual"
normalized_account = str(provider_account_id or "").strip()
normalized_external = str(external_identifier or "").strip()
base_stmt = select(TenantIntegrationRow).where(
func.lower(TenantIntegrationRow.provider_type) == "payment",
func.lower(TenantIntegrationRow.provider_name) == normalized_provider,
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, row
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, row
actor_tenant_id = _optional_tenant_id(actor)
if normalized_provider == "manual" and actor_tenant_id:
return actor_tenant_id, None
raise HTTPException(status_code=403, detail="Payment provider integration not found")
def _payment_webhook_event_duplicate_response(session, event: SalesPaymentWebhookEventRow) -> dict[str, Any]:
payment = None
if event.external_payment_id:
payment = session.execute(
select(SalesPaymentRow).where(
SalesPaymentRow.tenant_id == event.tenant_id,
SalesPaymentRow.payment_provider == event.payment_provider,
SalesPaymentRow.external_payment_id == event.external_payment_id,
)
).scalar_one_or_none()
response = {
"status": "duplicate",
"event_status": event.event_status,
"payment_id": payment.payment_id if payment is not None else None,
"invoice_id": payment.invoice_id if payment is not None else None,
"external_event_id": event.external_event_id,
"external_payment_id": event.external_payment_id,
}
if event.normalized_payload_json:
normalized = _json_dict(event.normalized_payload_json)
result = normalized.get("result") if isinstance(normalized.get("result"), dict) else {}
response["payment_id"] = response["payment_id"] or result.get("payment_id")
response["invoice_id"] = response["invoice_id"] or result.get("invoice_id")
return response
def _find_existing_webhook_event(
session,
*,
tenant_id: str,
provider: str,
external_event_id: str | None,
) -> SalesPaymentWebhookEventRow | None:
if not external_event_id:
return None
return session.execute(
select(SalesPaymentWebhookEventRow).where(
SalesPaymentWebhookEventRow.tenant_id == tenant_id,
SalesPaymentWebhookEventRow.payment_provider == provider,
SalesPaymentWebhookEventRow.external_event_id == external_event_id,
)
).scalar_one_or_none()
def _mark_payment_webhook_event_failed(event: SalesPaymentWebhookEventRow, detail: Any) -> None:
event.event_status = "failed"
event.error_message = str(detail)
event.updated_at = utc_now_iso()
def _payment_metadata(payload: dict) -> dict:
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
return dict(metadata)
def _payload_value(payload: dict, *keys: str) -> Any:
sources: list[dict] = [payload]
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
sources.append(metadata)
payment = payload.get("payment") if isinstance(payload.get("payment"), dict) else {}
sources.append(payment)
data_object = payload.get("data", {}).get("object") if isinstance(payload.get("data"), dict) else {}
if isinstance(data_object, dict):
sources.append(data_object)
for source in sources:
for key in keys:
value = source.get(key)
if value is not None and str(value).strip() != "":
return value
return None
def _normalized_payment_payload(payload: dict, headers: dict[str, str]) -> dict[str, Any]:
provider = extract_payment_provider(payload, headers)
provider_status = _payload_value(payload, "provider_status", "status", "payment_status")
event_type = extract_event_type(payload, headers)
normalized_status = normalize_payment_status(provider, str(provider_status or ""))
normalized_event_type = normalize_payment_event_type(provider, str(provider_status or ""), event_type)
return {
"deal_id": _payload_value(payload, "deal_id"),
"invoice_id": _payload_value(payload, "invoice_id"),
"payment_provider": provider,
"provider_account_id": extract_provider_account_id(payload, headers),
"external_event_id": extract_external_event_id(payload, headers),
"external_payment_id": extract_external_payment_id(payload, headers),
"event_type": normalized_event_type,
"provider_event_type": event_type,
"provider_status": provider_status,
"status": normalized_status,
"amount": _payload_value(payload, "amount", "paid_amount"),
"currency": str(_payload_value(payload, "currency") or "KZT").upper(),
"paid_at": _payload_value(payload, "paid_at", "captured_at", "succeeded_at"),
"payment_method": _payload_value(payload, "payment_method", "method"),
"failure_reason": _payload_value(payload, "failure_reason", "error_message", "decline_reason"),
"metadata": _payment_metadata(payload),
}
def _coerce_payment_amount(value: Any) -> float:
try:
return float(value)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Invalid payment amount") from exc
def _paid_sum_for_invoice(session, *, tenant_id: str, invoice_id: str) -> float:
value = session.execute(
select(func.coalesce(func.sum(SalesPaymentRow.amount), 0.0)).where(
SalesPaymentRow.tenant_id == tenant_id,
SalesPaymentRow.invoice_id == invoice_id,
SalesPaymentRow.status.in_(["success", "partial"]),
)
).scalar_one()
return float(value or 0.0)
def _cancel_paid_invoice_tasks(session, *, deal: SalesDealRow, invoice_id: str) -> None:
now = utc_now_iso()
rows = session.execute(
select(SalesAutomationTaskRow).where(
SalesAutomationTaskRow.tenant_id == deal.tenant_id,
SalesAutomationTaskRow.deal_id == deal.deal_id,
SalesAutomationTaskRow.task_type.in_({"send_invoice_reminder", "mark_invoice_overdue", "follow_up_customer"}),
SalesAutomationTaskRow.status == "pending",
)
).scalars().all()
for row in rows:
payload = _json_dict(row.payload_json)
if row.task_type != "follow_up_customer" or payload.get("invoice_id") == invoice_id or payload.get("reason") == "invoice_payment":
payload["canceled_reason"] = "invoice_paid"
row.payload_json = json.dumps(payload, ensure_ascii=False)
row.status = "canceled"
row.updated_at = now
def _publish_payment_received(session, *, tenant_id: str, deal: SalesDealRow, payment: SalesPaymentRow) -> None:
_publish_sales_event(
session,
tenant_id=tenant_id,
event_type=sales_event_types.PAYMENT_RECEIVED,
aggregate_type="payment",
aggregate_id=payment.payment_id,
actor_type="system",
actor_id=None,
payload={
"deal_id": deal.deal_id,
"invoice_id": payment.invoice_id,
"payment_id": payment.payment_id,
"payment_provider": payment.payment_provider,
"external_payment_id": payment.external_payment_id,
"amount": payment.amount,
"currency": payment.currency,
"status": payment.status,
},
)
def _apply_payment_update(
session,
*,
tenant_id: str,
deal: SalesDealRow,
invoice: SalesInvoiceRow | None,
payment_provider: str,
external_payment_id: str | None,
amount: Any,
currency: str,
status: str,
paid_at: str | None,
payment_method: str | None,
failure_reason: str | None,
metadata: dict,
integration: TenantIntegrationRow | None = None,
existing_payment: SalesPaymentRow | None = None,
) -> SalesPaymentRow:
now = utc_now_iso()
normalized_status = normalize_payment_status(payment_provider, status)
if amount is None and normalized_status not in {"success", "partial"}:
amount_value = 0.0
else:
amount_value = _coerce_payment_amount(amount)
normalized_currency = str(currency or "KZT").strip().upper()
if normalized_status in {"success", "partial"}:
if amount_value <= 0:
raise HTTPException(status_code=400, detail="Payment amount must be positive")
if deal.status == "lost":
raise HTTPException(status_code=400, detail="Cannot apply payment to lost deal")
if invoice is not None:
if invoice.status == "canceled":
raise HTTPException(status_code=400, detail="Cannot apply payment to canceled invoice")
if normalized_currency != str(invoice.currency or "").upper():
raise HTTPException(status_code=400, detail="Payment currency does not match invoice currency")
if amount_value > float(invoice.amount or 0) and not _payment_allows_overpayment(integration, metadata):
raise HTTPException(status_code=400, detail="Payment amount exceeds invoice amount")
row = existing_payment
previous_payment_status = row.status if row is not None else None
if row is None and external_payment_id:
row = session.execute(
select(SalesPaymentRow).where(
SalesPaymentRow.tenant_id == tenant_id,
SalesPaymentRow.payment_provider == payment_provider,
SalesPaymentRow.external_payment_id == external_payment_id,
)
).scalar_one_or_none()
previous_payment_status = row.status if row is not None else None
if row is None:
row = SalesPaymentRow(
payment_id=new_id("pay"),
tenant_id=tenant_id,
deal_id=deal.deal_id,
invoice_id=invoice.invoice_id if invoice is not None else None,
payment_provider=payment_provider,
external_payment_id=external_payment_id,
amount=amount_value,
currency=normalized_currency,
status=normalized_status,
paid_at=paid_at or (now if normalized_status in {"success", "partial"} else None),
payment_method=payment_method,
failure_reason=failure_reason,
metadata_json=json.dumps(metadata, ensure_ascii=False),
created_at=now,
updated_at=now,
)
session.add(row)
session.flush()
else:
row.deal_id = deal.deal_id
row.invoice_id = invoice.invoice_id if invoice is not None else row.invoice_id
row.amount = amount_value
row.currency = normalized_currency
row.status = normalized_status
row.paid_at = paid_at or row.paid_at or (now if normalized_status in {"success", "partial"} else None)
row.payment_method = payment_method
row.failure_reason = failure_reason
row.metadata_json = json.dumps(metadata, ensure_ascii=False)
row.updated_at = now
session.flush()
if normalized_status in {"success", "partial"} and previous_payment_status != normalized_status:
_publish_payment_received(session, tenant_id=tenant_id, deal=deal, payment=row)
if invoice is None:
return row
previous_invoice_status = invoice.status
previous_deal_status = deal.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 normalized_status in {"success", "partial"}:
paid_sum = _paid_sum_for_invoice(session, tenant_id=tenant_id, invoice_id=invoice.invoice_id)
if paid_sum <= 0:
pass
elif paid_sum < float(invoice.amount or 0):
invoice.status = "partially_paid"
invoice.updated_at = now
_apply_payment_stage(
session,
deal,
"partially_paid",
reason="payment.partial",
metadata={
"invoice_id": invoice.invoice_id,
"payment_id": row.payment_id,
"external_payment_id": row.external_payment_id,
"payment_provider": row.payment_provider,
"paid_sum": paid_sum,
},
)
else:
invoice.status = "paid"
invoice.paid_at = row.paid_at or now
invoice.updated_at = now
deal.final_amount = paid_sum
payment_metadata = {
"invoice_id": invoice.invoice_id,
"payment_id": row.payment_id,
"external_payment_id": row.external_payment_id,
"payment_provider": row.payment_provider,
"paid_sum": paid_sum,
}
if previous_deal_status != "won":
_apply_payment_stage(session, deal, "paid", reason="payment.received", metadata=payment_metadata)
_apply_payment_stage(session, deal, "won", reason="payment.received", metadata=payment_metadata)
_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": paid_sum,
"currency": row.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",
},
)
_cancel_paid_invoice_tasks(session, deal=deal, invoice_id=invoice.invoice_id)
elif normalized_status in {"failed", "canceled"}:
invoice.updated_at = now
return row
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,
skip_stage_transition: bool = False,
) -> SalesCommunicationSessionRow:
now = utc_now_iso()
metadata_payload = dict(metadata or {})
skip_stage_transition = skip_stage_transition or bool(metadata_payload.pop("skip_stage_transition", False))
channel_provider = str(metadata_payload.get("channel_provider") or "").strip()
if not channel_provider:
channel_provider = "voice" if payload.channel_type == "voice" else str(deal.current_channel if deal.current_channel != "voice" else (lead.preferred_channel if lead else "telegram") or "telegram")
metadata_payload["channel_provider"] = channel_provider
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,
channel_provider=channel_provider,
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_payload, 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,
},
)
if not skip_stage_transition:
_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()
channel_provider = str(metadata.get("channel_provider") or "").strip()
if channel_provider and communication.channel_provider != channel_provider:
communication.channel_provider = channel_provider
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:
metadata_payload = dict(metadata or {})
skip_stage_transition = bool(metadata_payload.pop("skip_stage_transition", False))
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_payload)
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_payload)
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_payload,
skip_stage_transition=skip_stage_transition,
)
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 _channel_type_for_value(value: str | None) -> str:
return "voice" if str(value or "").strip().lower() == "voice" else "text"
def _stage_code_for_deal(session, deal: SalesDealRow) -> str | None:
_, stage = _deal_pipeline_stage(session, deal)
return stage.code if stage is not None else None
def _maybe_transition_deal_for_channel_switch(
session,
*,
deal: SalesDealRow,
to_channel: str,
actor_type: str,
actor_id: str | None,
reason: str,
metadata: dict,
) -> None:
current_stage_code = _stage_code_for_deal(session, deal)
if current_stage_code not in {"active_text_communication", "active_voice_communication"}:
return
target_stage_code = "active_voice_communication" if to_channel == "voice" else "active_text_communication"
if current_stage_code == target_stage_code:
return
transition_deal_stage(
session,
tenant_id=deal.tenant_id,
deal_id=deal.deal_id,
target_stage_code=target_stage_code,
actor_type=actor_type,
actor_id=actor_id,
reason=reason,
metadata=metadata,
)
def _create_switch_communication_session(
session,
*,
deal: SalesDealRow,
lead: SalesLeadRow | None,
to_channel: str,
reason: str,
actor_user: str | None,
metadata: dict,
) -> SalesCommunicationSessionRow:
now = utc_now_iso()
channel_provider = str(metadata.get("channel_provider") or ("voice" if to_channel == "voice" else "text")).strip()
payload = dict(metadata)
payload["channel_provider"] = channel_provider
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=to_channel,
channel_provider=channel_provider,
direction="outbound",
agent_type="human",
started_at=now,
ended_at=None,
duration_sec=None,
subject=reason,
status="active",
summary=None,
transcript_id=None,
next_action_type=None,
next_action_at=None,
sentiment=None,
result_code=None,
metadata_json=json.dumps(payload, ensure_ascii=False),
created_at=now,
updated_at=now,
)
session.add(communication)
_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="human",
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,
"channel_provider": communication.channel_provider,
"direction": communication.direction,
"agent_type": communication.agent_type,
"subject": communication.subject,
},
)
return communication
def _complete_channel_switch_recommendation_task(
session,
*,
tenant_id: str,
deal_id: str,
task_id: str | None,
switch_id: str,
) -> None:
normalized = str(task_id or "").strip()
if not normalized:
return
task = session.execute(
select(SalesAutomationTaskRow).where(
SalesAutomationTaskRow.tenant_id == tenant_id,
SalesAutomationTaskRow.deal_id == deal_id,
SalesAutomationTaskRow.task_id == normalized,
SalesAutomationTaskRow.task_type == "recommend_channel_switch",
)
).scalar_one_or_none()
if task is None:
return
payload = _json_dict(task.payload_json)
payload["actual_switch_id"] = switch_id
task.payload_json = json.dumps(payload, ensure_ascii=False)
if task.status in {"pending", "running"}:
task.status = "completed"
task.completed_at = utc_now_iso()
task.updated_at = utc_now_iso()
def _perform_channel_switch(
session,
*,
deal: SalesDealRow,
payload: SalesCommunicationSwitchChannelIn,
actor_type: str,
actor_id: str | None,
communication: SalesCommunicationSessionRow | None = None,
) -> tuple[SalesChannelSwitchRow, SalesCommunicationSessionRow | None]:
if payload.to_channel not in {"text", "voice"}:
raise HTTPException(status_code=400, detail="Invalid target channel")
if payload.recommended_by_task_id:
existing = session.execute(
select(SalesChannelSwitchRow)
.where(
SalesChannelSwitchRow.tenant_id == deal.tenant_id,
SalesChannelSwitchRow.deal_id == deal.deal_id,
SalesChannelSwitchRow.recommended_by_task_id == payload.recommended_by_task_id,
)
.order_by(SalesChannelSwitchRow.id.desc())
).scalars().first()
if existing is not None:
return existing, None
now = utc_now_iso()
from_channel = _channel_type_for_value(communication.channel_type if communication is not None else deal.current_channel)
reason_text = payload.reason_text or payload.reason_for_channel_switch or payload.reason_code
reason_code = str(payload.reason_code or "human_decision").strip() or "human_decision"
metadata = dict(payload.metadata or {})
metadata.update(
{
"reason_code": reason_code,
"scheduled_at": payload.scheduled_at,
"source_type": payload.source_type,
"source_id": payload.source_id,
"recommended_by_task_id": payload.recommended_by_task_id,
}
)
new_communication = None
if payload.create_session:
lead = _get_lead(session, deal.lead_id, deal.tenant_id) if deal.lead_id else None
new_communication = _create_switch_communication_session(
session,
deal=deal,
lead=lead,
to_channel=payload.to_channel,
reason=reason_text,
actor_user=actor_id,
metadata=metadata,
)
row = SalesChannelSwitchRow(
switch_id=new_id("swc"),
tenant_id=deal.tenant_id,
deal_id=deal.deal_id,
communication_id=communication.communication_id if communication is not None else None,
communication_session_id=communication.communication_id if communication is not None else None,
previous_communication_session_id=communication.communication_id if communication is not None else None,
new_communication_session_id=new_communication.communication_id if new_communication is not None else None,
from_channel=from_channel,
to_channel=payload.to_channel,
reason_code=reason_code,
reason_text=payload.reason_text,
reason_for_channel_switch=reason_text,
initiated_by_type=actor_type,
initiated_by_id=actor_id,
source_type=payload.source_type,
source_id=payload.source_id,
recommended_by_task_id=payload.recommended_by_task_id,
switched_at=now,
created_at=now,
)
session.add(row)
deal.current_channel = payload.to_channel
deal.updated_at = now
_maybe_transition_deal_for_channel_switch(
session,
deal=deal,
to_channel=payload.to_channel,
actor_type=actor_type,
actor_id=actor_id,
reason=reason_text,
metadata={"switch_id": row.switch_id, **metadata},
)
_complete_channel_switch_recommendation_task(
session,
tenant_id=deal.tenant_id,
deal_id=deal.deal_id,
task_id=payload.recommended_by_task_id,
switch_id=row.switch_id,
)
_publish_sales_event(
session,
tenant_id=deal.tenant_id,
event_type=sales_event_types.COMMUNICATION_CHANNEL_SWITCHED,
aggregate_type="deal",
aggregate_id=deal.deal_id,
actor_type=actor_type,
actor_id=actor_id,
payload={
"deal_id": deal.deal_id,
"switch_id": row.switch_id,
"from_channel": row.from_channel,
"to_channel": row.to_channel,
"reason_code": row.reason_code,
"reason_text": row.reason_text,
"communication_session_id": row.communication_session_id,
"new_communication_session_id": row.new_communication_session_id,
"recommended_by_task_id": row.recommended_by_task_id,
},
)
return row, new_communication
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()
call_ids = [row.call_id for row in calls]
transcripts = (
session.execute(
select(SalesTranscriptRow)
.where(SalesTranscriptRow.tenant_id == deal.tenant_id, SalesTranscriptRow.call_id.in_(call_ids))
.order_by(SalesTranscriptRow.updated_at.desc(), SalesTranscriptRow.id.desc())
).scalars().all()
if call_ids
else []
)
notes = session.execute(
select(SalesNoteRow)
.where(
SalesNoteRow.deal_id == deal.deal_id,
SalesNoteRow.tenant_id == deal.tenant_id,
SalesNoteRow.archived_at.is_(None),
)
.order_by(SalesNoteRow.created_at.desc(), SalesNoteRow.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()
recommended_channel_switch = None
for task in tasks:
if task.task_type != "recommend_channel_switch":
continue
payload = _json_dict(task.payload_json)
result = payload.get("automation_result") if isinstance(payload.get("automation_result"), dict) else None
recommended_channel_switch = {
"task_id": task.task_id,
"status": task.status,
**(result or payload),
}
break
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},
)
)
for row in notes:
timeline.append(
SalesTimelineEventOut(
ts=row.updated_at or row.created_at,
kind="note",
title=f"Заметка: {row.note_type}",
body=row.content,
meta={"note_id": row.note_id, "source_type": row.source_type, "source_id": row.source_id},
)
)
for row in escalations:
timeline.append(
SalesTimelineEventOut(
ts=row.updated_at or row.resolved_at or row.created_at,
kind="escalation",
title=f"Эскалация: {row.status}",
body=row.resolution_summary or row.reason,
meta={"escalation_id": row.escalation_id, "escalation_type": row.escalation_type},
)
)
timeline.sort(key=lambda item: item.ts, reverse=True)
active_escalation = next((row for row in escalations if row.status in {"open", "assigned", "in_progress"}), None)
return SalesWorkspaceOut(
lead=_lead_to_out(lead, deal) 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],
transcripts=[_transcript_to_out(row) for row in transcripts],
notes=[_note_to_out(row) for row in notes],
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],
active_escalation=_escalation_to_out(active_escalation) if active_escalation else None,
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],
recommended_channel_switch=recommended_channel_switch,
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, deal)
finally:
session.close()
@app.get("/api/v1/leads", response_model=list[SalesLeadOut])
def list_leads(
query: str | None = None,
search: str | None = None,
status: str | None = None,
lead_temperature: str | None = None,
source_channel: str | None = None,
source_type: str | None = None,
customer_type: str | None = None,
preferred_channel: str | None = None,
limit: int = Query(default=100, ge=1, le=200),
offset: int = Query(default=0, ge=0),
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 lead_temperature:
stmt = stmt.where(SalesLeadRow.lead_temperature == lead_temperature)
if source_channel:
stmt = stmt.where(SalesLeadRow.source_channel == source_channel)
if source_type:
stmt = stmt.where(SalesLeadRow.source_type == source_type)
if customer_type:
stmt = stmt.where(SalesLeadRow.customer_type == customer_type)
if preferred_channel:
stmt = stmt.where(SalesLeadRow.preferred_channel == preferred_channel)
search_term = (search or query or "").strip()
if search_term:
pattern = f"%{search_term.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),
func.lower(func.coalesce(SalesLeadRow.lead_id, "")).like(pattern),
)
)
rows = session.execute(stmt.limit(limit).offset(offset)).scalars().all()
deals_by_lead = _latest_deals_by_lead(session, [row.lead_id for row in rows], tenant_id)
return [_lead_to_out(row, deals_by_lead.get(row.lead_id)) 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:
tenant_id = _tenant_id(actor)
lead = _get_lead(session, lead_id, tenant_id)
return _lead_to_out(lead, _latest_deal_for_lead(session, lead.lead_id, tenant_id))
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, _latest_deal_for_lead(session, lead.lead_id, tenant_id))
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.get("/api/v1/customers", response_model=list[SalesCustomerOut])
def list_customers(
query: str | None = None,
search: str | None = None,
limit: int = Query(default=100, ge=1, le=200),
offset: int = Query(default=0, ge=0),
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> list[SalesCustomerOut]:
session = get_session()
try:
tenant_id = _tenant_id(actor)
customer_ids = _sales_customer_ids_for_tenant(session, tenant_id)
if not customer_ids:
return []
stmt = select(Customer).where(Customer.customer_id.in_(customer_ids))
search_term = (search or query or "").strip()
if search_term:
pattern = f"%{search_term.lower()}%"
stmt = stmt.where(
or_(
func.lower(func.coalesce(Customer.customer_id, "")).like(pattern),
func.lower(func.coalesce(Customer.display_name, "")).like(pattern),
func.lower(func.coalesce(Customer.preferred_phone, "")).like(pattern),
)
)
rows = session.execute(stmt.order_by(Customer.display_name.asc(), Customer.id.desc()).limit(limit).offset(offset)).scalars().all()
counts = dict(
session.execute(
select(SalesDealRow.customer_id, func.count())
.where(
SalesDealRow.tenant_id == tenant_id,
SalesDealRow.customer_id.in_([row.customer_id for row in rows] or [""]),
)
.group_by(SalesDealRow.customer_id)
).all()
)
return [_customer_to_out(row, int(counts.get(row.customer_id) or 0)) for row in rows]
finally:
session.close()
@app.get("/api/v1/customers/{customer_id}", response_model=SalesCustomerOut)
def get_customer(
customer_id: str,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> SalesCustomerOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
customer = _get_sales_customer(session, customer_id, tenant_id)
return _customer_to_out(customer, _customer_deal_count(session, customer.customer_id, tenant_id))
finally:
session.close()
@app.patch("/api/v1/customers/{customer_id}", response_model=SalesCustomerOut)
def update_customer(
customer_id: str,
payload: SalesCustomerUpdate,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesCustomerOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
customer = _get_sales_customer(session, customer_id, tenant_id)
updates = payload.model_dump(exclude_unset=True)
if "display_name" in updates and updates["display_name"] is not None:
customer.display_name = str(updates["display_name"])
if "phones" in updates and updates["phones"] is not None:
customer.phones_json = json.dumps(updates["phones"], ensure_ascii=False)
if "preferred_phone" in updates:
customer.preferred_phone = updates["preferred_phone"]
if "tags" in updates and updates["tags"] is not None:
customer.tags_json = json.dumps(updates["tags"], ensure_ascii=False)
session.commit()
return _customer_to_out(customer, _customer_deal_count(session, customer.customer_id, tenant_id))
finally:
session.close()
@app.get("/api/v1/customers/{customer_id}/deals", response_model=list[SalesDealOut])
def list_customer_deals(
customer_id: str,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> list[SalesDealOut]:
session = get_session()
try:
tenant_id = _tenant_id(actor)
_get_sales_customer(session, customer_id, tenant_id)
rows = session.execute(
select(SalesDealRow)
.where(SalesDealRow.tenant_id == tenant_id, SalesDealRow.customer_id == customer_id)
.order_by(SalesDealRow.updated_at.desc(), SalesDealRow.id.desc())
).scalars().all()
return [_deal_to_out_with_refs(session, row) for row in rows]
finally:
session.close()
@app.get("/api/v1/customers/{customer_id}/communications", response_model=list[SalesCommunicationOut])
def list_customer_communications(
customer_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_sales_customer(session, customer_id, tenant_id)
rows = session.execute(
select(SalesCommunicationSessionRow)
.where(SalesCommunicationSessionRow.tenant_id == tenant_id, SalesCommunicationSessionRow.customer_id == customer_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.get("/api/v1/customers/{customer_id}/documents", response_model=list[SalesDocumentOut])
def list_customer_documents(
customer_id: str,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> list[SalesDocumentOut]:
session = get_session()
try:
tenant_id = _tenant_id(actor)
_get_sales_customer(session, customer_id, tenant_id)
rows = session.execute(
select(SalesDocumentRow)
.where(SalesDocumentRow.tenant_id == tenant_id, SalesDocumentRow.customer_id == customer_id)
.order_by(SalesDocumentRow.updated_at.desc(), SalesDocumentRow.id.desc())
).scalars().all()
return [_document_to_out(row) for row in rows]
finally:
session.close()
@app.get("/api/v1/customers/{customer_id}/invoices", response_model=list[SalesInvoiceOut])
def list_customer_invoices(
customer_id: str,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> list[SalesInvoiceOut]:
session = get_session()
try:
tenant_id = _tenant_id(actor)
_get_sales_customer(session, customer_id, tenant_id)
rows = session.execute(
select(SalesInvoiceRow)
.where(SalesInvoiceRow.tenant_id == tenant_id, SalesInvoiceRow.customer_id == customer_id)
.order_by(SalesInvoiceRow.updated_at.desc(), SalesInvoiceRow.id.desc())
).scalars().all()
return [_invoice_to_out(row) for row in rows]
finally:
session.close()
@app.get("/api/v1/customers/{customer_id}/payments", response_model=list[SalesPaymentOut])
def list_customer_payments(
customer_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_sales_customer(session, customer_id, tenant_id)
deal_ids = session.execute(
select(SalesDealRow.deal_id).where(SalesDealRow.tenant_id == tenant_id, SalesDealRow.customer_id == customer_id)
).scalars().all()
rows = session.execute(
select(SalesPaymentRow)
.where(SalesPaymentRow.tenant_id == tenant_id, SalesPaymentRow.deal_id.in_(deal_ids or [""]))
.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/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_for_state_change(session, deal_id, _tenant_id(actor))
target_stage_code = _resolve_stage_change_target_code(session, deal, payload)
actor_type = "admin" if payload.force and actor["role"] == Role.ADMIN.value else "human"
transition_deal_stage(
session,
tenant_id=deal.tenant_id,
deal_id=deal.deal_id,
target_stage_code=target_stage_code,
actor_type=actor_type,
actor_id=actor["user"],
reason=payload.reason,
metadata=payload.metadata,
force=payload.force,
)
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}/notes", response_model=SalesNoteOut)
def create_deal_note(
deal_id: str,
payload: SalesNoteCreateIn,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesNoteOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
deal = _get_deal(session, deal_id, tenant_id)
now = utc_now_iso()
row = SalesNoteRow(
note_id=new_id("not"),
tenant_id=tenant_id,
deal_id=deal.deal_id,
author_type="human",
author_id=actor["user"],
note_type=payload.note_type,
content=payload.content,
source_type=payload.source_type,
source_id=payload.source_id,
created_at=now,
updated_at=now,
archived_at=None,
)
session.add(row)
_publish_sales_event(
session,
tenant_id=tenant_id,
event_type=sales_event_types.DEAL_NOTE_CREATED,
aggregate_type="deal",
aggregate_id=deal.deal_id,
actor_type="human",
actor_id=actor["user"],
payload={
"deal_id": deal.deal_id,
"note_id": row.note_id,
"note_type": row.note_type,
"source_type": row.source_type,
"source_id": row.source_id,
},
)
session.commit()
return _note_to_out(row)
finally:
session.close()
@app.get("/api/v1/deals/{deal_id}/notes", response_model=list[SalesNoteOut])
def list_deal_notes(
deal_id: str,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> list[SalesNoteOut]:
session = get_session()
try:
tenant_id = _tenant_id(actor)
_get_deal(session, deal_id, tenant_id)
rows = session.execute(
select(SalesNoteRow)
.where(SalesNoteRow.deal_id == deal_id, SalesNoteRow.tenant_id == tenant_id, SalesNoteRow.archived_at.is_(None))
.order_by(SalesNoteRow.created_at.desc(), SalesNoteRow.id.desc())
).scalars().all()
return [_note_to_out(row) for row in rows]
finally:
session.close()
@app.patch("/api/v1/deals/{deal_id}/notes/{note_id}", response_model=SalesNoteOut)
def update_deal_note(
deal_id: str,
note_id: str,
payload: SalesNoteUpdateIn,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesNoteOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
deal = _get_deal(session, deal_id, tenant_id)
row = session.execute(
select(SalesNoteRow).where(
SalesNoteRow.note_id == note_id,
SalesNoteRow.deal_id == deal.deal_id,
SalesNoteRow.tenant_id == tenant_id,
SalesNoteRow.archived_at.is_(None),
)
).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="Note not found")
updates = payload.model_dump(exclude_unset=True)
for key, value in updates.items():
setattr(row, key, value)
row.updated_at = utc_now_iso()
_publish_sales_event(
session,
tenant_id=tenant_id,
event_type=sales_event_types.DEAL_NOTE_UPDATED,
aggregate_type="deal",
aggregate_id=deal.deal_id,
actor_type="human",
actor_id=actor["user"],
payload={
"deal_id": deal.deal_id,
"note_id": row.note_id,
"note_type": row.note_type,
"source_type": row.source_type,
"source_id": row.source_id,
},
)
session.commit()
return _note_to_out(row)
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}/escalations", response_model=SalesEscalationOut)
@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:
tenant_id = _tenant_id(actor)
deal = _get_deal(session, deal_id, tenant_id)
existing = session.execute(
select(SalesEscalationRow)
.where(
SalesEscalationRow.tenant_id == tenant_id,
SalesEscalationRow.deal_id == deal.deal_id,
SalesEscalationRow.escalation_type == payload.escalation_type,
SalesEscalationRow.reason == payload.reason,
SalesEscalationRow.status.in_(["open", "assigned", "in_progress"]),
)
.order_by(SalesEscalationRow.id.desc())
).scalars().first()
if existing is not None:
return _escalation_to_out(existing)
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="assigned" if payload.assigned_to_user_id else "open",
assigned_to_user_id=payload.assigned_to_user_id,
assigned_at=now if payload.assigned_to_user_id else None,
started_at=None,
created_at=now,
resolved_at=None,
canceled_at=None,
resolution_code=None,
resolution_summary=None,
sla_due_at=payload.sla_due_at,
source_channel=payload.source_channel or _channel_type_for_value(deal.current_channel),
source_communication_session_id=payload.source_communication_session_id,
source_automation_task_id=payload.source_automation_task_id,
updated_at=now,
)
session.add(row)
deal.assigned_human_user_id = payload.assigned_to_user_id or actor["user"]
deal.scenario_type = "custom_human_escalation"
try:
transition_deal_stage(
session,
tenant_id=tenant_id,
deal_id=deal.deal_id,
target_stage_code="transferred_to_support",
actor_type="human",
actor_id=actor["user"],
reason=payload.reason,
metadata={"escalation_id": row.escalation_id},
)
except DealStateMachineError as exc:
if exc.error != "invalid_stage_transition":
raise
transition_deal_stage(
session,
tenant_id=tenant_id,
deal_id=deal.deal_id,
target_stage_code="transferred_to_support",
actor_type="admin" if actor["role"] == Role.ADMIN.value else "system",
actor_id=actor["user"],
reason=payload.reason,
metadata={"escalation_id": row.escalation_id},
force=True,
)
_publish_sales_event(
session,
tenant_id=tenant_id,
event_type=sales_event_types.DEAL_ESCALATION_REQUESTED,
aggregate_type="deal",
aggregate_id=deal.deal_id,
actor_type="human",
actor_id=actor["user"],
payload={
"deal_id": deal.deal_id,
"escalation_id": row.escalation_id,
"escalation_type": row.escalation_type,
"severity": row.severity,
"assigned_to_user_id": row.assigned_to_user_id,
},
)
_publish_sales_event(
session,
tenant_id=tenant_id,
event_type=sales_event_types.ESCALATION_CREATED,
aggregate_type="escalation",
aggregate_id=row.escalation_id,
actor_type="human",
actor_id=actor["user"],
payload={
"deal_id": deal.deal_id,
"escalation_id": row.escalation_id,
"escalation_type": row.escalation_type,
"status": row.status,
"severity": row.severity,
},
)
session.commit()
return _escalation_to_out(row)
finally:
session.close()
@app.get("/api/v1/deals/{deal_id}/escalations", response_model=list[SalesEscalationOut])
def list_deal_escalations(
deal_id: str,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> list[SalesEscalationOut]:
session = get_session()
try:
tenant_id = _tenant_id(actor)
_get_deal(session, deal_id, tenant_id)
rows = session.execute(
select(SalesEscalationRow)
.where(SalesEscalationRow.deal_id == deal_id, SalesEscalationRow.tenant_id == tenant_id)
.order_by(SalesEscalationRow.created_at.desc(), SalesEscalationRow.id.desc())
).scalars().all()
return [_escalation_to_out(row) for row in rows]
finally:
session.close()
def _get_escalation(session, escalation_id: str, tenant_id: str) -> SalesEscalationRow:
row = session.execute(
select(SalesEscalationRow).where(SalesEscalationRow.escalation_id == escalation_id, SalesEscalationRow.tenant_id == tenant_id)
).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="Escalation not found")
return row
def _publish_escalation_lifecycle_event(
session,
*,
tenant_id: str,
event_type: str,
row: SalesEscalationRow,
actor_user: str,
) -> None:
_publish_sales_event(
session,
tenant_id=tenant_id,
event_type=event_type,
aggregate_type="escalation",
aggregate_id=row.escalation_id,
actor_type="human",
actor_id=actor_user,
payload={
"deal_id": row.deal_id,
"escalation_id": row.escalation_id,
"escalation_type": row.escalation_type,
"status": row.status,
"assigned_to_user_id": row.assigned_to_user_id,
"resolution_code": row.resolution_code,
},
)
@app.post("/api/v1/escalations/{escalation_id}/assign", response_model=SalesEscalationOut)
def assign_escalation(
escalation_id: str,
payload: SalesEscalationAssignIn,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesEscalationOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
row = _get_escalation(session, escalation_id, tenant_id)
now = utc_now_iso()
row.assigned_to_user_id = payload.assigned_to_user_id
row.assigned_at = now
if row.status == "open":
row.status = "assigned"
row.updated_at = now
_publish_escalation_lifecycle_event(
session,
tenant_id=tenant_id,
event_type=sales_event_types.ESCALATION_ASSIGNED,
row=row,
actor_user=actor["user"],
)
session.commit()
return _escalation_to_out(row)
finally:
session.close()
@app.post("/api/v1/escalations/{escalation_id}/start", response_model=SalesEscalationOut)
def start_escalation(
escalation_id: str,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesEscalationOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
row = _get_escalation(session, escalation_id, tenant_id)
if row.status in {"resolved", "canceled"}:
raise HTTPException(status_code=400, detail="Escalation is already closed")
now = utc_now_iso()
row.status = "in_progress"
row.started_at = row.started_at or now
row.updated_at = now
_publish_escalation_lifecycle_event(
session,
tenant_id=tenant_id,
event_type=sales_event_types.ESCALATION_STARTED,
row=row,
actor_user=actor["user"],
)
session.commit()
return _escalation_to_out(row)
finally:
session.close()
@app.post("/api/v1/escalations/{escalation_id}/resolve", response_model=SalesEscalationOut)
def resolve_escalation(
escalation_id: str,
payload: SalesEscalationResolveIn,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesEscalationOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
row = _get_escalation(session, escalation_id, tenant_id)
now = utc_now_iso()
row.status = "resolved"
row.resolved_at = now
row.resolution_code = payload.resolution_code
row.resolution_summary = payload.resolution_summary
row.updated_at = now
if payload.target_stage_code:
transition_deal_stage(
session,
tenant_id=tenant_id,
deal_id=row.deal_id,
target_stage_code=payload.target_stage_code,
actor_type="human",
actor_id=actor["user"],
reason=payload.resolution_summary or payload.resolution_code,
metadata={"escalation_id": row.escalation_id, **payload.metadata},
)
_publish_escalation_lifecycle_event(
session,
tenant_id=tenant_id,
event_type=sales_event_types.ESCALATION_RESOLVED,
row=row,
actor_user=actor["user"],
)
session.commit()
return _escalation_to_out(row)
finally:
session.close()
@app.post("/api/v1/escalations/{escalation_id}/cancel", response_model=SalesEscalationOut)
def cancel_escalation(
escalation_id: str,
payload: SalesEscalationCancelIn,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesEscalationOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
row = _get_escalation(session, escalation_id, tenant_id)
now = utc_now_iso()
row.status = "canceled"
row.canceled_at = now
row.resolution_code = payload.resolution_code
row.resolution_summary = payload.resolution_summary
row.updated_at = now
_publish_escalation_lifecycle_event(
session,
tenant_id=tenant_id,
event_type=sales_event_types.ESCALATION_CANCELED,
row=row,
actor_user=actor["user"],
)
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)
row, new_communication = _perform_channel_switch(
session,
deal=deal,
payload=payload,
actor_type="human",
actor_id=actor["user"],
communication=communication,
)
session.commit()
return _channel_switch_to_out_with_session(row, new_communication)
finally:
session.close()
@app.post("/api/v1/deals/{deal_id}/switch-channel", response_model=SalesChannelSwitchOut)
def switch_deal_channel(
deal_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)
deal = _get_deal(session, deal_id, tenant_id)
communication = session.execute(
select(SalesCommunicationSessionRow)
.where(SalesCommunicationSessionRow.deal_id == deal.deal_id, SalesCommunicationSessionRow.tenant_id == tenant_id)
.order_by(SalesCommunicationSessionRow.started_at.desc(), SalesCommunicationSessionRow.id.desc())
).scalars().first()
row, new_communication = _perform_channel_switch(
session,
deal=deal,
payload=payload,
actor_type="human",
actor_id=actor["user"],
communication=communication,
)
session.commit()
return _channel_switch_to_out_with_session(row, new_communication)
finally:
session.close()
@app.get("/api/v1/deals/{deal_id}/channel-switches", response_model=list[SalesChannelSwitchOut])
def list_deal_channel_switches(
deal_id: str,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> list[SalesChannelSwitchOut]:
session = get_session()
try:
tenant_id = _tenant_id(actor)
_get_deal(session, deal_id, tenant_id)
rows = session.execute(
select(SalesChannelSwitchRow)
.where(SalesChannelSwitchRow.deal_id == deal_id, SalesChannelSwitchRow.tenant_id == tenant_id)
.order_by(SalesChannelSwitchRow.switched_at.desc(), SalesChannelSwitchRow.id.desc())
).scalars().all()
return [_channel_switch_to_out(row) for row in rows]
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={
"channel_provider": "telegram",
"skip_stage_transition": True,
"stage_progression_mode": "telegram_message_count",
"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
message_created = False
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)
message_created = True
_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()
if message_created:
session.flush()
_maybe_advance_telegram_deal_stage(session, deal=deal, lead=lead, payload=payload)
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={
"channel_provider": "voice",
"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 or {}), "channel_provider": payload.channel_provider},
)
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)
_cancel_pending_automation_tasks(
session,
deal=deal,
task_types={"follow_up_customer"},
reason="customer_replied",
)
_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 or {}), "channel_provider": payload.channel_provider},
)
else:
_merge_communication_metadata(communication, {"channel_provider": payload.channel_provider})
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)
_schedule_unique_task(
session,
deal=deal,
task_type="follow_up_customer",
run_at=_automation_run_at_after(days=1),
payload={"message_id": message.message_id, "reason": "outbound_message.awaiting_reply"},
match_payload={"message_id": message.message_id},
)
_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, "channel_provider": "voice"},
)
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, "channel_provider": "voice"},
)
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.get("/api/v1/calls/{call_id}/transcript", response_model=SalesTranscriptOut)
def get_call_transcript(
call_id: str,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> 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")
row = session.execute(
select(SalesTranscriptRow)
.where(SalesTranscriptRow.call_id == call.call_id, SalesTranscriptRow.tenant_id == tenant_id)
.order_by(SalesTranscriptRow.updated_at.desc(), SalesTranscriptRow.id.desc())
).scalars().first()
if row is None:
raise HTTPException(status_code=404, detail="Transcript not found")
return _transcript_to_out(row)
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/deals/{deal_id}/offers", response_model=list[SalesOfferOut])
def list_offers(
deal_id: str,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> list[SalesOfferOut]:
session = get_session()
try:
tenant_id = _tenant_id(actor)
_get_deal(session, deal_id, tenant_id)
rows = session.execute(
select(SalesOfferRow)
.where(SalesOfferRow.deal_id == deal_id, SalesOfferRow.tenant_id == tenant_id)
.order_by(SalesOfferRow.updated_at.desc(), SalesOfferRow.id.desc())
).scalars().all()
return [_offer_to_out(row) for row in rows]
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")
_schedule_unique_task(
session,
deal=deal,
task_type="follow_up_customer",
run_at=_automation_run_at_after(days=1),
payload={"offer_id": row.offer_id, "reason": "offer.sent"},
match_payload={"offer_id": row.offer_id, "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.get("/api/v1/deals/{deal_id}/conditions", response_model=SalesConditionOut | None)
def get_deal_conditions(
deal_id: str,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> SalesConditionOut | None:
session = get_session()
try:
tenant_id = _tenant_id(actor)
_get_deal(session, deal_id, tenant_id)
row = session.execute(
select(SalesConditionRow)
.where(SalesConditionRow.deal_id == deal_id, SalesConditionRow.tenant_id == tenant_id)
.order_by(SalesConditionRow.updated_at.desc(), SalesConditionRow.id.desc())
).scalars().first()
return _condition_to_out(row) if row else None
finally:
session.close()
@app.post("/api/v1/deals/{deal_id}/conditions", response_model=SalesConditionOut)
@app.patch("/api/v1/deals/{deal_id}/conditions", response_model=SalesConditionOut)
@app.put("/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_if_needed(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}/conditions/confirm", response_model=SalesConditionOut)
def confirm_deal_conditions(
deal_id: str,
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)
.order_by(SalesConditionRow.updated_at.desc(), SalesConditionRow.id.desc())
).scalars().first()
if row is None:
raise HTTPException(status_code=404, detail="Conditions not found")
now = utc_now_iso()
row.confirmed_at = row.confirmed_at or now
row.updated_at = now
if row.agreed_price is not None:
deal.final_amount = row.agreed_price
_apply_stage_if_needed(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_if_needed(
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.get("/api/v1/deals/{deal_id}/documents", response_model=list[SalesDocumentOut])
def list_documents(
deal_id: str,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> list[SalesDocumentOut]:
session = get_session()
try:
tenant_id = _tenant_id(actor)
_get_deal(session, deal_id, tenant_id)
rows = session.execute(
select(SalesDocumentRow)
.where(SalesDocumentRow.deal_id == deal_id, SalesDocumentRow.tenant_id == tenant_id)
.order_by(SalesDocumentRow.updated_at.desc(), SalesDocumentRow.id.desc())
).scalars().all()
return [_document_to_out(row) for row in rows]
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")
_schedule_unique_task(
session,
deal=deal,
task_type="follow_up_customer",
run_at=_automation_run_at_after(days=1),
payload={"document_id": row.document_id, "reason": "document.sent"},
match_payload={"document_id": row.document_id, "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.get("/api/v1/deals/{deal_id}/invoices", response_model=list[SalesInvoiceOut])
def list_invoices(
deal_id: str,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> list[SalesInvoiceOut]:
session = get_session()
try:
tenant_id = _tenant_id(actor)
_get_deal(session, deal_id, tenant_id)
rows = session.execute(
select(SalesInvoiceRow)
.where(SalesInvoiceRow.deal_id == deal_id, SalesInvoiceRow.tenant_id == tenant_id)
.order_by(SalesInvoiceRow.updated_at.desc(), SalesInvoiceRow.id.desc())
).scalars().all()
return [_invoice_to_out(row) for row in rows]
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_unique_task(
session,
deal=deal,
task_type="send_invoice_reminder",
run_at=_invoice_reminder_run_at(row.due_date),
payload={"invoice_id": row.invoice_id, "invoice_number": row.invoice_number, "reason": "invoice.sent"},
match_payload={"invoice_id": row.invoice_id, "reason": "invoice.sent"},
)
_schedule_unique_task(
session,
deal=deal,
task_type="mark_invoice_overdue",
run_at=_invoice_overdue_run_at(row.due_date),
payload={"invoice_id": row.invoice_id, "invoice_number": row.invoice_number, "reason": "invoice.due_date"},
match_payload={"invoice_id": row.invoice_id, "reason": "invoice.due_date"},
)
_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")
_schedule_unique_task(
session,
deal=deal,
task_type="send_invoice_reminder",
run_at=utc_now_iso(),
payload={"invoice_id": row.invoice_id, "invoice_number": row.invoice_number, "reason": "invoice.overdue"},
match_payload={"invoice_id": row.invoice_id, "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")
async def payment_webhook(
request: Request,
actor: dict = Depends(get_actor),
):
raw_body = await request.body()
headers = normalize_headers(request.headers)
try:
raw_payload = safe_json_loads(raw_body)
except (json.JSONDecodeError, UnicodeDecodeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Invalid payment webhook payload") from exc
normalized_payload = _normalized_payment_payload(raw_payload, headers)
provider = normalized_payload["payment_provider"]
provider_account_id = normalized_payload.get("provider_account_id")
external_event_id = normalized_payload.get("external_event_id")
external_payment_id = normalized_payload.get("external_payment_id")
event_type = normalized_payload["event_type"]
session = get_session()
try:
tenant_id, integration = _resolve_payment_integration(
session,
actor,
provider=provider,
provider_account_id=provider_account_id,
external_identifier=normalized_payload["metadata"].get("payment_link_id")
or normalized_payload["metadata"].get("invoice_external_reference"),
)
now = utc_now_iso()
existing_event = _find_existing_webhook_event(
session,
tenant_id=tenant_id,
provider=provider,
external_event_id=external_event_id,
)
if existing_event is not None:
try:
signature_required = provider != "manual" or bool(_payment_webhook_secret(integration))
verify_payment_webhook_signature(
raw_body=raw_body,
headers=headers,
secret=_payment_webhook_secret(integration),
replay_window_seconds=_payment_replay_window_seconds(integration),
required=signature_required,
)
except PaymentWebhookVerificationError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
return _payment_webhook_event_duplicate_response(session, existing_event)
event = SalesPaymentWebhookEventRow(
tenant_id=tenant_id,
payment_provider=provider,
provider_account_id=provider_account_id,
external_event_id=external_event_id,
external_payment_id=external_payment_id,
event_type=event_type,
event_status="received",
signature_status="unchecked",
raw_payload_hash=raw_payload_hash(raw_body),
normalized_payload_json=json.dumps(normalized_payload, ensure_ascii=False),
headers_json=json.dumps(headers, ensure_ascii=False),
received_at=now,
processed_at=None,
error_message=None,
created_at=now,
updated_at=now,
)
session.add(event)
session.flush()
try:
signature_required = provider != "manual" or bool(_payment_webhook_secret(integration))
event.signature_status = verify_payment_webhook_signature(
raw_body=raw_body,
headers=headers,
secret=_payment_webhook_secret(integration),
replay_window_seconds=_payment_replay_window_seconds(integration),
required=signature_required,
)
except PaymentWebhookVerificationError as exc:
event.event_status = "rejected"
event.signature_status = exc.signature_status
event.error_message = exc.message
event.updated_at = utc_now_iso()
session.commit()
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
event.event_status = "verified"
event.updated_at = utc_now_iso()
deal_id = str(normalized_payload.get("deal_id") or "").strip()
invoice_id = str(normalized_payload.get("invoice_id") or "").strip()
if not deal_id and invoice_id:
invoice_for_deal = session.execute(
select(SalesInvoiceRow).where(SalesInvoiceRow.invoice_id == invoice_id, SalesInvoiceRow.tenant_id == tenant_id)
).scalar_one_or_none()
if invoice_for_deal is not None:
deal_id = invoice_for_deal.deal_id
if not deal_id:
_mark_payment_webhook_event_failed(event, "Payment webhook must include deal_id or invoice_id")
session.commit()
raise HTTPException(status_code=400, detail="Payment webhook must include deal_id or invoice_id")
try:
deal = _get_deal(session, deal_id, tenant_id)
except HTTPException as exc:
_mark_payment_webhook_event_failed(event, exc.detail)
session.commit()
raise
invoice = None
if invoice_id:
invoice = session.execute(
select(SalesInvoiceRow).where(SalesInvoiceRow.invoice_id == invoice_id, SalesInvoiceRow.tenant_id == tenant_id)
).scalar_one_or_none()
if invoice is None:
_mark_payment_webhook_event_failed(event, "Invoice not found")
session.commit()
raise HTTPException(status_code=404, detail="Invoice not found")
if invoice.deal_id != deal.deal_id:
_mark_payment_webhook_event_failed(event, "Invoice does not belong to deal")
session.commit()
raise HTTPException(status_code=400, detail="Invoice does not belong to deal")
try:
payment_metadata = dict(normalized_payload["metadata"])
payment_metadata.update(
{
"external_event_id": external_event_id,
"event_type": event_type,
"webhook_event_row_id": event.id,
"raw_payload_hash": event.raw_payload_hash,
}
)
row = _apply_payment_update(
session,
tenant_id=tenant_id,
deal=deal,
invoice=invoice,
payment_provider=provider,
external_payment_id=external_payment_id,
amount=normalized_payload.get("amount"),
currency=normalized_payload.get("currency") or "KZT",
status=normalized_payload.get("status") or "pending",
paid_at=normalized_payload.get("paid_at"),
payment_method=normalized_payload.get("payment_method"),
failure_reason=normalized_payload.get("failure_reason"),
metadata=payment_metadata,
integration=integration,
)
except HTTPException as exc:
_mark_payment_webhook_event_failed(event, exc.detail)
session.commit()
raise
result = _payment_to_dict(row)
normalized_payload["result"] = {
"payment_id": row.payment_id,
"invoice_id": row.invoice_id,
"deal_id": row.deal_id,
"status": row.status,
}
event.normalized_payload_json = json.dumps(normalized_payload, ensure_ascii=False)
event.event_status = "processed"
event.processed_at = utc_now_iso()
event.updated_at = event.processed_at
session.commit()
return result
except IntegrityError:
session.rollback()
resolved_tenant_id = tenant_id if "tenant_id" in locals() else ""
existing_event = _find_existing_webhook_event(
session,
tenant_id=resolved_tenant_id,
provider=provider,
external_event_id=external_event_id,
)
if existing_event is not None:
return _payment_webhook_event_duplicate_response(session, existing_event)
raise
finally:
session.close()
@app.get("/api/v1/deals/{deal_id}/automation-tasks", response_model=list[SalesAutomationTaskOut])
def list_automation_tasks(
deal_id: str,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> list[SalesAutomationTaskOut]:
session = get_session()
try:
tenant_id = _tenant_id(actor)
_get_deal(session, deal_id, tenant_id)
rows = session.execute(
select(SalesAutomationTaskRow)
.where(SalesAutomationTaskRow.deal_id == deal_id, SalesAutomationTaskRow.tenant_id == tenant_id)
.order_by(SalesAutomationTaskRow.run_at.desc(), SalesAutomationTaskRow.id.desc())
).scalars().all()
return [_task_to_out(row) for row in rows]
finally:
session.close()
@app.get("/api/v1/automation-tasks", response_model=list[SalesAutomationTaskOut])
def list_tenant_automation_tasks(
status: str | None = Query(default=None),
task_type: str | None = Query(default=None),
run_at: str | None = Query(default=None),
deal_search: str | None = Query(default=None),
failed_only: bool = Query(default=False),
pending_only: bool = Query(default=False),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
) -> list[SalesAutomationTaskOut]:
session = get_session()
try:
tenant_id = _tenant_id(actor)
conditions = [SalesAutomationTaskRow.tenant_id == tenant_id]
if status:
conditions.append(SalesAutomationTaskRow.status == status)
if task_type:
conditions.append(SalesAutomationTaskRow.task_type == task_type)
if failed_only:
conditions.append(
or_(
SalesAutomationTaskRow.status == "failed",
SalesAutomationTaskRow.failed_at.is_not(None),
SalesAutomationTaskRow.last_error.is_not(None),
)
)
elif pending_only:
conditions.append(SalesAutomationTaskRow.status == "pending")
now = datetime.now(tz=timezone.utc).replace(microsecond=0)
if run_at == "today":
day_start = now.replace(hour=0, minute=0, second=0)
day_end = day_start + timedelta(days=1)
conditions.append(SalesAutomationTaskRow.run_at >= day_start.isoformat())
conditions.append(SalesAutomationTaskRow.run_at < day_end.isoformat())
elif run_at == "overdue":
conditions.append(SalesAutomationTaskRow.status == "pending")
conditions.append(SalesAutomationTaskRow.run_at < now.isoformat())
elif run_at == "future":
conditions.append(SalesAutomationTaskRow.run_at >= now.isoformat())
if deal_search:
term = f"%{deal_search.strip().lower()}%"
if term != "%%":
conditions.append(
or_(
func.lower(SalesAutomationTaskRow.deal_id).like(term),
func.lower(SalesDealRow.title).like(term),
func.lower(SalesDealRow.customer_id).like(term),
)
)
rows = session.execute(
select(SalesAutomationTaskRow, SalesDealRow)
.join(
SalesDealRow,
(SalesDealRow.deal_id == SalesAutomationTaskRow.deal_id)
& (SalesDealRow.tenant_id == SalesAutomationTaskRow.tenant_id),
isouter=True,
)
.where(*conditions)
.order_by(SalesAutomationTaskRow.run_at.asc(), SalesAutomationTaskRow.id.desc())
.limit(limit)
.offset(offset)
).all()
return [_task_to_out(task, deal) for task, deal in rows]
finally:
session.close()
@app.post("/api/v1/automation-tasks/{task_id}/cancel", response_model=SalesAutomationTaskOut)
def cancel_automation_task(
task_id: str,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesAutomationTaskOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
row = session.execute(
select(SalesAutomationTaskRow).where(SalesAutomationTaskRow.task_id == task_id, SalesAutomationTaskRow.tenant_id == tenant_id)
).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="Automation task not found")
if row.status != "pending":
raise HTTPException(status_code=400, detail={"code": "automation_task_not_pending", "message": "Only pending automation tasks can be canceled"})
payload = _json_dict(row.payload_json)
payload["canceled_by"] = actor["user"]
payload["canceled_reason"] = "manual"
row.payload_json = json.dumps(payload, ensure_ascii=False)
row.status = "canceled"
row.locked_at = None
row.locked_by = None
row.updated_at = utc_now_iso()
session.commit()
return _task_to_out(row)
finally:
session.close()
@app.post("/api/v1/automation-tasks/{task_id}/run-now", response_model=SalesAutomationTaskOut)
def run_automation_task_now(
task_id: str,
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> SalesAutomationTaskOut:
session = get_session()
try:
tenant_id = _tenant_id(actor)
row = session.execute(
select(SalesAutomationTaskRow).where(SalesAutomationTaskRow.task_id == task_id, SalesAutomationTaskRow.tenant_id == tenant_id)
).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="Automation task not found")
if row.status not in {"pending", "failed", "canceled"}:
raise HTTPException(status_code=400, detail={"code": "automation_task_not_pending", "message": "Automation task cannot be queued"})
row.status = "pending"
row.run_at = utc_now_iso()
row.locked_at = None
row.locked_by = None
row.failed_at = None
row.completed_at = None
row.last_error = None
row.updated_at = row.run_at
session.commit()
return _task_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 | None = None,
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")
deal = _get_deal(session, row.deal_id, tenant_id)
invoice = None
if row.invoice_id:
invoice = session.execute(
select(SalesInvoiceRow).where(SalesInvoiceRow.invoice_id == row.invoice_id, SalesInvoiceRow.tenant_id == tenant_id)
).scalar_one_or_none()
if invoice is None:
raise HTTPException(status_code=404, detail="Invoice not found")
provider = str(row.payment_provider or "manual").strip().lower() or "manual"
integration = session.execute(
select(TenantIntegrationRow)
.where(
TenantIntegrationRow.tenant_id == tenant_id,
func.lower(TenantIntegrationRow.provider_type) == "payment",
func.lower(TenantIntegrationRow.provider_name) == provider,
TenantIntegrationRow.is_active == True, # noqa: E712
)
.order_by(TenantIntegrationRow.id.desc())
).scalars().first()
metadata = _json_dict(row.metadata_json)
failure_reason = payload.failure_reason if payload is not None else row.failure_reason
if payload is not None:
status = payload.status
metadata.update(payload.metadata or {})
else:
adapter = get_payment_provider_adapter(provider)
if adapter is None:
if provider == "manual":
status = row.status
else:
raise HTTPException(status_code=400, detail="Payment provider adapter is not configured")
elif not row.external_payment_id:
raise HTTPException(status_code=400, detail="Payment external_payment_id is required for reconcile")
else:
provider_result = adapter.get_status(
row.external_payment_id,
context={
"tenant_id": tenant_id,
"payment_id": row.payment_id,
"payment_provider": provider,
"integration_id": integration.integration_id if integration is not None else None,
},
)
if isinstance(provider_result, dict):
status = normalize_payment_status(provider, str(provider_result.get("status") or provider_result.get("provider_status") or ""))
failure_reason = str(provider_result.get("failure_reason") or failure_reason or "") or None
metadata.update(provider_result.get("metadata") if isinstance(provider_result.get("metadata"), dict) else {})
if provider_result.get("amount") is not None:
row.amount = _coerce_payment_amount(provider_result.get("amount"))
if provider_result.get("currency"):
row.currency = str(provider_result.get("currency")).upper()
else:
status = normalize_payment_status(provider, str(provider_result))
updated = _apply_payment_update(
session,
tenant_id=tenant_id,
deal=deal,
invoice=invoice,
payment_provider=provider,
external_payment_id=row.external_payment_id,
amount=row.amount,
currency=row.currency,
status=status,
paid_at=row.paid_at,
payment_method=row.payment_method,
failure_reason=failure_reason,
metadata=metadata,
integration=integration,
existing_payment=row,
)
session.commit()
return _payment_to_out(updated)
finally:
session.close()