"""Admin endpoint for browsing PRINZIP CRM leads. GET /api/v1/admin/leads — list with filters + pagination GET /api/v1/admin/leads/stats — KPI summary (total/conversion/revenue) Auth: gendsgn.ru-wide Caddy basic_auth gate (PR #426). App-level X-Admin-Token header removed 2026-05-23 — двойная auth избыточна для pilot. PII compliance: only phone_last4 and email_hash exposed; raw phone/email were never persisted (см. data/sql/52_import_prinzip_crm.py). """ from __future__ import annotations from typing import Annotated, Any, Literal from fastapi import APIRouter, Depends, Query from sqlalchemy import text from sqlalchemy.orm import Session from app.core.db import get_db from app.services import analytics_queries as q router = APIRouter() @router.get("/") def list_leads( db: Annotated[Session, Depends(get_db)], status: Annotated[str | None, Query()] = None, source: Annotated[str | None, Query()] = None, converted: Annotated[bool | None, Query()] = None, obj_id: Annotated[int | None, Query()] = None, search: Annotated[str | None, Query(min_length=2, max_length=64)] = None, date_from: Annotated[str | None, Query(description="YYYY-MM-DD")] = None, date_to: Annotated[str | None, Query(description="YYYY-MM-DD")] = None, sort: Annotated[Literal["created_desc", "created_asc"], Query()] = "created_desc", limit: Annotated[int, Query(ge=1, le=200)] = 50, offset: Annotated[int, Query(ge=0)] = 0, ) -> dict[str, Any]: where: list[str] = [] params: dict[str, Any] = {"lim": limit, "off": offset} if status: where.append("l.status = :status") params["status"] = status if source: where.append("l.source = :source") params["source"] = source if converted is not None: where.append("l.converted = :converted") params["converted"] = converted if obj_id is not None: where.append("l.obj_interest = :obj_id") params["obj_id"] = obj_id if date_from: where.append("l.created_at >= :date_from") params["date_from"] = date_from if date_to: where.append("l.created_at < (CAST(:date_to AS date) + INTERVAL '1 day')") params["date_to"] = date_to if search: where.append("(l.name ILIKE :search OR l.phone_last4 = :exact_search)") params["search"] = f"%{search}%" params["exact_search"] = search where_sql = "WHERE " + " AND ".join(where) if where else "" order_sql = "ORDER BY l.created_at DESC" if sort == "created_desc" else "ORDER BY l.created_at" rows = ( db.execute( text( f""" SELECT l.lead_id, l.created_at, l.source, l.channel, l.name, l.phone_last4, l.email_hash, l.obj_interest, l.rooms, l.budget_min, l.budget_max, l.status, l.converted, l.deal_id, o.comm_name AS obj_name, d.deal_price, d.closed_at AS deal_closed_at FROM prinzip_leads l LEFT JOIN domrf_kn_objects o ON o.obj_id = l.obj_interest AND o.snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_kn_objects) LEFT JOIN prinzip_deals d ON d.deal_id = l.deal_id {where_sql} {order_sql} LIMIT :lim OFFSET :off """ ), params, ) .mappings() .all() ) total = db.execute( text(f"SELECT COUNT(*) FROM prinzip_leads l {where_sql}"), {k: v for k, v in params.items() if k not in ("lim", "off")}, ).scalar_one() return { "total": int(total or 0), "limit": limit, "offset": offset, "rows": [ { "lead_id": r["lead_id"], "created_at": r["created_at"].isoformat() if r["created_at"] else None, "source": r["source"], "channel": r["channel"], "name": r["name"], "phone_last4": r["phone_last4"], "email_hash": r["email_hash"], "obj_interest": r["obj_interest"], "obj_name": r["obj_name"], "rooms": r["rooms"], "budget_min": float(r["budget_min"]) if r["budget_min"] is not None else None, "budget_max": float(r["budget_max"]) if r["budget_max"] is not None else None, "status": r["status"], "converted": r["converted"], "deal_id": r["deal_id"], "deal_price": float(r["deal_price"]) if r["deal_price"] is not None else None, "deal_closed_at": ( r["deal_closed_at"].isoformat() if r["deal_closed_at"] else None ), } for r in rows ], } @router.get("/stats") def leads_stats( db: Annotated[Session, Depends(get_db)], months: Annotated[int, Query(ge=1, le=120)] = 12, ) -> dict[str, Any]: """KPI summary за последние N месяцев.""" row = ( db.execute( text( """ WITH window_leads AS ( SELECT * FROM prinzip_leads WHERE created_at >= NOW() - (:m || ' months')::interval ) SELECT (SELECT COUNT(*) FROM prinzip_leads) AS leads_total, COUNT(*) FILTER (WHERE TRUE) AS leads_window, COUNT(*) FILTER (WHERE converted) AS converted_window, ROUND( 100.0 * COUNT(*) FILTER (WHERE converted) / NULLIF(COUNT(*), 0), 2 ) AS conv_pct_window, (SELECT COUNT(DISTINCT source) FROM prinzip_leads) AS sources_total, (SELECT SUM(deal_price) FROM prinzip_deals) AS revenue_total, (SELECT COUNT(*) FROM prinzip_deals) AS deals_total FROM window_leads """ ), {"m": months}, ) .mappings() .first() ) if not row: return { "leads_total": 0, "leads_window": 0, "converted_window": 0, "conv_pct_window": None, "sources_total": 0, "revenue_total": None, "deals_total": 0, } return { "leads_total": row["leads_total"] or 0, "leads_window": row["leads_window"] or 0, "converted_window": row["converted_window"] or 0, "conv_pct_window": ( float(row["conv_pct_window"]) if row["conv_pct_window"] is not None else None ), "sources_total": row["sources_total"] or 0, "revenue_total": ( float(row["revenue_total"]) if row["revenue_total"] is not None else None ), "deals_total": row["deals_total"] or 0, "window_months": months, } @router.get("/funnel/monthly") def funnel_monthly( db: Annotated[Session, Depends(get_db)], months: Annotated[int, Query(ge=1, le=120)] = 24, ) -> list[dict[str, Any]]: """Воронка по месяцам: leads → engaged → converted (по source).""" return q.prinzip_funnel_monthly(db, months=months) @router.get("/funnel/by-source") def funnel_by_source( db: Annotated[Session, Depends(get_db)], months: Annotated[int, Query(ge=1, le=120)] = 12, ) -> list[dict[str, Any]]: """Splitting по source: кто конвертит лучше за последние N месяцев.""" return q.prinzip_funnel_by_source(db, months=months) @router.get("/funnel/by-object") def funnel_by_object( db: Annotated[Session, Depends(get_db)], ) -> list[dict[str, Any]]: """Воронка для каждого ЖК PRINZIP: leads / deals / revenue.""" return q.prinzip_funnel_by_object(db) @router.get("/sources") def list_sources( db: Annotated[Session, Depends(get_db)], ) -> list[str]: """Distinct list of source values for filter dropdown.""" rows = db.execute( text( """ SELECT source, COUNT(*) AS n FROM prinzip_leads WHERE source IS NOT NULL AND source <> '' GROUP BY source ORDER BY n DESC LIMIT 50 """ ) ).all() return [r[0] for r in rows]