sales fix
This commit is contained in:
@@ -18,6 +18,7 @@ TELEGRAM_ADAPTER_SERVICE_URL=http://localhost:8007
|
||||
KB_SERVICE_URL=http://localhost:8008
|
||||
REPORTING_SERVICE_URL=http://localhost:8009
|
||||
SUPERVISOR_SERVICE_URL=http://localhost:8010
|
||||
SALES_SERVICE_URL=http://localhost:8020
|
||||
TELEGRAM_BOT_ENABLED=0
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_WEBHOOK_SECRET=
|
||||
@@ -49,6 +50,8 @@ AI_WHATSAPP_MAX_CONTEXT_MESSAGES=12
|
||||
AI_WHATSAPP_MAX_KB_RESULTS=3
|
||||
AI_WHATSAPP_CONFIDENCE_HANDOFF_THRESHOLD=0.65
|
||||
AI_VOICE_RUNTIME_SERVICE_URL=http://localhost:8018
|
||||
SALES_TELEGRAM_BRIDGE_ENABLED=1
|
||||
SALES_VOICE_RUNTIME_ENABLED=1
|
||||
AI_VOICE_ENABLED=0
|
||||
AI_VOICE_POLICY_MODE=v2_fast_conversational
|
||||
AI_VOICE_QUEUE_CONFIG_JSON={}
|
||||
|
||||
@@ -12,6 +12,9 @@ RECORDING_SERVICE_URL=http://recording-service:8000
|
||||
TELEGRAM_ADAPTER_SERVICE_URL=http://telegram-adapter-service:8000
|
||||
AI_ORCHESTRATOR_SERVICE_URL=http://ai-orchestrator-service:8000
|
||||
AI_VOICE_RUNTIME_SERVICE_URL=http://ai-voice-runtime-service:8000
|
||||
SALES_SERVICE_URL=http://sales-service:8000
|
||||
SALES_TELEGRAM_BRIDGE_ENABLED=1
|
||||
SALES_VOICE_RUNTIME_ENABLED=1
|
||||
|
||||
# Telegram real-bot chat (Track 15)
|
||||
TELEGRAM_BOT_ENABLED=1
|
||||
|
||||
Generated
+10
@@ -0,0 +1,10 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
@@ -22,6 +22,7 @@ x-app-env: &app_env
|
||||
KB_SERVICE_URL: http://kb-service:8000
|
||||
REPORTING_SERVICE_URL: http://reporting-service:8000
|
||||
SUPERVISOR_SERVICE_URL: http://supervisor-service:8000
|
||||
SALES_SERVICE_URL: http://sales-service:8000
|
||||
AI_VOICE_ASR_PROVIDER: yandex
|
||||
AI_VOICE_TTS_PROVIDER: yandex
|
||||
AI_VOICE_V2_STREAMING_ASR_BACKEND: yandex_speechkit
|
||||
@@ -195,6 +196,14 @@ services:
|
||||
<<: *app_env
|
||||
APP_MODULE: services.supervisor_service.app:app
|
||||
|
||||
sales-service:
|
||||
<<: *service_defaults
|
||||
environment:
|
||||
<<: *app_env
|
||||
APP_MODULE: services.sales_service.app:app
|
||||
SALES_TELEGRAM_BRIDGE_ENABLED: "1"
|
||||
SALES_VOICE_RUNTIME_ENABLED: "1"
|
||||
|
||||
event-bus-service:
|
||||
<<: *service_defaults
|
||||
environment:
|
||||
@@ -229,6 +238,7 @@ services:
|
||||
- kb-service
|
||||
- reporting-service
|
||||
- supervisor-service
|
||||
- sales-service
|
||||
- event-bus-service
|
||||
- asterisk-bridge-service
|
||||
environment:
|
||||
|
||||
@@ -22,6 +22,7 @@ x-app-env: &app_env
|
||||
KB_SERVICE_URL: http://kb-service:8000
|
||||
REPORTING_SERVICE_URL: http://reporting-service:8000
|
||||
SUPERVISOR_SERVICE_URL: http://supervisor-service:8000
|
||||
SALES_SERVICE_URL: http://sales-service:8000
|
||||
AI_VOICE_ASR_PROVIDER: yandex
|
||||
AI_VOICE_TTS_PROVIDER: yandex
|
||||
AI_VOICE_V2_STREAMING_ASR_BACKEND: yandex_speechkit
|
||||
@@ -191,6 +192,14 @@ services:
|
||||
<<: *app_env
|
||||
APP_MODULE: services.supervisor_service.app:app
|
||||
|
||||
sales-service:
|
||||
<<: *service_defaults
|
||||
environment:
|
||||
<<: *app_env
|
||||
APP_MODULE: services.sales_service.app:app
|
||||
SALES_TELEGRAM_BRIDGE_ENABLED: "1"
|
||||
SALES_VOICE_RUNTIME_ENABLED: "1"
|
||||
|
||||
event-bus-service:
|
||||
<<: *service_defaults
|
||||
environment:
|
||||
@@ -228,6 +237,7 @@ services:
|
||||
- kb-service
|
||||
- reporting-service
|
||||
- supervisor-service
|
||||
- sales-service
|
||||
- event-bus-service
|
||||
- asterisk-bridge-service
|
||||
environment:
|
||||
|
||||
@@ -22,6 +22,7 @@ x-app-env: &app_env
|
||||
KB_SERVICE_URL: http://kb-service:8000
|
||||
REPORTING_SERVICE_URL: http://reporting-service:8000
|
||||
SUPERVISOR_SERVICE_URL: http://supervisor-service:8000
|
||||
SALES_SERVICE_URL: http://sales-service:8000
|
||||
AI_VOICE_ASR_PROVIDER: ${AI_VOICE_ASR_PROVIDER:-elevenlabs}
|
||||
AI_VOICE_TTS_PROVIDER: ${AI_VOICE_TTS_PROVIDER:-elevenlabs}
|
||||
AI_VOICE_V2_STREAMING_ASR_BACKEND: ${AI_VOICE_V2_STREAMING_ASR_BACKEND:-elevenlabs_realtime}
|
||||
@@ -193,6 +194,14 @@ services:
|
||||
<<: *app_env
|
||||
APP_MODULE: services.supervisor_service.app:app
|
||||
|
||||
sales-service:
|
||||
<<: *service_defaults
|
||||
environment:
|
||||
<<: *app_env
|
||||
APP_MODULE: services.sales_service.app:app
|
||||
SALES_TELEGRAM_BRIDGE_ENABLED: "1"
|
||||
SALES_VOICE_RUNTIME_ENABLED: "1"
|
||||
|
||||
event-bus-service:
|
||||
<<: *service_defaults
|
||||
environment:
|
||||
@@ -230,6 +239,7 @@ services:
|
||||
- kb-service
|
||||
- reporting-service
|
||||
- supervisor-service
|
||||
- sales-service
|
||||
- event-bus-service
|
||||
- asterisk-bridge-service
|
||||
environment:
|
||||
|
||||
@@ -295,6 +295,19 @@ services:
|
||||
ports:
|
||||
- "8010:8000"
|
||||
|
||||
sales-service:
|
||||
build: ..
|
||||
depends_on: [postgres, rabbitmq]
|
||||
environment:
|
||||
<<: *app_env
|
||||
APP_MODULE: services.sales_service.app:app
|
||||
SALES_TELEGRAM_BRIDGE_ENABLED: "1"
|
||||
SALES_VOICE_RUNTIME_ENABLED: "1"
|
||||
volumes:
|
||||
- ../.data:/app/.data
|
||||
ports:
|
||||
- "8020:8000"
|
||||
|
||||
event-bus-service:
|
||||
build: ..
|
||||
depends_on: [postgres, rabbitmq]
|
||||
@@ -342,6 +355,7 @@ services:
|
||||
- kb-service
|
||||
- reporting-service
|
||||
- supervisor-service
|
||||
- sales-service
|
||||
- event-bus-service
|
||||
- asterisk-bridge-service
|
||||
environment:
|
||||
@@ -364,6 +378,7 @@ services:
|
||||
KB_SERVICE_URL: http://kb-service:8000
|
||||
REPORTING_SERVICE_URL: http://reporting-service:8000
|
||||
SUPERVISOR_SERVICE_URL: http://supervisor-service:8000
|
||||
SALES_SERVICE_URL: http://sales-service:8000
|
||||
ports:
|
||||
- "8080:8000"
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Real-Time Voice Service Compatibility
|
||||
|
||||
This project uses `services/ai_voice_runtime_service` as the canonical
|
||||
real-time voice runtime implementation.
|
||||
|
||||
To match integration naming used in external projects like
|
||||
`realtime_voice_assistance`, a compatibility entrypoint is also available:
|
||||
|
||||
- `services.real_time_voice.app:app`
|
||||
|
||||
Both entrypoints reference the same FastAPI application object and behavior.
|
||||
|
||||
@@ -118,6 +118,7 @@ _OPERATOR_UI_DIR = _ROOT / "ui" / "operator"
|
||||
_SUPERVISOR_UI_DIR = _ROOT / "ui" / "supervisor"
|
||||
_ANALYST_UI_DIR = _ROOT / "ui" / "analyst"
|
||||
_ADMIN_UI_DIR = _ROOT / "ui" / "admin"
|
||||
_SALES_UI_DIR = _ROOT / "ui" / "sales"
|
||||
_IVR_PREVIEW_AUDIO_DIR = Path(
|
||||
os.getenv("IVR_PREVIEW_AUDIO_DIR", str(_ROOT / ".data_local" / "generated_ivr_yandex"))
|
||||
).expanduser()
|
||||
@@ -131,6 +132,8 @@ if _ANALYST_UI_DIR.exists():
|
||||
app.mount("/analyst/assets", StaticFiles(directory=str(_ANALYST_UI_DIR)), name="analyst_assets")
|
||||
if _ADMIN_UI_DIR.exists():
|
||||
app.mount("/admin/assets", StaticFiles(directory=str(_ADMIN_UI_DIR)), name="admin_assets")
|
||||
if _SALES_UI_DIR.exists():
|
||||
app.mount("/sales/assets", StaticFiles(directory=str(_SALES_UI_DIR)), name="sales_assets")
|
||||
if _IVR_PREVIEW_AUDIO_DIR.exists():
|
||||
app.mount("/_preview_audio", StaticFiles(directory=str(_IVR_PREVIEW_AUDIO_DIR)), name="ivr_preview_audio")
|
||||
|
||||
@@ -154,6 +157,7 @@ SERVICE_URLS = {
|
||||
"kb": os.getenv("KB_SERVICE_URL", "http://localhost:8008"),
|
||||
"reporting": os.getenv("REPORTING_SERVICE_URL", "http://localhost:8009"),
|
||||
"supervisor": os.getenv("SUPERVISOR_SERVICE_URL", "http://localhost:8010"),
|
||||
"sales": os.getenv("SALES_SERVICE_URL", "http://localhost:8020"),
|
||||
}
|
||||
|
||||
|
||||
@@ -275,6 +279,14 @@ def contracts() -> dict:
|
||||
"/ai/voice/sessions/*",
|
||||
"/asterisk/live-calls/*/ai-summary",
|
||||
],
|
||||
"stage_18": [
|
||||
"/api/v1/leads",
|
||||
"/api/v1/deals",
|
||||
"/api/v1/messages/inbound-webhook",
|
||||
"/api/v1/calls/inbound-webhook",
|
||||
"/api/v1/deals/*/workspace",
|
||||
"/api/v1/payments/webhook",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -306,6 +318,13 @@ def admin_ui() -> FileResponse:
|
||||
return FileResponse(_ADMIN_UI_DIR / "index.html")
|
||||
|
||||
|
||||
@app.get("/sales")
|
||||
def sales_ui() -> FileResponse:
|
||||
if not _SALES_UI_DIR.exists():
|
||||
raise HTTPException(status_code=404, detail="Sales UI not found")
|
||||
return FileResponse(_SALES_UI_DIR / "index.html")
|
||||
|
||||
|
||||
@app.get("/privacy")
|
||||
def privacy_page() -> HTMLResponse:
|
||||
return HTMLResponse(_LEGAL_HTML["privacy"])
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -95,6 +95,7 @@ def build_service_specs(env: dict[str, str] | None = None) -> list[dict[str, Any
|
||||
{"name": "kb", "port": 8008, "module": "services.kb_service.app:app"},
|
||||
{"name": "reporting", "port": 8009, "module": "services.reporting_service.app:app"},
|
||||
{"name": "supervisor", "port": 8010, "module": "services.supervisor_service.app:app"},
|
||||
{"name": "sales", "port": 8020, "module": "services.sales_service.app:app"},
|
||||
{"name": "gateway", "port": gateway_port, "module": "gateway.app:app"},
|
||||
]
|
||||
|
||||
@@ -121,6 +122,7 @@ def build_gateway_env(env: dict[str, str] | None = None) -> dict[str, str]:
|
||||
"KB_SERVICE_URL": f"{base}:{ports['kb']}",
|
||||
"REPORTING_SERVICE_URL": f"{base}:{ports['reporting']}",
|
||||
"SUPERVISOR_SERVICE_URL": f"{base}:{ports['supervisor']}",
|
||||
"SALES_SERVICE_URL": f"{base}:{ports['sales']}",
|
||||
}
|
||||
|
||||
|
||||
@@ -338,6 +340,7 @@ def spawn_service(spec: dict[str, Any], runtime_dir: Path, data_dir: Path, base_
|
||||
"AI_VOICE_RUNTIME_TRUSTED_SERVICE_SUBJECTS",
|
||||
"svc:ai-voice-runtime",
|
||||
)
|
||||
env.update(build_gateway_env(env))
|
||||
env["ASTERISK_CALLCONTROL_CLAIM_CONTEXT"] = env.get("ASTERISK_CALLCONTROL_CLAIM_CONTEXT", "mvpcc-claim")
|
||||
env["ASTERISK_CALLCONTROL_TRANSFER_CONTEXT"] = env.get(
|
||||
"ASTERISK_CALLCONTROL_TRANSFER_CONTEXT",
|
||||
@@ -348,8 +351,6 @@ def spawn_service(spec: dict[str, Any], runtime_dir: Path, data_dir: Path, base_
|
||||
env["RECORDING_MAX_BYTES"] = env.get("RECORDING_MAX_BYTES", "26214400")
|
||||
if spec["name"] == "streaming-asr-sidecar":
|
||||
Path(env["AI_VOICE_V2_STREAMING_ASR_CACHE_DIR"]).mkdir(parents=True, exist_ok=True)
|
||||
if spec["name"] == "gateway":
|
||||
env.update(build_gateway_env(env))
|
||||
|
||||
stdout_path, stderr_path = create_log_paths(runtime_dir, str(spec["name"]))
|
||||
creationflags = 0
|
||||
|
||||
@@ -83,6 +83,10 @@ def _interaction_service_url() -> str:
|
||||
return os.getenv("INTERACTION_SERVICE_URL", "http://localhost:8004").rstrip("/")
|
||||
|
||||
|
||||
def _sales_service_url() -> str:
|
||||
return os.getenv("SALES_SERVICE_URL", "http://localhost:8020").rstrip("/")
|
||||
|
||||
|
||||
def _asr_provider_name() -> str:
|
||||
return os.getenv("AI_VOICE_ASR_PROVIDER", "yandex").strip() or "yandex"
|
||||
|
||||
@@ -266,6 +270,10 @@ def _interaction_request(method: str, path: str, *, payload: dict[str, Any] | No
|
||||
return _request(method, f"{_interaction_service_url()}{path}", payload=payload, timeout=timeout)
|
||||
|
||||
|
||||
def _sales_request(method: str, path: str, *, payload: dict[str, Any] | None = None, timeout: float = 8.0) -> dict[str, Any]:
|
||||
return _request(method, f"{_sales_service_url()}{path}", payload=payload, timeout=timeout)
|
||||
|
||||
|
||||
def _resolved_voice_language(language_hint: str | None, fallback: str | None = None) -> str:
|
||||
return str(language_hint or fallback or "ru").strip() or "ru"
|
||||
|
||||
@@ -302,6 +310,136 @@ def _load_voice_session(session, session_id: str) -> VoiceAISessionRow:
|
||||
return row
|
||||
|
||||
|
||||
def _load_call_link(session, call_id: str) -> AsteriskCallLinkRow | None:
|
||||
return session.execute(
|
||||
select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == call_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _latest_caller_transcript_text(session, session_id: str) -> str | None:
|
||||
row = session.execute(
|
||||
select(VoiceTranscriptSegmentRow)
|
||||
.where(VoiceTranscriptSegmentRow.session_id == session_id)
|
||||
.where(VoiceTranscriptSegmentRow.speaker == "caller")
|
||||
.where(VoiceTranscriptSegmentRow.is_final.is_(True))
|
||||
.order_by(VoiceTranscriptSegmentRow.sequence_no.desc())
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
text = str(row.text or "").strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _sales_transcript_snapshot(session, session_id: str, *, max_chars: int = 6000) -> str | None:
|
||||
rows = session.execute(
|
||||
select(VoiceTranscriptSegmentRow)
|
||||
.where(VoiceTranscriptSegmentRow.session_id == session_id)
|
||||
.where(VoiceTranscriptSegmentRow.is_final.is_(True))
|
||||
.order_by(VoiceTranscriptSegmentRow.sequence_no.asc())
|
||||
).scalars().all()
|
||||
lines: list[str] = []
|
||||
for row in rows:
|
||||
text = str(row.text or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
speaker = "customer" if str(row.speaker or "").strip() == "caller" else str(row.speaker or "assistant").strip()
|
||||
lines.append(f"{speaker}: {text}")
|
||||
if not lines:
|
||||
return None
|
||||
selected: list[str] = []
|
||||
total = 0
|
||||
for line in reversed(lines):
|
||||
line_len = len(line) + (1 if selected else 0)
|
||||
if selected and total + line_len > max_chars:
|
||||
break
|
||||
if not selected and len(line) > max_chars:
|
||||
selected.append(line[-max_chars:])
|
||||
total = len(selected[0])
|
||||
break
|
||||
selected.append(line)
|
||||
total += line_len
|
||||
return "\n".join(reversed(selected)) or None
|
||||
|
||||
|
||||
def _sync_sales_voice_session(
|
||||
session_id: str,
|
||||
*,
|
||||
caller_number: str | None = None,
|
||||
caller_name: str | None = None,
|
||||
queue_code: str | None = None,
|
||||
summary: str | None = None,
|
||||
include_transcript: bool = False,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
session = get_session()
|
||||
try:
|
||||
voice_session = _load_voice_session(session, session_id)
|
||||
link = _load_call_link(session, voice_session.call_id)
|
||||
resolved_summary = (
|
||||
str(summary or "").strip()
|
||||
or _latest_caller_transcript_text(session, session_id)
|
||||
or str(voice_session.handoff_reason or "").strip()
|
||||
or None
|
||||
)
|
||||
transcript_text = _sales_transcript_snapshot(session, session_id) if include_transcript else None
|
||||
payload = {
|
||||
"call_id": voice_session.call_id,
|
||||
"interaction_id": str(voice_session.interaction_id or getattr(link, "interaction_id", "") or "").strip() or None,
|
||||
"queue_id": str(voice_session.queue_id or getattr(link, "queue_id", "") or "").strip() or None,
|
||||
"queue_code": str(getattr(link, "queue_code", "") or queue_code or "").strip() or None,
|
||||
"caller_number": str(getattr(link, "caller_number", "") or caller_number or "").strip() or None,
|
||||
"caller_name": str(getattr(link, "caller_name", "") or caller_name or "").strip() or None,
|
||||
"voice_session_id": voice_session.session_id,
|
||||
"ai_session_id": voice_session.ai_session_id,
|
||||
"ai_state": voice_session.status,
|
||||
"handoff_reason": voice_session.handoff_reason,
|
||||
"telephony_status": str(getattr(link, "telephony_status", "") or voice_session.media_status or "").strip() or None,
|
||||
"call_status": str(getattr(link, "status", "") or voice_session.status or "").strip() or None,
|
||||
"started_at": voice_session.started_at,
|
||||
"ended_at": voice_session.ended_at,
|
||||
"summary": resolved_summary,
|
||||
"transcript_text": transcript_text,
|
||||
"metadata": {
|
||||
"linked_id": voice_session.linked_id,
|
||||
"agent_profile": voice_session.agent_profile,
|
||||
"language": voice_session.language,
|
||||
"asr_provider": voice_session.asr_provider,
|
||||
"tts_provider": voice_session.tts_provider,
|
||||
"handoff_target_queue_id": voice_session.handoff_target_queue_id,
|
||||
"media_status": voice_session.media_status,
|
||||
"media_uuid": voice_session.media_uuid,
|
||||
"media_connected_at": voice_session.media_connected_at,
|
||||
"media_ended_at": voice_session.media_ended_at,
|
||||
"last_media_frame_at": voice_session.last_media_frame_at,
|
||||
"last_user_utterance_at": voice_session.last_user_utterance_at,
|
||||
"last_ai_reply_at": voice_session.last_ai_reply_at,
|
||||
"voice_start_language": voice_session.voice_start_language,
|
||||
"customer_name_status": voice_session.customer_name_status,
|
||||
"customer_name_value": voice_session.customer_name_value,
|
||||
"customer_name_source": voice_session.customer_name_source,
|
||||
"customer_name_resolved_at": voice_session.customer_name_resolved_at,
|
||||
"claimed_by_user": getattr(link, "claimed_by_user", None),
|
||||
"claimed_at": getattr(link, "claimed_at", None),
|
||||
"operator_extension": getattr(link, "operator_extension", None),
|
||||
"channel_name": getattr(link, "channel_name", None),
|
||||
**(metadata or {}),
|
||||
},
|
||||
}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
try:
|
||||
_sales_request("POST", "/internal/sales-sync/voice", payload=payload, timeout=8.0)
|
||||
except Exception:
|
||||
LOGGER.exception(
|
||||
"voice_runtime.sales_sync_failed session_id=%s call_id=%s ai_state=%s",
|
||||
payload.get("voice_session_id"),
|
||||
payload.get("call_id"),
|
||||
payload.get("ai_state"),
|
||||
)
|
||||
|
||||
|
||||
def _next_sequence(session, session_id: str) -> int:
|
||||
return next_transcript_sequence(session, session_id)
|
||||
|
||||
@@ -1007,6 +1145,11 @@ def _set_voice_session_state(
|
||||
handoff_reason=handoff_reason,
|
||||
metadata=metadata,
|
||||
)
|
||||
_sync_sales_voice_session(
|
||||
voice_session.session_id,
|
||||
include_transcript=ai_state in {"handoff_requested", "human_owned", "completed", "closed", "error"},
|
||||
metadata=metadata,
|
||||
)
|
||||
if ai_state == "error":
|
||||
_append_interaction_timeline(
|
||||
voice_session.interaction_id,
|
||||
@@ -1150,6 +1293,11 @@ def _handle_media_error(session_id: str, message: str, metadata: dict[str, Any]
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
_sync_sales_voice_session(
|
||||
voice_session.session_id,
|
||||
include_transcript=True,
|
||||
metadata=metadata,
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@@ -1235,6 +1383,13 @@ def _process_voice_ai_turn_sync(
|
||||
session.commit()
|
||||
if auto_handoff and decision.needs_handoff and not early_plan_only:
|
||||
_request_runtime_handoff(session_id, payload.transcript_text, decision)
|
||||
if not early_plan_only:
|
||||
_sync_sales_voice_session(
|
||||
session_id,
|
||||
include_transcript=True,
|
||||
summary=decision.summary_text or decision.reply_text or payload.transcript_text,
|
||||
metadata=decision.metadata,
|
||||
)
|
||||
return decision
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -1331,6 +1486,11 @@ def _complete_voice_ai_session_start(session_id: str, start_payload: VoiceAIStar
|
||||
handoff_reason=voice_session.handoff_reason,
|
||||
metadata=handoff_metadata,
|
||||
)
|
||||
_sync_sales_voice_session(
|
||||
voice_session.session_id,
|
||||
include_transcript=False,
|
||||
metadata=handoff_metadata,
|
||||
)
|
||||
if needs_handoff:
|
||||
handoff_summary = {
|
||||
"customer_request_text": "voice_start",
|
||||
@@ -1502,6 +1662,14 @@ def create_voice_ai_session(
|
||||
)
|
||||
_register_voice_start_thread(voice_start_thread)
|
||||
voice_start_thread.start()
|
||||
_sync_sales_voice_session(
|
||||
voice_session.session_id,
|
||||
caller_number=payload.caller_number,
|
||||
caller_name=payload.caller_name,
|
||||
queue_code=str(payload_metadata.get("queue_code") or "").strip() or None,
|
||||
include_transcript=False,
|
||||
metadata=payload_metadata,
|
||||
)
|
||||
return VoiceAISessionOut(
|
||||
voice_session_id=voice_session.session_id,
|
||||
ai_session_id=voice_session.ai_session_id,
|
||||
@@ -1574,6 +1742,11 @@ def push_voice_ai_telephony_event(
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
_sync_sales_voice_session(
|
||||
session_id,
|
||||
include_transcript=payload.event_type in {"operator.connected", "call.ended"},
|
||||
metadata={"telephony_event_type": payload.event_type, **(payload.payload or {})},
|
||||
)
|
||||
|
||||
return VoiceAISessionOut(
|
||||
voice_session_id=session_id,
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import audioop
|
||||
import io
|
||||
import struct
|
||||
import uuid
|
||||
import wave
|
||||
from dataclasses import dataclass
|
||||
|
||||
from services.shared.audioop_compat import audioop
|
||||
|
||||
|
||||
AUDIO_SOCKET_PACKET_HANGUP = 0x00
|
||||
AUDIO_SOCKET_PACKET_UUID = 0x01
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import audioop
|
||||
import contextlib
|
||||
import hashlib
|
||||
import logging
|
||||
@@ -33,6 +32,7 @@ from services.ai_voice_runtime_service.providers.asr import (
|
||||
StreamingASRProvider,
|
||||
StreamingASRUnavailable,
|
||||
)
|
||||
from services.shared.audioop_compat import audioop
|
||||
from services.ai_voice_runtime_service.providers.tts import TTSProvider
|
||||
from services.shared.models import VoiceAITurnDecisionOut
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import audioop
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
@@ -17,6 +16,7 @@ from dataclasses import dataclass, field
|
||||
import httpx
|
||||
|
||||
from services.ai_voice_runtime_service.audiosocket import pcm16le_to_wav_bytes, resample_pcm16le
|
||||
from services.shared.audioop_compat import audioop
|
||||
from services.shared.security import issue_app_token
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Compatibility package for real-time voice runtime service."""
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Sales service package."""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -0,0 +1,445 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Boolean, Float, Index, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from services.shared.sql_models import Base
|
||||
|
||||
|
||||
class SalesLeadRow(Base):
|
||||
__tablename__ = "sales_leads"
|
||||
__table_args__ = (
|
||||
Index("idx_sales_leads_tenant_status_score", "tenant_id", "status", "lead_score"),
|
||||
Index("idx_sales_leads_phone_email", "phone", "email"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
lead_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
source_type: Mapped[str] = mapped_column(String(64), index=True)
|
||||
source_channel: Mapped[str] = mapped_column(String(32), index=True)
|
||||
source_campaign_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
full_name: Mapped[str] = mapped_column(String(256), index=True)
|
||||
company_name: Mapped[str | None] = mapped_column(String(256), nullable=True, index=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
email: Mapped[str | None] = mapped_column(String(256), nullable=True, index=True)
|
||||
messenger_handles_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
lead_temperature: Mapped[str] = mapped_column(String(16), index=True)
|
||||
lead_score: Mapped[float] = mapped_column(Float, default=50.0, index=True)
|
||||
customer_type: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
segment_type: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
initial_need_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
preferred_channel: Mapped[str] = mapped_column(String(32), index=True)
|
||||
assigned_agent_type: Mapped[str] = mapped_column(String(32), index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), index=True)
|
||||
crm_customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class SalesDealRow(Base):
|
||||
__tablename__ = "sales_deals"
|
||||
__table_args__ = (
|
||||
Index("idx_sales_deals_tenant_stage_status", "tenant_id", "stage_id", "status"),
|
||||
Index("idx_sales_deals_customer_channel", "customer_id", "current_channel"),
|
||||
Index("idx_sales_deals_next_action", "next_action_at", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
deal_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
lead_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
pipeline_id: Mapped[str] = mapped_column(String(64), default="sales_default", index=True)
|
||||
stage_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
scenario_type: Mapped[str] = mapped_column(String(64), index=True)
|
||||
priority: Mapped[int] = mapped_column(Integer, default=3, index=True)
|
||||
title: Mapped[str] = mapped_column(String(512))
|
||||
need_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
product_context_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
estimated_amount: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
final_amount: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
currency: Mapped[str] = mapped_column(String(16), default="KZT")
|
||||
payment_model: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
document_required: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
payment_required: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
assigned_human_user_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
assigned_ai_orchestrator_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
preferred_channel: Mapped[str] = mapped_column(String(32), index=True)
|
||||
current_channel: Mapped[str] = mapped_column(String(32), index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), index=True, default="active")
|
||||
won_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
lost_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
close_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
next_action_type: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
next_action_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
last_contact_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
closed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class SalesCommunicationSessionRow(Base):
|
||||
__tablename__ = "sales_communication_sessions"
|
||||
__table_args__ = (
|
||||
Index("idx_sales_comm_deal_started", "deal_id", "started_at"),
|
||||
Index("idx_sales_comm_channel_status", "channel_type", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
communication_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
deal_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
lead_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
channel_type: Mapped[str] = mapped_column(String(16), index=True)
|
||||
direction: Mapped[str] = mapped_column(String(16), index=True)
|
||||
agent_type: Mapped[str] = mapped_column(String(32), index=True)
|
||||
started_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
ended_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
duration_sec: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
subject: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32), index=True, default="active")
|
||||
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
transcript_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
next_action_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
next_action_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
sentiment: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
result_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
metadata_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class SalesMessageRow(Base):
|
||||
__tablename__ = "sales_messages"
|
||||
__table_args__ = (
|
||||
Index("idx_sales_messages_deal_sent", "deal_id", "sent_at"),
|
||||
Index("idx_sales_messages_ext_id", "channel_provider", "external_message_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
message_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
deal_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
communication_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
sender_type: Mapped[str] = mapped_column(String(32), index=True)
|
||||
sender_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
channel_provider: Mapped[str] = mapped_column(String(32), index=True)
|
||||
external_message_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
body: Mapped[str] = mapped_column(Text)
|
||||
attachments_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||
delivery_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
read_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
message_metadata_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
sent_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class SalesCallRow(Base):
|
||||
__tablename__ = "sales_calls"
|
||||
__table_args__ = (
|
||||
Index("idx_sales_calls_deal_started", "deal_id", "started_at"),
|
||||
Index("idx_sales_calls_ext_provider", "provider", "external_call_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
call_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
deal_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
communication_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
phone_number: Mapped[str] = mapped_column(String(64), index=True)
|
||||
direction: Mapped[str] = mapped_column(String(16), index=True)
|
||||
provider: Mapped[str] = mapped_column(String(64), index=True)
|
||||
external_call_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
recording_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
transcript_status: Mapped[str] = mapped_column(String(32), index=True, default="pending")
|
||||
transcript_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
call_status: Mapped[str] = mapped_column(String(32), index=True, default="started")
|
||||
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
started_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
ended_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
duration_sec: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class SalesTranscriptRow(Base):
|
||||
__tablename__ = "sales_transcripts"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
transcript_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
call_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
language: Mapped[str] = mapped_column(String(16), index=True, default="ru")
|
||||
transcript_text: Mapped[str] = mapped_column(Text)
|
||||
diarization_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
extracted_entities_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class SalesNoteRow(Base):
|
||||
__tablename__ = "sales_notes"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
note_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
deal_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
author_type: Mapped[str] = mapped_column(String(32), index=True)
|
||||
author_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
note_type: Mapped[str] = mapped_column(String(64), index=True)
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class SalesOfferRow(Base):
|
||||
__tablename__ = "sales_offers"
|
||||
__table_args__ = (
|
||||
Index("idx_sales_offers_deal_status", "deal_id", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
offer_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
deal_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
offer_type: Mapped[str] = mapped_column(String(32), index=True)
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
line_items_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||
pricing_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
total_amount: Mapped[float] = mapped_column(Float, default=0)
|
||||
currency: Mapped[str] = mapped_column(String(16), default="KZT")
|
||||
validity_until: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), index=True, default="draft")
|
||||
rendered_document_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_by_type: Mapped[str] = mapped_column(String(32), default="text_ai")
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class SalesConditionRow(Base):
|
||||
__tablename__ = "sales_conditions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
condition_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
deal_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
product_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
service_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
quantity: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
unit: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
delivery_mode: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
execution_date: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
start_date: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
end_date: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
payment_terms: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
custom_terms_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
agreed_price: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
currency: Mapped[str] = mapped_column(String(16), default="KZT")
|
||||
confirmed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class SalesCounterpartyRow(Base):
|
||||
__tablename__ = "sales_counterparties"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
counterparty_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
deal_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
company_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
full_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
bin_iin: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
bank_details_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
signer_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
signer_role: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
signer_basis: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
email_for_docs: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
phone_for_docs: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
completeness_status: Mapped[str] = mapped_column(String(32), index=True, default="draft")
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class SalesDocumentRow(Base):
|
||||
__tablename__ = "sales_documents"
|
||||
__table_args__ = (
|
||||
Index("idx_sales_documents_deal_status", "deal_id", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
document_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
deal_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
document_type: Mapped[str] = mapped_column(String(64), index=True)
|
||||
template_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
version: Mapped[int] = mapped_column(Integer, default=1)
|
||||
status: Mapped[str] = mapped_column(String(32), index=True, default="draft")
|
||||
file_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
rendered_payload_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
external_sign_provider_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
signed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class SalesInvoiceRow(Base):
|
||||
__tablename__ = "sales_invoices"
|
||||
__table_args__ = (
|
||||
Index("idx_sales_invoices_deal_status_due", "deal_id", "status", "due_date"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
invoice_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
deal_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
invoice_number: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
basis_document_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
amount: Mapped[float] = mapped_column(Float, default=0)
|
||||
currency: Mapped[str] = mapped_column(String(16), default="KZT")
|
||||
due_date: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), index=True, default="draft")
|
||||
payment_link: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
line_items_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||
metadata_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
issued_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
paid_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class SalesPaymentRow(Base):
|
||||
__tablename__ = "sales_payments"
|
||||
__table_args__ = (
|
||||
Index("idx_sales_payments_deal_status", "deal_id", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
payment_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
deal_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
invoice_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
payment_provider: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
external_payment_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
amount: Mapped[float] = mapped_column(Float, default=0)
|
||||
currency: Mapped[str] = mapped_column(String(16), default="KZT")
|
||||
status: Mapped[str] = mapped_column(String(32), index=True, default="pending")
|
||||
paid_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
payment_method: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
metadata_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class SalesStageHistoryRow(Base):
|
||||
__tablename__ = "sales_stage_history"
|
||||
__table_args__ = (
|
||||
Index("idx_sales_stage_history_deal_changed", "deal_id", "changed_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
history_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
deal_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
from_stage_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
to_stage_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
changed_by_type: Mapped[str] = mapped_column(String(32), index=True)
|
||||
changed_by_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
changed_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class SalesEscalationRow(Base):
|
||||
__tablename__ = "sales_escalations"
|
||||
__table_args__ = (
|
||||
Index("idx_sales_escalations_deal_status", "deal_id", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
escalation_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
deal_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
escalation_type: Mapped[str] = mapped_column(String(64), index=True)
|
||||
reason: Mapped[str] = mapped_column(Text)
|
||||
severity: Mapped[str] = mapped_column(String(16), index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), index=True, default="open")
|
||||
assigned_to_user_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
resolved_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
|
||||
|
||||
class SalesAutomationTaskRow(Base):
|
||||
__tablename__ = "sales_automation_tasks"
|
||||
__table_args__ = (
|
||||
Index("idx_sales_tasks_run_status", "run_at", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
task_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
deal_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
task_type: Mapped[str] = mapped_column(String(64), index=True)
|
||||
payload_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
run_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), index=True, default="pending")
|
||||
retry_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class SalesChannelSwitchRow(Base):
|
||||
__tablename__ = "sales_channel_switches"
|
||||
__table_args__ = (
|
||||
Index("idx_sales_channel_switches_deal_switched", "deal_id", "switched_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
switch_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
deal_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
communication_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
from_channel: Mapped[str] = mapped_column(String(32), index=True)
|
||||
to_channel: Mapped[str] = mapped_column(String(32), index=True)
|
||||
reason_for_channel_switch: Mapped[str] = mapped_column(Text)
|
||||
switched_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class SalesExternalLinkRow(Base):
|
||||
__tablename__ = "sales_external_links"
|
||||
__table_args__ = (
|
||||
Index("idx_sales_external_links_deal_sync", "deal_id", "last_sync_at"),
|
||||
Index("idx_sales_external_links_thread", "external_thread_id"),
|
||||
Index("idx_sales_external_links_voice_session", "voice_session_id"),
|
||||
Index("idx_sales_external_links_external_call", "external_call_id"),
|
||||
Index("idx_sales_external_links_interaction", "interaction_id"),
|
||||
Index("idx_sales_external_links_customer_phone", "customer_id", "phone_number"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
link_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
deal_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
communication_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
sales_call_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
channel_provider: Mapped[str] = mapped_column(String(32), index=True)
|
||||
external_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
external_chat_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
external_call_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
voice_session_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
ai_session_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
phone_number: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
external_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
link_metadata_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
last_sync_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import audioop
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
@@ -13,6 +12,7 @@ from fastapi import Depends, FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services.shared.core import Role, new_id
|
||||
from services.shared.audioop_compat import audioop
|
||||
from services.shared.models import HealthResponse
|
||||
from services.shared.security import require_roles
|
||||
|
||||
|
||||
@@ -231,6 +231,10 @@ def _ai_service_url() -> str:
|
||||
return os.getenv("AI_ORCHESTRATOR_SERVICE_URL", "http://localhost:8017").rstrip("/")
|
||||
|
||||
|
||||
def _sales_service_url() -> str:
|
||||
return os.getenv("SALES_SERVICE_URL", "http://localhost:8020").rstrip("/")
|
||||
|
||||
|
||||
def _ai_telegram_enabled() -> bool:
|
||||
return _bool_env("AI_TELEGRAM_ENABLED", False)
|
||||
|
||||
@@ -276,6 +280,62 @@ def _interaction_request(method: str, path: str, *, payload: dict | None = None)
|
||||
return response.json()
|
||||
|
||||
|
||||
def _sales_sync_request(payload: dict[str, Any]) -> None:
|
||||
try:
|
||||
with httpx.Client(timeout=5.0) as client:
|
||||
response = client.post(
|
||||
f"{_sales_service_url()}/internal/sales-sync/telegram",
|
||||
json=payload,
|
||||
headers=_internal_service_headers(
|
||||
subject="svc:telegram-adapter",
|
||||
username="telegram-adapter",
|
||||
provider="telegram-adapter",
|
||||
),
|
||||
)
|
||||
response.raise_for_status()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Telegram sales sync failed",
|
||||
extra={
|
||||
"thread_id": payload.get("thread_id"),
|
||||
"message_id": payload.get("message_id"),
|
||||
"direction": payload.get("direction"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _sync_sales_thread(thread: TelegramThreadRow, row: TelegramMessageRow | None = None) -> None:
|
||||
payload = {
|
||||
"thread_id": thread.thread_id,
|
||||
"chat_id": thread.chat_id,
|
||||
"interaction_id": thread.interaction_id,
|
||||
"customer_id": getattr(row, "customer_id", None),
|
||||
"phone_number": None,
|
||||
"display_name": thread.display_name,
|
||||
"queue_id": thread.queue_id,
|
||||
"status": thread.status,
|
||||
"ai_state": thread.ai_state,
|
||||
"ai_handoff_reason": thread.ai_handoff_reason,
|
||||
"message_id": getattr(row, "message_id", None),
|
||||
"external_message_id": getattr(row, "telegram_message_id_external", None),
|
||||
"text": getattr(row, "text", None),
|
||||
"direction": getattr(row, "direction", "inbound"),
|
||||
"author_type": getattr(row, "author_type", None),
|
||||
"author_id": getattr(row, "author_id", None),
|
||||
"happened_at": getattr(row, "created_at", None) or thread.updated_at,
|
||||
"metadata": {
|
||||
"telegram_user_id": thread.telegram_user_id,
|
||||
"username": thread.username,
|
||||
"claimed_by_user": thread.claimed_by_user,
|
||||
"claimed_at": thread.claimed_at,
|
||||
"ai_session_id": thread.ai_session_id,
|
||||
"last_message_at": thread.last_message_at,
|
||||
"last_message_preview": thread.last_message_preview,
|
||||
},
|
||||
}
|
||||
_sales_sync_request(payload)
|
||||
|
||||
|
||||
def _ai_enqueue_request(thread_id: str, trigger_message_id: str | None) -> None:
|
||||
if not _ai_telegram_enabled():
|
||||
return
|
||||
@@ -1563,6 +1623,7 @@ def webhook(payload: TelegramWebhookIn) -> TelegramWebhookOut:
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
session.refresh(thread)
|
||||
_sync_sales_thread(thread, row)
|
||||
_maybe_enqueue_ai_for_thread(thread, row.message_id, author_type=row.author_type)
|
||||
return _to_webhook_out(row)
|
||||
finally:
|
||||
@@ -1614,6 +1675,9 @@ def bot_webhook(
|
||||
if existing:
|
||||
return _to_bot_webhook_result(existing, thread_created=False)
|
||||
raise
|
||||
session.refresh(row)
|
||||
session.refresh(thread)
|
||||
_sync_sales_thread(thread, row)
|
||||
_maybe_enqueue_ai_for_thread(thread, row.message_id, author_type=row.author_type)
|
||||
return _to_bot_webhook_result(row, thread_created=created)
|
||||
finally:
|
||||
@@ -1768,6 +1832,7 @@ def claim_thread(
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(thread)
|
||||
_sync_sales_thread(thread)
|
||||
return _to_thread_out(thread)
|
||||
finally:
|
||||
session.close()
|
||||
@@ -1826,6 +1891,7 @@ def return_thread_to_ai(
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(thread)
|
||||
_sync_sales_thread(thread)
|
||||
return _to_thread_out(thread)
|
||||
finally:
|
||||
session.close()
|
||||
@@ -1872,6 +1938,8 @@ def reply_thread(
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
session.refresh(thread)
|
||||
_sync_sales_thread(thread, row)
|
||||
_start_telegram_reply_delivery(row.message_id)
|
||||
return _to_message_out(row)
|
||||
finally:
|
||||
@@ -1947,6 +2015,8 @@ def ai_reply_thread(
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
session.refresh(thread)
|
||||
_sync_sales_thread(thread, row)
|
||||
_start_telegram_reply_delivery(row.message_id)
|
||||
return _to_message_out(row)
|
||||
finally:
|
||||
@@ -1996,6 +2066,7 @@ def ai_handoff_thread(
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(thread)
|
||||
_sync_sales_thread(thread)
|
||||
return _to_thread_out(thread)
|
||||
finally:
|
||||
session.close()
|
||||
@@ -2032,6 +2103,7 @@ def close_thread(
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(thread)
|
||||
_sync_sales_thread(thread)
|
||||
return _to_thread_out(thread)
|
||||
finally:
|
||||
session.close()
|
||||
@@ -2067,6 +2139,7 @@ def escalate_thread(
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(thread)
|
||||
_sync_sales_thread(thread)
|
||||
return _to_thread_out(thread)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@@ -14,6 +14,7 @@ def test_local_stack_gateway_env_contains_expected_urls():
|
||||
assert env["WEBCHAT_ADAPTER_SERVICE_URL"] == "http://127.0.0.1:8011"
|
||||
assert env["EMAIL_ADAPTER_SERVICE_URL"] == "http://127.0.0.1:8012"
|
||||
assert env["AI_VOICE_RUNTIME_SERVICE_URL"] == "http://127.0.0.1:8018"
|
||||
assert env["SALES_SERVICE_URL"] == "http://127.0.0.1:8020"
|
||||
|
||||
|
||||
def test_local_stack_service_specs_include_ai_voice_runtime():
|
||||
@@ -22,6 +23,7 @@ def test_local_stack_service_specs_include_ai_voice_runtime():
|
||||
assert "ai" in names
|
||||
assert "ai-voice-runtime" in names
|
||||
assert "asterisk-bridge" in names
|
||||
assert "sales" in names
|
||||
|
||||
|
||||
def test_local_stack_manifest_payload_has_core_fields(tmp_path: Path):
|
||||
|
||||
@@ -28,6 +28,14 @@ def test_login_ui_route_exists():
|
||||
assert "login/assets/app.js" in response.text
|
||||
|
||||
|
||||
def test_sales_ui_route_exists():
|
||||
client = TestClient(app)
|
||||
response = client.get("/sales")
|
||||
assert response.status_code == 200
|
||||
assert "sales/assets/app.js" in response.text
|
||||
assert 'id="sendTelegramReplyBtn"' in response.text
|
||||
|
||||
|
||||
def test_legal_routes_exist_for_meta_review():
|
||||
client = TestClient(app)
|
||||
|
||||
@@ -60,6 +68,7 @@ def test_contracts_shape():
|
||||
assert "stage_15" in payload
|
||||
assert "stage_16" in payload
|
||||
assert "stage_17" in payload
|
||||
assert "stage_18" in payload
|
||||
assert "/integrations/webchat/messages" in payload["stage_2"]
|
||||
assert "/integrations/email/messages" in payload["stage_2"]
|
||||
assert "/integrations/whatsapp/webhook" in payload["stage_2"]
|
||||
@@ -89,6 +98,10 @@ def test_contracts_shape():
|
||||
assert "/ai/analytics/drilldown" in payload["stage_16"]
|
||||
assert "/ai/analytics/sessions/*" in payload["stage_16"]
|
||||
assert "/asterisk/live-calls/*/ai-summary" in payload["stage_17"]
|
||||
assert "/api/v1/leads" in payload["stage_18"]
|
||||
assert "/api/v1/deals" in payload["stage_18"]
|
||||
assert "/api/v1/messages/inbound-webhook" in payload["stage_18"]
|
||||
assert "/api/v1/calls/inbound-webhook" in payload["stage_18"]
|
||||
|
||||
|
||||
def test_operator_ui_includes_favicon_link():
|
||||
|
||||
@@ -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())
|
||||
@@ -27,6 +27,7 @@
|
||||
<a class="nav-link" data-shell="operator" data-roles="admin,supervisor,operator,analyst" href="/operator"><span class="nav-bullet"></span>Оператор</a>
|
||||
<a class="nav-link" data-shell="supervisor" data-roles="admin,supervisor" href="/supervisor"><span class="nav-bullet"></span>Супервизор</a>
|
||||
<a class="nav-link" data-shell="analyst" data-roles="admin,supervisor,analyst" href="/analyst"><span class="nav-bullet"></span>Аналитика</a>
|
||||
<a class="nav-link" data-shell="sales" data-roles="admin,supervisor,operator" href="/sales"><span class="nav-bullet"></span>Продажи</a>
|
||||
<a class="nav-link active" data-shell="admin" data-roles="admin" href="/admin"><span class="nav-bullet"></span>Администрирование</a>
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<a class="nav-link" data-shell="supervisor" data-roles="admin,supervisor" href="/supervisor"><span class="nav-bullet"></span>Супервизор</a>
|
||||
<a class="nav-link" data-shell="analyst" data-roles="admin,supervisor,analyst" href="/analyst"><span class="nav-bullet"></span>Аналитика</a>
|
||||
<a class="nav-link" data-shell="admin" data-roles="admin" href="/admin"><span class="nav-bullet"></span>Администрирование</a>
|
||||
<a class="nav-link" data-shell="sales" data-roles="admin,supervisor,operator" href="/sales"><span class="nav-bullet"></span>Продажи</a>
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
<div class="nav-label">Страницы</div>
|
||||
|
||||
+927
@@ -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 });
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user