The complete code integration is done. Co-authored-by: Sagnik <sagnik7896@gmail.com> Reviewed-on: #18
631 lines
23 KiB
Python
631 lines
23 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Literal
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, Request, status
|
|
from pydantic import BaseModel, Field
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
crm_router = APIRouter()
|
|
analytics_router = APIRouter()
|
|
|
|
_CRM_SCHEMA_CACHE_KEY = "_crm_schema_ready"
|
|
_KANBAN_STAGE_MAP = {
|
|
"new": "New",
|
|
"new_inquiries": "New",
|
|
"qualifying": "Qualifying",
|
|
"qualified": "Qualifying",
|
|
"site_visit": "Site Visit",
|
|
"site visit": "Site Visit",
|
|
"negotiation": "Negotiation",
|
|
"closed": "Closed",
|
|
"closed_won": "Closed",
|
|
"closed/won": "Closed",
|
|
}
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def _normalize_stage(value: str | None) -> str:
|
|
if not value:
|
|
return "New"
|
|
return _KANBAN_STAGE_MAP.get(value.strip().lower(), value.strip())
|
|
|
|
|
|
def _stage_key(value: str) -> str:
|
|
stage = _normalize_stage(value)
|
|
return stage.lower().replace(" ", "_")
|
|
|
|
|
|
def _infer_qualification(score: int | None, source: str | None, notes: str | None) -> str:
|
|
joined = f"{source or ''} {notes or ''}".lower()
|
|
if score is None:
|
|
return "UNKNOWN"
|
|
if score >= 90 or "cash" in joined or "hnw" in joined or "family office" in joined:
|
|
return "WHALE"
|
|
if score >= 70:
|
|
return "POTENTIAL"
|
|
if score >= 45:
|
|
return "HOT"
|
|
return "TIRE_KICKER"
|
|
|
|
|
|
async def _broadcast_crm_event(request: Request, payload: dict[str, Any]) -> None:
|
|
broadcaster = getattr(request.app.state, "broadcast_crm_event", None)
|
|
if broadcaster is not None:
|
|
await broadcaster(payload)
|
|
|
|
|
|
async def _get_pool(request: Request):
|
|
pool = getattr(request.app.state, "db_pool", None)
|
|
if pool is None:
|
|
raise HTTPException(status_code=503, detail="Database unavailable.")
|
|
return pool
|
|
|
|
|
|
async def _ensure_schema(request: Request) -> None:
|
|
if getattr(request.app.state, _CRM_SCHEMA_CACHE_KEY, False):
|
|
return
|
|
|
|
pool = await _get_pool(request)
|
|
async with pool.acquire() as conn:
|
|
await conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS leads (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
email TEXT,
|
|
phone TEXT,
|
|
source TEXT NOT NULL DEFAULT 'website',
|
|
notes TEXT NOT NULL DEFAULT '',
|
|
qualification TEXT NOT NULL DEFAULT 'UNKNOWN',
|
|
score INTEGER NOT NULL DEFAULT 0,
|
|
kanban_status TEXT NOT NULL DEFAULT 'New',
|
|
budget TEXT NOT NULL DEFAULT '',
|
|
unit_interest TEXT NOT NULL DEFAULT '',
|
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
)
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS chat_logs (
|
|
id TEXT PRIMARY KEY,
|
|
lead_id TEXT NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
|
|
sender TEXT NOT NULL,
|
|
channel TEXT NOT NULL DEFAULT 'oracle',
|
|
content TEXT NOT NULL,
|
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
)
|
|
"""
|
|
)
|
|
await conn.execute("CREATE INDEX IF NOT EXISTS idx_leads_stage ON leads (kanban_status)")
|
|
await conn.execute("CREATE INDEX IF NOT EXISTS idx_leads_score ON leads (score DESC)")
|
|
await conn.execute("CREATE INDEX IF NOT EXISTS idx_chat_logs_lead_id ON chat_logs (lead_id, created_at DESC)")
|
|
|
|
setattr(request.app.state, _CRM_SCHEMA_CACHE_KEY, True)
|
|
|
|
|
|
class LeadUpsertRequest(BaseModel):
|
|
name: str = Field(..., min_length=1, max_length=200)
|
|
email: str | None = Field(default=None, max_length=255)
|
|
phone: str | None = Field(default=None, max_length=64)
|
|
source: str = Field(default="website", max_length=64)
|
|
notes: str = Field(default="", max_length=5000)
|
|
qualification: str | None = Field(default=None, max_length=64)
|
|
score: int = Field(default=0, ge=0, le=100)
|
|
kanban_status: str = Field(default="New", max_length=64)
|
|
budget: str = Field(default="", max_length=255)
|
|
unit_interest: str = Field(default="", max_length=255)
|
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class KanbanMoveRequest(BaseModel):
|
|
lead_id: str
|
|
target_status: str
|
|
|
|
|
|
class ChatLogCreateRequest(BaseModel):
|
|
lead_id: str
|
|
sender: Literal["lead", "oracle", "system", "broker"] = "oracle"
|
|
channel: str = Field(default="oracle", max_length=64)
|
|
content: str = Field(..., min_length=1, max_length=8000)
|
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class SyntheticSeedRequest(BaseModel):
|
|
count: int = Field(default=100, ge=1, le=500)
|
|
|
|
|
|
def _serialize_lead(row: Any) -> dict[str, Any]:
|
|
score = int(row["score"] or 0)
|
|
status_label = _normalize_stage(row["kanban_status"])
|
|
qualification = row["qualification"] or _infer_qualification(score, row.get("source"), row.get("notes"))
|
|
return {
|
|
"id": row["id"],
|
|
"name": row["name"],
|
|
"email": row["email"],
|
|
"phone": row["phone"],
|
|
"source": row["source"],
|
|
"notes": row["notes"],
|
|
"qualification": qualification,
|
|
"score": score,
|
|
"kanban_status": status_label,
|
|
"stage": _stage_key(status_label),
|
|
"budget": row["budget"],
|
|
"unit_interest": row["unit_interest"],
|
|
"metadata": row["metadata"] or {},
|
|
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
|
"updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
|
|
}
|
|
|
|
|
|
def _serialize_chat_log(row: Any) -> dict[str, Any]:
|
|
return {
|
|
"id": row["id"],
|
|
"lead_id": row["lead_id"],
|
|
"sender": row["sender"],
|
|
"channel": row["channel"],
|
|
"content": row["content"],
|
|
"metadata": row["metadata"] or {},
|
|
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
|
}
|
|
|
|
|
|
def _build_synthetic_leads(count: int) -> list[dict[str, Any]]:
|
|
first_names = ["Amina", "Omar", "Farah", "Rayan", "Maya", "Khalid", "Noor", "Zara", "Ibrahim", "Layla"]
|
|
last_names = ["Rahman", "Al-Farsi", "Kapoor", "Haddad", "Mehta", "Nadeem", "Shaikh", "Rao", "Wilson", "Chen"]
|
|
sources = ["website", "walkin", "whatsapp"]
|
|
stages = ["New", "Qualifying", "Site Visit", "Negotiation", "Closed"]
|
|
interests = ["2BHK Marina View", "3BHK Corner Unit", "Penthouse Sky Deck", "Investment Studio", "4BHK Sea View"]
|
|
budgets = ["AED 2.4M", "AED 4.8M", "AED 7.2M", "AED 12M", "AED 18M"]
|
|
rows: list[dict[str, Any]] = []
|
|
for idx in range(count):
|
|
score = 35 + ((idx * 7) % 61)
|
|
if idx % 12 == 0:
|
|
score = 94
|
|
name = f"{first_names[idx % len(first_names)]} {last_names[(idx * 3) % len(last_names)]}"
|
|
source = sources[idx % len(sources)]
|
|
notes = (
|
|
"Cash-ready HNI buyer focusing on waterfront premium inventory."
|
|
if score >= 90
|
|
else "Follow-up required on payment plan and amenity preferences."
|
|
)
|
|
rows.append(
|
|
{
|
|
"id": str(uuid.uuid4()),
|
|
"name": name,
|
|
"email": f"{name.lower().replace(' ', '.')}@synthetic.velocity.local",
|
|
"phone": f"+9715000{idx:05d}",
|
|
"source": source,
|
|
"notes": notes,
|
|
"qualification": _infer_qualification(score, source, notes).upper(),
|
|
"score": score,
|
|
"kanban_status": stages[idx % len(stages)],
|
|
"budget": budgets[idx % len(budgets)],
|
|
"unit_interest": interests[idx % len(interests)],
|
|
"metadata": {
|
|
"synthetic": True,
|
|
"campaign": "verification-seed",
|
|
"batch": "sprint1-root-integration",
|
|
},
|
|
}
|
|
)
|
|
return rows
|
|
|
|
|
|
@crm_router.get("/leads")
|
|
async def list_leads(
|
|
request: Request,
|
|
kanban_status: str | None = None,
|
|
qualification: str | None = None,
|
|
search: str | None = Query(default=None, min_length=1),
|
|
) -> dict[str, Any]:
|
|
await _ensure_schema(request)
|
|
pool = await _get_pool(request)
|
|
clauses: list[str] = []
|
|
params: list[Any] = []
|
|
if kanban_status:
|
|
params.append(_normalize_stage(kanban_status))
|
|
clauses.append(f"kanban_status = ${len(params)}")
|
|
if qualification:
|
|
params.append(qualification.upper())
|
|
clauses.append(f"qualification = ${len(params)}")
|
|
if search:
|
|
params.append(f"%{search.lower()}%")
|
|
clauses.append(f"(LOWER(name) LIKE ${len(params)} OR LOWER(COALESCE(email, '')) LIKE ${len(params)} OR LOWER(COALESCE(phone, '')) LIKE ${len(params)})")
|
|
|
|
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
query = f"""
|
|
SELECT id, name, email, phone, source, notes, qualification, score, kanban_status,
|
|
budget, unit_interest, metadata, created_at, updated_at
|
|
FROM leads
|
|
{where}
|
|
ORDER BY score DESC, updated_at DESC, created_at DESC
|
|
"""
|
|
async with pool.acquire() as conn:
|
|
rows = await conn.fetch(query, *params)
|
|
leads = [_serialize_lead(row) for row in rows]
|
|
return {"status": "ok", "data": leads, "meta": {"count": len(leads)}}
|
|
|
|
|
|
@crm_router.post("/leads", status_code=status.HTTP_201_CREATED)
|
|
async def create_lead(request: Request, payload: LeadUpsertRequest) -> dict[str, Any]:
|
|
await _ensure_schema(request)
|
|
pool = await _get_pool(request)
|
|
lead_id = str(uuid.uuid4())
|
|
qualification = (payload.qualification or _infer_qualification(payload.score, payload.source, payload.notes)).upper()
|
|
stage = _normalize_stage(payload.kanban_status)
|
|
async with pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
INSERT INTO leads (
|
|
id, name, email, phone, source, notes, qualification, score, kanban_status,
|
|
budget, unit_interest, metadata, created_at, updated_at
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9,
|
|
$10, $11, $12::jsonb, NOW(), NOW()
|
|
)
|
|
RETURNING id, name, email, phone, source, notes, qualification, score, kanban_status,
|
|
budget, unit_interest, metadata, created_at, updated_at
|
|
""",
|
|
lead_id,
|
|
payload.name,
|
|
payload.email,
|
|
payload.phone,
|
|
payload.source,
|
|
payload.notes,
|
|
qualification,
|
|
payload.score,
|
|
stage,
|
|
payload.budget,
|
|
payload.unit_interest,
|
|
json.dumps(payload.metadata),
|
|
)
|
|
data = _serialize_lead(row)
|
|
await _broadcast_crm_event(request, {"type": "lead_created", "entity": "lead", "data": data})
|
|
return {"status": "ok", "data": data}
|
|
|
|
|
|
@crm_router.put("/leads/{lead_id}")
|
|
async def update_lead(lead_id: str, request: Request, payload: LeadUpsertRequest) -> dict[str, Any]:
|
|
await _ensure_schema(request)
|
|
pool = await _get_pool(request)
|
|
qualification = (payload.qualification or _infer_qualification(payload.score, payload.source, payload.notes)).upper()
|
|
stage = _normalize_stage(payload.kanban_status)
|
|
async with pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
UPDATE leads
|
|
SET name = $2,
|
|
email = $3,
|
|
phone = $4,
|
|
source = $5,
|
|
notes = $6,
|
|
qualification = $7,
|
|
score = $8,
|
|
kanban_status = $9,
|
|
budget = $10,
|
|
unit_interest = $11,
|
|
metadata = $12::jsonb,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING id, name, email, phone, source, notes, qualification, score, kanban_status,
|
|
budget, unit_interest, metadata, created_at, updated_at
|
|
""",
|
|
lead_id,
|
|
payload.name,
|
|
payload.email,
|
|
payload.phone,
|
|
payload.source,
|
|
payload.notes,
|
|
qualification,
|
|
payload.score,
|
|
stage,
|
|
payload.budget,
|
|
payload.unit_interest,
|
|
json.dumps(payload.metadata),
|
|
)
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail=f"Lead '{lead_id}' not found.")
|
|
data = _serialize_lead(row)
|
|
await _broadcast_crm_event(request, {"type": "lead_updated", "entity": "lead", "data": data})
|
|
return {"status": "ok", "data": data}
|
|
|
|
|
|
@crm_router.delete("/leads/{lead_id}")
|
|
async def delete_lead(lead_id: str, request: Request) -> dict[str, Any]:
|
|
await _ensure_schema(request)
|
|
pool = await _get_pool(request)
|
|
async with pool.acquire() as conn:
|
|
result = await conn.execute("DELETE FROM leads WHERE id = $1", lead_id)
|
|
if result.endswith("0"):
|
|
raise HTTPException(status_code=404, detail=f"Lead '{lead_id}' not found.")
|
|
await _broadcast_crm_event(request, {"type": "lead_deleted", "entity": "lead", "entity_id": lead_id})
|
|
return {"status": "ok", "data": {"id": lead_id, "deleted": True}}
|
|
|
|
|
|
@crm_router.post("/leads/seed-synthetic", status_code=status.HTTP_201_CREATED)
|
|
async def seed_synthetic_leads(request: Request, payload: SyntheticSeedRequest) -> dict[str, Any]:
|
|
await _ensure_schema(request)
|
|
pool = await _get_pool(request)
|
|
synthetic_rows = _build_synthetic_leads(payload.count)
|
|
inserted = 0
|
|
chat_logs_inserted = 0
|
|
async with pool.acquire() as conn:
|
|
for row in synthetic_rows:
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO leads (
|
|
id, name, email, phone, source, notes, qualification, score, kanban_status,
|
|
budget, unit_interest, metadata, created_at, updated_at
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9,
|
|
$10, $11, $12::jsonb, NOW(), NOW()
|
|
)
|
|
ON CONFLICT (id) DO NOTHING
|
|
""",
|
|
row["id"],
|
|
row["name"],
|
|
row["email"],
|
|
row["phone"],
|
|
row["source"],
|
|
row["notes"],
|
|
row["qualification"],
|
|
row["score"],
|
|
row["kanban_status"],
|
|
row["budget"],
|
|
row["unit_interest"],
|
|
json.dumps(row["metadata"]),
|
|
)
|
|
inserted += 1
|
|
for sender, channel, content in [
|
|
("lead", "whatsapp", f"{row['name']} asked for availability on {row['unit_interest']}."),
|
|
("oracle", "oracle", "Oracle generated a guided follow-up based on budget, stage, and source quality."),
|
|
]:
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO chat_logs (id, lead_id, sender, channel, content, metadata, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6::jsonb, NOW())
|
|
""",
|
|
str(uuid.uuid4()),
|
|
row["id"],
|
|
sender,
|
|
channel,
|
|
content,
|
|
json.dumps({"synthetic": True}),
|
|
)
|
|
chat_logs_inserted += 1
|
|
result = {
|
|
"status": "ok",
|
|
"data": {
|
|
"seeded": inserted,
|
|
"chat_logs_seeded": chat_logs_inserted,
|
|
"batch": "sprint1-root-integration",
|
|
},
|
|
}
|
|
await _broadcast_crm_event(
|
|
request,
|
|
{
|
|
"type": "crm_seeded",
|
|
"entity": "lead_batch",
|
|
"data": result["data"],
|
|
},
|
|
)
|
|
return result
|
|
|
|
|
|
@crm_router.get("/leads/demographics")
|
|
async def lead_demographics(request: Request) -> dict[str, Any]:
|
|
await _ensure_schema(request)
|
|
pool = await _get_pool(request)
|
|
async with pool.acquire() as conn:
|
|
source_rows = await conn.fetch(
|
|
"""
|
|
SELECT source, COUNT(*)::int AS lead_count, COALESCE(AVG(score), 0)::float AS avg_score
|
|
FROM leads
|
|
GROUP BY source
|
|
ORDER BY lead_count DESC, source ASC
|
|
"""
|
|
)
|
|
qualification_rows = await conn.fetch(
|
|
"""
|
|
SELECT qualification, COUNT(*)::int AS lead_count
|
|
FROM leads
|
|
GROUP BY qualification
|
|
ORDER BY lead_count DESC, qualification ASC
|
|
"""
|
|
)
|
|
return {
|
|
"status": "ok",
|
|
"data": {
|
|
"by_source": [dict(row) for row in source_rows],
|
|
"by_qualification": [dict(row) for row in qualification_rows],
|
|
},
|
|
}
|
|
|
|
|
|
@crm_router.get("/leads/{lead_id}")
|
|
async def get_lead(lead_id: str, request: Request) -> dict[str, Any]:
|
|
await _ensure_schema(request)
|
|
pool = await _get_pool(request)
|
|
async with pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
SELECT id, name, email, phone, source, notes, qualification, score, kanban_status,
|
|
budget, unit_interest, metadata, created_at, updated_at
|
|
FROM leads
|
|
WHERE id = $1
|
|
""",
|
|
lead_id,
|
|
)
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail=f"Lead '{lead_id}' not found.")
|
|
return {"status": "ok", "data": _serialize_lead(row)}
|
|
|
|
|
|
@crm_router.get("/chat-logs")
|
|
async def list_chat_logs(
|
|
request: Request,
|
|
lead_id: str | None = None,
|
|
channel: str | None = None,
|
|
) -> dict[str, Any]:
|
|
await _ensure_schema(request)
|
|
pool = await _get_pool(request)
|
|
clauses: list[str] = []
|
|
params: list[Any] = []
|
|
if lead_id:
|
|
params.append(lead_id)
|
|
clauses.append(f"lead_id = ${len(params)}")
|
|
if channel:
|
|
params.append(channel)
|
|
clauses.append(f"channel = ${len(params)}")
|
|
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
query = f"""
|
|
SELECT id, lead_id, sender, channel, content, metadata, created_at
|
|
FROM chat_logs
|
|
{where}
|
|
ORDER BY created_at DESC
|
|
"""
|
|
async with pool.acquire() as conn:
|
|
rows = await conn.fetch(query, *params)
|
|
data = [_serialize_chat_log(row) for row in rows]
|
|
return {"status": "ok", "data": data, "meta": {"count": len(data)}}
|
|
|
|
|
|
@crm_router.post("/chat-logs", status_code=status.HTTP_201_CREATED)
|
|
async def create_chat_log(request: Request, payload: ChatLogCreateRequest) -> dict[str, Any]:
|
|
await _ensure_schema(request)
|
|
pool = await _get_pool(request)
|
|
log_id = str(uuid.uuid4())
|
|
async with pool.acquire() as conn:
|
|
lead = await conn.fetchrow("SELECT id FROM leads WHERE id = $1", payload.lead_id)
|
|
if lead is None:
|
|
raise HTTPException(status_code=404, detail=f"Lead '{payload.lead_id}' not found.")
|
|
row = await conn.fetchrow(
|
|
"""
|
|
INSERT INTO chat_logs (id, lead_id, sender, channel, content, metadata, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6::jsonb, NOW())
|
|
RETURNING id, lead_id, sender, channel, content, metadata, created_at
|
|
""",
|
|
log_id,
|
|
payload.lead_id,
|
|
payload.sender,
|
|
payload.channel,
|
|
payload.content,
|
|
json.dumps(payload.metadata),
|
|
)
|
|
data = _serialize_chat_log(row)
|
|
await _broadcast_crm_event(request, {"type": "chat_log_created", "entity": "chat_log", "data": data})
|
|
return {"status": "ok", "data": data}
|
|
|
|
|
|
@crm_router.get("/kanban/board")
|
|
async def get_kanban_board(request: Request) -> dict[str, Any]:
|
|
await _ensure_schema(request)
|
|
pool = await _get_pool(request)
|
|
ordered_stages = ["New", "Qualifying", "Site Visit", "Negotiation", "Closed"]
|
|
async with pool.acquire() as conn:
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT id, name, email, phone, source, notes, qualification, score, kanban_status,
|
|
budget, unit_interest, metadata, created_at, updated_at
|
|
FROM leads
|
|
ORDER BY score DESC, updated_at DESC, created_at DESC
|
|
"""
|
|
)
|
|
leads = [_serialize_lead(row) for row in rows]
|
|
grouped = {stage: [] for stage in ordered_stages}
|
|
for lead in leads:
|
|
grouped.setdefault(lead["kanban_status"], []).append(lead)
|
|
board = [
|
|
{
|
|
"status": stage,
|
|
"stage": _stage_key(stage),
|
|
"count": len(grouped.get(stage, [])),
|
|
"items": grouped.get(stage, []),
|
|
}
|
|
for stage in ordered_stages
|
|
]
|
|
return {"status": "ok", "data": board}
|
|
|
|
|
|
@crm_router.put("/kanban/move")
|
|
async def move_kanban_card(request: Request, payload: KanbanMoveRequest) -> dict[str, Any]:
|
|
await _ensure_schema(request)
|
|
pool = await _get_pool(request)
|
|
stage = _normalize_stage(payload.target_status)
|
|
async with pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
UPDATE leads
|
|
SET kanban_status = $2,
|
|
qualification = CASE
|
|
WHEN score >= 90 THEN 'WHALE'
|
|
WHEN score >= 70 THEN 'POTENTIAL'
|
|
WHEN score >= 45 THEN 'HOT'
|
|
ELSE qualification
|
|
END,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING id, name, email, phone, source, notes, qualification, score, kanban_status,
|
|
budget, unit_interest, metadata, created_at, updated_at
|
|
""",
|
|
payload.lead_id,
|
|
stage,
|
|
)
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail=f"Lead '{payload.lead_id}' not found.")
|
|
data = _serialize_lead(row)
|
|
await _broadcast_crm_event(
|
|
request,
|
|
{
|
|
"type": "kanban_moved",
|
|
"entity": "lead",
|
|
"entity_id": payload.lead_id,
|
|
"data": data,
|
|
},
|
|
)
|
|
return {"status": "ok", "data": data}
|
|
|
|
|
|
@analytics_router.get("/sentiment-scatter")
|
|
async def sentiment_scatter(request: Request) -> list[dict[str, Any]]:
|
|
await _ensure_schema(request)
|
|
pool = await _get_pool(request)
|
|
async with pool.acquire() as conn:
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT id, name, score, qualification, kanban_status, source, notes, updated_at
|
|
FROM leads
|
|
WHERE score IS NOT NULL
|
|
ORDER BY score DESC, updated_at DESC
|
|
"""
|
|
)
|
|
points: list[dict[str, Any]] = []
|
|
for row in rows:
|
|
score = int(row["score"] or 0)
|
|
qualification = row["qualification"] or _infer_qualification(score, row["source"], row["notes"])
|
|
points.append(
|
|
{
|
|
"id": row["id"],
|
|
"name": row["name"],
|
|
"sentiment_score": max(0, min(100, int(score * 0.82) + 10)),
|
|
"response_time_ms": max(120, 10000 - (score * 55)),
|
|
"score": score,
|
|
"qualification": qualification,
|
|
"kanban_status": _normalize_stage(row["kanban_status"]),
|
|
}
|
|
)
|
|
return points
|