From f53af1f7dca390fa5749b1db319e121583bb33d5 Mon Sep 17 00:00:00 2001 From: arys Date: Sat, 9 May 2026 09:16:06 +0500 Subject: [PATCH] sales fix --- .env.example | 3 + .env.production.template | 3 + .idea/.gitignore | 10 + deployment/docker-compose.parallel.server.yml | 10 + deployment/docker-compose.server.registry.yml | 10 + deployment/docker-compose.server.yml | 10 + deployment/docker-compose.yml | 15 + docs/architecture/real-time-voice-service.md | 12 + gateway/app.py | 19 + migrations/sql/0023_sales_postgres.sql | 345 ++ migrations/sql/0023_sales_sqlite.sql | 345 ++ .../0024_sales_external_links_postgres.sql | 29 + .../sql/0024_sales_external_links_sqlite.sql | 29 + scripts/local_stack.py | 5 +- services/ai_voice_runtime_service/app.py | 173 + .../ai_voice_runtime_service/audiosocket.py | 3 +- .../ai_voice_runtime_service/media_runtime.py | 2 +- .../ai_voice_runtime_service/providers/asr.py | 2 +- services/real_time_voice/__init__.py | 2 + services/real_time_voice/app.py | 15 + services/sales_service/__init__.py | 1 + services/sales_service/app.py | 3319 +++++++++++++++++ services/shared/audioop_compat.py | 102 + services/shared/sales_models.py | 685 ++++ services/shared/sales_sql_models.py | 445 +++ services/streaming_asr_sidecar_service/app.py | 2 +- services/telegram_adapter_service/app.py | 73 + tests/test_demo_ready_scripts.py | 2 + tests/test_gateway_ui.py | 13 + tests/test_sales_service.py | 300 ++ ui/admin/index.html | 1 + ui/operator/index.html | 1 + ui/sales/app.js | 927 +++++ ui/sales/index.html | 215 ++ ui/sales/styles.css | 373 ++ 35 files changed, 7495 insertions(+), 6 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 docs/architecture/real-time-voice-service.md create mode 100644 migrations/sql/0023_sales_postgres.sql create mode 100644 migrations/sql/0023_sales_sqlite.sql create mode 100644 migrations/sql/0024_sales_external_links_postgres.sql create mode 100644 migrations/sql/0024_sales_external_links_sqlite.sql create mode 100644 services/real_time_voice/__init__.py create mode 100644 services/real_time_voice/app.py create mode 100644 services/sales_service/__init__.py create mode 100644 services/sales_service/app.py create mode 100644 services/shared/audioop_compat.py create mode 100644 services/shared/sales_models.py create mode 100644 services/shared/sales_sql_models.py create mode 100644 tests/test_sales_service.py create mode 100644 ui/sales/app.js create mode 100644 ui/sales/index.html create mode 100644 ui/sales/styles.css diff --git a/.env.example b/.env.example index f1b375c..d971788 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,7 @@ TELEGRAM_ADAPTER_SERVICE_URL=http://localhost:8007 KB_SERVICE_URL=http://localhost:8008 REPORTING_SERVICE_URL=http://localhost:8009 SUPERVISOR_SERVICE_URL=http://localhost:8010 +SALES_SERVICE_URL=http://localhost:8020 TELEGRAM_BOT_ENABLED=0 TELEGRAM_BOT_TOKEN= TELEGRAM_WEBHOOK_SECRET= @@ -49,6 +50,8 @@ AI_WHATSAPP_MAX_CONTEXT_MESSAGES=12 AI_WHATSAPP_MAX_KB_RESULTS=3 AI_WHATSAPP_CONFIDENCE_HANDOFF_THRESHOLD=0.65 AI_VOICE_RUNTIME_SERVICE_URL=http://localhost:8018 +SALES_TELEGRAM_BRIDGE_ENABLED=1 +SALES_VOICE_RUNTIME_ENABLED=1 AI_VOICE_ENABLED=0 AI_VOICE_POLICY_MODE=v2_fast_conversational AI_VOICE_QUEUE_CONFIG_JSON={} diff --git a/.env.production.template b/.env.production.template index dc9a595..65d1276 100644 --- a/.env.production.template +++ b/.env.production.template @@ -12,6 +12,9 @@ RECORDING_SERVICE_URL=http://recording-service:8000 TELEGRAM_ADAPTER_SERVICE_URL=http://telegram-adapter-service:8000 AI_ORCHESTRATOR_SERVICE_URL=http://ai-orchestrator-service:8000 AI_VOICE_RUNTIME_SERVICE_URL=http://ai-voice-runtime-service:8000 +SALES_SERVICE_URL=http://sales-service:8000 +SALES_TELEGRAM_BRIDGE_ENABLED=1 +SALES_VOICE_RUNTIME_ENABLED=1 # Telegram real-bot chat (Track 15) TELEGRAM_BOT_ENABLED=1 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/deployment/docker-compose.parallel.server.yml b/deployment/docker-compose.parallel.server.yml index 503ea4a..ff39b66 100644 --- a/deployment/docker-compose.parallel.server.yml +++ b/deployment/docker-compose.parallel.server.yml @@ -22,6 +22,7 @@ x-app-env: &app_env KB_SERVICE_URL: http://kb-service:8000 REPORTING_SERVICE_URL: http://reporting-service:8000 SUPERVISOR_SERVICE_URL: http://supervisor-service:8000 + SALES_SERVICE_URL: http://sales-service:8000 AI_VOICE_ASR_PROVIDER: yandex AI_VOICE_TTS_PROVIDER: yandex AI_VOICE_V2_STREAMING_ASR_BACKEND: yandex_speechkit @@ -195,6 +196,14 @@ services: <<: *app_env APP_MODULE: services.supervisor_service.app:app + sales-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.sales_service.app:app + SALES_TELEGRAM_BRIDGE_ENABLED: "1" + SALES_VOICE_RUNTIME_ENABLED: "1" + event-bus-service: <<: *service_defaults environment: @@ -229,6 +238,7 @@ services: - kb-service - reporting-service - supervisor-service + - sales-service - event-bus-service - asterisk-bridge-service environment: diff --git a/deployment/docker-compose.server.registry.yml b/deployment/docker-compose.server.registry.yml index bb302f2..bf1022e 100644 --- a/deployment/docker-compose.server.registry.yml +++ b/deployment/docker-compose.server.registry.yml @@ -22,6 +22,7 @@ x-app-env: &app_env KB_SERVICE_URL: http://kb-service:8000 REPORTING_SERVICE_URL: http://reporting-service:8000 SUPERVISOR_SERVICE_URL: http://supervisor-service:8000 + SALES_SERVICE_URL: http://sales-service:8000 AI_VOICE_ASR_PROVIDER: yandex AI_VOICE_TTS_PROVIDER: yandex AI_VOICE_V2_STREAMING_ASR_BACKEND: yandex_speechkit @@ -191,6 +192,14 @@ services: <<: *app_env APP_MODULE: services.supervisor_service.app:app + sales-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.sales_service.app:app + SALES_TELEGRAM_BRIDGE_ENABLED: "1" + SALES_VOICE_RUNTIME_ENABLED: "1" + event-bus-service: <<: *service_defaults environment: @@ -228,6 +237,7 @@ services: - kb-service - reporting-service - supervisor-service + - sales-service - event-bus-service - asterisk-bridge-service environment: diff --git a/deployment/docker-compose.server.yml b/deployment/docker-compose.server.yml index d5a052c..cd683e1 100644 --- a/deployment/docker-compose.server.yml +++ b/deployment/docker-compose.server.yml @@ -22,6 +22,7 @@ x-app-env: &app_env KB_SERVICE_URL: http://kb-service:8000 REPORTING_SERVICE_URL: http://reporting-service:8000 SUPERVISOR_SERVICE_URL: http://supervisor-service:8000 + SALES_SERVICE_URL: http://sales-service:8000 AI_VOICE_ASR_PROVIDER: ${AI_VOICE_ASR_PROVIDER:-elevenlabs} AI_VOICE_TTS_PROVIDER: ${AI_VOICE_TTS_PROVIDER:-elevenlabs} AI_VOICE_V2_STREAMING_ASR_BACKEND: ${AI_VOICE_V2_STREAMING_ASR_BACKEND:-elevenlabs_realtime} @@ -193,6 +194,14 @@ services: <<: *app_env APP_MODULE: services.supervisor_service.app:app + sales-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.sales_service.app:app + SALES_TELEGRAM_BRIDGE_ENABLED: "1" + SALES_VOICE_RUNTIME_ENABLED: "1" + event-bus-service: <<: *service_defaults environment: @@ -230,6 +239,7 @@ services: - kb-service - reporting-service - supervisor-service + - sales-service - event-bus-service - asterisk-bridge-service environment: diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml index 40203e7..05f874a 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -295,6 +295,19 @@ services: ports: - "8010:8000" + sales-service: + build: .. + depends_on: [postgres, rabbitmq] + environment: + <<: *app_env + APP_MODULE: services.sales_service.app:app + SALES_TELEGRAM_BRIDGE_ENABLED: "1" + SALES_VOICE_RUNTIME_ENABLED: "1" + volumes: + - ../.data:/app/.data + ports: + - "8020:8000" + event-bus-service: build: .. depends_on: [postgres, rabbitmq] @@ -342,6 +355,7 @@ services: - kb-service - reporting-service - supervisor-service + - sales-service - event-bus-service - asterisk-bridge-service environment: @@ -364,6 +378,7 @@ services: KB_SERVICE_URL: http://kb-service:8000 REPORTING_SERVICE_URL: http://reporting-service:8000 SUPERVISOR_SERVICE_URL: http://supervisor-service:8000 + SALES_SERVICE_URL: http://sales-service:8000 ports: - "8080:8000" diff --git a/docs/architecture/real-time-voice-service.md b/docs/architecture/real-time-voice-service.md new file mode 100644 index 0000000..0ddca0a --- /dev/null +++ b/docs/architecture/real-time-voice-service.md @@ -0,0 +1,12 @@ +# Real-Time Voice Service Compatibility + +This project uses `services/ai_voice_runtime_service` as the canonical +real-time voice runtime implementation. + +To match integration naming used in external projects like +`realtime_voice_assistance`, a compatibility entrypoint is also available: + +- `services.real_time_voice.app:app` + +Both entrypoints reference the same FastAPI application object and behavior. + diff --git a/gateway/app.py b/gateway/app.py index c3e54cb..453ada9 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -118,6 +118,7 @@ _OPERATOR_UI_DIR = _ROOT / "ui" / "operator" _SUPERVISOR_UI_DIR = _ROOT / "ui" / "supervisor" _ANALYST_UI_DIR = _ROOT / "ui" / "analyst" _ADMIN_UI_DIR = _ROOT / "ui" / "admin" +_SALES_UI_DIR = _ROOT / "ui" / "sales" _IVR_PREVIEW_AUDIO_DIR = Path( os.getenv("IVR_PREVIEW_AUDIO_DIR", str(_ROOT / ".data_local" / "generated_ivr_yandex")) ).expanduser() @@ -131,6 +132,8 @@ if _ANALYST_UI_DIR.exists(): app.mount("/analyst/assets", StaticFiles(directory=str(_ANALYST_UI_DIR)), name="analyst_assets") if _ADMIN_UI_DIR.exists(): app.mount("/admin/assets", StaticFiles(directory=str(_ADMIN_UI_DIR)), name="admin_assets") +if _SALES_UI_DIR.exists(): + app.mount("/sales/assets", StaticFiles(directory=str(_SALES_UI_DIR)), name="sales_assets") if _IVR_PREVIEW_AUDIO_DIR.exists(): app.mount("/_preview_audio", StaticFiles(directory=str(_IVR_PREVIEW_AUDIO_DIR)), name="ivr_preview_audio") @@ -154,6 +157,7 @@ SERVICE_URLS = { "kb": os.getenv("KB_SERVICE_URL", "http://localhost:8008"), "reporting": os.getenv("REPORTING_SERVICE_URL", "http://localhost:8009"), "supervisor": os.getenv("SUPERVISOR_SERVICE_URL", "http://localhost:8010"), + "sales": os.getenv("SALES_SERVICE_URL", "http://localhost:8020"), } @@ -275,6 +279,14 @@ def contracts() -> dict: "/ai/voice/sessions/*", "/asterisk/live-calls/*/ai-summary", ], + "stage_18": [ + "/api/v1/leads", + "/api/v1/deals", + "/api/v1/messages/inbound-webhook", + "/api/v1/calls/inbound-webhook", + "/api/v1/deals/*/workspace", + "/api/v1/payments/webhook", + ], } @@ -306,6 +318,13 @@ def admin_ui() -> FileResponse: return FileResponse(_ADMIN_UI_DIR / "index.html") +@app.get("/sales") +def sales_ui() -> FileResponse: + if not _SALES_UI_DIR.exists(): + raise HTTPException(status_code=404, detail="Sales UI not found") + return FileResponse(_SALES_UI_DIR / "index.html") + + @app.get("/privacy") def privacy_page() -> HTMLResponse: return HTMLResponse(_LEGAL_HTML["privacy"]) diff --git a/migrations/sql/0023_sales_postgres.sql b/migrations/sql/0023_sales_postgres.sql new file mode 100644 index 0000000..0bcb147 --- /dev/null +++ b/migrations/sql/0023_sales_postgres.sql @@ -0,0 +1,345 @@ +CREATE TABLE IF NOT EXISTS sales_leads ( + id BIGSERIAL PRIMARY KEY, + lead_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + source_type TEXT NOT NULL, + source_channel TEXT NOT NULL, + source_campaign_id TEXT, + full_name TEXT NOT NULL, + company_name TEXT, + phone TEXT, + email TEXT, + messenger_handles_json TEXT NOT NULL DEFAULT '{}', + lead_temperature TEXT NOT NULL, + lead_score DOUBLE PRECISION NOT NULL DEFAULT 50, + customer_type TEXT, + segment_type TEXT, + initial_need_summary TEXT, + preferred_channel TEXT NOT NULL, + assigned_agent_type TEXT NOT NULL, + status TEXT NOT NULL, + crm_customer_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sales_leads_tenant_status_score ON sales_leads(tenant_id, status, lead_score); +CREATE INDEX IF NOT EXISTS idx_sales_leads_phone_email ON sales_leads(phone, email); + +CREATE TABLE IF NOT EXISTS sales_deals ( + id BIGSERIAL PRIMARY KEY, + deal_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + lead_id TEXT, + customer_id TEXT, + pipeline_id TEXT NOT NULL DEFAULT 'sales_default', + stage_id TEXT NOT NULL, + scenario_type TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 3, + title TEXT NOT NULL, + need_summary TEXT, + product_context_json TEXT NOT NULL DEFAULT '{}', + estimated_amount DOUBLE PRECISION, + final_amount DOUBLE PRECISION, + currency TEXT NOT NULL DEFAULT 'KZT', + payment_model TEXT, + document_required BOOLEAN NOT NULL DEFAULT TRUE, + payment_required BOOLEAN NOT NULL DEFAULT TRUE, + assigned_human_user_id TEXT, + assigned_ai_orchestrator_id TEXT, + preferred_channel TEXT NOT NULL, + current_channel TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + won_reason TEXT, + lost_reason TEXT, + close_reason TEXT, + next_action_type TEXT, + next_action_at TEXT, + last_contact_at TEXT, + closed_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sales_deals_tenant_stage_status ON sales_deals(tenant_id, stage_id, status); +CREATE INDEX IF NOT EXISTS idx_sales_deals_customer_channel ON sales_deals(customer_id, current_channel); +CREATE INDEX IF NOT EXISTS idx_sales_deals_next_action ON sales_deals(next_action_at, status); + +CREATE TABLE IF NOT EXISTS sales_communication_sessions ( + id BIGSERIAL PRIMARY KEY, + communication_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + lead_id TEXT, + customer_id TEXT, + channel_type TEXT NOT NULL, + direction TEXT NOT NULL, + agent_type TEXT NOT NULL, + started_at TEXT NOT NULL, + ended_at TEXT, + duration_sec INTEGER, + subject TEXT, + status TEXT NOT NULL DEFAULT 'active', + summary TEXT, + transcript_id TEXT, + next_action_type TEXT, + next_action_at TEXT, + sentiment TEXT, + result_code TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sales_comm_deal_started ON sales_communication_sessions(deal_id, started_at); +CREATE INDEX IF NOT EXISTS idx_sales_comm_channel_status ON sales_communication_sessions(channel_type, status); + +CREATE TABLE IF NOT EXISTS sales_messages ( + id BIGSERIAL PRIMARY KEY, + message_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + communication_id TEXT NOT NULL, + sender_type TEXT NOT NULL, + sender_id TEXT, + channel_provider TEXT NOT NULL, + external_message_id TEXT, + body TEXT NOT NULL, + attachments_json TEXT NOT NULL DEFAULT '[]', + delivery_status TEXT, + read_status TEXT, + message_metadata_json TEXT NOT NULL DEFAULT '{}', + sent_at TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sales_messages_deal_sent ON sales_messages(deal_id, sent_at); +CREATE INDEX IF NOT EXISTS idx_sales_messages_ext_id ON sales_messages(channel_provider, external_message_id); + +CREATE TABLE IF NOT EXISTS sales_calls ( + id BIGSERIAL PRIMARY KEY, + call_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + communication_id TEXT NOT NULL, + phone_number TEXT NOT NULL, + direction TEXT NOT NULL, + provider TEXT NOT NULL, + external_call_id TEXT, + recording_url TEXT, + transcript_status TEXT NOT NULL DEFAULT 'pending', + transcript_id TEXT, + call_status TEXT NOT NULL DEFAULT 'started', + summary TEXT, + started_at TEXT NOT NULL, + ended_at TEXT, + duration_sec INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sales_calls_deal_started ON sales_calls(deal_id, started_at); +CREATE INDEX IF NOT EXISTS idx_sales_calls_ext_provider ON sales_calls(provider, external_call_id); + +CREATE TABLE IF NOT EXISTS sales_transcripts ( + id BIGSERIAL PRIMARY KEY, + transcript_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + call_id TEXT NOT NULL, + language TEXT NOT NULL DEFAULT 'ru', + transcript_text TEXT NOT NULL, + diarization_json TEXT NOT NULL DEFAULT '{}', + extracted_entities_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS sales_notes ( + id BIGSERIAL PRIMARY KEY, + note_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + author_type TEXT NOT NULL, + author_id TEXT, + note_type TEXT NOT NULL, + content TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS sales_offers ( + id BIGSERIAL PRIMARY KEY, + offer_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + offer_type TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + line_items_json TEXT NOT NULL DEFAULT '[]', + pricing_json TEXT NOT NULL DEFAULT '{}', + total_amount DOUBLE PRECISION NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT 'KZT', + validity_until TEXT, + status TEXT NOT NULL DEFAULT 'draft', + rendered_document_url TEXT, + created_by_type TEXT NOT NULL DEFAULT 'text_ai', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sales_offers_deal_status ON sales_offers(deal_id, status); + +CREATE TABLE IF NOT EXISTS sales_conditions ( + id BIGSERIAL PRIMARY KEY, + condition_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + product_name TEXT, + service_name TEXT, + quantity DOUBLE PRECISION, + unit TEXT, + delivery_mode TEXT, + execution_date TEXT, + start_date TEXT, + end_date TEXT, + payment_terms TEXT, + custom_terms_json TEXT NOT NULL DEFAULT '{}', + agreed_price DOUBLE PRECISION, + currency TEXT NOT NULL DEFAULT 'KZT', + confirmed_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS sales_counterparties ( + id BIGSERIAL PRIMARY KEY, + counterparty_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL UNIQUE, + customer_id TEXT, + company_name TEXT, + full_name TEXT, + bin_iin TEXT, + address TEXT, + bank_details_json TEXT NOT NULL DEFAULT '{}', + signer_name TEXT, + signer_role TEXT, + signer_basis TEXT, + email_for_docs TEXT, + phone_for_docs TEXT, + completeness_status TEXT NOT NULL DEFAULT 'draft', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS sales_documents ( + id BIGSERIAL PRIMARY KEY, + document_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + customer_id TEXT, + document_type TEXT NOT NULL, + template_id TEXT, + version INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'draft', + file_url TEXT, + rendered_payload_json TEXT NOT NULL DEFAULT '{}', + external_sign_provider_id TEXT, + signed_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sales_documents_deal_status ON sales_documents(deal_id, status); + +CREATE TABLE IF NOT EXISTS sales_invoices ( + id BIGSERIAL PRIMARY KEY, + invoice_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + customer_id TEXT, + invoice_number TEXT NOT NULL UNIQUE, + basis_document_id TEXT, + amount DOUBLE PRECISION NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT 'KZT', + due_date TEXT, + status TEXT NOT NULL DEFAULT 'draft', + payment_link TEXT, + line_items_json TEXT NOT NULL DEFAULT '[]', + metadata_json TEXT NOT NULL DEFAULT '{}', + issued_at TEXT, + paid_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sales_invoices_deal_status_due ON sales_invoices(deal_id, status, due_date); + +CREATE TABLE IF NOT EXISTS sales_payments ( + id BIGSERIAL PRIMARY KEY, + payment_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + invoice_id TEXT, + payment_provider TEXT, + external_payment_id TEXT, + amount DOUBLE PRECISION NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT 'KZT', + status TEXT NOT NULL DEFAULT 'pending', + paid_at TEXT, + payment_method TEXT, + failure_reason TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sales_payments_deal_status ON sales_payments(deal_id, status); + +CREATE TABLE IF NOT EXISTS sales_stage_history ( + id BIGSERIAL PRIMARY KEY, + history_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + from_stage_id TEXT, + to_stage_id TEXT NOT NULL, + changed_by_type TEXT NOT NULL, + changed_by_id TEXT, + reason TEXT, + changed_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sales_stage_history_deal_changed ON sales_stage_history(deal_id, changed_at); + +CREATE TABLE IF NOT EXISTS sales_escalations ( + id BIGSERIAL PRIMARY KEY, + escalation_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + escalation_type TEXT NOT NULL, + reason TEXT NOT NULL, + severity TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + assigned_to_user_id TEXT, + created_at TEXT NOT NULL, + resolved_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_sales_escalations_deal_status ON sales_escalations(deal_id, status); + +CREATE TABLE IF NOT EXISTS sales_automation_tasks ( + id BIGSERIAL PRIMARY KEY, + task_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + task_type TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + run_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + retry_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sales_tasks_run_status ON sales_automation_tasks(run_at, status); + +CREATE TABLE IF NOT EXISTS sales_channel_switches ( + id BIGSERIAL PRIMARY KEY, + switch_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + communication_id TEXT, + from_channel TEXT NOT NULL, + to_channel TEXT NOT NULL, + reason_for_channel_switch TEXT NOT NULL, + switched_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sales_channel_switches_deal_switched ON sales_channel_switches(deal_id, switched_at); diff --git a/migrations/sql/0023_sales_sqlite.sql b/migrations/sql/0023_sales_sqlite.sql new file mode 100644 index 0000000..fd27735 --- /dev/null +++ b/migrations/sql/0023_sales_sqlite.sql @@ -0,0 +1,345 @@ +CREATE TABLE IF NOT EXISTS sales_leads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + lead_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + source_type TEXT NOT NULL, + source_channel TEXT NOT NULL, + source_campaign_id TEXT, + full_name TEXT NOT NULL, + company_name TEXT, + phone TEXT, + email TEXT, + messenger_handles_json TEXT NOT NULL DEFAULT '{}', + lead_temperature TEXT NOT NULL, + lead_score REAL NOT NULL DEFAULT 50, + customer_type TEXT, + segment_type TEXT, + initial_need_summary TEXT, + preferred_channel TEXT NOT NULL, + assigned_agent_type TEXT NOT NULL, + status TEXT NOT NULL, + crm_customer_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sales_leads_tenant_status_score ON sales_leads(tenant_id, status, lead_score); +CREATE INDEX IF NOT EXISTS idx_sales_leads_phone_email ON sales_leads(phone, email); + +CREATE TABLE IF NOT EXISTS sales_deals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + deal_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + lead_id TEXT, + customer_id TEXT, + pipeline_id TEXT NOT NULL DEFAULT 'sales_default', + stage_id TEXT NOT NULL, + scenario_type TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 3, + title TEXT NOT NULL, + need_summary TEXT, + product_context_json TEXT NOT NULL DEFAULT '{}', + estimated_amount REAL, + final_amount REAL, + currency TEXT NOT NULL DEFAULT 'KZT', + payment_model TEXT, + document_required INTEGER NOT NULL DEFAULT 1, + payment_required INTEGER NOT NULL DEFAULT 1, + assigned_human_user_id TEXT, + assigned_ai_orchestrator_id TEXT, + preferred_channel TEXT NOT NULL, + current_channel TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + won_reason TEXT, + lost_reason TEXT, + close_reason TEXT, + next_action_type TEXT, + next_action_at TEXT, + last_contact_at TEXT, + closed_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sales_deals_tenant_stage_status ON sales_deals(tenant_id, stage_id, status); +CREATE INDEX IF NOT EXISTS idx_sales_deals_customer_channel ON sales_deals(customer_id, current_channel); +CREATE INDEX IF NOT EXISTS idx_sales_deals_next_action ON sales_deals(next_action_at, status); + +CREATE TABLE IF NOT EXISTS sales_communication_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + communication_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + lead_id TEXT, + customer_id TEXT, + channel_type TEXT NOT NULL, + direction TEXT NOT NULL, + agent_type TEXT NOT NULL, + started_at TEXT NOT NULL, + ended_at TEXT, + duration_sec INTEGER, + subject TEXT, + status TEXT NOT NULL DEFAULT 'active', + summary TEXT, + transcript_id TEXT, + next_action_type TEXT, + next_action_at TEXT, + sentiment TEXT, + result_code TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sales_comm_deal_started ON sales_communication_sessions(deal_id, started_at); +CREATE INDEX IF NOT EXISTS idx_sales_comm_channel_status ON sales_communication_sessions(channel_type, status); + +CREATE TABLE IF NOT EXISTS sales_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + communication_id TEXT NOT NULL, + sender_type TEXT NOT NULL, + sender_id TEXT, + channel_provider TEXT NOT NULL, + external_message_id TEXT, + body TEXT NOT NULL, + attachments_json TEXT NOT NULL DEFAULT '[]', + delivery_status TEXT, + read_status TEXT, + message_metadata_json TEXT NOT NULL DEFAULT '{}', + sent_at TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sales_messages_deal_sent ON sales_messages(deal_id, sent_at); +CREATE INDEX IF NOT EXISTS idx_sales_messages_ext_id ON sales_messages(channel_provider, external_message_id); + +CREATE TABLE IF NOT EXISTS sales_calls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + call_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + communication_id TEXT NOT NULL, + phone_number TEXT NOT NULL, + direction TEXT NOT NULL, + provider TEXT NOT NULL, + external_call_id TEXT, + recording_url TEXT, + transcript_status TEXT NOT NULL DEFAULT 'pending', + transcript_id TEXT, + call_status TEXT NOT NULL DEFAULT 'started', + summary TEXT, + started_at TEXT NOT NULL, + ended_at TEXT, + duration_sec INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sales_calls_deal_started ON sales_calls(deal_id, started_at); +CREATE INDEX IF NOT EXISTS idx_sales_calls_ext_provider ON sales_calls(provider, external_call_id); + +CREATE TABLE IF NOT EXISTS sales_transcripts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + transcript_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + call_id TEXT NOT NULL, + language TEXT NOT NULL DEFAULT 'ru', + transcript_text TEXT NOT NULL, + diarization_json TEXT NOT NULL DEFAULT '{}', + extracted_entities_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS sales_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + note_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + author_type TEXT NOT NULL, + author_id TEXT, + note_type TEXT NOT NULL, + content TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS sales_offers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + offer_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + offer_type TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + line_items_json TEXT NOT NULL DEFAULT '[]', + pricing_json TEXT NOT NULL DEFAULT '{}', + total_amount REAL NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT 'KZT', + validity_until TEXT, + status TEXT NOT NULL DEFAULT 'draft', + rendered_document_url TEXT, + created_by_type TEXT NOT NULL DEFAULT 'text_ai', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sales_offers_deal_status ON sales_offers(deal_id, status); + +CREATE TABLE IF NOT EXISTS sales_conditions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + condition_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + product_name TEXT, + service_name TEXT, + quantity REAL, + unit TEXT, + delivery_mode TEXT, + execution_date TEXT, + start_date TEXT, + end_date TEXT, + payment_terms TEXT, + custom_terms_json TEXT NOT NULL DEFAULT '{}', + agreed_price REAL, + currency TEXT NOT NULL DEFAULT 'KZT', + confirmed_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS sales_counterparties ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + counterparty_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL UNIQUE, + customer_id TEXT, + company_name TEXT, + full_name TEXT, + bin_iin TEXT, + address TEXT, + bank_details_json TEXT NOT NULL DEFAULT '{}', + signer_name TEXT, + signer_role TEXT, + signer_basis TEXT, + email_for_docs TEXT, + phone_for_docs TEXT, + completeness_status TEXT NOT NULL DEFAULT 'draft', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS sales_documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + document_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + customer_id TEXT, + document_type TEXT NOT NULL, + template_id TEXT, + version INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'draft', + file_url TEXT, + rendered_payload_json TEXT NOT NULL DEFAULT '{}', + external_sign_provider_id TEXT, + signed_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sales_documents_deal_status ON sales_documents(deal_id, status); + +CREATE TABLE IF NOT EXISTS sales_invoices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + invoice_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + customer_id TEXT, + invoice_number TEXT NOT NULL UNIQUE, + basis_document_id TEXT, + amount REAL NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT 'KZT', + due_date TEXT, + status TEXT NOT NULL DEFAULT 'draft', + payment_link TEXT, + line_items_json TEXT NOT NULL DEFAULT '[]', + metadata_json TEXT NOT NULL DEFAULT '{}', + issued_at TEXT, + paid_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sales_invoices_deal_status_due ON sales_invoices(deal_id, status, due_date); + +CREATE TABLE IF NOT EXISTS sales_payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + payment_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + invoice_id TEXT, + payment_provider TEXT, + external_payment_id TEXT, + amount REAL NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT 'KZT', + status TEXT NOT NULL DEFAULT 'pending', + paid_at TEXT, + payment_method TEXT, + failure_reason TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sales_payments_deal_status ON sales_payments(deal_id, status); + +CREATE TABLE IF NOT EXISTS sales_stage_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + history_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + from_stage_id TEXT, + to_stage_id TEXT NOT NULL, + changed_by_type TEXT NOT NULL, + changed_by_id TEXT, + reason TEXT, + changed_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sales_stage_history_deal_changed ON sales_stage_history(deal_id, changed_at); + +CREATE TABLE IF NOT EXISTS sales_escalations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + escalation_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + escalation_type TEXT NOT NULL, + reason TEXT NOT NULL, + severity TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + assigned_to_user_id TEXT, + created_at TEXT NOT NULL, + resolved_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_sales_escalations_deal_status ON sales_escalations(deal_id, status); + +CREATE TABLE IF NOT EXISTS sales_automation_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + task_type TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + run_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + retry_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sales_tasks_run_status ON sales_automation_tasks(run_at, status); + +CREATE TABLE IF NOT EXISTS sales_channel_switches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + switch_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + communication_id TEXT, + from_channel TEXT NOT NULL, + to_channel TEXT NOT NULL, + reason_for_channel_switch TEXT NOT NULL, + switched_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sales_channel_switches_deal_switched ON sales_channel_switches(deal_id, switched_at); diff --git a/migrations/sql/0024_sales_external_links_postgres.sql b/migrations/sql/0024_sales_external_links_postgres.sql new file mode 100644 index 0000000..9c89cd7 --- /dev/null +++ b/migrations/sql/0024_sales_external_links_postgres.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS sales_external_links ( + id BIGSERIAL PRIMARY KEY, + link_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + communication_id TEXT, + sales_call_id TEXT, + channel_provider TEXT NOT NULL, + external_thread_id TEXT, + external_chat_id TEXT, + external_call_id TEXT, + voice_session_id TEXT, + ai_session_id TEXT, + interaction_id TEXT, + customer_id TEXT, + phone_number TEXT, + external_status TEXT, + link_metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_sync_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_sales_external_links_deal_sync ON sales_external_links(deal_id, last_sync_at); +CREATE INDEX IF NOT EXISTS idx_sales_external_links_thread ON sales_external_links(external_thread_id); +CREATE INDEX IF NOT EXISTS idx_sales_external_links_voice_session ON sales_external_links(voice_session_id); +CREATE INDEX IF NOT EXISTS idx_sales_external_links_external_call ON sales_external_links(external_call_id); +CREATE INDEX IF NOT EXISTS idx_sales_external_links_interaction ON sales_external_links(interaction_id); +CREATE INDEX IF NOT EXISTS idx_sales_external_links_customer_phone ON sales_external_links(customer_id, phone_number); diff --git a/migrations/sql/0024_sales_external_links_sqlite.sql b/migrations/sql/0024_sales_external_links_sqlite.sql new file mode 100644 index 0000000..78b3d3d --- /dev/null +++ b/migrations/sql/0024_sales_external_links_sqlite.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS sales_external_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + link_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + communication_id TEXT, + sales_call_id TEXT, + channel_provider TEXT NOT NULL, + external_thread_id TEXT, + external_chat_id TEXT, + external_call_id TEXT, + voice_session_id TEXT, + ai_session_id TEXT, + interaction_id TEXT, + customer_id TEXT, + phone_number TEXT, + external_status TEXT, + link_metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_sync_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_sales_external_links_deal_sync ON sales_external_links(deal_id, last_sync_at); +CREATE INDEX IF NOT EXISTS idx_sales_external_links_thread ON sales_external_links(external_thread_id); +CREATE INDEX IF NOT EXISTS idx_sales_external_links_voice_session ON sales_external_links(voice_session_id); +CREATE INDEX IF NOT EXISTS idx_sales_external_links_external_call ON sales_external_links(external_call_id); +CREATE INDEX IF NOT EXISTS idx_sales_external_links_interaction ON sales_external_links(interaction_id); +CREATE INDEX IF NOT EXISTS idx_sales_external_links_customer_phone ON sales_external_links(customer_id, phone_number); diff --git a/scripts/local_stack.py b/scripts/local_stack.py index 881a09c..ef0555b 100644 --- a/scripts/local_stack.py +++ b/scripts/local_stack.py @@ -95,6 +95,7 @@ def build_service_specs(env: dict[str, str] | None = None) -> list[dict[str, Any {"name": "kb", "port": 8008, "module": "services.kb_service.app:app"}, {"name": "reporting", "port": 8009, "module": "services.reporting_service.app:app"}, {"name": "supervisor", "port": 8010, "module": "services.supervisor_service.app:app"}, + {"name": "sales", "port": 8020, "module": "services.sales_service.app:app"}, {"name": "gateway", "port": gateway_port, "module": "gateway.app:app"}, ] @@ -121,6 +122,7 @@ def build_gateway_env(env: dict[str, str] | None = None) -> dict[str, str]: "KB_SERVICE_URL": f"{base}:{ports['kb']}", "REPORTING_SERVICE_URL": f"{base}:{ports['reporting']}", "SUPERVISOR_SERVICE_URL": f"{base}:{ports['supervisor']}", + "SALES_SERVICE_URL": f"{base}:{ports['sales']}", } @@ -338,6 +340,7 @@ def spawn_service(spec: dict[str, Any], runtime_dir: Path, data_dir: Path, base_ "AI_VOICE_RUNTIME_TRUSTED_SERVICE_SUBJECTS", "svc:ai-voice-runtime", ) + env.update(build_gateway_env(env)) env["ASTERISK_CALLCONTROL_CLAIM_CONTEXT"] = env.get("ASTERISK_CALLCONTROL_CLAIM_CONTEXT", "mvpcc-claim") env["ASTERISK_CALLCONTROL_TRANSFER_CONTEXT"] = env.get( "ASTERISK_CALLCONTROL_TRANSFER_CONTEXT", @@ -348,8 +351,6 @@ def spawn_service(spec: dict[str, Any], runtime_dir: Path, data_dir: Path, base_ env["RECORDING_MAX_BYTES"] = env.get("RECORDING_MAX_BYTES", "26214400") if spec["name"] == "streaming-asr-sidecar": Path(env["AI_VOICE_V2_STREAMING_ASR_CACHE_DIR"]).mkdir(parents=True, exist_ok=True) - if spec["name"] == "gateway": - env.update(build_gateway_env(env)) stdout_path, stderr_path = create_log_paths(runtime_dir, str(spec["name"])) creationflags = 0 diff --git a/services/ai_voice_runtime_service/app.py b/services/ai_voice_runtime_service/app.py index 3c3caf9..446e36b 100644 --- a/services/ai_voice_runtime_service/app.py +++ b/services/ai_voice_runtime_service/app.py @@ -83,6 +83,10 @@ def _interaction_service_url() -> str: return os.getenv("INTERACTION_SERVICE_URL", "http://localhost:8004").rstrip("/") +def _sales_service_url() -> str: + return os.getenv("SALES_SERVICE_URL", "http://localhost:8020").rstrip("/") + + def _asr_provider_name() -> str: return os.getenv("AI_VOICE_ASR_PROVIDER", "yandex").strip() or "yandex" @@ -266,6 +270,10 @@ def _interaction_request(method: str, path: str, *, payload: dict[str, Any] | No return _request(method, f"{_interaction_service_url()}{path}", payload=payload, timeout=timeout) +def _sales_request(method: str, path: str, *, payload: dict[str, Any] | None = None, timeout: float = 8.0) -> dict[str, Any]: + return _request(method, f"{_sales_service_url()}{path}", payload=payload, timeout=timeout) + + def _resolved_voice_language(language_hint: str | None, fallback: str | None = None) -> str: return str(language_hint or fallback or "ru").strip() or "ru" @@ -302,6 +310,136 @@ def _load_voice_session(session, session_id: str) -> VoiceAISessionRow: return row +def _load_call_link(session, call_id: str) -> AsteriskCallLinkRow | None: + return session.execute( + select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == call_id) + ).scalar_one_or_none() + + +def _latest_caller_transcript_text(session, session_id: str) -> str | None: + row = session.execute( + select(VoiceTranscriptSegmentRow) + .where(VoiceTranscriptSegmentRow.session_id == session_id) + .where(VoiceTranscriptSegmentRow.speaker == "caller") + .where(VoiceTranscriptSegmentRow.is_final.is_(True)) + .order_by(VoiceTranscriptSegmentRow.sequence_no.desc()) + .limit(1) + ).scalar_one_or_none() + if row is None: + return None + text = str(row.text or "").strip() + return text or None + + +def _sales_transcript_snapshot(session, session_id: str, *, max_chars: int = 6000) -> str | None: + rows = session.execute( + select(VoiceTranscriptSegmentRow) + .where(VoiceTranscriptSegmentRow.session_id == session_id) + .where(VoiceTranscriptSegmentRow.is_final.is_(True)) + .order_by(VoiceTranscriptSegmentRow.sequence_no.asc()) + ).scalars().all() + lines: list[str] = [] + for row in rows: + text = str(row.text or "").strip() + if not text: + continue + speaker = "customer" if str(row.speaker or "").strip() == "caller" else str(row.speaker or "assistant").strip() + lines.append(f"{speaker}: {text}") + if not lines: + return None + selected: list[str] = [] + total = 0 + for line in reversed(lines): + line_len = len(line) + (1 if selected else 0) + if selected and total + line_len > max_chars: + break + if not selected and len(line) > max_chars: + selected.append(line[-max_chars:]) + total = len(selected[0]) + break + selected.append(line) + total += line_len + return "\n".join(reversed(selected)) or None + + +def _sync_sales_voice_session( + session_id: str, + *, + caller_number: str | None = None, + caller_name: str | None = None, + queue_code: str | None = None, + summary: str | None = None, + include_transcript: bool = False, + metadata: dict[str, Any] | None = None, +) -> None: + session = get_session() + try: + voice_session = _load_voice_session(session, session_id) + link = _load_call_link(session, voice_session.call_id) + resolved_summary = ( + str(summary or "").strip() + or _latest_caller_transcript_text(session, session_id) + or str(voice_session.handoff_reason or "").strip() + or None + ) + transcript_text = _sales_transcript_snapshot(session, session_id) if include_transcript else None + payload = { + "call_id": voice_session.call_id, + "interaction_id": str(voice_session.interaction_id or getattr(link, "interaction_id", "") or "").strip() or None, + "queue_id": str(voice_session.queue_id or getattr(link, "queue_id", "") or "").strip() or None, + "queue_code": str(getattr(link, "queue_code", "") or queue_code or "").strip() or None, + "caller_number": str(getattr(link, "caller_number", "") or caller_number or "").strip() or None, + "caller_name": str(getattr(link, "caller_name", "") or caller_name or "").strip() or None, + "voice_session_id": voice_session.session_id, + "ai_session_id": voice_session.ai_session_id, + "ai_state": voice_session.status, + "handoff_reason": voice_session.handoff_reason, + "telephony_status": str(getattr(link, "telephony_status", "") or voice_session.media_status or "").strip() or None, + "call_status": str(getattr(link, "status", "") or voice_session.status or "").strip() or None, + "started_at": voice_session.started_at, + "ended_at": voice_session.ended_at, + "summary": resolved_summary, + "transcript_text": transcript_text, + "metadata": { + "linked_id": voice_session.linked_id, + "agent_profile": voice_session.agent_profile, + "language": voice_session.language, + "asr_provider": voice_session.asr_provider, + "tts_provider": voice_session.tts_provider, + "handoff_target_queue_id": voice_session.handoff_target_queue_id, + "media_status": voice_session.media_status, + "media_uuid": voice_session.media_uuid, + "media_connected_at": voice_session.media_connected_at, + "media_ended_at": voice_session.media_ended_at, + "last_media_frame_at": voice_session.last_media_frame_at, + "last_user_utterance_at": voice_session.last_user_utterance_at, + "last_ai_reply_at": voice_session.last_ai_reply_at, + "voice_start_language": voice_session.voice_start_language, + "customer_name_status": voice_session.customer_name_status, + "customer_name_value": voice_session.customer_name_value, + "customer_name_source": voice_session.customer_name_source, + "customer_name_resolved_at": voice_session.customer_name_resolved_at, + "claimed_by_user": getattr(link, "claimed_by_user", None), + "claimed_at": getattr(link, "claimed_at", None), + "operator_extension": getattr(link, "operator_extension", None), + "channel_name": getattr(link, "channel_name", None), + **(metadata or {}), + }, + } + finally: + session.close() + + try: + _sales_request("POST", "/internal/sales-sync/voice", payload=payload, timeout=8.0) + except Exception: + LOGGER.exception( + "voice_runtime.sales_sync_failed session_id=%s call_id=%s ai_state=%s", + payload.get("voice_session_id"), + payload.get("call_id"), + payload.get("ai_state"), + ) + + def _next_sequence(session, session_id: str) -> int: return next_transcript_sequence(session, session_id) @@ -1007,6 +1145,11 @@ def _set_voice_session_state( handoff_reason=handoff_reason, metadata=metadata, ) + _sync_sales_voice_session( + voice_session.session_id, + include_transcript=ai_state in {"handoff_requested", "human_owned", "completed", "closed", "error"}, + metadata=metadata, + ) if ai_state == "error": _append_interaction_timeline( voice_session.interaction_id, @@ -1150,6 +1293,11 @@ def _handle_media_error(session_id: str, message: str, metadata: dict[str, Any] ) except Exception: pass + _sync_sales_voice_session( + voice_session.session_id, + include_transcript=True, + metadata=metadata, + ) finally: session.close() @@ -1235,6 +1383,13 @@ def _process_voice_ai_turn_sync( session.commit() if auto_handoff and decision.needs_handoff and not early_plan_only: _request_runtime_handoff(session_id, payload.transcript_text, decision) + if not early_plan_only: + _sync_sales_voice_session( + session_id, + include_transcript=True, + summary=decision.summary_text or decision.reply_text or payload.transcript_text, + metadata=decision.metadata, + ) return decision except HTTPException: raise @@ -1331,6 +1486,11 @@ def _complete_voice_ai_session_start(session_id: str, start_payload: VoiceAIStar handoff_reason=voice_session.handoff_reason, metadata=handoff_metadata, ) + _sync_sales_voice_session( + voice_session.session_id, + include_transcript=False, + metadata=handoff_metadata, + ) if needs_handoff: handoff_summary = { "customer_request_text": "voice_start", @@ -1502,6 +1662,14 @@ def create_voice_ai_session( ) _register_voice_start_thread(voice_start_thread) voice_start_thread.start() + _sync_sales_voice_session( + voice_session.session_id, + caller_number=payload.caller_number, + caller_name=payload.caller_name, + queue_code=str(payload_metadata.get("queue_code") or "").strip() or None, + include_transcript=False, + metadata=payload_metadata, + ) return VoiceAISessionOut( voice_session_id=voice_session.session_id, ai_session_id=voice_session.ai_session_id, @@ -1574,6 +1742,11 @@ def push_voice_ai_telephony_event( ) except Exception: pass + _sync_sales_voice_session( + session_id, + include_transcript=payload.event_type in {"operator.connected", "call.ended"}, + metadata={"telephony_event_type": payload.event_type, **(payload.payload or {})}, + ) return VoiceAISessionOut( voice_session_id=session_id, diff --git a/services/ai_voice_runtime_service/audiosocket.py b/services/ai_voice_runtime_service/audiosocket.py index 3beb919..d65d76a 100644 --- a/services/ai_voice_runtime_service/audiosocket.py +++ b/services/ai_voice_runtime_service/audiosocket.py @@ -1,13 +1,14 @@ from __future__ import annotations import asyncio -import audioop import io import struct import uuid import wave from dataclasses import dataclass +from services.shared.audioop_compat import audioop + AUDIO_SOCKET_PACKET_HANGUP = 0x00 AUDIO_SOCKET_PACKET_UUID = 0x01 diff --git a/services/ai_voice_runtime_service/media_runtime.py b/services/ai_voice_runtime_service/media_runtime.py index fd88dd5..40cfa6f 100644 --- a/services/ai_voice_runtime_service/media_runtime.py +++ b/services/ai_voice_runtime_service/media_runtime.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import audioop import contextlib import hashlib import logging @@ -33,6 +32,7 @@ from services.ai_voice_runtime_service.providers.asr import ( StreamingASRProvider, StreamingASRUnavailable, ) +from services.shared.audioop_compat import audioop from services.ai_voice_runtime_service.providers.tts import TTSProvider from services.shared.models import VoiceAITurnDecisionOut diff --git a/services/ai_voice_runtime_service/providers/asr.py b/services/ai_voice_runtime_service/providers/asr.py index 7f57e43..8b6eebe 100644 --- a/services/ai_voice_runtime_service/providers/asr.py +++ b/services/ai_voice_runtime_service/providers/asr.py @@ -1,6 +1,5 @@ from __future__ import annotations -import audioop import base64 import io import json @@ -17,6 +16,7 @@ from dataclasses import dataclass, field import httpx from services.ai_voice_runtime_service.audiosocket import pcm16le_to_wav_bytes, resample_pcm16le +from services.shared.audioop_compat import audioop from services.shared.security import issue_app_token diff --git a/services/real_time_voice/__init__.py b/services/real_time_voice/__init__.py new file mode 100644 index 0000000..95b39e1 --- /dev/null +++ b/services/real_time_voice/__init__.py @@ -0,0 +1,2 @@ +"""Compatibility package for real-time voice runtime service.""" + diff --git a/services/real_time_voice/app.py b/services/real_time_voice/app.py new file mode 100644 index 0000000..25d44c2 --- /dev/null +++ b/services/real_time_voice/app.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +""" +Compatibility entrypoint for the real-time voice service. + +This module allows running the voice runtime under: +`services.real_time_voice.app:app` +while reusing the canonical implementation from: +`services.ai_voice_runtime_service.app:app`. +""" + +from services.ai_voice_runtime_service.app import app + +__all__ = ["app"] + diff --git a/services/sales_service/__init__.py b/services/sales_service/__init__.py new file mode 100644 index 0000000..885903e --- /dev/null +++ b/services/sales_service/__init__.py @@ -0,0 +1 @@ +"""Sales service package.""" diff --git a/services/sales_service/app.py b/services/sales_service/app.py new file mode 100644 index 0000000..8c6187d --- /dev/null +++ b/services/sales_service/app.py @@ -0,0 +1,3319 @@ +from __future__ import annotations + +import json +import os +from datetime import datetime, timezone +from typing import Any + +import httpx +from fastapi import Depends, FastAPI, HTTPException, Query +from sqlalchemy import func, or_, select + +from services.shared.core import Role, new_id, utc_now_iso +from services.shared.db import get_session +from services.shared.models import HealthResponse +from services.shared.sales_models import ( + SalesAutomationTaskOut, + SalesCallCompleteIn, + SalesCallOut, + SalesCallWebhookIn, + SalesCommunicationBindExternalIn, + SalesChannelSwitchOut, + SalesCommunicationOut, + SalesCommunicationStartIn, + SalesCommunicationSummaryIn, + SalesCommunicationSwitchChannelIn, + SalesConditionOut, + SalesConditionUpsertIn, + SalesCounterpartyOut, + SalesCounterpartyUpsertIn, + SalesDashboardStageOut, + SalesDashboardSummaryOut, + SalesDealCloseIn, + SalesDealCreate, + SalesDealNextActionIn, + SalesDealOut, + SalesDealScenarioIn, + SalesDealStageChangeIn, + SalesDealUpdate, + SalesDocumentCreate, + SalesDocumentOut, + SalesEscalationCreateIn, + SalesEscalationOut, + SalesInvoiceCreate, + SalesInvoiceOut, + SalesLeadCreate, + SalesLeadEnrichIn, + SalesLeadOut, + SalesLeadUpdate, + SalesMessageOut, + SalesMessageSendIn, + SalesMessageWebhookIn, + SalesOfferCreate, + SalesOfferOut, + SalesPaymentOut, + SalesPaymentReconcileIn, + SalesPaymentWebhookIn, + SalesStageHistoryOut, + SalesTelegramSyncIn, + SalesTimelineEventOut, + SalesTranscriptIn, + SalesTranscriptOut, + SalesVoiceSyncIn, + SalesWorkspaceOut, +) +from services.shared.security import issue_app_token, require_roles +from services.shared.sql_init import init_sql_schema +from services.shared.sql_models import Customer +from services.shared.sales_sql_models import ( + SalesAutomationTaskRow, + SalesCallRow, + SalesChannelSwitchRow, + SalesCommunicationSessionRow, + SalesConditionRow, + SalesCounterpartyRow, + SalesDealRow, + SalesDocumentRow, + SalesEscalationRow, + SalesExternalLinkRow, + SalesInvoiceRow, + SalesLeadRow, + SalesMessageRow, + SalesOfferRow, + SalesPaymentRow, + SalesStageHistoryRow, + SalesTranscriptRow, +) + +# Import the models module so sales tables are registered on the shared Base metadata. +from services.shared import sales_sql_models as _sales_sql_models # noqa: F401 + +app = FastAPI(title="sales-service", version="1.0.0") + +init_sql_schema() + +DEFAULT_TENANT_ID = os.getenv("DEFAULT_TENANT_ID", "tenant_default").strip() or "tenant_default" +DEFAULT_SALES_PIPELINE_ID = os.getenv("DEFAULT_SALES_PIPELINE_ID", "sales_default").strip() or "sales_default" +DEFAULT_SALES_CURRENCY = os.getenv("DEFAULT_SALES_CURRENCY", "KZT").strip() or "KZT" + +STAGE_LABELS = { + "new_qualified_lead": "Новый квалифицированный лид", + "warm_lead": "Теплый лид", + "hot_lead": "Горячий лид", + "enrichment_required": "Нужно дообогащение", + "active_text_communication": "Текстовая коммуникация", + "active_voice_communication": "Голосовая коммуникация", + "waiting_customer_reply": "Ждем ответ клиента", + "need_clarification": "Нужно уточнение", + "need_confirmed": "Потребность подтверждена", + "offer_selection": "Подбор предложения", + "offer_preparing": "Готовим предложение", + "offer_sent": "Предложение отправлено", + "conditions_negotiation": "Согласование условий", + "counterparty_data_requested": "Запрос реквизитов", + "counterparty_data_received": "Реквизиты получены", + "document_preparing": "Готовим документ", + "document_sent": "Документ отправлен", + "document_under_review": "Документ на согласовании", + "document_confirmed": "Документ подтвержден", + "invoice_preparing": "Готовим счет", + "invoice_sent": "Счет отправлен", + "payment_expected": "Ожидаем оплату", + "partially_paid": "Оплачено частично", + "paid": "Оплачено", + "payment_overdue": "Оплата просрочена", + "won": "Сделка выиграна", + "lost": "Сделка проиграна", + "postponed": "Отложено", + "follow_up_scheduled": "Запланирован follow-up", + "transferred_to_execution": "Передано в исполнение", + "transferred_to_support": "Передано человеку", +} + + +def _tenant_id() -> str: + return DEFAULT_TENANT_ID + + +def _now() -> datetime: + return datetime.now(timezone.utc).replace(microsecond=0) + + +def _json_dict(raw: str | None) -> dict: + try: + payload = json.loads(raw or "{}") + except json.JSONDecodeError: + return {} + return payload if isinstance(payload, dict) else {} + + +def _json_list(raw: str | None) -> list: + try: + payload = json.loads(raw or "[]") + except json.JSONDecodeError: + return [] + return payload if isinstance(payload, list) else [] + + +def _service_headers(subject: str, username: str, provider: str) -> dict[str, str]: + token = issue_app_token( + subject=subject, + username=username, + role="admin", + auth_source="service", + provider=provider, + ttl_seconds=300, + ) + return {"Authorization": f"Bearer {token}"} + + +def _bool_env(name: str, default: bool = False) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _voice_runtime_enabled() -> bool: + return _bool_env("SALES_VOICE_RUNTIME_ENABLED", False) + + +def _telegram_bridge_enabled() -> bool: + return _bool_env("SALES_TELEGRAM_BRIDGE_ENABLED", False) + + +def _voice_runtime_url() -> str: + return os.getenv("AI_VOICE_RUNTIME_SERVICE_URL", "http://localhost:8018").rstrip("/") + + +def _telegram_adapter_url() -> str: + return os.getenv("TELEGRAM_ADAPTER_SERVICE_URL", "http://localhost:8007").rstrip("/") + + +def _default_handoff_queue_id() -> str: + return os.getenv("SALES_DEFAULT_HANDOFF_QUEUE_ID", "sales_line").strip() or "sales_line" + + +def _infer_text_channel(preferred_channel: str | None) -> str: + return "voice" if (preferred_channel or "").strip().lower() == "voice" else "text" + + +def _infer_stage_for_channel(channel: str) -> str: + return "active_voice_communication" if channel == "voice" else "active_text_communication" + + +def _stage_label(stage_id: str | None) -> str: + return STAGE_LABELS.get(stage_id or "", stage_id or "Неизвестный этап") + + +def _lead_to_out(row: SalesLeadRow) -> SalesLeadOut: + return SalesLeadOut( + lead_id=row.lead_id, + tenant_id=row.tenant_id, + source_type=row.source_type, + source_channel=row.source_channel, + source_campaign_id=row.source_campaign_id, + full_name=row.full_name, + company_name=row.company_name, + phone=row.phone, + email=row.email, + messenger_handles=_json_dict(row.messenger_handles_json), + lead_temperature=row.lead_temperature, # type: ignore[arg-type] + lead_score=row.lead_score, + customer_type=row.customer_type, + segment_type=row.segment_type, + initial_need_summary=row.initial_need_summary, + preferred_channel=row.preferred_channel, # type: ignore[arg-type] + assigned_agent_type=row.assigned_agent_type, # type: ignore[arg-type] + status=row.status, # type: ignore[arg-type] + crm_customer_id=row.crm_customer_id, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def _deal_to_out(row: SalesDealRow) -> SalesDealOut: + return SalesDealOut( + deal_id=row.deal_id, + tenant_id=row.tenant_id, + lead_id=row.lead_id, + customer_id=row.customer_id, + pipeline_id=row.pipeline_id, + stage_id=row.stage_id, + scenario_type=row.scenario_type, # type: ignore[arg-type] + priority=row.priority, + title=row.title, + need_summary=row.need_summary, + product_context=_json_dict(row.product_context_json), + estimated_amount=row.estimated_amount, + final_amount=row.final_amount, + currency=row.currency, + payment_model=row.payment_model, + document_required=row.document_required, + payment_required=row.payment_required, + assigned_human_user_id=row.assigned_human_user_id, + assigned_ai_orchestrator_id=row.assigned_ai_orchestrator_id, + preferred_channel=row.preferred_channel, + current_channel=row.current_channel, + status=row.status, # type: ignore[arg-type] + won_reason=row.won_reason, + lost_reason=row.lost_reason, + close_reason=row.close_reason, + next_action_type=row.next_action_type, + next_action_at=row.next_action_at, + last_contact_at=row.last_contact_at, + closed_at=row.closed_at, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def _communication_to_out(row: SalesCommunicationSessionRow) -> SalesCommunicationOut: + return SalesCommunicationOut( + communication_id=row.communication_id, + tenant_id=row.tenant_id, + deal_id=row.deal_id, + lead_id=row.lead_id, + customer_id=row.customer_id, + channel_type=row.channel_type, # type: ignore[arg-type] + direction=row.direction, # type: ignore[arg-type] + agent_type=row.agent_type, # type: ignore[arg-type] + started_at=row.started_at, + ended_at=row.ended_at, + duration_sec=row.duration_sec, + subject=row.subject, + status=row.status, + summary=row.summary, + transcript_id=row.transcript_id, + next_action_type=row.next_action_type, + next_action_at=row.next_action_at, + sentiment=row.sentiment, + result_code=row.result_code, + metadata=_json_dict(row.metadata_json), + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def _message_to_out(row: SalesMessageRow) -> SalesMessageOut: + return SalesMessageOut( + message_id=row.message_id, + deal_id=row.deal_id, + communication_id=row.communication_id, + sender_type=row.sender_type, + sender_id=row.sender_id, + channel_provider=row.channel_provider, + external_message_id=row.external_message_id, + body=row.body, + attachments=_json_list(row.attachments_json), + delivery_status=row.delivery_status, + read_status=row.read_status, + metadata=_json_dict(row.message_metadata_json), + sent_at=row.sent_at, + created_at=row.created_at, + ) + + +def _call_to_out(row: SalesCallRow) -> SalesCallOut: + return SalesCallOut( + call_id=row.call_id, + deal_id=row.deal_id, + communication_id=row.communication_id, + phone_number=row.phone_number, + direction=row.direction, # type: ignore[arg-type] + provider=row.provider, + external_call_id=row.external_call_id, + recording_url=row.recording_url, + transcript_status=row.transcript_status, # type: ignore[arg-type] + transcript_id=row.transcript_id, + call_status=row.call_status, + started_at=row.started_at, + ended_at=row.ended_at, + duration_sec=row.duration_sec, + summary=row.summary, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def _transcript_to_out(row: SalesTranscriptRow) -> SalesTranscriptOut: + return SalesTranscriptOut( + transcript_id=row.transcript_id, + call_id=row.call_id, + language=row.language, + transcript_text=row.transcript_text, + diarization=_json_dict(row.diarization_json), + extracted_entities=_json_dict(row.extracted_entities_json), + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def _offer_to_out(row: SalesOfferRow) -> SalesOfferOut: + return SalesOfferOut( + offer_id=row.offer_id, + deal_id=row.deal_id, + offer_type=row.offer_type, # type: ignore[arg-type] + title=row.title, + description=row.description, + line_items=_json_list(row.line_items_json), + pricing=_json_dict(row.pricing_json), + total_amount=row.total_amount, + currency=row.currency, + validity_until=row.validity_until, + status=row.status, # type: ignore[arg-type] + rendered_document_url=row.rendered_document_url, + created_by_type=row.created_by_type, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def _condition_to_out(row: SalesConditionRow) -> SalesConditionOut: + return SalesConditionOut( + condition_id=row.condition_id, + deal_id=row.deal_id, + product_name=row.product_name, + service_name=row.service_name, + quantity=row.quantity, + unit=row.unit, + delivery_mode=row.delivery_mode, + execution_date=row.execution_date, + start_date=row.start_date, + end_date=row.end_date, + payment_terms=row.payment_terms, + custom_terms=_json_dict(row.custom_terms_json), + agreed_price=row.agreed_price, + currency=row.currency, + confirmed_at=row.confirmed_at, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def _counterparty_to_out(row: SalesCounterpartyRow) -> SalesCounterpartyOut: + return SalesCounterpartyOut( + counterparty_id=row.counterparty_id, + deal_id=row.deal_id, + customer_id=row.customer_id, + company_name=row.company_name, + full_name=row.full_name, + bin_iin=row.bin_iin, + address=row.address, + bank_details=_json_dict(row.bank_details_json), + signer_name=row.signer_name, + signer_role=row.signer_role, + signer_basis=row.signer_basis, + email_for_docs=row.email_for_docs, + phone_for_docs=row.phone_for_docs, + completeness_status=row.completeness_status, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def _document_to_out(row: SalesDocumentRow) -> SalesDocumentOut: + return SalesDocumentOut( + document_id=row.document_id, + deal_id=row.deal_id, + customer_id=row.customer_id, + document_type=row.document_type, # type: ignore[arg-type] + template_id=row.template_id, + version=row.version, + status=row.status, # type: ignore[arg-type] + file_url=row.file_url, + rendered_payload=_json_dict(row.rendered_payload_json), + external_sign_provider_id=row.external_sign_provider_id, + signed_at=row.signed_at, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def _invoice_to_out(row: SalesInvoiceRow) -> SalesInvoiceOut: + return SalesInvoiceOut( + invoice_id=row.invoice_id, + deal_id=row.deal_id, + customer_id=row.customer_id, + invoice_number=row.invoice_number, + basis_document_id=row.basis_document_id, + amount=row.amount, + currency=row.currency, + due_date=row.due_date, + status=row.status, # type: ignore[arg-type] + payment_link=row.payment_link, + line_items=_json_list(row.line_items_json), + metadata=_json_dict(row.metadata_json), + issued_at=row.issued_at, + paid_at=row.paid_at, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def _payment_to_out(row: SalesPaymentRow) -> SalesPaymentOut: + return SalesPaymentOut( + payment_id=row.payment_id, + deal_id=row.deal_id, + invoice_id=row.invoice_id, + payment_provider=row.payment_provider, + external_payment_id=row.external_payment_id, + amount=row.amount, + currency=row.currency, + status=row.status, # type: ignore[arg-type] + paid_at=row.paid_at, + payment_method=row.payment_method, + failure_reason=row.failure_reason, + metadata=_json_dict(row.metadata_json), + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def _escalation_to_out(row: SalesEscalationRow) -> SalesEscalationOut: + return SalesEscalationOut( + escalation_id=row.escalation_id, + deal_id=row.deal_id, + escalation_type=row.escalation_type, + reason=row.reason, + severity=row.severity, # type: ignore[arg-type] + status=row.status, # type: ignore[arg-type] + assigned_to_user_id=row.assigned_to_user_id, + created_at=row.created_at, + resolved_at=row.resolved_at, + ) + + +def _task_to_out(row: SalesAutomationTaskRow) -> SalesAutomationTaskOut: + return SalesAutomationTaskOut( + task_id=row.task_id, + deal_id=row.deal_id, + task_type=row.task_type, + payload=_json_dict(row.payload_json), + run_at=row.run_at, + status=row.status, # type: ignore[arg-type] + retry_count=row.retry_count, + last_error=row.last_error, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def _stage_history_to_out(row: SalesStageHistoryRow) -> SalesStageHistoryOut: + return SalesStageHistoryOut( + history_id=row.history_id, + deal_id=row.deal_id, + from_stage_id=row.from_stage_id, + to_stage_id=row.to_stage_id, + changed_by_type=row.changed_by_type, + changed_by_id=row.changed_by_id, + reason=row.reason, + changed_at=row.changed_at, + ) + + +def _channel_switch_to_out(row: SalesChannelSwitchRow) -> SalesChannelSwitchOut: + return SalesChannelSwitchOut( + switch_id=row.switch_id, + deal_id=row.deal_id, + communication_id=row.communication_id, + from_channel=row.from_channel, + to_channel=row.to_channel, + reason_for_channel_switch=row.reason_for_channel_switch, + switched_at=row.switched_at, + ) + + +def _get_lead(session, lead_id: str) -> SalesLeadRow: + row = session.execute(select(SalesLeadRow).where(SalesLeadRow.lead_id == lead_id)).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Lead not found") + return row + + +def _get_deal(session, deal_id: str) -> SalesDealRow: + row = session.execute(select(SalesDealRow).where(SalesDealRow.deal_id == deal_id)).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Deal not found") + return row + + +def _get_communication(session, communication_id: str) -> SalesCommunicationSessionRow: + row = session.execute( + select(SalesCommunicationSessionRow).where(SalesCommunicationSessionRow.communication_id == communication_id) + ).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Communication not found") + return row + + +def _find_external_link( + session, + *, + thread_id: str | None = None, + voice_session_id: str | None = None, + external_call_id: str | None = None, + interaction_id: str | None = None, +) -> SalesExternalLinkRow | None: + conditions = [] + if str(thread_id or "").strip(): + conditions.append(SalesExternalLinkRow.external_thread_id == str(thread_id).strip()) + if str(voice_session_id or "").strip(): + conditions.append(SalesExternalLinkRow.voice_session_id == str(voice_session_id).strip()) + if str(external_call_id or "").strip(): + conditions.append(SalesExternalLinkRow.external_call_id == str(external_call_id).strip()) + if str(interaction_id or "").strip(): + conditions.append(SalesExternalLinkRow.interaction_id == str(interaction_id).strip()) + if not conditions: + return None + return session.execute( + select(SalesExternalLinkRow) + .where(or_(*conditions)) + .order_by(SalesExternalLinkRow.id.desc()) + ).scalars().first() + + +def _find_customer_by_customer_id(session, customer_id: str | None) -> Customer | None: + normalized = str(customer_id or "").strip() + if not normalized: + return None + return session.execute(select(Customer).where(Customer.customer_id == normalized)).scalar_one_or_none() + + +def _create_lead_and_deal_for_contact( + session, + *, + source_channel: str, + preferred_channel: str, + full_name: str, + phone: str | None, + customer_id: str | None = None, + subject: str | None = None, +) -> tuple[SalesLeadRow, SalesDealRow]: + now = _now().isoformat() + lead = SalesLeadRow( + lead_id=new_id("sld"), + tenant_id=_tenant_id(), + source_type="autosync", + source_channel=source_channel, + source_campaign_id=None, + full_name=full_name, + company_name=None, + phone=phone, + email=None, + messenger_handles_json="{}", + lead_temperature="warm", + lead_score=60.0, + customer_type=None, + segment_type=None, + initial_need_summary=subject, + preferred_channel=preferred_channel, + assigned_agent_type="voice_ai" if preferred_channel == "voice" else "text_ai", + status="new_qualified_lead", + crm_customer_id=customer_id, + created_at=now, + updated_at=now, + ) + deal = SalesDealRow( + deal_id=new_id("sde"), + tenant_id=_tenant_id(), + lead_id=lead.lead_id, + customer_id=customer_id, + pipeline_id=DEFAULT_SALES_PIPELINE_ID, + stage_id="new_qualified_lead", + scenario_type="quick_sale", + priority=3, + title=subject or f"Сделка {full_name}", + need_summary=subject, + product_context_json="{}", + estimated_amount=None, + final_amount=None, + currency=DEFAULT_SALES_CURRENCY, + payment_model=None, + document_required=True, + payment_required=True, + assigned_human_user_id=None, + assigned_ai_orchestrator_id=None, + preferred_channel=preferred_channel, + current_channel=preferred_channel, + status="active", + won_reason=None, + lost_reason=None, + close_reason=None, + next_action_type=None, + next_action_at=None, + last_contact_at=None, + closed_at=None, + created_at=now, + updated_at=now, + ) + session.add(lead) + session.add(deal) + _record_stage_change( + session, + deal=deal, + from_stage_id=None, + to_stage_id="new_qualified_lead", + changed_by_type="system", + changed_by_id=None, + reason="autosync.created", + ) + return lead, deal + + +def _resolve_or_create_sync_deal( + session, + *, + source_channel: str, + preferred_channel: str, + customer_id: str | None = None, + phone: str | None = None, + display_name: str | None = None, + interaction_id: str | None = None, + thread_id: str | None = None, + voice_session_id: str | None = None, + external_call_id: str | None = None, + subject: str | None = None, +) -> tuple[SalesLeadRow | None, SalesDealRow]: + link = _find_external_link( + session, + thread_id=thread_id, + voice_session_id=voice_session_id, + external_call_id=external_call_id, + interaction_id=interaction_id, + ) + if link is not None: + deal = _get_deal(session, link.deal_id) + lead = _get_lead(session, deal.lead_id) if deal.lead_id else None + return lead, deal + + normalized_customer_id = str(customer_id or "").strip() or None + if normalized_customer_id: + deal = session.execute( + select(SalesDealRow) + .where(SalesDealRow.customer_id == normalized_customer_id) + .where(SalesDealRow.status == "active") + .order_by(SalesDealRow.updated_at.desc(), SalesDealRow.id.desc()) + ).scalars().first() + if deal is not None: + lead = _get_lead(session, deal.lead_id) if deal.lead_id else None + return lead, deal + + normalized_phone = str(phone or "").strip() or None + if normalized_phone: + lead, deal = _resolve_deal_for_inbound( + session, + deal_id=None, + lead_id=None, + phone=normalized_phone, + subject=subject, + source_channel=source_channel, + preferred_channel=preferred_channel, + ) + if normalized_customer_id and deal.customer_id != normalized_customer_id: + deal.customer_id = normalized_customer_id + deal.updated_at = utc_now_iso() + return lead, deal + + customer = _find_customer_by_customer_id(session, normalized_customer_id) + full_name = str(display_name or (customer.display_name if customer else "") or "").strip() or ( + "Telegram customer" if source_channel == "telegram" else "Voice customer" + ) + lead, deal = _create_lead_and_deal_for_contact( + session, + source_channel=source_channel, + preferred_channel=preferred_channel, + full_name=full_name, + phone=normalized_phone, + customer_id=normalized_customer_id, + subject=subject, + ) + return lead, deal + + +def _find_customer_by_phone(session, phone: str | None) -> Customer | None: + normalized = str(phone or "").strip() + if not normalized: + return None + direct = session.execute(select(Customer).where(Customer.preferred_phone == normalized)).scalar_one_or_none() + if direct is not None: + return direct + rows = session.execute(select(Customer).order_by(Customer.id.desc())).scalars().all() + for row in rows: + phones = _json_list(row.phones_json) + if normalized in phones: + return row + return None + + +def _ensure_crm_customer(session, *, lead: SalesLeadRow | None, deal: SalesDealRow, counterparty: SalesCounterpartyRow | None) -> str | None: + if deal.customer_id: + return deal.customer_id + + phone = None + display_name = None + tags = ["sales"] + if counterparty: + phone = counterparty.phone_for_docs + display_name = counterparty.full_name or counterparty.company_name + if counterparty.company_name: + tags.append("b2b") + if not display_name and lead: + display_name = lead.full_name + phone = phone or lead.phone + if not display_name: + return None + + existing = _find_customer_by_phone(session, phone) + if existing is not None: + deal.customer_id = existing.customer_id + if lead and not lead.crm_customer_id: + lead.crm_customer_id = existing.customer_id + lead.updated_at = utc_now_iso() + return existing.customer_id + + now = utc_now_iso() + customer = Customer( + customer_id=new_id("cus"), + display_name=display_name, + phones_json=json.dumps([phone] if phone else [], ensure_ascii=False), + preferred_phone=phone, + tags_json=json.dumps(tags, ensure_ascii=False), + created_at=now, + ) + session.add(customer) + deal.customer_id = customer.customer_id + if lead: + lead.crm_customer_id = customer.customer_id + lead.updated_at = now + return customer.customer_id + + +def _record_stage_change(session, *, deal: SalesDealRow, from_stage_id: str | None, to_stage_id: str, changed_by_type: str, changed_by_id: str | None, reason: str | None = None) -> None: + session.add( + SalesStageHistoryRow( + history_id=new_id("sth"), + tenant_id=deal.tenant_id, + deal_id=deal.deal_id, + from_stage_id=from_stage_id, + to_stage_id=to_stage_id, + changed_by_type=changed_by_type, + changed_by_id=changed_by_id, + reason=reason, + changed_at=utc_now_iso(), + ) + ) + + +def _schedule_task(session, *, deal: SalesDealRow, task_type: str, run_at: str | None, payload: dict | None = None) -> SalesAutomationTaskRow: + now = utc_now_iso() + task = SalesAutomationTaskRow( + task_id=new_id("tsk"), + tenant_id=deal.tenant_id, + deal_id=deal.deal_id, + task_type=task_type, + payload_json=json.dumps(payload or {}, ensure_ascii=False), + run_at=run_at or now, + status="pending", + retry_count=0, + last_error=None, + created_at=now, + updated_at=now, + ) + session.add(task) + return task + + +def _apply_stage(session, deal: SalesDealRow, stage_id: str, *, actor_type: str, actor_id: str | None, reason: str | None = None) -> None: + if deal.stage_id != stage_id: + previous = deal.stage_id + deal.stage_id = stage_id + _record_stage_change( + session, + deal=deal, + from_stage_id=previous, + to_stage_id=stage_id, + changed_by_type=actor_type, + changed_by_id=actor_id, + reason=reason, + ) + deal.updated_at = utc_now_iso() + + +def _touch_deal_contact(deal: SalesDealRow) -> None: + now = utc_now_iso() + deal.last_contact_at = now + deal.updated_at = now + + +def _start_communication(session, *, deal: SalesDealRow, lead: SalesLeadRow | None, payload: SalesCommunicationStartIn, actor_user: str | None = None, metadata: dict | None = None) -> SalesCommunicationSessionRow: + now = utc_now_iso() + agent_type = payload.agent_type or ("voice_ai" if payload.channel_type == "voice" else "text_ai") + communication = SalesCommunicationSessionRow( + communication_id=new_id("com"), + tenant_id=deal.tenant_id, + deal_id=deal.deal_id, + lead_id=lead.lead_id if lead else deal.lead_id, + customer_id=deal.customer_id, + channel_type=payload.channel_type, + direction=payload.direction, + agent_type=agent_type, + started_at=now, + ended_at=None, + duration_sec=None, + subject=payload.subject, + status="active", + summary=payload.summary, + transcript_id=None, + next_action_type=payload.next_action_type, + next_action_at=payload.next_action_at, + sentiment=None, + result_code=None, + metadata_json=json.dumps(metadata or {}, ensure_ascii=False), + created_at=now, + updated_at=now, + ) + session.add(communication) + deal.current_channel = "voice" if payload.channel_type == "voice" else (lead.preferred_channel if lead else deal.current_channel) + _touch_deal_contact(deal) + _apply_stage( + session, + deal, + _infer_stage_for_channel(payload.channel_type), + actor_type="system" if agent_type.endswith("_ai") else "human", + actor_id=actor_user, + reason=payload.subject or "communication.started", + ) + if payload.next_action_type: + _schedule_task( + session, + deal=deal, + task_type=payload.next_action_type, + run_at=payload.next_action_at, + payload={"source": "communication.start", "communication_id": communication.communication_id}, + ) + return communication + + +def _resolve_deal_for_inbound(session, *, deal_id: str | None, lead_id: str | None, phone: str | None, subject: str | None, source_channel: str, preferred_channel: str) -> tuple[SalesLeadRow | None, SalesDealRow]: + if deal_id: + return None, _get_deal(session, deal_id) + if lead_id: + lead = _get_lead(session, lead_id) + deal = session.execute( + select(SalesDealRow) + .where(SalesDealRow.lead_id == lead.lead_id) + .order_by(SalesDealRow.id.desc()) + ).scalars().first() + if deal is None: + raise HTTPException(status_code=404, detail="Lead has no deal") + return lead, deal + + normalized_phone = str(phone or "").strip() + if normalized_phone: + lead = session.execute( + select(SalesLeadRow) + .where(SalesLeadRow.phone == normalized_phone) + .order_by(SalesLeadRow.updated_at.desc(), SalesLeadRow.id.desc()) + ).scalars().first() + if lead is not None: + deal = session.execute( + select(SalesDealRow) + .where(SalesDealRow.lead_id == lead.lead_id, SalesDealRow.status == "active") + .order_by(SalesDealRow.updated_at.desc(), SalesDealRow.id.desc()) + ).scalars().first() + if deal is not None: + return lead, deal + + crm_customer = _find_customer_by_phone(session, normalized_phone) + now = utc_now_iso() + lead = SalesLeadRow( + lead_id=new_id("sld"), + tenant_id=_tenant_id(), + source_type="inbound", + source_channel=source_channel, + source_campaign_id=None, + full_name=crm_customer.display_name if crm_customer else "Новый лид", + company_name=None, + phone=normalized_phone, + email=None, + messenger_handles_json="{}", + lead_temperature="warm", + lead_score=55, + customer_type=None, + segment_type=None, + initial_need_summary=subject, + preferred_channel=preferred_channel, + assigned_agent_type="voice_ai" if preferred_channel == "voice" else "text_ai", + status="new_qualified_lead", + crm_customer_id=crm_customer.customer_id if crm_customer else None, + created_at=now, + updated_at=now, + ) + deal = SalesDealRow( + deal_id=new_id("sde"), + tenant_id=_tenant_id(), + lead_id=lead.lead_id, + customer_id=crm_customer.customer_id if crm_customer else None, + pipeline_id=DEFAULT_SALES_PIPELINE_ID, + stage_id="new_qualified_lead", + scenario_type="quick_sale", + priority=3, + title=subject or f"Новый лид {normalized_phone}", + need_summary=subject, + product_context_json="{}", + estimated_amount=None, + final_amount=None, + currency=DEFAULT_SALES_CURRENCY, + payment_model=None, + document_required=True, + payment_required=True, + assigned_human_user_id=None, + assigned_ai_orchestrator_id=None, + preferred_channel=preferred_channel, + current_channel=preferred_channel, + status="active", + won_reason=None, + lost_reason=None, + close_reason=None, + next_action_type=None, + next_action_at=None, + last_contact_at=None, + closed_at=None, + created_at=now, + updated_at=now, + ) + session.add(lead) + session.add(deal) + _record_stage_change( + session, + deal=deal, + from_stage_id=None, + to_stage_id="new_qualified_lead", + changed_by_type="system", + changed_by_id=None, + reason="lead.entered_crm", + ) + return lead, deal + + raise HTTPException(status_code=400, detail="Cannot resolve inbound contact without deal_id, lead_id or phone") + + +def _bridge_voice_runtime(call: SalesCallRow, communication: SalesCommunicationSessionRow, deal: SalesDealRow) -> None: + if not _voice_runtime_enabled(): + return + metadata = _json_dict(communication.metadata_json) + if metadata.get("voice_session_id"): + return + payload = { + "call_id": call.external_call_id or call.call_id, + "interaction_id": None, + "customer_id": deal.customer_id, + "queue_id": _default_handoff_queue_id(), + "ai_session_id": None, + "agent_profile": "voice_support", + "language": "ru", + "handoff_queue_id": _default_handoff_queue_id(), + "greeting_text": f"Здравствуйте! Продолжим работу по сделке: {deal.title}.", + } + try: + with httpx.Client(timeout=8.0) as client: + response = client.post( + f"{_voice_runtime_url()}/internal/voice-ai/sessions", + json=payload, + headers=_service_headers("svc:sales-service", "sales-service", "sales-service"), + ) + response.raise_for_status() + data = response.json() + metadata["voice_session_id"] = data.get("voice_session_id") + metadata["voice_runtime"] = "attached" + communication.metadata_json = json.dumps(metadata, ensure_ascii=False) + communication.updated_at = utc_now_iso() + except Exception as exc: # noqa: BLE001 + metadata["voice_runtime"] = "degraded" + metadata["voice_runtime_error"] = str(exc) + communication.metadata_json = json.dumps(metadata, ensure_ascii=False) + communication.updated_at = utc_now_iso() + + +def _bridge_telegram_outbound(communication: SalesCommunicationSessionRow, message: SalesMessageRow) -> None: + if not _telegram_bridge_enabled(): + return + metadata = _json_dict(communication.metadata_json) + thread_id = str(metadata.get("telegram_thread_id") or "").strip() + if not thread_id or message.channel_provider != "telegram": + return + payload = {"text": message.body} + try: + with httpx.Client(timeout=8.0) as client: + response = client.post( + f"{_telegram_adapter_url()}/integrations/telegram/threads/{thread_id}/messages", + json=payload, + headers=_service_headers("svc:sales-service", "sales-service", "sales-service"), + ) + response.raise_for_status() + message.delivery_status = "sent" + except Exception as exc: # noqa: BLE001 + message.delivery_status = "queued" + metadata["telegram_bridge_error"] = str(exc) + communication.metadata_json = json.dumps(metadata, ensure_ascii=False) + communication.updated_at = utc_now_iso() + + +def _counterparty_status(payload: SalesCounterpartyUpsertIn) -> str: + required_hits = sum( + 1 + for value in [ + payload.company_name or payload.full_name, + payload.email_for_docs or payload.phone_for_docs, + payload.bin_iin or payload.signer_name, + ] + if str(value or "").strip() + ) + return "completed" if required_hits >= 2 else "requested" + + +def _invoice_number() -> str: + return f"INV-{_now().strftime('%Y%m%d')}-{new_id('num').split('_', 1)[1].upper()}" + + +def _merge_communication_metadata(communication: SalesCommunicationSessionRow, values: dict[str, Any]) -> dict: + metadata = _json_dict(communication.metadata_json) + changed = False + for key, value in values.items(): + if value is None: + if key in metadata: + metadata.pop(key, None) + changed = True + continue + if isinstance(value, str): + normalized = value.strip() + if not normalized: + if key in metadata: + metadata.pop(key, None) + changed = True + continue + value = normalized + if metadata.get(key) != value: + metadata[key] = value + changed = True + if changed: + communication.metadata_json = json.dumps(metadata, ensure_ascii=False) + communication.updated_at = utc_now_iso() + return metadata + + +def _upsert_external_link( + session, + *, + deal: SalesDealRow, + channel_provider: str, + communication_id: str | None = None, + sales_call_id: str | None = None, + external_thread_id: str | None = None, + external_chat_id: str | None = None, + external_call_id: str | None = None, + voice_session_id: str | None = None, + ai_session_id: str | None = None, + interaction_id: str | None = None, + customer_id: str | None = None, + phone_number: str | None = None, + external_status: str | None = None, + metadata: dict[str, Any] | None = None, +) -> SalesExternalLinkRow: + row = _find_external_link( + session, + thread_id=external_thread_id, + voice_session_id=voice_session_id, + external_call_id=external_call_id, + interaction_id=interaction_id, + ) + now = utc_now_iso() + if row is None: + row = SalesExternalLinkRow( + link_id=new_id("xlk"), + tenant_id=deal.tenant_id, + deal_id=deal.deal_id, + communication_id=communication_id, + sales_call_id=sales_call_id, + channel_provider=channel_provider, + external_thread_id=external_thread_id, + external_chat_id=external_chat_id, + external_call_id=external_call_id, + voice_session_id=voice_session_id, + ai_session_id=ai_session_id, + interaction_id=interaction_id, + customer_id=customer_id or deal.customer_id, + phone_number=phone_number, + external_status=external_status, + link_metadata_json=json.dumps(metadata or {}, ensure_ascii=False), + created_at=now, + updated_at=now, + last_sync_at=now, + ) + session.add(row) + return row + + row.deal_id = deal.deal_id + row.communication_id = communication_id or row.communication_id + row.sales_call_id = sales_call_id or row.sales_call_id + row.channel_provider = channel_provider or row.channel_provider + row.external_thread_id = str(external_thread_id or "").strip() or row.external_thread_id + row.external_chat_id = str(external_chat_id or "").strip() or row.external_chat_id + row.external_call_id = str(external_call_id or "").strip() or row.external_call_id + row.voice_session_id = str(voice_session_id or "").strip() or row.voice_session_id + row.ai_session_id = str(ai_session_id or "").strip() or row.ai_session_id + row.interaction_id = str(interaction_id or "").strip() or row.interaction_id + row.customer_id = str(customer_id or "").strip() or row.customer_id or deal.customer_id + row.phone_number = str(phone_number or "").strip() or row.phone_number + row.external_status = str(external_status or "").strip() or row.external_status + merged_meta = _json_dict(row.link_metadata_json) + merged_meta.update(metadata or {}) + row.link_metadata_json = json.dumps(merged_meta, ensure_ascii=False) + row.updated_at = now + row.last_sync_at = now + return row + + +def _get_or_create_text_communication( + session, + *, + deal: SalesDealRow, + lead: SalesLeadRow | None, + direction: str, + subject: str | None, + metadata: dict | None = None, + thread_id: str | None = None, +) -> SalesCommunicationSessionRow: + if str(thread_id or "").strip(): + link = _find_external_link(session, thread_id=thread_id) + if link and link.communication_id: + existing = session.execute( + select(SalesCommunicationSessionRow).where( + SalesCommunicationSessionRow.communication_id == link.communication_id + ) + ).scalar_one_or_none() + if existing is not None: + _merge_communication_metadata(existing, metadata or {}) + return existing + existing = session.execute( + select(SalesCommunicationSessionRow) + .where(SalesCommunicationSessionRow.deal_id == deal.deal_id) + .where(SalesCommunicationSessionRow.channel_type == "text") + .order_by(SalesCommunicationSessionRow.started_at.desc(), SalesCommunicationSessionRow.id.desc()) + ).scalars().first() + if existing is not None: + _merge_communication_metadata(existing, metadata or {}) + return existing + return _start_communication( + session, + deal=deal, + lead=lead, + payload=SalesCommunicationStartIn( + channel_type="text", + direction=direction, # type: ignore[arg-type] + agent_type="human" if direction == "outbound" else "text_ai", + subject=subject, + ), + metadata=metadata, + ) + + +def _get_or_create_voice_communication( + session, + *, + deal: SalesDealRow, + lead: SalesLeadRow | None, + subject: str | None, + metadata: dict | None = None, + voice_session_id: str | None = None, +) -> SalesCommunicationSessionRow: + if str(voice_session_id or "").strip(): + link = _find_external_link(session, voice_session_id=voice_session_id) + if link and link.communication_id: + existing = session.execute( + select(SalesCommunicationSessionRow).where( + SalesCommunicationSessionRow.communication_id == link.communication_id + ) + ).scalar_one_or_none() + if existing is not None: + _merge_communication_metadata(existing, metadata or {}) + return existing + existing = session.execute( + select(SalesCommunicationSessionRow) + .where(SalesCommunicationSessionRow.deal_id == deal.deal_id) + .where(SalesCommunicationSessionRow.channel_type == "voice") + .order_by(SalesCommunicationSessionRow.started_at.desc(), SalesCommunicationSessionRow.id.desc()) + ).scalars().first() + if existing is not None: + _merge_communication_metadata(existing, metadata or {}) + return existing + return _start_communication( + session, + deal=deal, + lead=lead, + payload=SalesCommunicationStartIn( + channel_type="voice", + direction="inbound", + agent_type="voice_ai", + subject=subject, + ), + metadata=metadata, + ) + + +def _build_workspace(session, deal: SalesDealRow) -> SalesWorkspaceOut: + lead = _get_lead(session, deal.lead_id) if deal.lead_id else None + communications = session.execute( + select(SalesCommunicationSessionRow) + .where(SalesCommunicationSessionRow.deal_id == deal.deal_id) + .order_by(SalesCommunicationSessionRow.started_at.desc(), SalesCommunicationSessionRow.id.desc()) + ).scalars().all() + messages = session.execute( + select(SalesMessageRow) + .where(SalesMessageRow.deal_id == deal.deal_id) + .order_by(SalesMessageRow.sent_at.desc(), SalesMessageRow.id.desc()) + ).scalars().all() + calls = session.execute( + select(SalesCallRow) + .where(SalesCallRow.deal_id == deal.deal_id) + .order_by(SalesCallRow.started_at.desc(), SalesCallRow.id.desc()) + ).scalars().all() + offers = session.execute( + select(SalesOfferRow) + .where(SalesOfferRow.deal_id == deal.deal_id) + .order_by(SalesOfferRow.updated_at.desc(), SalesOfferRow.id.desc()) + ).scalars().all() + conditions = session.execute( + select(SalesConditionRow) + .where(SalesConditionRow.deal_id == deal.deal_id) + .order_by(SalesConditionRow.updated_at.desc(), SalesConditionRow.id.desc()) + ).scalars().all() + counterparty = session.execute( + select(SalesCounterpartyRow).where(SalesCounterpartyRow.deal_id == deal.deal_id) + ).scalar_one_or_none() + documents = session.execute( + select(SalesDocumentRow) + .where(SalesDocumentRow.deal_id == deal.deal_id) + .order_by(SalesDocumentRow.updated_at.desc(), SalesDocumentRow.id.desc()) + ).scalars().all() + invoices = session.execute( + select(SalesInvoiceRow) + .where(SalesInvoiceRow.deal_id == deal.deal_id) + .order_by(SalesInvoiceRow.updated_at.desc(), SalesInvoiceRow.id.desc()) + ).scalars().all() + payments = session.execute( + select(SalesPaymentRow) + .where(SalesPaymentRow.deal_id == deal.deal_id) + .order_by(SalesPaymentRow.updated_at.desc(), SalesPaymentRow.id.desc()) + ).scalars().all() + escalations = session.execute( + select(SalesEscalationRow) + .where(SalesEscalationRow.deal_id == deal.deal_id) + .order_by(SalesEscalationRow.created_at.desc(), SalesEscalationRow.id.desc()) + ).scalars().all() + tasks = session.execute( + select(SalesAutomationTaskRow) + .where(SalesAutomationTaskRow.deal_id == deal.deal_id) + .order_by(SalesAutomationTaskRow.run_at.desc(), SalesAutomationTaskRow.id.desc()) + ).scalars().all() + stage_history = session.execute( + select(SalesStageHistoryRow) + .where(SalesStageHistoryRow.deal_id == deal.deal_id) + .order_by(SalesStageHistoryRow.changed_at.desc(), SalesStageHistoryRow.id.desc()) + ).scalars().all() + channel_switches = session.execute( + select(SalesChannelSwitchRow) + .where(SalesChannelSwitchRow.deal_id == deal.deal_id) + .order_by(SalesChannelSwitchRow.switched_at.desc(), SalesChannelSwitchRow.id.desc()) + ).scalars().all() + + timeline: list[SalesTimelineEventOut] = [] + for row in stage_history: + timeline.append( + SalesTimelineEventOut( + ts=row.changed_at, + kind="stage", + title=f"Этап: {_stage_label(row.to_stage_id)}", + body=row.reason or "Изменение стадии сделки", + meta={"from_stage_id": row.from_stage_id, "to_stage_id": row.to_stage_id}, + ) + ) + for row in communications: + timeline.append( + SalesTimelineEventOut( + ts=row.started_at, + kind="communication", + title=f"Коммуникация: {'звонок' if row.channel_type == 'voice' else 'текст'}", + body=row.summary or row.subject or "Открыта новая сессия коммуникации", + meta={"communication_id": row.communication_id, "agent_type": row.agent_type}, + ) + ) + for row in offers: + timeline.append( + SalesTimelineEventOut( + ts=row.updated_at, + kind="offer", + title=f"Оффер: {row.title}", + body=f"Статус: {row.status}. Сумма: {row.total_amount:.2f} {row.currency}", + meta={"offer_id": row.offer_id}, + ) + ) + for row in documents: + timeline.append( + SalesTimelineEventOut( + ts=row.updated_at, + kind="document", + title=f"Документ: {row.document_type}", + body=f"Статус: {row.status}", + meta={"document_id": row.document_id}, + ) + ) + for row in invoices: + timeline.append( + SalesTimelineEventOut( + ts=row.updated_at, + kind="invoice", + title=f"Счет: {row.invoice_number}", + body=f"Статус: {row.status}. Сумма: {row.amount:.2f} {row.currency}", + meta={"invoice_id": row.invoice_id}, + ) + ) + for row in payments: + timeline.append( + SalesTimelineEventOut( + ts=row.updated_at, + kind="payment", + title=f"Оплата: {row.status}", + body=f"Сумма: {row.amount:.2f} {row.currency}", + meta={"payment_id": row.payment_id}, + ) + ) + for row in channel_switches: + timeline.append( + SalesTimelineEventOut( + ts=row.switched_at, + kind="channel_switch", + title=f"Смена канала: {row.from_channel} -> {row.to_channel}", + body=row.reason_for_channel_switch, + meta={"switch_id": row.switch_id}, + ) + ) + timeline.sort(key=lambda item: item.ts, reverse=True) + + return SalesWorkspaceOut( + lead=_lead_to_out(lead) if lead else None, + deal=_deal_to_out(deal), + communications=[_communication_to_out(row) for row in communications], + messages=[_message_to_out(row) for row in messages], + calls=[_call_to_out(row) for row in calls], + offers=[_offer_to_out(row) for row in offers], + conditions=[_condition_to_out(row) for row in conditions], + counterparty=_counterparty_to_out(counterparty) if counterparty else None, + documents=[_document_to_out(row) for row in documents], + invoices=[_invoice_to_out(row) for row in invoices], + payments=[_payment_to_out(row) for row in payments], + escalations=[_escalation_to_out(row) for row in escalations], + tasks=[_task_to_out(row) for row in tasks], + stage_history=[_stage_history_to_out(row) for row in stage_history], + channel_switches=[_channel_switch_to_out(row) for row in channel_switches], + timeline=timeline[:40], + ) + + +@app.get("/health", response_model=HealthResponse) +def health() -> HealthResponse: + return HealthResponse(status="ok", service="sales-service") + + +@app.get("/api/v1/dashboard", response_model=SalesDashboardSummaryOut) +def sales_dashboard(_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST))) -> SalesDashboardSummaryOut: + session = get_session() + try: + leads_total = session.execute(select(func.count()).select_from(SalesLeadRow)).scalar_one() + deals_active = session.execute( + select(func.count()).select_from(SalesDealRow).where(SalesDealRow.status == "active") + ).scalar_one() + deals_won = session.execute( + select(func.count()).select_from(SalesDealRow).where(SalesDealRow.status == "won") + ).scalar_one() + deals_lost = session.execute( + select(func.count()).select_from(SalesDealRow).where(SalesDealRow.status == "lost") + ).scalar_one() + overdue_invoices = session.execute( + select(func.count()).select_from(SalesInvoiceRow).where(SalesInvoiceRow.status == "overdue") + ).scalar_one() + payment_expected = session.execute( + select(func.coalesce(func.sum(SalesInvoiceRow.amount), 0.0)).where( + SalesInvoiceRow.status.in_(["issued", "sent", "partially_paid", "overdue"]) + ) + ).scalar_one() + payment_received = session.execute( + select(func.coalesce(func.sum(SalesPaymentRow.amount), 0.0)).where(SalesPaymentRow.status.in_(["success", "partial"])) + ).scalar_one() + voice_sessions = session.execute( + select(func.count()).select_from(SalesCommunicationSessionRow).where(SalesCommunicationSessionRow.channel_type == "voice") + ).scalar_one() + text_sessions = session.execute( + select(func.count()).select_from(SalesCommunicationSessionRow).where(SalesCommunicationSessionRow.channel_type == "text") + ).scalar_one() + channel_switches = session.execute(select(func.count()).select_from(SalesChannelSwitchRow)).scalar_one() + human_escalations = session.execute(select(func.count()).select_from(SalesEscalationRow)).scalar_one() + + stage_rows = session.execute( + select(SalesDealRow.stage_id, func.count(), func.coalesce(func.sum(SalesDealRow.final_amount), func.sum(SalesDealRow.estimated_amount), 0.0)) + .group_by(SalesDealRow.stage_id) + ).all() + stage_counts = [ + SalesDashboardStageOut(stage_id=stage_id, label=_stage_label(stage_id), count=int(count or 0), amount=float(amount or 0.0)) + for stage_id, count, amount in stage_rows + ] + hottest_rows = session.execute( + select(SalesDealRow) + .order_by(SalesDealRow.priority.asc(), SalesDealRow.updated_at.desc(), SalesDealRow.id.desc()) + .limit(5) + ).scalars().all() + return SalesDashboardSummaryOut( + leads_total=int(leads_total or 0), + deals_active=int(deals_active or 0), + deals_won=int(deals_won or 0), + deals_lost=int(deals_lost or 0), + overdue_invoices=int(overdue_invoices or 0), + payment_expected=float(payment_expected or 0.0), + payment_received=float(payment_received or 0.0), + voice_sessions=int(voice_sessions or 0), + text_sessions=int(text_sessions or 0), + channel_switches=int(channel_switches or 0), + human_escalations=int(human_escalations or 0), + stage_counts=stage_counts, + hottest_deals=[_deal_to_out(row) for row in hottest_rows], + ) + finally: + session.close() + + +@app.post("/api/v1/leads", response_model=SalesLeadOut) +def create_lead( + payload: SalesLeadCreate, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesLeadOut: + session = get_session() + try: + now = utc_now_iso() + crm_customer = _find_customer_by_phone(session, payload.phone) + lead = SalesLeadRow( + lead_id=new_id("sld"), + tenant_id=_tenant_id(), + source_type=payload.source_type, + source_channel=payload.source_channel, + source_campaign_id=payload.source_campaign_id, + full_name=payload.full_name, + company_name=payload.company_name, + phone=payload.phone, + email=payload.email, + messenger_handles_json=json.dumps(payload.messenger_handles, ensure_ascii=False), + lead_temperature=payload.lead_temperature, + lead_score=payload.lead_score, + customer_type=payload.customer_type, + segment_type=payload.segment_type, + initial_need_summary=payload.initial_need_summary, + preferred_channel=payload.preferred_channel, + assigned_agent_type=payload.assigned_agent_type, + status=payload.status, + crm_customer_id=crm_customer.customer_id if crm_customer else None, + created_at=now, + updated_at=now, + ) + deal = SalesDealRow( + deal_id=new_id("sde"), + tenant_id=_tenant_id(), + lead_id=lead.lead_id, + customer_id=crm_customer.customer_id if crm_customer else None, + pipeline_id=DEFAULT_SALES_PIPELINE_ID, + stage_id=payload.status, + scenario_type="quick_sale", + priority=payload.priority, + title=payload.title or f"{payload.full_name} • {payload.source_channel}", + need_summary=payload.initial_need_summary, + product_context_json="{}", + estimated_amount=payload.estimated_amount, + final_amount=None, + currency=payload.currency or DEFAULT_SALES_CURRENCY, + payment_model=None, + document_required=True, + payment_required=True, + assigned_human_user_id=None, + assigned_ai_orchestrator_id=None, + preferred_channel=payload.preferred_channel, + current_channel=payload.preferred_channel, + status="active", + won_reason=None, + lost_reason=None, + close_reason=None, + next_action_type=None, + next_action_at=None, + last_contact_at=None, + closed_at=None, + created_at=now, + updated_at=now, + ) + session.add(lead) + session.add(deal) + _record_stage_change( + session, + deal=deal, + from_stage_id=None, + to_stage_id=payload.status, + changed_by_type="human", + changed_by_id=actor["user"], + reason="lead.entered_crm", + ) + session.commit() + return _lead_to_out(lead) + finally: + session.close() + + +@app.get("/api/v1/leads", response_model=list[SalesLeadOut]) +def list_leads( + query: str | None = None, + status: str | None = None, + limit: int = Query(default=100, ge=1, le=200), + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)), +) -> list[SalesLeadOut]: + session = get_session() + try: + stmt = select(SalesLeadRow).order_by(SalesLeadRow.updated_at.desc(), SalesLeadRow.id.desc()) + if status: + stmt = stmt.where(SalesLeadRow.status == status) + if query: + pattern = f"%{query.strip().lower()}%" + stmt = stmt.where( + or_( + func.lower(func.coalesce(SalesLeadRow.full_name, "")).like(pattern), + func.lower(func.coalesce(SalesLeadRow.company_name, "")).like(pattern), + func.lower(func.coalesce(SalesLeadRow.phone, "")).like(pattern), + func.lower(func.coalesce(SalesLeadRow.email, "")).like(pattern), + ) + ) + rows = session.execute(stmt.limit(limit)).scalars().all() + return [_lead_to_out(row) for row in rows] + finally: + session.close() + + +@app.get("/api/v1/leads/{lead_id}", response_model=SalesLeadOut) +def get_lead( + lead_id: str, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)), +) -> SalesLeadOut: + session = get_session() + try: + return _lead_to_out(_get_lead(session, lead_id)) + finally: + session.close() + + +@app.patch("/api/v1/leads/{lead_id}", response_model=SalesLeadOut) +def update_lead( + lead_id: str, + payload: SalesLeadUpdate, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesLeadOut: + session = get_session() + try: + lead = _get_lead(session, lead_id) + updates = payload.model_dump(exclude_unset=True) + if "messenger_handles" in updates: + lead.messenger_handles_json = json.dumps(updates.pop("messenger_handles") or {}, ensure_ascii=False) + for key, value in updates.items(): + setattr(lead, key, value) + lead.updated_at = utc_now_iso() + session.commit() + return _lead_to_out(lead) + finally: + session.close() + + +@app.post("/api/v1/leads/{lead_id}/enrich", response_model=SalesWorkspaceOut) +def enrich_lead( + lead_id: str, + payload: SalesLeadEnrichIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesWorkspaceOut: + session = get_session() + try: + lead = _get_lead(session, lead_id) + if payload.need_summary: + lead.initial_need_summary = payload.need_summary + if payload.customer_type: + lead.customer_type = payload.customer_type + if payload.preferred_channel: + lead.preferred_channel = payload.preferred_channel + lead.updated_at = utc_now_iso() + deal = session.execute( + select(SalesDealRow).where(SalesDealRow.lead_id == lead.lead_id).order_by(SalesDealRow.id.desc()) + ).scalars().first() + if deal is None: + raise HTTPException(status_code=404, detail="Deal not found for lead") + if payload.need_summary: + deal.need_summary = payload.need_summary + if payload.product_context: + deal.product_context_json = json.dumps(payload.product_context, ensure_ascii=False) + if payload.preferred_channel: + deal.preferred_channel = payload.preferred_channel + deal.current_channel = payload.preferred_channel + deal.updated_at = utc_now_iso() + next_stage = _infer_stage_for_channel(_infer_text_channel(lead.preferred_channel)) + _apply_stage( + session, + deal, + next_stage, + actor_type="human", + actor_id=actor["user"], + reason="lead.enrichment_completed", + ) + session.commit() + return _build_workspace(session, deal) + finally: + session.close() + + +@app.post("/api/v1/leads/{lead_id}/convert-to-deal", response_model=SalesDealOut) +def convert_lead_to_deal( + lead_id: str, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesDealOut: + session = get_session() + try: + lead = _get_lead(session, lead_id) + lead.status = "converted" + lead.updated_at = utc_now_iso() + deal = session.execute( + select(SalesDealRow).where(SalesDealRow.lead_id == lead.lead_id).order_by(SalesDealRow.id.desc()) + ).scalars().first() + if deal is None: + raise HTTPException(status_code=404, detail="Deal not found for lead") + session.commit() + return _deal_to_out(deal) + finally: + session.close() + + +@app.post("/api/v1/deals", response_model=SalesDealOut) +def create_deal( + payload: SalesDealCreate, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesDealOut: + session = get_session() + try: + now = utc_now_iso() + deal = SalesDealRow( + deal_id=new_id("sde"), + tenant_id=_tenant_id(), + lead_id=payload.lead_id, + customer_id=payload.customer_id, + pipeline_id=DEFAULT_SALES_PIPELINE_ID, + stage_id=payload.stage_id, + scenario_type=payload.scenario_type, + priority=payload.priority, + title=payload.title, + need_summary=payload.need_summary, + product_context_json=json.dumps(payload.product_context, ensure_ascii=False), + estimated_amount=payload.estimated_amount, + final_amount=payload.final_amount, + currency=payload.currency or DEFAULT_SALES_CURRENCY, + payment_model=payload.payment_model, + document_required=payload.document_required, + payment_required=payload.payment_required, + assigned_human_user_id=None, + assigned_ai_orchestrator_id=None, + preferred_channel=payload.preferred_channel, + current_channel=payload.current_channel, + status="active", + won_reason=None, + lost_reason=None, + close_reason=None, + next_action_type=None, + next_action_at=None, + last_contact_at=None, + closed_at=None, + created_at=now, + updated_at=now, + ) + session.add(deal) + _record_stage_change( + session, + deal=deal, + from_stage_id=None, + to_stage_id=payload.stage_id, + changed_by_type="human", + changed_by_id=actor["user"], + reason="deal.created", + ) + session.commit() + return _deal_to_out(deal) + finally: + session.close() + + +@app.get("/api/v1/deals", response_model=list[SalesDealOut]) +def list_deals( + query: str | None = None, + stage_id: str | None = None, + status: str | None = None, + limit: int = Query(default=100, ge=1, le=300), + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)), +) -> list[SalesDealOut]: + session = get_session() + try: + stmt = select(SalesDealRow).order_by(SalesDealRow.updated_at.desc(), SalesDealRow.id.desc()) + if stage_id: + stmt = stmt.where(SalesDealRow.stage_id == stage_id) + if status: + stmt = stmt.where(SalesDealRow.status == status) + if query: + normalized_query = query.strip().lower() + pattern = f"%{normalized_query}%" + matching_lead_ids = session.execute( + select(SalesLeadRow.lead_id).where( + or_( + func.lower(func.coalesce(SalesLeadRow.full_name, "")).like(pattern), + func.lower(func.coalesce(SalesLeadRow.company_name, "")).like(pattern), + func.lower(func.coalesce(SalesLeadRow.phone, "")).like(pattern), + func.lower(func.coalesce(SalesLeadRow.email, "")).like(pattern), + ) + ) + ).scalars().all() + matching_deal_ids = session.execute( + select(SalesExternalLinkRow.deal_id).where( + or_( + func.lower(func.coalesce(SalesExternalLinkRow.external_thread_id, "")).like(pattern), + func.lower(func.coalesce(SalesExternalLinkRow.external_chat_id, "")).like(pattern), + func.lower(func.coalesce(SalesExternalLinkRow.voice_session_id, "")).like(pattern), + func.lower(func.coalesce(SalesExternalLinkRow.external_call_id, "")).like(pattern), + func.lower(func.coalesce(SalesExternalLinkRow.interaction_id, "")).like(pattern), + func.lower(func.coalesce(SalesExternalLinkRow.phone_number, "")).like(pattern), + func.lower(func.coalesce(SalesExternalLinkRow.customer_id, "")).like(pattern), + ) + ) + ).scalars().all() + stmt = stmt.where( + or_( + func.lower(func.coalesce(SalesDealRow.title, "")).like(pattern), + func.lower(func.coalesce(SalesDealRow.need_summary, "")).like(pattern), + func.lower(func.coalesce(SalesDealRow.customer_id, "")).like(pattern), + SalesDealRow.lead_id.in_(matching_lead_ids or [""]), + SalesDealRow.deal_id.in_(matching_deal_ids or [""]), + ) + ) + rows = session.execute(stmt.limit(limit)).scalars().all() + return [_deal_to_out(row) for row in rows] + finally: + session.close() + + +@app.get("/api/v1/deals/{deal_id}", response_model=SalesDealOut) +def get_deal( + deal_id: str, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)), +) -> SalesDealOut: + session = get_session() + try: + return _deal_to_out(_get_deal(session, deal_id)) + finally: + session.close() + + +@app.get("/api/v1/deals/{deal_id}/workspace", response_model=SalesWorkspaceOut) +def get_workspace( + deal_id: str, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)), +) -> SalesWorkspaceOut: + session = get_session() + try: + return _build_workspace(session, _get_deal(session, deal_id)) + finally: + session.close() + + +@app.patch("/api/v1/deals/{deal_id}", response_model=SalesDealOut) +def update_deal( + deal_id: str, + payload: SalesDealUpdate, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesDealOut: + session = get_session() + try: + deal = _get_deal(session, deal_id) + updates = payload.model_dump(exclude_unset=True) + if "product_context" in updates: + deal.product_context_json = json.dumps(updates.pop("product_context") or {}, ensure_ascii=False) + for key, value in updates.items(): + setattr(deal, key, value) + deal.updated_at = utc_now_iso() + session.commit() + return _deal_to_out(deal) + finally: + session.close() + + +@app.post("/api/v1/deals/{deal_id}/change-stage", response_model=SalesDealOut) +def change_stage( + deal_id: str, + payload: SalesDealStageChangeIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesDealOut: + session = get_session() + try: + deal = _get_deal(session, deal_id) + _apply_stage(session, deal, payload.stage_id, actor_type="human", actor_id=actor["user"], reason=payload.reason) + session.commit() + return _deal_to_out(deal) + finally: + session.close() + + +@app.post("/api/v1/deals/{deal_id}/select-scenario", response_model=SalesDealOut) +def select_scenario( + deal_id: str, + payload: SalesDealScenarioIn, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesDealOut: + session = get_session() + try: + deal = _get_deal(session, deal_id) + deal.scenario_type = payload.scenario_type + deal.updated_at = utc_now_iso() + session.commit() + return _deal_to_out(deal) + finally: + session.close() + + +@app.post("/api/v1/deals/{deal_id}/schedule-next-action", response_model=SalesDealOut) +def schedule_next_action( + deal_id: str, + payload: SalesDealNextActionIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesDealOut: + session = get_session() + try: + deal = _get_deal(session, deal_id) + deal.next_action_type = payload.next_action_type + deal.next_action_at = payload.next_action_at + deal.updated_at = utc_now_iso() + _schedule_task( + session, + deal=deal, + task_type=payload.next_action_type, + run_at=payload.next_action_at, + payload={**payload.payload, "scheduled_by": actor["user"]}, + ) + _apply_stage( + session, + deal, + "follow_up_scheduled", + actor_type="human", + actor_id=actor["user"], + reason="deal.next_action_scheduled", + ) + session.commit() + return _deal_to_out(deal) + finally: + session.close() + + +@app.post("/api/v1/deals/{deal_id}/close", response_model=SalesDealOut) +def close_deal( + deal_id: str, + payload: SalesDealCloseIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesDealOut: + session = get_session() + try: + deal = _get_deal(session, deal_id) + deal.status = payload.status + deal.closed_at = utc_now_iso() + deal.close_reason = payload.reason + if payload.status == "won": + deal.won_reason = payload.reason + elif payload.status == "lost": + deal.lost_reason = payload.reason + _apply_stage( + session, + deal, + payload.status, + actor_type="human", + actor_id=actor["user"], + reason=payload.reason or "deal.closed", + ) + session.commit() + return _deal_to_out(deal) + finally: + session.close() + + +@app.post("/api/v1/deals/{deal_id}/escalate", response_model=SalesEscalationOut) +def escalate_deal( + deal_id: str, + payload: SalesEscalationCreateIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesEscalationOut: + session = get_session() + try: + deal = _get_deal(session, deal_id) + now = utc_now_iso() + row = SalesEscalationRow( + escalation_id=new_id("esc"), + tenant_id=deal.tenant_id, + deal_id=deal.deal_id, + escalation_type=payload.escalation_type, + reason=payload.reason, + severity=payload.severity, + status="open", + assigned_to_user_id=payload.assigned_to_user_id, + created_at=now, + resolved_at=None, + ) + session.add(row) + deal.assigned_human_user_id = payload.assigned_to_user_id or actor["user"] + deal.scenario_type = "custom_human_escalation" + _apply_stage( + session, + deal, + "transferred_to_support", + actor_type="human", + actor_id=actor["user"], + reason=payload.reason, + ) + session.commit() + return _escalation_to_out(row) + finally: + session.close() + + +@app.post("/api/v1/deals/{deal_id}/communications/text", response_model=SalesCommunicationOut) +def start_text_communication( + deal_id: str, + payload: SalesCommunicationStartIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesCommunicationOut: + payload = payload.model_copy(update={"channel_type": "text"}) + session = get_session() + try: + deal = _get_deal(session, deal_id) + lead = _get_lead(session, deal.lead_id) if deal.lead_id else None + row = _start_communication(session, deal=deal, lead=lead, payload=payload, actor_user=actor["user"]) + session.commit() + return _communication_to_out(row) + finally: + session.close() + + +@app.post("/api/v1/deals/{deal_id}/communications/voice", response_model=SalesCommunicationOut) +def start_voice_communication( + deal_id: str, + payload: SalesCommunicationStartIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesCommunicationOut: + payload = payload.model_copy(update={"channel_type": "voice"}) + session = get_session() + try: + deal = _get_deal(session, deal_id) + lead = _get_lead(session, deal.lead_id) if deal.lead_id else None + row = _start_communication(session, deal=deal, lead=lead, payload=payload, actor_user=actor["user"]) + session.commit() + return _communication_to_out(row) + finally: + session.close() + + +@app.get("/api/v1/deals/{deal_id}/communications", response_model=list[SalesCommunicationOut]) +def list_communications( + deal_id: str, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)), +) -> list[SalesCommunicationOut]: + session = get_session() + try: + _get_deal(session, deal_id) + rows = session.execute( + select(SalesCommunicationSessionRow) + .where(SalesCommunicationSessionRow.deal_id == deal_id) + .order_by(SalesCommunicationSessionRow.started_at.desc(), SalesCommunicationSessionRow.id.desc()) + ).scalars().all() + return [_communication_to_out(row) for row in rows] + finally: + session.close() + + +@app.post("/api/v1/communications/{communication_id}/summary", response_model=SalesCommunicationOut) +def summarize_communication( + communication_id: str, + payload: SalesCommunicationSummaryIn, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesCommunicationOut: + session = get_session() + try: + row = session.execute( + select(SalesCommunicationSessionRow).where(SalesCommunicationSessionRow.communication_id == communication_id) + ).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Communication not found") + row.summary = payload.summary + row.result_code = payload.result_code + row.sentiment = payload.sentiment + row.next_action_type = payload.next_action_type + row.next_action_at = payload.next_action_at + row.updated_at = utc_now_iso() + deal = _get_deal(session, row.deal_id) + _touch_deal_contact(deal) + if payload.next_action_type: + deal.next_action_type = payload.next_action_type + deal.next_action_at = payload.next_action_at + _schedule_task( + session, + deal=deal, + task_type=payload.next_action_type, + run_at=payload.next_action_at, + payload={"source": "communication.summary", "communication_id": row.communication_id}, + ) + session.commit() + return _communication_to_out(row) + finally: + session.close() + + +@app.post("/api/v1/communications/{communication_id}/switch-channel", response_model=SalesChannelSwitchOut) +def switch_channel( + communication_id: str, + payload: SalesCommunicationSwitchChannelIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesChannelSwitchOut: + session = get_session() + try: + communication = _get_communication(session, communication_id) + deal = _get_deal(session, communication.deal_id) + from_channel = "voice" if communication.channel_type == "voice" else deal.current_channel + now = utc_now_iso() + row = SalesChannelSwitchRow( + switch_id=new_id("swc"), + tenant_id=deal.tenant_id, + deal_id=deal.deal_id, + communication_id=communication.communication_id, + from_channel=from_channel, + to_channel=payload.to_channel, + reason_for_channel_switch=payload.reason_for_channel_switch, + switched_at=now, + ) + session.add(row) + deal.current_channel = payload.to_channel + deal.updated_at = now + _apply_stage( + session, + deal, + _infer_stage_for_channel(_infer_text_channel(payload.to_channel)), + actor_type="human", + actor_id=actor["user"], + reason=payload.reason_for_channel_switch, + ) + _schedule_task( + session, + deal=deal, + task_type=f"switch_to_{payload.to_channel}", + run_at=now, + payload={"reason": payload.reason_for_channel_switch, "communication_id": communication.communication_id}, + ) + session.commit() + return _channel_switch_to_out(row) + finally: + session.close() + + +@app.post("/api/v1/communications/{communication_id}/bind-external", response_model=SalesCommunicationOut) +def bind_external_communication( + communication_id: str, + payload: SalesCommunicationBindExternalIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesCommunicationOut: + session = get_session() + try: + communication = _get_communication(session, communication_id) + deal = _get_deal(session, communication.deal_id) + metadata_updates = dict(payload.metadata or {}) + if payload.channel_provider: + metadata_updates["channel_provider"] = payload.channel_provider + if payload.telegram_thread_id: + metadata_updates["telegram_thread_id"] = payload.telegram_thread_id + if payload.telegram_chat_id: + metadata_updates["telegram_chat_id"] = payload.telegram_chat_id + if payload.voice_session_id: + metadata_updates["voice_session_id"] = payload.voice_session_id + if payload.external_call_id: + metadata_updates["external_call_id"] = payload.external_call_id + _merge_communication_metadata(communication, metadata_updates) + + text_channels = {"telegram", "whatsapp", "webchat", "email"} + channel_provider = str(payload.channel_provider or "").strip().lower() + if payload.telegram_thread_id or channel_provider in text_channels: + next_channel = channel_provider if channel_provider in text_channels else "telegram" + deal.current_channel = next_channel + deal.preferred_channel = next_channel + if communication.channel_type != "voice": + _apply_stage( + session, + deal, + "active_text_communication", + actor_type="human", + actor_id=actor["user"], + reason="external.channel_bound", + ) + + if payload.voice_session_id: + deal.current_channel = "voice" + deal.preferred_channel = "voice" + _apply_stage( + session, + deal, + "active_voice_communication", + actor_type="human", + actor_id=actor["user"], + reason="voice.session_bound", + ) + + if payload.external_call_id: + call = session.execute( + select(SalesCallRow) + .where(SalesCallRow.communication_id == communication.communication_id) + .order_by(SalesCallRow.id.desc()) + .limit(1) + ).scalar_one_or_none() + if call is not None: + call.external_call_id = payload.external_call_id.strip() + call.updated_at = utc_now_iso() + + session.commit() + return _communication_to_out(communication) + finally: + session.close() + + +@app.post("/internal/sales-sync/telegram", response_model=SalesWorkspaceOut) +def sync_telegram_thread( + payload: SalesTelegramSyncIn, + _: dict = Depends(require_roles(Role.ADMIN)), +) -> SalesWorkspaceOut: + session = get_session() + try: + lead, deal = _resolve_or_create_sync_deal( + session, + source_channel="telegram", + preferred_channel="telegram", + customer_id=payload.customer_id, + phone=payload.phone_number, + display_name=payload.display_name, + interaction_id=payload.interaction_id, + thread_id=payload.thread_id, + subject=payload.text or payload.display_name or "Telegram inquiry", + ) + communication = _get_or_create_text_communication( + session, + deal=deal, + lead=lead, + direction=payload.direction, + subject=(payload.text or payload.display_name or "Telegram conversation")[:120], + metadata={ + "telegram_thread_id": payload.thread_id, + "telegram_chat_id": payload.chat_id, + "interaction_id": payload.interaction_id, + "queue_id": payload.queue_id, + "ai_state": payload.ai_state, + "ai_handoff_reason": payload.ai_handoff_reason, + **(payload.metadata or {}), + }, + thread_id=payload.thread_id, + ) + event_ts = str(payload.happened_at or utc_now_iso()).strip() or utc_now_iso() + dedupe_external_message_id = str(payload.external_message_id or payload.message_id or "").strip() or None + if dedupe_external_message_id: + message = session.execute( + select(SalesMessageRow) + .where(SalesMessageRow.channel_provider == "telegram") + .where(SalesMessageRow.external_message_id == dedupe_external_message_id) + ).scalar_one_or_none() + else: + message = None + if message is None and str(payload.text or "").strip(): + message = SalesMessageRow( + message_id=new_id("msg"), + tenant_id=deal.tenant_id, + deal_id=deal.deal_id, + communication_id=communication.communication_id, + sender_type=payload.author_type or ("customer" if payload.direction == "inbound" else "human"), + sender_id=payload.author_id, + channel_provider="telegram", + external_message_id=dedupe_external_message_id, + body=payload.text.strip(), + attachments_json="[]", + delivery_status="sent" if payload.direction == "outbound" else "received", + read_status="unread" if payload.direction == "inbound" else None, + message_metadata_json=json.dumps(payload.metadata or {}, ensure_ascii=False), + sent_at=event_ts, + created_at=event_ts, + ) + session.add(message) + + _upsert_external_link( + session, + deal=deal, + communication_id=communication.communication_id, + channel_provider="telegram", + external_thread_id=payload.thread_id, + external_chat_id=payload.chat_id, + interaction_id=payload.interaction_id, + customer_id=payload.customer_id or deal.customer_id, + phone_number=payload.phone_number, + external_status=payload.status or payload.ai_state, + metadata={ + "display_name": payload.display_name, + "queue_id": payload.queue_id, + "last_direction": payload.direction, + "last_author_type": payload.author_type, + "ai_state": payload.ai_state, + "ai_handoff_reason": payload.ai_handoff_reason, + **(payload.metadata or {}), + }, + ) + deal.current_channel = "telegram" + deal.preferred_channel = "telegram" + deal.updated_at = utc_now_iso() + session.commit() + return _build_workspace(session, deal) + finally: + session.close() + + +@app.post("/internal/sales-sync/voice", response_model=SalesWorkspaceOut) +def sync_voice_session( + payload: SalesVoiceSyncIn, + _: dict = Depends(require_roles(Role.ADMIN)), +) -> SalesWorkspaceOut: + session = get_session() + try: + lead, deal = _resolve_or_create_sync_deal( + session, + source_channel="voice", + preferred_channel="voice", + phone=payload.caller_number, + display_name=payload.caller_name, + interaction_id=payload.interaction_id, + voice_session_id=payload.voice_session_id, + external_call_id=payload.call_id, + subject=payload.summary or payload.caller_name or "Voice inquiry", + ) + communication = _get_or_create_voice_communication( + session, + deal=deal, + lead=lead, + subject=(payload.summary or payload.caller_name or f"Voice call {payload.call_id}")[:120], + metadata={ + "voice_session_id": payload.voice_session_id, + "ai_session_id": payload.ai_session_id, + "interaction_id": payload.interaction_id, + "queue_id": payload.queue_id, + "queue_code": payload.queue_code, + "ai_state": payload.ai_state, + "handoff_reason": payload.handoff_reason, + **(payload.metadata or {}), + }, + voice_session_id=payload.voice_session_id, + ) + call = None + existing_link = _find_external_link( + session, + voice_session_id=payload.voice_session_id, + external_call_id=payload.call_id, + interaction_id=payload.interaction_id, + ) + if existing_link and existing_link.sales_call_id: + call = session.execute( + select(SalesCallRow).where(SalesCallRow.call_id == existing_link.sales_call_id) + ).scalar_one_or_none() + if call is None: + call = session.execute( + select(SalesCallRow) + .where(SalesCallRow.external_call_id == payload.call_id) + .order_by(SalesCallRow.id.desc()) + ).scalars().first() + started_at = str(payload.started_at or utc_now_iso()).strip() or utc_now_iso() + ended_at = str(payload.ended_at or "").strip() or None + if call is None: + call = SalesCallRow( + call_id=new_id("cal"), + tenant_id=deal.tenant_id, + deal_id=deal.deal_id, + communication_id=communication.communication_id, + phone_number=str(payload.caller_number or "").strip() or (lead.phone if lead else "") or "unknown", + direction="inbound", + provider="voice_runtime", + external_call_id=payload.call_id, + recording_url=None, + transcript_status="ready" if str(payload.transcript_text or "").strip() else "pending", + transcript_id=None, + call_status=payload.call_status or payload.ai_state or "started", + summary=payload.summary, + started_at=started_at, + ended_at=ended_at, + duration_sec=None, + created_at=started_at, + updated_at=utc_now_iso(), + ) + session.add(call) + else: + call.communication_id = communication.communication_id + call.phone_number = str(payload.caller_number or "").strip() or call.phone_number + call.provider = "voice_runtime" + call.external_call_id = payload.call_id + call.call_status = payload.call_status or payload.ai_state or call.call_status + call.summary = payload.summary or call.summary + call.started_at = started_at or call.started_at + call.ended_at = ended_at or call.ended_at + call.transcript_status = "ready" if str(payload.transcript_text or "").strip() else call.transcript_status + call.updated_at = utc_now_iso() + + if str(payload.transcript_text or "").strip(): + transcript = None + if call.transcript_id: + transcript = session.execute( + select(SalesTranscriptRow).where(SalesTranscriptRow.transcript_id == call.transcript_id) + ).scalar_one_or_none() + if transcript is None: + transcript = SalesTranscriptRow( + transcript_id=new_id("trn"), + tenant_id=deal.tenant_id, + call_id=call.call_id, + language="ru", + transcript_text=payload.transcript_text.strip(), + diarization_json="{}", + extracted_entities_json=json.dumps(payload.metadata or {}, ensure_ascii=False), + created_at=utc_now_iso(), + updated_at=utc_now_iso(), + ) + session.add(transcript) + call.transcript_id = transcript.transcript_id + else: + transcript.transcript_text = payload.transcript_text.strip() + transcript.extracted_entities_json = json.dumps(payload.metadata or {}, ensure_ascii=False) + transcript.updated_at = utc_now_iso() + + _upsert_external_link( + session, + deal=deal, + communication_id=communication.communication_id, + sales_call_id=call.call_id, + channel_provider="voice", + external_call_id=payload.call_id, + voice_session_id=payload.voice_session_id, + ai_session_id=payload.ai_session_id, + interaction_id=payload.interaction_id, + customer_id=deal.customer_id, + phone_number=payload.caller_number, + external_status=payload.call_status or payload.ai_state or payload.telephony_status, + metadata={ + "caller_name": payload.caller_name, + "queue_id": payload.queue_id, + "queue_code": payload.queue_code, + "telephony_status": payload.telephony_status, + "ai_state": payload.ai_state, + "handoff_reason": payload.handoff_reason, + **(payload.metadata or {}), + }, + ) + deal.current_channel = "voice" + deal.preferred_channel = "voice" + deal.updated_at = utc_now_iso() + session.commit() + return _build_workspace(session, deal) + finally: + session.close() + + +@app.post("/api/v1/messages/inbound-webhook", response_model=SalesMessageOut) +def inbound_message( + payload: SalesMessageWebhookIn, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesMessageOut: + session = get_session() + try: + lead, deal = _resolve_deal_for_inbound( + session, + deal_id=payload.deal_id, + lead_id=payload.lead_id, + phone=payload.phone, + subject=payload.body[:120], + source_channel=payload.channel_provider, + preferred_channel=payload.channel_provider if payload.channel_provider in {"telegram", "whatsapp", "webchat", "email"} else "telegram", + ) + communication = _start_communication( + session, + deal=deal, + lead=lead, + payload=SalesCommunicationStartIn( + channel_type="text", + direction="inbound", + agent_type="text_ai", + subject=payload.body[:120], + summary=None, + ), + metadata=payload.metadata, + ) + message = SalesMessageRow( + message_id=new_id("msg"), + tenant_id=deal.tenant_id, + deal_id=deal.deal_id, + communication_id=communication.communication_id, + sender_type="customer", + sender_id=payload.sender_id, + channel_provider=payload.channel_provider, + external_message_id=payload.external_message_id, + body=payload.body, + attachments_json=json.dumps(payload.attachments, ensure_ascii=False), + delivery_status="received", + read_status="unread", + message_metadata_json=json.dumps(payload.metadata, ensure_ascii=False), + sent_at=utc_now_iso(), + created_at=utc_now_iso(), + ) + session.add(message) + session.commit() + return _message_to_out(message) + finally: + session.close() + + +@app.post("/api/v1/messages/outbound", response_model=SalesMessageOut) +def outbound_message( + payload: SalesMessageSendIn, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesMessageOut: + session = get_session() + try: + deal = _get_deal(session, payload.deal_id) + communication = None + if payload.communication_id: + communication = session.execute( + select(SalesCommunicationSessionRow).where(SalesCommunicationSessionRow.communication_id == payload.communication_id) + ).scalar_one_or_none() + if communication is None: + lead = _get_lead(session, deal.lead_id) if deal.lead_id else None + communication = _start_communication( + session, + deal=deal, + lead=lead, + payload=SalesCommunicationStartIn( + channel_type="text", + direction="outbound", + agent_type=payload.sender_type if payload.sender_type == "human" else "text_ai", + subject=payload.body[:120], + ), + metadata=payload.metadata, + ) + message = SalesMessageRow( + message_id=new_id("msg"), + tenant_id=deal.tenant_id, + deal_id=deal.deal_id, + communication_id=communication.communication_id, + sender_type=payload.sender_type, + sender_id=payload.sender_id, + channel_provider=payload.channel_provider, + external_message_id=None, + body=payload.body, + attachments_json=json.dumps(payload.attachments, ensure_ascii=False), + delivery_status="queued", + read_status=None, + message_metadata_json=json.dumps(payload.metadata, ensure_ascii=False), + sent_at=utc_now_iso(), + created_at=utc_now_iso(), + ) + session.add(message) + _touch_deal_contact(deal) + _bridge_telegram_outbound(communication, message) + session.commit() + return _message_to_out(message) + finally: + session.close() + + +@app.get("/api/v1/deals/{deal_id}/messages", response_model=list[SalesMessageOut]) +def list_messages( + deal_id: str, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)), +) -> list[SalesMessageOut]: + session = get_session() + try: + _get_deal(session, deal_id) + rows = session.execute( + select(SalesMessageRow) + .where(SalesMessageRow.deal_id == deal_id) + .order_by(SalesMessageRow.sent_at.desc(), SalesMessageRow.id.desc()) + ).scalars().all() + return [_message_to_out(row) for row in rows] + finally: + session.close() + + +@app.post("/api/v1/calls/inbound-webhook", response_model=SalesCallOut) +def inbound_call( + payload: SalesCallWebhookIn, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesCallOut: + session = get_session() + try: + lead, deal = _resolve_deal_for_inbound( + session, + deal_id=payload.deal_id, + lead_id=payload.lead_id, + phone=payload.phone_number, + subject=payload.subject, + source_channel="voice", + preferred_channel="voice", + ) + communication = _start_communication( + session, + deal=deal, + lead=lead, + payload=SalesCommunicationStartIn( + channel_type="voice", + direction="inbound", + agent_type="voice_ai", + subject=payload.subject or f"Входящий звонок {payload.phone_number}", + ), + metadata={"provider": payload.provider}, + ) + row = SalesCallRow( + call_id=new_id("cal"), + tenant_id=deal.tenant_id, + deal_id=deal.deal_id, + communication_id=communication.communication_id, + phone_number=payload.phone_number, + direction="inbound", + provider=payload.provider, + external_call_id=payload.external_call_id, + recording_url=None, + transcript_status="pending", + transcript_id=None, + call_status="started", + summary=None, + started_at=utc_now_iso(), + ended_at=None, + duration_sec=None, + created_at=utc_now_iso(), + updated_at=utc_now_iso(), + ) + session.add(row) + _bridge_voice_runtime(row, communication, deal) + session.commit() + return _call_to_out(row) + finally: + session.close() + + +@app.post("/api/v1/calls/outbound", response_model=SalesCallOut) +def outbound_call( + payload: SalesCallWebhookIn, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesCallOut: + if not payload.deal_id: + raise HTTPException(status_code=400, detail="deal_id is required for outbound calls") + session = get_session() + try: + deal = _get_deal(session, payload.deal_id) + lead = _get_lead(session, deal.lead_id) if deal.lead_id else None + communication = _start_communication( + session, + deal=deal, + lead=lead, + payload=SalesCommunicationStartIn( + channel_type="voice", + direction="outbound", + agent_type="voice_ai", + subject=payload.subject or f"Исходящий звонок {payload.phone_number}", + ), + metadata={"provider": payload.provider}, + ) + row = SalesCallRow( + call_id=new_id("cal"), + tenant_id=deal.tenant_id, + deal_id=deal.deal_id, + communication_id=communication.communication_id, + phone_number=payload.phone_number, + direction="outbound", + provider=payload.provider, + external_call_id=payload.external_call_id, + recording_url=None, + transcript_status="pending", + transcript_id=None, + call_status="started", + summary=None, + started_at=utc_now_iso(), + ended_at=None, + duration_sec=None, + created_at=utc_now_iso(), + updated_at=utc_now_iso(), + ) + session.add(row) + _bridge_voice_runtime(row, communication, deal) + session.commit() + return _call_to_out(row) + finally: + session.close() + + +@app.post("/api/v1/calls/{call_id}/complete", response_model=SalesCallOut) +def complete_call( + call_id: str, + payload: SalesCallCompleteIn, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesCallOut: + session = get_session() + try: + row = session.execute(select(SalesCallRow).where(SalesCallRow.call_id == call_id)).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Call not found") + row.summary = payload.summary + row.recording_url = payload.recording_url + row.call_status = payload.result_code or "completed" + row.ended_at = utc_now_iso() + if row.started_at: + started = datetime.fromisoformat(row.started_at.replace("Z", "+00:00")) + row.duration_sec = max(int((_now() - started).total_seconds()), 0) + row.updated_at = utc_now_iso() + communication = session.execute( + select(SalesCommunicationSessionRow).where(SalesCommunicationSessionRow.communication_id == row.communication_id) + ).scalar_one_or_none() + if communication is not None: + communication.ended_at = row.ended_at + communication.duration_sec = row.duration_sec + communication.summary = payload.summary or communication.summary + communication.result_code = payload.result_code + communication.status = "completed" + communication.updated_at = row.updated_at + deal = _get_deal(session, row.deal_id) + _touch_deal_contact(deal) + session.commit() + return _call_to_out(row) + finally: + session.close() + + +@app.get("/api/v1/deals/{deal_id}/calls", response_model=list[SalesCallOut]) +def list_calls( + deal_id: str, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)), +) -> list[SalesCallOut]: + session = get_session() + try: + _get_deal(session, deal_id) + rows = session.execute( + select(SalesCallRow) + .where(SalesCallRow.deal_id == deal_id) + .order_by(SalesCallRow.started_at.desc(), SalesCallRow.id.desc()) + ).scalars().all() + return [_call_to_out(row) for row in rows] + finally: + session.close() + + +@app.post("/api/v1/calls/{call_id}/transcript", response_model=SalesTranscriptOut) +def attach_transcript( + call_id: str, + payload: SalesTranscriptIn, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesTranscriptOut: + session = get_session() + try: + call = session.execute(select(SalesCallRow).where(SalesCallRow.call_id == call_id)).scalar_one_or_none() + if call is None: + raise HTTPException(status_code=404, detail="Call not found") + now = utc_now_iso() + row = SalesTranscriptRow( + transcript_id=new_id("trn"), + tenant_id=_tenant_id(), + call_id=call.call_id, + language=payload.language, + transcript_text=payload.transcript_text, + diarization_json=json.dumps(payload.diarization, ensure_ascii=False), + extracted_entities_json=json.dumps(payload.extracted_entities, ensure_ascii=False), + created_at=now, + updated_at=now, + ) + session.add(row) + call.transcript_id = row.transcript_id + call.transcript_status = "ready" + call.updated_at = now + communication = session.execute( + select(SalesCommunicationSessionRow).where(SalesCommunicationSessionRow.communication_id == call.communication_id) + ).scalar_one_or_none() + if communication is not None: + communication.transcript_id = row.transcript_id + communication.updated_at = now + session.commit() + return _transcript_to_out(row) + finally: + session.close() + + +@app.post("/api/v1/deals/{deal_id}/offers", response_model=SalesOfferOut) +def create_offer( + deal_id: str, + payload: SalesOfferCreate, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesOfferOut: + session = get_session() + try: + deal = _get_deal(session, deal_id) + now = utc_now_iso() + row = SalesOfferRow( + offer_id=new_id("off"), + tenant_id=deal.tenant_id, + deal_id=deal.deal_id, + offer_type=payload.offer_type, + title=payload.title, + description=payload.description, + line_items_json=json.dumps(payload.line_items, ensure_ascii=False), + pricing_json=json.dumps(payload.pricing, ensure_ascii=False), + total_amount=payload.total_amount, + currency=payload.currency, + validity_until=payload.validity_until, + status="draft", + rendered_document_url=payload.rendered_document_url, + created_by_type=actor["user"], + created_at=now, + updated_at=now, + ) + session.add(row) + _apply_stage(session, deal, "offer_preparing", actor_type="human", actor_id=actor["user"], reason="offer.created") + session.commit() + return _offer_to_out(row) + finally: + session.close() + + +@app.get("/api/v1/offers/{offer_id}", response_model=SalesOfferOut) +def get_offer( + offer_id: str, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)), +) -> SalesOfferOut: + session = get_session() + try: + row = session.execute(select(SalesOfferRow).where(SalesOfferRow.offer_id == offer_id)).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Offer not found") + return _offer_to_out(row) + finally: + session.close() + + +@app.post("/api/v1/offers/{offer_id}/send", response_model=SalesOfferOut) +def send_offer( + offer_id: str, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesOfferOut: + session = get_session() + try: + row = session.execute(select(SalesOfferRow).where(SalesOfferRow.offer_id == offer_id)).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Offer not found") + row.status = "sent" + row.updated_at = utc_now_iso() + deal = _get_deal(session, row.deal_id) + _apply_stage(session, deal, "offer_sent", actor_type="system", actor_id=None, reason="offer.sent") + session.commit() + return _offer_to_out(row) + finally: + session.close() + + +@app.post("/api/v1/offers/{offer_id}/accept", response_model=SalesOfferOut) +def accept_offer( + offer_id: str, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesOfferOut: + session = get_session() + try: + row = session.execute(select(SalesOfferRow).where(SalesOfferRow.offer_id == offer_id)).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Offer not found") + row.status = "accepted" + row.updated_at = utc_now_iso() + deal = _get_deal(session, row.deal_id) + _apply_stage(session, deal, "conditions_negotiation", actor_type="system", actor_id=None, reason="offer.accepted") + session.commit() + return _offer_to_out(row) + finally: + session.close() + + +@app.post("/api/v1/offers/{offer_id}/reject", response_model=SalesOfferOut) +def reject_offer( + offer_id: str, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesOfferOut: + session = get_session() + try: + row = session.execute(select(SalesOfferRow).where(SalesOfferRow.offer_id == offer_id)).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Offer not found") + row.status = "rejected" + row.updated_at = utc_now_iso() + deal = _get_deal(session, row.deal_id) + _apply_stage(session, deal, "need_clarification", actor_type="system", actor_id=None, reason="offer.rejected") + session.commit() + return _offer_to_out(row) + finally: + session.close() + + +@app.post("/api/v1/deals/{deal_id}/conditions", response_model=SalesConditionOut) +def upsert_conditions( + deal_id: str, + payload: SalesConditionUpsertIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesConditionOut: + session = get_session() + try: + deal = _get_deal(session, deal_id) + row = session.execute(select(SalesConditionRow).where(SalesConditionRow.deal_id == deal.deal_id)).scalar_one_or_none() + now = utc_now_iso() + if row is None: + row = SalesConditionRow( + condition_id=new_id("con"), + tenant_id=deal.tenant_id, + deal_id=deal.deal_id, + product_name=payload.product_name, + service_name=payload.service_name, + quantity=payload.quantity, + unit=payload.unit, + delivery_mode=payload.delivery_mode, + execution_date=payload.execution_date, + start_date=payload.start_date, + end_date=payload.end_date, + payment_terms=payload.payment_terms, + custom_terms_json=json.dumps(payload.custom_terms, ensure_ascii=False), + agreed_price=payload.agreed_price, + currency=payload.currency, + confirmed_at=now, + created_at=now, + updated_at=now, + ) + session.add(row) + else: + row.product_name = payload.product_name + row.service_name = payload.service_name + row.quantity = payload.quantity + row.unit = payload.unit + row.delivery_mode = payload.delivery_mode + row.execution_date = payload.execution_date + row.start_date = payload.start_date + row.end_date = payload.end_date + row.payment_terms = payload.payment_terms + row.custom_terms_json = json.dumps(payload.custom_terms, ensure_ascii=False) + row.agreed_price = payload.agreed_price + row.currency = payload.currency + row.confirmed_at = now + row.updated_at = now + if payload.agreed_price is not None: + deal.final_amount = payload.agreed_price + _apply_stage(session, deal, "conditions_negotiation", actor_type="human", actor_id=actor["user"], reason="deal.conditions_confirmed") + session.commit() + return _condition_to_out(row) + finally: + session.close() + + +@app.post("/api/v1/deals/{deal_id}/counterparty", response_model=SalesCounterpartyOut) +def create_counterparty( + deal_id: str, + payload: SalesCounterpartyUpsertIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesCounterpartyOut: + return patch_counterparty(deal_id, payload, actor) + + +@app.patch("/api/v1/deals/{deal_id}/counterparty", response_model=SalesCounterpartyOut) +def patch_counterparty( + deal_id: str, + payload: SalesCounterpartyUpsertIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesCounterpartyOut: + session = get_session() + try: + deal = _get_deal(session, deal_id) + row = session.execute(select(SalesCounterpartyRow).where(SalesCounterpartyRow.deal_id == deal.deal_id)).scalar_one_or_none() + now = utc_now_iso() + status = _counterparty_status(payload) + if row is None: + row = SalesCounterpartyRow( + counterparty_id=new_id("ctp"), + tenant_id=deal.tenant_id, + deal_id=deal.deal_id, + customer_id=deal.customer_id, + company_name=payload.company_name, + full_name=payload.full_name, + bin_iin=payload.bin_iin, + address=payload.address, + bank_details_json=json.dumps(payload.bank_details, ensure_ascii=False), + signer_name=payload.signer_name, + signer_role=payload.signer_role, + signer_basis=payload.signer_basis, + email_for_docs=payload.email_for_docs, + phone_for_docs=payload.phone_for_docs, + completeness_status=status, + created_at=now, + updated_at=now, + ) + session.add(row) + else: + row.company_name = payload.company_name + row.full_name = payload.full_name + row.bin_iin = payload.bin_iin + row.address = payload.address + row.bank_details_json = json.dumps(payload.bank_details, ensure_ascii=False) + row.signer_name = payload.signer_name + row.signer_role = payload.signer_role + row.signer_basis = payload.signer_basis + row.email_for_docs = payload.email_for_docs + row.phone_for_docs = payload.phone_for_docs + row.completeness_status = status + row.updated_at = now + row.customer_id = _ensure_crm_customer(session, lead=_get_lead(session, deal.lead_id) if deal.lead_id else None, deal=deal, counterparty=row) + _apply_stage( + session, + deal, + "counterparty_data_received" if status == "completed" else "counterparty_data_requested", + actor_type="human", + actor_id=actor["user"], + reason="counterparty.completed" if status == "completed" else "counterparty.requested", + ) + session.commit() + return _counterparty_to_out(row) + finally: + session.close() + + +@app.get("/api/v1/deals/{deal_id}/counterparty", response_model=SalesCounterpartyOut) +def get_counterparty( + deal_id: str, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)), +) -> SalesCounterpartyOut: + session = get_session() + try: + row = session.execute(select(SalesCounterpartyRow).where(SalesCounterpartyRow.deal_id == deal_id)).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Counterparty not found") + return _counterparty_to_out(row) + finally: + session.close() + + +@app.post("/api/v1/deals/{deal_id}/documents", response_model=SalesDocumentOut) +def create_document( + deal_id: str, + payload: SalesDocumentCreate, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesDocumentOut: + session = get_session() + try: + deal = _get_deal(session, deal_id) + now = utc_now_iso() + row = SalesDocumentRow( + document_id=new_id("doc"), + tenant_id=deal.tenant_id, + deal_id=deal.deal_id, + customer_id=deal.customer_id, + document_type=payload.document_type, + template_id=payload.template_id, + version=payload.version, + status="draft", + file_url=payload.file_url, + rendered_payload_json=json.dumps(payload.rendered_payload, ensure_ascii=False), + external_sign_provider_id=None, + signed_at=None, + created_at=now, + updated_at=now, + ) + session.add(row) + _apply_stage(session, deal, "document_preparing", actor_type="human", actor_id=actor["user"], reason="document.created") + session.commit() + return _document_to_out(row) + finally: + session.close() + + +@app.get("/api/v1/documents/{document_id}", response_model=SalesDocumentOut) +def get_document( + document_id: str, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)), +) -> SalesDocumentOut: + session = get_session() + try: + row = session.execute(select(SalesDocumentRow).where(SalesDocumentRow.document_id == document_id)).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Document not found") + return _document_to_out(row) + finally: + session.close() + + +@app.post("/api/v1/documents/{document_id}/send", response_model=SalesDocumentOut) +def send_document( + document_id: str, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesDocumentOut: + session = get_session() + try: + row = session.execute(select(SalesDocumentRow).where(SalesDocumentRow.document_id == document_id)).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Document not found") + row.status = "sent" + row.updated_at = utc_now_iso() + deal = _get_deal(session, row.deal_id) + _apply_stage(session, deal, "document_sent", actor_type="system", actor_id=None, reason="document.sent") + session.commit() + return _document_to_out(row) + finally: + session.close() + + +@app.post("/api/v1/documents/{document_id}/confirm", response_model=SalesDocumentOut) +def confirm_document( + document_id: str, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesDocumentOut: + session = get_session() + try: + row = session.execute(select(SalesDocumentRow).where(SalesDocumentRow.document_id == document_id)).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Document not found") + row.status = "confirmed" + row.updated_at = utc_now_iso() + deal = _get_deal(session, row.deal_id) + _apply_stage(session, deal, "document_confirmed", actor_type="system", actor_id=None, reason="document.confirmed") + session.commit() + return _document_to_out(row) + finally: + session.close() + + +@app.post("/api/v1/documents/{document_id}/sign-status-webhook", response_model=SalesDocumentOut) +def sign_document( + document_id: str, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesDocumentOut: + session = get_session() + try: + row = session.execute(select(SalesDocumentRow).where(SalesDocumentRow.document_id == document_id)).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Document not found") + row.status = "signed" + row.signed_at = utc_now_iso() + row.updated_at = row.signed_at + session.commit() + return _document_to_out(row) + finally: + session.close() + + +@app.post("/api/v1/deals/{deal_id}/invoices", response_model=SalesInvoiceOut) +def create_invoice( + deal_id: str, + payload: SalesInvoiceCreate, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesInvoiceOut: + session = get_session() + try: + deal = _get_deal(session, deal_id) + now = utc_now_iso() + row = SalesInvoiceRow( + invoice_id=new_id("inv"), + tenant_id=deal.tenant_id, + deal_id=deal.deal_id, + customer_id=deal.customer_id, + invoice_number=_invoice_number(), + basis_document_id=payload.basis_document_id, + amount=payload.amount, + currency=payload.currency, + due_date=payload.due_date, + status="draft", + payment_link=payload.payment_link, + line_items_json=json.dumps(payload.line_items, ensure_ascii=False), + metadata_json=json.dumps(payload.metadata, ensure_ascii=False), + issued_at=None, + paid_at=None, + created_at=now, + updated_at=now, + ) + session.add(row) + _apply_stage(session, deal, "invoice_preparing", actor_type="human", actor_id=actor["user"], reason="invoice.created") + session.commit() + return _invoice_to_out(row) + finally: + session.close() + + +@app.get("/api/v1/invoices/{invoice_id}", response_model=SalesInvoiceOut) +def get_invoice( + invoice_id: str, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)), +) -> SalesInvoiceOut: + session = get_session() + try: + row = session.execute(select(SalesInvoiceRow).where(SalesInvoiceRow.invoice_id == invoice_id)).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Invoice not found") + return _invoice_to_out(row) + finally: + session.close() + + +@app.post("/api/v1/invoices/{invoice_id}/send", response_model=SalesInvoiceOut) +def send_invoice( + invoice_id: str, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesInvoiceOut: + session = get_session() + try: + row = session.execute(select(SalesInvoiceRow).where(SalesInvoiceRow.invoice_id == invoice_id)).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Invoice not found") + row.status = "sent" + row.issued_at = utc_now_iso() + row.updated_at = row.issued_at + deal = _get_deal(session, row.deal_id) + deal.next_action_type = "payment_follow_up" + deal.next_action_at = row.due_date + _schedule_task( + session, + deal=deal, + task_type="invoice_follow_up", + run_at=row.due_date, + payload={"invoice_id": row.invoice_id, "invoice_number": row.invoice_number}, + ) + _apply_stage(session, deal, "invoice_sent", actor_type="system", actor_id=None, reason="invoice.sent") + session.commit() + return _invoice_to_out(row) + finally: + session.close() + + +@app.post("/api/v1/invoices/{invoice_id}/mark-overdue", response_model=SalesInvoiceOut) +def mark_invoice_overdue( + invoice_id: str, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesInvoiceOut: + session = get_session() + try: + row = session.execute(select(SalesInvoiceRow).where(SalesInvoiceRow.invoice_id == invoice_id)).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Invoice not found") + row.status = "overdue" + row.updated_at = utc_now_iso() + deal = _get_deal(session, row.deal_id) + _apply_stage(session, deal, "payment_overdue", actor_type="system", actor_id=None, reason="invoice.overdue") + session.commit() + return _invoice_to_out(row) + finally: + session.close() + + +@app.post("/api/v1/payments/webhook", response_model=SalesPaymentOut) +def payment_webhook( + payload: SalesPaymentWebhookIn, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesPaymentOut: + session = get_session() + try: + deal = _get_deal(session, payload.deal_id) + invoice = None + if payload.invoice_id: + invoice = session.execute(select(SalesInvoiceRow).where(SalesInvoiceRow.invoice_id == payload.invoice_id)).scalar_one_or_none() + now = utc_now_iso() + row = SalesPaymentRow( + payment_id=new_id("pay"), + tenant_id=deal.tenant_id, + deal_id=deal.deal_id, + invoice_id=payload.invoice_id, + payment_provider=payload.payment_provider, + external_payment_id=payload.external_payment_id, + amount=payload.amount, + currency=payload.currency, + status=payload.status, + paid_at=payload.paid_at or (now if payload.status in {"success", "partial"} else None), + payment_method=payload.payment_method, + failure_reason=payload.failure_reason, + metadata_json=json.dumps(payload.metadata, ensure_ascii=False), + created_at=now, + updated_at=now, + ) + session.add(row) + lead = _get_lead(session, deal.lead_id) if deal.lead_id else None + counterparty = session.execute(select(SalesCounterpartyRow).where(SalesCounterpartyRow.deal_id == deal.deal_id)).scalar_one_or_none() + if invoice is not None: + if payload.status == "success": + invoice.status = "paid" + invoice.paid_at = row.paid_at + invoice.updated_at = now + deal.status = "won" + deal.closed_at = row.paid_at + deal.final_amount = payload.amount + _apply_stage(session, deal, "won", actor_type="system", actor_id=None, reason="payment.received") + _ensure_crm_customer(session, lead=lead, deal=deal, counterparty=counterparty) + elif payload.status == "partial": + invoice.status = "partially_paid" + invoice.updated_at = now + _apply_stage(session, deal, "partially_paid", actor_type="system", actor_id=None, reason="payment.partial") + elif payload.status == "failed": + invoice.updated_at = now + session.commit() + return _payment_to_out(row) + finally: + session.close() + + +@app.get("/api/v1/deals/{deal_id}/payments", response_model=list[SalesPaymentOut]) +def list_payments( + deal_id: str, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)), +) -> list[SalesPaymentOut]: + session = get_session() + try: + _get_deal(session, deal_id) + rows = session.execute( + select(SalesPaymentRow) + .where(SalesPaymentRow.deal_id == deal_id) + .order_by(SalesPaymentRow.updated_at.desc(), SalesPaymentRow.id.desc()) + ).scalars().all() + return [_payment_to_out(row) for row in rows] + finally: + session.close() + + +@app.post("/api/v1/payments/{payment_id}/reconcile", response_model=SalesPaymentOut) +def reconcile_payment( + payment_id: str, + payload: SalesPaymentReconcileIn, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesPaymentOut: + session = get_session() + try: + row = session.execute(select(SalesPaymentRow).where(SalesPaymentRow.payment_id == payment_id)).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Payment not found") + row.status = payload.status + row.failure_reason = payload.failure_reason + row.metadata_json = json.dumps(payload.metadata, ensure_ascii=False) + row.updated_at = utc_now_iso() + session.commit() + return _payment_to_out(row) + finally: + session.close() diff --git a/services/shared/audioop_compat.py b/services/shared/audioop_compat.py new file mode 100644 index 0000000..b8652c4 --- /dev/null +++ b/services/shared/audioop_compat.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import math +import struct +from array import array + +try: + import audioop as _audioop +except ModuleNotFoundError: + try: + import audioop_lts as _audioop # type: ignore[import-not-found] + except ModuleNotFoundError: + _audioop = None + + +def _native_is_little_endian() -> bool: + return struct.pack("=h", 1) == struct.pack(" array: + if width != 2: + raise NotImplementedError("audioop_compat fallback currently supports only 16-bit PCM") + samples = array("h") + samples.frombytes(fragment) + if not _native_is_little_endian(): + samples.byteswap() + return samples + + +def _write_int16_samples(samples: array) -> bytes: + output = array("h", samples) + if not _native_is_little_endian(): + output.byteswap() + return output.tobytes() + + +def _clip_int16(value: float) -> int: + return max(-32768, min(32767, int(round(value)))) + + +class _AudioopFallback: + @staticmethod + def rms(fragment: bytes, width: int) -> int: + samples = _read_int16_samples(fragment, width) + if not samples: + return 0 + mean_square = sum(sample * sample for sample in samples) / len(samples) + return int(math.sqrt(mean_square)) + + @staticmethod + def tomono(fragment: bytes, width: int, lfactor: float, rfactor: float) -> bytes: + samples = _read_int16_samples(fragment, width) + if len(samples) % 2 != 0: + raise ValueError("Stereo PCM must contain an even number of samples") + mono = array("h") + for index in range(0, len(samples), 2): + left = samples[index] + right = samples[index + 1] + mono.append(_clip_int16((left * lfactor) + (right * rfactor))) + return _write_int16_samples(mono) + + @staticmethod + def ratecv( + fragment: bytes, + width: int, + nchannels: int, + inrate: int, + outrate: int, + state, + weightA: int = 1, + weightB: int = 0, + ) -> tuple[bytes, None]: + if nchannels <= 0: + raise ValueError("nchannels must be positive") + if inrate <= 0 or outrate <= 0: + raise ValueError("Sample rates must be positive") + samples = _read_int16_samples(fragment, width) + if not samples or inrate == outrate: + return fragment, None + if len(samples) % nchannels != 0: + raise ValueError("PCM fragment size does not match channel count") + + frame_count = len(samples) // nchannels + output_frame_count = max(1, int(round(frame_count * outrate / inrate))) + output = array("h") + + for out_index in range(output_frame_count): + position = out_index * inrate / outrate + left_index = min(int(position), frame_count - 1) + right_index = min(left_index + 1, frame_count - 1) + fraction = max(0.0, min(1.0, position - left_index)) + for channel_index in range(nchannels): + left_sample = samples[(left_index * nchannels) + channel_index] + right_sample = samples[(right_index * nchannels) + channel_index] + interpolated = left_sample + ((right_sample - left_sample) * fraction) + output.append(_clip_int16(interpolated)) + return _write_int16_samples(output), None + + +audioop = _audioop or _AudioopFallback() + +__all__ = ["audioop"] diff --git a/services/shared/sales_models.py b/services/shared/sales_models.py new file mode 100644 index 0000000..b250218 --- /dev/null +++ b/services/shared/sales_models.py @@ -0,0 +1,685 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + + +SalesLeadTemperature = Literal["warm", "hot"] +SalesChannelType = Literal["text", "voice"] +SalesPreferredChannel = Literal["telegram", "whatsapp", "webchat", "email", "voice"] +SalesDirection = Literal["inbound", "outbound"] +SalesAgentType = Literal["text_ai", "voice_ai", "human"] +SalesLeadStatus = Literal["new_qualified_lead", "warm_lead", "hot_lead", "enrichment_required", "converted", "lost"] +SalesDealStatus = Literal["active", "won", "lost", "postponed", "closed"] +SalesScenarioType = Literal[ + "quick_sale", + "consultative_sale", + "quotation_based_sale", + "booking_based_sale", + "subscription_sale", + "custom_human_escalation", +] +SalesOfferType = Literal["offer", "quotation", "estimate", "order", "booking", "appointment", "subscription_plan"] +SalesOfferStatus = Literal["draft", "sent", "viewed", "accepted", "rejected"] +SalesTranscriptStatus = Literal["pending", "ready"] +SalesDocumentType = Literal[ + "contract", + "offer_acceptance", + "appendix", + "specification", + "invoice_basis_doc", + "order_confirmation", + "booking_confirmation", + "appointment_confirmation", + "additional_agreement", +] +SalesDocumentStatus = Literal["draft", "sent", "under_review", "confirmed", "signed"] +SalesInvoiceStatus = Literal["draft", "issued", "sent", "partially_paid", "paid", "overdue", "canceled"] +SalesPaymentStatus = Literal["pending", "success", "failed", "canceled", "partial"] +SalesEscalationSeverity = Literal["low", "medium", "high", "critical"] +SalesEscalationStatus = Literal["open", "in_progress", "resolved"] +SalesAutomationStatus = Literal["pending", "running", "completed", "failed", "canceled"] + + +class SalesLeadCreate(BaseModel): + source_type: str = Field(default="crm") + source_channel: str = Field(default="webchat") + source_campaign_id: str | None = None + full_name: str = Field(min_length=2) + company_name: str | None = None + phone: str | None = None + email: str | None = None + messenger_handles: dict = Field(default_factory=dict) + lead_temperature: SalesLeadTemperature = "warm" + lead_score: float = Field(default=50.0, ge=0, le=100) + customer_type: str | None = None + segment_type: str | None = None + initial_need_summary: str | None = None + preferred_channel: SalesPreferredChannel = "telegram" + assigned_agent_type: SalesAgentType = "text_ai" + status: SalesLeadStatus = "new_qualified_lead" + priority: int = Field(default=3, ge=1, le=5) + title: str | None = None + estimated_amount: float | None = Field(default=None, ge=0) + currency: str = "KZT" + + +class SalesLeadUpdate(BaseModel): + full_name: str | None = Field(default=None, min_length=2) + company_name: str | None = None + phone: str | None = None + email: str | None = None + messenger_handles: dict | None = None + lead_temperature: SalesLeadTemperature | None = None + lead_score: float | None = Field(default=None, ge=0, le=100) + customer_type: str | None = None + segment_type: str | None = None + initial_need_summary: str | None = None + preferred_channel: SalesPreferredChannel | None = None + assigned_agent_type: SalesAgentType | None = None + status: SalesLeadStatus | None = None + + +class SalesLeadEnrichIn(BaseModel): + need_summary: str | None = None + product_context: dict = Field(default_factory=dict) + customer_type: str | None = None + preferred_channel: SalesPreferredChannel | None = None + scenario_type: SalesScenarioType | None = None + required_fields: dict = Field(default_factory=dict) + + +class SalesLeadOut(BaseModel): + lead_id: str + tenant_id: str + source_type: str + source_channel: str + source_campaign_id: str | None = None + full_name: str + company_name: str | None = None + phone: str | None = None + email: str | None = None + messenger_handles: dict = Field(default_factory=dict) + lead_temperature: SalesLeadTemperature + lead_score: float + customer_type: str | None = None + segment_type: str | None = None + initial_need_summary: str | None = None + preferred_channel: SalesPreferredChannel + assigned_agent_type: SalesAgentType + status: SalesLeadStatus + crm_customer_id: str | None = None + created_at: str + updated_at: str + + +class SalesDealCreate(BaseModel): + lead_id: str | None = None + customer_id: str | None = None + stage_id: str = "new_qualified_lead" + scenario_type: SalesScenarioType = "quick_sale" + priority: int = Field(default=3, ge=1, le=5) + title: str = Field(min_length=3) + need_summary: str | None = None + product_context: dict = Field(default_factory=dict) + estimated_amount: float | None = Field(default=None, ge=0) + final_amount: float | None = Field(default=None, ge=0) + currency: str = "KZT" + payment_model: str | None = None + document_required: bool = True + payment_required: bool = True + preferred_channel: SalesPreferredChannel = "telegram" + current_channel: SalesPreferredChannel = "telegram" + + +class SalesDealUpdate(BaseModel): + title: str | None = Field(default=None, min_length=3) + need_summary: str | None = None + product_context: dict | None = None + estimated_amount: float | None = Field(default=None, ge=0) + final_amount: float | None = Field(default=None, ge=0) + currency: str | None = None + payment_model: str | None = None + document_required: bool | None = None + payment_required: bool | None = None + preferred_channel: SalesPreferredChannel | None = None + current_channel: SalesPreferredChannel | None = None + assigned_human_user_id: str | None = None + next_action_type: str | None = None + next_action_at: str | None = None + + +class SalesDealStageChangeIn(BaseModel): + stage_id: str = Field(min_length=2) + reason: str | None = None + + +class SalesDealScenarioIn(BaseModel): + scenario_type: SalesScenarioType + + +class SalesDealNextActionIn(BaseModel): + next_action_type: str = Field(min_length=2) + next_action_at: str | None = None + payload: dict = Field(default_factory=dict) + + +class SalesDealCloseIn(BaseModel): + status: Literal["won", "lost", "postponed"] + reason: str | None = None + + +class SalesDealOut(BaseModel): + deal_id: str + tenant_id: str + lead_id: str | None = None + customer_id: str | None = None + pipeline_id: str = "sales_default" + stage_id: str + scenario_type: SalesScenarioType + priority: int + title: str + need_summary: str | None = None + product_context: dict = Field(default_factory=dict) + estimated_amount: float | None = None + final_amount: float | None = None + currency: str + payment_model: str | None = None + document_required: bool = True + payment_required: bool = True + assigned_human_user_id: str | None = None + assigned_ai_orchestrator_id: str | None = None + preferred_channel: str + current_channel: str + status: SalesDealStatus + won_reason: str | None = None + lost_reason: str | None = None + close_reason: str | None = None + next_action_type: str | None = None + next_action_at: str | None = None + last_contact_at: str | None = None + closed_at: str | None = None + created_at: str + updated_at: str + + +class SalesCommunicationStartIn(BaseModel): + channel_type: SalesChannelType + direction: SalesDirection = "outbound" + agent_type: SalesAgentType | None = None + subject: str | None = None + summary: str | None = None + next_action_type: str | None = None + next_action_at: str | None = None + + +class SalesCommunicationSummaryIn(BaseModel): + summary: str = Field(min_length=2) + result_code: str | None = None + sentiment: str | None = None + next_action_type: str | None = None + next_action_at: str | None = None + + +class SalesCommunicationSwitchChannelIn(BaseModel): + to_channel: SalesPreferredChannel + reason_for_channel_switch: str = Field(min_length=3) + + +class SalesCommunicationBindExternalIn(BaseModel): + channel_provider: str | None = None + telegram_thread_id: str | None = None + telegram_chat_id: str | None = None + voice_session_id: str | None = None + external_call_id: str | None = None + metadata: dict = Field(default_factory=dict) + + +class SalesCommunicationOut(BaseModel): + communication_id: str + tenant_id: str + deal_id: str + lead_id: str | None = None + customer_id: str | None = None + channel_type: SalesChannelType + direction: SalesDirection + agent_type: SalesAgentType + started_at: str + ended_at: str | None = None + duration_sec: int | None = None + subject: str | None = None + status: str + summary: str | None = None + transcript_id: str | None = None + next_action_type: str | None = None + next_action_at: str | None = None + sentiment: str | None = None + result_code: str | None = None + metadata: dict = Field(default_factory=dict) + created_at: str + updated_at: str + + +class SalesMessageWebhookIn(BaseModel): + deal_id: str | None = None + lead_id: str | None = None + phone: str | None = None + channel_provider: str = "telegram" + external_message_id: str | None = None + sender_id: str | None = None + body: str = Field(min_length=1) + attachments: list[dict] = Field(default_factory=list) + metadata: dict = Field(default_factory=dict) + + +class SalesMessageSendIn(BaseModel): + deal_id: str + communication_id: str | None = None + channel_provider: str = "telegram" + sender_type: Literal["text_ai", "human"] = "text_ai" + sender_id: str | None = None + body: str = Field(min_length=1) + attachments: list[dict] = Field(default_factory=list) + metadata: dict = Field(default_factory=dict) + + +class SalesMessageOut(BaseModel): + message_id: str + deal_id: str + communication_id: str + sender_type: str + sender_id: str | None = None + channel_provider: str + external_message_id: str | None = None + body: str + attachments: list[dict] = Field(default_factory=list) + delivery_status: str | None = None + read_status: str | None = None + metadata: dict = Field(default_factory=dict) + sent_at: str + created_at: str + + +class SalesCallWebhookIn(BaseModel): + deal_id: str | None = None + lead_id: str | None = None + phone_number: str + provider: str = "asterisk" + external_call_id: str | None = None + subject: str | None = None + + +class SalesCallCompleteIn(BaseModel): + summary: str | None = None + recording_url: str | None = None + result_code: str | None = None + + +class SalesTranscriptIn(BaseModel): + language: str = "ru" + transcript_text: str = Field(min_length=1) + diarization: dict = Field(default_factory=dict) + extracted_entities: dict = Field(default_factory=dict) + + +class SalesTranscriptOut(BaseModel): + transcript_id: str + call_id: str + language: str + transcript_text: str + diarization: dict = Field(default_factory=dict) + extracted_entities: dict = Field(default_factory=dict) + created_at: str + updated_at: str + + +class SalesCallOut(BaseModel): + call_id: str + deal_id: str + communication_id: str + phone_number: str + direction: SalesDirection + provider: str + external_call_id: str | None = None + recording_url: str | None = None + transcript_status: SalesTranscriptStatus + transcript_id: str | None = None + call_status: str + started_at: str + ended_at: str | None = None + duration_sec: int | None = None + summary: str | None = None + created_at: str + updated_at: str + + +class SalesOfferCreate(BaseModel): + offer_type: SalesOfferType = "offer" + title: str = Field(min_length=3) + description: str | None = None + line_items: list[dict] = Field(default_factory=list) + pricing: dict = Field(default_factory=dict) + total_amount: float = Field(ge=0) + currency: str = "KZT" + validity_until: str | None = None + rendered_document_url: str | None = None + + +class SalesOfferOut(BaseModel): + offer_id: str + deal_id: str + offer_type: SalesOfferType + title: str + description: str | None = None + line_items: list[dict] = Field(default_factory=list) + pricing: dict = Field(default_factory=dict) + total_amount: float + currency: str + validity_until: str | None = None + status: SalesOfferStatus + rendered_document_url: str | None = None + created_by_type: str + created_at: str + updated_at: str + + +class SalesConditionUpsertIn(BaseModel): + product_name: str | None = None + service_name: str | None = None + quantity: float | None = Field(default=None, ge=0) + unit: str | None = None + delivery_mode: str | None = None + execution_date: str | None = None + start_date: str | None = None + end_date: str | None = None + payment_terms: str | None = None + custom_terms: dict = Field(default_factory=dict) + agreed_price: float | None = Field(default=None, ge=0) + currency: str = "KZT" + + +class SalesConditionOut(BaseModel): + condition_id: str + deal_id: str + product_name: str | None = None + service_name: str | None = None + quantity: float | None = None + unit: str | None = None + delivery_mode: str | None = None + execution_date: str | None = None + start_date: str | None = None + end_date: str | None = None + payment_terms: str | None = None + custom_terms: dict = Field(default_factory=dict) + agreed_price: float | None = None + currency: str + confirmed_at: str | None = None + created_at: str + updated_at: str + + +class SalesCounterpartyUpsertIn(BaseModel): + company_name: str | None = None + full_name: str | None = None + bin_iin: str | None = None + address: str | None = None + bank_details: dict = Field(default_factory=dict) + signer_name: str | None = None + signer_role: str | None = None + signer_basis: str | None = None + email_for_docs: str | None = None + phone_for_docs: str | None = None + + +class SalesCounterpartyOut(BaseModel): + counterparty_id: str + deal_id: str + customer_id: str | None = None + company_name: str | None = None + full_name: str | None = None + bin_iin: str | None = None + address: str | None = None + bank_details: dict = Field(default_factory=dict) + signer_name: str | None = None + signer_role: str | None = None + signer_basis: str | None = None + email_for_docs: str | None = None + phone_for_docs: str | None = None + completeness_status: str + created_at: str + updated_at: str + + +class SalesDocumentCreate(BaseModel): + document_type: SalesDocumentType + template_id: str | None = None + version: int = Field(default=1, ge=1) + rendered_payload: dict = Field(default_factory=dict) + file_url: str | None = None + + +class SalesDocumentOut(BaseModel): + document_id: str + deal_id: str + customer_id: str | None = None + document_type: SalesDocumentType + template_id: str | None = None + version: int + status: SalesDocumentStatus + file_url: str | None = None + rendered_payload: dict = Field(default_factory=dict) + external_sign_provider_id: str | None = None + signed_at: str | None = None + created_at: str + updated_at: str + + +class SalesInvoiceCreate(BaseModel): + basis_document_id: str | None = None + amount: float = Field(ge=0) + currency: str = "KZT" + due_date: str | None = None + payment_link: str | None = None + line_items: list[dict] = Field(default_factory=list) + metadata: dict = Field(default_factory=dict) + + +class SalesInvoiceOut(BaseModel): + invoice_id: str + deal_id: str + customer_id: str | None = None + invoice_number: str + basis_document_id: str | None = None + amount: float + currency: str + due_date: str | None = None + status: SalesInvoiceStatus + payment_link: str | None = None + line_items: list[dict] = Field(default_factory=list) + metadata: dict = Field(default_factory=dict) + issued_at: str | None = None + paid_at: str | None = None + created_at: str + updated_at: str + + +class SalesPaymentWebhookIn(BaseModel): + deal_id: str + invoice_id: str | None = None + payment_provider: str = "manual" + external_payment_id: str | None = None + amount: float = Field(ge=0) + currency: str = "KZT" + status: SalesPaymentStatus = "success" + paid_at: str | None = None + payment_method: str | None = None + failure_reason: str | None = None + metadata: dict = Field(default_factory=dict) + + +class SalesPaymentReconcileIn(BaseModel): + status: SalesPaymentStatus + failure_reason: str | None = None + metadata: dict = Field(default_factory=dict) + + +class SalesPaymentOut(BaseModel): + payment_id: str + deal_id: str + invoice_id: str | None = None + payment_provider: str | None = None + external_payment_id: str | None = None + amount: float + currency: str + status: SalesPaymentStatus + paid_at: str | None = None + payment_method: str | None = None + failure_reason: str | None = None + metadata: dict = Field(default_factory=dict) + created_at: str + updated_at: str + + +class SalesEscalationCreateIn(BaseModel): + escalation_type: str = Field(min_length=2) + reason: str = Field(min_length=3) + severity: SalesEscalationSeverity = "medium" + assigned_to_user_id: str | None = None + + +class SalesEscalationOut(BaseModel): + escalation_id: str + deal_id: str + escalation_type: str + reason: str + severity: SalesEscalationSeverity + status: SalesEscalationStatus + assigned_to_user_id: str | None = None + created_at: str + resolved_at: str | None = None + + +class SalesAutomationTaskOut(BaseModel): + task_id: str + deal_id: str + task_type: str + payload: dict = Field(default_factory=dict) + run_at: str + status: SalesAutomationStatus + retry_count: int = 0 + last_error: str | None = None + created_at: str + updated_at: str + + +class SalesStageHistoryOut(BaseModel): + history_id: str + deal_id: str + from_stage_id: str | None = None + to_stage_id: str + changed_by_type: str + changed_by_id: str | None = None + reason: str | None = None + changed_at: str + + +class SalesChannelSwitchOut(BaseModel): + switch_id: str + deal_id: str + communication_id: str | None = None + from_channel: str + to_channel: str + reason_for_channel_switch: str + switched_at: str + + +class SalesTimelineEventOut(BaseModel): + ts: str + kind: str + title: str + body: str + meta: dict = Field(default_factory=dict) + + +class SalesWorkspaceOut(BaseModel): + lead: SalesLeadOut | None = None + deal: SalesDealOut + communications: list[SalesCommunicationOut] = Field(default_factory=list) + messages: list[SalesMessageOut] = Field(default_factory=list) + calls: list[SalesCallOut] = Field(default_factory=list) + offers: list[SalesOfferOut] = Field(default_factory=list) + conditions: list[SalesConditionOut] = Field(default_factory=list) + counterparty: SalesCounterpartyOut | None = None + documents: list[SalesDocumentOut] = Field(default_factory=list) + invoices: list[SalesInvoiceOut] = Field(default_factory=list) + payments: list[SalesPaymentOut] = Field(default_factory=list) + escalations: list[SalesEscalationOut] = Field(default_factory=list) + tasks: list[SalesAutomationTaskOut] = Field(default_factory=list) + stage_history: list[SalesStageHistoryOut] = Field(default_factory=list) + channel_switches: list[SalesChannelSwitchOut] = Field(default_factory=list) + timeline: list[SalesTimelineEventOut] = Field(default_factory=list) + + +class SalesDashboardStageOut(BaseModel): + stage_id: str + label: str + count: int = 0 + amount: float = 0.0 + + +class SalesDashboardSummaryOut(BaseModel): + leads_total: int = 0 + deals_active: int = 0 + deals_won: int = 0 + deals_lost: int = 0 + overdue_invoices: int = 0 + payment_expected: float = 0.0 + payment_received: float = 0.0 + voice_sessions: int = 0 + text_sessions: int = 0 + channel_switches: int = 0 + human_escalations: int = 0 + stage_counts: list[SalesDashboardStageOut] = Field(default_factory=list) + hottest_deals: list[SalesDealOut] = Field(default_factory=list) + + +class SalesTelegramSyncIn(BaseModel): + thread_id: str = Field(min_length=1) + chat_id: str = Field(min_length=1) + interaction_id: str | None = None + customer_id: str | None = None + phone_number: str | None = None + display_name: str | None = None + queue_id: str | None = None + status: str | None = None + ai_state: str | None = None + ai_handoff_reason: str | None = None + message_id: str | None = None + external_message_id: str | None = None + text: str | None = None + direction: str = "inbound" + author_type: str | None = None + author_id: str | None = None + happened_at: str | None = None + metadata: dict = Field(default_factory=dict) + + +class SalesVoiceSyncIn(BaseModel): + call_id: str = Field(min_length=1) + interaction_id: str | None = None + queue_id: str | None = None + queue_code: str | None = None + caller_number: str | None = None + caller_name: str | None = None + voice_session_id: str | None = None + ai_session_id: str | None = None + ai_state: str | None = None + handoff_reason: str | None = None + telephony_status: str | None = None + call_status: str | None = None + started_at: str | None = None + ended_at: str | None = None + summary: str | None = None + transcript_text: str | None = None + metadata: dict = Field(default_factory=dict) diff --git a/services/shared/sales_sql_models.py b/services/shared/sales_sql_models.py new file mode 100644 index 0000000..4e9b1f8 --- /dev/null +++ b/services/shared/sales_sql_models.py @@ -0,0 +1,445 @@ +from __future__ import annotations + +from sqlalchemy import Boolean, Float, Index, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from services.shared.sql_models import Base + + +class SalesLeadRow(Base): + __tablename__ = "sales_leads" + __table_args__ = ( + Index("idx_sales_leads_tenant_status_score", "tenant_id", "status", "lead_score"), + Index("idx_sales_leads_phone_email", "phone", "email"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + lead_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + tenant_id: Mapped[str] = mapped_column(String(64), index=True) + source_type: Mapped[str] = mapped_column(String(64), index=True) + source_channel: Mapped[str] = mapped_column(String(32), index=True) + source_campaign_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + full_name: Mapped[str] = mapped_column(String(256), index=True) + company_name: Mapped[str | None] = mapped_column(String(256), nullable=True, index=True) + phone: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + email: Mapped[str | None] = mapped_column(String(256), nullable=True, index=True) + messenger_handles_json: Mapped[str] = mapped_column(Text, default="{}") + lead_temperature: Mapped[str] = mapped_column(String(16), index=True) + lead_score: Mapped[float] = mapped_column(Float, default=50.0, index=True) + customer_type: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + segment_type: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + initial_need_summary: Mapped[str | None] = mapped_column(Text, nullable=True) + preferred_channel: Mapped[str] = mapped_column(String(32), index=True) + assigned_agent_type: Mapped[str] = mapped_column(String(32), index=True) + status: Mapped[str] = mapped_column(String(32), index=True) + crm_customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + + +class SalesDealRow(Base): + __tablename__ = "sales_deals" + __table_args__ = ( + Index("idx_sales_deals_tenant_stage_status", "tenant_id", "stage_id", "status"), + Index("idx_sales_deals_customer_channel", "customer_id", "current_channel"), + Index("idx_sales_deals_next_action", "next_action_at", "status"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + deal_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + tenant_id: Mapped[str] = mapped_column(String(64), index=True) + lead_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + pipeline_id: Mapped[str] = mapped_column(String(64), default="sales_default", index=True) + stage_id: Mapped[str] = mapped_column(String(64), index=True) + scenario_type: Mapped[str] = mapped_column(String(64), index=True) + priority: Mapped[int] = mapped_column(Integer, default=3, index=True) + title: Mapped[str] = mapped_column(String(512)) + need_summary: Mapped[str | None] = mapped_column(Text, nullable=True) + product_context_json: Mapped[str] = mapped_column(Text, default="{}") + estimated_amount: Mapped[float | None] = mapped_column(Float, nullable=True) + final_amount: Mapped[float | None] = mapped_column(Float, nullable=True) + currency: Mapped[str] = mapped_column(String(16), default="KZT") + payment_model: Mapped[str | None] = mapped_column(String(64), nullable=True) + document_required: Mapped[bool] = mapped_column(Boolean, default=True) + payment_required: Mapped[bool] = mapped_column(Boolean, default=True) + assigned_human_user_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + assigned_ai_orchestrator_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + preferred_channel: Mapped[str] = mapped_column(String(32), index=True) + current_channel: Mapped[str] = mapped_column(String(32), index=True) + status: Mapped[str] = mapped_column(String(32), index=True, default="active") + won_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + lost_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + close_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + next_action_type: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + next_action_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + last_contact_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + closed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + + +class SalesCommunicationSessionRow(Base): + __tablename__ = "sales_communication_sessions" + __table_args__ = ( + Index("idx_sales_comm_deal_started", "deal_id", "started_at"), + Index("idx_sales_comm_channel_status", "channel_type", "status"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + communication_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + tenant_id: Mapped[str] = mapped_column(String(64), index=True) + deal_id: Mapped[str] = mapped_column(String(64), index=True) + lead_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + channel_type: Mapped[str] = mapped_column(String(16), index=True) + direction: Mapped[str] = mapped_column(String(16), index=True) + agent_type: Mapped[str] = mapped_column(String(32), index=True) + started_at: Mapped[str] = mapped_column(String(64), index=True) + ended_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + duration_sec: Mapped[int | None] = mapped_column(Integer, nullable=True) + subject: Mapped[str | None] = mapped_column(String(512), nullable=True) + status: Mapped[str] = mapped_column(String(32), index=True, default="active") + summary: Mapped[str | None] = mapped_column(Text, nullable=True) + transcript_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + next_action_type: Mapped[str | None] = mapped_column(String(128), nullable=True) + next_action_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + sentiment: Mapped[str | None] = mapped_column(String(32), nullable=True) + result_code: Mapped[str | None] = mapped_column(String(64), nullable=True) + metadata_json: Mapped[str] = mapped_column(Text, default="{}") + created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + + +class SalesMessageRow(Base): + __tablename__ = "sales_messages" + __table_args__ = ( + Index("idx_sales_messages_deal_sent", "deal_id", "sent_at"), + Index("idx_sales_messages_ext_id", "channel_provider", "external_message_id"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + message_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + tenant_id: Mapped[str] = mapped_column(String(64), index=True) + deal_id: Mapped[str] = mapped_column(String(64), index=True) + communication_id: Mapped[str] = mapped_column(String(64), index=True) + sender_type: Mapped[str] = mapped_column(String(32), index=True) + sender_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + channel_provider: Mapped[str] = mapped_column(String(32), index=True) + external_message_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + body: Mapped[str] = mapped_column(Text) + attachments_json: Mapped[str] = mapped_column(Text, default="[]") + delivery_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + read_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + message_metadata_json: Mapped[str] = mapped_column(Text, default="{}") + sent_at: Mapped[str] = mapped_column(String(64), index=True) + created_at: Mapped[str] = mapped_column(String(64), index=True) + + +class SalesCallRow(Base): + __tablename__ = "sales_calls" + __table_args__ = ( + Index("idx_sales_calls_deal_started", "deal_id", "started_at"), + Index("idx_sales_calls_ext_provider", "provider", "external_call_id"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + call_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + tenant_id: Mapped[str] = mapped_column(String(64), index=True) + deal_id: Mapped[str] = mapped_column(String(64), index=True) + communication_id: Mapped[str] = mapped_column(String(64), index=True) + phone_number: Mapped[str] = mapped_column(String(64), index=True) + direction: Mapped[str] = mapped_column(String(16), index=True) + provider: Mapped[str] = mapped_column(String(64), index=True) + external_call_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + recording_url: Mapped[str | None] = mapped_column(Text, nullable=True) + transcript_status: Mapped[str] = mapped_column(String(32), index=True, default="pending") + transcript_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + call_status: Mapped[str] = mapped_column(String(32), index=True, default="started") + summary: Mapped[str | None] = mapped_column(Text, nullable=True) + started_at: Mapped[str] = mapped_column(String(64), index=True) + ended_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + duration_sec: Mapped[int | None] = mapped_column(Integer, nullable=True) + created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + + +class SalesTranscriptRow(Base): + __tablename__ = "sales_transcripts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + transcript_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + tenant_id: Mapped[str] = mapped_column(String(64), index=True) + call_id: Mapped[str] = mapped_column(String(64), index=True) + language: Mapped[str] = mapped_column(String(16), index=True, default="ru") + transcript_text: Mapped[str] = mapped_column(Text) + diarization_json: Mapped[str] = mapped_column(Text, default="{}") + extracted_entities_json: Mapped[str] = mapped_column(Text, default="{}") + created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + + +class SalesNoteRow(Base): + __tablename__ = "sales_notes" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + note_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + tenant_id: Mapped[str] = mapped_column(String(64), index=True) + deal_id: Mapped[str] = mapped_column(String(64), index=True) + author_type: Mapped[str] = mapped_column(String(32), index=True) + author_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + note_type: Mapped[str] = mapped_column(String(64), index=True) + content: Mapped[str] = mapped_column(Text) + created_at: Mapped[str] = mapped_column(String(64), index=True) + + +class SalesOfferRow(Base): + __tablename__ = "sales_offers" + __table_args__ = ( + Index("idx_sales_offers_deal_status", "deal_id", "status"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + offer_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + tenant_id: Mapped[str] = mapped_column(String(64), index=True) + deal_id: Mapped[str] = mapped_column(String(64), index=True) + offer_type: Mapped[str] = mapped_column(String(32), index=True) + title: Mapped[str] = mapped_column(String(256)) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + line_items_json: Mapped[str] = mapped_column(Text, default="[]") + pricing_json: Mapped[str] = mapped_column(Text, default="{}") + total_amount: Mapped[float] = mapped_column(Float, default=0) + currency: Mapped[str] = mapped_column(String(16), default="KZT") + validity_until: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + status: Mapped[str] = mapped_column(String(32), index=True, default="draft") + rendered_document_url: Mapped[str | None] = mapped_column(Text, nullable=True) + created_by_type: Mapped[str] = mapped_column(String(32), default="text_ai") + created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + + +class SalesConditionRow(Base): + __tablename__ = "sales_conditions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + condition_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + tenant_id: Mapped[str] = mapped_column(String(64), index=True) + deal_id: Mapped[str] = mapped_column(String(64), index=True) + product_name: Mapped[str | None] = mapped_column(String(256), nullable=True) + service_name: Mapped[str | None] = mapped_column(String(256), nullable=True) + quantity: Mapped[float | None] = mapped_column(Float, nullable=True) + unit: Mapped[str | None] = mapped_column(String(32), nullable=True) + delivery_mode: Mapped[str | None] = mapped_column(String(64), nullable=True) + execution_date: Mapped[str | None] = mapped_column(String(64), nullable=True) + start_date: Mapped[str | None] = mapped_column(String(64), nullable=True) + end_date: Mapped[str | None] = mapped_column(String(64), nullable=True) + payment_terms: Mapped[str | None] = mapped_column(Text, nullable=True) + custom_terms_json: Mapped[str] = mapped_column(Text, default="{}") + agreed_price: Mapped[float | None] = mapped_column(Float, nullable=True) + currency: Mapped[str] = mapped_column(String(16), default="KZT") + confirmed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + + +class SalesCounterpartyRow(Base): + __tablename__ = "sales_counterparties" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + counterparty_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + tenant_id: Mapped[str] = mapped_column(String(64), index=True) + deal_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + company_name: Mapped[str | None] = mapped_column(String(256), nullable=True) + full_name: Mapped[str | None] = mapped_column(String(256), nullable=True) + bin_iin: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + address: Mapped[str | None] = mapped_column(Text, nullable=True) + bank_details_json: Mapped[str] = mapped_column(Text, default="{}") + signer_name: Mapped[str | None] = mapped_column(String(256), nullable=True) + signer_role: Mapped[str | None] = mapped_column(String(128), nullable=True) + signer_basis: Mapped[str | None] = mapped_column(String(256), nullable=True) + email_for_docs: Mapped[str | None] = mapped_column(String(256), nullable=True) + phone_for_docs: Mapped[str | None] = mapped_column(String(64), nullable=True) + completeness_status: Mapped[str] = mapped_column(String(32), index=True, default="draft") + created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + + +class SalesDocumentRow(Base): + __tablename__ = "sales_documents" + __table_args__ = ( + Index("idx_sales_documents_deal_status", "deal_id", "status"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + document_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + tenant_id: Mapped[str] = mapped_column(String(64), index=True) + deal_id: Mapped[str] = mapped_column(String(64), index=True) + customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + document_type: Mapped[str] = mapped_column(String(64), index=True) + template_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + version: Mapped[int] = mapped_column(Integer, default=1) + status: Mapped[str] = mapped_column(String(32), index=True, default="draft") + file_url: Mapped[str | None] = mapped_column(Text, nullable=True) + rendered_payload_json: Mapped[str] = mapped_column(Text, default="{}") + external_sign_provider_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + signed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + + +class SalesInvoiceRow(Base): + __tablename__ = "sales_invoices" + __table_args__ = ( + Index("idx_sales_invoices_deal_status_due", "deal_id", "status", "due_date"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + invoice_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + tenant_id: Mapped[str] = mapped_column(String(64), index=True) + deal_id: Mapped[str] = mapped_column(String(64), index=True) + customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + invoice_number: Mapped[str] = mapped_column(String(64), unique=True, index=True) + basis_document_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + amount: Mapped[float] = mapped_column(Float, default=0) + currency: Mapped[str] = mapped_column(String(16), default="KZT") + due_date: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + status: Mapped[str] = mapped_column(String(32), index=True, default="draft") + payment_link: Mapped[str | None] = mapped_column(Text, nullable=True) + line_items_json: Mapped[str] = mapped_column(Text, default="[]") + metadata_json: Mapped[str] = mapped_column(Text, default="{}") + issued_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + paid_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + + +class SalesPaymentRow(Base): + __tablename__ = "sales_payments" + __table_args__ = ( + Index("idx_sales_payments_deal_status", "deal_id", "status"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + payment_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + tenant_id: Mapped[str] = mapped_column(String(64), index=True) + deal_id: Mapped[str] = mapped_column(String(64), index=True) + invoice_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + payment_provider: Mapped[str | None] = mapped_column(String(64), nullable=True) + external_payment_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + amount: Mapped[float] = mapped_column(Float, default=0) + currency: Mapped[str] = mapped_column(String(16), default="KZT") + status: Mapped[str] = mapped_column(String(32), index=True, default="pending") + paid_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + payment_method: Mapped[str | None] = mapped_column(String(64), nullable=True) + failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + metadata_json: Mapped[str] = mapped_column(Text, default="{}") + created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + + +class SalesStageHistoryRow(Base): + __tablename__ = "sales_stage_history" + __table_args__ = ( + Index("idx_sales_stage_history_deal_changed", "deal_id", "changed_at"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + history_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + tenant_id: Mapped[str] = mapped_column(String(64), index=True) + deal_id: Mapped[str] = mapped_column(String(64), index=True) + from_stage_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + to_stage_id: Mapped[str] = mapped_column(String(64), index=True) + changed_by_type: Mapped[str] = mapped_column(String(32), index=True) + changed_by_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + reason: Mapped[str | None] = mapped_column(Text, nullable=True) + changed_at: Mapped[str] = mapped_column(String(64), index=True) + + +class SalesEscalationRow(Base): + __tablename__ = "sales_escalations" + __table_args__ = ( + Index("idx_sales_escalations_deal_status", "deal_id", "status"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + escalation_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + tenant_id: Mapped[str] = mapped_column(String(64), index=True) + deal_id: Mapped[str] = mapped_column(String(64), index=True) + escalation_type: Mapped[str] = mapped_column(String(64), index=True) + reason: Mapped[str] = mapped_column(Text) + severity: Mapped[str] = mapped_column(String(16), index=True) + status: Mapped[str] = mapped_column(String(32), index=True, default="open") + assigned_to_user_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + created_at: Mapped[str] = mapped_column(String(64), index=True) + resolved_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + + +class SalesAutomationTaskRow(Base): + __tablename__ = "sales_automation_tasks" + __table_args__ = ( + Index("idx_sales_tasks_run_status", "run_at", "status"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + task_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + tenant_id: Mapped[str] = mapped_column(String(64), index=True) + deal_id: Mapped[str] = mapped_column(String(64), index=True) + task_type: Mapped[str] = mapped_column(String(64), index=True) + payload_json: Mapped[str] = mapped_column(Text, default="{}") + run_at: Mapped[str] = mapped_column(String(64), index=True) + status: Mapped[str] = mapped_column(String(32), index=True, default="pending") + retry_count: Mapped[int] = mapped_column(Integer, default=0) + last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + + +class SalesChannelSwitchRow(Base): + __tablename__ = "sales_channel_switches" + __table_args__ = ( + Index("idx_sales_channel_switches_deal_switched", "deal_id", "switched_at"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + switch_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + tenant_id: Mapped[str] = mapped_column(String(64), index=True) + deal_id: Mapped[str] = mapped_column(String(64), index=True) + communication_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + from_channel: Mapped[str] = mapped_column(String(32), index=True) + to_channel: Mapped[str] = mapped_column(String(32), index=True) + reason_for_channel_switch: Mapped[str] = mapped_column(Text) + switched_at: Mapped[str] = mapped_column(String(64), index=True) + + +class SalesExternalLinkRow(Base): + __tablename__ = "sales_external_links" + __table_args__ = ( + Index("idx_sales_external_links_deal_sync", "deal_id", "last_sync_at"), + Index("idx_sales_external_links_thread", "external_thread_id"), + Index("idx_sales_external_links_voice_session", "voice_session_id"), + Index("idx_sales_external_links_external_call", "external_call_id"), + Index("idx_sales_external_links_interaction", "interaction_id"), + Index("idx_sales_external_links_customer_phone", "customer_id", "phone_number"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + link_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + tenant_id: Mapped[str] = mapped_column(String(64), index=True) + deal_id: Mapped[str] = mapped_column(String(64), index=True) + communication_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + sales_call_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + channel_provider: Mapped[str] = mapped_column(String(32), index=True) + external_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + external_chat_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + external_call_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + voice_session_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + ai_session_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + phone_number: Mapped[str | None] = mapped_column(String(64), nullable=True) + external_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + link_metadata_json: Mapped[str] = mapped_column(Text, default="{}") + created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + last_sync_at: Mapped[str] = mapped_column(String(64), index=True) diff --git a/services/streaming_asr_sidecar_service/app.py b/services/streaming_asr_sidecar_service/app.py index e6fe84f..36788f2 100644 --- a/services/streaming_asr_sidecar_service/app.py +++ b/services/streaming_asr_sidecar_service/app.py @@ -1,6 +1,5 @@ from __future__ import annotations -import audioop import base64 import logging import os @@ -13,6 +12,7 @@ from fastapi import Depends, FastAPI, HTTPException from pydantic import BaseModel from services.shared.core import Role, new_id +from services.shared.audioop_compat import audioop from services.shared.models import HealthResponse from services.shared.security import require_roles diff --git a/services/telegram_adapter_service/app.py b/services/telegram_adapter_service/app.py index e6bf2b4..1549540 100644 --- a/services/telegram_adapter_service/app.py +++ b/services/telegram_adapter_service/app.py @@ -231,6 +231,10 @@ def _ai_service_url() -> str: return os.getenv("AI_ORCHESTRATOR_SERVICE_URL", "http://localhost:8017").rstrip("/") +def _sales_service_url() -> str: + return os.getenv("SALES_SERVICE_URL", "http://localhost:8020").rstrip("/") + + def _ai_telegram_enabled() -> bool: return _bool_env("AI_TELEGRAM_ENABLED", False) @@ -276,6 +280,62 @@ def _interaction_request(method: str, path: str, *, payload: dict | None = None) return response.json() +def _sales_sync_request(payload: dict[str, Any]) -> None: + try: + with httpx.Client(timeout=5.0) as client: + response = client.post( + f"{_sales_service_url()}/internal/sales-sync/telegram", + json=payload, + headers=_internal_service_headers( + subject="svc:telegram-adapter", + username="telegram-adapter", + provider="telegram-adapter", + ), + ) + response.raise_for_status() + except Exception: + logger.exception( + "Telegram sales sync failed", + extra={ + "thread_id": payload.get("thread_id"), + "message_id": payload.get("message_id"), + "direction": payload.get("direction"), + }, + ) + + +def _sync_sales_thread(thread: TelegramThreadRow, row: TelegramMessageRow | None = None) -> None: + payload = { + "thread_id": thread.thread_id, + "chat_id": thread.chat_id, + "interaction_id": thread.interaction_id, + "customer_id": getattr(row, "customer_id", None), + "phone_number": None, + "display_name": thread.display_name, + "queue_id": thread.queue_id, + "status": thread.status, + "ai_state": thread.ai_state, + "ai_handoff_reason": thread.ai_handoff_reason, + "message_id": getattr(row, "message_id", None), + "external_message_id": getattr(row, "telegram_message_id_external", None), + "text": getattr(row, "text", None), + "direction": getattr(row, "direction", "inbound"), + "author_type": getattr(row, "author_type", None), + "author_id": getattr(row, "author_id", None), + "happened_at": getattr(row, "created_at", None) or thread.updated_at, + "metadata": { + "telegram_user_id": thread.telegram_user_id, + "username": thread.username, + "claimed_by_user": thread.claimed_by_user, + "claimed_at": thread.claimed_at, + "ai_session_id": thread.ai_session_id, + "last_message_at": thread.last_message_at, + "last_message_preview": thread.last_message_preview, + }, + } + _sales_sync_request(payload) + + def _ai_enqueue_request(thread_id: str, trigger_message_id: str | None) -> None: if not _ai_telegram_enabled(): return @@ -1563,6 +1623,7 @@ def webhook(payload: TelegramWebhookIn) -> TelegramWebhookOut: session.commit() session.refresh(row) session.refresh(thread) + _sync_sales_thread(thread, row) _maybe_enqueue_ai_for_thread(thread, row.message_id, author_type=row.author_type) return _to_webhook_out(row) finally: @@ -1614,6 +1675,9 @@ def bot_webhook( if existing: return _to_bot_webhook_result(existing, thread_created=False) raise + session.refresh(row) + session.refresh(thread) + _sync_sales_thread(thread, row) _maybe_enqueue_ai_for_thread(thread, row.message_id, author_type=row.author_type) return _to_bot_webhook_result(row, thread_created=created) finally: @@ -1768,6 +1832,7 @@ def claim_thread( ) session.commit() session.refresh(thread) + _sync_sales_thread(thread) return _to_thread_out(thread) finally: session.close() @@ -1826,6 +1891,7 @@ def return_thread_to_ai( ) session.commit() session.refresh(thread) + _sync_sales_thread(thread) return _to_thread_out(thread) finally: session.close() @@ -1872,6 +1938,8 @@ def reply_thread( ) session.commit() session.refresh(row) + session.refresh(thread) + _sync_sales_thread(thread, row) _start_telegram_reply_delivery(row.message_id) return _to_message_out(row) finally: @@ -1947,6 +2015,8 @@ def ai_reply_thread( ) session.commit() session.refresh(row) + session.refresh(thread) + _sync_sales_thread(thread, row) _start_telegram_reply_delivery(row.message_id) return _to_message_out(row) finally: @@ -1996,6 +2066,7 @@ def ai_handoff_thread( ) session.commit() session.refresh(thread) + _sync_sales_thread(thread) return _to_thread_out(thread) finally: session.close() @@ -2032,6 +2103,7 @@ def close_thread( ) session.commit() session.refresh(thread) + _sync_sales_thread(thread) return _to_thread_out(thread) finally: session.close() @@ -2067,6 +2139,7 @@ def escalate_thread( ) session.commit() session.refresh(thread) + _sync_sales_thread(thread) return _to_thread_out(thread) finally: session.close() diff --git a/tests/test_demo_ready_scripts.py b/tests/test_demo_ready_scripts.py index 5d88270..72525a3 100644 --- a/tests/test_demo_ready_scripts.py +++ b/tests/test_demo_ready_scripts.py @@ -14,6 +14,7 @@ def test_local_stack_gateway_env_contains_expected_urls(): assert env["WEBCHAT_ADAPTER_SERVICE_URL"] == "http://127.0.0.1:8011" assert env["EMAIL_ADAPTER_SERVICE_URL"] == "http://127.0.0.1:8012" assert env["AI_VOICE_RUNTIME_SERVICE_URL"] == "http://127.0.0.1:8018" + assert env["SALES_SERVICE_URL"] == "http://127.0.0.1:8020" def test_local_stack_service_specs_include_ai_voice_runtime(): @@ -22,6 +23,7 @@ def test_local_stack_service_specs_include_ai_voice_runtime(): assert "ai" in names assert "ai-voice-runtime" in names assert "asterisk-bridge" in names + assert "sales" in names def test_local_stack_manifest_payload_has_core_fields(tmp_path: Path): diff --git a/tests/test_gateway_ui.py b/tests/test_gateway_ui.py index 6e45acb..c3ae37c 100644 --- a/tests/test_gateway_ui.py +++ b/tests/test_gateway_ui.py @@ -28,6 +28,14 @@ def test_login_ui_route_exists(): assert "login/assets/app.js" in response.text +def test_sales_ui_route_exists(): + client = TestClient(app) + response = client.get("/sales") + assert response.status_code == 200 + assert "sales/assets/app.js" in response.text + assert 'id="sendTelegramReplyBtn"' in response.text + + def test_legal_routes_exist_for_meta_review(): client = TestClient(app) @@ -60,6 +68,7 @@ def test_contracts_shape(): assert "stage_15" in payload assert "stage_16" in payload assert "stage_17" in payload + assert "stage_18" in payload assert "/integrations/webchat/messages" in payload["stage_2"] assert "/integrations/email/messages" in payload["stage_2"] assert "/integrations/whatsapp/webhook" in payload["stage_2"] @@ -89,6 +98,10 @@ def test_contracts_shape(): assert "/ai/analytics/drilldown" in payload["stage_16"] assert "/ai/analytics/sessions/*" in payload["stage_16"] assert "/asterisk/live-calls/*/ai-summary" in payload["stage_17"] + assert "/api/v1/leads" in payload["stage_18"] + assert "/api/v1/deals" in payload["stage_18"] + assert "/api/v1/messages/inbound-webhook" in payload["stage_18"] + assert "/api/v1/calls/inbound-webhook" in payload["stage_18"] def test_operator_ui_includes_favicon_link(): diff --git a/tests/test_sales_service.py b/tests/test_sales_service.py new file mode 100644 index 0000000..2dd9444 --- /dev/null +++ b/tests/test_sales_service.py @@ -0,0 +1,300 @@ +from fastapi.testclient import TestClient + +import services.sales_service.app as sales_module + + +def _headers() -> dict[str, str]: + return {"X-User": "admin", "X-Role": "admin"} + + +def _create_lead(client: TestClient) -> tuple[dict, str]: + created = client.post( + "/api/v1/leads", + json={ + "source_type": "crm", + "source_channel": "telegram", + "full_name": "Test Buyer", + "company_name": "Sales QA", + "phone": "+77001234567", + "email": "buyer@test.local", + "lead_temperature": "hot", + "lead_score": 82, + "initial_need_summary": "Need a commercial offer and a callback.", + "preferred_channel": "telegram", + "assigned_agent_type": "text_ai", + "status": "new_qualified_lead", + "priority": 3, + "title": "Lead: Test Buyer", + }, + headers=_headers(), + ) + assert created.status_code == 200 + lead = created.json() + deals = client.get("/api/v1/deals", headers=_headers()) + assert deals.status_code == 200 + deal_id = next(item["deal_id"] for item in deals.json() if item["lead_id"] == lead["lead_id"]) + return lead, deal_id + + +class _DummySyncResponse: + def __init__(self, payload: dict[str, str]): + self._payload = payload + self.status_code = 200 + self.text = "" + + def raise_for_status(self) -> None: + return None + + def json(self) -> dict[str, str]: + return self._payload + + +class _DummyHttpxClient: + requests: list[dict] = [] + response_payload: dict[str, str] = {} + + def __init__(self, timeout: float): + self.timeout = timeout + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return None + + def post(self, url: str, json: dict | None = None, headers: dict | None = None): + self.__class__.requests.append( + { + "url": url, + "json": json or {}, + "headers": headers or {}, + "timeout": self.timeout, + } + ) + return _DummySyncResponse(self.__class__.response_payload) + + +def test_sales_lead_creation_auto_creates_deal_and_workspace(): + client = TestClient(sales_module.app) + + lead, deal_id = _create_lead(client) + workspace = client.get(f"/api/v1/deals/{deal_id}/workspace", headers=_headers()) + + assert workspace.status_code == 200 + payload = workspace.json() + assert payload["lead"]["lead_id"] == lead["lead_id"] + assert payload["deal"]["deal_id"] == deal_id + assert payload["deal"]["preferred_channel"] == "telegram" + assert payload["timeline"] + + +def test_sales_bind_telegram_and_send_outbound_message(monkeypatch): + client = TestClient(sales_module.app) + _, deal_id = _create_lead(client) + _DummyHttpxClient.requests = [] + _DummyHttpxClient.response_payload = {"ok": True} + monkeypatch.setattr(sales_module.httpx, "Client", _DummyHttpxClient) + monkeypatch.setenv("SALES_TELEGRAM_BRIDGE_ENABLED", "1") + + created_communication = client.post( + f"/api/v1/deals/{deal_id}/communications/text", + json={ + "channel_type": "text", + "direction": "outbound", + "agent_type": "text_ai", + "subject": "Telegram follow-up", + }, + headers=_headers(), + ) + assert created_communication.status_code == 200 + communication = created_communication.json() + + bound = client.post( + f"/api/v1/communications/{communication['communication_id']}/bind-external", + json={ + "channel_provider": "telegram", + "telegram_thread_id": "tg-thread-42", + "telegram_chat_id": "tg-chat-42", + }, + headers=_headers(), + ) + assert bound.status_code == 200 + assert bound.json()["metadata"]["telegram_thread_id"] == "tg-thread-42" + + outbound = client.post( + "/api/v1/messages/outbound", + json={ + "deal_id": deal_id, + "communication_id": communication["communication_id"], + "channel_provider": "telegram", + "sender_type": "human", + "sender_id": "admin", + "body": "Please check the offer in Telegram.", + "metadata": {"telegram_thread_id": "tg-thread-42"}, + }, + headers=_headers(), + ) + assert outbound.status_code == 200 + assert outbound.json()["delivery_status"] == "sent" + assert _DummyHttpxClient.requests + assert _DummyHttpxClient.requests[0]["url"].endswith("/integrations/telegram/threads/tg-thread-42/messages") + assert _DummyHttpxClient.requests[0]["json"]["text"] == "Please check the offer in Telegram." + + switched = client.post( + f"/api/v1/communications/{communication['communication_id']}/switch-channel", + json={ + "to_channel": "voice", + "reason_for_channel_switch": "Need to clarify details by phone", + }, + headers=_headers(), + ) + assert switched.status_code == 200 + + workspace = client.get(f"/api/v1/deals/{deal_id}/workspace", headers=_headers()) + assert workspace.status_code == 200 + payload = workspace.json() + assert payload["deal"]["current_channel"] == "voice" + assert payload["channel_switches"][0]["to_channel"] == "voice" + + +def test_sales_inbound_call_bridges_voice_runtime(monkeypatch): + client = TestClient(sales_module.app) + _, deal_id = _create_lead(client) + _DummyHttpxClient.requests = [] + _DummyHttpxClient.response_payload = {"voice_session_id": "avs_sales_case_01"} + monkeypatch.setattr(sales_module.httpx, "Client", _DummyHttpxClient) + monkeypatch.setenv("SALES_VOICE_RUNTIME_ENABLED", "1") + + response = client.post( + "/api/v1/calls/inbound-webhook", + json={ + "deal_id": deal_id, + "phone_number": "+77001234567", + "provider": "asterisk", + "external_call_id": "call-sales-01", + "subject": "Inbound sales call", + }, + headers=_headers(), + ) + assert response.status_code == 200 + assert _DummyHttpxClient.requests + assert _DummyHttpxClient.requests[0]["url"].endswith("/internal/voice-ai/sessions") + assert _DummyHttpxClient.requests[0]["json"]["call_id"] == "call-sales-01" + + workspace = client.get(f"/api/v1/deals/{deal_id}/workspace", headers=_headers()) + assert workspace.status_code == 200 + communication = workspace.json()["communications"][0] + assert communication["channel_type"] == "voice" + assert communication["metadata"]["voice_session_id"] == "avs_sales_case_01" + + +def test_sales_internal_telegram_sync_auto_creates_workspace(): + client = TestClient(sales_module.app) + + response = client.post( + "/internal/sales-sync/telegram", + json={ + "thread_id": "tg-thread-auto-01", + "chat_id": "tg-chat-auto-01", + "interaction_id": "int_tg_auto_01", + "phone_number": "+77005550101", + "display_name": "Telegram Prospect", + "queue_id": "q_sales", + "status": "new", + "ai_state": "queued", + "message_id": "msg_tg_auto_01", + "external_message_id": "ext_tg_auto_01", + "text": "Здравствуйте, нужен расчет и коммерческое предложение.", + "direction": "inbound", + "author_type": "customer", + "author_id": "tg-user-01", + "metadata": {"username": "sales_prospect"}, + }, + headers=_headers(), + ) + + assert response.status_code == 200 + workspace = response.json() + assert workspace["deal"]["current_channel"] == "telegram" + assert workspace["deal"]["preferred_channel"] == "telegram" + assert workspace["communications"][0]["channel_provider"] == "telegram" + assert workspace["communications"][0]["metadata"]["telegram_thread_id"] == "tg-thread-auto-01" + assert workspace["messages"][0]["external_message_id"] == "ext_tg_auto_01" + + +def test_sales_internal_voice_sync_creates_call_and_transcript(): + client = TestClient(sales_module.app) + + response = client.post( + "/internal/sales-sync/voice", + json={ + "call_id": "call-auto-voice-01", + "interaction_id": "int_voice_auto_01", + "queue_id": "q_voice_sales", + "queue_code": "voice_sales", + "caller_number": "+77005550202", + "caller_name": "Voice Prospect", + "voice_session_id": "avs_auto_01", + "ai_session_id": "ais_auto_01", + "ai_state": "completed", + "telephony_status": "ended", + "call_status": "completed", + "started_at": "2026-05-08T10:00:00Z", + "ended_at": "2026-05-08T10:03:00Z", + "summary": "Клиент запросил прайс и условия подключения.", + "transcript_text": "customer: Добрый день, нужен прайс.\nassistant: Подготовлю информацию.", + "metadata": {"agent_profile": "voice_sales"}, + }, + headers=_headers(), + ) + + assert response.status_code == 200 + workspace = response.json() + assert workspace["deal"]["current_channel"] == "voice" + assert workspace["communications"][0]["channel_type"] == "voice" + assert workspace["communications"][0]["metadata"]["voice_session_id"] == "avs_auto_01" + assert workspace["calls"][0]["external_call_id"] == "call-auto-voice-01" + assert workspace["transcripts"][0]["transcript_text"].startswith("customer:") + + +def test_sales_list_deals_query_matches_external_links(): + client = TestClient(sales_module.app) + + voice_workspace = client.post( + "/internal/sales-sync/voice", + json={ + "call_id": "call-search-voice-01", + "interaction_id": "int_search_voice_01", + "caller_number": "+77005550303", + "caller_name": "Voice Search Prospect", + "voice_session_id": "avs_search_01", + "call_status": "completed", + }, + headers=_headers(), + ) + assert voice_workspace.status_code == 200 + + telegram_workspace = client.post( + "/internal/sales-sync/telegram", + json={ + "thread_id": "tg-thread-search-01", + "chat_id": "tg-chat-search-01", + "interaction_id": "int_search_tg_01", + "display_name": "Telegram Search Prospect", + "text": "Need pricing details", + }, + headers=_headers(), + ) + assert telegram_workspace.status_code == 200 + + by_voice_session = client.get("/api/v1/deals?query=avs_search_01", headers=_headers()) + assert by_voice_session.status_code == 200 + assert any(item["deal_id"] == voice_workspace.json()["deal"]["deal_id"] for item in by_voice_session.json()) + + by_call_id = client.get("/api/v1/deals?query=call-search-voice-01", headers=_headers()) + assert by_call_id.status_code == 200 + assert any(item["deal_id"] == voice_workspace.json()["deal"]["deal_id"] for item in by_call_id.json()) + + by_thread_id = client.get("/api/v1/deals?query=tg-thread-search-01", headers=_headers()) + assert by_thread_id.status_code == 200 + assert any(item["deal_id"] == telegram_workspace.json()["deal"]["deal_id"] for item in by_thread_id.json()) diff --git a/ui/admin/index.html b/ui/admin/index.html index dd12ad9..59f3f50 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -27,6 +27,7 @@ Оператор Супервизор Аналитика + Продажи Администрирование