Compare commits
2 commits
7d1db9edce
...
0792e34172
| Author | SHA1 | Date | |
|---|---|---|---|
| 0792e34172 | |||
| 53becb2e64 |
8 changed files with 526 additions and 25 deletions
|
|
@ -130,7 +130,16 @@ def leads_stats(
|
|||
db: Annotated[Session, Depends(get_db)],
|
||||
months: Annotated[int, Query(ge=1, le=120)] = 12,
|
||||
) -> 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 = (
|
||||
db.execute(
|
||||
text(
|
||||
|
|
@ -155,14 +164,14 @@ def leads_stats(
|
|||
WHERE d.deal_id IN (
|
||||
SELECT deal_id FROM window_leads WHERE deal_id IS NOT NULL
|
||||
)
|
||||
) AS revenue_total,
|
||||
) AS revenue_window,
|
||||
(
|
||||
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
|
||||
) AS deals_window
|
||||
FROM window_leads
|
||||
"""
|
||||
),
|
||||
|
|
@ -178,8 +187,16 @@ def leads_stats(
|
|||
"converted_window": 0,
|
||||
"conv_pct_window": None,
|
||||
"sources_total": 0,
|
||||
"revenue_total": None,
|
||||
"deals_total": 0,
|
||||
"revenue_window": None,
|
||||
"deals_window": 0,
|
||||
# window_months раньше отдавался ТОЛЬКО в непустой ветке — формы ответа
|
||||
# различались. Оговорка про достижимость: этот `if not row` СЕГОДНЯ не
|
||||
# срабатывает — запрос агрегатный и всегда возвращает ровно одну строку
|
||||
# (проверено на пустых таблицах: leads_total=0, leads_window=0, строка
|
||||
# truthy). То есть правка здесь — согласованность, а не наблюдаемая
|
||||
# починка; ветка остаётся защитой на случай смены формы запроса, и
|
||||
# расходиться с основной ей нельзя — именно так пропажа поля и возникла.
|
||||
"window_months": months,
|
||||
}
|
||||
return {
|
||||
"leads_total": row["leads_total"] or 0,
|
||||
|
|
@ -189,10 +206,10 @@ def leads_stats(
|
|||
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
|
||||
"revenue_window": (
|
||||
float(row["revenue_window"]) if row["revenue_window"] is not None else None
|
||||
),
|
||||
"deals_total": row["deals_total"] or 0,
|
||||
"deals_window": row["deals_window"] or 0,
|
||||
"window_months": months,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,14 +7,18 @@ UPSERT-ит в land_reservation (м.136). Reservation_lookup / analyze-wiring (#
|
|||
|
||||
Дедуп-ключ:
|
||||
ON CONFLICT (cad_num, act_number) — унаследован из reservation_ingest.py.
|
||||
Если act_number IS NULL (не извлечён из сканов) → конфликт НЕ возникает при NULL-UPSERT
|
||||
(NULL != NULL в SQL). Чтобы предотвратить дубли при act_number IS NULL, дедуплицируем
|
||||
по (cad_num, doc_url) на уровне Python перед UPSERT: один URL = один батч,
|
||||
повторный запуск с тем же URL обновит существующую строку через source+fetched_at
|
||||
(где act_number IS NULL используем DO NOTHING вместо DO UPDATE — нет stable key).
|
||||
Решение: для строк с act_number IS NULL добавляем в ON CONFLICT УНИКАЛЬНОСТЬ через
|
||||
отдельный UPSERT с COALESCE-fallback: если запись с (cad_num, doc_url) уже есть —
|
||||
UPDATE, иначе INSERT. Реализовано через двухшаговый UPSERT ниже.
|
||||
Уникальность держит констрейнт uq_land_reservation_cad_act; с миграции 189 он
|
||||
объявлен как UNIQUE NULLS NOT DISTINCT, поэтому записи без номера акта тоже
|
||||
конфликтуют между собой и ON CONFLICT DO NOTHING реально их ловит.
|
||||
|
||||
До м.189 констрейнт был обычным UNIQUE, где NULL != NULL: у записей с
|
||||
act_number IS NULL конфликт не наступал никогда, и каждый недельный прогон
|
||||
вставлял копию. Замер прода 20.08.2026 до правки — 297 строк, все без номера
|
||||
акта, 27 групп с дублями, до 11 копий, 270 лишних строк (91% таблицы).
|
||||
|
||||
Прежняя редакция этого docstring обещала python-дедуп по (cad_num, doc_url)
|
||||
перед UPSERT и «двухшаговый UPSERT ниже». Ни того, ни другого в коде не было —
|
||||
описание расходилось с реализацией и скрывало накопление дублей (#2464).
|
||||
|
||||
Beat: еженедельно (пятница 07:00 МСК) — изъятия выходят редко.
|
||||
|
||||
|
|
@ -45,10 +49,13 @@ logger = logging.getLogger(__name__)
|
|||
# Stable key = (cad_num, act_number). Идемпотентно при повторном прогоне.
|
||||
#
|
||||
# Вариант B (act_number IS NULL): INSERT ... ON CONFLICT DO NOTHING.
|
||||
# NULL != NULL → (cad_num, NULL) никогда не конфликтует по индексу.
|
||||
# Python-дедуп per-batch предотвращает дубли в рамках одного прогона.
|
||||
# Повторные прогоны добавят дубли если строки нет — acceptable (rare, data audit OK).
|
||||
# Альтернатива (partial unique index на NULL) — задача database-expert, не здесь.
|
||||
# Работает с миграции 189: uq_land_reservation_cad_act объявлен как
|
||||
# UNIQUE NULLS NOT DISTINCT, поэтому (cad_num, NULL) конфликтует с такой же
|
||||
# строкой и повторный прогон становится no-op.
|
||||
# Прежний комментарий здесь оценивал накопление дублей как «rare, data audit OK»
|
||||
# и откладывал уникальный индекс. Оценка не подтвердилась: на 20.08.2026 дубли
|
||||
# составляли 91% таблицы (270 лишних строк из 297), максимум 11 копий одной
|
||||
# записи. Отложенный вариант и реализован м.189 (#2464).
|
||||
|
||||
_UPSERT_WITH_ACT_SQL = text(
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -106,3 +106,22 @@ tests/sql/test_2956_freshness_ignores_failed_dumps.py::test_failed_dumps_do_not_
|
|||
tests/sql/test_2956_freshness_ignores_failed_dumps.py::test_successful_dump_still_counts_as_fresh
|
||||
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
|
||||
|
||||
# ── #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) ──────────────────────────────
|
||||
# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию таблицы, проверяют семантику
|
||||
# UNIQUE NULLS NOT DISTINCT и репетируют миграцию на засеянных дублях. В CI ИДУТ
|
||||
# (postgres-сервис, #2745); записи нужны для машины без БД и без туннеля.
|
||||
tests/sql/test_2464_land_reservation_dedup.py::test_nulls_not_distinct_deduplicates
|
||||
tests/sql/test_2464_land_reservation_dedup.py::test_plain_unique_does_not_deduplicate
|
||||
tests/sql/test_2464_land_reservation_dedup.py::test_records_with_act_number_still_deduplicate
|
||||
tests/sql/test_2464_land_reservation_dedup.py::test_different_parcels_are_not_collapsed
|
||||
tests/sql/test_2464_land_reservation_dedup.py::test_migration_dedup_statement_matches_the_key
|
||||
tests/sql/test_2464_land_reservation_dedup.py::test_migration_body_runs_on_a_prod_shaped_replica
|
||||
|
|
|
|||
215
backend/tests/sql/test_2464_land_reservation_dedup.py
Normal file
215
backend/tests/sql/test_2464_land_reservation_dedup.py
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
"""ON CONFLICT DO NOTHING в land_reservation обязан реально ловить дубли (#2464).
|
||||
|
||||
`_UPSERT_NO_ACT_SQL` (workers/tasks/izyatie_ocr_ingest.py) заканчивается
|
||||
`ON CONFLICT DO NOTHING`, а единственный подходящий констрейнт был
|
||||
`UNIQUE (cad_num, act_number)` с обычной NULL-семантикой. В Postgres NULL != NULL,
|
||||
поэтому у записей БЕЗ номера акта конфликт не наступал никогда — каждый недельный
|
||||
прогон вставлял копию.
|
||||
|
||||
Замер прода 20.08.2026 до правки: 297 строк, все с `act_number IS NULL`, 27 групп с
|
||||
дублями, до 11 копий, 270 лишних строк — 91 % таблицы.
|
||||
|
||||
Миграция 189 дедуплицирует таблицу и пересоздаёт констрейнт как
|
||||
`UNIQUE NULLS NOT DISTINCT`. Здесь проверяется САМ МЕХАНИЗМ на временной копии:
|
||||
с новой семантикой повторная вставка — no-op, со старой — дубль. Второе
|
||||
утверждение обязательно: без него тест не отличить от «оно и так работало».
|
||||
|
||||
Тест герметичный: таблицы временные, боевые данные не читаются и не меняются.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
_MIGRATION = (
|
||||
Path(__file__).resolve().parents[3]
|
||||
/ "data"
|
||||
/ "sql"
|
||||
/ "189_land_reservation_nulls_not_distinct.sql"
|
||||
)
|
||||
|
||||
|
||||
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}")
|
||||
|
||||
_TABLE = """
|
||||
CREATE TEMP TABLE land_reservation (
|
||||
id bigserial PRIMARY KEY,
|
||||
cad_num text NOT NULL,
|
||||
act_number text,
|
||||
doc_url text,
|
||||
reservation_kind text,
|
||||
is_active boolean DEFAULT true,
|
||||
fetched_at timestamptz DEFAULT now()
|
||||
) ON COMMIT DROP;
|
||||
"""
|
||||
|
||||
_INSERT = """
|
||||
INSERT INTO land_reservation (cad_num, act_number, doc_url, reservation_kind)
|
||||
VALUES (:cad, :act, :url, 'изъятие')
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db():
|
||||
engine = create_engine(_dsn())
|
||||
session = sessionmaker(bind=engine)()
|
||||
try:
|
||||
session.execute(text(_TABLE))
|
||||
n = session.execute(text("SELECT count(*) FROM land_reservation")).scalar()
|
||||
assert n == 0, f"запрос попал НЕ во временную таблицу ({n} строк)"
|
||||
yield session
|
||||
finally:
|
||||
session.rollback()
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _add_constraint(db, nulls_not_distinct: bool) -> None:
|
||||
kind = "UNIQUE NULLS NOT DISTINCT" if nulls_not_distinct else "UNIQUE"
|
||||
db.execute(
|
||||
text(f"ALTER TABLE land_reservation ADD CONSTRAINT uq_t {kind} (cad_num, act_number)")
|
||||
)
|
||||
|
||||
|
||||
def _insert_twice(db) -> int:
|
||||
for _ in range(2):
|
||||
db.execute(
|
||||
text(_INSERT), {"cad": "66:41:0303004:22", "act": None, "url": "https://x/y.pdf"}
|
||||
)
|
||||
return int(db.execute(text("SELECT count(*) FROM land_reservation")).scalar())
|
||||
|
||||
|
||||
def test_nulls_not_distinct_deduplicates(db) -> None:
|
||||
"""С новой семантикой повторная вставка act-less записи — no-op."""
|
||||
_add_constraint(db, nulls_not_distinct=True)
|
||||
assert _insert_twice(db) == 1, "дубль всё равно вставился"
|
||||
|
||||
|
||||
def test_plain_unique_does_not_deduplicate(db) -> None:
|
||||
"""Фальсификация: со СТАРЫМ констрейнтом дубль обязан появиться.
|
||||
|
||||
Без этой проверки зелёный тест выше неотличим от «оно и так работало».
|
||||
"""
|
||||
_add_constraint(db, nulls_not_distinct=False)
|
||||
assert (
|
||||
_insert_twice(db) == 2
|
||||
), "обычный UNIQUE неожиданно поймал дубль — значит тест выше ничего не доказывает"
|
||||
|
||||
|
||||
def test_records_with_act_number_still_deduplicate(db) -> None:
|
||||
"""Контроль: записи С номером акта дедуплицировались и раньше — не сломали."""
|
||||
_add_constraint(db, nulls_not_distinct=True)
|
||||
for _ in range(2):
|
||||
db.execute(text(_INSERT), {"cad": "66:41:1", "act": "12-АК", "url": "https://x/1.pdf"})
|
||||
assert int(db.execute(text("SELECT count(*) FROM land_reservation")).scalar()) == 1
|
||||
|
||||
|
||||
def test_different_parcels_are_not_collapsed(db) -> None:
|
||||
"""Контроль: разные участки без номера акта остаются разными строками.
|
||||
|
||||
Ловит «починку» через слишком широкий ключ.
|
||||
"""
|
||||
_add_constraint(db, nulls_not_distinct=True)
|
||||
for cad in ("66:41:1", "66:41:2", "66:41:3"):
|
||||
db.execute(text(_INSERT), {"cad": cad, "act": None, "url": "https://x/z.pdf"})
|
||||
assert int(db.execute(text("SELECT count(*) FROM land_reservation")).scalar()) == 3
|
||||
|
||||
|
||||
def test_migration_dedup_statement_matches_the_key(db) -> None:
|
||||
"""DELETE в миграции обязан чистить ровно по ключу констрейнта.
|
||||
|
||||
Расхождение ключа дедупа и ключа констрейнта означало бы, что после DELETE
|
||||
констрейнт всё равно не создастся — миграция упала бы на проде.
|
||||
"""
|
||||
sql = _MIGRATION.read_text()
|
||||
assert "UNIQUE NULLS NOT DISTINCT (cad_num, act_number)" in sql
|
||||
delete_stmt = re.search(r"DELETE FROM land_reservation.*?;", sql, re.S)
|
||||
assert delete_stmt is not None, "в миграции нет DELETE — дедуп не выполняется"
|
||||
body = delete_stmt.group(0)
|
||||
assert "a.cad_num = b.cad_num" in body, "дедуп не по cad_num"
|
||||
assert (
|
||||
"a.act_number IS NULL" in body and "b.act_number IS NULL" in body
|
||||
), "дедуп затрагивает записи С номером акта — они и так были уникальны"
|
||||
assert "a.id > b.id" in body, "не задан выживающий (минимальный id)"
|
||||
|
||||
|
||||
def test_migration_body_runs_on_a_prod_shaped_replica(db) -> None:
|
||||
"""Репетиция миграции: 11 копий → 1 строка, констрейнт создаётся.
|
||||
|
||||
Сильнее проверки регулярками: исполняются РЕАЛЬНЫЕ выражения из файла миграции.
|
||||
Если DELETE чистит не по тому ключу, ADD CONSTRAINT здесь же и упадёт — как
|
||||
упал бы на проде.
|
||||
"""
|
||||
# Засев как на проде: одна группа, 11 точных копий, плюс соседний участок.
|
||||
for _ in range(11):
|
||||
db.execute(
|
||||
text(
|
||||
"INSERT INTO land_reservation (cad_num, act_number, doc_url, reservation_kind)"
|
||||
" VALUES ('66:41:0303004:22', NULL, 'https://x/y.pdf', 'изъятие')"
|
||||
)
|
||||
)
|
||||
db.execute(
|
||||
text(
|
||||
"INSERT INTO land_reservation (cad_num, act_number, doc_url, reservation_kind)"
|
||||
" VALUES ('66:41:0206032:8499', NULL, 'https://x/z.pdf', 'изъятие')"
|
||||
)
|
||||
)
|
||||
assert int(db.execute(text("SELECT count(*) FROM land_reservation")).scalar()) == 12
|
||||
|
||||
sql = _MIGRATION.read_text()
|
||||
body = sql[sql.index("BEGIN;") + len("BEGIN;") : sql.rindex("COMMIT;")]
|
||||
for chunk in body.split(";"):
|
||||
# Срезаем ведущие строки-комментарии, а не пропускаем кусок целиком:
|
||||
# DELETE в миграции идёт СРАЗУ ПОСЛЕ комментария, и наивный пропуск
|
||||
# «кусков, начинающихся с --» молча выкинул бы его. Первая версия этого
|
||||
# теста так и сделала — репетиция упала на ADD CONSTRAINT, и это было
|
||||
# ровно то, что она и должна ловить.
|
||||
stmt = "\n".join(
|
||||
ln for ln in chunk.splitlines() if ln.strip() and not ln.lstrip().startswith("--")
|
||||
).strip()
|
||||
if stmt:
|
||||
db.execute(text(stmt))
|
||||
|
||||
rows = db.execute(
|
||||
text("SELECT cad_num, count(*) FROM land_reservation GROUP BY 1 ORDER BY 1")
|
||||
).all()
|
||||
assert [(r[0], r[1]) for r in rows] == [
|
||||
("66:41:0206032:8499", 1),
|
||||
("66:41:0303004:22", 1),
|
||||
], f"после миграции осталось не по одной строке: {rows}"
|
||||
|
||||
# И теперь повторная вставка действительно no-op.
|
||||
db.execute(text(_INSERT), {"cad": "66:41:0303004:22", "act": None, "url": "https://x/y.pdf"})
|
||||
assert int(db.execute(text("SELECT count(*) FROM land_reservation")).scalar()) == 2
|
||||
189
backend/tests/sql/test_2464_leads_stats_suffix_contract.py
Normal file
189
backend/tests/sql/test_2464_leads_stats_suffix_contract.py
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
"""Суффикс поля в /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
|
||||
52
data/sql/189_land_reservation_nulls_not_distinct.sql
Normal file
52
data/sql/189_land_reservation_nulls_not_distinct.sql
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
-- 189_land_reservation_nulls_not_distinct.sql
|
||||
-- #2464 — дедуп land_reservation и UNIQUE NULLS NOT DISTINCT на живой таблице.
|
||||
--
|
||||
-- БАГ. `_UPSERT_NO_ACT_SQL` (workers/tasks/izyatie_ocr_ingest.py) заканчивается
|
||||
-- `ON CONFLICT DO NOTHING`, а единственный подходящий констрейнт —
|
||||
-- `uq_land_reservation_cad_act UNIQUE (cad_num, act_number)` с обычной NULL-семантикой.
|
||||
-- В Postgres NULL != NULL, поэтому у записей БЕЗ номера акта конфликт не наступает
|
||||
-- никогда: `ON CONFLICT DO NOTHING` не срабатывает, и каждый недельный прогон
|
||||
-- вставляет копию. Docstring таски при этом обещает per-batch дедуп и двухшаговый
|
||||
-- upsert по (cad_num, doc_url) — ни того, ни другого в коде нет.
|
||||
--
|
||||
-- ЗАМЕР ПРОДА 2026-08-20 (до правки):
|
||||
-- строк всего 297
|
||||
-- из них с act_number IS NULL 297 (то есть все)
|
||||
-- групп (cad_num, doc_url) с дублями 27
|
||||
-- максимум копий в группе 11
|
||||
-- лишних строк 270 (91% таблицы)
|
||||
--
|
||||
-- Проверено, что ключ подходит: ни у одного cad_num нет более одного doc_url
|
||||
-- (max = 1), то есть NULLS NOT DISTINCT по (cad_num, act_number) НЕ схлопнет
|
||||
-- разные документы одного участка. Дубли внутри групп — точные копии: по одному
|
||||
-- различному значению reservation_kind и act_date на группу.
|
||||
--
|
||||
-- ЧТО УДАЛЯЕТСЯ. Строки-копии сверх первой (по возрастанию id) в каждой группе
|
||||
-- (cad_num, act_number) среди act_number IS NULL. Это порождение бага, а не
|
||||
-- пользовательские данные; таблица — кэш OCR-разбора PDF с сайта, пересобираемый
|
||||
-- прогоном таски. Первая строка группы (минимальный id) сохраняется целиком.
|
||||
--
|
||||
-- ПОЧЕМУ ОТДЕЛЬНОЙ МИГРАЦИЕЙ, а не правкой CREATE TABLE: та же причина, что в
|
||||
-- м.158 — исходный файл уже в _schema_migrations и на деплое пропускается.
|
||||
-- Прецеденты NULLS NOT DISTINCT в репо: м.110, м.125, м.140, м.158. Prod = PG16.4.
|
||||
-- Apply after: 188_regrant_quarter_price_index_fdw.sql
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1) Дедуп: оставляем строку с минимальным id в каждой группе.
|
||||
DELETE FROM land_reservation a
|
||||
USING land_reservation b
|
||||
WHERE a.act_number IS NULL
|
||||
AND b.act_number IS NULL
|
||||
AND a.cad_num = b.cad_num
|
||||
AND a.id > b.id;
|
||||
|
||||
-- 2) Пересоздаём констрейнт с NULL-семантикой, при которой ON CONFLICT матчит.
|
||||
ALTER TABLE land_reservation
|
||||
DROP CONSTRAINT IF EXISTS uq_land_reservation_cad_act;
|
||||
|
||||
ALTER TABLE land_reservation
|
||||
ADD CONSTRAINT uq_land_reservation_cad_act
|
||||
UNIQUE NULLS NOT DISTINCT (cad_num, act_number);
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -62,8 +62,8 @@ interface LeadsStats {
|
|||
converted_window: number;
|
||||
conv_pct_window: number | null;
|
||||
sources_total: number;
|
||||
revenue_total: number | null;
|
||||
deals_total: number;
|
||||
revenue_window: number | null;
|
||||
deals_window: number;
|
||||
window_months: number;
|
||||
}
|
||||
|
||||
|
|
@ -344,9 +344,9 @@ export default function AdminLeadsPage() {
|
|||
}
|
||||
/>
|
||||
<Card
|
||||
label="Revenue (всего)"
|
||||
value={fmtMoney(stats.data?.revenue_total ?? null)}
|
||||
hint={`${stats.data?.deals_total ?? 0} сделок`}
|
||||
label={`Revenue (${stats.data?.window_months ?? 12} мес)`}
|
||||
value={fmtMoney(stats.data?.revenue_window ?? null)}
|
||||
hint={`${stats.data?.deals_window ?? 0} сделок`}
|
||||
/>
|
||||
<Card label="Источников" value={stats.data?.sources_total ?? "—"} />
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -2048,6 +2048,8 @@ export interface paths {
|
|||
/**
|
||||
* Leads Stats
|
||||
* @description KPI summary за последние N месяцев.
|
||||
*
|
||||
* Суффикс `_window` — за окно `months`, `_total` — за всё время.
|
||||
*/
|
||||
get: operations["leads_stats_api_v1_admin_leads_stats_get"];
|
||||
put?: never;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue