sales fix

This commit is contained in:
arys
2026-05-09 09:16:06 +05:00
parent d617b6908c
commit f53af1f7dc
35 changed files with 7495 additions and 6 deletions
+3
View File
@@ -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={}
+3
View File
@@ -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
+10
View File
@@ -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
@@ -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:
@@ -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:
+10
View File
@@ -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:
+15
View File
@@ -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"
@@ -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.
+19
View File
@@ -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"])
+345
View File
@@ -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);
+345
View File
@@ -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);
@@ -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);
@@ -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);
+3 -2
View File
@@ -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
+173
View File
@@ -83,6 +83,10 @@ def _interaction_service_url() -> str:
return os.getenv("INTERACTION_SERVICE_URL", "http://localhost:8004").rstrip("/")
def _sales_service_url() -> str:
return os.getenv("SALES_SERVICE_URL", "http://localhost:8020").rstrip("/")
def _asr_provider_name() -> str:
return os.getenv("AI_VOICE_ASR_PROVIDER", "yandex").strip() or "yandex"
@@ -266,6 +270,10 @@ def _interaction_request(method: str, path: str, *, payload: dict[str, Any] | No
return _request(method, f"{_interaction_service_url()}{path}", payload=payload, timeout=timeout)
def _sales_request(method: str, path: str, *, payload: dict[str, Any] | None = None, timeout: float = 8.0) -> dict[str, Any]:
return _request(method, f"{_sales_service_url()}{path}", payload=payload, timeout=timeout)
def _resolved_voice_language(language_hint: str | None, fallback: str | None = None) -> str:
return str(language_hint or fallback or "ru").strip() or "ru"
@@ -302,6 +310,136 @@ def _load_voice_session(session, session_id: str) -> VoiceAISessionRow:
return row
def _load_call_link(session, call_id: str) -> AsteriskCallLinkRow | None:
return session.execute(
select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == call_id)
).scalar_one_or_none()
def _latest_caller_transcript_text(session, session_id: str) -> str | None:
row = session.execute(
select(VoiceTranscriptSegmentRow)
.where(VoiceTranscriptSegmentRow.session_id == session_id)
.where(VoiceTranscriptSegmentRow.speaker == "caller")
.where(VoiceTranscriptSegmentRow.is_final.is_(True))
.order_by(VoiceTranscriptSegmentRow.sequence_no.desc())
.limit(1)
).scalar_one_or_none()
if row is None:
return None
text = str(row.text or "").strip()
return text or None
def _sales_transcript_snapshot(session, session_id: str, *, max_chars: int = 6000) -> str | None:
rows = session.execute(
select(VoiceTranscriptSegmentRow)
.where(VoiceTranscriptSegmentRow.session_id == session_id)
.where(VoiceTranscriptSegmentRow.is_final.is_(True))
.order_by(VoiceTranscriptSegmentRow.sequence_no.asc())
).scalars().all()
lines: list[str] = []
for row in rows:
text = str(row.text or "").strip()
if not text:
continue
speaker = "customer" if str(row.speaker or "").strip() == "caller" else str(row.speaker or "assistant").strip()
lines.append(f"{speaker}: {text}")
if not lines:
return None
selected: list[str] = []
total = 0
for line in reversed(lines):
line_len = len(line) + (1 if selected else 0)
if selected and total + line_len > max_chars:
break
if not selected and len(line) > max_chars:
selected.append(line[-max_chars:])
total = len(selected[0])
break
selected.append(line)
total += line_len
return "\n".join(reversed(selected)) or None
def _sync_sales_voice_session(
session_id: str,
*,
caller_number: str | None = None,
caller_name: str | None = None,
queue_code: str | None = None,
summary: str | None = None,
include_transcript: bool = False,
metadata: dict[str, Any] | None = None,
) -> None:
session = get_session()
try:
voice_session = _load_voice_session(session, session_id)
link = _load_call_link(session, voice_session.call_id)
resolved_summary = (
str(summary or "").strip()
or _latest_caller_transcript_text(session, session_id)
or str(voice_session.handoff_reason or "").strip()
or None
)
transcript_text = _sales_transcript_snapshot(session, session_id) if include_transcript else None
payload = {
"call_id": voice_session.call_id,
"interaction_id": str(voice_session.interaction_id or getattr(link, "interaction_id", "") or "").strip() or None,
"queue_id": str(voice_session.queue_id or getattr(link, "queue_id", "") or "").strip() or None,
"queue_code": str(getattr(link, "queue_code", "") or queue_code or "").strip() or None,
"caller_number": str(getattr(link, "caller_number", "") or caller_number or "").strip() or None,
"caller_name": str(getattr(link, "caller_name", "") or caller_name or "").strip() or None,
"voice_session_id": voice_session.session_id,
"ai_session_id": voice_session.ai_session_id,
"ai_state": voice_session.status,
"handoff_reason": voice_session.handoff_reason,
"telephony_status": str(getattr(link, "telephony_status", "") or voice_session.media_status or "").strip() or None,
"call_status": str(getattr(link, "status", "") or voice_session.status or "").strip() or None,
"started_at": voice_session.started_at,
"ended_at": voice_session.ended_at,
"summary": resolved_summary,
"transcript_text": transcript_text,
"metadata": {
"linked_id": voice_session.linked_id,
"agent_profile": voice_session.agent_profile,
"language": voice_session.language,
"asr_provider": voice_session.asr_provider,
"tts_provider": voice_session.tts_provider,
"handoff_target_queue_id": voice_session.handoff_target_queue_id,
"media_status": voice_session.media_status,
"media_uuid": voice_session.media_uuid,
"media_connected_at": voice_session.media_connected_at,
"media_ended_at": voice_session.media_ended_at,
"last_media_frame_at": voice_session.last_media_frame_at,
"last_user_utterance_at": voice_session.last_user_utterance_at,
"last_ai_reply_at": voice_session.last_ai_reply_at,
"voice_start_language": voice_session.voice_start_language,
"customer_name_status": voice_session.customer_name_status,
"customer_name_value": voice_session.customer_name_value,
"customer_name_source": voice_session.customer_name_source,
"customer_name_resolved_at": voice_session.customer_name_resolved_at,
"claimed_by_user": getattr(link, "claimed_by_user", None),
"claimed_at": getattr(link, "claimed_at", None),
"operator_extension": getattr(link, "operator_extension", None),
"channel_name": getattr(link, "channel_name", None),
**(metadata or {}),
},
}
finally:
session.close()
try:
_sales_request("POST", "/internal/sales-sync/voice", payload=payload, timeout=8.0)
except Exception:
LOGGER.exception(
"voice_runtime.sales_sync_failed session_id=%s call_id=%s ai_state=%s",
payload.get("voice_session_id"),
payload.get("call_id"),
payload.get("ai_state"),
)
def _next_sequence(session, session_id: str) -> int:
return next_transcript_sequence(session, session_id)
@@ -1007,6 +1145,11 @@ def _set_voice_session_state(
handoff_reason=handoff_reason,
metadata=metadata,
)
_sync_sales_voice_session(
voice_session.session_id,
include_transcript=ai_state in {"handoff_requested", "human_owned", "completed", "closed", "error"},
metadata=metadata,
)
if ai_state == "error":
_append_interaction_timeline(
voice_session.interaction_id,
@@ -1150,6 +1293,11 @@ def _handle_media_error(session_id: str, message: str, metadata: dict[str, Any]
)
except Exception:
pass
_sync_sales_voice_session(
voice_session.session_id,
include_transcript=True,
metadata=metadata,
)
finally:
session.close()
@@ -1235,6 +1383,13 @@ def _process_voice_ai_turn_sync(
session.commit()
if auto_handoff and decision.needs_handoff and not early_plan_only:
_request_runtime_handoff(session_id, payload.transcript_text, decision)
if not early_plan_only:
_sync_sales_voice_session(
session_id,
include_transcript=True,
summary=decision.summary_text or decision.reply_text or payload.transcript_text,
metadata=decision.metadata,
)
return decision
except HTTPException:
raise
@@ -1331,6 +1486,11 @@ def _complete_voice_ai_session_start(session_id: str, start_payload: VoiceAIStar
handoff_reason=voice_session.handoff_reason,
metadata=handoff_metadata,
)
_sync_sales_voice_session(
voice_session.session_id,
include_transcript=False,
metadata=handoff_metadata,
)
if needs_handoff:
handoff_summary = {
"customer_request_text": "voice_start",
@@ -1502,6 +1662,14 @@ def create_voice_ai_session(
)
_register_voice_start_thread(voice_start_thread)
voice_start_thread.start()
_sync_sales_voice_session(
voice_session.session_id,
caller_number=payload.caller_number,
caller_name=payload.caller_name,
queue_code=str(payload_metadata.get("queue_code") or "").strip() or None,
include_transcript=False,
metadata=payload_metadata,
)
return VoiceAISessionOut(
voice_session_id=voice_session.session_id,
ai_session_id=voice_session.ai_session_id,
@@ -1574,6 +1742,11 @@ def push_voice_ai_telephony_event(
)
except Exception:
pass
_sync_sales_voice_session(
session_id,
include_transcript=payload.event_type in {"operator.connected", "call.ended"},
metadata={"telephony_event_type": payload.event_type, **(payload.payload or {})},
)
return VoiceAISessionOut(
voice_session_id=session_id,
@@ -1,13 +1,14 @@
from __future__ import annotations
import asyncio
import audioop
import io
import struct
import uuid
import wave
from dataclasses import dataclass
from services.shared.audioop_compat import audioop
AUDIO_SOCKET_PACKET_HANGUP = 0x00
AUDIO_SOCKET_PACKET_UUID = 0x01
@@ -1,7 +1,6 @@
from __future__ import annotations
import asyncio
import audioop
import contextlib
import hashlib
import logging
@@ -33,6 +32,7 @@ from services.ai_voice_runtime_service.providers.asr import (
StreamingASRProvider,
StreamingASRUnavailable,
)
from services.shared.audioop_compat import audioop
from services.ai_voice_runtime_service.providers.tts import TTSProvider
from services.shared.models import VoiceAITurnDecisionOut
@@ -1,6 +1,5 @@
from __future__ import annotations
import audioop
import base64
import io
import json
@@ -17,6 +16,7 @@ from dataclasses import dataclass, field
import httpx
from services.ai_voice_runtime_service.audiosocket import pcm16le_to_wav_bytes, resample_pcm16le
from services.shared.audioop_compat import audioop
from services.shared.security import issue_app_token
+2
View File
@@ -0,0 +1,2 @@
"""Compatibility package for real-time voice runtime service."""
+15
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
"""Sales service package."""
File diff suppressed because it is too large Load Diff
+102
View File
@@ -0,0 +1,102 @@
from __future__ import annotations
import math
import struct
from array import array
try:
import audioop as _audioop
except ModuleNotFoundError:
try:
import audioop_lts as _audioop # type: ignore[import-not-found]
except ModuleNotFoundError:
_audioop = None
def _native_is_little_endian() -> bool:
return struct.pack("=h", 1) == struct.pack("<h", 1)
def _read_int16_samples(fragment: bytes, width: int) -> array:
if width != 2:
raise NotImplementedError("audioop_compat fallback currently supports only 16-bit PCM")
samples = array("h")
samples.frombytes(fragment)
if not _native_is_little_endian():
samples.byteswap()
return samples
def _write_int16_samples(samples: array) -> bytes:
output = array("h", samples)
if not _native_is_little_endian():
output.byteswap()
return output.tobytes()
def _clip_int16(value: float) -> int:
return max(-32768, min(32767, int(round(value))))
class _AudioopFallback:
@staticmethod
def rms(fragment: bytes, width: int) -> int:
samples = _read_int16_samples(fragment, width)
if not samples:
return 0
mean_square = sum(sample * sample for sample in samples) / len(samples)
return int(math.sqrt(mean_square))
@staticmethod
def tomono(fragment: bytes, width: int, lfactor: float, rfactor: float) -> bytes:
samples = _read_int16_samples(fragment, width)
if len(samples) % 2 != 0:
raise ValueError("Stereo PCM must contain an even number of samples")
mono = array("h")
for index in range(0, len(samples), 2):
left = samples[index]
right = samples[index + 1]
mono.append(_clip_int16((left * lfactor) + (right * rfactor)))
return _write_int16_samples(mono)
@staticmethod
def ratecv(
fragment: bytes,
width: int,
nchannels: int,
inrate: int,
outrate: int,
state,
weightA: int = 1,
weightB: int = 0,
) -> tuple[bytes, None]:
if nchannels <= 0:
raise ValueError("nchannels must be positive")
if inrate <= 0 or outrate <= 0:
raise ValueError("Sample rates must be positive")
samples = _read_int16_samples(fragment, width)
if not samples or inrate == outrate:
return fragment, None
if len(samples) % nchannels != 0:
raise ValueError("PCM fragment size does not match channel count")
frame_count = len(samples) // nchannels
output_frame_count = max(1, int(round(frame_count * outrate / inrate)))
output = array("h")
for out_index in range(output_frame_count):
position = out_index * inrate / outrate
left_index = min(int(position), frame_count - 1)
right_index = min(left_index + 1, frame_count - 1)
fraction = max(0.0, min(1.0, position - left_index))
for channel_index in range(nchannels):
left_sample = samples[(left_index * nchannels) + channel_index]
right_sample = samples[(right_index * nchannels) + channel_index]
interpolated = left_sample + ((right_sample - left_sample) * fraction)
output.append(_clip_int16(interpolated))
return _write_int16_samples(output), None
audioop = _audioop or _AudioopFallback()
__all__ = ["audioop"]
+685
View File
@@ -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)
+445
View File
@@ -0,0 +1,445 @@
from __future__ import annotations
from sqlalchemy import Boolean, Float, Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from services.shared.sql_models import Base
class SalesLeadRow(Base):
__tablename__ = "sales_leads"
__table_args__ = (
Index("idx_sales_leads_tenant_status_score", "tenant_id", "status", "lead_score"),
Index("idx_sales_leads_phone_email", "phone", "email"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
lead_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
source_type: Mapped[str] = mapped_column(String(64), index=True)
source_channel: Mapped[str] = mapped_column(String(32), index=True)
source_campaign_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
full_name: Mapped[str] = mapped_column(String(256), index=True)
company_name: Mapped[str | None] = mapped_column(String(256), nullable=True, index=True)
phone: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
email: Mapped[str | None] = mapped_column(String(256), nullable=True, index=True)
messenger_handles_json: Mapped[str] = mapped_column(Text, default="{}")
lead_temperature: Mapped[str] = mapped_column(String(16), index=True)
lead_score: Mapped[float] = mapped_column(Float, default=50.0, index=True)
customer_type: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
segment_type: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
initial_need_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
preferred_channel: Mapped[str] = mapped_column(String(32), index=True)
assigned_agent_type: Mapped[str] = mapped_column(String(32), index=True)
status: Mapped[str] = mapped_column(String(32), index=True)
crm_customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesDealRow(Base):
__tablename__ = "sales_deals"
__table_args__ = (
Index("idx_sales_deals_tenant_stage_status", "tenant_id", "stage_id", "status"),
Index("idx_sales_deals_customer_channel", "customer_id", "current_channel"),
Index("idx_sales_deals_next_action", "next_action_at", "status"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
deal_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
lead_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
pipeline_id: Mapped[str] = mapped_column(String(64), default="sales_default", index=True)
stage_id: Mapped[str] = mapped_column(String(64), index=True)
scenario_type: Mapped[str] = mapped_column(String(64), index=True)
priority: Mapped[int] = mapped_column(Integer, default=3, index=True)
title: Mapped[str] = mapped_column(String(512))
need_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
product_context_json: Mapped[str] = mapped_column(Text, default="{}")
estimated_amount: Mapped[float | None] = mapped_column(Float, nullable=True)
final_amount: Mapped[float | None] = mapped_column(Float, nullable=True)
currency: Mapped[str] = mapped_column(String(16), default="KZT")
payment_model: Mapped[str | None] = mapped_column(String(64), nullable=True)
document_required: Mapped[bool] = mapped_column(Boolean, default=True)
payment_required: Mapped[bool] = mapped_column(Boolean, default=True)
assigned_human_user_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
assigned_ai_orchestrator_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
preferred_channel: Mapped[str] = mapped_column(String(32), index=True)
current_channel: Mapped[str] = mapped_column(String(32), index=True)
status: Mapped[str] = mapped_column(String(32), index=True, default="active")
won_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
lost_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
close_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
next_action_type: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
next_action_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
last_contact_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
closed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesCommunicationSessionRow(Base):
__tablename__ = "sales_communication_sessions"
__table_args__ = (
Index("idx_sales_comm_deal_started", "deal_id", "started_at"),
Index("idx_sales_comm_channel_status", "channel_type", "status"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
communication_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
lead_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
channel_type: Mapped[str] = mapped_column(String(16), index=True)
direction: Mapped[str] = mapped_column(String(16), index=True)
agent_type: Mapped[str] = mapped_column(String(32), index=True)
started_at: Mapped[str] = mapped_column(String(64), index=True)
ended_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
duration_sec: Mapped[int | None] = mapped_column(Integer, nullable=True)
subject: Mapped[str | None] = mapped_column(String(512), nullable=True)
status: Mapped[str] = mapped_column(String(32), index=True, default="active")
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
transcript_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
next_action_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
next_action_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
sentiment: Mapped[str | None] = mapped_column(String(32), nullable=True)
result_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
metadata_json: Mapped[str] = mapped_column(Text, default="{}")
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesMessageRow(Base):
__tablename__ = "sales_messages"
__table_args__ = (
Index("idx_sales_messages_deal_sent", "deal_id", "sent_at"),
Index("idx_sales_messages_ext_id", "channel_provider", "external_message_id"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
message_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
communication_id: Mapped[str] = mapped_column(String(64), index=True)
sender_type: Mapped[str] = mapped_column(String(32), index=True)
sender_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
channel_provider: Mapped[str] = mapped_column(String(32), index=True)
external_message_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
body: Mapped[str] = mapped_column(Text)
attachments_json: Mapped[str] = mapped_column(Text, default="[]")
delivery_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
read_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
message_metadata_json: Mapped[str] = mapped_column(Text, default="{}")
sent_at: Mapped[str] = mapped_column(String(64), index=True)
created_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesCallRow(Base):
__tablename__ = "sales_calls"
__table_args__ = (
Index("idx_sales_calls_deal_started", "deal_id", "started_at"),
Index("idx_sales_calls_ext_provider", "provider", "external_call_id"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
call_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
communication_id: Mapped[str] = mapped_column(String(64), index=True)
phone_number: Mapped[str] = mapped_column(String(64), index=True)
direction: Mapped[str] = mapped_column(String(16), index=True)
provider: Mapped[str] = mapped_column(String(64), index=True)
external_call_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
recording_url: Mapped[str | None] = mapped_column(Text, nullable=True)
transcript_status: Mapped[str] = mapped_column(String(32), index=True, default="pending")
transcript_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
call_status: Mapped[str] = mapped_column(String(32), index=True, default="started")
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
started_at: Mapped[str] = mapped_column(String(64), index=True)
ended_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
duration_sec: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesTranscriptRow(Base):
__tablename__ = "sales_transcripts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
transcript_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
call_id: Mapped[str] = mapped_column(String(64), index=True)
language: Mapped[str] = mapped_column(String(16), index=True, default="ru")
transcript_text: Mapped[str] = mapped_column(Text)
diarization_json: Mapped[str] = mapped_column(Text, default="{}")
extracted_entities_json: Mapped[str] = mapped_column(Text, default="{}")
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesNoteRow(Base):
__tablename__ = "sales_notes"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
note_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
author_type: Mapped[str] = mapped_column(String(32), index=True)
author_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
note_type: Mapped[str] = mapped_column(String(64), index=True)
content: Mapped[str] = mapped_column(Text)
created_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesOfferRow(Base):
__tablename__ = "sales_offers"
__table_args__ = (
Index("idx_sales_offers_deal_status", "deal_id", "status"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
offer_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
offer_type: Mapped[str] = mapped_column(String(32), index=True)
title: Mapped[str] = mapped_column(String(256))
description: Mapped[str | None] = mapped_column(Text, nullable=True)
line_items_json: Mapped[str] = mapped_column(Text, default="[]")
pricing_json: Mapped[str] = mapped_column(Text, default="{}")
total_amount: Mapped[float] = mapped_column(Float, default=0)
currency: Mapped[str] = mapped_column(String(16), default="KZT")
validity_until: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
status: Mapped[str] = mapped_column(String(32), index=True, default="draft")
rendered_document_url: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by_type: Mapped[str] = mapped_column(String(32), default="text_ai")
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesConditionRow(Base):
__tablename__ = "sales_conditions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
condition_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
product_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
service_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
quantity: Mapped[float | None] = mapped_column(Float, nullable=True)
unit: Mapped[str | None] = mapped_column(String(32), nullable=True)
delivery_mode: Mapped[str | None] = mapped_column(String(64), nullable=True)
execution_date: Mapped[str | None] = mapped_column(String(64), nullable=True)
start_date: Mapped[str | None] = mapped_column(String(64), nullable=True)
end_date: Mapped[str | None] = mapped_column(String(64), nullable=True)
payment_terms: Mapped[str | None] = mapped_column(Text, nullable=True)
custom_terms_json: Mapped[str] = mapped_column(Text, default="{}")
agreed_price: Mapped[float | None] = mapped_column(Float, nullable=True)
currency: Mapped[str] = mapped_column(String(16), default="KZT")
confirmed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesCounterpartyRow(Base):
__tablename__ = "sales_counterparties"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
counterparty_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
company_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
full_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
bin_iin: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
address: Mapped[str | None] = mapped_column(Text, nullable=True)
bank_details_json: Mapped[str] = mapped_column(Text, default="{}")
signer_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
signer_role: Mapped[str | None] = mapped_column(String(128), nullable=True)
signer_basis: Mapped[str | None] = mapped_column(String(256), nullable=True)
email_for_docs: Mapped[str | None] = mapped_column(String(256), nullable=True)
phone_for_docs: Mapped[str | None] = mapped_column(String(64), nullable=True)
completeness_status: Mapped[str] = mapped_column(String(32), index=True, default="draft")
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesDocumentRow(Base):
__tablename__ = "sales_documents"
__table_args__ = (
Index("idx_sales_documents_deal_status", "deal_id", "status"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
document_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
document_type: Mapped[str] = mapped_column(String(64), index=True)
template_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
version: Mapped[int] = mapped_column(Integer, default=1)
status: Mapped[str] = mapped_column(String(32), index=True, default="draft")
file_url: Mapped[str | None] = mapped_column(Text, nullable=True)
rendered_payload_json: Mapped[str] = mapped_column(Text, default="{}")
external_sign_provider_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
signed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesInvoiceRow(Base):
__tablename__ = "sales_invoices"
__table_args__ = (
Index("idx_sales_invoices_deal_status_due", "deal_id", "status", "due_date"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
invoice_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
invoice_number: Mapped[str] = mapped_column(String(64), unique=True, index=True)
basis_document_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
amount: Mapped[float] = mapped_column(Float, default=0)
currency: Mapped[str] = mapped_column(String(16), default="KZT")
due_date: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
status: Mapped[str] = mapped_column(String(32), index=True, default="draft")
payment_link: Mapped[str | None] = mapped_column(Text, nullable=True)
line_items_json: Mapped[str] = mapped_column(Text, default="[]")
metadata_json: Mapped[str] = mapped_column(Text, default="{}")
issued_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
paid_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesPaymentRow(Base):
__tablename__ = "sales_payments"
__table_args__ = (
Index("idx_sales_payments_deal_status", "deal_id", "status"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
payment_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
invoice_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
payment_provider: Mapped[str | None] = mapped_column(String(64), nullable=True)
external_payment_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
amount: Mapped[float] = mapped_column(Float, default=0)
currency: Mapped[str] = mapped_column(String(16), default="KZT")
status: Mapped[str] = mapped_column(String(32), index=True, default="pending")
paid_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
payment_method: Mapped[str | None] = mapped_column(String(64), nullable=True)
failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
metadata_json: Mapped[str] = mapped_column(Text, default="{}")
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesStageHistoryRow(Base):
__tablename__ = "sales_stage_history"
__table_args__ = (
Index("idx_sales_stage_history_deal_changed", "deal_id", "changed_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
history_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
from_stage_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
to_stage_id: Mapped[str] = mapped_column(String(64), index=True)
changed_by_type: Mapped[str] = mapped_column(String(32), index=True)
changed_by_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
changed_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesEscalationRow(Base):
__tablename__ = "sales_escalations"
__table_args__ = (
Index("idx_sales_escalations_deal_status", "deal_id", "status"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
escalation_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
escalation_type: Mapped[str] = mapped_column(String(64), index=True)
reason: Mapped[str] = mapped_column(Text)
severity: Mapped[str] = mapped_column(String(16), index=True)
status: Mapped[str] = mapped_column(String(32), index=True, default="open")
assigned_to_user_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
created_at: Mapped[str] = mapped_column(String(64), index=True)
resolved_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
class SalesAutomationTaskRow(Base):
__tablename__ = "sales_automation_tasks"
__table_args__ = (
Index("idx_sales_tasks_run_status", "run_at", "status"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
task_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
task_type: Mapped[str] = mapped_column(String(64), index=True)
payload_json: Mapped[str] = mapped_column(Text, default="{}")
run_at: Mapped[str] = mapped_column(String(64), index=True)
status: Mapped[str] = mapped_column(String(32), index=True, default="pending")
retry_count: Mapped[int] = mapped_column(Integer, default=0)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesChannelSwitchRow(Base):
__tablename__ = "sales_channel_switches"
__table_args__ = (
Index("idx_sales_channel_switches_deal_switched", "deal_id", "switched_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
switch_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
communication_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
from_channel: Mapped[str] = mapped_column(String(32), index=True)
to_channel: Mapped[str] = mapped_column(String(32), index=True)
reason_for_channel_switch: Mapped[str] = mapped_column(Text)
switched_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesExternalLinkRow(Base):
__tablename__ = "sales_external_links"
__table_args__ = (
Index("idx_sales_external_links_deal_sync", "deal_id", "last_sync_at"),
Index("idx_sales_external_links_thread", "external_thread_id"),
Index("idx_sales_external_links_voice_session", "voice_session_id"),
Index("idx_sales_external_links_external_call", "external_call_id"),
Index("idx_sales_external_links_interaction", "interaction_id"),
Index("idx_sales_external_links_customer_phone", "customer_id", "phone_number"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
link_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
communication_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
sales_call_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
channel_provider: Mapped[str] = mapped_column(String(32), index=True)
external_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
external_chat_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
external_call_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
voice_session_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
ai_session_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
phone_number: Mapped[str | None] = mapped_column(String(64), nullable=True)
external_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
link_metadata_json: Mapped[str] = mapped_column(Text, default="{}")
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
last_sync_at: Mapped[str] = mapped_column(String(64), index=True)
@@ -1,6 +1,5 @@
from __future__ import annotations
import audioop
import base64
import logging
import os
@@ -13,6 +12,7 @@ from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel
from services.shared.core import Role, new_id
from services.shared.audioop_compat import audioop
from services.shared.models import HealthResponse
from services.shared.security import require_roles
+73
View File
@@ -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()
+2
View File
@@ -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):
+13
View File
@@ -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():
+300
View File
@@ -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())
+1
View File
@@ -27,6 +27,7 @@
<a class="nav-link" data-shell="operator" data-roles="admin,supervisor,operator,analyst" href="/operator"><span class="nav-bullet"></span>Оператор</a>
<a class="nav-link" data-shell="supervisor" data-roles="admin,supervisor" href="/supervisor"><span class="nav-bullet"></span>Супервизор</a>
<a class="nav-link" data-shell="analyst" data-roles="admin,supervisor,analyst" href="/analyst"><span class="nav-bullet"></span>Аналитика</a>
<a class="nav-link" data-shell="sales" data-roles="admin,supervisor,operator" href="/sales"><span class="nav-bullet"></span>Продажи</a>
<a class="nav-link active" data-shell="admin" data-roles="admin" href="/admin"><span class="nav-bullet"></span>Администрирование</a>
</div>
<div class="nav-group">
+1
View File
@@ -27,6 +27,7 @@
<a class="nav-link" data-shell="supervisor" data-roles="admin,supervisor" href="/supervisor"><span class="nav-bullet"></span>Супервизор</a>
<a class="nav-link" data-shell="analyst" data-roles="admin,supervisor,analyst" href="/analyst"><span class="nav-bullet"></span>Аналитика</a>
<a class="nav-link" data-shell="admin" data-roles="admin" href="/admin"><span class="nav-bullet"></span>Администрирование</a>
<a class="nav-link" data-shell="sales" data-roles="admin,supervisor,operator" href="/sales"><span class="nav-bullet"></span>Продажи</a>
</div>
<div class="nav-group">
<div class="nav-label">Страницы</div>
+927
View File
@@ -0,0 +1,927 @@
const state = {
user: 'admin',
role: 'admin',
token: null,
authSource: 'local',
fullName: null,
dashboard: null,
leads: [],
deals: [],
selectedDealId: '',
workspaceById: {},
logLines: [],
};
const SESSION_STORAGE_KEY = 'cc_session';
const STAGE_OPTIONS = [
'new_qualified_lead',
'warm_lead',
'hot_lead',
'enrichment_required',
'active_text_communication',
'active_voice_communication',
'waiting_customer_reply',
'offer_preparing',
'offer_sent',
'conditions_negotiation',
'counterparty_data_requested',
'counterparty_data_received',
'document_preparing',
'document_sent',
'document_confirmed',
'invoice_preparing',
'invoice_sent',
'payment_expected',
'partially_paid',
'paid',
'payment_overdue',
'won',
'lost',
'postponed',
'follow_up_scheduled',
'transferred_to_execution',
'transferred_to_support',
];
const BOARD_GROUPS = [
{ key: 'inbound', title: 'Вход', note: 'Квалифицированный lead intake и дообогащение.', stages: ['new_qualified_lead', 'warm_lead', 'hot_lead', 'enrichment_required'] },
{ key: 'communication', title: 'Коммуникация', note: 'Text/voice сессии, вопросы и follow-up.', stages: ['active_text_communication', 'active_voice_communication', 'waiting_customer_reply'] },
{ key: 'commercial', title: 'Коммерция', note: 'Оффер, согласование условий, next step.', stages: ['offer_preparing', 'offer_sent', 'conditions_negotiation'] },
{ key: 'paperwork', title: 'Оформление', note: 'Реквизиты, документы, подтверждение.', stages: ['counterparty_data_requested', 'counterparty_data_received', 'document_preparing', 'document_sent', 'document_confirmed'] },
{ key: 'finance', title: 'Финансы', note: 'Счет, оплата, просрочка.', stages: ['invoice_preparing', 'invoice_sent', 'payment_expected', 'partially_paid', 'paid', 'payment_overdue'] },
{ key: 'outcome', title: 'Закрытие', note: 'Win/loss/post-sale handoff.', stages: ['won', 'lost', 'postponed', 'follow_up_scheduled', 'transferred_to_execution', 'transferred_to_support'] },
];
const STAGE_LABELS = {
new_qualified_lead: 'Новый квалифицированный лид',
warm_lead: 'Теплый лид',
hot_lead: 'Горячий лид',
enrichment_required: 'Нужно дообогащение',
active_text_communication: 'Текстовая коммуникация',
active_voice_communication: 'Голосовая коммуникация',
waiting_customer_reply: 'Ждем ответ клиента',
offer_preparing: 'Готовим оффер',
offer_sent: 'Оффер отправлен',
conditions_negotiation: 'Согласуем условия',
counterparty_data_requested: 'Запрос реквизитов',
counterparty_data_received: 'Реквизиты получены',
document_preparing: 'Готовим документ',
document_sent: 'Документ отправлен',
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: 'Передано человеку',
};
const $ = (id) => document.getElementById(id);
function log(message, payload) {
const ts = new Date().toLocaleTimeString();
const suffix = payload ? ` ${JSON.stringify(payload)}` : '';
state.logLines.unshift(`[${ts}] ${message}${suffix}`);
state.logLines = state.logLines.slice(0, 14);
$('salesLog').textContent = state.logLines.join('\n');
}
function syncSessionFromInputs() {
state.user = $('sessionUser').value.trim() || 'admin';
state.role = $('sessionRole').value || 'admin';
}
function persistSession() {
window.localStorage.setItem(
SESSION_STORAGE_KEY,
JSON.stringify({
access_token: state.token,
user: state.user,
role: state.role,
auth_source: state.authSource,
full_name: state.fullName,
}),
);
}
function clearStoredSession() {
window.localStorage.removeItem(SESSION_STORAGE_KEY);
}
function restoreStoredSession() {
const raw = window.localStorage.getItem(SESSION_STORAGE_KEY);
if (!raw) {
return false;
}
try {
const payload = JSON.parse(raw);
if (!payload?.access_token) {
clearStoredSession();
return false;
}
state.token = payload.access_token;
state.user = payload.user || state.user;
state.role = payload.role || state.role;
state.authSource = payload.auth_source || 'local';
state.fullName = payload.full_name || null;
$('sessionUser').value = state.user;
$('sessionRole').value = state.role;
$('authPanel').style.display = 'none';
updateSessionInfo();
updateProfile();
return true;
} catch {
clearStoredSession();
return false;
}
}
function updateSessionInfo(extra = '') {
$('sessionInfo').textContent = state.token
? `Токен активен | роль: ${state.role}${extra ? ` | ${extra}` : ''}`
: 'Токен: отсутствует';
}
function updateProfile() {
$('profileName').textContent = state.fullName || state.user || 'Экран продаж';
$('profileRole').textContent = `Роль: ${state.role || 'unknown'}`;
}
async function api(path, options = {}) {
syncSessionFromInputs();
const headers = {
'Content-Type': 'application/json',
'X-User': state.user,
'X-Role': state.role,
...(options.headers || {}),
};
if (state.token) {
headers.Authorization = `Bearer ${state.token}`;
}
const response = await fetch(`/proxy/sales/${path}`, { ...options, headers });
let data;
try {
data = await response.json();
} catch {
data = { error: 'Сервис вернул не JSON' };
}
if (!response.ok) {
throw new Error(`${response.status}: ${JSON.stringify(data)}`);
}
return data;
}
async function login() {
syncSessionFromInputs();
try {
const data = await fetch('/proxy/auth/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User': state.user,
'X-Role': state.role,
},
body: JSON.stringify({
username: state.user,
password: $('sessionPassword').value,
}),
}).then((response) => response.json());
state.token = data.access_token;
state.role = data.role;
state.authSource = data.auth_source || 'local';
state.fullName = data.full_name || null;
$('authPanel').style.display = 'none';
updateSessionInfo();
updateProfile();
persistSession();
log('Логин выполнен', { user: state.user, role: state.role });
await refreshAll();
} catch (error) {
updateSessionInfo(`ошибка: ${error.message}`);
log('Ошибка входа', { error: error.message });
}
}
function logout() {
clearStoredSession();
window.location.href = '/';
}
async function checkGateway() {
try {
const data = await fetch('/health').then((response) => response.json());
$('gatewayStatus').textContent = `Шлюз: ${data.status === 'ok' ? 'готов' : data.status}`;
} catch {
$('gatewayStatus').textContent = 'Шлюз: недоступен';
}
}
function stageLabel(stageId) {
return STAGE_LABELS[stageId] || stageId || 'Без этапа';
}
function boardGroupsWithItems() {
const query = ($('salesSearch').value || '').trim().toLowerCase();
return BOARD_GROUPS.map((group) => {
const items = state.deals.filter((deal) => {
const inGroup = group.stages.includes(deal.stage_id);
if (!inGroup) {
return false;
}
if (!query) {
return true;
}
return [
deal.title,
deal.need_summary,
deal.current_channel,
deal.customer_id,
].some((value) => String(value || '').toLowerCase().includes(query));
});
return { ...group, items };
});
}
function salesCard(deal) {
const activeClass = deal.deal_id === state.selectedDealId ? 'active' : '';
const amount = Number(deal.final_amount || deal.estimated_amount || 0);
return `
<button class="sales-card ${activeClass}" type="button" data-deal-id="${deal.deal_id}">
<div class="sales-card-top">
<div class="sales-card-title">${deal.title}</div>
<div class="sales-card-priority">P${deal.priority}</div>
</div>
<div class="sales-card-body">${deal.need_summary || 'Потребность еще не уточнена.'}</div>
<div class="sales-card-meta">
<span class="sales-chip">${stageLabel(deal.stage_id)}</span>
<span class="sales-chip">${deal.current_channel || 'канал не задан'}</span>
<span class="sales-chip">${amount.toLocaleString('ru-RU')} ${deal.currency || 'KZT'}</span>
</div>
</button>
`;
}
function renderBoard() {
$('salesBoard').innerHTML = boardGroupsWithItems()
.map(
(group) => `
<section class="sales-column">
<div class="sales-column-head">
<div class="sales-column-title">${group.title}</div>
<div class="sales-column-count">${group.items.length}</div>
</div>
<div class="sales-column-note">${group.note}</div>
<div class="sales-card-list">
${group.items.length ? group.items.map((deal) => salesCard(deal)).join('') : '<div class="sales-workspace-empty">Пусто.</div>'}
</div>
</section>
`,
)
.join('');
}
function kpiCard(label, value, note) {
return `
<article class="sales-kpi-card">
<div class="sales-kpi-label">${label}</div>
<div class="sales-kpi-value">${value}</div>
<div class="sales-kpi-note">${note}</div>
</article>
`;
}
function renderDashboard() {
const data = state.dashboard;
if (!data) {
$('salesDashboard').innerHTML = '';
return;
}
$('salesDashboard').innerHTML = [
kpiCard('Лиды', Number(data.leads_total || 0), 'Сколько квалифицированных лидов уже в CRM'),
kpiCard('Активные сделки', Number(data.deals_active || 0), 'Открытые кейсы в работе'),
kpiCard('Выиграно', Number(data.deals_won || 0), 'Закрыто с оплатой'),
kpiCard('Просрочка', Number(data.overdue_invoices || 0), 'Счета, которые зависли'),
kpiCard('Ожидаем оплату', `${Number(data.payment_expected || 0).toLocaleString('ru-RU')} KZT`, 'Потенциальный cash-in'),
kpiCard('Получено', `${Number(data.payment_received || 0).toLocaleString('ru-RU')} KZT`, 'Фактические оплаты'),
kpiCard('Voice сессии', Number(data.voice_sessions || 0), 'Голосовой контур продаж'),
kpiCard('Text сессии', Number(data.text_sessions || 0), 'Текстовый контур продаж'),
kpiCard('Switch channel', Number(data.channel_switches || 0), 'Переходы между voice/text'),
kpiCard('Эскалации', Number(data.human_escalations || 0), 'Передачи живому менеджеру'),
].join('');
}
function renderStageOptions() {
$('dealStageSelect').innerHTML = STAGE_OPTIONS.map((value) => `<option value="${value}">${stageLabel(value)}</option>`).join('');
}
function workspaceMetric(label, value) {
return `
<div class="sales-metric">
<div class="sales-metric-label">${label}</div>
<div class="sales-metric-value">${value}</div>
</div>
`;
}
function kvItem(label, value) {
return `
<div class="sales-kv-item">
<div class="sales-kv-label">${label}</div>
<div class="sales-kv-value">${value || '—'}</div>
</div>
`;
}
function listRows(items, titleKey, bodyKey) {
if (!items?.length) {
return '<div class="sales-workspace-empty">Пока пусто.</div>';
}
return `<div class="sales-list">${items
.map(
(item) => `
<div class="sales-list-row">
<div>
<strong>${item[titleKey]}</strong>
<span>${item[bodyKey] || ''}</span>
</div>
<div>
<span>${item.updated_at || item.created_at || item.started_at || ''}</span>
</div>
</div>
`,
)
.join('')}</div>`;
}
function renderWorkspace() {
if (!state.selectedDealId) {
$('dealWorkspace').innerHTML = 'Выберите карточку на воронке, чтобы увидеть подробности.';
$('dealMetrics').innerHTML = '';
$('dealTitle').textContent = 'Выберите сделку';
$('dealSubtitle').textContent = 'Справа будет единый контекст: text + voice, офферы, счета, оплата, timeline.';
$('dealStageBadge').textContent = 'Нет данных';
$('telegramThreadId').value = '';
$('telegramChatId').value = '';
$('voiceSessionId').value = '';
return;
}
const workspace = state.workspaceById[state.selectedDealId];
if (!workspace) {
$('dealWorkspace').innerHTML = 'Загружаю карточку сделки...';
return;
}
const { deal, lead, communications, offers, documents, invoices, payments, timeline, counterparty, escalations, tasks } = workspace;
const bindings = workspaceBindings(workspace);
$('dealTitle').textContent = deal.title;
$('dealSubtitle').textContent = `${stageLabel(deal.stage_id)}${deal.current_channel || 'канал не задан'}${deal.status}`;
$('dealStageBadge').textContent = stageLabel(deal.stage_id);
$('dealStageSelect').value = deal.stage_id;
$('telegramThreadId').value = bindings.telegramThreadId || '';
$('telegramChatId').value = bindings.telegramChatId || '';
$('voiceSessionId').value = bindings.voiceSessionId || '';
$('dealMetrics').innerHTML = [
workspaceMetric('Канал', deal.current_channel || '—'),
workspaceMetric('Следующий шаг', deal.next_action_type || 'не задан'),
workspaceMetric('Сумма', `${Number(deal.final_amount || deal.estimated_amount || 0).toLocaleString('ru-RU')} ${deal.currency || 'KZT'}`),
workspaceMetric('Customer link', deal.customer_id || 'не привязан'),
].join('');
$('dealWorkspace').innerHTML = `
<div class="sales-workspace-grid">
<section class="sales-section-card">
<h3>Lead / Deal core</h3>
<div class="sales-kv">
${kvItem('Lead', lead?.lead_id || '—')}
${kvItem('Имя', lead?.full_name || '—')}
${kvItem('Телефон', lead?.phone || '—')}
${kvItem('Предпочтительный канал', deal.preferred_channel || '—')}
${kvItem('Score', lead?.lead_score || '—')}
${kvItem('Потребность', deal.need_summary || lead?.initial_need_summary || '—')}
</div>
</section>
<section class="sales-section-card">
<h3>Counterparty</h3>
<div class="sales-kv">
${kvItem('Компания', counterparty?.company_name || '—')}
${kvItem('Контакт', counterparty?.full_name || '—')}
${kvItem('BIN/IIN', counterparty?.bin_iin || '—')}
${kvItem('Email для документов', counterparty?.email_for_docs || '—')}
</div>
</section>
<section class="sales-section-card">
<h3>Коммуникации</h3>
${listRows(
communications.map((item) => ({
...item,
headline: `${item.channel_type === 'voice' ? 'Voice' : 'Text'}${item.direction}`,
detail: item.summary || item.subject || item.result_code || 'Без summary',
})),
'headline',
'detail',
)}
</section>
<section class="sales-section-card">
<h3>Коммерческий пакет</h3>
${listRows(
offers.map((item) => ({
...item,
headline: `${item.title}${item.status}`,
detail: `${Number(item.total_amount || 0).toLocaleString('ru-RU')} ${item.currency}`,
})),
'headline',
'detail',
)}
</section>
<section class="sales-section-card">
<h3>Документы и счета</h3>
${listRows(
[...documents, ...invoices].map((item) => ({
...item,
headline: item.document_id
? `${item.document_type}${item.status}`
: `${item.invoice_number}${item.status}`,
detail: item.document_id
? (item.file_url || 'Файл еще не прикреплен')
: `${Number(item.amount || 0).toLocaleString('ru-RU')} ${item.currency}`,
})),
'headline',
'detail',
)}
</section>
<section class="sales-section-card">
<h3>Оплаты, задачи, эскалации</h3>
${listRows(
[
...payments.map((item) => ({
...item,
headline: `Оплата • ${item.status}`,
detail: `${Number(item.amount || 0).toLocaleString('ru-RU')} ${item.currency}`,
})),
...tasks.map((item) => ({
...item,
headline: `Task • ${item.task_type}`,
detail: `${item.status}${item.run_at}`,
})),
...escalations.map((item) => ({
...item,
headline: `Эскалация • ${item.severity}`,
detail: item.reason,
})),
],
'headline',
'detail',
)}
</section>
<section class="sales-section-card">
<h3>Timeline</h3>
<div class="sales-stream">
${timeline.length ? timeline
.map(
(item) => `
<div class="sales-stream-item">
<div class="sales-stream-title">${item.title}</div>
<div class="sales-stream-time">${item.ts}</div>
<div class="sales-stream-body">${item.body}</div>
</div>
`,
)
.join('') : '<div class="sales-workspace-empty">Пока нет timeline событий.</div>'}
</div>
</section>
</div>
`;
}
function latestCommunication(workspace, channelType = '') {
const items = workspace?.communications || [];
if (!channelType) {
return items[0] || null;
}
return items.find((item) => item.channel_type === channelType) || items[0] || null;
}
function workspaceBindings(workspace) {
const bindings = {
telegramThreadId: '',
telegramChatId: '',
voiceSessionId: '',
};
for (const item of workspace?.communications || []) {
const metadata = item.metadata || {};
if (!bindings.telegramThreadId && metadata.telegram_thread_id) {
bindings.telegramThreadId = metadata.telegram_thread_id;
}
if (!bindings.telegramChatId && metadata.telegram_chat_id) {
bindings.telegramChatId = metadata.telegram_chat_id;
}
if (!bindings.voiceSessionId && metadata.voice_session_id) {
bindings.voiceSessionId = metadata.voice_session_id;
}
}
return bindings;
}
async function loadDashboard() {
state.dashboard = await api('api/v1/dashboard');
renderDashboard();
}
async function loadDeals() {
const query = ($('salesSearch').value || '').trim();
const params = new URLSearchParams();
if (query) {
params.set('query', query);
}
const path = params.toString() ? `api/v1/deals?${params.toString()}` : 'api/v1/deals';
state.deals = await api(path);
renderBoard();
}
async function loadLeads() {
state.leads = await api('api/v1/leads');
}
async function loadWorkspace(dealId) {
state.workspaceById[dealId] = await api(`api/v1/deals/${encodeURIComponent(dealId)}/workspace`);
renderWorkspace();
}
async function refreshAll() {
await Promise.all([loadDashboard(), loadDeals(), loadLeads()]);
if (state.selectedDealId) {
await loadWorkspace(state.selectedDealId);
}
}
async function selectDeal(dealId) {
state.selectedDealId = dealId;
renderBoard();
renderWorkspace();
await loadWorkspace(dealId);
}
function selectedWorkspace() {
return state.workspaceById[state.selectedDealId] || null;
}
async function createLead() {
try {
const payload = {
source_type: 'crm',
source_channel: $('leadSourceChannel').value,
full_name: $('leadName').value.trim(),
company_name: $('leadCompany').value.trim() || null,
phone: $('leadPhone').value.trim() || null,
email: $('leadEmail').value.trim() || null,
lead_temperature: $('leadTemperature').value,
lead_score: Number($('leadScore').value || 68),
initial_need_summary: $('leadNeedSummary').value.trim() || null,
preferred_channel: $('leadPreferredChannel').value,
assigned_agent_type: $('leadPreferredChannel').value === 'voice' ? 'voice_ai' : 'text_ai',
status: 'new_qualified_lead',
priority: 3,
title: $('leadName').value.trim() ? `Лид: ${$('leadName').value.trim()}` : null,
};
await api('api/v1/leads', { method: 'POST', body: JSON.stringify(payload) });
log('Лид создан', payload);
await refreshAll();
} catch (error) {
log('Ошибка создания лида', { error: error.message });
}
}
async function seedDemo() {
$('leadName').value = 'Demo Buyer';
$('leadCompany').value = 'KazTrade Labs';
$('leadPhone').value = '+77015551234';
$('leadEmail').value = 'buyer@kaztrade.test';
$('leadNeedSummary').value = 'Нужен расчет и оффер на пакет услуг с возможностью быстро перейти из чата в звонок.';
$('leadPreferredChannel').value = 'telegram';
$('leadTemperature').value = 'hot';
$('leadScore').value = '81';
await createLead();
}
async function postQuickAction(handler, successLabel) {
if (!state.selectedDealId) {
log('Сначала выберите сделку.');
return;
}
try {
await handler();
log(successLabel, { deal_id: state.selectedDealId });
await refreshAll();
} catch (error) {
log('Ошибка действия', { error: error.message });
}
}
async function startTextSession() {
return postQuickAction(
() => api(`api/v1/deals/${encodeURIComponent(state.selectedDealId)}/communications/text`, {
method: 'POST',
body: JSON.stringify({
channel_type: 'text',
direction: 'outbound',
agent_type: 'text_ai',
subject: 'Текстовый follow-up по сделке',
}),
}),
'Текстовая сессия создана',
);
}
async function startVoiceSession() {
const workspace = selectedWorkspace();
const phone = workspace?.lead?.phone || workspace?.counterparty?.phone_for_docs || '+77010000000';
return postQuickAction(
() => api('api/v1/calls/outbound', {
method: 'POST',
body: JSON.stringify({
deal_id: state.selectedDealId,
phone_number: phone,
provider: 'voice_assistant',
subject: 'Голосовой follow-up по сделке',
}),
}),
'Голосовая сессия создана',
);
}
async function switchToText() {
const workspace = selectedWorkspace();
const communication = workspace?.communications?.[0];
if (!communication) {
log('Нет коммуникации для channel switch.');
return;
}
return postQuickAction(
() => api(`api/v1/communications/${encodeURIComponent(communication.communication_id)}/switch-channel`, {
method: 'POST',
body: JSON.stringify({
to_channel: 'telegram',
reason_for_channel_switch: 'После звонка нужно зафиксировать условия письменно',
}),
}),
'Сделка переведена в текст',
);
}
async function switchToVoice() {
const workspace = selectedWorkspace();
const communication = workspace?.communications?.[0];
if (!communication) {
log('Нет коммуникации для channel switch.');
return;
}
return postQuickAction(
() => api(`api/v1/communications/${encodeURIComponent(communication.communication_id)}/switch-channel`, {
method: 'POST',
body: JSON.stringify({
to_channel: 'voice',
reason_for_channel_switch: 'Сложный кейс, удобнее перейти в звонок',
}),
}),
'Сделка переведена в voice',
);
}
async function createOffer() {
const workspace = selectedWorkspace();
const amount = Number(workspace?.deal?.estimated_amount || workspace?.deal?.final_amount || 150000);
return postQuickAction(
() => api(`api/v1/deals/${encodeURIComponent(state.selectedDealId)}/offers`, {
method: 'POST',
body: JSON.stringify({
offer_type: 'offer',
title: `Коммерческое предложение • ${workspace?.deal?.title || 'Сделка'}`,
description: 'Автогенерированный оффер для быстрого запуска продажного цикла.',
line_items: [{ name: 'Основной пакет', quantity: 1, price: amount }],
pricing: { subtotal: amount, vat: 0, total: amount },
total_amount: amount,
currency: workspace?.deal?.currency || 'KZT',
}),
}),
'Оффер создан',
);
}
async function createInvoice() {
const workspace = selectedWorkspace();
const amount = Number(workspace?.deal?.final_amount || workspace?.deal?.estimated_amount || 150000);
const firstDocument = workspace?.documents?.[0];
return postQuickAction(
() => api(`api/v1/deals/${encodeURIComponent(state.selectedDealId)}/invoices`, {
method: 'POST',
body: JSON.stringify({
basis_document_id: firstDocument?.document_id || null,
amount,
currency: workspace?.deal?.currency || 'KZT',
due_date: $('nextActionAt').value.trim() || null,
line_items: [{ title: workspace?.deal?.title || 'Сделка', amount }],
metadata: { generated_from: 'sales_ui' },
}),
}),
'Счет создан',
);
}
async function markPaid() {
const workspace = selectedWorkspace();
const invoice = workspace?.invoices?.[0];
if (!invoice) {
log('Сначала создайте счет.');
return;
}
return postQuickAction(
() => api('api/v1/payments/webhook', {
method: 'POST',
body: JSON.stringify({
deal_id: state.selectedDealId,
invoice_id: invoice.invoice_id,
payment_provider: 'manual',
amount: invoice.amount,
currency: invoice.currency,
status: 'success',
payment_method: 'bank_transfer',
}),
}),
'Оплата зафиксирована',
);
}
async function saveStage() {
return postQuickAction(
() => api(`api/v1/deals/${encodeURIComponent(state.selectedDealId)}/change-stage`, {
method: 'POST',
body: JSON.stringify({
stage_id: $('dealStageSelect').value,
reason: 'Ручное обновление стадии из sales workspace',
}),
}),
'Этап обновлен',
);
}
async function saveNextAction() {
return postQuickAction(
() => api(`api/v1/deals/${encodeURIComponent(state.selectedDealId)}/schedule-next-action`, {
method: 'POST',
body: JSON.stringify({
next_action_type: $('nextActionType').value.trim() || 'follow_up',
next_action_at: $('nextActionAt').value.trim() || null,
payload: { source: 'sales_ui' },
}),
}),
'Next action сохранен',
);
}
async function bindTelegramThread() {
const workspace = selectedWorkspace();
const communication = latestCommunication(workspace, 'text');
const threadId = $('telegramThreadId').value.trim();
if (!communication) {
log('Сначала откройте text-сессию по сделке.');
return;
}
if (!threadId) {
log('Укажите telegram thread id.');
return;
}
return postQuickAction(
() => api(`api/v1/communications/${encodeURIComponent(communication.communication_id)}/bind-external`, {
method: 'POST',
body: JSON.stringify({
channel_provider: 'telegram',
telegram_thread_id: threadId,
telegram_chat_id: $('telegramChatId').value.trim() || null,
metadata: { source: 'sales_ui_binding' },
}),
}),
'Telegram thread привязан к сделке',
);
}
async function bindVoiceSession() {
const workspace = selectedWorkspace();
const communication = latestCommunication(workspace, 'voice');
const voiceSessionId = $('voiceSessionId').value.trim();
if (!communication) {
log('Сначала откройте voice-сессию по сделке.');
return;
}
if (!voiceSessionId) {
log('Укажите voice session id.');
return;
}
return postQuickAction(
() => api(`api/v1/communications/${encodeURIComponent(communication.communication_id)}/bind-external`, {
method: 'POST',
body: JSON.stringify({
channel_provider: 'voice',
voice_session_id: voiceSessionId,
metadata: { source: 'sales_ui_binding' },
}),
}),
'Voice session привязана к сделке',
);
}
async function sendTelegramReply() {
const workspace = selectedWorkspace();
const communication = latestCommunication(workspace, 'text');
const threadId = $('telegramThreadId').value.trim();
const body = $('telegramReplyText').value.trim();
if (!threadId) {
log('Сначала привяжите Telegram thread.');
return;
}
if (!body) {
log('Введите текст сообщения.');
return;
}
return postQuickAction(
() => api('api/v1/messages/outbound', {
method: 'POST',
body: JSON.stringify({
deal_id: state.selectedDealId,
communication_id: communication?.communication_id || null,
channel_provider: 'telegram',
sender_type: 'human',
sender_id: state.user,
body,
metadata: {
telegram_thread_id: threadId,
telegram_chat_id: $('telegramChatId').value.trim() || null,
source: 'sales_ui_temp_telegram',
},
}),
}),
'Сообщение поставлено в Telegram bridge',
).then(() => {
$('telegramReplyText').value = '';
});
}
function handleBoardClick(event) {
const button = event.target.closest('[data-deal-id]');
if (!button) {
return;
}
selectDeal(button.dataset.dealId).catch((error) => {
log('Ошибка загрузки сделки', { error: error.message });
});
}
function bindEvents() {
$('loginBtn').addEventListener('click', login);
$('logoutBtn').addEventListener('click', logout);
$('refreshSalesBtn').addEventListener('click', () => refreshAll().catch((error) => log('Ошибка обновления', { error: error.message })));
$('salesSearch').addEventListener('input', renderBoard);
$('salesBoard').addEventListener('click', handleBoardClick);
$('createLeadBtn').addEventListener('click', createLead);
$('seedDemoBtn').addEventListener('click', seedDemo);
$('startTextBtn').addEventListener('click', startTextSession);
$('startVoiceBtn').addEventListener('click', startVoiceSession);
$('switchToTextBtn').addEventListener('click', switchToText);
$('switchToVoiceBtn').addEventListener('click', switchToVoice);
$('createOfferBtn').addEventListener('click', createOffer);
$('createInvoiceBtn').addEventListener('click', createInvoice);
$('markPaidBtn').addEventListener('click', markPaid);
$('saveStageBtn').addEventListener('click', saveStage);
$('saveNextActionBtn').addEventListener('click', saveNextAction);
$('bindTelegramBtn').addEventListener('click', bindTelegramThread);
$('bindVoiceSessionBtn').addEventListener('click', bindVoiceSession);
$('sendTelegramReplyBtn').addEventListener('click', sendTelegramReply);
}
async function init() {
renderStageOptions();
bindEvents();
updateProfile();
await checkGateway();
if (restoreStoredSession()) {
log('Сессия восстановлена', { user: state.user, role: state.role });
await refreshAll();
}
}
init().catch((error) => {
log('Ошибка инициализации', { error: error.message });
});
+215
View File
@@ -0,0 +1,215 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KonturCC Продажи</title>
<link rel="icon" href="/operator/assets/favicon.svg" type="image/svg+xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700;800&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/operator/assets/styles.css" />
<link rel="stylesheet" href="/sales/assets/styles.css?v=sales-v1" />
</head>
<body>
<div class="app-shell">
<aside class="sidebar">
<div class="brand">
<div class="brand-mark">S</div>
<div>
<div class="brand-title">KonturCC</div>
<div class="brand-sub">Sales orchestration</div>
</div>
</div>
<nav class="nav-menu">
<div class="nav-group">
<div class="nav-label">Рабочие зоны</div>
<a class="nav-link" href="/operator"><span class="nav-bullet"></span>Оператор</a>
<a class="nav-link" href="/supervisor"><span class="nav-bullet"></span>Супервизор</a>
<a class="nav-link" href="/analyst"><span class="nav-bullet"></span>Аналитика</a>
<a class="nav-link" href="/admin"><span class="nav-bullet"></span>Администрирование</a>
<a class="nav-link active" href="/sales"><span class="nav-bullet"></span>Продажи</a>
</div>
<div class="nav-group">
<div class="nav-label">Секции</div>
<a class="nav-link active" href="#overview"><span class="nav-bullet"></span>Воронка</a>
<a class="nav-link" href="#dealPanel"><span class="nav-bullet"></span>Карточка сделки</a>
<a class="nav-link" href="#createLeadPanel"><span class="nav-bullet"></span>Новый лид</a>
</div>
</nav>
<div class="sidebar-footer">
<strong>Модуль продаж</strong>
<p>Одна сделка живет через text и voice. Здесь же этапы, офферы, счета, оплаты, эскалации и handoff между каналами.</p>
<button id="logoutBtn" class="btn ghost" type="button">Выйти</button>
</div>
</aside>
<section class="workspace">
<header class="workspace-topbar reveal">
<div class="search-box">
<input id="salesSearch" class="app-search" placeholder="Найти по сделке, клиенту, задаче или каналу..." />
</div>
<div class="topbar-actions">
<div class="status-pill" id="gatewayStatus">Шлюз: проверка...</div>
<button id="refreshSalesBtn" class="btn ghost" type="button">Обновить</button>
<div class="profile-chip">
<div class="avatar-badge">SL</div>
<div class="profile-meta">
<div class="profile-name" id="profileName">Экран продаж</div>
<div class="profile-role" id="profileRole">Omnichannel pipeline</div>
</div>
</div>
</div>
</header>
<main class="layout sales-layout">
<section class="page-hero reveal" id="overview">
<p class="eyebrow">Sales Control Room</p>
<h1>Омниканальная воронка продаж</h1>
<p class="hint">Теплый лид попадает в CRM, дальше система ведет его через коммуникации, оффер, документы, счет, оплату и post-sale handoff без потери контекста.</p>
</section>
<section class="panel auth reveal" id="authPanel">
<h2>Сессия</h2>
<div class="row">
<label>Пользователь</label>
<input id="sessionUser" value="admin" />
</div>
<div class="row">
<label>Роль</label>
<select id="sessionRole">
<option value="admin" selected>Администратор</option>
<option value="supervisor">Супервизор</option>
<option value="operator">Оператор</option>
<option value="analyst">Аналитик</option>
</select>
</div>
<div class="row">
<label>Пароль</label>
<input id="sessionPassword" type="password" value="admin123" />
</div>
<div class="actions">
<button id="loginBtn" class="btn" type="button">Войти</button>
</div>
<p class="hint" id="sessionInfo">Токен: отсутствует</p>
</section>
<section class="sales-dashboard-grid reveal" id="salesDashboard"></section>
<section class="sales-main-grid">
<article class="panel reveal sales-board-panel">
<div class="sales-board-head">
<div>
<h2>Воронка</h2>
<p class="hint">Колонки собраны по крупным бизнес-блокам из ТЗ: вход, коммуникация, коммерция, оформление, финансы и закрытие.</p>
</div>
<div class="sales-toolbar">
<button id="openCreateLeadBtn" class="btn" type="button">Добавить лид</button>
</div>
</div>
<div id="salesBoard" class="sales-board"></div>
</article>
<aside class="sales-detail-stack">
<article class="panel reveal sales-detail-panel" id="dealPanel">
<div class="sales-detail-head">
<div>
<p class="eyebrow">Карточка сделки</p>
<h2 id="dealTitle">Выберите сделку</h2>
<p id="dealSubtitle" class="hint">Справа будет единый контекст: text + voice, офферы, счета, оплата, timeline.</p>
</div>
<div id="dealStageBadge" class="status-pill">Нет данных</div>
</div>
<div class="sales-quick-actions">
<button id="startTextBtn" class="btn ghost" type="button">Текстовая сессия</button>
<button id="startVoiceBtn" class="btn ghost" type="button">Голосовая сессия</button>
<button id="switchToTextBtn" class="btn ghost" type="button">Voice -> Text</button>
<button id="switchToVoiceBtn" class="btn ghost" type="button">Text -> Voice</button>
<button id="createOfferBtn" class="btn ghost" type="button">Создать оффер</button>
<button id="createInvoiceBtn" class="btn ghost" type="button">Выставить счет</button>
<button id="markPaidBtn" class="btn ghost" type="button">Отметить оплату</button>
</div>
<div class="inline-form compact">
<select id="dealStageSelect"></select>
<button id="saveStageBtn" class="btn" type="button">Сменить этап</button>
</div>
<div class="inline-form compact">
<input id="nextActionType" placeholder="Следующий шаг, например follow_up_call" />
<input id="nextActionAt" placeholder="2026-05-10T12:00:00+05:00" />
<button id="saveNextActionBtn" class="btn ghost" type="button">Сохранить next action</button>
</div>
<div class="sales-bind-grid">
<input id="telegramThreadId" placeholder="Telegram thread id" />
<input id="telegramChatId" placeholder="Telegram chat id" />
<button id="bindTelegramBtn" class="btn ghost" type="button">Привязать Telegram</button>
<input id="voiceSessionId" placeholder="Voice session id" />
<button id="bindVoiceSessionBtn" class="btn ghost" type="button">Привязать voice session</button>
</div>
<div class="sales-inline-message">
<textarea id="telegramReplyText" rows="3" class="json-textarea" placeholder="Временная текстовая отправка через Telegram bot в рамках этой сделки."></textarea>
<button id="sendTelegramReplyBtn" class="btn" type="button">Отправить в Telegram</button>
</div>
<div class="sales-detail-metrics" id="dealMetrics"></div>
<div id="dealWorkspace" class="sales-workspace-empty">Выберите карточку на воронке, чтобы увидеть подробности.</div>
</article>
<article class="panel reveal" id="createLeadPanel">
<h2>Новый лид</h2>
<div class="inline-form split-two">
<input id="leadName" placeholder="Имя клиента" />
<input id="leadCompany" placeholder="Компания" />
</div>
<div class="inline-form split-two">
<input id="leadPhone" placeholder="Телефон" />
<input id="leadEmail" placeholder="Email" />
</div>
<div class="inline-form split-two">
<select id="leadSourceChannel">
<option value="telegram">Telegram</option>
<option value="whatsapp">WhatsApp</option>
<option value="webchat">Webchat</option>
<option value="email">Email</option>
<option value="voice">Voice</option>
</select>
<select id="leadPreferredChannel">
<option value="telegram">Telegram</option>
<option value="whatsapp">WhatsApp</option>
<option value="webchat">Webchat</option>
<option value="email">Email</option>
<option value="voice">Voice</option>
</select>
</div>
<div class="inline-form split-two">
<select id="leadTemperature">
<option value="warm">Warm</option>
<option value="hot">Hot</option>
</select>
<input id="leadScore" type="number" min="0" max="100" value="68" />
</div>
<div class="row">
<label for="leadNeedSummary">Краткая потребность</label>
<textarea id="leadNeedSummary" rows="4" class="json-textarea" placeholder="Например: клиенту нужен расчет, коммерческое предложение и созвон сегодня."></textarea>
</div>
<div class="actions compact-actions">
<button id="createLeadBtn" class="btn" type="button">Создать лид</button>
<button id="seedDemoBtn" class="btn ghost" type="button">Создать demo-сделку</button>
</div>
<pre id="salesLog" class="output">Здесь появятся ответы сервиса и состояние быстрых действий.</pre>
</article>
</aside>
</section>
</main>
</section>
</div>
<script src="/sales/assets/app.js"></script>
</body>
</html>
+373
View File
@@ -0,0 +1,373 @@
.sales-layout {
gap: 18px;
}
.sales-dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 14px;
}
.sales-kpi-card {
padding: 18px;
border-radius: 18px;
border: 1px solid var(--line);
background:
radial-gradient(circle at top right, rgba(51, 102, 232, 0.12), transparent 34%),
linear-gradient(180deg, #ffffff, #f8fbff);
box-shadow: var(--shadow);
}
.sales-kpi-label {
color: var(--text-soft);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.sales-kpi-value {
margin-top: 12px;
font-family: "Manrope", sans-serif;
font-size: 30px;
font-weight: 800;
letter-spacing: -0.04em;
}
.sales-kpi-note {
margin-top: 6px;
color: var(--text-soft);
font-size: 13px;
}
.sales-main-grid {
display: grid;
grid-template-columns: minmax(0, 1.45fr) minmax(340px, 0.95fr);
gap: 18px;
align-items: start;
}
.sales-detail-stack {
display: grid;
gap: 18px;
}
.sales-board-panel,
.sales-detail-panel {
min-height: 320px;
}
.sales-board-head,
.sales-detail-head {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.sales-board-head h2,
.sales-detail-head h2 {
margin-bottom: 8px;
}
.sales-board {
margin-top: 18px;
display: grid;
grid-template-columns: repeat(6, minmax(220px, 1fr));
gap: 14px;
overflow-x: auto;
padding-bottom: 6px;
}
.sales-column {
min-height: 420px;
border: 1px solid var(--line);
border-radius: 20px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(246, 249, 255, 0.92));
padding: 14px;
display: grid;
gap: 12px;
align-content: start;
}
.sales-column-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.sales-column-title {
font-family: "Manrope", sans-serif;
font-size: 16px;
font-weight: 800;
}
.sales-column-count {
min-width: 28px;
height: 28px;
border-radius: 999px;
display: grid;
place-items: center;
background: var(--primary-soft);
border: 1px solid var(--primary-line);
color: var(--primary);
font-size: 13px;
font-weight: 700;
}
.sales-column-note {
color: var(--text-soft);
font-size: 12px;
line-height: 1.45;
}
.sales-card-list {
display: grid;
gap: 10px;
}
.sales-card {
border: 1px solid var(--line);
border-radius: 18px;
background: #fff;
padding: 14px;
display: grid;
gap: 10px;
cursor: pointer;
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
}
.sales-card:hover {
transform: translateY(-2px);
border-color: var(--primary-line);
box-shadow: 0 12px 24px rgba(16, 33, 61, 0.08);
}
.sales-card.active {
border-color: var(--primary);
box-shadow: 0 16px 30px rgba(51, 102, 232, 0.16);
}
.sales-card-top {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: flex-start;
}
.sales-card-title {
font-family: "Manrope", sans-serif;
font-size: 15px;
font-weight: 800;
}
.sales-card-priority {
font-size: 11px;
font-weight: 700;
color: #9a4f00;
background: #fff2d8;
border-radius: 999px;
padding: 4px 8px;
}
.sales-card-body {
color: var(--text-soft);
font-size: 13px;
line-height: 1.5;
}
.sales-card-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.sales-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 9px;
border-radius: 999px;
background: #f4f7fd;
color: var(--text-soft);
font-size: 11px;
font-weight: 700;
}
.sales-workspace-empty {
margin-top: 18px;
border: 1px dashed var(--line-strong);
border-radius: 18px;
padding: 20px;
color: var(--text-soft);
background: #fbfcff;
}
.sales-quick-actions {
margin-top: 18px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.sales-detail-metrics {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.sales-bind-grid {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.sales-inline-message {
margin-top: 14px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: start;
}
.sales-metric {
padding: 12px;
border: 1px solid var(--line);
border-radius: 16px;
background: #fbfdff;
}
.sales-metric-label {
color: var(--text-soft);
font-size: 12px;
}
.sales-metric-value {
margin-top: 6px;
font-family: "Manrope", sans-serif;
font-size: 19px;
font-weight: 800;
}
.sales-workspace-grid {
margin-top: 18px;
display: grid;
gap: 14px;
}
.sales-section-card {
border: 1px solid var(--line);
border-radius: 18px;
background: #fff;
padding: 14px;
}
.sales-section-card h3 {
margin: 0 0 10px;
}
.sales-kv {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.sales-kv-item {
border: 1px solid #edf1f7;
border-radius: 14px;
padding: 10px 12px;
background: #fbfcff;
}
.sales-kv-label {
color: var(--text-soft);
font-size: 12px;
}
.sales-kv-value {
margin-top: 6px;
font-size: 14px;
font-weight: 600;
}
.sales-stream {
display: grid;
gap: 10px;
}
.sales-stream-item {
border-left: 3px solid var(--primary);
padding: 0 0 0 12px;
}
.sales-stream-title {
font-weight: 700;
}
.sales-stream-time {
color: var(--text-faint);
font-size: 12px;
margin-top: 3px;
}
.sales-stream-body {
color: var(--text-soft);
font-size: 13px;
line-height: 1.5;
margin-top: 5px;
}
.sales-list {
display: grid;
gap: 8px;
}
.sales-list-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-radius: 14px;
background: #f8fbff;
border: 1px solid #edf1f7;
}
.sales-list-row strong {
display: block;
margin-bottom: 4px;
}
.sales-list-row span {
color: var(--text-soft);
font-size: 13px;
}
@media (max-width: 1440px) {
.sales-board {
grid-template-columns: repeat(6, minmax(260px, 1fr));
}
}
@media (max-width: 1180px) {
.sales-main-grid {
grid-template-columns: 1fr;
}
.sales-bind-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.sales-detail-metrics,
.sales-kv {
grid-template-columns: 1fr;
}
.sales-bind-grid,
.sales-inline-message {
grid-template-columns: 1fr;
}
}