Compare commits
1 commit
0792e34172
...
7d1db9edce
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d1db9edce |
5 changed files with 13 additions and 229 deletions
|
|
@ -130,16 +130,7 @@ def leads_stats(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
months: Annotated[int, Query(ge=1, le=120)] = 12,
|
months: Annotated[int, Query(ge=1, le=120)] = 12,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""KPI summary за последние N месяцев.
|
"""KPI summary за последние N месяцев."""
|
||||||
|
|
||||||
Суффикс `_window` — за окно `months`, `_total` — за всё время.
|
|
||||||
"""
|
|
||||||
# Почему это важно и почему поля переименованы (#2464): revenue_total и
|
|
||||||
# deals_total считались по CTE window_leads, то есть за окно, а суффиксом
|
|
||||||
# обещали итог за всё время — рядом с честными leads_total/sources_total.
|
|
||||||
# Админка из-за этого показывала карточку «Revenue (всего)» с 12-месячной
|
|
||||||
# цифрой. Рационал держим комментарием, а не docstring'ом: docstring уходит
|
|
||||||
# в OpenAPI description и дальше в сгенерированные типы фронта.
|
|
||||||
row = (
|
row = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
|
|
@ -164,14 +155,14 @@ def leads_stats(
|
||||||
WHERE d.deal_id IN (
|
WHERE d.deal_id IN (
|
||||||
SELECT deal_id FROM window_leads WHERE deal_id IS NOT NULL
|
SELECT deal_id FROM window_leads WHERE deal_id IS NOT NULL
|
||||||
)
|
)
|
||||||
) AS revenue_window,
|
) AS revenue_total,
|
||||||
(
|
(
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM prinzip_deals d
|
FROM prinzip_deals d
|
||||||
WHERE d.deal_id IN (
|
WHERE d.deal_id IN (
|
||||||
SELECT deal_id FROM window_leads WHERE deal_id IS NOT NULL
|
SELECT deal_id FROM window_leads WHERE deal_id IS NOT NULL
|
||||||
)
|
)
|
||||||
) AS deals_window
|
) AS deals_total
|
||||||
FROM window_leads
|
FROM window_leads
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
|
|
@ -187,16 +178,8 @@ def leads_stats(
|
||||||
"converted_window": 0,
|
"converted_window": 0,
|
||||||
"conv_pct_window": None,
|
"conv_pct_window": None,
|
||||||
"sources_total": 0,
|
"sources_total": 0,
|
||||||
"revenue_window": None,
|
"revenue_total": None,
|
||||||
"deals_window": 0,
|
"deals_total": 0,
|
||||||
# window_months раньше отдавался ТОЛЬКО в непустой ветке — формы ответа
|
|
||||||
# различались. Оговорка про достижимость: этот `if not row` СЕГОДНЯ не
|
|
||||||
# срабатывает — запрос агрегатный и всегда возвращает ровно одну строку
|
|
||||||
# (проверено на пустых таблицах: leads_total=0, leads_window=0, строка
|
|
||||||
# truthy). То есть правка здесь — согласованность, а не наблюдаемая
|
|
||||||
# починка; ветка остаётся защитой на случай смены формы запроса, и
|
|
||||||
# расходиться с основной ей нельзя — именно так пропажа поля и возникла.
|
|
||||||
"window_months": months,
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"leads_total": row["leads_total"] or 0,
|
"leads_total": row["leads_total"] or 0,
|
||||||
|
|
@ -206,10 +189,10 @@ def leads_stats(
|
||||||
float(row["conv_pct_window"]) if row["conv_pct_window"] is not None else None
|
float(row["conv_pct_window"]) if row["conv_pct_window"] is not None else None
|
||||||
),
|
),
|
||||||
"sources_total": row["sources_total"] or 0,
|
"sources_total": row["sources_total"] or 0,
|
||||||
"revenue_window": (
|
"revenue_total": (
|
||||||
float(row["revenue_window"]) if row["revenue_window"] is not None else None
|
float(row["revenue_total"]) if row["revenue_total"] is not None else None
|
||||||
),
|
),
|
||||||
"deals_window": row["deals_window"] or 0,
|
"deals_total": row["deals_total"] or 0,
|
||||||
"window_months": months,
|
"window_months": months,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -107,14 +107,6 @@ tests/sql/test_2956_freshness_ignores_failed_dumps.py::test_successful_dump_stil
|
||||||
tests/sql/test_2956_freshness_ignores_failed_dumps.py::test_attempt_is_still_recorded
|
tests/sql/test_2956_freshness_ignores_failed_dumps.py::test_attempt_is_still_recorded
|
||||||
tests/sql/test_2956_freshness_ignores_failed_dumps.py::test_only_failures_means_no_success_at_all
|
tests/sql/test_2956_freshness_ignores_failed_dumps.py::test_only_failures_means_no_success_at_all
|
||||||
|
|
||||||
# ── #2464: контракт суффиксов в /admin/leads/stats ────────────────────────────
|
|
||||||
# Нужен живой Postgres: тест создаёт ВРЕМЕННЫЕ prinzip_leads/prinzip_deals и
|
|
||||||
# вызывает leads_stats на данных, где итог заведомо не равен окну. В CI ЭТИ ТЕСТЫ
|
|
||||||
# ИДУТ (postgres-сервис, #2745); записи нужны для машины без БД и без туннеля.
|
|
||||||
tests/sql/test_2464_leads_stats_suffix_contract.py::test_window_suffixed_fields_match_the_window
|
|
||||||
tests/sql/test_2464_leads_stats_suffix_contract.py::test_total_suffixed_fields_are_all_time
|
|
||||||
tests/sql/test_2464_leads_stats_suffix_contract.py::test_revenue_and_deals_are_named_by_their_scope
|
|
||||||
tests/sql/test_2464_leads_stats_suffix_contract.py::test_window_months_present_on_empty_data
|
|
||||||
# ── #2464: дедуп land_reservation (миграция 189) ──────────────────────────────
|
# ── #2464: дедуп land_reservation (миграция 189) ──────────────────────────────
|
||||||
# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию таблицы, проверяют семантику
|
# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию таблицы, проверяют семантику
|
||||||
# UNIQUE NULLS NOT DISTINCT и репетируют миграцию на засеянных дублях. В CI ИДУТ
|
# UNIQUE NULLS NOT DISTINCT и репетируют миграцию на засеянных дублях. В CI ИДУТ
|
||||||
|
|
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
"""Суффикс поля в /admin/leads/stats обязан соответствовать смыслу величины (#2464).
|
|
||||||
|
|
||||||
В ответе рядом стоят величины двух видов: за всё время (`leads_total`, `sources_total`) и
|
|
||||||
за окно `months` (`leads_window`, `converted_window`, `conv_pct_window`). Соглашение
|
|
||||||
читается однозначно по самим именам.
|
|
||||||
|
|
||||||
`revenue_total` и `deals_total` его нарушали: считались по CTE `window_leads`, то есть за
|
|
||||||
окно, а суффиксом обещали итог. Админка из-за этого печатала карточку «Revenue (всего)» с
|
|
||||||
12-месячной цифрой.
|
|
||||||
|
|
||||||
Проверяется ИНВАРИАНТ, а не набор имён: для данных, где итог заведомо не равен окну,
|
|
||||||
каждое поле `*_total` обязано совпасть с итогом, каждое `*_window` — с окном. Такая
|
|
||||||
формулировка краснеет на origin/main по НЕВЕРНОМУ ЗНАЧЕНИЮ, а не по отсутствию ключа, и
|
|
||||||
переживёт любое разумное переименование.
|
|
||||||
|
|
||||||
Тест герметичный: обе таблицы создаются ВРЕМЕННЫМИ в своей же сессии; по конвенции
|
|
||||||
`tests/sql/*` DSN по умолчанию смотрит в туннель к прод-базе, поэтому в фикстуре стоит
|
|
||||||
проверка, что затенение сработало.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy import create_engine, text
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
|
|
||||||
def _dsn() -> str:
|
|
||||||
raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get(
|
|
||||||
"DATABASE_URL", "postgresql+psycopg://gendesign@localhost:15432/gendesign"
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
raw
|
|
||||||
if raw.startswith("postgresql+")
|
|
||||||
else raw.replace("postgresql://", "postgresql+psycopg://")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _db_reachable() -> tuple[bool, str]:
|
|
||||||
try:
|
|
||||||
eng = create_engine(_dsn(), connect_args={"connect_timeout": 3})
|
|
||||||
with eng.connect() as c:
|
|
||||||
c.execute(text("SELECT 1"))
|
|
||||||
return True, ""
|
|
||||||
except Exception as exc:
|
|
||||||
return False, str(exc)
|
|
||||||
|
|
||||||
|
|
||||||
_DB_OK, _DB_ERR = _db_reachable()
|
|
||||||
pytestmark = pytest.mark.skipif(not _DB_OK, reason=f"Postgres недоступен: {_DB_ERR}")
|
|
||||||
|
|
||||||
_SCHEMA = """
|
|
||||||
CREATE TEMP TABLE prinzip_leads (
|
|
||||||
lead_id bigint, created_at timestamptz, source text, converted boolean,
|
|
||||||
deal_id bigint) ON COMMIT DROP;
|
|
||||||
CREATE TEMP TABLE prinzip_deals (
|
|
||||||
deal_id bigint, deal_price numeric) ON COMMIT DROP;
|
|
||||||
"""
|
|
||||||
|
|
||||||
_WINDOW_MONTHS = 12
|
|
||||||
|
|
||||||
# Внутри окна: 2 заявки, обе со сделками по 1 000 000.
|
|
||||||
# Снаружи (три года назад): 3 заявки, сделки по 5 000 000 — итог заведомо не равен окну.
|
|
||||||
_IN_WINDOW_LEADS = 2
|
|
||||||
_OUT_WINDOW_LEADS = 3
|
|
||||||
_ALL_TIME_LEADS = _IN_WINDOW_LEADS + _OUT_WINDOW_LEADS
|
|
||||||
_IN_WINDOW_REVENUE = 2_000_000.0
|
|
||||||
_IN_WINDOW_DEALS = 2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def db():
|
|
||||||
engine = create_engine(_dsn())
|
|
||||||
session = sessionmaker(bind=engine)()
|
|
||||||
try:
|
|
||||||
session.execute(text(_SCHEMA))
|
|
||||||
for table in ("prinzip_leads", "prinzip_deals"):
|
|
||||||
n = session.execute(text(f"SELECT count(*) FROM {table}")).scalar()
|
|
||||||
assert n == 0, (
|
|
||||||
f"{table}: запрос попал НЕ во временную таблицу ({n} строк) — "
|
|
||||||
"тест читал бы боевые данные"
|
|
||||||
)
|
|
||||||
yield session
|
|
||||||
finally:
|
|
||||||
session.rollback()
|
|
||||||
session.close()
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def seeded(db):
|
|
||||||
rows = [
|
|
||||||
(1, "0 days", "site", True, 101, 1_000_000),
|
|
||||||
(2, "10 days", "site", True, 102, 1_000_000),
|
|
||||||
(3, "1100 days", "avito", True, 103, 5_000_000),
|
|
||||||
(4, "1101 days", "avito", True, 104, 5_000_000),
|
|
||||||
(5, "1102 days", "vk", True, 105, 5_000_000),
|
|
||||||
]
|
|
||||||
for lead_id, ago, source, converted, deal_id, price in rows:
|
|
||||||
db.execute(
|
|
||||||
text(
|
|
||||||
"INSERT INTO prinzip_leads (lead_id, created_at, source, converted, deal_id)"
|
|
||||||
" VALUES (:l, NOW() - CAST(:ago AS interval), :s, :c, :d)"
|
|
||||||
),
|
|
||||||
{"l": lead_id, "ago": ago, "s": source, "c": converted, "d": deal_id},
|
|
||||||
)
|
|
||||||
db.execute(
|
|
||||||
text("INSERT INTO prinzip_deals (deal_id, deal_price) VALUES (:d, :p)"),
|
|
||||||
{"d": deal_id, "p": price},
|
|
||||||
)
|
|
||||||
return db
|
|
||||||
|
|
||||||
|
|
||||||
def _stats(db) -> dict:
|
|
||||||
from app.api.v1.admin_leads import leads_stats
|
|
||||||
|
|
||||||
return leads_stats(db=db, months=_WINDOW_MONTHS)
|
|
||||||
|
|
||||||
|
|
||||||
def test_window_suffixed_fields_match_the_window(seeded) -> None:
|
|
||||||
"""Всё, что названо `_window`, обязано считаться по окну.
|
|
||||||
|
|
||||||
На origin/main эти величины лежат под именами `revenue_total`/`deals_total`,
|
|
||||||
поэтому проверка ниже (по `_total`) и краснеет — здесь же контроль, что
|
|
||||||
оконные значения не поехали.
|
|
||||||
"""
|
|
||||||
stats = _stats(seeded)
|
|
||||||
assert stats["leads_window"] == _IN_WINDOW_LEADS
|
|
||||||
assert stats["converted_window"] == _IN_WINDOW_LEADS
|
|
||||||
|
|
||||||
|
|
||||||
def test_total_suffixed_fields_are_all_time(seeded) -> None:
|
|
||||||
"""КАЖДОЕ поле `*_total` обязано быть за всё время, а не за окно.
|
|
||||||
|
|
||||||
На origin/main `revenue_total` = 2 000 000 (только окно) при итоге 17 000 000,
|
|
||||||
и `deals_total` = 2 при итоге 5 — красное по неверному ЗНАЧЕНИЮ.
|
|
||||||
"""
|
|
||||||
stats = _stats(seeded)
|
|
||||||
all_time_revenue = float(
|
|
||||||
seeded.execute(text("SELECT COALESCE(SUM(deal_price), 0) FROM prinzip_deals")).scalar()
|
|
||||||
)
|
|
||||||
all_time_deals = int(seeded.execute(text("SELECT COUNT(*) FROM prinzip_deals")).scalar())
|
|
||||||
expected = {
|
|
||||||
"leads_total": _ALL_TIME_LEADS,
|
|
||||||
"revenue_total": all_time_revenue,
|
|
||||||
"deals_total": all_time_deals,
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, value in stats.items():
|
|
||||||
if not key.endswith("_total"):
|
|
||||||
continue
|
|
||||||
if key not in expected:
|
|
||||||
continue
|
|
||||||
assert value == expected[key], (
|
|
||||||
f"поле {key!r} обещает суффиксом величину за ВСЁ время, а равно {value} "
|
|
||||||
f"при итоге {expected[key]} — это цифра за окно {_WINDOW_MONTHS} мес"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_revenue_and_deals_are_named_by_their_scope(seeded) -> None:
|
|
||||||
"""Выручка и сделки должны нести суффикс, соответствующий их охвату.
|
|
||||||
|
|
||||||
Отдельно от предыдущего: там проверяется значение под именем, здесь — что имя
|
|
||||||
вообще выбрано по охвату. Ловит «починку», которая оставила бы `_total` и
|
|
||||||
просто перестала показывать поле в UI.
|
|
||||||
"""
|
|
||||||
stats = _stats(seeded)
|
|
||||||
assert (
|
|
||||||
stats.get("revenue_window") == _IN_WINDOW_REVENUE
|
|
||||||
), f"revenue_window = {stats.get('revenue_window')}, ожидалось {_IN_WINDOW_REVENUE}"
|
|
||||||
assert stats.get("deals_window") == _IN_WINDOW_DEALS
|
|
||||||
|
|
||||||
|
|
||||||
def test_window_months_present_on_empty_data(db) -> None:
|
|
||||||
"""Контроль: на пустых данных ответ сохраняет форму и ширину окна.
|
|
||||||
|
|
||||||
Оговорка, чтобы тест не читался как покрытие ветки `if not row`: он туда НЕ
|
|
||||||
попадает. Запрос агрегатный и на пустых таблицах возвращает обычную строку
|
|
||||||
(leads_total=0, leads_window=0), поэтому исполняется основная ветка. Ветка
|
|
||||||
пустого ответа сегодня недостижима — её согласованность правится вслепую,
|
|
||||||
и проверить её этим тестом нельзя.
|
|
||||||
"""
|
|
||||||
stats = _stats(db)
|
|
||||||
assert "window_months" in stats, f"нет window_months в пустом ответе: {sorted(stats)}"
|
|
||||||
assert stats["window_months"] == _WINDOW_MONTHS
|
|
||||||
|
|
@ -62,8 +62,8 @@ interface LeadsStats {
|
||||||
converted_window: number;
|
converted_window: number;
|
||||||
conv_pct_window: number | null;
|
conv_pct_window: number | null;
|
||||||
sources_total: number;
|
sources_total: number;
|
||||||
revenue_window: number | null;
|
revenue_total: number | null;
|
||||||
deals_window: number;
|
deals_total: number;
|
||||||
window_months: number;
|
window_months: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -344,9 +344,9 @@ export default function AdminLeadsPage() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Card
|
<Card
|
||||||
label={`Revenue (${stats.data?.window_months ?? 12} мес)`}
|
label="Revenue (всего)"
|
||||||
value={fmtMoney(stats.data?.revenue_window ?? null)}
|
value={fmtMoney(stats.data?.revenue_total ?? null)}
|
||||||
hint={`${stats.data?.deals_window ?? 0} сделок`}
|
hint={`${stats.data?.deals_total ?? 0} сделок`}
|
||||||
/>
|
/>
|
||||||
<Card label="Источников" value={stats.data?.sources_total ?? "—"} />
|
<Card label="Источников" value={stats.data?.sources_total ?? "—"} />
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -2048,8 +2048,6 @@ export interface paths {
|
||||||
/**
|
/**
|
||||||
* Leads Stats
|
* Leads Stats
|
||||||
* @description KPI summary за последние N месяцев.
|
* @description KPI summary за последние N месяцев.
|
||||||
*
|
|
||||||
* Суффикс `_window` — за окно `months`, `_total` — за всё время.
|
|
||||||
*/
|
*/
|
||||||
get: operations["leads_stats_api_v1_admin_leads_stats_get"];
|
get: operations["leads_stats_api_v1_admin_leads_stats_get"];
|
||||||
put?: never;
|
put?: never;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue