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 KB_SERVICE_URL=http://localhost:8008
REPORTING_SERVICE_URL=http://localhost:8009 REPORTING_SERVICE_URL=http://localhost:8009
SUPERVISOR_SERVICE_URL=http://localhost:8010 SUPERVISOR_SERVICE_URL=http://localhost:8010
SALES_SERVICE_URL=http://localhost:8020
TELEGRAM_BOT_ENABLED=0 TELEGRAM_BOT_ENABLED=0
TELEGRAM_BOT_TOKEN= TELEGRAM_BOT_TOKEN=
TELEGRAM_WEBHOOK_SECRET= TELEGRAM_WEBHOOK_SECRET=
@@ -49,6 +50,8 @@ AI_WHATSAPP_MAX_CONTEXT_MESSAGES=12
AI_WHATSAPP_MAX_KB_RESULTS=3 AI_WHATSAPP_MAX_KB_RESULTS=3
AI_WHATSAPP_CONFIDENCE_HANDOFF_THRESHOLD=0.65 AI_WHATSAPP_CONFIDENCE_HANDOFF_THRESHOLD=0.65
AI_VOICE_RUNTIME_SERVICE_URL=http://localhost:8018 AI_VOICE_RUNTIME_SERVICE_URL=http://localhost:8018
SALES_TELEGRAM_BRIDGE_ENABLED=1
SALES_VOICE_RUNTIME_ENABLED=1
AI_VOICE_ENABLED=0 AI_VOICE_ENABLED=0
AI_VOICE_POLICY_MODE=v2_fast_conversational AI_VOICE_POLICY_MODE=v2_fast_conversational
AI_VOICE_QUEUE_CONFIG_JSON={} 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 TELEGRAM_ADAPTER_SERVICE_URL=http://telegram-adapter-service:8000
AI_ORCHESTRATOR_SERVICE_URL=http://ai-orchestrator-service:8000 AI_ORCHESTRATOR_SERVICE_URL=http://ai-orchestrator-service:8000
AI_VOICE_RUNTIME_SERVICE_URL=http://ai-voice-runtime-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 real-bot chat (Track 15)
TELEGRAM_BOT_ENABLED=1 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 KB_SERVICE_URL: http://kb-service:8000
REPORTING_SERVICE_URL: http://reporting-service:8000 REPORTING_SERVICE_URL: http://reporting-service:8000
SUPERVISOR_SERVICE_URL: http://supervisor-service:8000 SUPERVISOR_SERVICE_URL: http://supervisor-service:8000
SALES_SERVICE_URL: http://sales-service:8000
AI_VOICE_ASR_PROVIDER: yandex AI_VOICE_ASR_PROVIDER: yandex
AI_VOICE_TTS_PROVIDER: yandex AI_VOICE_TTS_PROVIDER: yandex
AI_VOICE_V2_STREAMING_ASR_BACKEND: yandex_speechkit AI_VOICE_V2_STREAMING_ASR_BACKEND: yandex_speechkit
@@ -195,6 +196,14 @@ services:
<<: *app_env <<: *app_env
APP_MODULE: services.supervisor_service.app:app 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: event-bus-service:
<<: *service_defaults <<: *service_defaults
environment: environment:
@@ -229,6 +238,7 @@ services:
- kb-service - kb-service
- reporting-service - reporting-service
- supervisor-service - supervisor-service
- sales-service
- event-bus-service - event-bus-service
- asterisk-bridge-service - asterisk-bridge-service
environment: environment:
@@ -22,6 +22,7 @@ x-app-env: &app_env
KB_SERVICE_URL: http://kb-service:8000 KB_SERVICE_URL: http://kb-service:8000
REPORTING_SERVICE_URL: http://reporting-service:8000 REPORTING_SERVICE_URL: http://reporting-service:8000
SUPERVISOR_SERVICE_URL: http://supervisor-service:8000 SUPERVISOR_SERVICE_URL: http://supervisor-service:8000
SALES_SERVICE_URL: http://sales-service:8000
AI_VOICE_ASR_PROVIDER: yandex AI_VOICE_ASR_PROVIDER: yandex
AI_VOICE_TTS_PROVIDER: yandex AI_VOICE_TTS_PROVIDER: yandex
AI_VOICE_V2_STREAMING_ASR_BACKEND: yandex_speechkit AI_VOICE_V2_STREAMING_ASR_BACKEND: yandex_speechkit
@@ -191,6 +192,14 @@ services:
<<: *app_env <<: *app_env
APP_MODULE: services.supervisor_service.app:app 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: event-bus-service:
<<: *service_defaults <<: *service_defaults
environment: environment:
@@ -228,6 +237,7 @@ services:
- kb-service - kb-service
- reporting-service - reporting-service
- supervisor-service - supervisor-service
- sales-service
- event-bus-service - event-bus-service
- asterisk-bridge-service - asterisk-bridge-service
environment: environment:
+10
View File
@@ -22,6 +22,7 @@ x-app-env: &app_env
KB_SERVICE_URL: http://kb-service:8000 KB_SERVICE_URL: http://kb-service:8000
REPORTING_SERVICE_URL: http://reporting-service:8000 REPORTING_SERVICE_URL: http://reporting-service:8000
SUPERVISOR_SERVICE_URL: http://supervisor-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_ASR_PROVIDER: ${AI_VOICE_ASR_PROVIDER:-elevenlabs}
AI_VOICE_TTS_PROVIDER: ${AI_VOICE_TTS_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} AI_VOICE_V2_STREAMING_ASR_BACKEND: ${AI_VOICE_V2_STREAMING_ASR_BACKEND:-elevenlabs_realtime}
@@ -193,6 +194,14 @@ services:
<<: *app_env <<: *app_env
APP_MODULE: services.supervisor_service.app:app 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: event-bus-service:
<<: *service_defaults <<: *service_defaults
environment: environment:
@@ -230,6 +239,7 @@ services:
- kb-service - kb-service
- reporting-service - reporting-service
- supervisor-service - supervisor-service
- sales-service
- event-bus-service - event-bus-service
- asterisk-bridge-service - asterisk-bridge-service
environment: environment:
+15
View File
@@ -295,6 +295,19 @@ services:
ports: ports:
- "8010:8000" - "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: event-bus-service:
build: .. build: ..
depends_on: [postgres, rabbitmq] depends_on: [postgres, rabbitmq]
@@ -342,6 +355,7 @@ services:
- kb-service - kb-service
- reporting-service - reporting-service
- supervisor-service - supervisor-service
- sales-service
- event-bus-service - event-bus-service
- asterisk-bridge-service - asterisk-bridge-service
environment: environment:
@@ -364,6 +378,7 @@ services:
KB_SERVICE_URL: http://kb-service:8000 KB_SERVICE_URL: http://kb-service:8000
REPORTING_SERVICE_URL: http://reporting-service:8000 REPORTING_SERVICE_URL: http://reporting-service:8000
SUPERVISOR_SERVICE_URL: http://supervisor-service:8000 SUPERVISOR_SERVICE_URL: http://supervisor-service:8000
SALES_SERVICE_URL: http://sales-service:8000
ports: ports:
- "8080:8000" - "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" _SUPERVISOR_UI_DIR = _ROOT / "ui" / "supervisor"
_ANALYST_UI_DIR = _ROOT / "ui" / "analyst" _ANALYST_UI_DIR = _ROOT / "ui" / "analyst"
_ADMIN_UI_DIR = _ROOT / "ui" / "admin" _ADMIN_UI_DIR = _ROOT / "ui" / "admin"
_SALES_UI_DIR = _ROOT / "ui" / "sales"
_IVR_PREVIEW_AUDIO_DIR = Path( _IVR_PREVIEW_AUDIO_DIR = Path(
os.getenv("IVR_PREVIEW_AUDIO_DIR", str(_ROOT / ".data_local" / "generated_ivr_yandex")) os.getenv("IVR_PREVIEW_AUDIO_DIR", str(_ROOT / ".data_local" / "generated_ivr_yandex"))
).expanduser() ).expanduser()
@@ -131,6 +132,8 @@ if _ANALYST_UI_DIR.exists():
app.mount("/analyst/assets", StaticFiles(directory=str(_ANALYST_UI_DIR)), name="analyst_assets") app.mount("/analyst/assets", StaticFiles(directory=str(_ANALYST_UI_DIR)), name="analyst_assets")
if _ADMIN_UI_DIR.exists(): if _ADMIN_UI_DIR.exists():
app.mount("/admin/assets", StaticFiles(directory=str(_ADMIN_UI_DIR)), name="admin_assets") 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(): if _IVR_PREVIEW_AUDIO_DIR.exists():
app.mount("/_preview_audio", StaticFiles(directory=str(_IVR_PREVIEW_AUDIO_DIR)), name="ivr_preview_audio") 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"), "kb": os.getenv("KB_SERVICE_URL", "http://localhost:8008"),
"reporting": os.getenv("REPORTING_SERVICE_URL", "http://localhost:8009"), "reporting": os.getenv("REPORTING_SERVICE_URL", "http://localhost:8009"),
"supervisor": os.getenv("SUPERVISOR_SERVICE_URL", "http://localhost:8010"), "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/*", "/ai/voice/sessions/*",
"/asterisk/live-calls/*/ai-summary", "/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") 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") @app.get("/privacy")
def privacy_page() -> HTMLResponse: def privacy_page() -> HTMLResponse:
return HTMLResponse(_LEGAL_HTML["privacy"]) 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": "kb", "port": 8008, "module": "services.kb_service.app:app"},
{"name": "reporting", "port": 8009, "module": "services.reporting_service.app:app"}, {"name": "reporting", "port": 8009, "module": "services.reporting_service.app:app"},
{"name": "supervisor", "port": 8010, "module": "services.supervisor_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"}, {"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']}", "KB_SERVICE_URL": f"{base}:{ports['kb']}",
"REPORTING_SERVICE_URL": f"{base}:{ports['reporting']}", "REPORTING_SERVICE_URL": f"{base}:{ports['reporting']}",
"SUPERVISOR_SERVICE_URL": f"{base}:{ports['supervisor']}", "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", "AI_VOICE_RUNTIME_TRUSTED_SERVICE_SUBJECTS",
"svc:ai-voice-runtime", "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_CLAIM_CONTEXT"] = env.get("ASTERISK_CALLCONTROL_CLAIM_CONTEXT", "mvpcc-claim")
env["ASTERISK_CALLCONTROL_TRANSFER_CONTEXT"] = env.get( env["ASTERISK_CALLCONTROL_TRANSFER_CONTEXT"] = env.get(
"ASTERISK_CALLCONTROL_TRANSFER_CONTEXT", "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") env["RECORDING_MAX_BYTES"] = env.get("RECORDING_MAX_BYTES", "26214400")
if spec["name"] == "streaming-asr-sidecar": if spec["name"] == "streaming-asr-sidecar":
Path(env["AI_VOICE_V2_STREAMING_ASR_CACHE_DIR"]).mkdir(parents=True, exist_ok=True) 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"])) stdout_path, stderr_path = create_log_paths(runtime_dir, str(spec["name"]))
creationflags = 0 creationflags = 0
+173
View File
@@ -83,6 +83,10 @@ def _interaction_service_url() -> str:
return os.getenv("INTERACTION_SERVICE_URL", "http://localhost:8004").rstrip("/") 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: def _asr_provider_name() -> str:
return os.getenv("AI_VOICE_ASR_PROVIDER", "yandex").strip() or "yandex" 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) 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: def _resolved_voice_language(language_hint: str | None, fallback: str | None = None) -> str:
return str(language_hint or fallback or "ru").strip() or "ru" 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 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: def _next_sequence(session, session_id: str) -> int:
return next_transcript_sequence(session, session_id) return next_transcript_sequence(session, session_id)
@@ -1007,6 +1145,11 @@ def _set_voice_session_state(
handoff_reason=handoff_reason, handoff_reason=handoff_reason,
metadata=metadata, 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": if ai_state == "error":
_append_interaction_timeline( _append_interaction_timeline(
voice_session.interaction_id, voice_session.interaction_id,
@@ -1150,6 +1293,11 @@ def _handle_media_error(session_id: str, message: str, metadata: dict[str, Any]
) )
except Exception: except Exception:
pass pass
_sync_sales_voice_session(
voice_session.session_id,
include_transcript=True,
metadata=metadata,
)
finally: finally:
session.close() session.close()
@@ -1235,6 +1383,13 @@ def _process_voice_ai_turn_sync(
session.commit() session.commit()
if auto_handoff and decision.needs_handoff and not early_plan_only: if auto_handoff and decision.needs_handoff and not early_plan_only:
_request_runtime_handoff(session_id, payload.transcript_text, decision) _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 return decision
except HTTPException: except HTTPException:
raise raise
@@ -1331,6 +1486,11 @@ def _complete_voice_ai_session_start(session_id: str, start_payload: VoiceAIStar
handoff_reason=voice_session.handoff_reason, handoff_reason=voice_session.handoff_reason,
metadata=handoff_metadata, metadata=handoff_metadata,
) )
_sync_sales_voice_session(
voice_session.session_id,
include_transcript=False,
metadata=handoff_metadata,
)
if needs_handoff: if needs_handoff:
handoff_summary = { handoff_summary = {
"customer_request_text": "voice_start", "customer_request_text": "voice_start",
@@ -1502,6 +1662,14 @@ def create_voice_ai_session(
) )
_register_voice_start_thread(voice_start_thread) _register_voice_start_thread(voice_start_thread)
voice_start_thread.start() 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( return VoiceAISessionOut(
voice_session_id=voice_session.session_id, voice_session_id=voice_session.session_id,
ai_session_id=voice_session.ai_session_id, ai_session_id=voice_session.ai_session_id,
@@ -1574,6 +1742,11 @@ def push_voice_ai_telephony_event(
) )
except Exception: except Exception:
pass 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( return VoiceAISessionOut(
voice_session_id=session_id, voice_session_id=session_id,
@@ -1,13 +1,14 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import audioop
import io import io
import struct import struct
import uuid import uuid
import wave import wave
from dataclasses import dataclass from dataclasses import dataclass
from services.shared.audioop_compat import audioop
AUDIO_SOCKET_PACKET_HANGUP = 0x00 AUDIO_SOCKET_PACKET_HANGUP = 0x00
AUDIO_SOCKET_PACKET_UUID = 0x01 AUDIO_SOCKET_PACKET_UUID = 0x01
@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import audioop
import contextlib import contextlib
import hashlib import hashlib
import logging import logging
@@ -33,6 +32,7 @@ from services.ai_voice_runtime_service.providers.asr import (
StreamingASRProvider, StreamingASRProvider,
StreamingASRUnavailable, StreamingASRUnavailable,
) )
from services.shared.audioop_compat import audioop
from services.ai_voice_runtime_service.providers.tts import TTSProvider from services.ai_voice_runtime_service.providers.tts import TTSProvider
from services.shared.models import VoiceAITurnDecisionOut from services.shared.models import VoiceAITurnDecisionOut
@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import audioop
import base64 import base64
import io import io
import json import json
@@ -17,6 +16,7 @@ from dataclasses import dataclass, field
import httpx import httpx
from services.ai_voice_runtime_service.audiosocket import pcm16le_to_wav_bytes, resample_pcm16le 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 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 from __future__ import annotations
import audioop
import base64 import base64
import logging import logging
import os import os
@@ -13,6 +12,7 @@ from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from services.shared.core import Role, new_id from services.shared.core import Role, new_id
from services.shared.audioop_compat import audioop
from services.shared.models import HealthResponse from services.shared.models import HealthResponse
from services.shared.security import require_roles 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("/") 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: def _ai_telegram_enabled() -> bool:
return _bool_env("AI_TELEGRAM_ENABLED", False) 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() 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: def _ai_enqueue_request(thread_id: str, trigger_message_id: str | None) -> None:
if not _ai_telegram_enabled(): if not _ai_telegram_enabled():
return return
@@ -1563,6 +1623,7 @@ def webhook(payload: TelegramWebhookIn) -> TelegramWebhookOut:
session.commit() session.commit()
session.refresh(row) session.refresh(row)
session.refresh(thread) session.refresh(thread)
_sync_sales_thread(thread, row)
_maybe_enqueue_ai_for_thread(thread, row.message_id, author_type=row.author_type) _maybe_enqueue_ai_for_thread(thread, row.message_id, author_type=row.author_type)
return _to_webhook_out(row) return _to_webhook_out(row)
finally: finally:
@@ -1614,6 +1675,9 @@ def bot_webhook(
if existing: if existing:
return _to_bot_webhook_result(existing, thread_created=False) return _to_bot_webhook_result(existing, thread_created=False)
raise 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) _maybe_enqueue_ai_for_thread(thread, row.message_id, author_type=row.author_type)
return _to_bot_webhook_result(row, thread_created=created) return _to_bot_webhook_result(row, thread_created=created)
finally: finally:
@@ -1768,6 +1832,7 @@ def claim_thread(
) )
session.commit() session.commit()
session.refresh(thread) session.refresh(thread)
_sync_sales_thread(thread)
return _to_thread_out(thread) return _to_thread_out(thread)
finally: finally:
session.close() session.close()
@@ -1826,6 +1891,7 @@ def return_thread_to_ai(
) )
session.commit() session.commit()
session.refresh(thread) session.refresh(thread)
_sync_sales_thread(thread)
return _to_thread_out(thread) return _to_thread_out(thread)
finally: finally:
session.close() session.close()
@@ -1872,6 +1938,8 @@ def reply_thread(
) )
session.commit() session.commit()
session.refresh(row) session.refresh(row)
session.refresh(thread)
_sync_sales_thread(thread, row)
_start_telegram_reply_delivery(row.message_id) _start_telegram_reply_delivery(row.message_id)
return _to_message_out(row) return _to_message_out(row)
finally: finally:
@@ -1947,6 +2015,8 @@ def ai_reply_thread(
) )
session.commit() session.commit()
session.refresh(row) session.refresh(row)
session.refresh(thread)
_sync_sales_thread(thread, row)
_start_telegram_reply_delivery(row.message_id) _start_telegram_reply_delivery(row.message_id)
return _to_message_out(row) return _to_message_out(row)
finally: finally:
@@ -1996,6 +2066,7 @@ def ai_handoff_thread(
) )
session.commit() session.commit()
session.refresh(thread) session.refresh(thread)
_sync_sales_thread(thread)
return _to_thread_out(thread) return _to_thread_out(thread)
finally: finally:
session.close() session.close()
@@ -2032,6 +2103,7 @@ def close_thread(
) )
session.commit() session.commit()
session.refresh(thread) session.refresh(thread)
_sync_sales_thread(thread)
return _to_thread_out(thread) return _to_thread_out(thread)
finally: finally:
session.close() session.close()
@@ -2067,6 +2139,7 @@ def escalate_thread(
) )
session.commit() session.commit()
session.refresh(thread) session.refresh(thread)
_sync_sales_thread(thread)
return _to_thread_out(thread) return _to_thread_out(thread)
finally: finally:
session.close() 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["WEBCHAT_ADAPTER_SERVICE_URL"] == "http://127.0.0.1:8011"
assert env["EMAIL_ADAPTER_SERVICE_URL"] == "http://127.0.0.1:8012" 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["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(): 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" in names
assert "ai-voice-runtime" in names assert "ai-voice-runtime" in names
assert "asterisk-bridge" in names assert "asterisk-bridge" in names
assert "sales" in names
def test_local_stack_manifest_payload_has_core_fields(tmp_path: Path): 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 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(): def test_legal_routes_exist_for_meta_review():
client = TestClient(app) client = TestClient(app)
@@ -60,6 +68,7 @@ def test_contracts_shape():
assert "stage_15" in payload assert "stage_15" in payload
assert "stage_16" in payload assert "stage_16" in payload
assert "stage_17" in payload assert "stage_17" in payload
assert "stage_18" in payload
assert "/integrations/webchat/messages" in payload["stage_2"] assert "/integrations/webchat/messages" in payload["stage_2"]
assert "/integrations/email/messages" in payload["stage_2"] assert "/integrations/email/messages" in payload["stage_2"]
assert "/integrations/whatsapp/webhook" 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/drilldown" in payload["stage_16"]
assert "/ai/analytics/sessions/*" in payload["stage_16"] assert "/ai/analytics/sessions/*" in payload["stage_16"]
assert "/asterisk/live-calls/*/ai-summary" in payload["stage_17"] 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(): 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="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="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="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> <a class="nav-link active" data-shell="admin" data-roles="admin" href="/admin"><span class="nav-bullet"></span>Администрирование</a>
</div> </div>
<div class="nav-group"> <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="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="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="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>
<div class="nav-group"> <div class="nav-group">
<div class="nav-label">Страницы</div> <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;
}
}