gendesign/backend/app/api/v1/admin_leads.py
bot-backend 64ed95271f
Some checks failed
CI / changes (push) Successful in 10s
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (push) Has been skipped
CI / openapi-codegen-check (push) Successful in 1m42s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m45s
CI / backend-tests (push) Failing after 8m48s
CI / backend-tests (pull_request) Failing after 8m46s
fix(leads): window revenue_total/deals_total to leads_window (#1383)
Both metrics were querying prinzip_deals without any date filter,
returning all-time figures while the surrounding stats (leads_window,
converted_window, conv_pct_window) were scoped to the last N months.
Now both subqueries restrict to deals linked to leads in window_leads
(via deal_id IN (...)), making all «за период» figures consistent.
2026-06-17 20:49:16 +03:00

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() - (: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(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]