Some checks failed
CI / changes (push) Has been cancelled
CI / backend-tests (push) Has been cancelled
CI / frontend-tests (push) Has been cancelled
CI / openapi-codegen-check (push) Has been cancelled
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been cancelled
CI / frontend-tests (pull_request) Has been cancelled
CI / openapi-codegen-check (pull_request) Has been cancelled
(:bind || ' months')::interval is a psycopg v3 SyntaxError — the driver sees '::' immediately after the bind placeholder token and chokes. Replace all 6 occurrences (admin_leads.py + analytics_queries.py) with make_interval(months => :bind) which is unambiguous to the parser and semantically identical.
243 lines
8.6 KiB
Python
243 lines
8.6 KiB
Python
"""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() - make_interval(months => :m)
|
|
)
|
|
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(d.deal_price)
|
|
FROM prinzip_deals d
|
|
WHERE d.deal_id IN (
|
|
SELECT deal_id FROM window_leads WHERE deal_id IS NOT NULL
|
|
)
|
|
) AS revenue_total,
|
|
(
|
|
SELECT COUNT(*)
|
|
FROM prinzip_deals d
|
|
WHERE d.deal_id IN (
|
|
SELECT deal_id FROM window_leads WHERE deal_id IS NOT NULL
|
|
)
|
|
) 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]
|