Compare commits
100 commits
fix/rbac-t
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f3579b8c2 | |||
| 036ff84eaa | |||
| ca46411346 | |||
| bcb903cfa2 | |||
| a450aed71b | |||
| 76016fd469 | |||
| 35f5c3426b | |||
| dce2cd2040 | |||
| 6e132f8986 | |||
| cf3d0eb4f6 | |||
| d6b9891fe2 | |||
| 580be61914 | |||
| a0647a53a9 | |||
| ca1015bd4e | |||
| c9ba1b15ba | |||
| 06bf8dfada | |||
| e0b63cc637 | |||
| d56103219a | |||
|
|
7fd40f6ea5 | ||
| fcfc777baa | |||
|
|
b579fa4ced | ||
| 698576e77b | |||
| 67b50bf713 | |||
| c61ed5a96e | |||
| e61c2debe5 | |||
| c904fbf94e | |||
| 9fd6396fbc | |||
| 8b7af92ee3 | |||
| 2c5b30f8c9 | |||
| d530602fbd | |||
| 32037af6de | |||
| 5647c2b40e | |||
| 749c96cd0c | |||
| 987dce11bc | |||
| b5477362f0 | |||
| 60c21ae2a5 | |||
| 3bd0873230 | |||
| 5e73b35206 | |||
| 69ca746673 | |||
| 2cd24ecfbc | |||
| 92371e7d9e | |||
| 7fa52bb3ad | |||
| 348be445e3 | |||
|
|
8fdcd8df62 | ||
| c98a879a79 | |||
|
|
2ef3e42da1 | ||
|
|
720cee2c8e | ||
| 73f932d60a | |||
| e7c388dde7 | |||
|
|
07275c3c97 | ||
|
|
d92ff5c3d3 | ||
|
|
d31b1e01ee | ||
| 51473fc841 | |||
| c3d0f86f37 | |||
|
|
219cdfdbcc | ||
|
|
bf4ce1ba36 | ||
| 519b482cdd | |||
| e4bb2fc2e3 | |||
|
|
85321d1d1a | ||
| b1ac551451 | |||
| 032fd95a7e | |||
|
|
0583fb0efd | ||
| a3dfa2a99f | |||
|
|
20bdc7dec8 | ||
|
|
6487813ec5 | ||
|
|
1c9f0e1161 | ||
| 0f4ed2f6e9 | |||
|
|
27d1b09c72 | ||
|
|
c1a01fdbf9 | ||
| fb123bd97e | |||
|
|
f9ae6f0c53 | ||
| 3293df8e14 | |||
| 4c56c5189a | |||
|
|
25a865f27b | ||
|
|
69f8b50934 | ||
|
|
7801ac6cc1 | ||
| 5d7c3e1c30 | |||
| 6936558f6f | |||
|
|
f631af4de3 | ||
|
|
4a579e74e0 | ||
|
|
b93d26990b | ||
| 90a6e4bfe9 | |||
|
|
cf91ac6b24 | ||
| 74017b39c4 | |||
|
|
e250869c9d | ||
| b0b2c94ece | |||
|
|
e2502a0134 | ||
| 51952ebd70 | |||
|
|
c0cbdc2f99 | ||
| fcc37c46ab | |||
| 95f0b0aa64 | |||
|
|
752cb1044b | ||
| 338ccbb78f | |||
| a88e755403 | |||
| 4f205bd7da | |||
| 8dd35c01d8 | |||
| ed504cbbd9 | |||
| cc231946c2 | |||
| d22e0e00ae | |||
| 346767ba90 |
174 changed files with 21387 additions and 3309 deletions
|
|
@ -483,7 +483,11 @@ jobs:
|
|||
# рискует ложно отменить НЕ относящийся к этому recreate run (напр.
|
||||
# admin-triggered scrape внутри backend, если backend в этом деплое
|
||||
# не пересоздавался — его heartbeat продолжит расти после checkpoint'а).
|
||||
SERVICES="browser backend frontend"
|
||||
# tgbot: тот же backend-образ (rebuild уже покрыт filters.backend —
|
||||
# tradein-mvp/backend/** включает app/tgbot_main.py), никакого
|
||||
# in-flight state вроде scrape_runs → пересоздаётся безусловно вместе
|
||||
# с browser/backend/frontend, отдельного graceful-drain не требует.
|
||||
SERVICES="browser backend frontend tgbot"
|
||||
SCRAPER_STOP_TS=""
|
||||
if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
|
||||
echo "→ scraper paths changed — waiting for in-flight scrape_runs to drain (up to 5 min)"
|
||||
|
|
@ -588,6 +592,97 @@ jobs:
|
|||
fi
|
||||
echo "→ backend healthy на /health."
|
||||
|
||||
# Frontend health check — раньше проверялся ТОЛЬКО backend: сломанный
|
||||
# фронт (500/белый экран после build, или контейнер упавший на старте)
|
||||
# помечался успешным деплоем, отката не происходило (см. заголовок
|
||||
# секции выше). Проверяем изнутри backend-контейнера — он в одной
|
||||
# tradein-net сети с frontend, и curl там уже есть (в отличие от
|
||||
# node:alpine рантайм-образа frontend, где нет ни curl, ни wget —
|
||||
# добавлять их туда ради healthcheck не стали, backend достаточно).
|
||||
# Путь ОБЯЗАН включать /trade-in: basePath запечён в prod-образ на
|
||||
# build (NEXT_PUBLIC_BASE_PATH=/trade-in, см. build-frontend job) —
|
||||
# голый "/" внутри Next вернёт 404, а не что-то живое. "/trade-in/"
|
||||
# редиректит (307) на /trade-in/v2 — curl -f не считает 3xx ошибкой,
|
||||
# так что это чистая liveness-проверка (процесс жив и роутит),
|
||||
# без привязки к тому, что именно сейчас показывает витрина.
|
||||
frontend_healthy=""
|
||||
for i in $(seq 1 30); do
|
||||
if docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \
|
||||
exec -T backend curl -fsS http://frontend:3000/trade-in/ >/dev/null 2>&1; then
|
||||
frontend_healthy="yes"; break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "$frontend_healthy" ]; then
|
||||
echo "ERROR: frontend не ответил на /trade-in/ за 30s — деплой FAILED"
|
||||
exit 1
|
||||
fi
|
||||
echo "→ frontend healthy на /trade-in/."
|
||||
|
||||
# Browser health check — /health в browser/server.py всегда 200, пока
|
||||
# жив сам aiohttp-процесс (см. health_handler: "compose НЕ имеет
|
||||
# healthcheck на browser, только depends_on: service_started" — до
|
||||
# этой правки browser вообще не проверялся никаким деплой-шагом).
|
||||
# Это liveness процесса, НЕ readiness camoufox-инстансов конкретных
|
||||
# источников (те поднимаются лениво на первый /fetch) — но упавший
|
||||
# при старте контейнер (например, битый образ) здесь ловится сразу,
|
||||
# а не молча остаётся мёртвым до первого реального /fetch scraper'ом.
|
||||
browser_healthy=""
|
||||
for i in $(seq 1 30); do
|
||||
if docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \
|
||||
exec -T backend curl -fsS http://browser:3000/health >/dev/null 2>&1; then
|
||||
browser_healthy="yes"; break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "$browser_healthy" ]; then
|
||||
echo "ERROR: browser не ответил на /health за 30s — деплой FAILED"
|
||||
exit 1
|
||||
fi
|
||||
echo "→ browser healthy на /health."
|
||||
|
||||
# tgbot/scraper — те же backend-образ и Dockerfile, но bare python-
|
||||
# процессы БЕЗ ASGI/HTTP-сервера (см. комментарии в tgbot_main.py /
|
||||
# scheduler_main.py: "здесь нет ASGI-приложения"), поэтому HTTP-
|
||||
# healthcheck для них невозможен в принципе. Liveness проверяем по
|
||||
# состоянию контейнера через docker inspect: упавший на старте
|
||||
# процесс (например, ImportError в новом коде) restart-policy
|
||||
# unless-stopped уводит в бесконечный crash-loop — раньше это НИКАК
|
||||
# не блокировало деплой (маркер писался, даже если tgbot/scraper
|
||||
# были мертвы). Двойная проверка (running → пауза → снова running)
|
||||
# снижает шанс поймать контейнер ровно в момент between-restarts
|
||||
# промежуточного "running" внутри crash-loop.
|
||||
# tgbot пересоздаётся на КАЖДОМ деплое (безусловно в $SERVICES);
|
||||
# scraper — только когда SCRAPER_CHANGED (см. блок выше) — поэтому
|
||||
# проверяем только то, что реально входит в текущий $SERVICES.
|
||||
for svc in tgbot scraper; do
|
||||
case " $SERVICES " in
|
||||
*" $svc "*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
container_ok=""
|
||||
state="unknown"
|
||||
for i in $(seq 1 15); do
|
||||
state=$(docker inspect -f '{{.State.Status}}' "tradein-$svc" 2>/dev/null || echo "unknown")
|
||||
if [ "$state" = "running" ]; then
|
||||
container_ok="yes"; break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ -n "$container_ok" ]; then
|
||||
sleep 3
|
||||
state=$(docker inspect -f '{{.State.Status}}' "tradein-$svc" 2>/dev/null || echo "unknown")
|
||||
if [ "$state" != "running" ]; then
|
||||
container_ok=""
|
||||
fi
|
||||
fi
|
||||
if [ -z "$container_ok" ]; then
|
||||
echo "ERROR: tradein-$svc не в стабильном состоянии running (state='$state') — деплой FAILED"
|
||||
exit 1
|
||||
fi
|
||||
echo "→ tradein-$svc running."
|
||||
done
|
||||
|
||||
# Cleanup старых образов
|
||||
for repo in ghcr.io/lekss361/gendesign-tradein-backend \
|
||||
ghcr.io/lekss361/gendesign-tradein-frontend; do
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ users:
|
|||
admin: admin
|
||||
kopylov: pilot
|
||||
user1: pilot
|
||||
user2: expired # «Брусника» — пробный доступ закрыт 2026-07-09 (аналогично praktika) — NoAccessScreen variant="trial"
|
||||
user2: pilot # «Брусника» — доступ восстановлен 2026-07-13 (снят trial-expire от 2026-07-09)
|
||||
user3: pilot
|
||||
user4: pilot
|
||||
user5: pilot
|
||||
|
|
@ -79,7 +79,13 @@ users:
|
|||
user8: pilot
|
||||
user9: pilot
|
||||
user10: pilot
|
||||
praktika: expired # пробный доступ закончился 2026-06-27 — см. NoAccessScreen variant="trial"
|
||||
praktika: pilot # ГК «Практика» — доступ восстановлен 2026-07-27 (решение владельца
|
||||
# продукта; ранее expired с 2026-06-27). Безлимитная квота оценок
|
||||
# выдана через account_quota_overrides.unlimited (migration 191),
|
||||
# не через код — см. app.services.account_quota.is_unlimited.
|
||||
admintest: admin # temp QA 2026-05-26
|
||||
pilottest: pilot # temp QA 2026-05-26
|
||||
analysttest: analyst # temp QA 2026-06-07 (#962)
|
||||
expiredtest: expired # temp QA 2026-07-27 — role=expired regression coverage для
|
||||
# test_rbac.py (praktika перестал быть expired-фикстурой
|
||||
# после восстановления доступа)
|
||||
|
|
|
|||
|
|
@ -19,60 +19,110 @@ logger = logging.getLogger(__name__)
|
|||
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
||||
EKB_BBOX = (56.7, 60.5, 56.95, 60.75) # (south, west, north, east)
|
||||
|
||||
# Маппинг OSM-тег → нормализованная category
|
||||
OSM_CATEGORIES: dict[tuple[str, str], str] = {
|
||||
# Маппинг набора OSM-тегов (все теги в кортеже должны совпасть — AND) → нормализованная
|
||||
# category. Каждая запись — один per-category Overpass-запрос (см. _build_overpass_query);
|
||||
# несколько записей с ОДИНАКОВЫМ значением category (как у metro_stop ниже) — это "ИЛИ" на
|
||||
# уровне отдельных HTTP-запросов: элемент, подходящий под любую из альтернативных схем
|
||||
# разметки, попадёт в категорию.
|
||||
OSM_CATEGORIES: dict[tuple[tuple[str, str], ...], str] = {
|
||||
# amenity tags — школы расширены (school/college/university)
|
||||
("amenity", "school"): "school",
|
||||
("amenity", "college"): "school",
|
||||
("amenity", "university"): "school",
|
||||
("amenity", "kindergarten"): "kindergarten",
|
||||
("amenity", "pharmacy"): "pharmacy",
|
||||
("amenity", "hospital"): "hospital",
|
||||
("amenity", "clinic"): "hospital",
|
||||
(("amenity", "school"),): "school",
|
||||
(("amenity", "college"),): "school",
|
||||
(("amenity", "university"),): "school",
|
||||
(("amenity", "kindergarten"),): "kindergarten",
|
||||
(("amenity", "pharmacy"),): "pharmacy",
|
||||
(("amenity", "hospital"),): "hospital",
|
||||
(("amenity", "clinic"),): "hospital",
|
||||
# shop tags — supermarket расширен
|
||||
("shop", "mall"): "shop_mall",
|
||||
("shop", "supermarket"): "shop_supermarket",
|
||||
("shop", "hypermarket"): "shop_supermarket",
|
||||
("shop", "convenience"): "shop_small",
|
||||
("shop", "bakery"): "shop_small",
|
||||
(("shop", "mall"),): "shop_mall",
|
||||
(("shop", "supermarket"),): "shop_supermarket",
|
||||
(("shop", "hypermarket"),): "shop_supermarket",
|
||||
(("shop", "convenience"),): "shop_small",
|
||||
(("shop", "bakery"),): "shop_small",
|
||||
# leisure
|
||||
("leisure", "park"): "park",
|
||||
(("leisure", "park"),): "park",
|
||||
# transit
|
||||
("railway", "tram_stop"): "tram_stop",
|
||||
("highway", "bus_stop"): "bus_stop",
|
||||
# метро (одна линия в ЕКБ, но добавляем для полноты)
|
||||
("station", "subway"): "metro_stop",
|
||||
(("railway", "tram_stop"),): "tram_stop",
|
||||
(("highway", "bus_stop"),): "bus_stop",
|
||||
# Метро ЕКБ (9 станций, одна линия). Fix (location-index rework): фильтр раньше ловил
|
||||
# ТОЛЬКО station=subway и подтягивал лишь 5/9 станций — часть станций в OSM размечена
|
||||
# без ключа "station" вовсе, комбинацией railway=station + subway=yes (альтернативная,
|
||||
# но распространённая схема разметки метро). Обе схемы — отдельными записями ниже, чтобы
|
||||
# не терять станции, размеченные любой из них.
|
||||
(("station", "subway"),): "metro_stop",
|
||||
(("railway", "station"), ("subway", "yes")): "metro_stop",
|
||||
}
|
||||
|
||||
|
||||
def _build_overpass_query_single(key: str, value: str) -> str:
|
||||
"""Запрос для одной пары tag → нормированной категории.
|
||||
def _build_overpass_query(tag_filters: tuple[tuple[str, str], ...]) -> str:
|
||||
"""Запрос для ОДНОЙ комбинации tag=value (обычно один тег, иногда несколько — все AND).
|
||||
|
||||
Раньше делали один большой запрос на все 14 категорий — Overpass возвращал
|
||||
504 Gateway Timeout (запрос слишком тяжёлый). Сплит на per-category даёт
|
||||
14 быстрых запросов вместо одного 60+ секундного.
|
||||
быстрые запросы вместо одного 60+ секундного.
|
||||
"""
|
||||
south, west, north, east = EKB_BBOX
|
||||
bbox = f"({south},{west},{north},{east})"
|
||||
return (
|
||||
f"[out:json][timeout:30];"
|
||||
f'(node["{key}"="{value}"]{bbox};way["{key}"="{value}"]{bbox};);'
|
||||
f"out center meta;"
|
||||
)
|
||||
filt = "".join(f'["{k}"="{v}"]' for k, v in tag_filters)
|
||||
return f"[out:json][timeout:30];(node{filt}{bbox};way{filt}{bbox};);out center meta;"
|
||||
|
||||
|
||||
def _classify(tags: dict[str, str]) -> str | None:
|
||||
"""Определить category из OSM-тегов. None если не соответствует ни одной."""
|
||||
for (k, v), cat in OSM_CATEGORIES.items():
|
||||
if tags.get(k) == v:
|
||||
for tag_filters, cat in OSM_CATEGORIES.items():
|
||||
if all(tags.get(k) == v for k, v in tag_filters):
|
||||
return cat
|
||||
return None
|
||||
|
||||
|
||||
def _tag_filters_desc(tag_filters: tuple[tuple[str, str], ...]) -> str:
|
||||
return ",".join(f"{k}={v}" for k, v in tag_filters)
|
||||
|
||||
|
||||
async def _fetch_category(
|
||||
client: httpx.AsyncClient, tag_filters: tuple[tuple[str, str], ...], category: str
|
||||
) -> list[dict]:
|
||||
"""Один per-category Overpass-запрос с ОДНИМ повтором при транзиентной ошибке.
|
||||
|
||||
Fix (location-index rework, "не потерялись крупные категории"): раньше единственная
|
||||
неудача (таймаут / 504) на всю неделю обнуляла категорию целиком (следующая попытка —
|
||||
только на следующем weekly run). Один retry с паузой снимает большую часть транзиентных
|
||||
сбоев без риска зациклиться (Overpass rate-limit — max 2 concurrent, поэтому не более
|
||||
2 попыток на категорию).
|
||||
"""
|
||||
tag_desc = _tag_filters_desc(tag_filters)
|
||||
query = _build_overpass_query(tag_filters)
|
||||
for attempt in (1, 2):
|
||||
try:
|
||||
r = await client.post(OVERPASS_URL, data={"data": query})
|
||||
r.raise_for_status()
|
||||
elements: list[dict] = r.json().get("elements", [])
|
||||
logger.info(
|
||||
"Overpass: %s (%s) → %d [attempt %d]", tag_desc, category, len(elements), attempt
|
||||
)
|
||||
# Привязываем category именно к тому per-category запросу, под который
|
||||
# элемент реально пришёл. Элемент с двумя целевыми тегами (например
|
||||
# amenity=pharmacy + shop=supermarket) приходит дважды — каждая копия
|
||||
# несёт свою category. Иначе _classify по dict-порядку молча терял бы
|
||||
# вторую категорию при UPSERT по UNIQUE(osm_type, osm_id, category). См. #1372.
|
||||
for el in elements:
|
||||
el["_gd_category"] = category
|
||||
return elements
|
||||
except Exception as e:
|
||||
if attempt == 1:
|
||||
logger.warning("Overpass failed for %s (attempt 1, retrying): %s", tag_desc, e)
|
||||
await asyncio.sleep(3.0)
|
||||
continue
|
||||
logger.warning(
|
||||
"Overpass failed for %s after retry — category skipped this run: %s", tag_desc, e
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
async def fetch_overpass() -> list[dict]:
|
||||
"""Запросить Overpass API per category, вернуть combined список elements.
|
||||
|
||||
Делаем 14 отдельных запросов вместо одного гигантского — большой запрос
|
||||
Делаем отдельные запросы вместо одного гигантского — большой запрос
|
||||
отдаёт 504 Gateway Timeout. Между запросами sleep 1с (Overpass usage
|
||||
policy: max 2 concurrent, лучше 1 req/s).
|
||||
|
||||
|
|
@ -85,27 +135,14 @@ async def fetch_overpass() -> list[dict]:
|
|||
}
|
||||
all_elements: list[dict] = []
|
||||
async with httpx.AsyncClient(timeout=60, headers=headers) as client:
|
||||
for (key, value), category in OSM_CATEGORIES.items():
|
||||
query = _build_overpass_query_single(key, value)
|
||||
try:
|
||||
r = await client.post(OVERPASS_URL, data={"data": query})
|
||||
r.raise_for_status()
|
||||
elements: list[dict] = r.json().get("elements", [])
|
||||
logger.info("Overpass: %s=%s (%s) → %d", key, value, category, len(elements))
|
||||
# Привязываем category именно к тому per-category запросу, под который
|
||||
# элемент реально пришёл. Элемент с двумя целевыми тегами (например
|
||||
# amenity=pharmacy + shop=supermarket) приходит дважды — каждая копия
|
||||
# несёт свою category. Иначе _classify по dict-порядку молча терял бы
|
||||
# вторую категорию при UPSERT по UNIQUE(osm_type, osm_id, category). См. #1372.
|
||||
for el in elements:
|
||||
el["_gd_category"] = category
|
||||
for tag_filters, category in OSM_CATEGORIES.items():
|
||||
elements = await _fetch_category(client, tag_filters, category)
|
||||
all_elements.extend(elements)
|
||||
except Exception as e:
|
||||
# Не падаем на одной категории — логируем и продолжаем
|
||||
logger.warning("Overpass failed for %s=%s: %s", key, value, e)
|
||||
await asyncio.sleep(1.0)
|
||||
logger.info(
|
||||
"Overpass: total %d elements across %d categories", len(all_elements), len(OSM_CATEGORIES)
|
||||
"Overpass: total %d elements across %d category-queries",
|
||||
len(all_elements),
|
||||
len(OSM_CATEGORIES),
|
||||
)
|
||||
return all_elements
|
||||
|
||||
|
|
|
|||
|
|
@ -114,9 +114,7 @@ def test_get_role_known_users() -> None:
|
|||
assert auth_mod.get_role("admin") == "admin"
|
||||
assert auth_mod.get_role("kopylov") == "pilot"
|
||||
for n in range(1, 11):
|
||||
# user2 = «Брусника»: пробный доступ закрыт 2026-07-09 (roles.yaml)
|
||||
expected = "expired" if n == 2 else "pilot"
|
||||
assert auth_mod.get_role(f"user{n}") == expected
|
||||
assert auth_mod.get_role(f"user{n}") == "pilot"
|
||||
|
||||
|
||||
def test_get_role_unknown_user_raises() -> None:
|
||||
|
|
|
|||
48
data/sql/188_tradein_osm_poi_view_relax_freshness.sql
Normal file
48
data/sql/188_tradein_osm_poi_view_relax_freshness.sql
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
-- 188_tradein_osm_poi_view_relax_freshness.sql
|
||||
-- Fix for 185_tradein_osm_poi_view.sql: v_tradein_osm_poi_ekb enforced a HARD 2-year
|
||||
-- OSM last-edit-date filter that silently dropped legitimate, stable infrastructure.
|
||||
--
|
||||
-- CONTEXT (trade-in location-index rework, replaces the broken location-coef):
|
||||
-- Audit of the trade-in POI mirror (osm_poi_ekb_local, fed by this view via the FDW
|
||||
-- bridge) found only 5 of 9 EKB metro stations and just 2787 POI total reaching
|
||||
-- tradein-mvp, despite osm_poi_ekb (this table, Site Finder's own registry) having more.
|
||||
--
|
||||
-- Root cause: this view's WHERE clause dropped any POI whose OSM `last_osm_edit_date` is
|
||||
-- older than 2 years. A subway station node, once correctly mapped, is essentially never
|
||||
-- re-edited in OSM — "stale last edit" here means "nobody touched this tag in years",
|
||||
-- NOT "this station stopped existing". The same logic applies to schools/hospitals/parks:
|
||||
-- physically permanent infrastructure that simply isn't re-edited often.
|
||||
--
|
||||
-- The "2-year freshness" requirement itself (see 82_osm_poi_ekb.sql, "требование
|
||||
-- Максима") was intended as a SOFT confidence signal, not a hard existence filter — Site
|
||||
-- Finder itself (backend/app/api/v1/parcels.py, "POI freshness" confidence subscore) only
|
||||
-- uses last_osm_edit_date to DERATE a confidence score; every POI stays in the result set
|
||||
-- regardless of staleness. This view diverged into a hard filter when the FDW bridge was
|
||||
-- built (185) — this migration fixes that divergence, matching Site Finder's own intent.
|
||||
--
|
||||
-- WHAT: CREATE OR REPLACE VIEW, same 4-column shape as 185 (category, name, lat, lon) — no
|
||||
-- WHERE clause. tradein-mvp's FDW foreign table (tradein-mvp/backend/data/sql/
|
||||
-- 168_fdw_osm_poi_ekb.sql) is untouched — same column list, so no FDW-side change needed.
|
||||
--
|
||||
-- Idempotent: CREATE OR REPLACE VIEW. GRANT re-applied (idempotent, matches 185).
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE VIEW v_tradein_osm_poi_ekb AS
|
||||
SELECT
|
||||
category,
|
||||
name,
|
||||
lat,
|
||||
lon
|
||||
FROM osm_poi_ekb;
|
||||
|
||||
GRANT SELECT ON v_tradein_osm_poi_ekb TO tradein_fdw_reader;
|
||||
|
||||
COMMENT ON VIEW v_tradein_osm_poi_ekb IS
|
||||
'FDW source for tradein-mvp (postgres_fdw) location-index/nearby-POI list (replaces the '
|
||||
'broken location-coef, #2045). No freshness filter — last_osm_edit_date is a soft '
|
||||
'confidence signal only (see Site Finder parcels.py), not evidence a POI stopped '
|
||||
'existing. Fixes 185_tradein_osm_poi_view.sql hard 2-year WHERE filter that silently '
|
||||
'dropped 4/9 EKB metro stations + other stable infrastructure from the trade-in mirror.';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -40,3 +40,14 @@ DADATA_API_SECRET=
|
|||
POSTGRES_USER=tradein
|
||||
POSTGRES_PASSWORD=tradein
|
||||
POSTGRES_DB=tradein
|
||||
|
||||
# === Telegram support-bot bridge (tgbot service, docker-compose.prod.yml) ===
|
||||
# Long-polling worker: пересылает support-обращения в Telegram-топик. Не FastAPI,
|
||||
# отдельный процесс (app/tgbot_main.py), env читается из backend/.env.runtime на VPS.
|
||||
#
|
||||
# BotFather token. Пусто = бот не стартует (выключен).
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
# ID супергруппы-форума с включёнными топиками (вида -100XXXXXXXXXX).
|
||||
TELEGRAM_SUPPORT_CHAT_ID=
|
||||
# ID топика (thread) внутри супергруппы, куда падают support-сообщения.
|
||||
TELEGRAM_SUPPORT_TOPIC_ID=
|
||||
|
|
|
|||
|
|
@ -89,10 +89,47 @@ GLITCHTIP_DSN=<dsn или пусто>
|
|||
# Регистрация ключей: https://dadata.ru/api/clean/
|
||||
DADATA_API_TOKEN=<token или пусто>
|
||||
DADATA_API_SECRET=<secret или пусто>
|
||||
|
||||
# Telegram support-bot bridge (сервис tgbot, docker-compose.prod.yml).
|
||||
# Long-polling воркер (app/tgbot_main.py), тот же образ что backend/scraper,
|
||||
# отдельный контейнер tradein-tgbot. Пусто TELEGRAM_BOT_TOKEN = бот НЕ падает
|
||||
# и НЕ рестарт-лупится — процесс стартует, уходит в idle-блокировку и просто
|
||||
# висит (это норма для окружений без токена, не сбой; см. tgbot_main.py).
|
||||
#
|
||||
# 1. TELEGRAM_BOT_TOKEN — токен от @BotFather (/newbot). Пусто = бот выключен
|
||||
# (idle, не polling).
|
||||
TELEGRAM_BOT_TOKEN=<token или пусто>
|
||||
# 2. TELEGRAM_SUPPORT_CHAT_ID — id супергруппы-форума (Topics включены в
|
||||
# настройках группы), вида -100XXXXXXXXXX. Получить: добавить бота в группу,
|
||||
# отправить любое сообщение в любой топик, дернуть
|
||||
# https://api.telegram.org/bot<token>/getUpdates — в ответе
|
||||
# message.chat.id (для супергруппы всегда отрицательный, начинается с -100).
|
||||
TELEGRAM_SUPPORT_CHAT_ID=<-100... или пусто>
|
||||
# 3. TELEGRAM_SUPPORT_TOPIC_ID — id конкретного топика (thread) внутри группы,
|
||||
# куда падают support-обращения. Открыть нужный топик в Telegram Desktop/Web →
|
||||
# в URL топика (t.me/c/<chat>/<topic_id>) последнее число — это topic_id.
|
||||
# Либо взять message_thread_id из того же getUpdates-ответа (п.2), отправив
|
||||
# тестовое сообщение именно в целевой топик.
|
||||
TELEGRAM_SUPPORT_TOPIC_ID=<topic_id или пусто>
|
||||
```
|
||||
|
||||
Оба файла создаются вручную при первом деплое.
|
||||
|
||||
### Деплой / рестарт `tgbot`
|
||||
|
||||
```bash
|
||||
# .env.runtime читается на старте container — `compose restart` НЕ перечитывает.
|
||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml \
|
||||
up -d --force-recreate --no-deps tgbot
|
||||
```
|
||||
|
||||
`restart: unless-stopped` + `stop_grace_period: 120s` в compose (см. `docker-compose.prod.yml`)
|
||||
— автозапуск после ребута VPS гарантирован (`on-failure` сюда не годится: код выхода
|
||||
контейнера при ребуте — гонка с long-poll таймаутом 30с, `unless-stopped`/`always`
|
||||
не зависят от exit-кода). 120s grace даёт time докрутить long-poll + отработать
|
||||
кооперативный drain (`_DRAIN_TIMEOUT_S=100s` в `tgbot_main.py`) до docker SIGKILL —
|
||||
паттерн скопирован с `scraper` (см. комментарий там же).
|
||||
|
||||
### После изменения `backend/.env.runtime`
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -2394,9 +2394,14 @@ async def rotate_proxy_ip(
|
|||
data = resp.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
except Exception as exc:
|
||||
except Exception:
|
||||
# НЕ отдавать str(exc) клиенту (аудит-фикс, #security-audit): httpx-исключения
|
||||
# несут полный request URL, а rotate_url — mobileproxy changeip-ссылка с API-
|
||||
# ключом провайдера в query-string (?...&proxy_key=...). str(exc) с этим URL в
|
||||
# HTTP-ответе — прямая утечка секрета вызывающему клиенту. Причина сбоя остаётся
|
||||
# в логах (exc_info=True) для диагностики; наружу — только нейтральный reason.
|
||||
logger.warning("rotate-ip: changeip failed source=%s", source, exc_info=True)
|
||||
return RotateIpResponse(ok=False, reason=f"changeip error: {exc}")
|
||||
return RotateIpResponse(ok=False, reason="changeip request failed")
|
||||
|
||||
# changeip отдаёт новый IP в одном из полей (формат провайдер-зависимый).
|
||||
new_ip = None
|
||||
|
|
|
|||
280
tradein-mvp/backend/app/api/v1/audit.py
Normal file
280
tradein-mvp/backend/app/api/v1/audit.py
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
"""Admin read API над `user_events` — Feature 2 (login/IP audit) + Feature 3 (behavior
|
||||
analytics dashboard data).
|
||||
|
||||
Read-only: только SELECT, никаких мутаций. Auth не нужен в этом файле — вся ветка
|
||||
`/api/v1/admin/*` уже гейтится `rbac_guard` middleware в app/main.py
|
||||
(`_ADMIN_API_RE`, role != admin → 403).
|
||||
|
||||
`user_events` (migration `184_user_events.sql`) может быть пустой (feature только
|
||||
что задеплоена) — все запросы ниже написаны так, чтобы на пустой таблице отдавать
|
||||
пустые списки/нулевые счётчики, а не падать.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.schemas.audit import (
|
||||
AccountActivityEntry,
|
||||
AccountDeviceEntry,
|
||||
AccountDrilldown,
|
||||
AccountIpEntry,
|
||||
AccountSearchEntry,
|
||||
AccountSummary,
|
||||
AnalyticsByAccount,
|
||||
AnalyticsDailyPoint,
|
||||
AnalyticsDashboard,
|
||||
AnalyticsSummary,
|
||||
AnalyticsTopPath,
|
||||
AnalyticsTopSearch,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/audit/accounts", response_model=list[AccountSummary])
|
||||
async def list_accounts(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> list[AccountSummary]:
|
||||
"""Список аккаунтов, по одной строке на username, с базовой сводкой активности."""
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT username,
|
||||
min(created_at) AS first_seen_at,
|
||||
max(created_at) AS last_seen_at,
|
||||
count(DISTINCT ip_address) AS distinct_ips,
|
||||
count(DISTINCT user_agent) AS distinct_devices,
|
||||
count(*) FILTER (WHERE event_type = 'login') AS login_count,
|
||||
count(*) FILTER (WHERE event_type = 'api_request') AS request_count,
|
||||
count(*) FILTER (WHERE event_type = 'estimate_request') AS search_count
|
||||
FROM user_events
|
||||
GROUP BY username
|
||||
ORDER BY last_seen_at DESC
|
||||
"""
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
return [AccountSummary.model_validate(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/audit/accounts/{username}", response_model=AccountDrilldown)
|
||||
async def account_drilldown(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
username: Annotated[str, Path(min_length=1, max_length=200)],
|
||||
) -> AccountDrilldown:
|
||||
"""Drilldown по одному username: IP-адреса, устройства, поиски, недавняя активность.
|
||||
|
||||
Неизвестный username — НЕ 404, а пустой отчёт (все 4 списка == []): аудит не
|
||||
подтверждает/опровергает существование аккаунта, просто нет событий.
|
||||
"""
|
||||
params = {"username": username}
|
||||
|
||||
ips = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT CAST(ip_address AS text) AS ip_address,
|
||||
count(*) AS event_count,
|
||||
min(created_at) AS first_seen,
|
||||
max(created_at) AS last_seen
|
||||
FROM user_events
|
||||
WHERE username = :username AND ip_address IS NOT NULL
|
||||
GROUP BY ip_address
|
||||
ORDER BY last_seen DESC
|
||||
"""
|
||||
),
|
||||
params,
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
devices = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT user_agent,
|
||||
count(*) AS event_count,
|
||||
min(created_at) AS first_seen,
|
||||
max(created_at) AS last_seen
|
||||
FROM user_events
|
||||
WHERE username = :username AND user_agent IS NOT NULL
|
||||
GROUP BY user_agent
|
||||
ORDER BY last_seen DESC
|
||||
"""
|
||||
),
|
||||
params,
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
searches = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT payload ->> 'address' AS address,
|
||||
payload ->> 'area_m2' AS area_m2,
|
||||
payload ->> 'rooms' AS rooms,
|
||||
CAST(estimate_id AS text) AS estimate_id,
|
||||
CAST(ip_address AS text) AS ip_address,
|
||||
created_at
|
||||
FROM user_events
|
||||
WHERE username = :username AND event_type = 'estimate_request'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 200
|
||||
"""
|
||||
),
|
||||
params,
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
recent_activity = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT event_type,
|
||||
path,
|
||||
method,
|
||||
CAST(ip_address AS text) AS ip_address,
|
||||
created_at
|
||||
FROM user_events
|
||||
WHERE username = :username
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 200
|
||||
"""
|
||||
),
|
||||
params,
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
return AccountDrilldown(
|
||||
ips=[AccountIpEntry.model_validate(r) for r in ips],
|
||||
devices=[AccountDeviceEntry.model_validate(r) for r in devices],
|
||||
searches=[AccountSearchEntry.model_validate(r) for r in searches],
|
||||
recent_activity=[AccountActivityEntry.model_validate(r) for r in recent_activity],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/analytics", response_model=AnalyticsDashboard)
|
||||
async def analytics_dashboard(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
days: Annotated[int, Query(ge=1, le=365)] = 30,
|
||||
) -> AnalyticsDashboard:
|
||||
"""Feature 3 dashboard bundle: сводка, дневной time-series, топ-поиски/пути/аккаунты."""
|
||||
summary_row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT count(*) AS total_events,
|
||||
count(DISTINCT username) AS distinct_users,
|
||||
count(*) FILTER (
|
||||
WHERE created_at >= now() - INTERVAL '24 hours'
|
||||
) AS events_last_24h,
|
||||
count(DISTINCT username) FILTER (
|
||||
WHERE created_at >= now() - INTERVAL '24 hours'
|
||||
) AS active_users_last_24h
|
||||
FROM user_events
|
||||
"""
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.one()
|
||||
)
|
||||
|
||||
daily_rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT date_trunc('day', created_at)::date AS day,
|
||||
count(*) AS events,
|
||||
count(DISTINCT username) AS users
|
||||
FROM user_events
|
||||
WHERE created_at >= now() - make_interval(days => CAST(:days AS int))
|
||||
GROUP BY date_trunc('day', created_at)::date
|
||||
ORDER BY day
|
||||
"""
|
||||
),
|
||||
{"days": days},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
top_searches_rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT payload ->> 'address' AS address, count(*) AS n
|
||||
FROM user_events
|
||||
WHERE event_type = 'estimate_request'
|
||||
AND payload ->> 'address' IS NOT NULL
|
||||
GROUP BY payload ->> 'address'
|
||||
ORDER BY n DESC
|
||||
LIMIT 20
|
||||
"""
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
top_paths_rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT path, count(*) AS n
|
||||
FROM user_events
|
||||
WHERE event_type = 'api_request'
|
||||
GROUP BY path
|
||||
ORDER BY n DESC
|
||||
LIMIT 20
|
||||
"""
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
by_account_rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT username,
|
||||
count(*) AS events,
|
||||
count(*) FILTER (WHERE event_type = 'estimate_request') AS searches,
|
||||
max(created_at) AS last_seen
|
||||
FROM user_events
|
||||
GROUP BY username
|
||||
ORDER BY events DESC
|
||||
LIMIT 50
|
||||
"""
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
return AnalyticsDashboard(
|
||||
summary=AnalyticsSummary.model_validate(summary_row),
|
||||
daily=[AnalyticsDailyPoint.model_validate(r) for r in daily_rows],
|
||||
top_searches=[AnalyticsTopSearch.model_validate(r) for r in top_searches_rows],
|
||||
top_paths=[AnalyticsTopPath.model_validate(r) for r in top_paths_rows],
|
||||
by_account=[AnalyticsByAccount.model_validate(r) for r in by_account_rows],
|
||||
)
|
||||
|
|
@ -56,10 +56,12 @@ async def suggest_addresses(
|
|||
limit: Annotated[int, Query(ge=1, le=15)] = 8,
|
||||
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
|
||||
) -> SuggestResponse:
|
||||
"""Автокомплит адресов в пределах ЕКБ.
|
||||
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
|
||||
остаётся быстрым fast-path).
|
||||
|
||||
Используется в EstimateForm для подсказок пока пользователь печатает.
|
||||
Bounded viewbox = ЕКБ (lon 60.40-60.85, lat 56.65-56.95).
|
||||
Bounded viewbox — генеральный по всей области (см. geocoder.OBLAST66_VIEWBOX),
|
||||
ЕКБ (lon 60.40-60.85, lat 56.65-56.95) внутри него остаётся быстрым fast-path.
|
||||
|
||||
Пример:
|
||||
/api/v1/geocode/suggest?q=Малышева
|
||||
|
|
|
|||
|
|
@ -1,23 +1,35 @@
|
|||
"""Trade-in lead capture endpoint (issue #2376, sub-issue родителя #1971).
|
||||
|
||||
POST /api/v1/trade-in/lead — контактная заявка с результата оценки (или лендинга):
|
||||
POST /api/v1/trade-in/lead — контактная заявка с результата оценки:
|
||||
телефон + явное согласие на обработку персональных данных. Persist в
|
||||
trade_in_leads. Notification (Telegram/email) — вне scope: нет существующей
|
||||
SMTP/Telegram интеграции в коде (подтверждено при разборе issue), только
|
||||
persist + log; `notified_at` в таблице зарезервирован под будущую доставку.
|
||||
|
||||
IDOR-фикс (security-audit): `estimate_id` раньше только проверялся на
|
||||
СУЩЕСТВОВАНИЕ (`SELECT 1 ... WHERE id = ...`), без проверки владельца — любой
|
||||
аутентифицированный пилот мог привязать свою заявку к чужой оценке (утечка через
|
||||
последующий просмотр лида: чужой адрес/телефон/оценка в заявке, которую видит не
|
||||
её владелец). Гвард переиспользует `_assert_estimate_access` из
|
||||
`app.api.v1.trade_in` — тот же owner-or-admin подход, что и `GET /estimate/{id}`
|
||||
(#690, `tests/test_estimate_idor.py`): 401 без `X-Authenticated-User`, 403 —
|
||||
неизвестная роль, 404 — оценка не найдена ИЛИ принадлежит не этому пользователю
|
||||
(существование чужой оценки не подтверждаем).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Annotated, Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.v1.trade_in import _assert_estimate_access
|
||||
from app.core.db import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -28,13 +40,45 @@ router = APIRouter()
|
|||
# скобки/дефисы/точки, 5-32 символа. Полная нормализация в E.164 — вне scope MVP,
|
||||
# см. #2376 DoD ("простая regex, не EmailStr-подобное").
|
||||
_PHONE_PATTERN = r"^[+]?[\d\s().-]{5,32}$"
|
||||
# Маска выше делает цифры ОПЦИОНАЛЬНЫМИ: "(()) -- .." её проходит (0 цифр).
|
||||
# Поэтому дополнительно требуем правдоподобное число реальных цифр. RU-мобильный =
|
||||
# 11 цифр; берём лениентный диапазон 10-15 (нац. номер без/с кодом страны).
|
||||
_PHONE_MIN_DIGITS = 10
|
||||
_PHONE_MAX_DIGITS = 15
|
||||
|
||||
# Версия политики обработки ПДн (152-ФЗ), под которую собрано согласие. Персистится
|
||||
# per-row в trade_in_leads.consent_policy_version (migration 182) — до неё писалась
|
||||
# только в audit-лог (#2497 TODO, теперь закрыт).
|
||||
_CONSENT_POLICY_VERSION = "2026-07"
|
||||
|
||||
# Снимок точного текста согласия, который видит пользователь при отправке лида.
|
||||
# Должен ДОСЛОВНО совпадать с чекбоксом в LeadForm.tsx (frontend/src/components/
|
||||
# trade-in/v2/LeadForm.tsx) — если текст политики меняется, здесь нужно поднять
|
||||
# _CONSENT_POLICY_VERSION И обновить этот снимок в одном PR, иначе новые строки
|
||||
# будут нести устаревший snapshot под новой version-меткой.
|
||||
_CONSENT_TEXT_SNAPSHOT = (
|
||||
"Согласен(-на) на обработку персональных данных в соответствии с "
|
||||
"Федеральным законом «О персональных данных» № 152-ФЗ"
|
||||
)
|
||||
|
||||
|
||||
class TradeInLeadInput(BaseModel):
|
||||
phone: str = Field(min_length=5, max_length=32, pattern=_PHONE_PATTERN)
|
||||
estimate_id: UUID | None = None
|
||||
consent: Literal[True]
|
||||
source: Literal["result", "landing"] = "result"
|
||||
# "landing"-воронка недостижима: /api/v1/trade-in/lead закрыт rbac_guard
|
||||
# (main.py — путь не в _PUBLIC_PATHS => 401 без X-Authenticated-User), а
|
||||
# публичного лендинг-роута нет. Убрали мёртвый литерал, чтобы контракт не
|
||||
# обещал невозможную воронку (#2376). Вернуть, если появится public-роут.
|
||||
source: Literal["result"] = "result"
|
||||
|
||||
@field_validator("phone")
|
||||
@classmethod
|
||||
def _phone_has_enough_digits(cls, value: str) -> str:
|
||||
digits = len(re.sub(r"\D", "", value))
|
||||
if not (_PHONE_MIN_DIGITS <= digits <= _PHONE_MAX_DIGITS):
|
||||
raise ValueError(f"phone must contain {_PHONE_MIN_DIGITS}-{_PHONE_MAX_DIGITS} digits")
|
||||
return value
|
||||
|
||||
|
||||
@router.post("/lead")
|
||||
|
|
@ -42,24 +86,47 @@ async def create_trade_in_lead(
|
|||
payload: TradeInLeadInput,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Сохраняет лид (телефон + согласие) в trade_in_leads."""
|
||||
if payload.estimate_id is not None:
|
||||
exists = db.execute(
|
||||
text("SELECT 1 FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
|
||||
estimate_row = db.execute(
|
||||
text("SELECT created_by FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
|
||||
{"id": str(payload.estimate_id)},
|
||||
).fetchone()
|
||||
if exists is None:
|
||||
if estimate_row is None:
|
||||
raise HTTPException(status_code=404, detail="estimate not found")
|
||||
# IDOR guard (security-audit, зеркалит #690): нельзя привязать лид к
|
||||
# чужой оценке. 404 и на "не найдено", и на "чужая" — не подтверждаем
|
||||
# существование чужого estimate_id.
|
||||
_assert_estimate_access(estimate_row.created_by, x_authenticated_user)
|
||||
|
||||
user_agent = request.headers.get("user-agent")
|
||||
# 152-ФЗ audit trail: реальный клиентский IP из X-Forwarded-For (его ставит
|
||||
# фронтящий Caddy), fallback — прямой peer. Персистится per-row в
|
||||
# trade_in_leads.client_ip (migration 182), а не только в лог.
|
||||
client_ip = request.headers.get("x-forwarded-for")
|
||||
if client_ip:
|
||||
client_ip = client_ip.split(",")[0].strip()
|
||||
elif request.client is not None:
|
||||
client_ip = request.client.host
|
||||
|
||||
# 152-ФЗ proof-of-consent: client_ip / consent_policy_version /
|
||||
# consent_text_snapshot теперь durable-колонки на trade_in_leads (migration 182,
|
||||
# ранее — только audit-лог, #2497 TODO). client_ip может быть None (нет
|
||||
# X-Forwarded-For и request.client) — колонка nullable, CAST(NULL AS inet) валиден.
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO trade_in_leads (estimate_id, phone, consent, source, user_agent)
|
||||
VALUES (CAST(:estimate_id AS uuid), :phone, :consent, :source, :user_agent)
|
||||
INSERT INTO trade_in_leads (
|
||||
estimate_id, phone, consent, source, user_agent,
|
||||
client_ip, consent_policy_version, consent_text_snapshot
|
||||
)
|
||||
VALUES (
|
||||
CAST(:estimate_id AS uuid), :phone, :consent, :source, :user_agent,
|
||||
CAST(:client_ip AS inet), :consent_policy_version, :consent_text_snapshot
|
||||
)
|
||||
RETURNING CAST(id AS text), created_at
|
||||
"""
|
||||
),
|
||||
|
|
@ -69,6 +136,9 @@ async def create_trade_in_lead(
|
|||
"consent": payload.consent,
|
||||
"source": payload.source,
|
||||
"user_agent": user_agent,
|
||||
"client_ip": client_ip,
|
||||
"consent_policy_version": _CONSENT_POLICY_VERSION,
|
||||
"consent_text_snapshot": _CONSENT_TEXT_SNAPSHOT,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
|
|
@ -78,10 +148,12 @@ async def create_trade_in_lead(
|
|||
db.commit()
|
||||
|
||||
logger.info(
|
||||
"trade_in_lead saved id=%s estimate_id=%s source=%s",
|
||||
"trade_in_lead saved id=%s estimate_id=%s source=%s ip=%s policy=%s",
|
||||
row["id"],
|
||||
payload.estimate_id,
|
||||
payload.source,
|
||||
client_ip,
|
||||
_CONSENT_POLICY_VERSION,
|
||||
)
|
||||
|
||||
return {
|
||||
|
|
|
|||
264
tradein-mvp/backend/app/api/v1/support.py
Normal file
264
tradein-mvp/backend/app/api/v1/support.py
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
"""Веб-чат поддержки (#tgsupport-web) — поверх уже существующего Telegram
|
||||
support-моста (`app.services.tgbot.bridge`, data/sql/186_tg_support.sql).
|
||||
|
||||
Источник обращения — сайт (не Telegram-личка клиента): пользователь пишет через
|
||||
это API, сообщение зеркалится `sendMessage`-ом в тот же support-топик, оператор
|
||||
отвечает РЕПЛАЕМ ровно так же, как на Telegram-клиента — маршрутизация ответа
|
||||
обратно реализована в `bridge._handle_group_reply` (ветка добавлена там же, без
|
||||
изменения существующего Telegram-пути).
|
||||
|
||||
Изоляция тредов: все 4 ручки резолвят тред ИСКЛЮЧИТЕЛЬНО по `X-Authenticated-User`
|
||||
(rbac_guard в app/main.py гарантирует его наличие и валидность для non-public
|
||||
путей). thread_id НИКОГДА не принимается снаружи (ни в query, ни в body) — чужой
|
||||
тред прочитать/отметить нельзя ни при каких параметрах запроса, потому что
|
||||
параметра, которым можно было бы адресовать чужой тред, попросту не существует.
|
||||
|
||||
Копия зеркала в топике всегда помечена "[С САЙТА] <username>: ..." — оператор
|
||||
не должен путать веб-обращение с Telegram-клиентом (#tgsupport-web AC).
|
||||
|
||||
КРИТИЧНО (review H1) — порядок операций в `send_support_message`:
|
||||
БД-запись (`get_or_create_thread`) идёт ПОСЛЕ успешного `send_message`, не до.
|
||||
Прод — один uvicorn-процесс БЕЗ `--workers` (docker-compose.prod.yml) с
|
||||
синхронным SQLAlchemy engine (пул 5+10 overflow) на ОДНОМ event loop. Если бы
|
||||
`INSERT ... ON CONFLICT DO UPDATE` уходил ДО Telegram-вызова, строка/row-lock
|
||||
держались бы всё время, пока `send_message` ждёт Telegram (секунды-минуты при
|
||||
429/5xx на воркерных ретраях) — второй параллельный запрос ТОГО ЖЕ юзера
|
||||
(двойной клик, вторая вкладка) упёрся бы в этот lock ВНУТРИ синхронного
|
||||
psycopg-вызова внутри `async def`, останавливая event loop целиком (весь API
|
||||
встаёт, не только этот эндпоинт). `_format_mirror_text` использует только
|
||||
`username` — thread_id для отправки не нужен вообще, поэтому эту БД-операцию
|
||||
можно безопасно отложить до после успешного sendMessage. Бонус: неудачная
|
||||
отправка больше не создаёт тред.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.core.ratelimit import SlidingWindowLimiter
|
||||
from app.services.tgbot import web_support_storage as storage
|
||||
from app.services.tgbot.bridge import SERVICE_UNAVAILABLE_TEXT
|
||||
from app.services.tgbot.client import TelegramApiError, TelegramClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Лимит Telegram sendMessage (4096) с запасом — см. #tgsupport-web AC ("~4000").
|
||||
MAX_MESSAGE_LENGTH = 4000
|
||||
|
||||
# Жёстче общего RateLimitMiddleware (300 req/60с на пользователя, app/main.py):
|
||||
# бот-токен общий на ВСЕХ клиентов веб-чата, флуд одного клиента иначе может
|
||||
# упереться в Telegram-лимиты (`sendMessage` 429, лимит группы ~20 msg/min) и
|
||||
# застопорить доставку всем остальным (см. задачу, п.6). 12 сообщений/минуту —
|
||||
# щедро для живого диалога, но режет скрипт-флуд на порядок раньше общего API-лимита.
|
||||
_SEND_RATE_LIMIT = 12
|
||||
_SEND_RATE_WINDOW_S = 60.0
|
||||
_send_limiter = SlidingWindowLimiter(limit=_SEND_RATE_LIMIT, window_s=_SEND_RATE_WINDOW_S)
|
||||
|
||||
# #tgsupport-web review H1: интерактивный HTTP-запрос НЕ МОЖЕТ наследовать
|
||||
# воркерную политику ретраев `TelegramClient` (по умолчанию — до 5 попыток, на
|
||||
# 429 спит `retry_after` Telegram'а — для группы штатно 30-60с, на 5xx backoff до
|
||||
# 30с — легальный суммарный бюджет минуты). Узкий бюджет здесь: 1 повтор, короткий
|
||||
# timeout — интерактивный клиент должен получить ответ (даже если это ошибка)
|
||||
# за секунды, а не висеть до исчерпания воркерных ретраев.
|
||||
_INTERACTIVE_SEND_TIMEOUT_S = 10.0
|
||||
_INTERACTIVE_SEND_MAX_RETRIES = 1
|
||||
|
||||
# #tgsupport-web review M5: без LIMIT каждое монтирование виджета на старом
|
||||
# треде отдавало бы ВЕСЬ лог переписки. См. `web_support_storage.list_messages`.
|
||||
_LIST_MESSAGES_LIMIT = 200
|
||||
|
||||
|
||||
def _require_username(request: Request) -> str:
|
||||
"""Достаёт X-Authenticated-User. rbac_guard (app/main.py) уже гарантирует его
|
||||
наличие в проде для non-public путей — этот guard здесь defence-in-depth и
|
||||
делает роутер тестируемым без поднятия всего app.main (см. tests/test_support.py,
|
||||
как test_trade_in_lead.py для /lead). `.strip()` (review L4) — лишний пробел
|
||||
от прокси иначе завёл бы ВТОРОЙ тред на, по сути, того же пользователя
|
||||
(username — UNIQUE ключ треда, "alice" != "alice ")."""
|
||||
username = (request.headers.get("x-authenticated-user") or "").strip()
|
||||
if not username:
|
||||
raise HTTPException(status_code=401, detail="no authenticated user")
|
||||
return username
|
||||
|
||||
|
||||
def _bot_configured() -> bool:
|
||||
"""TELEGRAM_BOT_TOKEN и TELEGRAM_SUPPORT_CHAT_ID оба обязательны — без них
|
||||
зеркалировать в топик некуда (см. app/tgbot_main.py._should_run для токена,
|
||||
bridge.py для chat_id)."""
|
||||
return bool(settings.telegram_bot_token) and bool(settings.telegram_support_chat_id)
|
||||
|
||||
|
||||
class SupportMessageInput(BaseModel):
|
||||
text: str = Field(min_length=1, max_length=MAX_MESSAGE_LENGTH)
|
||||
|
||||
@field_validator("text")
|
||||
@classmethod
|
||||
def _not_blank(cls, value: str) -> str:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("text must not be blank")
|
||||
return stripped
|
||||
|
||||
|
||||
class SupportMessageOut(BaseModel):
|
||||
id: int
|
||||
direction: Literal["in", "out"]
|
||||
text_body: str
|
||||
operator_tg_id: int | None = None
|
||||
created_at: str
|
||||
|
||||
@field_validator("created_at", mode="before")
|
||||
@classmethod
|
||||
def _isoformat(cls, value: object) -> str:
|
||||
if hasattr(value, "isoformat"):
|
||||
return value.isoformat() # type: ignore[no-any-return]
|
||||
return str(value)
|
||||
|
||||
|
||||
class UnreadOut(BaseModel):
|
||||
unread: int
|
||||
|
||||
|
||||
class StatusOut(BaseModel):
|
||||
status: Literal["ok"] = "ok"
|
||||
|
||||
|
||||
def _format_mirror_text(username: str, message_text: str) -> str:
|
||||
"""Помечает зеркало как пришедшее С САЙТА, от какого пользователя — оператор
|
||||
иначе не отличит веб-обращение от Telegram-клиента (#tgsupport-web AC).
|
||||
Использует ТОЛЬКО username — thread_id здесь не нужен (см. H1 в docstring
|
||||
модуля), это то, что делает возможным отложить БД-запись до после отправки."""
|
||||
return f"[С САЙТА] {username}:\n{message_text}"
|
||||
|
||||
|
||||
@router.post("/support/messages", response_model=SupportMessageOut)
|
||||
async def send_support_message(
|
||||
payload: SupportMessageInput,
|
||||
username: Annotated[str, Depends(_require_username)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> SupportMessageOut:
|
||||
"""Отправляет сообщение от лица *username* в support-топик (`sendMessage` —
|
||||
не `copyMessage`: у веб-сообщения нет исходного Telegram-сообщения для копии).
|
||||
|
||||
Порядок операций см. H1 в docstring модуля: rate-limit проверяется (но НЕ
|
||||
расходуется, review L3) до отправки, thread создаётся ТОЛЬКО после успешного
|
||||
`send_message` — до этого момента с БД не происходит ничего.
|
||||
"""
|
||||
if not _bot_configured():
|
||||
# Предсказуемое поведение вместо 500 (#tgsupport-web AC): бот не настроен
|
||||
# (пустой TELEGRAM_BOT_TOKEN, dev/staging) или support-топик не задан —
|
||||
# мирроринг невозможен физически, ничего не пишем в БД.
|
||||
raise HTTPException(status_code=503, detail=SERVICE_UNAVAILABLE_TEXT)
|
||||
|
||||
# review L3: peek без расхода бюджета — неудачная отправка НЕ должна стоить
|
||||
# пользователю попытки (расходуем `.record()` только на успех, ниже).
|
||||
retry_after = _send_limiter.retry_after(username)
|
||||
if retry_after is not None:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много сообщений. Попробуйте через минуту.",
|
||||
headers={"Retry-After": str(int(retry_after) + 1)},
|
||||
)
|
||||
|
||||
client = TelegramClient(settings.telegram_bot_token)
|
||||
try:
|
||||
mirrored = await client.send_message(
|
||||
chat_id=settings.telegram_support_chat_id,
|
||||
text=_format_mirror_text(username, payload.text),
|
||||
message_thread_id=settings.telegram_support_topic_id or None,
|
||||
# review H1: узкий интерактивный бюджет — НЕ воркерные 5 ретраев/минуты.
|
||||
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
|
||||
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
||||
)
|
||||
except TelegramApiError:
|
||||
# НЕ логируем payload.text (переписка — ПДн) и НЕ логируем токен (его в
|
||||
# TelegramApiError и не бывает — см. client.py docstring про redaction).
|
||||
logger.exception(
|
||||
"web support: не удалось отправить зеркало в топик (username=%s)", username
|
||||
)
|
||||
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
|
||||
|
||||
# Отправка удалась — теперь и только теперь расходуем rate-limit бюджет.
|
||||
_send_limiter.record(username)
|
||||
|
||||
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
|
||||
if topic_message_id is None:
|
||||
# review L1: без topic_message_id реплай оператора на это сообщение
|
||||
# НИКОГДА не смаршрутизируется обратно (find_thread_by_topic_message ищет
|
||||
# именно по этому полю) — тихая, но зафиксированная в логе деградация.
|
||||
logger.warning(
|
||||
"web support: Telegram sendMessage не вернул message_id (username=%s) — "
|
||||
"ответ оператора на это сообщение не будет смаршрутизирован",
|
||||
username,
|
||||
)
|
||||
|
||||
# review H1: БД-операция ПОСЛЕ успешной отправки — см. docstring модуля.
|
||||
thread_id = storage.get_or_create_thread(db, username)
|
||||
row = storage.record_inbound(
|
||||
db,
|
||||
thread_id=thread_id,
|
||||
text_body=payload.text,
|
||||
topic_message_id=topic_message_id,
|
||||
support_chat_id=settings.telegram_support_chat_id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
logger.info("web support: message sent username=%s thread_id=%d", username, thread_id)
|
||||
return SupportMessageOut(**row)
|
||||
|
||||
|
||||
@router.get("/support/messages", response_model=list[SupportMessageOut])
|
||||
def list_support_messages(
|
||||
username: Annotated[str, Depends(_require_username)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
since: Annotated[int, Query(ge=0)] = 0,
|
||||
) -> list[SupportMessageOut]:
|
||||
"""Сообщения СВОЕГО треда с id > since. Тред резолвится по username — чужой
|
||||
тред недостижим (нет параметра, которым его можно адресовать).
|
||||
|
||||
Обычный (sync) `def`, не `async def` (review M3): тело — только синхронные
|
||||
psycopg-вызовы, ни одного `await`; как `async def` это исполнялось бы прямо в
|
||||
event loop (а фронт поллит эту ручку постоянно). Starlette гонит sync-handlers
|
||||
в threadpool автоматически — тот же паттерн, что `trade_in.py:get_estimate`.
|
||||
"""
|
||||
thread_id = storage.find_thread_id(db, username)
|
||||
if thread_id is None:
|
||||
return []
|
||||
rows = storage.list_messages(
|
||||
db, thread_id=thread_id, since_id=since, limit=_LIST_MESSAGES_LIMIT
|
||||
)
|
||||
return [SupportMessageOut(**r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/support/unread", response_model=UnreadOut)
|
||||
def get_support_unread(
|
||||
username: Annotated[str, Depends(_require_username)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> UnreadOut:
|
||||
"""Sync `def` (review M3) — см. `list_support_messages`."""
|
||||
thread_id = storage.find_thread_id(db, username)
|
||||
if thread_id is None:
|
||||
return UnreadOut(unread=0)
|
||||
return UnreadOut(unread=storage.count_unread(db, thread_id=thread_id))
|
||||
|
||||
|
||||
@router.post("/support/read", response_model=StatusOut)
|
||||
def mark_support_read(
|
||||
username: Annotated[str, Depends(_require_username)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> StatusOut:
|
||||
"""Sync `def` (review M3) — см. `list_support_messages`."""
|
||||
thread_id = storage.find_thread_id(db, username)
|
||||
if thread_id is not None:
|
||||
storage.mark_read(db, thread_id=thread_id)
|
||||
db.commit()
|
||||
return StatusOut()
|
||||
|
|
@ -11,12 +11,13 @@ from datetime import UTC, date, datetime, timedelta
|
|||
from typing import Annotated, Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Header, HTTPException, Response, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, Header, HTTPException, Request, Response, UploadFile
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.core.ratelimit import _client_ip
|
||||
from app.schemas.trade_in import (
|
||||
AggregatedEstimate,
|
||||
AnalogLot,
|
||||
|
|
@ -27,8 +28,8 @@ from app.schemas.trade_in import (
|
|||
HouseAnalyticsResponse,
|
||||
HouseInfoForEstimate,
|
||||
IMVBenchmarkResponse,
|
||||
LocationCoefFactorOut,
|
||||
LocationCoefResponse,
|
||||
LocationIndexResponse,
|
||||
NearbyPoiOut,
|
||||
PhotoMeta,
|
||||
PlacementHistoryEntry,
|
||||
PriceHistoryYearPoint,
|
||||
|
|
@ -45,6 +46,7 @@ from app.schemas.trade_in import (
|
|||
from app.services import account_quota
|
||||
from app.services.exporters.trade_in_pdf import generate_trade_in_pdf
|
||||
from app.services.image_sanitizer import ImageSanitizationError, sanitize_image
|
||||
from app.services.user_events import schedule_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -147,6 +149,7 @@ def _resolve_target_house_id(
|
|||
@router.post("/estimate", response_model=AggregatedEstimate)
|
||||
async def estimate(
|
||||
payload: TradeInEstimateInput,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||
) -> AggregatedEstimate:
|
||||
|
|
@ -180,9 +183,29 @@ async def estimate(
|
|||
) from None
|
||||
# #747: атомарно-условный инкремент — источник истины по лимиту. check_and_raise
|
||||
# выше остаётся быстрым pre-check (429 до дорогой оценки), но финальное решение
|
||||
# тут: при гонке двух /estimate на used=MONTHLY_LIMIT-1 второй получит False.
|
||||
if not account_quota.increment(db, x_authenticated_user):
|
||||
# тут: при гонке двух /estimate на used=lim-1 второй получит False.
|
||||
# Не списываем квоту за пустой результат (нерезолвящийся адрес и т.п.) — иначе
|
||||
# платный слот сгорает за insufficient_data=True (median=0, n_analogs=0) с HTTP 200.
|
||||
if not result.insufficient_data and not account_quota.increment(db, x_authenticated_user):
|
||||
raise HTTPException(status_code=429, detail=account_quota.LIMIT_EXHAUSTED_MESSAGE)
|
||||
|
||||
# Feature 2/3 foundation: "что искали" — обогащённая estimate_request-запись
|
||||
# в user_events (адрес/площадь/комнаты + estimate_id для join с trade_in_estimates).
|
||||
# schedule_event сам никогда не raises — сбой аудита не должен ронять ответ.
|
||||
schedule_event(
|
||||
event_type="estimate_request",
|
||||
username=x_authenticated_user or "",
|
||||
ip=_client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
path=str(request.url.path),
|
||||
method="POST",
|
||||
estimate_id=str(result.estimate_id),
|
||||
payload={
|
||||
"address": payload.address,
|
||||
"area_m2": payload.area_m2,
|
||||
"rooms": payload.rooms,
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
|
|
@ -244,6 +267,7 @@ def get_estimate(
|
|||
_fetch_house_imv_anchor,
|
||||
_fetch_price_trend,
|
||||
_qc_geo_to_precision,
|
||||
_resolve_target_city,
|
||||
_source_counts,
|
||||
)
|
||||
|
||||
|
|
@ -283,7 +307,16 @@ def get_estimate(
|
|||
else None
|
||||
)
|
||||
|
||||
dkp_raw = _fetch_dkp_corridor(db, address=row.address, rooms=row.rooms, area=area_f)
|
||||
# (oblast C2): city-scope корридора на GET-rehydrate. NB: row.address здесь =
|
||||
# geo.full_address (персистится на estimate-time), который РОНЯЕТ город для
|
||||
# не-ЕКБ («Ленина, 1») → target_city=None → corridor unscoped/None для не-ЕКБ
|
||||
# (KNOWN LIMITATION: POST-path чинит через payload.address; GET получит паритет
|
||||
# когда raw payload.address начнёт персиститься — follow-up). Для ЕКБ ок; reorder
|
||||
# ниже безвреден (оба source city-stripped для не-ЕКБ, оба екб для ЕКБ).
|
||||
target_city = _resolve_target_city(row.address) or _resolve_target_city(row.canonical_address)
|
||||
dkp_raw = _fetch_dkp_corridor(
|
||||
db, address=row.address, rooms=row.rooms, area=area_f, city=target_city
|
||||
)
|
||||
dkp_corridor = DkpCorridor(**dkp_raw) if dkp_raw else None
|
||||
|
||||
imv_raw = _fetch_house_imv_anchor(
|
||||
|
|
@ -1534,39 +1567,43 @@ def get_estimate_imv_benchmark(
|
|||
)
|
||||
|
||||
|
||||
# ── Location-coef POI scoring (#2045 BE-3, LocationDrawer) ────────────────────
|
||||
# ── Location index (issue TBD, замена сломанного location-coef #2045) ────────
|
||||
|
||||
|
||||
@router.get("/location-coef", response_model=LocationCoefResponse)
|
||||
def get_location_coef(
|
||||
@router.get("/location-index", response_model=LocationIndexResponse)
|
||||
def get_location_index(
|
||||
estimate_id: UUID,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||
radius_m: int | None = None,
|
||||
) -> LocationCoefResponse:
|
||||
"""Location-coefficient POI-скоринг для оценки (#2045 BE-3, LocationDrawer).
|
||||
) -> LocationIndexResponse:
|
||||
"""Location index для оценки (замена сломанного location-coef, LocationDrawer).
|
||||
|
||||
Резолвит lat/lon/median_price оценки, считает coef через
|
||||
app.services.location_coef.compute_location_coef — straight-line POI weighted score
|
||||
(портировано из Site Finder poi_score.py) поверх локального зеркала osm_poi_ekb_local,
|
||||
обновляемого scheduler'ом (source=osm_poi_ekb_refresh). result_price_rub = round(
|
||||
base_price_rub * coef).
|
||||
Резолвит lat/lon оценки, считает индекс через
|
||||
app.services.location_index.compute_location_index: % отклонения медианы ₽/м²
|
||||
сопоставимых активных листингов в радиусе точки от медианы ₽/м² по всему Екатеринбургу
|
||||
(percentile_cont(0.5) — устойчиво к выбросам). НЕ участвует в цене — estimator.py про
|
||||
этот показатель не знает (аналоги уже несут локацию в базовой цене).
|
||||
|
||||
404 — оценки нет / IDOR (тот же _assert_estimate_access_by_id, что и у соседних
|
||||
derived-роутов). radius_m опционален (None → DEFAULT_RADIUS_M=1200м, подобран для
|
||||
МКД, НЕ Ptica-дефолт 2000м для участков); явное значение клэмпится в [500, 3000].
|
||||
derived-роутов). radius_m опционален (None → адаптивная лестница радиусов
|
||||
RADIUS_LADDER_M, расширяется пока выборка не наберёт MIN_SAMPLE_SIZE); явное значение
|
||||
клэмпится в [500, 3000] и используется РОВНО как задано (без расширения).
|
||||
|
||||
Graceful fallback (НЕ 500, НЕ сфабрикованные факторы):
|
||||
- osm_poi_ekb_local пуста/не отрефрешена на этом окружении → coef=1.0, factors=[],
|
||||
geo_source="unavailable" (см. compute_location_coef).
|
||||
- у оценки нет lat/lon (легаси/geo-fallback не сработал на POST) → тот же fallback.
|
||||
Честная деградация (НЕ 500, НЕ сфабрикованные значения) — см. LocationIndexResponse:
|
||||
- status="out_of_coverage" — у оценки нет lat/lon, ИЛИ точка вне гео-охвата продукта
|
||||
(Екатеринбург).
|
||||
- status="insufficient_data" — даже на максимальном радиусе сопоставимых активных
|
||||
листингов меньше порога.
|
||||
- poi_status="unavailable" — osm_poi_ekb_local пуста/не отрефрешена (независимо от
|
||||
status выше — «что рядом» и числовой индекс деградируют раздельно).
|
||||
"""
|
||||
_assert_estimate_access_by_id(db, estimate_id, x_authenticated_user)
|
||||
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT lat, lon, median_price
|
||||
SELECT lat, lon
|
||||
FROM trade_in_estimates
|
||||
WHERE id = CAST(:id AS uuid)
|
||||
"""
|
||||
|
|
@ -1576,34 +1613,38 @@ def get_location_coef(
|
|||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="estimate not found")
|
||||
|
||||
base_price_rub = int(row.median_price or 0)
|
||||
|
||||
if row.lat is None or row.lon is None:
|
||||
logger.info("location_coef: estimate=%s has no lat/lon — unavailable fallback", estimate_id)
|
||||
return LocationCoefResponse(
|
||||
coef=1.0,
|
||||
factors=[],
|
||||
geo_source="unavailable",
|
||||
base_price_rub=base_price_rub,
|
||||
result_price_rub=base_price_rub,
|
||||
logger.info(
|
||||
"location_index: estimate=%s has no lat/lon — out_of_coverage fallback", estimate_id
|
||||
)
|
||||
return LocationIndexResponse(
|
||||
status="out_of_coverage",
|
||||
location_index_pct=None,
|
||||
local_median_price_per_m2=None,
|
||||
city_median_price_per_m2=None,
|
||||
sample_size=0,
|
||||
radius_m=radius_m or 0,
|
||||
nearby_poi=[],
|
||||
poi_status="unavailable",
|
||||
)
|
||||
|
||||
from app.services.location_coef import DEFAULT_RADIUS_M, compute_location_coef
|
||||
from app.services.location_index import compute_location_index
|
||||
|
||||
resolved_radius = DEFAULT_RADIUS_M if radius_m is None else max(500, min(radius_m, 3000))
|
||||
result = compute_location_coef(db, float(row.lat), float(row.lon), radius_m=resolved_radius)
|
||||
resolved_radius = None if radius_m is None else max(500, min(radius_m, 3000))
|
||||
result = compute_location_index(db, float(row.lat), float(row.lon), radius_m=resolved_radius)
|
||||
|
||||
return LocationCoefResponse(
|
||||
coef=result.coef,
|
||||
factors=[
|
||||
LocationCoefFactorOut(
|
||||
poi_type=f.poi_type, name=f.name, distance_m=f.distance_m, weight=f.weight
|
||||
)
|
||||
for f in result.factors
|
||||
return LocationIndexResponse(
|
||||
status=result.status,
|
||||
location_index_pct=result.location_index_pct,
|
||||
local_median_price_per_m2=result.local_median_price_per_m2,
|
||||
city_median_price_per_m2=result.city_median_price_per_m2,
|
||||
sample_size=result.sample_size,
|
||||
radius_m=result.radius_m,
|
||||
nearby_poi=[
|
||||
NearbyPoiOut(poi_type=p.poi_type, name=p.name, distance_m=p.distance_m)
|
||||
for p in result.nearby_poi
|
||||
],
|
||||
geo_source=result.geo_source,
|
||||
base_price_rub=base_price_rub,
|
||||
result_price_rub=round(base_price_rub * result.coef),
|
||||
poi_status=result.poi_status,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1628,7 +1669,12 @@ def get_street_deals(
|
|||
После PR-A (#549) таблица deals содержит только ДКП (ДДУ-первичка отфильтрована
|
||||
в import-rosreestr.sh).
|
||||
"""
|
||||
from app.services.estimator import _deal_to_analog, _percentile, extract_street_name
|
||||
from app.services.estimator import (
|
||||
_deal_to_analog,
|
||||
_percentile,
|
||||
_resolve_target_city,
|
||||
extract_street_name,
|
||||
)
|
||||
|
||||
now = datetime.now(tz=UTC)
|
||||
# #1381: отображаемое окно должно совпадать с SQL-фильтром ниже, который
|
||||
|
|
@ -1663,16 +1709,25 @@ def get_street_deals(
|
|||
area_min = area_m2 * (1.0 - area_tolerance)
|
||||
area_max = area_m2 * (1.0 + area_tolerance)
|
||||
|
||||
# #C1 city-scope (п.3, консистентно с estimator._fetch_dkp_corridor): резолвим
|
||||
# город целевого адреса через _resolve_target_city (словарь ~30 городов обл.66
|
||||
# вкл. ЕКБ + sweep-города) и фильтруем сделки по этому городу — одноимённые улицы
|
||||
# др. городов не контаминируют витрину. None (адрес вне словаря) → фильтр не
|
||||
# применяется. `city_filter` — литерал (не user-input), значение идёт bind-параметром.
|
||||
target_city = _resolve_target_city(address)
|
||||
city_filter = "AND LOWER(city) = CAST(:target_city AS text)" if target_city else ""
|
||||
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
f"""
|
||||
SELECT address, area_m2, rooms, floor, total_floors,
|
||||
price_rub, price_per_m2, deal_date, source
|
||||
FROM deals
|
||||
WHERE source = 'rosreestr'
|
||||
AND address ILIKE :street_pattern
|
||||
AND address ~* :street_regex
|
||||
{city_filter}
|
||||
AND rooms = CAST(:rooms AS integer)
|
||||
AND area_m2 BETWEEN :area_min AND :area_max
|
||||
AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval
|
||||
|
|
@ -1683,6 +1738,7 @@ def get_street_deals(
|
|||
{
|
||||
"street_pattern": "%" + street_name + "%",
|
||||
"street_regex": r"\m" + street_name + r"\M",
|
||||
"target_city": target_city.lower() if target_city else None,
|
||||
"rooms": rooms,
|
||||
"area_min": area_min,
|
||||
"area_max": area_max,
|
||||
|
|
|
|||
|
|
@ -106,9 +106,7 @@ class Settings(BaseSettings):
|
|||
# house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в
|
||||
# `threshold` раз — подмешиваем якорь к медиане с весом `weight` и
|
||||
# расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение).
|
||||
# Полностью за флагами — безопасно выкатить до демо; при отсутствии IMV/Yandex
|
||||
# no-op (медиана не меняется).
|
||||
estimate_imv_blend_enabled: bool = True
|
||||
# При отсутствии IMV/Yandex no-op (медиана не меняется).
|
||||
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
|
||||
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
|
||||
|
||||
|
|
@ -117,9 +115,8 @@ class Settings(BaseSettings):
|
|||
# комфорт −15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A),
|
||||
# similarity-weighted по площади/комнатам, premium-uplift к ~p70 для топ-юнита
|
||||
# дома, asking→sold haircut (banded по ppm²), hard guardrail (est ≥ min-comp×0.95)
|
||||
# и tighter FSD-диапазон. Полностью за флагом — OFF ⇒ точно текущее поведение.
|
||||
# и tighter FSD-диапазон.
|
||||
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
|
||||
estimate_same_building_anchor_enabled: bool = True
|
||||
estimate_sb_min_comps: int = 4 # стоп на первом тире с ≥ N активных комплов
|
||||
estimate_sb_area_sigma: float = 0.18 # σ log-нормального area-веса (Gaussian)
|
||||
estimate_sb_rooms_match_boost: float = 1.6 # ×вес если rooms компла == target
|
||||
|
|
@ -138,8 +135,7 @@ class Settings(BaseSettings):
|
|||
# интервал, выданный за полный). Эмпирически отношение actual_sold/expected_sold
|
||||
# по 2366 прод-сделкам имеет p10=0.649, p90=1.392 → band
|
||||
# expected_sold × [low_mult, high_mult] = настоящий ~80% prediction interval
|
||||
# (проверено: 80.0% coverage на тех же 2366). OFF ⇒ точно старое IQR-поведение.
|
||||
estimate_calibrated_pi_enabled: bool = True
|
||||
# (проверено: 80.0% coverage на тех же 2366).
|
||||
estimate_pi_low_mult: float = 0.649 # empirical p10 of sold/expected_sold (#1966, n=2366)
|
||||
estimate_pi_high_mult: float = 1.392 # empirical p90 of sold/expected_sold (#1966, n=2366)
|
||||
# ── #2002: hedonic year+area correction на точку expected_sold ─────────────
|
||||
|
|
@ -171,7 +167,6 @@ class Settings(BaseSettings):
|
|||
# median_ppm2 > high_ppm2×(1+slack) И count≥min_n И anchor_tier != "A"
|
||||
# (Tier A = реальные комплы того же дома → EXEMPT) — жёстко прижимаем headline
|
||||
# к high_ppm2×(1+slack) и пропорционально пересчитываем price/range/expected_sold.
|
||||
estimate_corridor_clamp_enabled: bool = True
|
||||
estimate_corridor_clamp_min_n: int = 10
|
||||
# cap = corridor_high×(1+slack) = ×1.40; даёт премиум-домам без own-листингов
|
||||
# (tier-C) больше воздуха над sold-коридором, не возвращая исходную 2× инфляцию
|
||||
|
|
@ -181,9 +176,8 @@ class Settings(BaseSettings):
|
|||
# Если итоговый median_ppm2 < dkp_low_ppm2 × factor — поднимаем до floor.
|
||||
# Применяется ТОЛЬКО на radius-пути (anchor_tier is None) и при dkp_raw.
|
||||
# factor=0.8: 20% зазор ниже P10 коридора → floor достаточно мягкий для эконома
|
||||
# (избегаем ложных подъёмов) и ловит явный undershoot. False → no-op.
|
||||
# ENV: ESTIMATE_RADIUS_FLOOR_ENABLED, ESTIMATE_RADIUS_FLOOR_FACTOR.
|
||||
estimate_radius_floor_enabled: bool = True
|
||||
# (избегаем ложных подъёмов) и ловит явный undershoot.
|
||||
# ENV: ESTIMATE_RADIUS_FLOOR_FACTOR.
|
||||
estimate_radius_floor_factor: float = 0.8
|
||||
# Шаг 5 — clamp expected_sold <= asking: ratio > 1.0 физически невозможен для
|
||||
# trade-in (ожидаемая цена сделки не должна превышать цену объявления).
|
||||
|
|
@ -215,7 +209,6 @@ class Settings(BaseSettings):
|
|||
# "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free).
|
||||
# Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус /
|
||||
# ratio — не затрагиваются.
|
||||
estimate_sb_tier_a_allow_primary_if_secondary_present: bool = True
|
||||
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
|
||||
estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона
|
||||
|
||||
|
|
@ -225,8 +218,6 @@ class Settings(BaseSettings):
|
|||
# здоровые якоря (n≥4 с FSD<0.15) проходят без изменений.
|
||||
# estimate_sb_gate_min_n=3 : при n<3 И FSD>max_fsd гейт срабатывает
|
||||
# estimate_sb_gate_max_fsd=0.20: FSD>0.20 при малом n → ненадёжный якорь
|
||||
# Отдельный флаг: False → точно старое поведение (гейта нет).
|
||||
estimate_sb_low_conf_gate_enabled: bool = True
|
||||
estimate_sb_gate_min_n: int = 3
|
||||
estimate_sb_gate_max_fsd: float = 0.20
|
||||
|
||||
|
|
@ -235,25 +226,12 @@ class Settings(BaseSettings):
|
|||
# Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо.
|
||||
estimate_price_trend_max_age_months: int = 6
|
||||
|
||||
# ── Mera-audit fix-3: cross-source dedup в price_trend ────────────────────
|
||||
# Один объект на avito_imv + yandex_valuation с разными ext_item_id даёт
|
||||
# double-count в house_placement_history → шум в помесячной медиане тренда.
|
||||
# True (дефолт) = дедуплицировать строки перед агрегацией по ключу
|
||||
# (round(area_m2,0), floor, COALESCE(last_price,start_price),
|
||||
# COALESCE(last_price_date,start_price_date)), приоритет avito_imv.
|
||||
# False = старое поведение без дедупа (backward-compat).
|
||||
# ENV: ESTIMATE_PRICE_TREND_DEDUP_ENABLED.
|
||||
estimate_price_trend_dedup_enabled: bool = True
|
||||
|
||||
# ── #1871 P1.2: ghost-anchor confidence floor ─────────────────────────────
|
||||
# True (дефолт) = форсировать confidence='low' + добавлять caveat в explanation
|
||||
# когда n_analogs == 0 (нет радиусных/anchor-аналогов) но confidence не 'low'.
|
||||
# Защита от ghost-anchor: внешние оценочные сервисы (yandex_valuation,
|
||||
# cian_valuation, avito_imv) могут дать median без единого реального рыночного
|
||||
# аналога → headline выглядит достоверным при нулевой реальной базе.
|
||||
# False = старое поведение без принудительного понижения (для отката).
|
||||
# ENV: ESTIMATE_CONFIDENCE_FLOOR_NO_ANALOGS.
|
||||
estimate_confidence_floor_no_analogs: bool = True
|
||||
|
||||
# ── #2002 #4: manual-review recommendation (derived FLAG, НЕ ценовой сигнал) ─
|
||||
# Помечает оценки, которые НЕ стоит авто-оффэрить — нужна ручная оценка
|
||||
|
|
@ -261,8 +239,6 @@ class Settings(BaseSettings):
|
|||
# от отделки/вида, чего нет в данных сделок). Триггеры: премиальный дом,
|
||||
# высокая стоимость, низкая уверенность, слишком широкий диапазон цены.
|
||||
# Чисто метаданные — не трогает median/expected_sold/ranges (gate byte-stable).
|
||||
# False = флаг всегда выключен (manual_review_recommended=False, reasons=[]).
|
||||
estimate_manual_review_enabled: bool = True
|
||||
estimate_manual_review_high_value_rub: int = 20_000_000 # ≥ этого — ручная оценка
|
||||
estimate_manual_review_wide_range_ratio: float = 1.9 # range_high/range_low ≥ — неопределённо
|
||||
# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна
|
||||
|
|
@ -274,12 +250,8 @@ class Settings(BaseSettings):
|
|||
# (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один
|
||||
# address и выживают на разных rn_addr рангах → раздувают n_analogs (prod
|
||||
# 2026-06-23: yandex 48, cian 9, n1 5 excess). Anchor-путь дедупит по
|
||||
# (source, source_id) — radius нет. True (дефолт) добавляет rn_dup=1 фильтр в
|
||||
# каждом тире (freshest scraped_at на (source, source_id|source_url|ctid)).
|
||||
# False = старое поведение без radius-дедупа (для отката). Окно rn_dup всегда
|
||||
# в SQL (безвредно без фильтра); флаг управляет только применением фильтра.
|
||||
# ENV: ESTIMATE_RADIUS_DEDUP_ENABLED.
|
||||
estimate_radius_dedup_enabled: bool = True
|
||||
# (source, source_id) — radius нет. Добавляет rn_dup=1 фильтр в каждом тире
|
||||
# (freshest scraped_at на (source, source_id|source_url|ctid)).
|
||||
|
||||
# ── #2087 H4: кросс-source физический дедуп аналогов ──────────────────────
|
||||
# Radius-дедуп выше ловит только повторы ВНУТРИ одного source (source, source_id).
|
||||
|
|
@ -302,7 +274,7 @@ class Settings(BaseSettings):
|
|||
# ТУ ЖЕ цену → нулевой вклад в дисперсию) → cv/коридор НЕ сужаются. Это фикс
|
||||
# ЧЕСТНОСТИ СЧЁТА (n_analogs не раздут ×3 кросс-постами, source_counts по
|
||||
# физлотам), accuracy-нейтральный, а НЕ рычаг сужения cv (рычаг cv→коридор —
|
||||
# estimate_sb_clip_after_weight, уже ON). Default ON (#2173): бэктест #1966 OFF vs
|
||||
# post-weight MAD-clip, уже ON). Default ON (#2173): бэктест #1966 OFF vs
|
||||
# ON accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias −3.83%, median width/cv
|
||||
# без изменений), меняется только user-visible n_analogs — перестаёт быть раздутым
|
||||
# кросс-постингом ×3. ENV: ESTIMATE_DEDUP_ANALOGS_ENABLED (=false откатывает).
|
||||
|
|
@ -367,11 +339,9 @@ class Settings(BaseSettings):
|
|||
# Tier A (same-building) матчит по address-regex (намеренно НЕ house_id — дом
|
||||
# дробится на несколько house_id). На split-доме разной этажности comp_min..max
|
||||
# растягивается через несколько ценовых режимов → коридор range_low/high
|
||||
# 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. True +
|
||||
# Tier A + corridor_pct > threshold → понижаем confidence на ступень и дописываем
|
||||
# 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. Tier A +
|
||||
# corridor_pct > threshold → понижаем confidence на ступень и дописываем
|
||||
# disclosure в explanation. НЕ трогает point/median/range.
|
||||
# ENV: ESTIMATE_WIDE_CORRIDOR_DISCLOSURE_ENABLED.
|
||||
estimate_wide_corridor_disclosure_enabled: bool = True
|
||||
# Порог ширины коридора (range_high-range_low)/median_price для disclosure.
|
||||
# 1.2 (120%): по prod-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6
|
||||
# фаерил бы на ~31% оценок (широкий коридор ≠ split-дом, ложная атрибуция).
|
||||
|
|
@ -388,11 +358,6 @@ class Settings(BaseSettings):
|
|||
cian_valuation_min_rub: float = 500_000
|
||||
cian_valuation_max_rub: float = 500_000_000
|
||||
|
||||
# ── #audit-4: MAD-clip after similarity-weighting ─────────────────────────
|
||||
# True = clip происходит ПОСЛЕ similarity-weighting (на взвешенных ppm²).
|
||||
# False = clip ДО weighting (старое поведение). Дефолт True.
|
||||
estimate_sb_clip_after_weight: bool = True
|
||||
|
||||
# ── #audit-5: data-age guards ─────────────────────────────────────────────
|
||||
# sber_index_max_age_days: максимальный допустимый возраст последнего месяца
|
||||
# СберИндекс-серии (дней). Если latest месяц старее — логируем warning.
|
||||
|
|
@ -410,8 +375,6 @@ class Settings(BaseSettings):
|
|||
# между квартальным уровнем целевого объекта и усреднённым квартальным уровнем
|
||||
# аналогов — не дублирует location, уже заложенный в медиану аналогов.
|
||||
# Формула: adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index.
|
||||
# За флагом: False → точно текущее поведение (backward-compatible).
|
||||
estimate_quarter_index_enabled: bool = True
|
||||
# Минимальное число сделок в квартале (sparse fallback: меньше — no-op).
|
||||
estimate_quarter_index_min_n_deals: int = 10
|
||||
# Guard-2 (no double-count): если доля аналогов ИЗ ТОГО ЖЕ квартала > порога —
|
||||
|
|
@ -701,5 +664,19 @@ class Settings(BaseSettings):
|
|||
# честная маркировка.
|
||||
sell_time_sensitivity_min_n_lots: int = 10
|
||||
|
||||
# ── Telegram support bridge (@MERAsupport_bot) ───────────────────────────
|
||||
# Клиент пишет боту в личку → зеркалится в топик support-группы → оператор
|
||||
# отвечает реплаем в топике → бот доставляет ответ клиенту. Standalone
|
||||
# long-polling воркер (app.tgbot_main), НЕ webhook — см. app/services/tgbot/.
|
||||
# Пусто/0 = бот выключен: tgbot_main логирует «disabled» и выходит с кодом 0
|
||||
# (чтобы контейнер без секрета не крутил рестарт-луп). ENV: TELEGRAM_BOT_TOKEN,
|
||||
# TELEGRAM_SUPPORT_CHAT_ID, TELEGRAM_SUPPORT_TOPIC_ID.
|
||||
telegram_bot_token: str = Field(default="", validation_alias="TELEGRAM_BOT_TOKEN")
|
||||
# Telegram id форум-группы (супергруппы с включёнными топиками), куда
|
||||
# зеркалятся обращения клиентов. Отрицательный для supergroup id (напр. -100...).
|
||||
telegram_support_chat_id: int = Field(default=0, validation_alias="TELEGRAM_SUPPORT_CHAT_ID")
|
||||
# message_thread_id топика внутри support-группы, в который идут зеркала.
|
||||
telegram_support_topic_id: int = Field(default=0, validation_alias="TELEGRAM_SUPPORT_TOPIC_ID")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
|
|
|||
|
|
@ -80,6 +80,63 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||
return await call_next(request)
|
||||
|
||||
|
||||
class SlidingWindowLimiter:
|
||||
"""Reusable in-process sliding-window limiter — тот же алгоритм, что
|
||||
`RateLimitMiddleware.dispatch` (deque per key, отбрасываем протухшие метки),
|
||||
вынесенный для feature-специфичных лимитов, которые нужны ЖЁСТЧЕ общего
|
||||
per-user порога `/api/*` (напр. отправка сообщений в веб-чат поддержки,
|
||||
#tgsupport-web — общий лимит 300/60с не спасёт support-топик от заливки
|
||||
одним флудящим клиентом, т.к. Telegram Bot API токен общий на всех).
|
||||
|
||||
Не заменяет `RateLimitMiddleware` (тот остаётся общим гейтом на `/api/*`),
|
||||
а даёт отдельный, более узкий бюджет для конкретного эндпоинта/действия.
|
||||
"""
|
||||
|
||||
def __init__(self, limit: int, window_s: float) -> None:
|
||||
self._limit = limit
|
||||
self._window_s = window_s
|
||||
self._hits: dict[str, deque[float]] = defaultdict(deque)
|
||||
|
||||
def _prune(self, bucket: deque[float], now: float) -> None:
|
||||
cutoff = now - self._window_s
|
||||
while bucket and bucket[0] < cutoff:
|
||||
bucket.popleft()
|
||||
|
||||
def retry_after(self, key: str) -> float | None:
|
||||
"""Non-destructive проверка: сколько секунд ждать, если *key* СЕЙЧАС за
|
||||
лимитом, иначе None. НЕ регистрирует попытку — вызывающая сторона решает
|
||||
сама, когда звать `record()` (обычно — только на успех действия, #tgsupport-web
|
||||
review L3: неудачная попытка не должна съедать бюджет)."""
|
||||
now = time.monotonic()
|
||||
bucket = self._hits[key]
|
||||
self._prune(bucket, now)
|
||||
if len(bucket) >= self._limit:
|
||||
return self._window_s - (now - bucket[0])
|
||||
return None
|
||||
|
||||
def record(self, key: str) -> None:
|
||||
"""Регистрирует одну успешную попытку под *key*."""
|
||||
now = time.monotonic()
|
||||
bucket = self._hits[key]
|
||||
self._prune(bucket, now)
|
||||
bucket.append(now)
|
||||
# Лёгкая защита от утечки памяти — чистим пустые корзины изредка (тот же
|
||||
# паттерн, что RateLimitMiddleware.dispatch).
|
||||
if len(self._hits) > 10000:
|
||||
for k in [k for k, v in self._hits.items() if not v]:
|
||||
del self._hits[k]
|
||||
|
||||
def check(self, key: str) -> float | None:
|
||||
"""Комбинированная проверка+регистрация (peek+record за один вызов) —
|
||||
для вызывающих, которым не нужно различать "попытка"/"успех" (см.
|
||||
`retry_after`/`record` для раздельного варианта)."""
|
||||
retry_after = self.retry_after(key)
|
||||
if retry_after is not None:
|
||||
return retry_after
|
||||
self.record(key)
|
||||
return None
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""Честный клиентский IP при РОВНО ОДНОМ доверенном прокси (Caddy) перед нами.
|
||||
|
||||
|
|
|
|||
136
tradein-mvp/backend/app/core/rbac.py
Normal file
136
tradein-mvp/backend/app/core/rbac.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"""RBAC guard middleware — extracted from ``app/main.py``.
|
||||
|
||||
Historically ``rbac_guard`` lived inline in ``app/main.py`` and the test suite
|
||||
(``tests/test_rbac.py``, ``tests/test_internal_auth_secret.py``) kept a
|
||||
hand-maintained *copy* of it, labelled "MIRROR of app.main — keep in sync
|
||||
manually". The copy drifted: it was missing the #2213
|
||||
``X-Internal-Auth-Secret`` defense-in-depth check that the real guard has,
|
||||
so a regression in that check would NOT have failed CI.
|
||||
|
||||
This module holds the real guard with no DB/lifespan/scheduler side effects
|
||||
(only ``app.core.auth`` + ``app.core.config``, both side-effect-free at
|
||||
import time beyond requiring ``DATABASE_URL`` in the environment for
|
||||
``Settings()``). ``app/main.py`` and the test apps both import THIS module,
|
||||
so tests exercise the exact production code path instead of a copy that can
|
||||
silently fall out of sync.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from app.core.auth import get_role, is_path_allowed
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# RBAC: defense-in-depth поверх Caddy basic_auth + X-Authenticated-User
|
||||
# (см. app/core/auth.py + auth/roles.yaml). Правила:
|
||||
# 1) Любой non-public path требует X-Authenticated-User — иначе 401.
|
||||
# 2) Юзер должен быть в roles.yaml — иначе 403 («неизвестный юзер ничего
|
||||
# не видит» — decided 2026-05-25).
|
||||
# 3) /api/v1/admin/* (= внешний /trade-in/api/v1/admin/* после Caddy
|
||||
# `uri strip_prefix /trade-in`) — только role=admin, иначе 403.
|
||||
# Public paths без auth (/health, /docs, /openapi.json) пропускаем —
|
||||
# X-Authenticated-User там не приходит из Caddy.
|
||||
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
|
||||
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
|
||||
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
|
||||
# tradein-backend, а globs в roles.yaml — ВНЕШНИЕ (/trade-in/api/v1/**). Для
|
||||
# scope-проверки восстанавливаем внешний путь.
|
||||
_EXTERNAL_PREFIX = "/trade-in"
|
||||
# Bootstrap-пути, доступные ЛЮБОМУ известному юзеру независимо от роли: /me отдаёт
|
||||
# роль (expired → trial-экран), /brand/* — брендинг login/trial-экрана. Без них
|
||||
# expired (roles.yaml paths:[] deny:/**) не получил бы роль и не увидел trial-экран.
|
||||
_RBAC_BOOTSTRAP_EXEMPT = ("/api/v1/me", "/api/v1/brand")
|
||||
|
||||
|
||||
async def rbac_guard(
|
||||
request: Request,
|
||||
call_next: Callable[[Request], Awaitable[Response]],
|
||||
) -> Response:
|
||||
path = request.url.path
|
||||
if path in _PUBLIC_PATHS:
|
||||
return await call_next(request)
|
||||
|
||||
username = request.headers.get("X-Authenticated-User")
|
||||
if not username:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "no authenticated user (Caddy basic_auth required)"},
|
||||
)
|
||||
|
||||
# #2213 defense-in-depth: если общий секрет задан — запрос с X-Authenticated-User
|
||||
# ОБЯЗАН нести валидный X-Internal-Auth-Secret (его добавляет Caddy из env).
|
||||
# Иначе это подделка заголовка мимо Caddy (напр. изнутри gendesign_shared) → 401.
|
||||
# Constant-time compare против timing-атак. Пусто = защита не активна (fail-open).
|
||||
secret = settings.tradein_internal_auth_secret
|
||||
if secret:
|
||||
provided = request.headers.get("X-Internal-Auth-Secret", "")
|
||||
if not secrets.compare_digest(provided, secret):
|
||||
logger.warning(
|
||||
"RBAC: X-Authenticated-User=%r без валидного X-Internal-Auth-Secret "
|
||||
"на %s — возможная подделка заголовка мимо Caddy",
|
||||
username,
|
||||
path,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "invalid or missing internal auth secret"},
|
||||
)
|
||||
|
||||
try:
|
||||
role = get_role(username)
|
||||
except KeyError:
|
||||
logger.warning("RBAC: unknown user %r tried %s", username, path)
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": "user not in roles config"},
|
||||
)
|
||||
|
||||
if _ADMIN_API_RE.match(path) and role != "admin":
|
||||
logger.info("RBAC: blocked %s (role=%s) from %s", username, role, path)
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": "admin only"},
|
||||
)
|
||||
|
||||
# #R2-H3: энфорсим roles.yaml scope (paths/deny) для ВСЕХ non-admin путей, а не
|
||||
# только /admin/*. Иначе revoked (role=expired, paths:[] deny:/**) или узко-
|
||||
# скоупленный аккаунт достаёт non-admin API (напр. POST /api/v1/search —
|
||||
# экспорт листингов), который roles.yaml ему запрещает. Bootstrap-пути (/me,
|
||||
# /brand) исключены выше по списку. roles.yaml globs внешние → восстанавливаем
|
||||
# внешний путь (Caddy срезал /trade-in). На сбой парса — fail-open + громкий
|
||||
# лог: не лочим платящего pilot из-за конфиг-бага (admin-гейт выше остаётся).
|
||||
if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT):
|
||||
external_path = _EXTERNAL_PREFIX + path
|
||||
try:
|
||||
allowed = is_path_allowed(role, external_path)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"RBAC scope-check raised for %s %s (ext=%s) — fail-open",
|
||||
username,
|
||||
path,
|
||||
external_path,
|
||||
)
|
||||
allowed = True
|
||||
if not allowed:
|
||||
logger.info(
|
||||
"RBAC: scope-blocked %s (role=%s) from %s (ext=%s)",
|
||||
username,
|
||||
role,
|
||||
path,
|
||||
external_path,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": "forbidden for role"},
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
130
tradein-mvp/backend/app/core/request_audit.py
Normal file
130
tradein-mvp/backend/app/core/request_audit.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""RequestAuditMiddleware — пишет `api_request` / `admin_action` (+ дедуплицированный
|
||||
`login`/`login_failed`) события в `user_events` для каждого аутентифицированного
|
||||
`/api/*` запроса.
|
||||
|
||||
Foundation для Feature 2 (login/IP audit) и базы Feature 3 (behavior analytics).
|
||||
Логирование выполняется ПОСЛЕ `call_next` (не задерживает и не ветвит реальный
|
||||
ответ клиенту) и через fire-and-forget `schedule_event` — сбой аудита никогда
|
||||
не влияет на HTTP-ответ.
|
||||
|
||||
Порядок middleware-стека (см. `app/main.py`: `rbac_guard` — `@app.middleware("http")`,
|
||||
объявлен ДО `app.add_middleware(RequestAuditMiddleware)`) делает `RequestAudit`
|
||||
ВНЕШНИМ по отношению к `rbac_guard` (Starlette строит стек в обратном порядке
|
||||
регистрации — последний `add_middleware` оборачивает предыдущие). Поэтому к моменту,
|
||||
когда код ниже читает `response.status_code`, в нём уже отражён исход rbac_guard
|
||||
(401/403 short-circuit) ИЛИ реального хендлера — статус несёт реальный смысл
|
||||
"успех/отказ", а не только "запрос дошёл до хендлера".
|
||||
|
||||
Заведомо неаутентифицированный трафик (сканеры, долбящиеся в /wp-login.php и т.п.
|
||||
без валидного basic_auth) сюда вообще не попадает: Caddy гейтит basic_auth ПЕРЕД
|
||||
проксированием, так что `X-Authenticated-User` в таких запросах нет — условие
|
||||
`if username and ...` ниже их уже отсекает. Поэтому шум сканеров не нужно
|
||||
дополнительно фильтровать в этом файле — тот класс проблемы («сигнал тонет в шуме
|
||||
сканера») здесь структурно невозможен: событие может появиться только для
|
||||
запроса, прошедшего Caddy basic_auth.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.core.ratelimit import _client_ip
|
||||
from app.services.user_events import schedule_event, should_log_login
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Зеркалит app.main._PUBLIC_PATHS. Не импортируем напрямую из app.main — оно
|
||||
# импортирует этот модуль (регистрирует middleware), обратный импорт дал бы
|
||||
# циклическую зависимость.
|
||||
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
|
||||
|
||||
# Методы, меняющие состояние — для /api/v1/admin/* именно они должны попадать в
|
||||
# аудит с атрибуцией (кто именно загрузил куки / включил авто-логин / поправил
|
||||
# прокси / изменил настройки скрапера / выполнил bulk-операцию). GET/HEAD/OPTIONS
|
||||
# на /admin/* остаются вне аудита (см. комментарий ниже — это просмотр дашбордов,
|
||||
# не действие).
|
||||
_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
||||
|
||||
|
||||
class RequestAuditMiddleware(BaseHTTPMiddleware):
|
||||
"""Логирует активность аутентифицированных пользователей в `user_events`."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def]
|
||||
response: Response = await call_next(request)
|
||||
|
||||
try:
|
||||
username = request.headers.get("x-authenticated-user")
|
||||
path = request.url.path
|
||||
if username and path.startswith("/api/") and path not in _PUBLIC_PATHS:
|
||||
ip = _client_ip(request)
|
||||
ua = request.headers.get("user-agent")
|
||||
method = request.method
|
||||
success = response.status_code < 400
|
||||
is_admin_path = path.startswith("/api/v1/admin/")
|
||||
|
||||
# Общий behavior/activity-поток — каждый authenticated API-запрос.
|
||||
# /api/v1/admin/* исключаем из `api_request`: это ops-действия
|
||||
# (просмотр дашбордов аудита/аналитики), а не поведение пилота —
|
||||
# иначе запросы дашборда зашумляют top_paths и счётчики активности.
|
||||
if not is_admin_path:
|
||||
schedule_event(
|
||||
event_type="api_request",
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
path=path,
|
||||
method=method,
|
||||
payload={"status_code": response.status_code},
|
||||
)
|
||||
elif method in _MUTATING_METHODS:
|
||||
# Admin-аудит (security-audit fix): раньше ЛЮБОЙ запрос под
|
||||
# /api/v1/admin/* (включая меняющие состояние — загрузка кук,
|
||||
# авто-логин, правка прокси, настройки скраперов, bulk-операции)
|
||||
# полностью исключался из `user_events` тем же условием, что и
|
||||
# шумные GET-дашборды — установить, КТО совершил действие, было
|
||||
# невозможно. Пишем факт действия + атрибуцию (username/ip/path/
|
||||
# method/статус) — БЕЗ тела запроса (там куки/пароли/секреты
|
||||
# правки прокси), это НЕ payload-лог, а событие "что произошло".
|
||||
schedule_event(
|
||||
event_type="admin_action",
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
path=path,
|
||||
method=method,
|
||||
payload={"status_code": response.status_code, "success": success},
|
||||
)
|
||||
|
||||
# Дедуплицированный login/IP-audit сигнал — максимум раз в день
|
||||
# на (юзер, IP, устройство). Security-audit fix: раньше событие
|
||||
# всегда писалось как `login` независимо от исхода запроса —
|
||||
# отражённая RBAC-попытка (валидный Caddy basic_auth, но
|
||||
# 401/403 от rbac_guard: протухший X-Internal-Auth-Secret,
|
||||
# неизвестная роль, scope-блок) была неотличима от настоящего
|
||||
# входа. Теперь тип события расходится по `response.status_code`:
|
||||
# `login` — успех, `login_failed` — otказ. Дедуп-бакет (once per
|
||||
# user+ip+ua+day) НЕ разбит отдельно на success/fail (это
|
||||
# потребовало бы менять `should_log_login` в user_events.py —
|
||||
# вне scope этого фикса): если в рамках одного дня с этого же
|
||||
# устройства сначала случился отказ, а затем реальный успешный
|
||||
# вход, второе событие в тот же день не запишется — тот же
|
||||
# компромисс дедупа, что был и раньше, разница только в том, что
|
||||
# теперь ЕДИНСТВЕННОЕ событие дня корректно отражает, чем оно было.
|
||||
if should_log_login(username, ip, ua):
|
||||
schedule_event(
|
||||
event_type="login" if success else "login_failed",
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
path=path,
|
||||
method=method,
|
||||
payload={"status_code": response.status_code},
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("RequestAuditMiddleware: failed to record event", exc_info=True)
|
||||
|
||||
return response
|
||||
|
|
@ -8,27 +8,36 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import sentry_sdk
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
||||
from sentry_sdk.integrations.httpx import HttpxIntegration
|
||||
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||
from sentry_sdk.integrations.starlette import StarletteIntegration
|
||||
|
||||
from app.api.v1 import admin, brand, buildings, geocode, lead, me, search, trade_in
|
||||
from app.core.auth import get_role
|
||||
from app.api.v1 import (
|
||||
admin,
|
||||
audit,
|
||||
brand,
|
||||
buildings,
|
||||
geocode,
|
||||
lead,
|
||||
me,
|
||||
search,
|
||||
support,
|
||||
trade_in,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.core.db import SessionLocal
|
||||
from app.core.fdw import ensure_fdw_user_mapping
|
||||
from app.core.ratelimit import RateLimitMiddleware
|
||||
from app.core.rbac import rbac_guard
|
||||
from app.core.request_audit import RequestAuditMiddleware
|
||||
from app.observability.sentry_scrub import scrub_pii_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -38,6 +47,13 @@ logging.basicConfig(
|
|||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
# #tgsupport-web: этот процесс теперь тоже зовёт Telegram Bot API напрямую
|
||||
# (app/api/v1/support.py — sendMessage при отправке веб-сообщения в топик), не
|
||||
# только изолированный tgbot_main.py. httpx-INFO логирует ПОЛНЫЙ request URL,
|
||||
# включая токен в пути (https://api.telegram.org/bot<TOKEN>/...) — то же самое
|
||||
# закрытие, что уже стоит в tgbot_main.py (см. его комментарий), нужно и здесь.
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
# Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396).
|
||||
# DSN из env GLITCHTIP_DSN; пусто (dev/текущий prod) → init не вызывается, NO-OP.
|
||||
# Integrations: Starlette/FastAPI (request errors), SQLAlchemy/Httpx (breadcrumbs),
|
||||
|
|
@ -45,13 +61,28 @@ logging.basicConfig(
|
|||
# worker (in-app scheduler зовёт task-функции напрямую; compose = postgres/backend/
|
||||
# frontend), отдельного broker нет → мониторить нечего.
|
||||
if settings.glitchtip_dsn:
|
||||
from app.observability.sentry_scrub import redact_telegram_bot_token
|
||||
|
||||
def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None:
|
||||
"""Композиция PII-scrub + Telegram bot-токен redaction (#tgsupport-web) —
|
||||
см. app/tgbot_main.py._before_send (идентичная композиция, тот же риск:
|
||||
теперь этот процесс тоже держит TelegramClient в стек-фреймах при ошибке
|
||||
sendMessage, а include_local_variables=False ниже — первый рубеж защиты)."""
|
||||
scrubbed = scrub_pii_event(event, hint) # type: ignore[arg-type]
|
||||
if scrubbed is None:
|
||||
return None
|
||||
return redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type,return-value]
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=settings.glitchtip_dsn,
|
||||
environment=settings.environment,
|
||||
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
|
||||
traces_sample_rate=0.0, # только ошибки, без performance-трейсов
|
||||
send_default_pii=False, # не шлём client_name / client_phone в отчёты
|
||||
before_send=scrub_pii_event, # дочищаем consumer-PII из тела error events
|
||||
include_local_variables=False, # #tgsupport-web: TelegramClient._request
|
||||
# держит base URL с токеном в локальных переменных стек-фрейма — default
|
||||
# sentry_sdk (True) приложил бы их к traceback открытым текстом.
|
||||
before_send=_before_send,
|
||||
integrations=[
|
||||
StarletteIntegration(),
|
||||
FastApiIntegration(),
|
||||
|
|
@ -104,63 +135,9 @@ app = FastAPI(
|
|||
# не видит» — decided 2026-05-25).
|
||||
# 3) /api/v1/admin/* (= внешний /trade-in/api/v1/admin/* после Caddy
|
||||
# `uri strip_prefix /trade-in`) — только role=admin, иначе 403.
|
||||
# Public paths без auth (/health, /docs, /openapi.json) пропускаем —
|
||||
# X-Authenticated-User там не приходит из Caddy.
|
||||
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
|
||||
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def rbac_guard(
|
||||
request: Request,
|
||||
call_next: Callable[[Request], Awaitable[Response]],
|
||||
) -> Response:
|
||||
path = request.url.path
|
||||
if path in _PUBLIC_PATHS:
|
||||
return await call_next(request)
|
||||
|
||||
username = request.headers.get("X-Authenticated-User")
|
||||
if not username:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "no authenticated user (Caddy basic_auth required)"},
|
||||
)
|
||||
|
||||
# #2213 defense-in-depth: если общий секрет задан — запрос с X-Authenticated-User
|
||||
# ОБЯЗАН нести валидный X-Internal-Auth-Secret (его добавляет Caddy из env).
|
||||
# Иначе это подделка заголовка мимо Caddy (напр. изнутри gendesign_shared) → 401.
|
||||
# Constant-time compare против timing-атак. Пусто = защита не активна (fail-open).
|
||||
secret = settings.tradein_internal_auth_secret
|
||||
if secret:
|
||||
provided = request.headers.get("X-Internal-Auth-Secret", "")
|
||||
if not secrets.compare_digest(provided, secret):
|
||||
logger.warning(
|
||||
"RBAC: X-Authenticated-User=%r без валидного X-Internal-Auth-Secret "
|
||||
"на %s — возможная подделка заголовка мимо Caddy",
|
||||
username,
|
||||
path,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "invalid or missing internal auth secret"},
|
||||
)
|
||||
|
||||
try:
|
||||
role = get_role(username)
|
||||
except KeyError:
|
||||
logger.warning("RBAC: unknown user %r tried %s", username, path)
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": "user not in roles config"},
|
||||
)
|
||||
|
||||
if _ADMIN_API_RE.match(path) and role != "admin":
|
||||
logger.info("RBAC: blocked %s (role=%s) from %s", username, role, path)
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": "admin only"},
|
||||
)
|
||||
return await call_next(request)
|
||||
# Guard body живёт в app/core/rbac.py (без DB/lifespan side effects), чтобы
|
||||
# тесты могли импортировать РЕАЛЬНЫЙ guard вместо hand-maintained копии.
|
||||
app.middleware("http")(rbac_guard)
|
||||
|
||||
|
||||
app.add_middleware(
|
||||
|
|
@ -172,6 +149,8 @@ app.add_middleware(
|
|||
)
|
||||
# Rate-limit публичного API (per-user / per-IP sliding window) — защита от абуза.
|
||||
app.add_middleware(RateLimitMiddleware)
|
||||
# Request-audit: пишет api_request/login события в user_events (Feature 2/3 foundation).
|
||||
app.add_middleware(RequestAuditMiddleware)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
|
@ -181,9 +160,11 @@ def health() -> dict[str, str]:
|
|||
|
||||
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
|
||||
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
|
||||
app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"])
|
||||
app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"])
|
||||
app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
||||
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
||||
app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-support"])
|
||||
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
|
||||
app.include_router(search.router, prefix="/api/v1", tags=["search"])
|
||||
app.include_router(me.router, prefix="/api/v1", tags=["me"])
|
||||
|
|
|
|||
|
|
@ -1,22 +1,76 @@
|
|||
"""Хук before_send для GlitchTip/Sentry SDK (tradein-local, #396).
|
||||
"""Хуки before_send для GlitchTip/Sentry SDK (tradein-local, #396, #tgsupport).
|
||||
|
||||
Redact-ит consumer-PII (client_name / client_phone / client_email и пр.)
|
||||
из error events до отправки в GlitchTip — estimator/trade-in flow таскает
|
||||
эти поля, а send_default_pii=False их не покрывает (это user-data в
|
||||
request.data / extra / contexts, не PII-заголовки).
|
||||
|
||||
`redact_telegram_bot_token` — отдельный хук (#tgsupport review): Telegram Bot
|
||||
API токен живёт в URL-пути (`https://api.telegram.org/bot<id>:<secret>/...`),
|
||||
а не в query/userinfo, поэтому НЕ покрывается sentry_sdk `sanitize_url` (тот
|
||||
режет только `user:pass@` и query-параметры). Токен утекает ДВУМЯ путями,
|
||||
которые `_scrub`/`scrub_pii_event` (ключ-based, PII-словарь) не ловят:
|
||||
1. `include_local_variables=True` (sentry_sdk default) кладёт locals
|
||||
stack-фрейма (`self._base`, `url` в `TelegramClient._request`) в
|
||||
traceback → полный токен открытым текстом.
|
||||
2. `HttpxIntegration` кладёт полный request URL в span `data` (виден при
|
||||
любом ненулевом `traces_sample_rate`), а не только в traceback.
|
||||
Поэтому редактор — НЕ ключ-based, а regex full-text по КАЖДОЙ строке во всём
|
||||
event (глубокий обход dict/list/tuple) — токен может всплыть в любом поле.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from sentry_sdk.types import Event
|
||||
|
||||
_REDACTED = "[REDACTED]"
|
||||
# Ключи consumer-PII (нижний регистр; сверка case-insensitive).
|
||||
_PII_KEYS = frozenset(
|
||||
{"client_name", "client_phone", "client_email", "phone", "email", "name"}
|
||||
_PII_KEYS = frozenset({"client_name", "client_phone", "client_email", "phone", "email", "name"})
|
||||
|
||||
# Telegram Bot API токен в пути URL: /bot<numeric_id>:<secret-part>/<method>.
|
||||
# Матчим ровно этот сегмент (не весь URL) — сохраняет остальной путь/query
|
||||
# читаемым для диагностики (метод API, error code и т.п.).
|
||||
_TG_BOT_TOKEN_RE = re.compile(r"/bot\d+:[A-Za-z0-9_-]+")
|
||||
_TG_BOT_TOKEN_REPLACEMENT = "/bot[REDACTED]"
|
||||
|
||||
# Тот же токен БЕЗ префикса `/bot` — форма `<numeric_id>:<secret>` сама по себе
|
||||
# (напр. локаль `token` в конструкторе TelegramClient, или если его кто-то
|
||||
# засунет в log-сообщение). Сейчас единственный путь такой формы в event —
|
||||
# locals стек-фрейма, а они выключены через include_local_variables=False в
|
||||
# tgbot_main. Но именно на отказ того флага этот редактор и страхует: без этой
|
||||
# ветки рубеж был бы один, а не два. Формат токена BotFather: 8-12 цифр `:` 35
|
||||
# символов base64url — нижние границы взяты с запасом, чтобы не промахнуться
|
||||
# на нестандартных id, но остаться уже, чем `\d+:\S+` (тот бил бы по любым
|
||||
# `id:value` в логах, напр. `chat_id:12345`).
|
||||
_TG_BOT_TOKEN_BARE_RE = re.compile(r"\b\d{6,12}:[A-Za-z0-9_-]{30,}\b")
|
||||
|
||||
# Query-string секреты в исходящих URL сторонних API (аудит-фикс, #security-audit):
|
||||
# mobileproxy changeip-ссылка (`AVITO_PROXY_ROTATE_URL` и др., admin.py
|
||||
# rotate_proxy_ip) несёт провайдерский API-ключ в query (`?...&proxy_key=...`).
|
||||
# Два независимых пути утечки в GlitchTip, зеркалящих TG-токен выше:
|
||||
# 1. `HttpxIntegration.send()` парсит URL через `parse_url(str(request.url),
|
||||
# sanitize=False)` (ЯВНЫЙ opt-out из sentry_sdk `sanitize_url`, который иначе
|
||||
# сам вырезал бы query-параметры) и кладёт полный URL в span `data["url"]` —
|
||||
# сейчас неактивно (`traces_sample_rate=0.0` в app/main.py/scheduler_main.py →
|
||||
# span не сэмплится/не уходит), но молча перестанет спасать, если трейсинг
|
||||
# когда-нибудь включат.
|
||||
# 2. `include_local_variables=True` (sentry_sdk default в app/main.py — в отличие
|
||||
# от tgbot_main.py, где явно False) кладёт stack-frame locals (`rotate_url`,
|
||||
# `exc` в rotate_proxy_ip) в traceback открытым текстом.
|
||||
# Как и TG-токен — full-text regex по КАЖДОЙ строке event (не ключ-based): секрет
|
||||
# может всплыть где угодно (frame locals, breadcrumb, exception message). НЕ
|
||||
# завязано на конкретного провайдера — покрывает любой query-параметр из
|
||||
# общеупотребимого набора секретных имён (api_key/proxy_key/token/secret/password/
|
||||
# access_token/auth), т.к. cian/yandex у нас имеют СВОИ rotate-URL (потенциально
|
||||
# другой провайдер, другое имя параметра).
|
||||
_URL_SECRET_QUERY_RE = re.compile(
|
||||
r"(?i)([?&](?:api[_-]?key|proxy[_-]?key|token|secret|password|pwd|"
|
||||
r"access[_-]?token|auth)=)[^&\s\"'<>]+"
|
||||
)
|
||||
_URL_SECRET_QUERY_REPLACEMENT = r"\g<1>" + _REDACTED
|
||||
|
||||
|
||||
def _scrub(obj: Any) -> None:
|
||||
|
|
@ -32,8 +86,50 @@ def _scrub(obj: Any) -> None:
|
|||
_scrub(item)
|
||||
|
||||
|
||||
def _redact_url_secrets_inplace(obj: Any) -> None:
|
||||
"""Рекурсивно (IN-PLACE, как `_scrub`) заменяет значения секрет-подобных
|
||||
query-параметров (`?token=...`, `?proxy_key=...` и т.п.) на [REDACTED] в
|
||||
КАЖДОЙ строке event — не ключ-based: секрет утекает через httpx span
|
||||
`url`/`query` data и через текст исключений (`str(exc)` httpx содержит полный
|
||||
request URL), а не только через известные PII-поля формы. Мутирует dict/list
|
||||
на месте (НЕ пересоздаёт структуру, в отличие от `_redact_strings`) —
|
||||
сохраняет identity верхнеуровневого `event`, на что опирается контракт
|
||||
`scrub_pii_event`/`before_send` и существующие тесты (`out is event`).
|
||||
"""
|
||||
if isinstance(obj, dict):
|
||||
for key, value in obj.items():
|
||||
if isinstance(value, str):
|
||||
redacted = _URL_SECRET_QUERY_RE.sub(_URL_SECRET_QUERY_REPLACEMENT, value)
|
||||
if redacted != value:
|
||||
obj[key] = redacted
|
||||
else:
|
||||
_redact_url_secrets_inplace(value)
|
||||
elif isinstance(obj, list):
|
||||
for i, value in enumerate(obj):
|
||||
if isinstance(value, str):
|
||||
redacted = _URL_SECRET_QUERY_RE.sub(_URL_SECRET_QUERY_REPLACEMENT, value)
|
||||
if redacted != value:
|
||||
obj[i] = redacted
|
||||
else:
|
||||
_redact_url_secrets_inplace(value)
|
||||
# tuple намеренно не обрабатываем: sentry_sdk event — это JSON-совместимая
|
||||
# структура (dict/list/str/int/...), tuple там не встречается, а даже если бы
|
||||
# встретился — он immutable, in-place правка невозможна (см. `_scrub`, тот же
|
||||
# выбор для dict/list).
|
||||
|
||||
|
||||
def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
|
||||
"""Redact consumer-PII из error event перед отправкой. Возвращает event (не None)."""
|
||||
"""Redact consumer-PII + URL query-string секретов из error event перед отправкой.
|
||||
|
||||
Композиция (обе — in-place, сохраняют identity `event`): (1) ключ-based
|
||||
dict-scrub consumer-PII полей формы (как раньше), (2) full-text regex-проход
|
||||
по ВСЕМУ event, вырезающий значения секрет-подобных query-параметров в любой
|
||||
строке (proxy/API-ключи в исходящих URL сторонних сервисов, напр. mobileproxy
|
||||
changeip — #security-audit). Второй шаг не завязан на конкретные ключи полей —
|
||||
ловит секрет в frame locals, breadcrumb, exception message и т.д., где он может
|
||||
оказаться независимо от include_local_variables/traces_sample_rate. Возвращает
|
||||
event (не None).
|
||||
"""
|
||||
if not isinstance(event, dict):
|
||||
return event
|
||||
request = event.get("request")
|
||||
|
|
@ -41,4 +137,36 @@ def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
|
|||
_scrub(request.get("data"))
|
||||
_scrub(event.get("extra"))
|
||||
_scrub(event.get("contexts"))
|
||||
_redact_url_secrets_inplace(event)
|
||||
return event
|
||||
|
||||
|
||||
def _redact_strings(obj: Any) -> Any:
|
||||
"""Рекурсивно проходит dict/list/tuple и прогоняет обе токен-регулярки по КАЖДОЙ
|
||||
строке (не только по конкретным ключам) — токен может оказаться в locals
|
||||
stack-фрейма, span data, breadcrumb message, request.url и т.д. Возвращает
|
||||
НОВУЮ структуру (не мутирует `obj` — в отличие от `_scrub`, чтобы не зависеть
|
||||
от того, какие контейнеры sentry_sdk считает mutable в своём event dict)."""
|
||||
if isinstance(obj, str):
|
||||
redacted = _TG_BOT_TOKEN_RE.sub(_TG_BOT_TOKEN_REPLACEMENT, obj)
|
||||
return _TG_BOT_TOKEN_BARE_RE.sub(_REDACTED, redacted)
|
||||
if isinstance(obj, dict):
|
||||
return {k: _redact_strings(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_redact_strings(v) for v in obj]
|
||||
if isinstance(obj, tuple):
|
||||
return tuple(_redact_strings(v) for v in obj)
|
||||
return obj
|
||||
|
||||
|
||||
def redact_telegram_bot_token(event: Event, _hint: dict[str, Any]) -> Event | None:
|
||||
"""Full-text regex redaction Telegram Bot API токена по ВСЕМУ event (#tgsupport).
|
||||
|
||||
Ловит оба вектора утечки токена в GlitchTip, которые ключ-based `scrub_pii_event`
|
||||
не покрывает: locals stack-фреймов (`include_local_variables=True`) и httpx-span
|
||||
`data` (полный request URL). Композировать с `scrub_pii_event`, не вместо него —
|
||||
разные классы секретов (PII полей формы vs bot-токен в URL).
|
||||
"""
|
||||
if not isinstance(event, dict):
|
||||
return event
|
||||
return _redact_strings(event) # type: ignore[return-value]
|
||||
|
|
|
|||
146
tradein-mvp/backend/app/schemas/audit.py
Normal file
146
tradein-mvp/backend/app/schemas/audit.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"""Pydantic-схемы admin read API над `user_events` (Feature 2 audit + Feature 3 analytics).
|
||||
|
||||
`user_events` (migration `184_user_events.sql`) — unified append-only событийный лог
|
||||
(login/IP audit + behavior analytics), admin-read-only. Эти схемы описывают ответы
|
||||
`GET /api/v1/admin/audit/*` и `GET /api/v1/admin/analytics` (см. app/api/v1/audit.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class AccountSummary(BaseModel):
|
||||
"""Одна строка списка `GET /audit/accounts` — сводка по одному username."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
username: str
|
||||
first_seen_at: datetime
|
||||
last_seen_at: datetime
|
||||
distinct_ips: int
|
||||
distinct_devices: int
|
||||
login_count: int
|
||||
request_count: int
|
||||
search_count: int
|
||||
|
||||
|
||||
class AccountIpEntry(BaseModel):
|
||||
"""Одна строка `ips` в drilldown `GET /audit/accounts/{username}`."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
ip_address: str | None = None
|
||||
event_count: int
|
||||
first_seen: datetime
|
||||
last_seen: datetime
|
||||
|
||||
|
||||
class AccountDeviceEntry(BaseModel):
|
||||
"""Одна строка `devices` в drilldown `GET /audit/accounts/{username}`."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
user_agent: str | None = None
|
||||
event_count: int
|
||||
first_seen: datetime
|
||||
last_seen: datetime
|
||||
|
||||
|
||||
class AccountSearchEntry(BaseModel):
|
||||
"""Одна строка `searches` в drilldown — недавний estimate_request."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
address: str | None = None
|
||||
area_m2: str | None = None
|
||||
rooms: str | None = None
|
||||
estimate_id: str | None = None
|
||||
ip_address: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AccountActivityEntry(BaseModel):
|
||||
"""Одна строка `recent_activity` в drilldown — сырое событие."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
event_type: str
|
||||
path: str | None = None
|
||||
method: str | None = None
|
||||
ip_address: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AccountDrilldown(BaseModel):
|
||||
"""Полный ответ `GET /audit/accounts/{username}` — 4 списка.
|
||||
|
||||
Неизвестный username НЕ 404 — это просто пустой отчёт (все списки == []).
|
||||
"""
|
||||
|
||||
ips: list[AccountIpEntry]
|
||||
devices: list[AccountDeviceEntry]
|
||||
searches: list[AccountSearchEntry]
|
||||
recent_activity: list[AccountActivityEntry]
|
||||
|
||||
|
||||
class AnalyticsSummary(BaseModel):
|
||||
"""Верхнеуровневые счётчики дашборда `GET /analytics`."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
total_events: int
|
||||
distinct_users: int
|
||||
events_last_24h: int
|
||||
active_users_last_24h: int
|
||||
|
||||
|
||||
class AnalyticsDailyPoint(BaseModel):
|
||||
"""Одна точка time-series `daily` — события/пользователи за день."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
day: date
|
||||
events: int
|
||||
users: int
|
||||
|
||||
|
||||
class AnalyticsTopSearch(BaseModel):
|
||||
"""Одна строка `top_searches` — самый частый искомый адрес."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
address: str | None = None
|
||||
n: int
|
||||
|
||||
|
||||
class AnalyticsTopPath(BaseModel):
|
||||
"""Одна строка `top_paths` — самый частый API-путь."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
path: str | None = None
|
||||
n: int
|
||||
|
||||
|
||||
class AnalyticsByAccount(BaseModel):
|
||||
"""Одна строка `by_account` — сводка активности по username."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
username: str
|
||||
events: int
|
||||
searches: int
|
||||
last_seen: datetime
|
||||
|
||||
|
||||
class AnalyticsDashboard(BaseModel):
|
||||
"""Полный ответ `GET /analytics` — бандл для дашборда Feature 3."""
|
||||
|
||||
summary: AnalyticsSummary
|
||||
daily: list[AnalyticsDailyPoint]
|
||||
top_searches: list[AnalyticsTopSearch]
|
||||
top_paths: list[AnalyticsTopPath]
|
||||
by_account: list[AnalyticsByAccount]
|
||||
|
|
@ -222,7 +222,9 @@ class AggregatedEstimate(BaseModel):
|
|||
# конкретному дому за 12 мес) — другая база и другой смысл «торга», см. докстринг
|
||||
# HouseAnalyticsKpi.
|
||||
asking_to_sold_ratio: float | None = None # =sold/asking, ~0.72–0.93
|
||||
ratio_basis: str | None = None # 'per_rooms' | 'global_fallback'
|
||||
# 'per_rooms' | 'global_fallback' | 'expected_over_median' (LOW audit #2: set when
|
||||
# #2141's honest_ratio recompute overwrites the raw ratio_resolver value below).
|
||||
ratio_basis: str | None = None
|
||||
# ── DaData enrichment (PR Q1) — on-demand для target адреса ──
|
||||
# canonical_address — DaData-нормализованная форма (с улицей в short form).
|
||||
# house_cadnum — кадастровый номер ДОМА (для будущего matching Росреестра).
|
||||
|
|
@ -266,7 +268,7 @@ class AggregatedEstimate(BaseModel):
|
|||
# оценка человеком (премиальный дом / высокая стоимость / низкая уверенность /
|
||||
# широкий диапазон). Дефолт False — не трогает median/expected_sold/ranges.
|
||||
# manual_review_reasons — человекочитаемые RU-причины для ревьюера; пусто, когда
|
||||
# рекомендация не сработала (или флаг estimate_manual_review_enabled выключен).
|
||||
# рекомендация не сработала.
|
||||
manual_review_recommended: bool = False
|
||||
manual_review_reasons: list[str] = Field(default_factory=list)
|
||||
# ── #2043 (BE-1): метрики достоверности выборки — уже считаются, отдаём наружу ──
|
||||
|
|
@ -596,29 +598,48 @@ class QuotaStatus(BaseModel):
|
|||
unlimited: bool # True для admin / kopylov / без заголовка
|
||||
|
||||
|
||||
class LocationCoefFactorOut(BaseModel):
|
||||
"""Один POI-фактор в ответе GET /api/v1/trade-in/location-coef (#2045 BE-3)."""
|
||||
class NearbyPoiOut(BaseModel):
|
||||
"""Один пункт «что рядом» в ответе GET /api/v1/trade-in/location-index.
|
||||
|
||||
Качественная справка (школа 185 м, остановка 93 м) — НЕ участвует в location_index_pct.
|
||||
"""
|
||||
|
||||
poi_type: str # категория POI (school/kindergarten/metro_stop/... — те же значения,
|
||||
# что в osm_poi_ekb на стороне gendesign)
|
||||
name: str | None
|
||||
distance_m: float
|
||||
weight: float
|
||||
|
||||
|
||||
class LocationCoefResponse(BaseModel):
|
||||
"""Ответ GET /api/v1/trade-in/location-coef (#2045 BE-3, LocationDrawer).
|
||||
class LocationIndexResponse(BaseModel):
|
||||
"""Ответ GET /api/v1/trade-in/location-index — замена сломанного location-coef.
|
||||
|
||||
coef — MVP-эвристика (НЕ откалибрована на реальных ценовых дельтах, см.
|
||||
app/services/location_coef.py::_score_to_coef), диапазон [0.95, 1.05].
|
||||
result_price_rub = round(base_price_rub * coef).
|
||||
ИСТОРИЯ: старый `location-coef` (`coef = 0.95 + score/100*0.10`, range [0.95,1.05],
|
||||
`result_price_rub = round(base_price_rub * coef)`) не был откалиброван на ценах — 67% из
|
||||
1500 адресов ЕКБ попадали в ±1%, а бакеты coef НЕ монотонны относительно медианы ₽/м² по
|
||||
4000 активным лотам (дороже — не значит выше coef). Полностью заменён.
|
||||
|
||||
geo_source="unavailable" — osm_poi_ekb_local пуста/не отрефрешена на этом окружении
|
||||
(graceful fallback: coef=1.0, factors=[], НЕ 500 и НЕ сфабрикованные факторы).
|
||||
location_index_pct — % отклонения медианы ₽/м² сопоставимых активных листингов в радиусе
|
||||
точки от медианы ₽/м² по всему Екатеринбургу (см. app/services/location_index.py). НЕ
|
||||
зажат искусственно — диапазон реальный. НЕ участвует в цене (estimator.py про него не
|
||||
знает: аналоги уже несут локацию в базовой цене, повторное умножение — double-count).
|
||||
|
||||
status:
|
||||
- "ok" — location_index_pct/local_median_price_per_m2 надёжны.
|
||||
- "out_of_coverage" — точка вне гео-охвата продукта (Екатеринбург). Все числовые
|
||||
поля None — честный прочерк на фронте, НЕ 0%.
|
||||
- "insufficient_data" — даже на максимальном радиусе сопоставимых активных листингов
|
||||
меньше порога (см. MIN_SAMPLE_SIZE). Числовые поля None, но sample_size/radius_m
|
||||
показывают, что реально нашлось.
|
||||
|
||||
poi_status — независимый статус для nearby_poi: "ok" | "unavailable" (osm_poi_ekb_local
|
||||
ещё не отрефрешена на этом окружении — пустой список, НЕ сфабрикованные точки).
|
||||
"""
|
||||
|
||||
coef: float
|
||||
factors: list[LocationCoefFactorOut]
|
||||
geo_source: str
|
||||
base_price_rub: int
|
||||
result_price_rub: int
|
||||
status: str
|
||||
location_index_pct: float | None
|
||||
local_median_price_per_m2: int | None
|
||||
city_median_price_per_m2: int | None
|
||||
sample_size: int
|
||||
radius_m: int
|
||||
nearby_poi: list[NearbyPoiOut]
|
||||
poi_status: str
|
||||
|
|
|
|||
|
|
@ -1,9 +1,22 @@
|
|||
"""Сервис квоты оценок trade-in — N успешных оценок в месяц на аккаунт.
|
||||
|
||||
Правила:
|
||||
- Лимит = settings.estimate_quota_limit успешных оценок за календарный месяц
|
||||
(UTC, период 'YYYY-MM'); конфигурируется через env ESTIMATE_QUOTA_LIMIT, default 15.
|
||||
- Без лимита (unlimited): роль admin ИЛИ username == 'kopylov'.
|
||||
- Лимит по умолчанию = settings.estimate_quota_limit успешных оценок за календарный
|
||||
месяц (UTC, период 'YYYY-MM'); конфигурируется через env ESTIMATE_QUOTA_LIMIT,
|
||||
default 15.
|
||||
- Персональный override: таблица account_quota_overrides (username → monthly_limit),
|
||||
см. миграцию 185_account_quota_overrides.sql. Заменяет прежний хак бонусных попыток
|
||||
через negative `used` (ломал /quota — «Осталось 50 из 15»).
|
||||
- `used` в account_estimate_usage защищён CHECK (used >= 0) на уровне схемы, см.
|
||||
миграцию 189_account_estimate_usage_nonnegative.sql — 185 сбросила негативный
|
||||
used только для user2, 189 закрывает остальные аккаунты + запрещает регресс.
|
||||
В коде декремента `used` НЕТ — increment() только `used + 1` под TOCTOU-guard
|
||||
(#747); любой negative used приходит исключительно извне (ручной UPDATE).
|
||||
- Без лимита (unlimited): роль admin (без похода в БД) ИЛИ персональный грант
|
||||
account_quota_overrides.unlimited = true (миграция 191_account_quota_unlimited_flag.sql).
|
||||
До миграции 191 unlimited для non-admin аккаунтов был захардкожен как
|
||||
`username == 'kopylov'` прямо в коде — данные (kopylov + praktika) заменяют этот
|
||||
хардкод целиком, единый источник правды для всех безлимитных non-admin грантов.
|
||||
- Учитываются ТОЛЬКО успешные оценки (инкремент ПОСЛЕ estimate_quality).
|
||||
- Если заголовок X-Authenticated-User отсутствует (dev без Caddy) → unlimited,
|
||||
лимит не применяется (fail-open).
|
||||
|
|
@ -38,26 +51,67 @@ def current_period() -> str:
|
|||
return datetime.now(UTC).strftime("%Y-%m")
|
||||
|
||||
|
||||
def is_unlimited(username: str) -> bool:
|
||||
def is_unlimited(db: Session, username: str) -> bool:
|
||||
"""True если пользователь не ограничен квотой.
|
||||
|
||||
Unlimited: роль admin ИЛИ username == 'kopylov'.
|
||||
KeyError (неизвестный пользователь) → трактуется как limited (False).
|
||||
Unlimited если:
|
||||
- роль admin (RBAC roles.yaml, in-memory, БЕЗ похода в БД — admin гарантированно
|
||||
безлимитен по дизайну RBAC, отдельная per-user запись не нужна);
|
||||
- ЛИБО персональный грант account_quota_overrides.unlimited = true (миграция
|
||||
191) — единственный источник правды для non-admin безлимитных аккаунтов,
|
||||
включая kopylov (перенесён сюда этой же миграцией, до 191 был захардкожен
|
||||
как `username == 'kopylov'`) и praktika (пилот восстановлен 2026-07-27).
|
||||
|
||||
KeyError (неизвестный пользователь, не в roles.yaml) → трактуется как limited
|
||||
(False), БЕЗ похода в БД — override-таблица не источник правды для юзеров,
|
||||
которых вообще нет в RBAC-конфиге.
|
||||
"""
|
||||
if username == "kopylov":
|
||||
return True
|
||||
try:
|
||||
role = get_role(username)
|
||||
return role == "admin"
|
||||
except KeyError:
|
||||
return False
|
||||
if role == "admin":
|
||||
return True
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT unlimited FROM account_quota_overrides
|
||||
WHERE username = :u
|
||||
"""
|
||||
),
|
||||
{"u": username},
|
||||
).fetchone()
|
||||
return bool(row is not None and row.unlimited)
|
||||
|
||||
|
||||
def user_limit(db: Session, username: str) -> int:
|
||||
"""Персональный месячный лимит для username, иначе глобальный MONTHLY_LIMIT.
|
||||
|
||||
Источник override — таблица account_quota_overrides (см. миграцию
|
||||
185_account_quota_overrides.sql). Заменяет прежний хак бонусных попыток через
|
||||
negative `used`, который ломал /quota (limit=15, used=-35 → remaining=50 —
|
||||
«Осталось 50 из 15»).
|
||||
"""
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT monthly_limit FROM account_quota_overrides
|
||||
WHERE username = :u
|
||||
"""
|
||||
),
|
||||
{"u": username},
|
||||
).fetchone()
|
||||
if row is not None and row.monthly_limit is not None:
|
||||
return int(row.monthly_limit)
|
||||
return MONTHLY_LIMIT
|
||||
|
||||
|
||||
def get_status(db: Session, username: str | None) -> dict:
|
||||
"""Возвращает статус квоты для пользователя.
|
||||
|
||||
Если username is None → unlimited True, used 0, remaining 15.
|
||||
Если unlimited → used = фактический или 0, remaining = limit.
|
||||
Если username is None → unlimited True, used 0, remaining = MONTHLY_LIMIT.
|
||||
Если unlimited → used = фактический или 0, remaining = limit (per-user override
|
||||
или глобальный MONTHLY_LIMIT).
|
||||
"""
|
||||
if username is None:
|
||||
return {
|
||||
|
|
@ -67,8 +121,9 @@ def get_status(db: Session, username: str | None) -> dict:
|
|||
"unlimited": True,
|
||||
}
|
||||
|
||||
unlimited = is_unlimited(username)
|
||||
unlimited = is_unlimited(db, username)
|
||||
period = current_period()
|
||||
limit = user_limit(db, username)
|
||||
|
||||
row = db.execute(
|
||||
text(
|
||||
|
|
@ -84,15 +139,18 @@ def get_status(db: Session, username: str | None) -> dict:
|
|||
|
||||
if unlimited:
|
||||
return {
|
||||
"limit": MONTHLY_LIMIT,
|
||||
"limit": limit,
|
||||
"used": used,
|
||||
"remaining": MONTHLY_LIMIT,
|
||||
"remaining": limit,
|
||||
"unlimited": True,
|
||||
}
|
||||
|
||||
remaining = max(0, MONTHLY_LIMIT - used)
|
||||
# Защитный кламп: remaining никогда не превышает limit, даже если used всё же
|
||||
# снова просочится отрицательным (прежний бонус-хак) — max(0, used) обнуляет
|
||||
# отрицательный used перед вычитанием.
|
||||
remaining = max(0, limit - max(0, used))
|
||||
return {
|
||||
"limit": MONTHLY_LIMIT,
|
||||
"limit": limit,
|
||||
"used": used,
|
||||
"remaining": remaining,
|
||||
"unlimited": False,
|
||||
|
|
@ -107,10 +165,11 @@ def check_and_raise(db: Session, username: str | None) -> None:
|
|||
if username is None:
|
||||
return
|
||||
|
||||
if is_unlimited(username):
|
||||
if is_unlimited(db, username):
|
||||
return
|
||||
|
||||
period = current_period()
|
||||
limit = user_limit(db, username)
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
|
|
@ -122,13 +181,13 @@ def check_and_raise(db: Session, username: str | None) -> None:
|
|||
).fetchone()
|
||||
|
||||
used = row.used if row is not None else 0
|
||||
if used >= MONTHLY_LIMIT:
|
||||
if used >= limit:
|
||||
logger.warning(
|
||||
"quota exhausted: username=%r period=%s used=%d limit=%d",
|
||||
username,
|
||||
period,
|
||||
used,
|
||||
MONTHLY_LIMIT,
|
||||
limit,
|
||||
)
|
||||
raise HTTPException(status_code=429, detail=LIMIT_EXHAUSTED_MESSAGE)
|
||||
|
||||
|
|
@ -140,14 +199,16 @@ def increment(db: Session, username: str | None) -> bool:
|
|||
None / unlimited → True (no-op success).
|
||||
|
||||
Защита от TOCTOU: предикат `WHERE used < :lim` применяется к ветке DO UPDATE —
|
||||
два параллельных запроса при used=MONTHLY_LIMIT-1 не могут оба инкрементировать
|
||||
(второй упрётся в WHERE → RETURNING пуст → False). Свежая вставка (used=1) НЕ
|
||||
задевается WHERE (он только для DO UPDATE), поэтому первая оценка месяца проходит.
|
||||
два параллельных запроса при used=lim-1 не могут оба инкрементировать (второй
|
||||
упрётся в WHERE → RETURNING пуст → False). Свежая вставка (used=1) НЕ задевается
|
||||
WHERE (он только для DO UPDATE), поэтому первая оценка месяца проходит. `lim` —
|
||||
персональный лимит (user_limit), НЕ жёстко зашитый глобальный MONTHLY_LIMIT.
|
||||
"""
|
||||
if username is None or is_unlimited(username):
|
||||
if username is None or is_unlimited(db, username):
|
||||
return True
|
||||
|
||||
period = current_period()
|
||||
lim = user_limit(db, username)
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
|
|
@ -161,7 +222,7 @@ def increment(db: Session, username: str | None) -> bool:
|
|||
RETURNING used
|
||||
"""
|
||||
),
|
||||
{"u": username, "p": period, "lim": MONTHLY_LIMIT},
|
||||
{"u": username, "p": period, "lim": lim},
|
||||
).fetchone()
|
||||
db.commit()
|
||||
ok = row is not None
|
||||
|
|
@ -172,6 +233,6 @@ def increment(db: Session, username: str | None) -> bool:
|
|||
"quota increment refused (atomic, #747): username=%r period=%s limit=%d",
|
||||
username,
|
||||
period,
|
||||
MONTHLY_LIMIT,
|
||||
lim,
|
||||
)
|
||||
return ok
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ class Brand:
|
|||
|
||||
_DEFAULT = Brand(
|
||||
slug="generic",
|
||||
name="Trade-In Estimator",
|
||||
name="МЕРА",
|
||||
logo_url=None,
|
||||
primary_color="#1d4ed8",
|
||||
accent_color="#f59e0b",
|
||||
|
|
|
|||
|
|
@ -71,6 +71,24 @@ _MFE_AUTH = "header-frontend"
|
|||
# Callers that need to distinguish ban from valid auth should check state.get("_ban").
|
||||
VERIFY_BAN_SENTINEL: dict[str, Any] = {"_ban": True}
|
||||
|
||||
# audit-scrapers finding 4: verify_session раньше сводило 5xx / сетевой сбой /
|
||||
# смену вёрстки к тому же None, что и реальный логаут (401 / isAuthenticated=false) —
|
||||
# вызывающие (_cian_pre_claim, admin upload/auto-login) реагировали "куки протухли,
|
||||
# перезалей" там, где куки ни при чём (Cian недоступен ИЛИ scraper_kit.cian_state_parser
|
||||
# больше не находит header-frontend initialState). Два отдельных сигнала ниже НЕ
|
||||
# триггерят "cookies expired" алерт у вызывающих.
|
||||
|
||||
# Cian источник недоступен прямо сейчас (5xx-ответ ИЛИ сетевой/транспортный сбой —
|
||||
# timeout, DNS, connection reset). Cookies могут быть абсолютно валидны — просто
|
||||
# нечем было их проверить. Retry позже, БЕЗ пометки session invalid.
|
||||
VERIFY_SOURCE_UNAVAILABLE_SENTINEL: dict[str, Any] = {"_source_unavailable": True}
|
||||
|
||||
# HTTP 200 получен, но ожидаемый auth-state (header-frontend/initialState с
|
||||
# user.isAuthenticated) не найден/не распарсился — Cian изменил вёрстку/MFE-схему.
|
||||
# Это engineering-проблема (extract_state/_MFE_AUTH нужно обновить), НЕ протухшие
|
||||
# cookies — переставлять куки здесь бесполезно.
|
||||
VERIFY_MARKUP_CHANGED_SENTINEL: dict[str, Any] = {"_markup_changed": True}
|
||||
|
||||
|
||||
def _classify_verify_response(
|
||||
status_code: int,
|
||||
|
|
@ -79,19 +97,25 @@ def _classify_verify_response(
|
|||
"""Pure classifier — maps (status_code, html) to verify_session outcome.
|
||||
|
||||
Returns:
|
||||
VERIFY_BAN_SENTINEL — 403/TLS ban (cookies may be fine, server is blocking)
|
||||
None — 401 or isAuthenticated=false (cookies genuinely expired)
|
||||
VERIFY_BAN_SENTINEL — 403/TLS ban (cookies могут быть в порядке,
|
||||
блокирует сервер)
|
||||
VERIFY_SOURCE_UNAVAILABLE_SENTINEL — 5xx/иной non-200 без содержимого —
|
||||
источник недоступен, НЕ cookies
|
||||
VERIFY_MARKUP_CHANGED_SENTINEL — HTTP 200, но auth-state не найден/не
|
||||
распарсился — вёрстка/схема изменилась
|
||||
None — 401 ИЛИ isAuthenticated=false — cookies
|
||||
ДЕЙСТВИТЕЛЬНО протухли/разлогинены
|
||||
state dict — authenticated successfully
|
||||
"""
|
||||
if status_code == 403:
|
||||
return VERIFY_BAN_SENTINEL
|
||||
if status_code == 401:
|
||||
return None
|
||||
if html is None:
|
||||
return None
|
||||
if status_code != 200 or html is None:
|
||||
return VERIFY_SOURCE_UNAVAILABLE_SENTINEL
|
||||
state = extract_state(html, mfe=_MFE_AUTH, key="initialState")
|
||||
if state is None:
|
||||
return None
|
||||
return VERIFY_MARKUP_CHANGED_SENTINEL
|
||||
user = state.get("user", {}) or {}
|
||||
if not user.get("isAuthenticated"):
|
||||
return None
|
||||
|
|
@ -104,11 +128,18 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
|
|||
Uses curl_cffi with impersonate='chrome120' (same as prod scrapers) to avoid
|
||||
TLS-fingerprint bans that httpx would trigger.
|
||||
|
||||
Returns:
|
||||
state dict — authenticated (contains user.isAuthenticated + userId)
|
||||
VERIFY_BAN_SENTINEL — HTTP 403 TLS/bot ban; cookies may still be valid —
|
||||
callers should NOT trigger a cookie-refresh alert
|
||||
None — HTTP 401 or isAuthenticated=false; cookies expired
|
||||
Returns (проверяй через `is`, НЕ `==` — это sentinel-объекты):
|
||||
state dict — authenticated (user.isAuthenticated + userId)
|
||||
VERIFY_BAN_SENTINEL — HTTP 403 TLS/bot ban; cookies могут быть
|
||||
валидны — НЕ триггерить cookie-refresh alert
|
||||
VERIFY_SOURCE_UNAVAILABLE_SENTINEL — 5xx/network/timeout; источник недоступен,
|
||||
НЕ триггерить cookie-refresh alert, retry позже
|
||||
VERIFY_MARKUP_CHANGED_SENTINEL — HTTP 200 но auth-state не распарсился;
|
||||
Cian изменил вёрстку — НЕ cookie-проблема,
|
||||
нужен engineering-фикс extract_state/_MFE_AUTH
|
||||
None — HTTP 401 или isAuthenticated=false; cookies
|
||||
ДЕЙСТВИТЕЛЬНО протухли — здесь и только здесь
|
||||
имеет смысл просить re-upload
|
||||
|
||||
Никогда не логирует сырые значения cookies.
|
||||
"""
|
||||
|
|
@ -134,6 +165,18 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
|
|||
logger.warning(
|
||||
"Cian cookies verify: HTTP 403 TLS/bot ban — cookies NOT marked expired"
|
||||
)
|
||||
elif result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL:
|
||||
logger.warning(
|
||||
"Cian cookies verify: source unavailable (status=%d) — "
|
||||
"cookies NOT marked expired, retry later",
|
||||
status,
|
||||
)
|
||||
elif result is VERIFY_MARKUP_CHANGED_SENTINEL:
|
||||
logger.error(
|
||||
"Cian cookies verify: HTTP 200 but auth-state not found/parseable "
|
||||
"(mfe=%s) — markup/schema changed, cookies NOT marked expired",
|
||||
_MFE_AUTH,
|
||||
)
|
||||
elif result is None:
|
||||
logger.warning("Cian cookies verify: expired/unauthenticated (status=%d)", status)
|
||||
else:
|
||||
|
|
@ -142,8 +185,11 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
|
|||
|
||||
return result
|
||||
except Exception as exc:
|
||||
logger.warning("Cian cookies verify failed: %s", exc)
|
||||
return None
|
||||
# Сетевой/транспортный сбой (timeout, DNS, connection reset и т.п.) — источник
|
||||
# недоступен, НЕ признак протухших cookies (finding 4). Раньше здесь везде
|
||||
# возвращался None, конфлируя с реальным логаутом.
|
||||
logger.warning("Cian cookies verify: transport/network error — %s", exc)
|
||||
return VERIFY_SOURCE_UNAVAILABLE_SENTINEL
|
||||
|
||||
|
||||
def save_session(
|
||||
|
|
|
|||
|
|
@ -167,9 +167,26 @@ async def clean_address(address: str) -> DadataAddressResult | None:
|
|||
logger.warning("dadata: HTTP 429 — quota exceeded (100/день demo limit?)")
|
||||
return None
|
||||
if status in (401, 403):
|
||||
body_preview = (response.text or "")[:200]
|
||||
# 403 «Feature 'CLEAN' disabled for token …» ≠ отклонённый токен: токен валиден,
|
||||
# но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ
|
||||
# поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback
|
||||
# (enrich_address). Разделяем сообщения, чтобы не гонять зря за ротацией токена.
|
||||
if status == 403 and (
|
||||
"disabled" in body_preview.lower() or "feature" in body_preview.lower()
|
||||
):
|
||||
logger.error(
|
||||
"dadata: HTTP %d — auth/secret rejected. Проверь DADATA_API_TOKEN/SECRET.",
|
||||
"dadata: HTTP 403 — услуга CLEAN (Стандартизация) выключена на аккаунте "
|
||||
"(токен валиден, НЕ отклонён). Включи услугу в кабинете DaData или "
|
||||
"полагайся на suggest-fallback (enrich_address). Ответ: %r",
|
||||
body_preview,
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"dadata: HTTP %d — auth/secret rejected. "
|
||||
"Проверь DADATA_API_TOKEN/SECRET. Ответ: %r",
|
||||
status,
|
||||
body_preview,
|
||||
)
|
||||
return None
|
||||
if status >= 500:
|
||||
|
|
@ -246,6 +263,12 @@ class DadataSuggestion:
|
|||
street: str | None
|
||||
city: str | None
|
||||
kind: str
|
||||
# #dadata-403 — доп. поля для suggest-fallback в enrich_address (когда CLEAN
|
||||
# выключен на аккаунте). house_fias_id — ФИАС ДОМА (для house-level == fias_id);
|
||||
# kladr_id / qc_geo дублируют то, что отдаёт /clean, чтобы деградация была равноценной.
|
||||
house_fias_id: str | None = None
|
||||
kladr_id: str | None = None
|
||||
qc_geo: int | None = None
|
||||
|
||||
|
||||
def _classify_kind(fias_level: Any) -> str:
|
||||
|
|
@ -295,11 +318,17 @@ def _parse_suggestion(item: dict[str, Any]) -> DadataSuggestion | None:
|
|||
street=data.get("street"),
|
||||
city=data.get("city"),
|
||||
kind=_classify_kind(data.get("fias_level")),
|
||||
house_fias_id=data.get("house_fias_id"),
|
||||
kladr_id=data.get("kladr_id"),
|
||||
qc_geo=_coerce_int(data.get("qc_geo")),
|
||||
)
|
||||
|
||||
|
||||
async def suggest_addresses(
|
||||
query: str, limit: int = 8, city: str = "Екатеринбург"
|
||||
query: str,
|
||||
limit: int = 8,
|
||||
city: str | None = "Екатеринбург",
|
||||
region: str | None = None,
|
||||
) -> list[DadataSuggestion]:
|
||||
"""Автокомплит адресов через DaData /suggest/address.
|
||||
|
||||
|
|
@ -307,6 +336,13 @@ async def suggest_addresses(
|
|||
query: текст для подсказки (минимум 2 символа).
|
||||
limit: сколько вариантов вернуть (DaData параметр `count`, max 20).
|
||||
city: locality для constraint (передаётся в `locations`). По умолчанию ЕКБ.
|
||||
Игнорируется, если задан `region`.
|
||||
region: область для hard-constraint в `locations` — используется вместо
|
||||
`city`, когда нужен охват шире одного города (например, вся
|
||||
Свердловская область / region 66). DaData `locations` сам по себе уже
|
||||
жёсткий фильтр (не boost) на уровне указанного admin-поля — доп.
|
||||
параметров не требуется. По умолчанию не задан — поведение (и body
|
||||
запроса) для существующих вызовов не меняется.
|
||||
|
||||
Returns:
|
||||
list[DadataSuggestion] — пустой список если:
|
||||
|
|
@ -334,7 +370,13 @@ async def suggest_addresses(
|
|||
"query": query.strip(),
|
||||
"count": max(1, min(int(limit), 20)),
|
||||
}
|
||||
if city:
|
||||
if region:
|
||||
# `locations` с полем region — уже жёсткий фильтр сам по себе (DaData
|
||||
# ограничивает выдачу этим регионом). `restrict_value` — ТОП-LEVEL параметр
|
||||
# body (не ключ внутри объекта locations) — здесь он был бы silent no-op,
|
||||
# поэтому не добавляем.
|
||||
body["locations"] = [{"region": region}]
|
||||
elif city:
|
||||
body["locations"] = [{"city": city}]
|
||||
|
||||
try:
|
||||
|
|
@ -352,9 +394,7 @@ async def suggest_addresses(
|
|||
logger.warning("dadata suggest: HTTP 429 — quota exceeded (10k/день free?)")
|
||||
return []
|
||||
if status in (401, 403):
|
||||
logger.error(
|
||||
"dadata suggest: HTTP %d — auth rejected. Проверь DADATA_API_TOKEN.", status
|
||||
)
|
||||
logger.error("dadata suggest: HTTP %d — auth rejected. Проверь DADATA_API_TOKEN.", status)
|
||||
return []
|
||||
if status >= 500:
|
||||
logger.warning("dadata suggest: HTTP %d — transient server error", status)
|
||||
|
|
@ -387,3 +427,55 @@ async def suggest_addresses(
|
|||
|
||||
logger.info("dadata suggest: %r → %d вариантов", query[:60], len(out))
|
||||
return out
|
||||
|
||||
|
||||
# ── Enrichment orchestrator (clean → suggest fallback) ───────────────────────
|
||||
|
||||
|
||||
async def enrich_address(address: str) -> DadataAddressResult | None:
|
||||
"""Обогащение целевого адреса с graceful-деградацией CLEAN → SUGGEST.
|
||||
|
||||
Порядок:
|
||||
1. /clean/address (token+secret) — canonical + house_cadnum + house_fias_id +
|
||||
qc_geo + metro. Если вернул результат — используем его.
|
||||
2. Fallback: /suggest/address (token-only, доступен на free-tier 10k/день) —
|
||||
берём top house-level кандидата. Даёт house_fias_id + гео + kladr_id + qc_geo,
|
||||
но НЕ canonical_address / house_cadnum (их умеет только CLEAN) → они остаются
|
||||
None. Честная деградация: недостающие поля не выдумываем.
|
||||
|
||||
Мотивация (#dadata-403, vault Mera_Hard_Audit_0712): на проде у аккаунта услуга
|
||||
CLEAN выключена (HTTP 403 «Feature CLEAN disabled»), из-за чего house_fias_id —
|
||||
ключ join с houses/ДОМ.РФ (year_built и пр.) — терялся, хотя /suggest его отдаёт
|
||||
бесплатно. Город НЕ ограничиваем (city="") — enrichment работает по всей обл. (66).
|
||||
"""
|
||||
cleaned = await clean_address(address)
|
||||
if cleaned is not None:
|
||||
return cleaned
|
||||
|
||||
suggestions = await suggest_addresses(address, limit=5, city="")
|
||||
for s in suggestions:
|
||||
fias = s.house_fias_id or s.fias_id
|
||||
if s.kind == "house" and fias:
|
||||
# house-level ФИАС → гео дома точное; qc_geo из ответа либо 0 (exact) по умолчанию.
|
||||
qc_geo = s.qc_geo if s.qc_geo is not None else 0
|
||||
logger.info(
|
||||
"dadata: CLEAN недоступен → suggest-fallback %r → house_fias_id=%s "
|
||||
"(canonical/cadnum недоступны без CLEAN)",
|
||||
address[:60],
|
||||
fias,
|
||||
)
|
||||
return DadataAddressResult(
|
||||
canonical_address=None,
|
||||
house_cadnum=None,
|
||||
house_fias_id=fias,
|
||||
lat=s.lat,
|
||||
lon=s.lon,
|
||||
qc_geo=qc_geo,
|
||||
qc_house=None,
|
||||
kladr_id=s.kladr_id,
|
||||
okato=None,
|
||||
oktmo=None,
|
||||
metro=[],
|
||||
raw={"_source": "suggest_fallback", "value": s.value},
|
||||
)
|
||||
return None
|
||||
|
|
|
|||
578
tradein-mvp/backend/app/services/domrf_kapremont_loader.py
Normal file
578
tradein-mvp/backend/app/services/domrf_kapremont_loader.py
Normal file
|
|
@ -0,0 +1,578 @@
|
|||
"""ДОМ.РФ капремонт open data loader: houses.year_built/material_walls (issue #2013).
|
||||
|
||||
CONTEXT: houses.year_built заполнен только на 38%, houses.material_walls — на 0%.
|
||||
Существующий ГИС-ЖКХ loader (zhkh_flats_loader.py, мигр. 146/149) заполняет
|
||||
houses.zhkh_year (70%) и houses.zhkh_floors (70%), НО никогда не копирует их в
|
||||
houses.year_built/total_floors — это основной пробел. material_walls вообще ни разу
|
||||
не заполнялся никаким источником. Estimator (app/services/estimator.py) фильтрует
|
||||
когорту по `year_built BETWEEN ...` — реальные годы напрямую двигают точность оценки.
|
||||
|
||||
ИСТОЧНИК: ДОМ.РФ капремонт open data (free, no auth), region 66 (Свердловская обл.):
|
||||
КР1.1 house registry (export/190) — zip → CSV, delimiter ';', UTF-8 BOM. Колонки:
|
||||
mkd_code, houseguid (ФИАС GUID дома), address, commission_year (год ввода, int),
|
||||
total_sq (decimal, запятая — «909,80»), number_floors_max (int), + служебные.
|
||||
КР1.2 constructive elements (export/275) — zip → CSV, тот же delimiter/encoding.
|
||||
LONG FORMAT: одна строка на конструктивный элемент на mkd_code; wall_material
|
||||
заполнен ТОЛЬКО на строке construction_element_type='фасад'. Чтобы получить
|
||||
материал стен на дом — группируем по mkd_code, берём первую строку с непустым
|
||||
wall_material.
|
||||
|
||||
TLS: домен домрф.рф отдаёт RU-сертификат, который httpx с default trust store не
|
||||
верифицирует ("unable to get local issuer certificate") — та же ситуация, что и у
|
||||
sber_index.py. Открытые неавторизованные данные, поэтому используем verify=False,
|
||||
как остальные RU-gov loader'ы в этом репо.
|
||||
|
||||
Матч staging→houses: domrf_kapremont.houseguid = COALESCE(houses.gar_house_guid,
|
||||
houses.house_fias_id, houses.zhkh_house_guid) — простой приоритетный COALESCE-джойн
|
||||
(без multi-attempt fallback-если-нет-матча — see PR discussion, "keep it simple/safe").
|
||||
|
||||
psycopg v3: SQL через `text(...)` использует `CAST(:x AS type)`, НИКОГДА `:x::type`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import tempfile
|
||||
import zipfile
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Константы
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# export/190 (КР1.1) и export/275 (КР1.2) без доп. параметров отдают Свердловскую
|
||||
# обл. (region 66) — подтверждено инспекцией скачанных файлов. Для других регионов
|
||||
# понадобится region-gid параметр (не реализовано — вне скоупа #2013).
|
||||
KR11_URL = "https://xn--80adsazqn.xn--p1aee.xn--p1ai/opendata/export/190"
|
||||
KR12_URL = "https://xn--80adsazqn.xn--p1aee.xn--p1ai/opendata/export/275"
|
||||
|
||||
DOWNLOAD_TIMEOUT_SEC = 180
|
||||
|
||||
# UPSERT в staging чанками (в одном SAVEPOINT), как zhkh_flats_loader.SAVEPOINT_CHUNK —
|
||||
# сбойный чанк откатывается изолированно, остальные доезжают.
|
||||
UPSERT_CHUNK_SIZE = 500
|
||||
|
||||
# Правдоподобные границы года постройки. Значение вне диапазона — почти наверняка
|
||||
# опечатка/мусор: для МКД-реестра капремонта дом < 1850 нереален, а > текущий+2 ещё
|
||||
# физически не введён в эксплуатацию. Такой год НЕ должен (а) попадать из ДОМ.РФ CSV
|
||||
# в staging (parse-гейт в parse_int_field), (б) экранировать авторитетный ДОМ.РФ
|
||||
# commission_year при бэкфилле houses.year_built (SQL-гейт в _BACKFILL_*_SQL).
|
||||
YEAR_BUILT_MIN = 1850
|
||||
YEAR_BUILT_FUTURE_SLACK = 2 # дом «в стройке» — текущий год + 2 ещё допустим
|
||||
|
||||
|
||||
def plausible_year_max() -> int:
|
||||
"""Верхняя граница правдоподобного года постройки (текущий год + slack)."""
|
||||
return date.today().year + YEAR_BUILT_FUTURE_SLACK
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Чистые хелперы парсинга (юнит-тестируются без сети/БД)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def parse_decimal_comma(raw: str | None) -> float | None:
|
||||
"""«909,80» / «909.80» / «» / None → float|None. Запятая — decimal separator ДОМ.РФ CSV."""
|
||||
if raw is None:
|
||||
return None
|
||||
s = raw.strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return float(s.replace(",", "."))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_int_field(
|
||||
raw: str | None,
|
||||
*,
|
||||
min_value: int | None = None,
|
||||
max_value: int | None = None,
|
||||
) -> int | None:
|
||||
"""Устойчивый str → int|None (commission_year / number_floors_max).
|
||||
|
||||
Пусто/None/нечисло → None. Терпит decimal-строки («5,0») через float-фоллбек —
|
||||
ДОМ.РФ CSV в принципе может так отдать целочисленные поля.
|
||||
|
||||
min_value/max_value — опциональный sanity-гейт: распарсенное значение вне
|
||||
[min_value, max_value] трактуется как None (для commission_year передаём
|
||||
YEAR_BUILT_MIN..plausible_year_max(), чтобы мусорный год не попал в staging;
|
||||
для number_floors_max границы не задаём — там гейт не требуется).
|
||||
"""
|
||||
if raw is None:
|
||||
return None
|
||||
s = raw.strip()
|
||||
if not s:
|
||||
return None
|
||||
value: int | None = None
|
||||
try:
|
||||
value = int(s)
|
||||
except ValueError:
|
||||
try:
|
||||
value = int(float(s.replace(",", ".")))
|
||||
except ValueError:
|
||||
return None
|
||||
if min_value is not None and value < min_value:
|
||||
return None
|
||||
if max_value is not None and value > max_value:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Kr11Row:
|
||||
"""Одна строка КР1.1 house registry (house-per-row)."""
|
||||
|
||||
mkd_code: str
|
||||
houseguid: str | None
|
||||
address: str | None
|
||||
commission_year: int | None
|
||||
number_floors_max: int | None
|
||||
total_sq: float | None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DomrfHouse:
|
||||
"""Объединённая КР1.1 + КР1.2(wall_material) строка — готова к UPSERT в staging."""
|
||||
|
||||
mkd_code: str
|
||||
houseguid: str | None
|
||||
address: str | None
|
||||
commission_year: int | None
|
||||
number_floors_max: int | None
|
||||
total_sq: float | None
|
||||
wall_material: str | None
|
||||
|
||||
|
||||
def _open_csv(path: str | Path) -> Iterator[dict[str, str]]:
|
||||
"""csv.DictReader по ДОМ.РФ CSV: delimiter ';', UTF-8 BOM (utf-8-sig)."""
|
||||
with open(path, encoding="utf-8-sig", newline="") as f:
|
||||
reader = csv.DictReader(f, delimiter=";")
|
||||
yield from reader
|
||||
|
||||
|
||||
def parse_kr11_csv(path: str | Path) -> dict[str, Kr11Row]:
|
||||
"""КР1.1 → dict по mkd_code. Строки без mkd_code пропускаются.
|
||||
|
||||
Дубликат mkd_code (не ожидается — mkd_code PK в реестре ДОМ.РФ) — последняя
|
||||
строка побеждает (совпадает с семантикой ON CONFLICT DO UPDATE ниже).
|
||||
"""
|
||||
rows: dict[str, Kr11Row] = {}
|
||||
for raw in _open_csv(path):
|
||||
mkd_code = (raw.get("mkd_code") or "").strip()
|
||||
if not mkd_code:
|
||||
continue
|
||||
rows[mkd_code] = Kr11Row(
|
||||
mkd_code=mkd_code,
|
||||
houseguid=(raw.get("houseguid") or "").strip() or None,
|
||||
address=(raw.get("address") or "").strip() or None,
|
||||
# sanity-гейт: год вне [1850, текущий+2] — мусор, не пускаем в staging
|
||||
# (иначе он позже мог бы записаться в houses.year_built как «авторитетный»).
|
||||
commission_year=parse_int_field(
|
||||
raw.get("commission_year"),
|
||||
min_value=YEAR_BUILT_MIN,
|
||||
max_value=plausible_year_max(),
|
||||
),
|
||||
number_floors_max=parse_int_field(raw.get("number_floors_max")),
|
||||
total_sq=parse_decimal_comma(raw.get("total_sq")),
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def parse_kr12_wall_materials(path: str | Path) -> dict[str, str]:
|
||||
"""КР1.2 (long-format) → dict mkd_code → wall_material.
|
||||
|
||||
wall_material непустой ТОЛЬКО на строке construction_element_type='фасад' —
|
||||
группировка не по этому полю, а просто "первая непустая wall_material на
|
||||
mkd_code" (устойчиво даже если разметка типа элемента когда-то изменится).
|
||||
"""
|
||||
materials: dict[str, str] = {}
|
||||
for raw in _open_csv(path):
|
||||
mkd_code = (raw.get("mkd_code") or "").strip()
|
||||
wall = (raw.get("wall_material") or "").strip()
|
||||
if mkd_code and wall and mkd_code not in materials:
|
||||
materials[mkd_code] = wall
|
||||
return materials
|
||||
|
||||
|
||||
def build_domrf_houses(
|
||||
kr11: dict[str, Kr11Row], wall_materials: dict[str, str]
|
||||
) -> list[DomrfHouse]:
|
||||
"""КР1.1 rows + КР1.2 wall_material lookup → список DomrfHouse (для UPSERT)."""
|
||||
return [
|
||||
DomrfHouse(
|
||||
mkd_code=row.mkd_code,
|
||||
houseguid=row.houseguid,
|
||||
address=row.address,
|
||||
commission_year=row.commission_year,
|
||||
number_floors_max=row.number_floors_max,
|
||||
total_sq=row.total_sq,
|
||||
wall_material=wall_materials.get(row.mkd_code),
|
||||
)
|
||||
for row in kr11.values()
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# HTTP: скачивание zip + извлечение CSV
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def _download_zip(url: str, *, client: httpx.Client) -> bytes:
|
||||
resp = client.get(url, timeout=DOWNLOAD_TIMEOUT_SEC)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
|
||||
def _extract_csv_from_zip(data: bytes, dest_dir: Path) -> Path:
|
||||
"""Распаковывает первый *.csv из zip-байтов в dest_dir, возвращает путь."""
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
csv_names = [n for n in zf.namelist() if n.lower().endswith(".csv")]
|
||||
if not csv_names:
|
||||
raise ValueError(f"zip не содержит .csv записей: {zf.namelist()!r}")
|
||||
extracted = zf.extract(csv_names[0], dest_dir)
|
||||
return Path(extracted)
|
||||
|
||||
|
||||
def fetch_domrf_csvs(dest_dir: Path, *, client: httpx.Client) -> tuple[Path, Path]:
|
||||
"""Скачивает КР1.1 + КР1.2 zip'ы, распаковывает в dest_dir. Возвращает (kr11, kr12)."""
|
||||
kr11_path = _extract_csv_from_zip(_download_zip(KR11_URL, client=client), dest_dir)
|
||||
kr12_path = _extract_csv_from_zip(_download_zip(KR12_URL, client=client), dest_dir)
|
||||
return kr11_path, kr12_path
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# UPSERT staging (domrf_kapremont, мигр. 176)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
_UPSERT_SQL = text(
|
||||
"""
|
||||
INSERT INTO domrf_kapremont (
|
||||
mkd_code, houseguid, address, commission_year,
|
||||
number_floors_max, total_sq, wall_material, loaded_at
|
||||
)
|
||||
VALUES (
|
||||
CAST(:mkd_code AS text), CAST(:houseguid AS text), CAST(:address AS text),
|
||||
CAST(:commission_year AS smallint), CAST(:number_floors_max AS int),
|
||||
CAST(:total_sq AS numeric), CAST(:wall_material AS text), now()
|
||||
)
|
||||
ON CONFLICT (mkd_code) DO UPDATE SET
|
||||
houseguid = EXCLUDED.houseguid,
|
||||
address = EXCLUDED.address,
|
||||
commission_year = EXCLUDED.commission_year,
|
||||
number_floors_max = EXCLUDED.number_floors_max,
|
||||
total_sq = EXCLUDED.total_sq,
|
||||
wall_material = EXCLUDED.wall_material,
|
||||
loaded_at = now()
|
||||
WHERE domrf_kapremont.houseguid IS DISTINCT FROM EXCLUDED.houseguid
|
||||
OR domrf_kapremont.address IS DISTINCT FROM EXCLUDED.address
|
||||
OR domrf_kapremont.commission_year IS DISTINCT FROM EXCLUDED.commission_year
|
||||
OR domrf_kapremont.number_floors_max IS DISTINCT FROM EXCLUDED.number_floors_max
|
||||
OR domrf_kapremont.total_sq IS DISTINCT FROM EXCLUDED.total_sq
|
||||
OR domrf_kapremont.wall_material IS DISTINCT FROM EXCLUDED.wall_material
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _chunk_houses(items: list[DomrfHouse], size: int) -> Iterator[list[DomrfHouse]]:
|
||||
for i in range(0, len(items), size):
|
||||
yield items[i : i + size]
|
||||
|
||||
|
||||
def upsert_domrf_kapremont(
|
||||
db: Session, houses: list[DomrfHouse], *, chunk_size: int = UPSERT_CHUNK_SIZE
|
||||
) -> int:
|
||||
"""UPSERT списка DomrfHouse в domrf_kapremont, чанками по SAVEPOINT. Не коммитит (caller).
|
||||
|
||||
Идемпотентно (IS DISTINCT FROM gate в _UPSERT_SQL — повторный прогон с теми же
|
||||
данными не трогает уже актуальные строки). Сбойный чанк откатывается изолированно
|
||||
(та же схема, что zhkh_flats_loader.match_houses_to_zhkh).
|
||||
"""
|
||||
upserted = 0
|
||||
for chunk in _chunk_houses(houses, chunk_size):
|
||||
try:
|
||||
with db.begin_nested():
|
||||
for h in chunk:
|
||||
res = db.execute(
|
||||
_UPSERT_SQL,
|
||||
{
|
||||
"mkd_code": h.mkd_code,
|
||||
"houseguid": h.houseguid,
|
||||
"address": h.address,
|
||||
"commission_year": h.commission_year,
|
||||
"number_floors_max": h.number_floors_max,
|
||||
"total_sq": h.total_sq,
|
||||
"wall_material": h.wall_material,
|
||||
},
|
||||
)
|
||||
upserted += res.rowcount
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"domrf_kapremont upsert: чанк из %d строк сбойнул (откат savepoint)",
|
||||
len(chunk),
|
||||
exc_info=True,
|
||||
)
|
||||
return upserted
|
||||
|
||||
|
||||
def load_domrf_kapremont(
|
||||
db: Session,
|
||||
*,
|
||||
kr11_path: str | Path | None = None,
|
||||
kr12_path: str | Path | None = None,
|
||||
work_dir: str | Path | None = None,
|
||||
chunk_size: int = UPSERT_CHUNK_SIZE,
|
||||
dry_run: bool = False,
|
||||
) -> dict[str, int]:
|
||||
"""Скачивает (если пути не заданы) КР1.1+КР1.2, парсит, UPSERT в domrf_kapremont.
|
||||
|
||||
kr11_path/kr12_path — локальные CSV (пропустить скачивание; используется тестами
|
||||
и ops-дебагом). work_dir — куда распаковывать скачанные zip (по умолчанию —
|
||||
временный каталог, удаляется после загрузки). dry_run — парс происходит, но НИ
|
||||
ОДНОЙ записи в БД не делается. Не коммитит (caller).
|
||||
"""
|
||||
tmp_ctx: tempfile.TemporaryDirectory[str] | None = None
|
||||
if kr11_path is None or kr12_path is None:
|
||||
if work_dir is not None:
|
||||
dest = Path(work_dir)
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
tmp_ctx = tempfile.TemporaryDirectory()
|
||||
dest = Path(tmp_ctx.name)
|
||||
# verify=False: домрф.рф отдаёт RU-сертификат, не верифицируемый дефолтным
|
||||
# trust store'ом (та же ситуация, что sber_index.py). Публичные open data,
|
||||
# без auth/PII — приемлемо skip'нуть TLS-верификацию.
|
||||
with httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False) as client:
|
||||
if kr11_path is None:
|
||||
kr11_path = _extract_csv_from_zip(_download_zip(KR11_URL, client=client), dest)
|
||||
if kr12_path is None:
|
||||
kr12_path = _extract_csv_from_zip(_download_zip(KR12_URL, client=client), dest)
|
||||
|
||||
try:
|
||||
kr11 = parse_kr11_csv(kr11_path)
|
||||
wall_materials = parse_kr12_wall_materials(kr12_path)
|
||||
houses = build_domrf_houses(kr11, wall_materials)
|
||||
upserted = 0 if dry_run else upsert_domrf_kapremont(db, houses, chunk_size=chunk_size)
|
||||
finally:
|
||||
if tmp_ctx is not None:
|
||||
tmp_ctx.cleanup()
|
||||
|
||||
result = {
|
||||
"kr11_rows": len(kr11),
|
||||
"kr12_wall_rows": len(wall_materials),
|
||||
"houses_built": len(houses),
|
||||
"upserted": upserted,
|
||||
}
|
||||
logger.info("domrf_kapremont load DONE (dry_run=%s): %s", dry_run, result)
|
||||
return result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Backfill houses.year_built/material_walls/total_floors
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Шаг 1: дома с domrf-матчем — year_built/material_walls/total_floors из domrf,
|
||||
# С ЗАФОЛЖЕННЫМ zhkh_year/zhkh_floors фоллбеком (COALESCE, как в issue #2013).
|
||||
#
|
||||
# ПРИОРИТЕТ year_built (Fix A): ВАЛИДНОЕ существующее значение выигрывает (COALESCE
|
||||
# семантика сохранена для valid). НО невозможное существующее значение (< :ymin,
|
||||
# > :ymax или NULL) НЕ должно экранировать авторитетный ДОМ.РФ commission_year —
|
||||
# для домов реестра капремонта commission_year это источник истины по году ввода.
|
||||
# Поэтому impossible/NULL заменяется валидным commission_year → затем валидным
|
||||
# zhkh_year; если валидной замены нет — оставляем существующее как есть (не обнуляем
|
||||
# даже мусор, чтобы не терять данные без выигрыша). Кандидаты замены тоже проходят
|
||||
# плаузибилити-гейт [:ymin, :ymax], чтобы в houses.year_built не попал мусорный год.
|
||||
# material_walls/total_floors — прежняя COALESCE-семантика (только заполнить NULL).
|
||||
_BACKFILL_HOUSES_FROM_DOMRF_SQL = text(
|
||||
"""
|
||||
UPDATE houses h
|
||||
SET year_built = CASE
|
||||
WHEN h.year_built BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
|
||||
THEN h.year_built
|
||||
ELSE COALESCE(
|
||||
CASE WHEN d.commission_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
|
||||
THEN d.commission_year END,
|
||||
CASE WHEN h.zhkh_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
|
||||
THEN h.zhkh_year END,
|
||||
h.year_built
|
||||
)
|
||||
END,
|
||||
material_walls = COALESCE(h.material_walls, d.wall_material),
|
||||
total_floors = COALESCE(h.total_floors, d.number_floors_max, h.zhkh_floors)
|
||||
FROM domrf_kapremont d
|
||||
WHERE d.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)
|
||||
AND (
|
||||
(
|
||||
(h.year_built IS NULL
|
||||
OR h.year_built NOT BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int))
|
||||
AND COALESCE(
|
||||
CASE WHEN d.commission_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
|
||||
THEN d.commission_year END,
|
||||
CASE WHEN h.zhkh_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
|
||||
THEN h.zhkh_year END
|
||||
) IS NOT NULL
|
||||
)
|
||||
OR (h.material_walls IS NULL AND d.wall_material IS NOT NULL)
|
||||
OR (h.total_floors IS NULL AND COALESCE(d.number_floors_max, h.zhkh_floors) IS NOT NULL)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
_BACKFILL_HOUSES_FROM_DOMRF_COUNT_SQL = text(
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM houses h
|
||||
JOIN domrf_kapremont d
|
||||
ON d.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)
|
||||
WHERE (
|
||||
(h.year_built IS NULL
|
||||
OR h.year_built NOT BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int))
|
||||
AND COALESCE(
|
||||
CASE WHEN d.commission_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
|
||||
THEN d.commission_year END,
|
||||
CASE WHEN h.zhkh_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
|
||||
THEN h.zhkh_year END
|
||||
) IS NOT NULL
|
||||
)
|
||||
OR (h.material_walls IS NULL AND d.wall_material IS NOT NULL)
|
||||
OR (h.total_floors IS NULL AND COALESCE(d.number_floors_max, h.zhkh_floors) IS NOT NULL)
|
||||
"""
|
||||
)
|
||||
|
||||
# Шаг 2: ЛЮБОЙ дом (включая без domrf-матча вообще — шаг 1 INNER JOIN их не трогает)
|
||||
# добивается zhkh_year/zhkh_floors, если ещё NULL. Домам, уже тронутым шагом 1, шаг 2
|
||||
# ничего не меняет: их COALESCE в шаге 1 уже включал zhkh_* как fallback, поэтому
|
||||
# гейт `IS NULL AND zhkh_* IS NOT NULL` здесь для них ложен — шаги взаимоисключающи
|
||||
# (не двойной счёт при суммировании houses_updated).
|
||||
_BACKFILL_HOUSES_ZHKH_FALLBACK_SQL = text(
|
||||
"""
|
||||
UPDATE houses
|
||||
SET year_built = COALESCE(year_built, zhkh_year),
|
||||
total_floors = COALESCE(total_floors, zhkh_floors)
|
||||
WHERE (year_built IS NULL AND zhkh_year IS NOT NULL)
|
||||
OR (total_floors IS NULL AND zhkh_floors IS NOT NULL)
|
||||
"""
|
||||
)
|
||||
|
||||
_BACKFILL_HOUSES_ZHKH_FALLBACK_COUNT_SQL = text(
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM houses
|
||||
WHERE (year_built IS NULL AND zhkh_year IS NOT NULL)
|
||||
OR (total_floors IS NULL AND zhkh_floors IS NOT NULL)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def backfill_houses_from_domrf(db: Session, *, dry_run: bool = False) -> dict[str, int]:
|
||||
"""COALESCE(year_built, domrf.commission_year, zhkh_year) + material_walls + total_floors.
|
||||
|
||||
Двухшаговый UPDATE (мирроринг zhkh guid-match → cadastre-fallback паттерна из
|
||||
zhkh_flats_loader.py):
|
||||
1) дома с domrf-матчем (COALESCE(gar_house_guid, house_fias_id, zhkh_house_guid) =
|
||||
domrf_kapremont.houseguid) — заполняются из domrf, с zhkh_year/zhkh_floors
|
||||
фоллбеком внутри той же COALESCE.
|
||||
2) ЛЮБОЙ дом (в т.ч. без domrf-матча) — добивает year_built/total_floors из
|
||||
zhkh_year/zhkh_floors, если шаг 1 их не тронул.
|
||||
Только COALESCE (заполняет NULL, никогда не перезаписывает existing non-null).
|
||||
Не коммитит (caller). dry_run — ноль записей, только SELECT count(*) по тем же
|
||||
предикатам.
|
||||
"""
|
||||
year_bounds = {"ymin": YEAR_BUILT_MIN, "ymax": plausible_year_max()}
|
||||
if dry_run:
|
||||
domrf_matched = db.execute(_BACKFILL_HOUSES_FROM_DOMRF_COUNT_SQL, year_bounds).scalar_one()
|
||||
zhkh_fallback = db.execute(_BACKFILL_HOUSES_ZHKH_FALLBACK_COUNT_SQL).scalar_one()
|
||||
result = {
|
||||
"domrf_matched": domrf_matched,
|
||||
"zhkh_fallback": zhkh_fallback,
|
||||
"houses_updated": 0,
|
||||
}
|
||||
logger.info("backfill_houses_from_domrf DRY-RUN: %s", result)
|
||||
return result
|
||||
|
||||
domrf_updated = db.execute(_BACKFILL_HOUSES_FROM_DOMRF_SQL, year_bounds).rowcount
|
||||
zhkh_updated = db.execute(_BACKFILL_HOUSES_ZHKH_FALLBACK_SQL).rowcount
|
||||
|
||||
result = {
|
||||
"domrf_matched": domrf_updated,
|
||||
"zhkh_fallback": zhkh_updated,
|
||||
"houses_updated": domrf_updated + zhkh_updated,
|
||||
}
|
||||
logger.info("backfill_houses_from_domrf DONE: %s", result)
|
||||
return result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Propagate houses.year_built → listings.year_built
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Только NULL listings.year_built, только когда houses.year_built уже известен —
|
||||
# никогда не перезаписывает существующий listings.year_built (source-provided данные
|
||||
# приоритетнее houses-агрегата).
|
||||
#
|
||||
# LINK-CONSISTENCY GUARD (Fix B): раньше пропагация шла по house_id_fk БЕЗ проверки,
|
||||
# что listing реально относится к этому дому. Перепривязанный FK (house dedup/merge
|
||||
# «съезжает» на дом за сотни метров) впрыскивал бы чужой когортный год в живой comp.
|
||||
# Пропагируем только если координаты самого объявления в пределах
|
||||
# PROPAGATE_MAX_DISTANCE_M от геометрии дома. Если у объявления нет координат —
|
||||
# консервативный address-фоллбек: адрес объявления должен содержать short_address
|
||||
# дома (тот же дом по адресной строке); нет short_address — не пропагируем.
|
||||
PROPAGATE_MAX_DISTANCE_M = 500
|
||||
|
||||
# Общий link-consistency предикат для UPDATE и COUNT (единый источник — одна правка
|
||||
# порога PROPAGATE_MAX_DISTANCE_M меняет обе SQL). Порог — int-константа, не bind-param
|
||||
# (в ST_DWithin проще литерал; значение доверенное, не из пользовательского ввода).
|
||||
_PROPAGATE_LINK_GUARD = f"""
|
||||
AND (
|
||||
(l.geom IS NOT NULL AND h.geom IS NOT NULL
|
||||
AND ST_DWithin(
|
||||
CAST(l.geom AS geography), CAST(h.geom AS geography),
|
||||
{PROPAGATE_MAX_DISTANCE_M}))
|
||||
OR (l.geom IS NULL
|
||||
AND h.short_address IS NOT NULL AND l.address IS NOT NULL
|
||||
AND l.address ILIKE '%' || h.short_address || '%')
|
||||
)
|
||||
"""
|
||||
|
||||
_PROPAGATE_LISTINGS_YEAR_SQL = text(
|
||||
f"""
|
||||
UPDATE listings l
|
||||
SET year_built = h.year_built
|
||||
FROM houses h
|
||||
WHERE l.house_id_fk = h.id
|
||||
AND l.year_built IS NULL
|
||||
AND h.year_built IS NOT NULL
|
||||
{_PROPAGATE_LINK_GUARD}
|
||||
"""
|
||||
)
|
||||
|
||||
_PROPAGATE_LISTINGS_YEAR_COUNT_SQL = text(
|
||||
f"""
|
||||
SELECT count(*)
|
||||
FROM listings l
|
||||
JOIN houses h ON l.house_id_fk = h.id
|
||||
WHERE l.year_built IS NULL
|
||||
AND h.year_built IS NOT NULL
|
||||
{_PROPAGATE_LINK_GUARD}
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def propagate_listings_year_from_houses(db: Session, *, dry_run: bool = False) -> dict[str, int]:
|
||||
"""UPDATE listings.year_built = houses.year_built где listing.year_built ещё NULL.
|
||||
|
||||
Не коммитит (caller). dry_run — ноль записей, только SELECT count(*) по тому же
|
||||
предикату (ключ результата — would_update вместо listings_updated).
|
||||
"""
|
||||
if dry_run:
|
||||
would_update = db.execute(_PROPAGATE_LISTINGS_YEAR_COUNT_SQL).scalar_one()
|
||||
logger.info("propagate_listings_year_from_houses DRY-RUN: would_update=%d", would_update)
|
||||
return {"listings_updated": 0, "would_update": would_update}
|
||||
|
||||
updated = db.execute(_PROPAGATE_LISTINGS_YEAR_SQL).rowcount
|
||||
logger.info("propagate_listings_year_from_houses DONE: listings_updated=%d", updated)
|
||||
return {"listings_updated": updated}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -40,7 +40,6 @@ import logging
|
|||
import pathlib
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
from uuid import UUID
|
||||
|
||||
import matplotlib
|
||||
|
||||
|
|
@ -376,11 +375,60 @@ def _mono(text: str) -> str:
|
|||
return f"<span class='mono'>{text}</span>"
|
||||
|
||||
|
||||
def _report_number(estimate_id: UUID) -> str:
|
||||
"""№ отчёта: 'EKБ-NNNN-XXXXXXX' где NNNN — короткий код."""
|
||||
short = int(estimate_id.int) % 10_000
|
||||
long = int(estimate_id.int) % 10_000_000_000
|
||||
return f"EKБ-{short:04d}-{long:010d}"
|
||||
# Город (region 66) → буквенный префикс № отчёта. Раньше _report_number
|
||||
# захардкоживал «EKБ» для ВСЕХ оценок — объект в Серове / Нижнем Тагиле получал
|
||||
# екатеринбургский код на обложке, в шапках и в футере (#pdf-honesty). Префикс
|
||||
# теперь выводится из адреса оценки; ключи в нижнем регистре, ё→е (симметрично
|
||||
# _resolve_target_city в estimator.py). Город вне карты → _DEFAULT_REPORT_PREFIX.
|
||||
_CITY_REPORT_PREFIX: dict[str, str] = {
|
||||
"екатеринбург": "ЕКБ",
|
||||
"нижний тагил": "НТ",
|
||||
"каменск-уральский": "КУ",
|
||||
"первоуральск": "ПРВ",
|
||||
"серов": "СЕР",
|
||||
"новоуральск": "НВУ",
|
||||
"ревда": "РЕВ",
|
||||
"полевской": "ПЛВ",
|
||||
"асбест": "АСБ",
|
||||
"верхняя пышма": "ВП",
|
||||
"березовский": "БРЗ",
|
||||
"краснотурьинск": "КРТ",
|
||||
"камышлов": "КМШ",
|
||||
}
|
||||
# Нейтральный префикс, когда город адреса не распознан (пустой / вне региона) —
|
||||
# НЕ выдумываем локацию, не подставляем ложный «EKБ» (#pdf-honesty).
|
||||
_DEFAULT_REPORT_PREFIX = "МЕРА"
|
||||
# Длинные имена раньше коротких — чтобы «нижний тагил» матчился целиком.
|
||||
_REPORT_CITY_RE = re.compile(
|
||||
r"\b(?:"
|
||||
+ "|".join(re.escape(c) for c in sorted(_CITY_REPORT_PREFIX, key=len, reverse=True))
|
||||
+ r")\b"
|
||||
)
|
||||
|
||||
|
||||
def _report_city_prefix(address: str | None) -> str:
|
||||
"""Буквенный префикс № отчёта по городу из адреса (region 66).
|
||||
|
||||
«МЕРА» если город не распознан — не выдумываем локацию (раньше был хардкод
|
||||
«EKБ» для любого адреса, включая не-екатеринбургские). ё→е нормализация до
|
||||
поиска (симметрично _resolve_target_city в estimator.py)."""
|
||||
if not address:
|
||||
return _DEFAULT_REPORT_PREFIX
|
||||
norm = address.replace("ё", "е").replace("Ё", "е").lower()
|
||||
m = _REPORT_CITY_RE.search(norm)
|
||||
return _CITY_REPORT_PREFIX[m.group(0)] if m else _DEFAULT_REPORT_PREFIX
|
||||
|
||||
|
||||
def _report_number(estimate: AggregatedEstimate) -> str:
|
||||
"""№ отчёта: 'PREFIX-NNNN-XXXXXXXXXX'.
|
||||
|
||||
PREFIX — буквенный код города оценки (ЕКБ / НТ / … по target_address, «МЕРА»
|
||||
если город не распознан); NNNN/XXXXXXXXXX — детерминированные коды от
|
||||
estimate_id. Раньше PREFIX был захардкожен «EKБ» для всех оценок (#pdf-honesty)."""
|
||||
prefix = _report_city_prefix(estimate.target_address or estimate.canonical_address)
|
||||
short = int(estimate.estimate_id.int) % 10_000
|
||||
long = int(estimate.estimate_id.int) % 10_000_000_000
|
||||
return f"{prefix}-{short:04d}-{long:010d}"
|
||||
|
||||
|
||||
def _expires_date(estimate: AggregatedEstimate) -> dt.date:
|
||||
|
|
@ -518,7 +566,7 @@ def _page_footer(
|
|||
# тела страницы) и был источником сложности; заменён на простую тонкую
|
||||
# градиентную линию-разделитель между строками meta/wordmark.
|
||||
return f"""
|
||||
<div style="margin-top:20pt;">
|
||||
<div style="margin-top:6pt;">
|
||||
{note_html}
|
||||
<div style="border-top:1pt solid {_LINE_SOFT};padding-top:8pt;
|
||||
font-family:{mono_family};font-size:{_FS_XS};letter-spacing:0.06em;
|
||||
|
|
@ -811,8 +859,8 @@ def _dual_price_block(estimate: AggregatedEstimate, brand) -> str: # type: igno
|
|||
)
|
||||
|
||||
explainer = (
|
||||
f'<p style="margin:6pt 0 2pt 0;padding:6pt 10pt;background:{_ACCENT_SOFT};'
|
||||
f'border-radius:4pt;font-size:{_FS_SM};color:{_MUTED};line-height:1.3;">'
|
||||
f'<p style="margin:4pt 0 2pt 0;padding:4pt 10pt;background:{_ACCENT_SOFT};'
|
||||
f'border-radius:4pt;font-size:{_FS_SM};color:{_MUTED};line-height:1.25;">'
|
||||
"<strong>Запрос</strong> — по чему выставлены сопоставимые квартиры в объявлениях. "
|
||||
"<strong>Ожидаемая цена продажи</strong> — реалистичная цена сделки по данным ДКП "
|
||||
"Росреестра, обычно ниже запроса."
|
||||
|
|
@ -821,7 +869,7 @@ def _dual_price_block(estimate: AggregatedEstimate, brand) -> str: # type: igno
|
|||
|
||||
return (
|
||||
f'<table style="width:100%;border-collapse:separate;border-spacing:8pt 0;'
|
||||
f'margin:6pt 0 0 0;"><tr>{asking_cell}{sold_cell}</tr></table>'
|
||||
f'margin:3pt 0 0 0;"><tr>{asking_cell}{sold_cell}</tr></table>'
|
||||
f"{explainer}"
|
||||
)
|
||||
|
||||
|
|
@ -970,7 +1018,7 @@ def _quality_card_html(estimate: AggregatedEstimate) -> str:
|
|||
cv_str = f"{cv}%" if cv is not None else "—"
|
||||
gauge = _data_quality_gauge_svg(estimate.confidence)
|
||||
return f"""
|
||||
<div class="card-glass bracket-tr" style="margin:6pt 0 6pt 0;padding:8pt 12pt 7pt 12pt;
|
||||
<div class="card-glass bracket-tr" style="margin:3pt 0 3pt 0;padding:6pt 12pt 5pt 12pt;
|
||||
display:flex;align-items:center;gap:12pt;flex-wrap:wrap;">
|
||||
{gauge}
|
||||
<div style="flex:1;min-width:170pt;overflow-wrap:anywhere;">
|
||||
|
|
@ -1000,7 +1048,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
|
|||
if estimate.expires_at is not None
|
||||
else today + dt.timedelta(days=30)
|
||||
)
|
||||
report_num = _report_number(estimate.estimate_id)
|
||||
report_num = _report_number(estimate)
|
||||
|
||||
# Короткий адрес (для cover): берём первую часть до запятой
|
||||
full_address = input_snapshot.get("address", "—")
|
||||
|
|
@ -1038,29 +1086,37 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
|
|||
house_label = house_labels.get(house_type, "—") if house_type else "—"
|
||||
repair_label = repair_labels.get(repair_state, "Не указано") if repair_state else "Не указано"
|
||||
balcony_label = "Есть" if has_balcony else "Нет" if has_balcony is False else "—"
|
||||
# #pdf-none: year_built может быть None (адрес без house_metadata) — раньше в
|
||||
# таблицу утекало литеральное «None»; показываем «—» как в остальных пустых полях.
|
||||
year_label = _mono(str(year_built)) if year_built else "—"
|
||||
|
||||
# Active market subband — 4-118 days range (fallback без данных).
|
||||
# Если есть days_on_market в analogs — берём min/max, иначе фиксированно.
|
||||
days_min, days_max = _days_on_market_range(estimate.analogs)
|
||||
|
||||
# Deals range — если deals есть, считаем; иначе fallback к listings range
|
||||
deals_low, deals_high = _deals_range(
|
||||
estimate.actual_deals, fallback=(estimate.range_low_rub, estimate.range_high_rub)
|
||||
)
|
||||
# Срок экспозиции показываем ТОЛЬКО при реальных days_on_market в analogs;
|
||||
# если данных нет — days не рисуем (не выдумываем «4-118 дней», #pdf-honesty).
|
||||
days_range = _days_on_market_range(estimate.analogs)
|
||||
|
||||
listings_bar = _price_range_chart_svg(
|
||||
estimate.range_low_rub,
|
||||
estimate.range_high_rub,
|
||||
sub_label="Активный рынок с аналогичным состоянием ремонта",
|
||||
days_min=days_min,
|
||||
days_max=days_max,
|
||||
show_days=True,
|
||||
days_min=days_range[0] if days_range else None,
|
||||
days_max=days_range[1] if days_range else None,
|
||||
show_days=days_range is not None,
|
||||
)
|
||||
|
||||
deals_bar = _price_range_chart_svg(
|
||||
deals_low,
|
||||
deals_high,
|
||||
sub_label="Диапазон цен по фактическим сделкам",
|
||||
# Совет «Цены в объявлениях — ожидания собственников» ссылается на РЕАЛЬНЫЙ
|
||||
# рассчитанный дисконт запрос→продажа (тот же, что chip «−N%» в dual-price),
|
||||
# а не хардкод «10–18%», который противоречил бы вычисленному «−N%» (#pdf-honesty).
|
||||
discount_pct = _discount_pct(estimate)
|
||||
if discount_pct is not None:
|
||||
advice_discount_text = (
|
||||
f"Фактические сделки проходят ниже цен в объявлениях — по этому объекту "
|
||||
f"на {discount_pct}% (см. «Ожидаемая цена продажи»); подтверждают Росреестр, "
|
||||
f"ДомКлик и продажи агентств недвижимости"
|
||||
)
|
||||
else:
|
||||
advice_discount_text = (
|
||||
"Фактические сделки проходят ниже цен в объявлениях, что подтверждают "
|
||||
"Росреестр, ДомКлик и продажи агентств недвижимости"
|
||||
)
|
||||
|
||||
disclaimer_html = ""
|
||||
|
|
@ -1070,8 +1126,6 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
|
|||
f"border-top:1pt solid {_LINE};padding-top:6pt;overflow-wrap:anywhere;'>"
|
||||
f"{_html.escape(brand.pdf_disclaimer)}</p>"
|
||||
)
|
||||
footer_html = _page_footer(brand, report_num, today, valid_until=expires)
|
||||
|
||||
heading_html = _section_heading(
|
||||
"01",
|
||||
"АНАЛИЗ РЫНКА И РАСЧЕТ ВЫКУПНОЙ СТОИМОСТИ КВАРТИРЫ",
|
||||
|
|
@ -1082,12 +1136,10 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
|
|||
return f"""
|
||||
<div style="page-break-after:always;">
|
||||
|
||||
{_page_header(brand, report_num, today)}
|
||||
|
||||
{heading_html}
|
||||
|
||||
<!-- Параметры квартиры (dotted-row — ObjectSummary.tsx summary-row идиома) -->
|
||||
<table class="params-table" style="width:100%;border-collapse:collapse;margin-bottom:10pt;">
|
||||
<table class="params-table" style="width:100%;border-collapse:collapse;margin-bottom:6pt;">
|
||||
<tr><td class="dotted-row">№ отчета</td>
|
||||
<td class="bold mono dotted-row">{_mono(report_num)}</td></tr>
|
||||
<tr><td class="dotted-row">Дата отчета</td>
|
||||
|
|
@ -1096,7 +1148,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
|
|||
<td class="bold dotted-row">до {_mono(expires.strftime("%d.%m.%Y"))}</td></tr>
|
||||
<tr><td class="dotted-row">Адрес</td><td class="bold dotted-row">{address}</td></tr>
|
||||
<tr><td class="dotted-row">Год постройки</td>
|
||||
<td class="bold dotted-row">{_mono(str(year_built))}</td></tr>
|
||||
<td class="bold dotted-row">{year_label}</td></tr>
|
||||
<tr><td class="dotted-row">Тип дома</td><td class="bold dotted-row">{house_label}</td></tr>
|
||||
<tr><td class="dotted-row">Этаж / этажность</td>
|
||||
<td class="bold dotted-row">{_mono(f"{floor} / {total_floors}")}</td></tr>
|
||||
|
|
@ -1116,58 +1168,56 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
|
|||
{_dual_price_block(estimate, brand)}
|
||||
|
||||
<div style="break-inside:avoid;page-break-inside:avoid;">
|
||||
<p style="margin:6pt 0 2pt 0;font-size:{_FS_MD};font-weight:700;">
|
||||
<p style="margin:3pt 0 2pt 0;font-size:{_FS_MD};font-weight:700;">
|
||||
Диапазон цен в объявлениях
|
||||
<span style="font-weight:400;font-size:{_FS_SM};color:{_MUTED};">(без учета ремонта)</span>
|
||||
</p>
|
||||
{listings_bar}
|
||||
</div>
|
||||
|
||||
<div style="break-inside:avoid;page-break-inside:avoid;">
|
||||
<p style="margin:6pt 0 2pt 0;font-size:{_FS_MD};font-weight:700;">
|
||||
Диапазон цен по фактическим сделкам
|
||||
</p>
|
||||
{deals_bar}
|
||||
</div>
|
||||
|
||||
<!-- Что важно при оценке -->
|
||||
<h3 style="margin-top:8pt;font-size:{_FS_MD};font-weight:700;">
|
||||
<h3 style="margin-top:4pt;font-size:{_FS_MD};font-weight:700;">
|
||||
Что важно при оценке стоимости квартиры:
|
||||
</h3>
|
||||
<table class="advice-table" style="width:100%;border-collapse:collapse;margin-top:4pt;">
|
||||
<table class="advice-table" style="width:100%;border-collapse:collapse;margin-top:2pt;">
|
||||
<tr>
|
||||
<td class="advice-title">Цены в объявлениях — ожидания собственников</td>
|
||||
<td class="advice-text">Фактические сделки проходят на 10–18% ниже, что подтверждают
|
||||
Росреестр, ДомКлик и продажи агентств недвижимости</td>
|
||||
<td class="advice-text">{advice_discount_text}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="advice-title">Ремонт оценивается по состоянию, а не по вложенным суммам</td>
|
||||
<td class="advice-text">Инвестиции в 400 – 600 тыс. руб. повышают цену объекта всего
|
||||
на 150 – 250 тыс. руб — покупатель оценивает общее состояние квартиры</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="advice-title">Неочевидные расходы при самостоятельной продаже</td>
|
||||
<td class="advice-text">При самостоятельной продаже суммарные расходы могут достигать
|
||||
до 15% стоимости квартиры (торг, риелтор, нотариус, справки)</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- #4-pages-fix: 3-я строка advice-table («Неочевидные расходы при
|
||||
самостоятельной продаже — до 15%») убрана — дублирует детальную
|
||||
постатейную разбивку на Offer-странице (см. _build_offer_page: торг
|
||||
5-15% + риелтор 2-5% + аренда + юрсопровождение + реклама), которая
|
||||
даёт ту же цифру НАМНОГО подробнее. Единственный content-cut в этом
|
||||
фиксе (см. PR/задача про 2 пустые страницы); остальной контент cover
|
||||
сохранён полностью. -->
|
||||
|
||||
<p style="margin-top:6pt;font-size:{_FS_XS};color:{_MUTED};border-top:1pt solid {_LINE};
|
||||
padding-top:4pt;overflow-wrap:anywhere;">
|
||||
<p style="margin-top:3pt;font-size:{_FS_XS};color:{_MUTED};border-top:1pt solid {_LINE};
|
||||
padding-top:3pt;overflow-wrap:anywhere;">
|
||||
<strong>Этот отчёт онлайн:</strong> {settings.public_url}?id={estimate.estimate_id}
|
||||
</p>
|
||||
{disclaimer_html}
|
||||
{footer_html}
|
||||
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def _days_on_market_range(lots: list[AnalogLot]) -> tuple[int, int]:
|
||||
"""Min/max days_on_market по аналогам. Fallback 4-118 при отсутствии данных."""
|
||||
def _days_on_market_range(lots: list[AnalogLot]) -> tuple[int, int] | None:
|
||||
"""Min/max days_on_market по аналогам, либо None если реальных данных нет.
|
||||
|
||||
Раньше возвращал хардкод (4, 118) при отсутствии days_on_market — обложка и
|
||||
страница объявлений печатали этот выдуманный коридор как измеренный срок
|
||||
экспозиции (~48% оценок без days_on_market). Теперь None → caller не рисует
|
||||
срок (show_days=False), как уже делает страница сделок (#pdf-honesty)."""
|
||||
days = [lot.days_on_market for lot in lots if lot.days_on_market is not None]
|
||||
if not days:
|
||||
return 4, 118
|
||||
return None
|
||||
return min(days), max(days)
|
||||
|
||||
|
||||
|
|
@ -1225,38 +1275,27 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
|
|||
)
|
||||
rooms_label = "Студия" if rooms == 0 else f"{rooms} комнаты"
|
||||
|
||||
# Полоска диапазона
|
||||
days_min, days_max = _days_on_market_range(estimate.analogs)
|
||||
# Полоска диапазона — срок экспозиции только при реальных days_on_market
|
||||
# (иначе не рисуем, не выдумываем «4-118 дней», #pdf-honesty).
|
||||
days_range = _days_on_market_range(estimate.analogs)
|
||||
range_bar = _price_range_chart_svg(
|
||||
estimate.range_low_rub,
|
||||
estimate.range_high_rub,
|
||||
sub_label="Рынок",
|
||||
days_min=days_min,
|
||||
days_max=days_max,
|
||||
show_days=True,
|
||||
days_min=days_range[0] if days_range else None,
|
||||
days_max=days_range[1] if days_range else None,
|
||||
show_days=days_range is not None,
|
||||
)
|
||||
|
||||
# Топ-5 примеров (отсортированных по distance)
|
||||
top5 = sorted(estimate.analogs, key=lambda x: x.distance_m or 9999)[:5]
|
||||
examples_rows = _examples_rows(top5)
|
||||
|
||||
report_num = _report_number(estimate.estimate_id)
|
||||
today = dt.date.today()
|
||||
footer_html = _page_footer(
|
||||
brand,
|
||||
report_num,
|
||||
today,
|
||||
f"{_html.escape(brand.name)} · Анализ рынка вторичной недвижимости · стр. 2",
|
||||
valid_until=_expires_date(estimate),
|
||||
)
|
||||
|
||||
heading_html = _section_heading("02", "РЫНОК КВАРТИР – АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ")
|
||||
|
||||
return f"""
|
||||
<div style="page-break-after:always;">
|
||||
|
||||
{_page_header(brand, report_num, today)}
|
||||
|
||||
{heading_html}
|
||||
|
||||
<!-- Два колонки: левая (counts + источники), правая (params card + bracket-tr) -->
|
||||
|
|
@ -1306,8 +1345,6 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
|
|||
<tbody>{examples_rows}</tbody>
|
||||
</table>
|
||||
|
||||
{footer_html}
|
||||
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
|
@ -1391,6 +1428,21 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
|
|||
today = dt.date.today()
|
||||
period_start = today - dt.timedelta(days=estimate.period_months * 30)
|
||||
|
||||
# Баннер дисконта ссылается на РЕАЛЬНЫЙ рассчитанный дисконт запрос→продажа
|
||||
# (тот же _discount_pct, что chip «−N%» на обложке), а не хардкод «10–18%»,
|
||||
# который противоречил бы вычисленному «−N%» в том же PDF. Убран и ложный
|
||||
# хвост «(Екатеринбург, 2026)» — локация не привязана к объекту (#pdf-honesty).
|
||||
discount_pct = _discount_pct(estimate)
|
||||
if discount_pct is not None:
|
||||
deals_discount_text = (
|
||||
"По данным реальных сделок, квартиры продаются дешевле, чем заявлено в "
|
||||
f"объявлениях — по этому объекту на {discount_pct}%"
|
||||
)
|
||||
else:
|
||||
deals_discount_text = (
|
||||
"По данным реальных сделок, квартиры продаются дешевле, чем заявлено в объявлениях"
|
||||
)
|
||||
|
||||
# Источники для сделок — берём из estimate.sources_used (не захардкоженный список).
|
||||
# Фильтруем по известным источникам сделок; fallback к пустому (не fabricate).
|
||||
_deal_source_keys = {"etazhi", "domklik", "rosreestr"}
|
||||
|
|
@ -1438,22 +1490,11 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
|
|||
top5 = estimate.actual_deals[:5]
|
||||
examples_rows = _examples_rows(top5)
|
||||
|
||||
report_num = _report_number(estimate.estimate_id)
|
||||
footer_html = _page_footer(
|
||||
brand,
|
||||
report_num,
|
||||
today,
|
||||
f"{_html.escape(brand.name)} · Анализ рынка вторичной недвижимости · стр. 3",
|
||||
valid_until=_expires_date(estimate),
|
||||
)
|
||||
|
||||
heading_html = _section_heading("03", "ФАКТИЧЕСКИЕ СДЕЛКИ ПО КВАРТИРАМ — АНАЛОГАМ")
|
||||
|
||||
return f"""
|
||||
<div style="page-break-after:always;">
|
||||
|
||||
{_page_header(brand, report_num, today)}
|
||||
|
||||
{heading_html}
|
||||
|
||||
<table style="width:100%;border-collapse:collapse;margin-bottom:14pt;">
|
||||
|
|
@ -1495,8 +1536,7 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
|
|||
|
||||
<div style="margin-top:14pt;padding:10pt 14pt;border-left:3pt solid {_DANGER};
|
||||
background:{_DANGER_SOFT};font-size:{_FS_MD};color:{_DANGER};font-weight:600;">
|
||||
По данным реальных сделок, квартиры продаются в среднем на 10–18% дешевле,
|
||||
чем заявлено в объявлениях (Екатеринбург, 2026)
|
||||
{deals_discount_text}
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top:16pt;font-size:{_FS_MD};font-weight:700;">
|
||||
|
|
@ -1508,8 +1548,6 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
|
|||
<tbody>{examples_rows}</tbody>
|
||||
</table>
|
||||
|
||||
{footer_html}
|
||||
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
|
@ -1517,7 +1555,7 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
|
|||
# ── Page 4: Offer (Trade-In vs Самопродажа) ──────────────────────────────────
|
||||
|
||||
|
||||
def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: ignore[no-untyped-def]
|
||||
def _build_offer_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
|
||||
median = estimate.median_price_rub
|
||||
|
||||
# Расчёт расходов: часть — на основе медианы (торг «от цены в объявлении»),
|
||||
|
|
@ -1544,8 +1582,24 @@ def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: igno
|
|||
rieltor_low = int(sold_price * rieltor_pct_low / 100)
|
||||
rieltor_high = int(sold_price * rieltor_pct_high / 100)
|
||||
|
||||
# 3 месяца аренды 1-комнатной квартиры, ЕКБ 2026: ~28-45 тыс. руб./мес.
|
||||
rent_low, rent_high = 84_000, 135_000
|
||||
# 3 месяца аренды квартиры, ЕКБ 2026 (базовая ставка — студия/1-комн., ~28-45
|
||||
# тыс. руб./мес.), масштабируется по числу комнат объекта (#pdf-honesty: раньше
|
||||
# был плоский 1-комнатный тариф для ЛЮБОГО объекта, включая 3-4-комнатные).
|
||||
# Ориентировочные месячные ставки аренды по комнатности, ЕКБ 2026:
|
||||
# студия/1к ~28-45k, 2к ~40-60k, 3к ~55-80k, 4к+ ~75-110k.
|
||||
# Множители — грубое отношение верхних границ этих диапазонов к базовой (1к).
|
||||
rooms = int(input_snapshot.get("rooms", 0) or 0)
|
||||
if rooms <= 1:
|
||||
rent_multiplier = 1.0
|
||||
elif rooms == 2:
|
||||
rent_multiplier = 1.35
|
||||
elif rooms == 3:
|
||||
rent_multiplier = 1.8
|
||||
else:
|
||||
rent_multiplier = 2.4
|
||||
rent_base_low, rent_base_high = 84_000, 135_000
|
||||
rent_low = round(rent_base_low * rent_multiplier / 1000) * 1000
|
||||
rent_high = round(rent_base_high * rent_multiplier / 1000) * 1000
|
||||
# Юридическое сопровождение сделки — диапазон (не фикс), варьируется по объёму работ.
|
||||
juridical_low, juridical_high = 15_000, 50_000
|
||||
# Реклама/продвижение за 3 месяца — низкая уверенность в оценке (нет единого
|
||||
|
|
@ -1558,22 +1612,9 @@ def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: igno
|
|||
brand_short = _html.escape(brand.name)
|
||||
trade_in_label = f"{brand_short}.Обмен" if brand.slug != "generic" else "Trade-In"
|
||||
|
||||
report_num = _report_number(estimate.estimate_id)
|
||||
today = dt.date.today()
|
||||
footer_html = _page_footer(
|
||||
brand,
|
||||
report_num,
|
||||
today,
|
||||
f"{brand_short} · Анализ рынка вторичной недвижимости · стр. 4 · "
|
||||
"Расчёт носит ориентировочный характер и не является офертой.",
|
||||
valid_until=_expires_date(estimate),
|
||||
)
|
||||
|
||||
return f"""
|
||||
<div><!-- last page: NO page-break-after -->
|
||||
|
||||
{_page_header(brand, report_num, today)}
|
||||
|
||||
{_section_heading("04", "ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ")}
|
||||
|
||||
<h3 style="font-size:{_FS_MD};font-weight:700;margin-bottom:8pt;">
|
||||
|
|
@ -1626,7 +1667,8 @@ def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: igno
|
|||
<tr style="border-bottom:1px solid {_BORDER};">
|
||||
<td style="padding:6pt 4pt;">
|
||||
<div class="bold">Аренда после сделки</div>
|
||||
<div style="font-size:{_FS_XS};color:{_MUTED};">однокомнатной квартиры на 3 месяца</div>
|
||||
<div style="font-size:{_FS_XS};color:{_MUTED};">
|
||||
{f"{rooms}-комн. квартиры на 3 месяца" if rooms > 0 else "квартиры на 3 месяца"}</div>
|
||||
</td>
|
||||
<td style="padding:6pt 4pt;text-align:right;color:{_SUCCESS};font-weight:700;">
|
||||
бесплатно</td>
|
||||
|
|
@ -1738,7 +1780,9 @@ def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: igno
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{footer_html}
|
||||
<p style="margin-top:14pt;font-size:{_FS_XS};color:{_MUTED};">
|
||||
Расчёт носит ориентировочный характер и не является офертой.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
"""
|
||||
|
|
@ -1794,8 +1838,12 @@ def _build_css(brand=None) -> str: # type: ignore[no-untyped-def]
|
|||
}}
|
||||
@page {{
|
||||
size: A4;
|
||||
margin: 20mm 18mm 20mm 18mm;
|
||||
margin: 25mm 18mm 19mm 18mm;
|
||||
@top-center {{ content: element(runningHeader); vertical-align: bottom; }}
|
||||
@bottom-center {{ content: element(runningFooter); vertical-align: top; }}
|
||||
}}
|
||||
.running-header {{ position: running(runningHeader); }}
|
||||
.running-footer {{ position: running(runningFooter); }}
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{
|
||||
font-family: 'PT Sans', 'DejaVu Sans', sans-serif;
|
||||
|
|
@ -2015,7 +2063,7 @@ def _build_insufficient_data_page(estimate: AggregatedEstimate, input_snapshot:
|
|||
Предотвращает публикацию «0,0 млн» и fabricated таблиц потерь.
|
||||
"""
|
||||
address = _html.escape(input_snapshot.get("address", "—"))
|
||||
report_num = _report_number(estimate.estimate_id)
|
||||
report_num = _report_number(estimate)
|
||||
today = dt.date.today()
|
||||
return f"""
|
||||
<div>
|
||||
|
|
@ -2103,11 +2151,28 @@ def generate_trade_in_pdf(
|
|||
if estimate.insufficient_data:
|
||||
body_html = _build_insufficient_data_page(estimate, input_snapshot, brand)
|
||||
else:
|
||||
# Running header/footer — единый HUD-хром печатается WeasyPrint'ом в page
|
||||
# margin-boxes (@top-center/@bottom-center, см. _build_css) на КАЖДОЙ странице
|
||||
# документа, а не только там, где явно вставлен в поток секции-билдера.
|
||||
# Рендерится один раз здесь (не внутри каждого _build_*_page) — иначе WeasyPrint
|
||||
# печатает независимый running-элемент с последнего "видимого" перед разрывом
|
||||
# страницы, что дало бы 4 разных чанка вместо одного персистентного хрома.
|
||||
_chrome_report_num = _report_number(estimate)
|
||||
_chrome_today = dt.date.today()
|
||||
_chrome_expires = _expires_date(estimate)
|
||||
chrome_html = (
|
||||
f'<div class="running-header">'
|
||||
f"{_page_header(brand, _chrome_report_num, _chrome_today)}</div>"
|
||||
f'<div class="running-footer">'
|
||||
f"{_page_footer(brand, _chrome_report_num, _chrome_today, valid_until=_chrome_expires)}"
|
||||
f"</div>"
|
||||
)
|
||||
body_html = (
|
||||
_build_cover(estimate, input_snapshot, brand)
|
||||
chrome_html
|
||||
+ _build_cover(estimate, input_snapshot, brand)
|
||||
+ _build_listings_page(estimate, input_snapshot, brand)
|
||||
+ _build_deals_page(estimate, input_snapshot, brand)
|
||||
+ _build_offer_page(estimate, brand)
|
||||
+ _build_offer_page(estimate, input_snapshot, brand)
|
||||
)
|
||||
|
||||
html_str = (
|
||||
|
|
|
|||
|
|
@ -72,6 +72,165 @@ def is_within_ekb_bbox_wide(lat: float, lon: float) -> bool:
|
|||
return is_within_ekb_bbox(lat, lon, EKB_BBOX_WIDE)
|
||||
|
||||
|
||||
# ── Sverdlovsk oblast (region 66) ────────────────────────────────────────────
|
||||
# Генеральный bbox всей области — от южной границы (ЕКБ/Полевской) до
|
||||
# северных городов (Ивдель/Североуральск/Краснотурьинск). Специально щедрый:
|
||||
# используется как fallback-accept для провайдеров БЕЗ структурного region-поля
|
||||
# (сырые Point.pos от Yandex/Nominatim) — когда нет ФИАС/КЛАДР для точной
|
||||
# проверки region==66. Дороже false-positive у самой границы соседних регионов,
|
||||
# дешевле false-negative (выбросить корректный областной адрес — исходный баг).
|
||||
# bbox = (lat_min, lat_max, lon_min, lon_max).
|
||||
OBLAST66_BBOX = (55.8, 62.2, 56.7, 66.6)
|
||||
|
||||
# Nominatim/Yandex viewport для областного (не только ЕКБ) поиска — тот же
|
||||
# формат что EKB_BBOX ниже, но покрывает всю область. viewbox — Nominatim
|
||||
# (lon1,lat1,lon2,lat2, NW→SE); ll/spn — Yandex центр+охват (lon,lat).
|
||||
OBLAST66_VIEWBOX = {
|
||||
"viewbox": "56.7,62.2,66.6,55.8",
|
||||
"ll": "61.65,59.0",
|
||||
"spn": "9.9,6.4",
|
||||
}
|
||||
|
||||
|
||||
def is_within_oblast66_bbox(lat: float, lon: float) -> bool:
|
||||
"""True если (lat, lon) в генеральном bbox Свердловской области (регион 66)."""
|
||||
return is_within_ekb_bbox(lat, lon, OBLAST66_BBOX)
|
||||
|
||||
|
||||
# Крупные города области (кроме ЕКБ) — НЕ исчерпывающий список (в области ~30
|
||||
# городов), покрывает основные региональные центры. Используется чтобы:
|
||||
# (a) не навязывать "Екатеринбург, " в запрос, если пользователь уже указал
|
||||
# другой город региона (геокодер forward/suggest prefix-логика);
|
||||
# (b) как known-locality set в _yandex_reverse при перестановке "улица, город".
|
||||
SVERDLOVSK_OBLAST_CITIES = frozenset(
|
||||
{
|
||||
"екатеринбург",
|
||||
"нижний тагил",
|
||||
"каменск-уральский",
|
||||
"первоуральск",
|
||||
"серов",
|
||||
"новоуральск",
|
||||
"ревда",
|
||||
"полевской",
|
||||
"асбест",
|
||||
"богданович",
|
||||
"ирбит",
|
||||
"красноуфимск",
|
||||
"верхняя пышма",
|
||||
"берёзовский",
|
||||
"березовский",
|
||||
"заречный",
|
||||
"качканар",
|
||||
"краснотурьинск",
|
||||
"североуральск",
|
||||
"ивдель",
|
||||
"тавда",
|
||||
"туринск",
|
||||
"сысерть",
|
||||
"среднеуральск",
|
||||
"дегтярск",
|
||||
"верхняя салда",
|
||||
"нижняя салда",
|
||||
"невьянск",
|
||||
"артёмовский",
|
||||
"артемовский",
|
||||
"камышлов",
|
||||
"алапаевск",
|
||||
"сухой лог",
|
||||
"кушва",
|
||||
"красноуральск",
|
||||
"карпинск",
|
||||
"нижняя тура",
|
||||
"верхний тагил",
|
||||
"нижние серги",
|
||||
# НЕ добавляем "лесной": ekb_geoportal_buildings содержит реальную ЕКБ-улицу
|
||||
# «Лесной» (3 дома) — точное совпадение целого слова, не отличимое от
|
||||
# города ЗАТО Лесной word-boundary'ом (в отличие от «Серова»/«Серов»,
|
||||
# где разные словоформы). Бары-адрес "Лесной, 5" без "Екатеринбург" и
|
||||
# без district-префикса ложно ушёл бы в non-EKB gate.
|
||||
}
|
||||
)
|
||||
SVERDLOVSK_OBLAST_REGION = "Свердловская область"
|
||||
|
||||
# Word/phrase-boundary regex — НЕ substring — чтобы «Серова 27» не матчил город
|
||||
# «Серов», «Ирбитская 5» — «Ирбит», «Асбестовский пер.» — «Асбест», «Невьянский
|
||||
# пер.» — «Невьянск», «Богдановича» — «Богданович» (все реальные EKB-адреса,
|
||||
# substring-матч ложно ронял "Екатеринбург, "-префикс на них → неверный город
|
||||
# → неверная цена). Python `\b` для str-паттернов юникод-осведомлён (Cyrillic
|
||||
# входит в `\w`), поэтому «серова» корректно НЕ матчит `\bсеров\b` (после
|
||||
# «серов» идёт «а» — тоже \w, границы нет).
|
||||
_OBLAST_CITY_RE = re.compile(
|
||||
r"\b(?:" + "|".join(re.escape(city) for city in SVERDLOVSK_OBLAST_CITIES) + r")\b"
|
||||
)
|
||||
_OBLAST_MARKER_RE = re.compile(r"\bсвердловск\w*\b")
|
||||
# Приставки, после которых токен обозначает РАЙОН/микрорайон города, а не сам
|
||||
# город региона: «мкр Заречный» — микрорайон ВНУТРИ другого города (не ЗАТО
|
||||
# Заречный). Без этого исключения точное совпадение слова "заречный" всё равно
|
||||
# ложно сработало бы (это не substring-баг, а реальная омонимия имени).
|
||||
_DISTRICT_PREFIXES = frozenset({"мкр", "мкр.", "микрорайон", "р-н", "р-он", "район", "жк"})
|
||||
|
||||
|
||||
def _has_oblast_marker(text_lower: str) -> bool:
|
||||
"""True если текст уже содержит упоминание области/города региона 66.
|
||||
|
||||
Используется чтобы НЕ навязывать "Екатеринбург, " в запрос, когда адрес
|
||||
уже привязан к другому городу/области — иначе получим двойной город
|
||||
("Екатеринбург, Нижний Тагил, Ленина 10") и провайдер вернёт мусор/пусто.
|
||||
|
||||
Матчинг — по границе слова/фразы (см. `_OBLAST_CITY_RE`), НЕ substring —
|
||||
и с исключением "мкр/микрорайон/р-н <город>" (район ВНУТРИ другого города).
|
||||
"""
|
||||
normalized = " ".join(text_lower.split())
|
||||
if _OBLAST_MARKER_RE.search(normalized):
|
||||
return True
|
||||
for m in _OBLAST_CITY_RE.finditer(normalized):
|
||||
prefix_words = normalized[: m.start()].split()
|
||||
if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES:
|
||||
continue # «мкр Заречный» — район, не город-ЗАТО Заречный
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Города региона 66 КРОМЕ Екатеринбурга — используется чтобы отсечь EKB-only
|
||||
# локальные тиры (geoportal/cadastral, см. `geocode()`) от адреса другого
|
||||
# города области. re.escape на элементах SVERDLOVSK_OBLAST_CITIES-{ekb}.
|
||||
_NON_EKB_OBLAST_CITIES = SVERDLOVSK_OBLAST_CITIES - {"екатеринбург"}
|
||||
_NON_EKB_CITY_RE = re.compile(
|
||||
r"\b(?:" + "|".join(re.escape(c) for c in _NON_EKB_OBLAST_CITIES) + r")\b"
|
||||
)
|
||||
# Word-boundary, НЕ substring — «екатеринбургское шоссе» (реальная улица в
|
||||
# городах-спутниках, напр. Первоуральске) не должно матчить как «Екатеринбург».
|
||||
_EKATERINBURG_RE = re.compile(r"\bекатеринбург\b")
|
||||
|
||||
|
||||
def _names_non_ekb_city(address: str) -> bool:
|
||||
"""True если адрес явно называет город Свердловской области, ОТЛИЧНЫЙ от
|
||||
Екатеринбурга (по границе слова, с исключением district-приставок «мкр/р-н
|
||||
<город>»). Используется чтобы НЕ пускать EKB-only локальные тиры
|
||||
(geoportal/cadastral) на не-ЕКБ адрес: улица+дом, совпадающие с ЕКБ-домом
|
||||
(проспект Ленина 1 есть в обоих городах), иначе адрес снапается в ЕКБ (#11).
|
||||
|
||||
Явное "Екатеринбург" в адресе имеет приоритет над любым омонимом другого
|
||||
города региона — иначе ЖК/посёлки с названием, совпадающим с другим
|
||||
городом области (ЖК «Заречный», пос. «Сысерть» внутри ЕКБ), ложно
|
||||
отсекали бы EKB-only тиры для реального ЕКБ-адреса.
|
||||
"""
|
||||
normalized = " ".join(address.lower().split())
|
||||
# Явное "Екатеринбург" (отдельным словом — см. `_EKATERINBURG_RE`) в адресе
|
||||
# → это ЕКБ, даже если в тексте встречается омоним соседнего города (ЖК
|
||||
# «Заречный», пос. Сысерть в составе МО «город Екатеринбург» и т.п. —
|
||||
# реальные false-positive из прод-кэша). Word-boundary же не даёт слову
|
||||
# «Екатеринбургское» (шоссе в городах-спутниках) ложно сработать как город.
|
||||
if _EKATERINBURG_RE.search(normalized):
|
||||
return False
|
||||
for m in _NON_EKB_CITY_RE.finditer(normalized):
|
||||
prefix_words = normalized[: m.start()].split()
|
||||
if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES:
|
||||
continue # «мкр Заречный» — район внутри другого города, не ЗАТО Заречный
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ── Address normalisation ───────────────────────────────────────────────────
|
||||
def normalize_address(address: str) -> str:
|
||||
"""Нормализация для cache lookup: lowercase + trim + collapse whitespace.
|
||||
|
|
@ -166,11 +325,35 @@ def _cache_put(db: Session, address_norm: str, result: GeocodeResult) -> None:
|
|||
|
||||
|
||||
# ── Provider: Nominatim (OSM, без ключа) ────────────────────────────────────
|
||||
async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | None:
|
||||
"""Single Nominatim search. Возвращает первый item или None.
|
||||
def _nominatim_region_ok(item: dict) -> bool | None:
|
||||
"""Кросс-чек региона по Nominatim `address.state` (доступно т.к. addressdetails=1).
|
||||
|
||||
ВАЖНО: фильтруем результаты по ЕКБ bbox прямо тут, чтобы при опечатках
|
||||
не возвращать Пермский край / Челябинск.
|
||||
True/False если state однозначно про/не про Свердловскую область. None если
|
||||
поле отсутствует/не строка — тогда accept-логика падает обратно на bbox.
|
||||
Ловит Тюмень/Шадринск/Кунгур/Снежинск — они внутри генерального OBLAST66_BBOX
|
||||
(специально щедрого), но их state явно другой регион.
|
||||
"""
|
||||
addr = item.get("address")
|
||||
if not isinstance(addr, dict):
|
||||
return None
|
||||
state = addr.get("state")
|
||||
if not isinstance(state, str) or not state:
|
||||
return None
|
||||
return "свердловск" in state.lower()
|
||||
|
||||
|
||||
async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | None:
|
||||
"""Single Nominatim search. Возвращает лучший item или None.
|
||||
|
||||
ВАЖНО: фильтруем результаты по bbox области (region 66) прямо тут, чтобы при
|
||||
опечатках не возвращать Пермский край / Челябинск — но не резать легитимные
|
||||
Нижний Тагил / Серов и т.д. (генеральный bbox всей Свердловской области).
|
||||
|
||||
Two-pass tie-break: среди кандидатов предпочитаем того, кто попадает в TIGHT
|
||||
ЕКБ-bbox (byte-identical для ЕКБ-запросов, даже если Nominatim ранжировал его
|
||||
не первым) — иначе первый кандидат внутри OBLAST66. Плюс region cross-check
|
||||
(`address.state`) — отсекает кандидатов ЯВНО из другого региона (Тюмень и
|
||||
т.п.), даже если координаты попали в генеральный bbox.
|
||||
"""
|
||||
response = await client.get(
|
||||
"https://nominatim.openstreetmap.org/search",
|
||||
|
|
@ -180,21 +363,26 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
|
|||
"limit": "3",
|
||||
"countrycodes": "ru",
|
||||
"addressdetails": "1",
|
||||
"viewbox": EKB_BBOX["viewbox"],
|
||||
"bounded": "1", # строго в ЕКБ bbox
|
||||
"viewbox": OBLAST66_VIEWBOX["viewbox"],
|
||||
"bounded": "1", # строго в пределах области (region 66)
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
oblast_fallback: dict | None = None
|
||||
for item in data:
|
||||
try:
|
||||
lat_f = float(item["lat"])
|
||||
lon_f = float(item["lon"])
|
||||
if is_within_ekb_bbox(lat_f, lon_f):
|
||||
return item
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
if _nominatim_region_ok(item) is False:
|
||||
continue # регион явно не Свердловская область — не рассматриваем
|
||||
if is_within_ekb_bbox(lat_f, lon_f):
|
||||
return item # tight-ЕКБ приоритетнее — тот же результат, что и раньше
|
||||
if oblast_fallback is None and is_within_oblast66_bbox(lat_f, lon_f):
|
||||
oblast_fallback = item
|
||||
return oblast_fallback
|
||||
|
||||
|
||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
|
||||
|
|
@ -202,8 +390,8 @@ async def _nominatim_lookup(address: str) -> GeocodeResult | None:
|
|||
"""OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.
|
||||
|
||||
Бан-policy: User-Agent с email обязателен.
|
||||
Tier 1: bounded ЕКБ на оригинальный адрес.
|
||||
Tier 2: bounded ЕКБ на typo-варианты (Цвилинга → Цвиллинга).
|
||||
Tier 1: bounded область (region 66) на оригинальный адрес.
|
||||
Tier 2: bounded область (region 66) на typo-варианты (Цвилинга → Цвиллинга).
|
||||
"""
|
||||
headers = {
|
||||
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
|
||||
|
|
@ -237,6 +425,30 @@ async def _nominatim_lookup(address: str) -> GeocodeResult | None:
|
|||
)
|
||||
|
||||
|
||||
def _yandex_region_ok(geo_object: dict) -> bool | None:
|
||||
"""Кросс-чек региона по Yandex `AdministrativeAreaName`.
|
||||
|
||||
True/False если Yandex вернул поле и оно однозначно про/не про Свердловскую
|
||||
область. None если поля нет / оно не строка — тогда accept-логика падает
|
||||
обратно на bbox. Ловит Тюмень/Шадринск/Кунгур/Снежинск — они внутри
|
||||
генерального OBLAST66_BBOX (специально щедрого), но их регион явно другой.
|
||||
"""
|
||||
try:
|
||||
admin_area = (
|
||||
geo_object.get("metaDataProperty", {})
|
||||
.get("GeocoderMetaData", {})
|
||||
.get("AddressDetails", {})
|
||||
.get("Country", {})
|
||||
.get("AdministrativeArea", {})
|
||||
.get("AdministrativeAreaName")
|
||||
)
|
||||
except AttributeError:
|
||||
return None
|
||||
if not isinstance(admin_area, str) or not admin_area:
|
||||
return None
|
||||
return "свердловск" in admin_area.lower()
|
||||
|
||||
|
||||
# ── Provider: Yandex Geocoder (требует key, лучшее покрытие РФ) ─────────────
|
||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
|
||||
async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
|
||||
|
|
@ -247,8 +459,9 @@ async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
|
|||
Запрашиваем с ll+spn (центр ЕКБ) для приоритизации местных результатов,
|
||||
но БЕЗ rspn — чтобы fuzzy matching работал при опечатках.
|
||||
"""
|
||||
# Не запихиваем "Екатеринбург" если оно уже есть в адресе (типичный кейс из suggest)
|
||||
geocode_query = address if "екатеринбург" in address.lower() else f"Екатеринбург, {address}"
|
||||
# Не навязываем "Екатеринбург, " если в адресе уже есть город/область региона 66
|
||||
# (типичный кейс из suggest, либо явный запрос по другому городу области).
|
||||
geocode_query = address if _has_oblast_marker(address.lower()) else f"Екатеринбург, {address}"
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
"https://geocode-maps.yandex.ru/1.x/",
|
||||
|
|
@ -269,22 +482,44 @@ async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
|
|||
if not members:
|
||||
return None
|
||||
|
||||
# Фильтруем top-5 по ЕКБ bbox — игнорируем Челябинск/Уфу/Москву при опечатке
|
||||
# Фильтруем top-5 по bbox области (region 66) — игнорируем Челябинск/Уфу/Москву
|
||||
# при опечатке, но принимаем легитимные Нижний Тагил/Серов/etc. Two-pass
|
||||
# tie-break: tight-ЕКБ приоритетнее (byte-identical для ЕКБ-запросов, даже
|
||||
# если Yandex ранжировал его не первым), иначе первый oblast-bbox кандидат.
|
||||
# Region cross-check (AdministrativeAreaName) отсекает кандидатов ЯВНО из
|
||||
# другого региона (Тюмень и т.п. попадают в генеральный bbox координатно).
|
||||
best = None
|
||||
oblast_fallback = None
|
||||
for m in members:
|
||||
obj = m.get("GeoObject", {})
|
||||
try:
|
||||
lon_str, lat_str = obj["Point"]["pos"].split()
|
||||
lat_f, lon_f = float(lat_str), float(lon_str)
|
||||
except Exception:
|
||||
continue
|
||||
if _yandex_region_ok(obj) is False:
|
||||
continue # регион явно не Свердловская область — не рассматриваем
|
||||
if is_within_ekb_bbox(lat_f, lon_f):
|
||||
best = obj
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if oblast_fallback is None and is_within_oblast66_bbox(lat_f, lon_f):
|
||||
oblast_fallback = obj
|
||||
|
||||
if best is None:
|
||||
# Никто из top-5 не попал в ЕКБ → берем первый «как есть» (вне ЕКБ — но хоть что-то)
|
||||
best = members[0]["GeoObject"]
|
||||
best = oblast_fallback
|
||||
|
||||
if best is None:
|
||||
# Никто из top-5 не попал ни в tight, ни в oblast bbox (или все явно
|
||||
# отсеяны по региону) → берём первый «как есть», НО только если его
|
||||
# регион не опровергнут явно — иначе весь смысл region cross-check
|
||||
# теряется на garbage-query, где top-1 — гарантированно не тот регион
|
||||
# (например, Тюмень).
|
||||
first_obj = members[0]["GeoObject"]
|
||||
if _yandex_region_ok(first_obj) is not False:
|
||||
best = first_obj
|
||||
|
||||
if best is None:
|
||||
return None
|
||||
|
||||
lon_str, lat_str = best["Point"]["pos"].split()
|
||||
precision_raw = (
|
||||
|
|
@ -310,6 +545,8 @@ async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
|
|||
|
||||
# ── Suggest (автокомплит) ───────────────────────────────────────────────────
|
||||
# ЕКБ bounding box (приблизительно): юг 56.65, запад 60.40, север 56.95, восток 60.85
|
||||
# Это ЕКБ fast-path (Yandex rspn=1 bounded tier + soft-bias ll/spn). Для областного
|
||||
# охвата (region 66) — см. OBLAST66_VIEWBOX выше.
|
||||
EKB_BBOX = {
|
||||
"viewbox": "60.40,56.95,60.85,56.65", # Nominatim format: lon1,lat1,lon2,lat2 (NW,SE)
|
||||
"ll": "60.605,56.838", # Yandex center (lon,lat)
|
||||
|
|
@ -375,9 +612,13 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]
|
|||
типа город/район, для autocomplete с привязкой к карте они бесполезны).
|
||||
|
||||
Label собирается из DaData `value` (короткая форма «ул Малышева, д 30»).
|
||||
Без city suffix — query уже ограничен `locations=[{city: 'Екатеринбург'}]`.
|
||||
Constraint — вся область (region='Свердловская область', hard-filter внутри
|
||||
`suggest_addresses`), а не один город ЕКБ — иначе Нижний Тагил/Серов/etc
|
||||
никогда не появились бы в подсказках.
|
||||
"""
|
||||
raw = await dadata.suggest_addresses(query, limit=limit)
|
||||
raw = await dadata.suggest_addresses(
|
||||
query, limit=limit, city=None, region=SVERDLOVSK_OBLAST_REGION
|
||||
)
|
||||
out: list[GeocodeSuggestion] = []
|
||||
for s in raw:
|
||||
if s.lat is None or s.lon is None:
|
||||
|
|
@ -424,16 +665,19 @@ async def _yandex_geocode_request(
|
|||
async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[GeocodeSuggestion]:
|
||||
"""Yandex Geocoder с авто-fallback на typo-tolerant режим.
|
||||
|
||||
Tier 1: bounded ЕКБ (rspn=1) на оригинальный query.
|
||||
Tier 1: bounded ЕКБ (rspn=1) — быстрый путь для основного (ЕКБ) трафика.
|
||||
Tier 2: bounded ЕКБ на typo-variants (удвоение согласных).
|
||||
Tier 3: без rspn — fuzzy по всей стране, фильтр результатов по ЕКБ bbox.
|
||||
Tier 3: без rspn — fuzzy по всей стране, фильтр результатов по bbox области
|
||||
(region 66) — ловит легитимные Нижний Тагил/Серов/etc, которые Tier 1/2
|
||||
(bounded строго ЕКБ) структурно вернуть не могут.
|
||||
"""
|
||||
prefixed_query = query if _has_oblast_marker(query.lower()) else f"Екатеринбург, {query}"
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
# Tier 1: strict bounded на оригинал
|
||||
# Tier 1: strict bounded на оригинал (ЕКБ fast path)
|
||||
members = await _yandex_geocode_request(
|
||||
client,
|
||||
api_key,
|
||||
f"Екатеринбург, {query}",
|
||||
prefixed_query,
|
||||
limit,
|
||||
bounded=True,
|
||||
)
|
||||
|
|
@ -441,12 +685,15 @@ async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[Geoc
|
|||
if results:
|
||||
return results
|
||||
|
||||
# Tier 2: bounded на typo-варианты
|
||||
# Tier 2: bounded на typo-варианты (тот же ЕКБ fast path)
|
||||
for variant in _typo_variants(query, limit=4):
|
||||
variant_query = (
|
||||
variant if _has_oblast_marker(variant.lower()) else f"Екатеринбург, {variant}"
|
||||
)
|
||||
members = await _yandex_geocode_request(
|
||||
client,
|
||||
api_key,
|
||||
f"Екатеринбург, {variant}",
|
||||
variant_query,
|
||||
limit,
|
||||
bounded=True,
|
||||
)
|
||||
|
|
@ -454,21 +701,21 @@ async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[Geoc
|
|||
if results:
|
||||
return results
|
||||
|
||||
# Tier 3: без rspn — даём fuzzy сделать своё дело, фильтр по bbox
|
||||
# Tier 3: без rspn — даём fuzzy сделать своё дело, фильтр по bbox области
|
||||
members = await _yandex_geocode_request(
|
||||
client,
|
||||
api_key,
|
||||
f"Екатеринбург, {query}",
|
||||
prefixed_query,
|
||||
limit,
|
||||
bounded=False,
|
||||
)
|
||||
results = _parse_yandex_members(members)
|
||||
in_ekb = [r for r in results if 60.40 <= r.lon <= 60.85 and 56.65 <= r.lat <= 56.95]
|
||||
return in_ekb
|
||||
in_oblast = [r for r in results if is_within_oblast66_bbox(r.lat, r.lon)]
|
||||
return in_oblast
|
||||
|
||||
|
||||
async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: int) -> list[dict]:
|
||||
"""Один Nominatim search с фильтром по ЕКБ bbox. Возвращает up to N items."""
|
||||
"""Один Nominatim search с фильтром по bbox области (region 66). Возвращает up to N items."""
|
||||
response = await client.get(
|
||||
"https://nominatim.openstreetmap.org/search",
|
||||
params={
|
||||
|
|
@ -476,7 +723,7 @@ async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: i
|
|||
"format": "json",
|
||||
"limit": str(limit),
|
||||
"countrycodes": "ru",
|
||||
"viewbox": EKB_BBOX["viewbox"],
|
||||
"viewbox": OBLAST66_VIEWBOX["viewbox"],
|
||||
"bounded": "1",
|
||||
"addressdetails": "1",
|
||||
},
|
||||
|
|
@ -488,21 +735,29 @@ async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: i
|
|||
|
||||
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4))
|
||||
async def _nominatim_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]:
|
||||
"""Nominatim в режиме suggest. С typo-fallback (для случаев когда Yandex недоступен)."""
|
||||
"""Nominatim в режиме suggest. С typo-fallback (для случаев когда Yandex недоступен).
|
||||
|
||||
Суффикс ", Екатеринбург" навязывается ТОЛЬКО если в запросе ещё нет города/области
|
||||
региона 66 — иначе не режем явные запросы по другим городам области.
|
||||
"""
|
||||
headers = {
|
||||
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
|
||||
"Accept": "application/json",
|
||||
"Accept-Language": "ru,en;q=0.8",
|
||||
}
|
||||
suffixed_query = query if _has_oblast_marker(query.lower()) else f"{query}, Екатеринбург"
|
||||
async with httpx.AsyncClient(timeout=8.0, headers=headers) as client:
|
||||
# Tier 1: оригинальный query
|
||||
data = await _nominatim_query_multi(client, f"{query}, Екатеринбург", limit)
|
||||
data = await _nominatim_query_multi(client, suffixed_query, limit)
|
||||
|
||||
# Tier 2: typo-варианты если оригинал пустой
|
||||
if not data:
|
||||
for variant in _typo_variants(query, limit=3):
|
||||
await asyncio.sleep(1.0) # Nominatim 1 req/sec
|
||||
data = await _nominatim_query_multi(client, f"{variant}, Екатеринбург", limit)
|
||||
variant_query = (
|
||||
variant if _has_oblast_marker(variant.lower()) else f"{variant}, Екатеринбург"
|
||||
)
|
||||
data = await _nominatim_query_multi(client, variant_query, limit)
|
||||
if data:
|
||||
logger.info("nominatim suggest typo-fixed: %s → %s", query, variant)
|
||||
break
|
||||
|
|
@ -832,7 +1087,8 @@ def _cadastral_reverse_sync(db: Session, lat: float, lon: float, radius_m: int =
|
|||
|
||||
|
||||
async def suggest(query: str, db: Session | None = None, limit: int = 8) -> list[GeocodeSuggestion]:
|
||||
"""Автокомплит адресов в ЕКБ. Cadastral FDW → DaData → Yandex → Nominatim → [].
|
||||
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
|
||||
остаётся быстрым fast-path). Cadastral FDW → DaData → Yandex → Nominatim → [].
|
||||
|
||||
db: если передан — cadastral lookup через gendesign_cad_buildings (первый tier).
|
||||
DaData /suggest (PR Q2) — token-only, 10k/день, заменяет Yandex который
|
||||
|
|
@ -842,8 +1098,12 @@ async def suggest(query: str, db: Session | None = None, limit: int = 8) -> list
|
|||
if not query or len(query.strip()) < 2:
|
||||
return []
|
||||
|
||||
# Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов
|
||||
if db is not None:
|
||||
# Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов.
|
||||
# EKB-only (см. `geocode()` #11) — пропускаем, если query явно называет
|
||||
# другой город области, иначе не-ЕКБ автокомплит может всплыть ЕКБ-домом
|
||||
# с совпадающими улица+дом. Внешние тиры (2/3/4 ниже) не гейтим — они уже
|
||||
# oblast-aware.
|
||||
if db is not None and not _names_non_ekb_city(query):
|
||||
# 1a. Anchored house-match: парсим street+house → точный матч по дом-маркеру.
|
||||
# Решает кейс «Серова 27» где raw-ILIKE по readable_address давал 0 hits.
|
||||
parsed = _parse_street_house(query.strip())
|
||||
|
|
@ -910,8 +1170,14 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
|
|||
# 2. Локальные источники по street+house (без внешнего API).
|
||||
parsed = _parse_street_house(address.strip())
|
||||
|
||||
# #11: EKB-only локальные тиры (geoportal/cadastral) не должны отвечать на
|
||||
# адрес другого города области — иначе улица+дом, коллизящие с ЕКБ-домом
|
||||
# (напр. "проспект Ленина 1" есть и в Нижнем Тагиле, и в ЕКБ), снапаются в
|
||||
# ЕКБ. Пропускаем сразу к oblast-aware внешним провайдерам ниже (3/4).
|
||||
use_local_ekb = not _names_non_ekb_city(address)
|
||||
|
||||
# 2a. Геопортал ЕКБ — ПЕРВЫЙ локальный tier (полнее cad_buildings ~на 70%).
|
||||
if parsed is not None:
|
||||
if use_local_ekb and parsed is not None:
|
||||
street, house = parsed
|
||||
try:
|
||||
hit = await asyncio.to_thread(_geoportal_house_match, db, street, house)
|
||||
|
|
@ -939,7 +1205,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
|
|||
# Anchored house-match: парсим street+house → точный матч по дом-маркеру.
|
||||
# raw-ILIKE по полному readable_address давал 0 hits для «Серова 27» / DaData-форм
|
||||
# (литеральная подстрока не совпадает).
|
||||
if parsed is not None:
|
||||
if use_local_ekb and parsed is not None:
|
||||
street, house = parsed
|
||||
hit = await asyncio.to_thread(_cadastral_house_match, db, street, house)
|
||||
if hit is not None:
|
||||
|
|
@ -960,7 +1226,10 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
|
|||
return result
|
||||
|
||||
# 2d. Fallback: legacy raw-ILIKE forward search (для нераспарсенных форм)
|
||||
cad_suggestions = await asyncio.to_thread(_cadastral_forward_sync, db, address.strip(), limit=1)
|
||||
if use_local_ekb:
|
||||
cad_suggestions = await asyncio.to_thread(
|
||||
_cadastral_forward_sync, db, address.strip(), limit=1
|
||||
)
|
||||
if cad_suggestions:
|
||||
s = cad_suggestions[0]
|
||||
result = GeocodeResult(
|
||||
|
|
@ -971,18 +1240,18 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
|
|||
confidence="exact",
|
||||
)
|
||||
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
||||
logger.info("geocode cadastral fdw: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
|
||||
logger.info(
|
||||
"geocode cadastral fdw: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon
|
||||
)
|
||||
return result
|
||||
|
||||
# 3. Yandex (если есть key) с typo-fallback
|
||||
if settings.yandex_geocoder_api_key:
|
||||
try:
|
||||
result = await _yandex_lookup(address, settings.yandex_geocoder_api_key)
|
||||
# Если результат вне ЕКБ — пробуем typo-варианты
|
||||
in_ekb = (
|
||||
result is not None and 60.40 <= result.lon <= 60.85 and 56.65 <= result.lat <= 56.95
|
||||
)
|
||||
if result is not None and in_ekb:
|
||||
# Если результат вне области (region 66) — пробуем typo-варианты
|
||||
in_oblast = result is not None and is_within_oblast66_bbox(result.lat, result.lon)
|
||||
if result is not None and in_oblast:
|
||||
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
||||
logger.info("geocode yandex: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
|
||||
return result
|
||||
|
|
@ -994,7 +1263,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
|
|||
continue
|
||||
if result is None:
|
||||
continue
|
||||
if 60.40 <= result.lon <= 60.85 and 56.65 <= result.lat <= 56.95:
|
||||
if is_within_oblast66_bbox(result.lat, result.lon):
|
||||
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
||||
logger.info(
|
||||
"geocode yandex typo-fixed: %s → %s → (%.5f, %.5f)",
|
||||
|
|
@ -1128,11 +1397,12 @@ async def _yandex_reverse(lat: float, lon: float, api_key: str) -> ReverseGeocod
|
|||
for p in parts
|
||||
if p not in {"Россия", "Свердловская область"} and not p.startswith("городской округ")
|
||||
]
|
||||
# Найдём locality (Екатеринбург / Берёзовский / …) и переставим в конец
|
||||
# Найдём locality (Екатеринбург / Нижний Тагил / Берёзовский / …) и переставим
|
||||
# в конец — любой город региона 66, не только ЕКБ (case-insensitive match).
|
||||
locality = None
|
||||
rest: list[str] = []
|
||||
for p in filtered:
|
||||
if locality is None and p in {"Екатеринбург"}:
|
||||
if locality is None and p.lower() in SVERDLOVSK_OBLAST_CITIES:
|
||||
locality = p
|
||||
else:
|
||||
rest.append(p)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,24 @@ logger = logging.getLogger(__name__)
|
|||
# small enough to bound storage & decode cost.
|
||||
_MAX_DIMENSION = 2400
|
||||
|
||||
# Hard cap on declared pixel count (width*height) enforced BEFORE any decode.
|
||||
# A highly-compressible ≤10 MB upload can declare ~89-178 MP; once img.load()
|
||||
# inflates it to raw RGB (3 B/px) that is a 260-530 MB buffer (+ transpose/convert
|
||||
# copies) → OOM-kills the 768 MiB backend (#2214), amplified ×12 photo slots.
|
||||
# 40 MP (~8000×5000) sits far above any real listing photo yet well below the
|
||||
# ~89 MP default Pillow bomb threshold. img.size is read from the header on
|
||||
# open() without decoding pixels, so this check is cheap and pre-decode.
|
||||
_MAX_PIXELS = 40_000_000
|
||||
|
||||
# Backstop byte cap (the upload endpoint already enforces 10 MB → 413, but this
|
||||
# service is standalone; keep it self-defending against oversized inputs).
|
||||
_MAX_BYTES = 10 * 1024 * 1024
|
||||
|
||||
# Belt-and-braces: lower Pillow's own DecompressionBombError threshold from its
|
||||
# ~89 MP default to our cap, so oversized declared dimensions raise even on any
|
||||
# decode path that bypasses the explicit size guard below.
|
||||
Image.MAX_IMAGE_PIXELS = _MAX_PIXELS
|
||||
|
||||
# JPEG quality. 85 is industry standard for photos — visually lossless,
|
||||
# ~70% size reduction vs original phone JPEGs.
|
||||
_JPEG_QUALITY = 85
|
||||
|
|
@ -46,10 +64,28 @@ def sanitize_image(content: bytes) -> tuple[bytes, str]:
|
|||
(sanitized_bytes, "image/jpeg") — both ready for storage/serving.
|
||||
|
||||
Raises:
|
||||
ImageSanitizationError: bytes are not a recognized image format.
|
||||
ImageSanitizationError: bytes are not a recognized image format, or the
|
||||
declared dimensions / byte size exceed the pixel-flood safety caps.
|
||||
"""
|
||||
if len(content) > _MAX_BYTES:
|
||||
raise ImageSanitizationError(f"image too large: {len(content)} bytes exceeds cap")
|
||||
try:
|
||||
with Image.open(io.BytesIO(content)) as img:
|
||||
# #2214: reject pixel-flood bombs BEFORE img.load(). img.size comes
|
||||
# from the header (no pixel decode); loading an oversized declared
|
||||
# image would inflate a small file into a hundreds-of-MB RGB buffer.
|
||||
width, height = img.size
|
||||
if width * height > _MAX_PIXELS:
|
||||
logger.warning(
|
||||
"image rejected: %dx%d (%d px) exceeds %d px cap",
|
||||
width,
|
||||
height,
|
||||
width * height,
|
||||
_MAX_PIXELS,
|
||||
)
|
||||
raise ImageSanitizationError(
|
||||
f"image too large: {width}x{height} exceeds {_MAX_PIXELS} px cap"
|
||||
)
|
||||
img.load() # force full decode; surfaces UnidentifiedImageError early
|
||||
# Bake EXIF Orientation into pixels BEFORE stripping EXIF, else phone
|
||||
# portrait photos (Orientation=6/8) end up rotated 90/270° once the tag
|
||||
|
|
@ -65,10 +101,16 @@ def sanitize_image(content: bytes) -> tuple[bytes, str]:
|
|||
# save() does NOT carry over .info / EXIF unless we pass exif=...; we don't.
|
||||
img.save(buf, format="JPEG", quality=_JPEG_QUALITY, optimize=True)
|
||||
return buf.getvalue(), SANITIZED_CONTENT_TYPE
|
||||
except ImageSanitizationError:
|
||||
raise # already a handled, user-facing rejection (byte/pixel cap)
|
||||
except UnidentifiedImageError as e:
|
||||
raise ImageSanitizationError("not a recognized image format") from e
|
||||
except Image.DecompressionBombError as e:
|
||||
# Any decode path that outran the size guard above (e.g. >2× cap) lands here.
|
||||
logger.warning("decompression bomb rejected: %s", e)
|
||||
raise ImageSanitizationError("image dimensions exceed safe limits") from e
|
||||
except Exception as e:
|
||||
# PIL can raise broad errors (DecompressionBombError, OSError on truncated, ...);
|
||||
# normalize to one user-facing error type.
|
||||
# PIL can raise broad errors (OSError on truncated, DecompressionBombWarning
|
||||
# promoted to error, ...); normalize to one user-facing error type.
|
||||
logger.warning("image sanitization failed: %s", e)
|
||||
raise ImageSanitizationError(f"image decode failed: {e}") from e
|
||||
|
|
|
|||
|
|
@ -1,200 +0,0 @@
|
|||
"""Location-coefficient POI scoring for trade-in estimates (#2045 BE-3, LocationDrawer).
|
||||
|
||||
Ported straight-line formula from Site Finder (ПТИЦА)
|
||||
`backend/app/services/site_finder/poi_score.py::compute_poi_weighted_top7`:
|
||||
|
||||
weight = (1 / (distance_m + 100)) * CATEGORY_WEIGHTS[category]
|
||||
|
||||
Reads from the LOCAL mirror table `osm_poi_ekb_local` (populated by
|
||||
`app/tasks/osm_poi_ekb_refresh.py` from the `gendesign_osm_poi_ekb` FDW — see migrations
|
||||
168-170). We deliberately do NOT query the FDW directly per-request: the same per-row cost
|
||||
measured for the analogous cadastral-buildings FDW (~1.16s/row without a geom index on the
|
||||
remote) would make a synchronous endpoint unusable.
|
||||
|
||||
Scope (MVP, #2045 BE-3):
|
||||
- Straight-line distance only. The ORS routing-decay mode from Site Finder
|
||||
(`compute_poi_routing_decay`) is NOT ported — no ORS infrastructure in trade-in, out of
|
||||
MVP scope.
|
||||
- Radius tuned for apartments (1000-1500m), NOT Site Finder's 2000m default for land parcels.
|
||||
- The score→coef mapping (`_score_to_coef`) is a NEW MVP heuristic, not present in Site
|
||||
Finder (there POI score is a ranking metric, not a price multiplier) — see its docstring.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Веса по категории — скопированы as-is из Site Finder CATEGORY_WEIGHTS
|
||||
# (backend/app/services/site_finder/poi_score.py), чтобы ranking POI был согласован
|
||||
# между продуктами.
|
||||
CATEGORY_WEIGHTS: dict[str, float] = {
|
||||
"metro_stop": 6.0,
|
||||
"school": 5.0,
|
||||
"kindergarten": 4.5,
|
||||
"hospital": 4.0,
|
||||
"shop_mall": 4.0,
|
||||
"shop_supermarket": 3.5,
|
||||
"bus_stop": 4.5,
|
||||
"park": 3.5,
|
||||
"pharmacy": 2.5,
|
||||
"tram_stop": 2.0,
|
||||
"shop_small": 2.0,
|
||||
"default": 1.0,
|
||||
}
|
||||
|
||||
# Радиус подобран для КВАРТИР (МКД), а не для участков (Ptica default 2000м) —
|
||||
# пешая доступность в пределах квартала/микрорайона.
|
||||
DEFAULT_RADIUS_M = 1200
|
||||
DEFAULT_TOP_N = 7
|
||||
|
||||
# Теоретический максимум суммы весов top-7 POI при идеальном расположении (d=0):
|
||||
# w_i = cat_weight_i / (0 + 100) → max_sum = Σ(top7 cat_weights) / 100.
|
||||
# Top-7 категорий по убыванию веса: 6.0+5.0+4.5+4.5+4.0+4.0+3.5 = 31.5 (тот же набор, что у Ptica).
|
||||
_TOP7_WEIGHT_SUM: float = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])
|
||||
_MAX_STRAIGHT_SCORE: float = _TOP7_WEIGHT_SUM / 100.0 # ≈ 0.315
|
||||
|
||||
# coef диапазон ±5% вокруг 1.0 — heuristic v1, НЕ откалибровано на реальных ценовых дельтах
|
||||
# (в отличие от Ptica, где poi_weighted_score — ранжирующая метрика, не ценовой множитель).
|
||||
_COEF_BASE = 0.95
|
||||
_COEF_SPREAD = 0.10
|
||||
|
||||
|
||||
def _category_weight(category: str | None) -> float:
|
||||
"""Вернуть вес категории. Если не знаем — default."""
|
||||
return CATEGORY_WEIGHTS.get(category or "default", CATEGORY_WEIGHTS["default"])
|
||||
|
||||
|
||||
class LocationCoefFactor(BaseModel):
|
||||
"""Один POI-фактор в ответе location-coef."""
|
||||
|
||||
poi_type: str
|
||||
name: str | None
|
||||
distance_m: float
|
||||
weight: float
|
||||
|
||||
|
||||
class LocationCoefResult(BaseModel):
|
||||
"""Результат compute_location_coef — потребляется эндпоинтом location-coef."""
|
||||
|
||||
coef: float
|
||||
factors: list[LocationCoefFactor]
|
||||
geo_source: str # "osm_poi_ekb" (норма) | "unavailable" (mirror пуста/не отрефрешена)
|
||||
|
||||
|
||||
def _score_to_coef(poi_weighted_score: float) -> float:
|
||||
"""MVP-эвристика score(0..100) → ценовой коэффициент.
|
||||
|
||||
coef = 0.95 + (score/100) * 0.10 → диапазон [0.95, 1.05].
|
||||
|
||||
ВНИМАНИЕ: это НЕ откалиброванная на реальных ценовых дельтах формула — первая рабочая
|
||||
эвристика для MVP location-coef. Site Finder использует ту же POI-модель как ранжирующую
|
||||
метрику (poi_weighted_score), а не как прямой ценовой множитель; здесь смысл другой,
|
||||
поэтому маппинг введён отдельно и явно помечен как heuristic v1.
|
||||
"""
|
||||
return round(_COEF_BASE + (poi_weighted_score / 100.0) * _COEF_SPREAD, 4)
|
||||
|
||||
|
||||
_NEAREST_POI_SQL = text(
|
||||
"""
|
||||
SELECT
|
||||
p.name,
|
||||
p.category,
|
||||
CAST(
|
||||
ST_Distance(
|
||||
p.geom::geography,
|
||||
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography
|
||||
) AS double precision
|
||||
) AS distance_m
|
||||
FROM osm_poi_ekb_local p
|
||||
WHERE p.geom IS NOT NULL
|
||||
AND ST_DWithin(
|
||||
p.geom::geography,
|
||||
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography,
|
||||
CAST(:radius_m AS double precision)
|
||||
)
|
||||
ORDER BY distance_m ASC
|
||||
LIMIT :limit
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def compute_location_coef(
|
||||
db: Any,
|
||||
lat: float,
|
||||
lon: float,
|
||||
radius_m: int = DEFAULT_RADIUS_M,
|
||||
top_n: int = DEFAULT_TOP_N,
|
||||
) -> LocationCoefResult:
|
||||
"""Посчитать location-coef для координат (lat, lon) по POI из osm_poi_ekb_local.
|
||||
|
||||
Graceful fallback: если osm_poi_ekb_local пуста (рефреш ещё не запускался на этом
|
||||
окружении) — возвращает coef=1.0, factors=[], geo_source="unavailable" вместо 500 или
|
||||
сфабрикованных факторов. Отсутствие POI В РАДИУСЕ у непустой таблицы — это легитимный
|
||||
результат (coef=0.95, factors=[], geo_source="osm_poi_ekb"), не fallback.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy Session.
|
||||
lat: широта целевой квартиры.
|
||||
lon: долгота целевой квартиры.
|
||||
radius_m: радиус поиска в метрах (default 1200 — подобран для МКД, не для участков).
|
||||
top_n: количество POI, учитываемых в score (default 7).
|
||||
"""
|
||||
total = db.execute(text("SELECT count(*) FROM osm_poi_ekb_local")).scalar() or 0
|
||||
if total == 0:
|
||||
logger.warning(
|
||||
"location_coef: osm_poi_ekb_local is empty (refresh job not yet run on this "
|
||||
"environment) — returning unavailable fallback, no fabricated factors"
|
||||
)
|
||||
return LocationCoefResult(coef=1.0, factors=[], geo_source="unavailable")
|
||||
|
||||
rows = (
|
||||
db.execute(
|
||||
_NEAREST_POI_SQL,
|
||||
{"lat": lat, "lon": lon, "radius_m": radius_m, "limit": top_n * 10},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
scored: list[tuple[float, LocationCoefFactor]] = []
|
||||
for row in rows:
|
||||
distance_m = float(row["distance_m"])
|
||||
category = row["category"] or "default"
|
||||
weight = (1.0 / (distance_m + 100.0)) * _category_weight(category)
|
||||
scored.append(
|
||||
(
|
||||
weight,
|
||||
LocationCoefFactor(
|
||||
poi_type=category,
|
||||
name=row["name"],
|
||||
distance_m=round(distance_m, 1),
|
||||
weight=round(weight, 6),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
scored.sort(key=lambda pair: pair[0], reverse=True)
|
||||
top_factors = [factor for _weight, factor in scored[:top_n]]
|
||||
|
||||
raw_sum = sum(factor.weight for factor in top_factors)
|
||||
poi_weighted_score = min(100.0, (raw_sum / _MAX_STRAIGHT_SCORE) * 100.0)
|
||||
coef = _score_to_coef(poi_weighted_score)
|
||||
|
||||
logger.debug(
|
||||
"location_coef: lat=%.5f lon=%.5f radius=%dm poi_found=%d top=%d " "score=%.1f coef=%.4f",
|
||||
lat,
|
||||
lon,
|
||||
radius_m,
|
||||
len(rows),
|
||||
len(top_factors),
|
||||
poi_weighted_score,
|
||||
coef,
|
||||
)
|
||||
|
||||
return LocationCoefResult(coef=coef, factors=top_factors, geo_source="osm_poi_ekb")
|
||||
436
tradein-mvp/backend/app/services/location_index.py
Normal file
436
tradein-mvp/backend/app/services/location_index.py
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
"""Location index for trade-in estimates — replaces the broken `location_coef` (LocationDrawer).
|
||||
|
||||
ИСТОРИЯ / ПОЧЕМУ ПЕРЕПИСАНО:
|
||||
Старый `location_coef.py` считал `coef = 0.95 + (poi_weighted_score/100) * 0.10` — диапазон
|
||||
жёстко зажат в [0.95, 1.05], без какой-либо калибровки на реальных ценах. Аудит на боевой БД
|
||||
(1500 адресов ЕКБ + 4000 активных лотов) показал:
|
||||
- 67% адресов попадали в −1%…+1%, у ~25% coef был РОВНО 1.0 (score=50) — почти
|
||||
неинформативно, весь город умещался в −4%…+5%;
|
||||
- связи с ценой не было вообще: медиана ₽/м² по бакетам coef плоская и НЕ монотонна
|
||||
(бакет −4% дороже бакета +3%).
|
||||
Для сравнения, расстояние до центра ЕКБ на 31 тыс. лотов даёт чистый монотонный градиент
|
||||
(0-2км 249 686 ₽/м² → 12-13км 93 677 ₽/м², разброс 2.7×) — сигнал в данных есть, просто
|
||||
POI-score его не улавливал (POI ranking ≠ цена).
|
||||
|
||||
НОВЫЙ ПОКАЗАТЕЛЬ (location index):
|
||||
location_index_pct = (медиана ₽/м² сопоставимых активных листингов в радиусе точки −
|
||||
медиана ₽/м² по всему ЕКБ) / медиана по ЕКБ * 100
|
||||
|
||||
Самообновляем (те же `listings`, что уже скрейпятся под estimator), интерпретируем напрямую
|
||||
("район на N% дороже/дешевле среднего по городу"), устойчив к выбросам (percentile_cont(0.5) —
|
||||
медиана самой природой игнорирует единичные экстремумы, в отличие от mean/min/max), и НЕ зажат
|
||||
искусственно — если район реально на 40% дороже, так и покажет.
|
||||
|
||||
ЧЕСТНАЯ ДЕГРАДАЦИЯ (см. LocationIndexResult.status):
|
||||
- "out_of_coverage" — точка вне гео-охвата продукта (bbox Екатеринбурга). НЕ 0%, НЕ
|
||||
fallback-число — прочерк на фронте.
|
||||
- "insufficient_data" — даже на максимальном радиусе выборки < MIN_SAMPLE_SIZE сопоставимых
|
||||
активных листингов. Тоже прочерк, а не шум по 3 объявлениям.
|
||||
- "ok" — index надёжен.
|
||||
|
||||
В ЦЕНУ НЕ ИДЁТ: estimator.py про этот модуль не знает и не должен знать — аналоги уже берутся
|
||||
из того же района (локация учтена в базовой цене через сам подбор сопоставимых объектов),
|
||||
повторное умножение на локационный индекс было бы двойным учётом одного и того же эффекта.
|
||||
|
||||
POI («что рядом» — школа/метро/остановка) сохранены как ОТДЕЛЬНАЯ качественная справка
|
||||
(`nearby_poi`, ранжирование как раньше в location_coef.py), но больше не участвуют в числовом
|
||||
показателе.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Гео-охват продукта: только Екатеринбург ──────────────────────────────────
|
||||
# Тот же bbox, что EKB_BBOX в backend/app/services/site_finder/poi_loader.py (main
|
||||
# gendesign backend, Overpass-загрузчик osm_poi_ekb) и что использовался при аудите
|
||||
# (1500 адресов / 4000 активных лотов / 2787 POI, все — "по Екатеринбургу"). tradein-mvp —
|
||||
# отдельный деплой/venv от backend/, поэтому константа продублирована, не импортирована;
|
||||
# при изменении bbox в одном месте — проверить и второе (комментарий в обе стороны).
|
||||
_EKB_BBOX_SOUTH = 56.70
|
||||
_EKB_BBOX_WEST = 60.50
|
||||
_EKB_BBOX_NORTH = 56.95
|
||||
_EKB_BBOX_EAST = 60.75
|
||||
|
||||
|
||||
def _in_ekb_bbox(lat: float, lon: float) -> bool:
|
||||
"""True если точка внутри гео-охвата продукта (Екатеринбург)."""
|
||||
return _EKB_BBOX_SOUTH <= lat <= _EKB_BBOX_NORTH and _EKB_BBOX_WEST <= lon <= _EKB_BBOX_EAST
|
||||
|
||||
|
||||
# ── Калибровочные константы (радиус / минимальная выборка) ──────────────────
|
||||
#
|
||||
# Плотность-прикидка для обоснования порядка величины (НЕ подтверждено живым запросом к
|
||||
# прод-БД в этом изменении — см. PR description "непроверенное"): ЕКБ-аудит насчитал ~4000
|
||||
# активных лотов в bbox площадью ~ 27.8км (0.25° широты) × 15.3км (0.25° долготы на широте
|
||||
# 56.8°) ≈ 425 км² → плотность ~9.4 лота/км². Круг радиусом 800м имеет площадь ~2.01 км² →
|
||||
# ожидаемо ~19 лотов при равномерной плотности — близко к MIN_SAMPLE_SIZE=20, т.е. стартовый
|
||||
# радиус разумен для "средней" точки. Плотность в городе крайне неравномерна (центр много
|
||||
# гуще окраин) — поэтому лестница радиусов расширяется, а не фиксированный радиус.
|
||||
RADIUS_LADDER_M: tuple[int, ...] = (800, 1500, 2500)
|
||||
|
||||
# Ниже этого числа сопоставимых активных листингов медиана — шум, не показатель.
|
||||
# Порог не откалиброван статистически (например через доверительный интервал медианы) —
|
||||
# первая рабочая оценка для MVP. TODO: перепроверить на реальном распределении выборок по
|
||||
# районам ЕКБ (см. "непроверенное" в отчёте задачи).
|
||||
MIN_SAMPLE_SIZE = 20
|
||||
|
||||
# Санитарные (НЕ бизнес-калибровочные) границы ₽/м² — отсекают заведомо битые скрейп-строки
|
||||
# (парсинг ошибся на порядок и т.п.), не сужают реальный рынок ЕКБ (там диапазон примерно
|
||||
# 40-400 тыс₽/м², с большим запасом по краям).
|
||||
_PRICE_PER_M2_SANITY_MIN = 20_000
|
||||
_PRICE_PER_M2_SANITY_MAX = 1_000_000
|
||||
|
||||
DEFAULT_POI_RADIUS_M = 1200 # как в старом location_coef.py — подобран для МКД
|
||||
DEFAULT_POI_TOP_N = 7
|
||||
|
||||
# Веса по категории POI — те же, что были в location_coef.py (ranking "что рядом",
|
||||
# больше НЕ конвертируются в число, влияющее на индекс).
|
||||
CATEGORY_WEIGHTS: dict[str, float] = {
|
||||
"metro_stop": 6.0,
|
||||
"school": 5.0,
|
||||
"kindergarten": 4.5,
|
||||
"hospital": 4.0,
|
||||
"shop_mall": 4.0,
|
||||
"shop_supermarket": 3.5,
|
||||
"bus_stop": 4.5,
|
||||
"park": 3.5,
|
||||
"pharmacy": 2.5,
|
||||
"tram_stop": 2.0,
|
||||
"shop_small": 2.0,
|
||||
"default": 1.0,
|
||||
}
|
||||
|
||||
|
||||
def _category_weight(category: str | None) -> float:
|
||||
"""Вернуть вес категории. Если не знаем — default."""
|
||||
return CATEGORY_WEIGHTS.get(category or "default", CATEGORY_WEIGHTS["default"])
|
||||
|
||||
|
||||
class NearbyPoi(BaseModel):
|
||||
"""Один пункт «что рядом» — качественная справка, НЕ участвует в location_index_pct."""
|
||||
|
||||
poi_type: str
|
||||
name: str | None
|
||||
distance_m: float
|
||||
|
||||
|
||||
class LocationIndexResult(BaseModel):
|
||||
"""Результат compute_location_index — потребляется эндпоинтом location-index."""
|
||||
|
||||
status: str # "ok" | "out_of_coverage" | "insufficient_data"
|
||||
location_index_pct: float | None
|
||||
local_median_price_per_m2: int | None
|
||||
city_median_price_per_m2: int | None
|
||||
sample_size: int
|
||||
radius_m: int
|
||||
nearby_poi: list[NearbyPoi]
|
||||
poi_status: str # "ok" | "unavailable" (osm_poi_ekb_local пуста/не отрефрешена)
|
||||
|
||||
|
||||
def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
|
||||
"""% отклонения локальной медианы от городской.
|
||||
|
||||
Округление до 1 знака — не создаёт ложной точности (исходные данные — шумные скрейп-цены).
|
||||
"""
|
||||
if city_median_ppm2 <= 0:
|
||||
# Защита от деления на ноль при вырожденной городской выборке — не должно
|
||||
# случаться в проде (там ~4000 активных лотов), только в пустой dev-БД.
|
||||
return 0.0
|
||||
return round((local_median_ppm2 - city_median_ppm2) / city_median_ppm2 * 100.0, 1)
|
||||
|
||||
|
||||
# ── SQL: медиана ₽/м² сопоставимых активных листингов ────────────────────────
|
||||
#
|
||||
# percentile_cont(0.5) — тот же идиом, что уже используется в estimator.py для медианных
|
||||
# ₽/м² трендов (_fetch_price_trend) — устойчив к выбросам В ОТЛИЧИЕ от AVG/min/max: единичный
|
||||
# аномально дорогой/дешёвый лот не сдвигает медиану заметно.
|
||||
#
|
||||
# geo_precision IS DISTINCT FROM 'city' — тот же фильтр, что в estimator.py (#769 Part E):
|
||||
# исключает листинги с геокодом до центра города (city-centroid fallback без номера дома),
|
||||
# которые иначе "подмешивались" бы в любой радиус вокруг центра.
|
||||
#
|
||||
# price_per_m2 BETWEEN sanity-границы — не бизнес-калибровка, а защита от битых строк
|
||||
# (см. _PRICE_PER_M2_SANITY_MIN/MAX выше).
|
||||
#
|
||||
# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО по Екатеринбургу, даже если
|
||||
# сам продукт уже скрейпит соседние города области (city-sweep): географию location_index
|
||||
# явно ограничил владелец продукта.
|
||||
_MEDIAN_PPM2_LOCAL_SQL = text(
|
||||
"""
|
||||
SELECT
|
||||
CAST(percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS double precision)
|
||||
AS median_ppm2,
|
||||
count(*) AS n
|
||||
FROM listings
|
||||
WHERE is_active = true
|
||||
AND price_per_m2 IS NOT NULL
|
||||
AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer)
|
||||
AND (geo_precision IS DISTINCT FROM 'city')
|
||||
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
||||
AND CAST(:bbox_north AS double precision)
|
||||
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
||||
AND CAST(:bbox_east AS double precision)
|
||||
AND ST_DWithin(
|
||||
geom::geography,
|
||||
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography,
|
||||
CAST(:radius_m AS double precision)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
_MEDIAN_PPM2_CITYWIDE_SQL = text(
|
||||
"""
|
||||
SELECT
|
||||
CAST(percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS double precision)
|
||||
AS median_ppm2,
|
||||
count(*) AS n
|
||||
FROM listings
|
||||
WHERE is_active = true
|
||||
AND price_per_m2 IS NOT NULL
|
||||
AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer)
|
||||
AND (geo_precision IS DISTINCT FROM 'city')
|
||||
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
||||
AND CAST(:bbox_north AS double precision)
|
||||
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
||||
AND CAST(:bbox_east AS double precision)
|
||||
"""
|
||||
)
|
||||
|
||||
_NEAREST_POI_SQL = text(
|
||||
"""
|
||||
SELECT
|
||||
p.name,
|
||||
p.category,
|
||||
CAST(
|
||||
ST_Distance(
|
||||
p.geom::geography,
|
||||
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography
|
||||
) AS double precision
|
||||
) AS distance_m
|
||||
FROM osm_poi_ekb_local p
|
||||
WHERE p.geom IS NOT NULL
|
||||
AND ST_DWithin(
|
||||
p.geom::geography,
|
||||
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography,
|
||||
CAST(:radius_m AS double precision)
|
||||
)
|
||||
ORDER BY distance_m ASC
|
||||
LIMIT :limit
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _local_median_ppm2(db: Any, lat: float, lon: float, radius_m: int) -> tuple[float | None, int]:
|
||||
row = (
|
||||
db.execute(
|
||||
_MEDIAN_PPM2_LOCAL_SQL,
|
||||
{
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"radius_m": radius_m,
|
||||
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
||||
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
||||
"bbox_south": _EKB_BBOX_SOUTH,
|
||||
"bbox_north": _EKB_BBOX_NORTH,
|
||||
"bbox_west": _EKB_BBOX_WEST,
|
||||
"bbox_east": _EKB_BBOX_EAST,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
if row is None:
|
||||
return None, 0
|
||||
median = row["median_ppm2"]
|
||||
return (float(median) if median is not None else None), int(row["n"] or 0)
|
||||
|
||||
|
||||
def _citywide_median_ppm2(db: Any) -> tuple[float | None, int]:
|
||||
row = (
|
||||
db.execute(
|
||||
_MEDIAN_PPM2_CITYWIDE_SQL,
|
||||
{
|
||||
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
||||
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
||||
"bbox_south": _EKB_BBOX_SOUTH,
|
||||
"bbox_north": _EKB_BBOX_NORTH,
|
||||
"bbox_west": _EKB_BBOX_WEST,
|
||||
"bbox_east": _EKB_BBOX_EAST,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
if row is None:
|
||||
return None, 0
|
||||
median = row["median_ppm2"]
|
||||
return (float(median) if median is not None else None), int(row["n"] or 0)
|
||||
|
||||
|
||||
def _fetch_nearby_poi(
|
||||
db: Any, lat: float, lon: float, radius_m: int, top_n: int
|
||||
) -> tuple[list[NearbyPoi], str]:
|
||||
"""Top-N POI поблизости — качественная справка «что рядом», не числовой показатель.
|
||||
|
||||
Graceful fallback: osm_poi_ekb_local пуста (рефреш ещё не запускался на этом окружении)
|
||||
→ ([], "unavailable") вместо 500 или сфабрикованного списка.
|
||||
"""
|
||||
total = db.execute(text("SELECT count(*) FROM osm_poi_ekb_local")).scalar() or 0
|
||||
if total == 0:
|
||||
logger.warning(
|
||||
"location_index: osm_poi_ekb_local is empty (refresh job not yet run on this "
|
||||
"environment) — nearby_poi unavailable, no fabricated factors"
|
||||
)
|
||||
return [], "unavailable"
|
||||
|
||||
rows = (
|
||||
db.execute(
|
||||
_NEAREST_POI_SQL,
|
||||
{"lat": lat, "lon": lon, "radius_m": radius_m, "limit": top_n * 10},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
ranked: list[tuple[float, NearbyPoi]] = []
|
||||
for row in rows:
|
||||
distance_m = float(row["distance_m"])
|
||||
category = row["category"] or "default"
|
||||
weight = (1.0 / (distance_m + 100.0)) * _category_weight(category)
|
||||
ranked.append(
|
||||
(
|
||||
weight,
|
||||
NearbyPoi(poi_type=category, name=row["name"], distance_m=round(distance_m, 1)),
|
||||
)
|
||||
)
|
||||
|
||||
ranked.sort(key=lambda pair: pair[0], reverse=True)
|
||||
return [poi for _weight, poi in ranked[:top_n]], "ok"
|
||||
|
||||
|
||||
def compute_location_index(
|
||||
db: Any,
|
||||
lat: float,
|
||||
lon: float,
|
||||
*,
|
||||
radius_m: int | None = None,
|
||||
poi_radius_m: int = DEFAULT_POI_RADIUS_M,
|
||||
poi_top_n: int = DEFAULT_POI_TOP_N,
|
||||
) -> LocationIndexResult:
|
||||
"""Посчитать location index для координат (lat, lon).
|
||||
|
||||
location_index_pct = (медиана ₽/м² сопоставимых активных листингов в радиусе точки −
|
||||
медиана ₽/м² по всему ЕКБ) / медиана по ЕКБ * 100. Радиус — лестница RADIUS_LADDER_M
|
||||
(расширяется, пока выборка не наберёт MIN_SAMPLE_SIZE), если явный radius_m не передан
|
||||
(тогда используется РОВНО он, без расширения — для отладки/тестов).
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy Session.
|
||||
lat: широта целевой точки.
|
||||
lon: долгота целевой точки.
|
||||
radius_m: явный радиус в метрах — если задан, лестница не используется.
|
||||
poi_radius_m: радиус для качественного списка «что рядом» (независим от индекса).
|
||||
poi_top_n: сколько POI показать в «что рядом».
|
||||
|
||||
Returns:
|
||||
LocationIndexResult со status:
|
||||
- "out_of_coverage" — точка вне bbox Екатеринбурга, ничего не считаем.
|
||||
- "insufficient_data" — даже на максимальном радиусе сопоставимых листингов
|
||||
меньше MIN_SAMPLE_SIZE (или городская выборка-эталон сама вырождена).
|
||||
- "ok" — location_index_pct надёжен.
|
||||
"""
|
||||
if not _in_ekb_bbox(lat, lon):
|
||||
logger.info(
|
||||
"location_index: lat=%.5f lon=%.5f outside EKB coverage bbox — out_of_coverage",
|
||||
lat,
|
||||
lon,
|
||||
)
|
||||
return LocationIndexResult(
|
||||
status="out_of_coverage",
|
||||
location_index_pct=None,
|
||||
local_median_price_per_m2=None,
|
||||
city_median_price_per_m2=None,
|
||||
sample_size=0,
|
||||
radius_m=radius_m or RADIUS_LADDER_M[0],
|
||||
nearby_poi=[],
|
||||
poi_status="unavailable",
|
||||
)
|
||||
|
||||
nearby_poi, poi_status = _fetch_nearby_poi(db, lat, lon, poi_radius_m, poi_top_n)
|
||||
|
||||
city_median, city_n = _citywide_median_ppm2(db)
|
||||
if city_median is None or city_n < MIN_SAMPLE_SIZE:
|
||||
logger.warning(
|
||||
"location_index: citywide reference sample too small (n=%d) — insufficient_data",
|
||||
city_n,
|
||||
)
|
||||
return LocationIndexResult(
|
||||
status="insufficient_data",
|
||||
location_index_pct=None,
|
||||
local_median_price_per_m2=None,
|
||||
city_median_price_per_m2=(round(city_median) if city_median is not None else None),
|
||||
sample_size=city_n,
|
||||
radius_m=radius_m or RADIUS_LADDER_M[-1],
|
||||
nearby_poi=nearby_poi,
|
||||
poi_status=poi_status,
|
||||
)
|
||||
|
||||
radii = [radius_m] if radius_m is not None else list(RADIUS_LADDER_M)
|
||||
local_median: float | None = None
|
||||
sample_size = 0
|
||||
used_radius = radii[-1]
|
||||
for r in radii:
|
||||
local_median, sample_size = _local_median_ppm2(db, lat, lon, r)
|
||||
used_radius = r
|
||||
if sample_size >= MIN_SAMPLE_SIZE:
|
||||
break
|
||||
|
||||
if local_median is None or sample_size < MIN_SAMPLE_SIZE:
|
||||
logger.info(
|
||||
"location_index: lat=%.5f lon=%.5f sample=%d < MIN_SAMPLE_SIZE=%d up to "
|
||||
"radius=%dm — insufficient_data",
|
||||
lat,
|
||||
lon,
|
||||
sample_size,
|
||||
MIN_SAMPLE_SIZE,
|
||||
used_radius,
|
||||
)
|
||||
return LocationIndexResult(
|
||||
status="insufficient_data",
|
||||
location_index_pct=None,
|
||||
local_median_price_per_m2=None,
|
||||
city_median_price_per_m2=round(city_median),
|
||||
sample_size=sample_size,
|
||||
radius_m=used_radius,
|
||||
nearby_poi=nearby_poi,
|
||||
poi_status=poi_status,
|
||||
)
|
||||
|
||||
pct = _pct_deviation(local_median, city_median)
|
||||
logger.debug(
|
||||
"location_index: lat=%.5f lon=%.5f radius=%dm n=%d local=%d city=%d pct=%.1f",
|
||||
lat,
|
||||
lon,
|
||||
used_radius,
|
||||
sample_size,
|
||||
round(local_median),
|
||||
round(city_median),
|
||||
pct,
|
||||
)
|
||||
return LocationIndexResult(
|
||||
status="ok",
|
||||
location_index_pct=pct,
|
||||
local_median_price_per_m2=round(local_median),
|
||||
city_median_price_per_m2=round(city_median),
|
||||
sample_size=sample_size,
|
||||
radius_m=used_radius,
|
||||
nearby_poi=nearby_poi,
|
||||
poi_status=poi_status,
|
||||
)
|
||||
|
|
@ -5,6 +5,25 @@ Source-of-truth dicts read by merge logic in match_or_create_house/listing.
|
|||
|
||||
Sources covered: avito (serp/detail/houses_catalog/domoteka/imv),
|
||||
cian (serp/bti/detail/stats/valuation), yandex (serp/detail/realty_nb/valuation).
|
||||
|
||||
STATUS (audit finding, confirmed against vault Decision_774_Matching_Architecture,
|
||||
2026-05-31, code-archaeology + live-DB verified): `resolve_house_field` /
|
||||
`resolve_listing_field` / `HOUSE_FIELD_PRIORITY` / `LISTING_FIELD_PRIORITY` are
|
||||
NOT called anywhere in the production merge path — real house/listing upserts
|
||||
in `matching/houses.py` / `matching/listings.py` use a simpler ad hoc
|
||||
`COALESCE(EXCLUDED.x, table.x)` (newest-non-null-wins) pattern instead.
|
||||
`update_canonical_fields` below is a deliberate Stage-8-v1 no-op stub ("Full
|
||||
arbitration deferred to Stage 8.x" — see its own docstring); this is NOT an
|
||||
accidentally-orphaned integration, it is an intentionally-staged one that never
|
||||
got a Stage-8.x follow-up. Decision_774 already scoped removing this
|
||||
(`resolve_*`/stub/`match_or_create_listing`) as an independent "Path 2 / Sub-3"
|
||||
cleanup PR, deliberately kept separate from the accuracy-affecting Path 1a work
|
||||
(house_id_fk anchor) — do NOT wire this into the live price-calc path without a
|
||||
dedicated backtest+A/B (would change client-facing estimates). Left in place
|
||||
(not deleted) here because ~30 existing tests in
|
||||
`tests/matching/test_conflict_resolution.py` + `tests/test_matching.py` cover
|
||||
it in detail; removing both belongs in that separate Sub-3 PR, not bundled with
|
||||
an unrelated Tier-S bugfix.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
|
|||
|
|
@ -16,14 +16,25 @@ from sqlalchemy import text
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.matching.normalize import (
|
||||
EKB_CITY_TOKEN,
|
||||
address_fingerprint,
|
||||
has_city_token,
|
||||
has_house_number,
|
||||
house_number_token,
|
||||
normalize_address,
|
||||
resolve_city_token,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Tier-2b oblast guard radius (metres). A bare street+number normalized_address carries
|
||||
# NO city token yet is globally unique in house_address_aliases, so 'улица ленина 100'
|
||||
# physically exists in ЕКБ, Н.Тагил, Каменск… When the incoming card HAS coords we accept
|
||||
# the alias only if its house geom is within this radius. Cities in обл.66 are ≥ ~9-15 km
|
||||
# apart, so 3 km cleanly separates them while absorbing the intra-city geocoder drift that
|
||||
# Tier-2b exists to bridge (same building, different provider coords).
|
||||
_TIER2B_GUARD_M = 3000
|
||||
|
||||
|
||||
def match_or_create_house(
|
||||
db: Session,
|
||||
|
|
@ -180,19 +191,78 @@ def match_or_create_house(
|
|||
# Without 2b, two scrapers for the same house with slightly different lat/lon (beyond
|
||||
# the 4-decimal rounding tolerance) would produce distinct fingerprints, miss Tier 2a,
|
||||
# and each potentially create a duplicate house row.
|
||||
#
|
||||
# Tier-2a/2b oblast city guard (#2500 follow-up, closes the Tier-2a mis-bucket that the
|
||||
# #2500 Tier-2b geo-guard could not reach): a coord-less card whose address explicitly
|
||||
# resolves a NON-ЕКБ обл.66 city must NOT match a globally-unique alias (fingerprint OR
|
||||
# normalized_address) that almost certainly belongs to ЕКБ/another city — that would
|
||||
# corrupt ЕКБ house data. When it fires we skip both alias lookups and fall through to a
|
||||
# New house (Tier 3 is coord-gated, so coord-less cards skip it too).
|
||||
#
|
||||
# This PREVENTS ЕКБ CORRUPTION. Full oblast-internal dedup (deduping two listings of the
|
||||
# SAME oblast building) still needs city-keyed aliases — a separate follow-up, out of
|
||||
# scope, only relevant once the oblast sweep is enabled.
|
||||
#
|
||||
# EKB happy-path is byte-identical: the guard fires ONLY when the address names a non-ЕКБ
|
||||
# city AND no coords disambiguate. ЕКБ cards (resolved city = екатеринбург) and the
|
||||
# dominant bare/city-less Avito coord-less cards (resolved city None) run Tier-2a/2b
|
||||
# exactly as before. NB: a BARE oblast card (no city token in the address — today's Avito
|
||||
# SERP format) carries no signal here and is deliberately left on the unchanged path; that
|
||||
# residual needs sweep-context and is out of this fix's scope.
|
||||
_resolved_city = resolve_city_token(norm_addr) if (lat is None and lon is None) else None
|
||||
_skip_oblast_alias = _resolved_city is not None and _resolved_city != EKB_CITY_TOKEN
|
||||
if _skip_oblast_alias:
|
||||
logger.info(
|
||||
"house tier2a/2b skip: coord-less non-ЕКБ city %r na=%r src=%s",
|
||||
_resolved_city,
|
||||
norm_addr,
|
||||
ext_source,
|
||||
)
|
||||
|
||||
row = None
|
||||
if not _skip_oblast_alias:
|
||||
row = (
|
||||
db.execute(
|
||||
text("SELECT house_id FROM house_address_aliases " "WHERE fingerprint = :fp LIMIT 1"),
|
||||
text("SELECT house_id FROM house_address_aliases WHERE fingerprint = :fp LIMIT 1"),
|
||||
{"fp": fp},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
if row is None and norm_addr and has_num:
|
||||
if row is None and norm_addr and has_num and not _skip_oblast_alias:
|
||||
# Tier 2b: same normalized address, possibly different coords fingerprint.
|
||||
# Gated on has_num (P1): never match a bare-street normalized_address — any
|
||||
# numberless listing would otherwise collapse into whichever house first
|
||||
# registered that street.
|
||||
#
|
||||
# Oblast guard (bug #2, обл.66 per-city rollout): normalized_address has NO city
|
||||
# token, so a bare street+number is globally unique in the alias table yet exists
|
||||
# physically in several cities. Accept the match only when the incoming listing is
|
||||
# confirmably the same place:
|
||||
# • coords present → aliased house geom within _TIER2B_GUARD_M metres;
|
||||
# • coords absent → norm_addr carries a city token (self-disambiguating —
|
||||
# 'екатеринбург улица ленина 5' can't collide with a Н.Тагил address);
|
||||
# • neither → skip. A bare common-street with no geo signal is too
|
||||
# ambiguous — matching would mis-bucket an oblast card into a same-named ЕКБ
|
||||
# house. Conservative: fall through to a New house rather than mis-match.
|
||||
if lat is not None and lon is not None:
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"SELECT a.house_id FROM house_address_aliases a "
|
||||
"JOIN houses h ON h.id = a.house_id "
|
||||
"WHERE a.normalized_address = :na "
|
||||
" AND h.geom IS NOT NULL "
|
||||
" AND ST_DWithin(h.geom::geography, "
|
||||
" ST_MakePoint(:lon, :lat)::geography, :thr) "
|
||||
"LIMIT 1"
|
||||
),
|
||||
{"na": norm_addr, "lon": lon, "lat": lat, "thr": _TIER2B_GUARD_M},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
elif has_city_token(norm_addr):
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
|
|
@ -204,6 +274,12 @@ def match_or_create_house(
|
|||
.mappings()
|
||||
.first()
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"house tier2b skip: bare common-street, no coords/city na=%r src=%s",
|
||||
norm_addr,
|
||||
ext_source,
|
||||
)
|
||||
if row:
|
||||
house_id = int(row["house_id"])
|
||||
_upsert_house_source(
|
||||
|
|
@ -520,6 +596,12 @@ def _insert_alias(
|
|||
row with the latest fingerprint, which is then found by Tier 2a on the next scrape.
|
||||
house_id is not updated on conflict: the first writer wins canonical ownership.
|
||||
|
||||
Oblast guard (bug #2): the fingerprint/source are refreshed ONLY when the conflicting
|
||||
row belongs to the SAME house. A DIFFERENT house colliding on a bare common-street
|
||||
normalized_address (an oblast card that fell through Tier-2b's guard to a New INSERT)
|
||||
must NOT rewrite the owner's fingerprint — doing so would downgrade a safe coord-bearing
|
||||
alias to a coord-less one and let the next no-coord card mis-bucket via Tier 2a.
|
||||
|
||||
P1: a bare-street normalized_address (no house number) is NOT registered as an
|
||||
alias — it is too ambiguous to serve as a building key. Any later numberless
|
||||
listing would otherwise hit that alias via Tier 2b and be mass-dumped into the
|
||||
|
|
@ -533,8 +615,12 @@ def _insert_alias(
|
|||
INSERT INTO house_address_aliases (house_id, normalized_address, fingerprint, source)
|
||||
VALUES (CAST(:hid AS bigint), :na, :fp, :src)
|
||||
ON CONFLICT (normalized_address) DO UPDATE SET
|
||||
fingerprint = EXCLUDED.fingerprint,
|
||||
source = EXCLUDED.source
|
||||
fingerprint = CASE
|
||||
WHEN house_address_aliases.house_id = EXCLUDED.house_id
|
||||
THEN EXCLUDED.fingerprint ELSE house_address_aliases.fingerprint END,
|
||||
source = CASE
|
||||
WHEN house_address_aliases.house_id = EXCLUDED.house_id
|
||||
THEN EXCLUDED.source ELSE house_address_aliases.source END
|
||||
"""),
|
||||
{
|
||||
"hid": house_id,
|
||||
|
|
|
|||
|
|
@ -112,6 +112,62 @@ def house_number_token(normalized: str | None) -> str | None:
|
|||
return m.group(1) if m else None
|
||||
|
||||
|
||||
# Екатеринбург — the home city; every other _CITY_TOKENS entry is a non-ЕКБ обл.66 city.
|
||||
# The Tier-2a oblast guard treats a resolved non-ЕКБ city specially (see resolve_city_token).
|
||||
EKB_CITY_TOKEN = "екатеринбург"
|
||||
|
||||
# Cities covered by the обл.66 sweep rollout (ЕКБ + oblast per-city schedules).
|
||||
# Normalized form: lowercase, hyphens collapsed to spaces (mirrors normalize_address,
|
||||
# e.g. 'каменск-уральский' -> 'каменск уральский'). Mirrors CITY_ANCHORS in
|
||||
# scraper_kit.orchestration.pipeline — extend together as the sweep adds cities.
|
||||
# EKB_CITY_TOKEN must be first (leftmost-alternation determinism / documentation).
|
||||
_CITY_TOKENS: tuple[str, ...] = (
|
||||
EKB_CITY_TOKEN,
|
||||
"нижний тагил",
|
||||
"каменск уральский",
|
||||
"первоуральск",
|
||||
"верхняя пышма",
|
||||
"серов",
|
||||
)
|
||||
# Token-bounded (not substring) so a street named after a city does NOT false-positive:
|
||||
# 'улица серова 5' has token 'серова' (trailing 'а'), never bare 'серов'.
|
||||
_CITY_TOKEN_RE = re.compile(
|
||||
r"(?:^|\s)(?:" + "|".join(re.escape(c) for c in _CITY_TOKENS) + r")(?:\s|$)"
|
||||
)
|
||||
|
||||
|
||||
def resolve_city_token(normalized: str | None) -> str | None:
|
||||
"""Return the обл.66 city token present in the normalized address, else None.
|
||||
|
||||
Token-bounded (see _CITY_TOKEN_RE) so a street named after a city never matches
|
||||
('улица серова 5' -> None, not 'серов'). When the address names ЕКБ, returns
|
||||
EKB_CITY_TOKEN; a non-ЕКБ oblast city returns its own token. Used by the Tier-2a
|
||||
oblast guard in match_or_create_house: a coord-less card that resolves a NON-ЕКБ city
|
||||
must not fingerprint/normalized_address-match a globally-unique alias that (almost
|
||||
certainly) belongs to ЕКБ/another city — that would corrupt ЕКБ house data.
|
||||
"""
|
||||
if not normalized:
|
||||
return None
|
||||
m = _CITY_TOKEN_RE.search(normalized)
|
||||
if m is None:
|
||||
return None
|
||||
# group(0) carries the (?:^|\s)...(?:\s|$) boundary whitespace — strip it to the token.
|
||||
return m.group(0).strip()
|
||||
|
||||
|
||||
def has_city_token(normalized: str | None) -> bool:
|
||||
"""True if the normalized address carries a known обл.66 city token.
|
||||
|
||||
A city token makes the normalized_address city-scoped — two addresses in different
|
||||
cities can then never collapse to the same key, so the alias is a safe cross-source
|
||||
building key. Used by the Tier-2b oblast guard in match_or_create_house: a bare
|
||||
common-street address with NO city token AND no coords is too ambiguous to match a
|
||||
globally-unique normalized_address alias (it would mis-bucket an oblast card into a
|
||||
same-named ЕКБ house — bug #2 oblast rollout).
|
||||
"""
|
||||
return resolve_city_token(normalized) is not None
|
||||
|
||||
|
||||
def address_fingerprint(address: str | None, lat: float | None, lon: float | None) -> str:
|
||||
"""SHA-256 fingerprint of normalized address + rounded coordinates (4 dp ≈ 11 m).
|
||||
|
||||
|
|
|
|||
|
|
@ -193,6 +193,16 @@ async def _job_deals_freshness_monitor(
|
|||
await loop.run_in_executor(None, check_deals_freshness, db, run_id, params)
|
||||
|
||||
|
||||
# ── sber_freshness_monitor — sync DB-only freshness check в executor ──────────
|
||||
async def _job_sber_freshness_monitor(
|
||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||
) -> None:
|
||||
from app.tasks.sber_freshness_monitor import check_sber_freshness
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, check_sber_freshness, db, run_id, params)
|
||||
|
||||
|
||||
# ── newbuilding_enrich — async, owns lifecycle ───────────────────────────────
|
||||
async def _job_newbuilding_enrich(
|
||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||
|
|
@ -394,6 +404,7 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
|
|||
"sber_index_pull": Handler(_job_sber_index_pull, "sber_index_pull"),
|
||||
"rosreestr_quarter_poll": Handler(_job_rosreestr_quarter_poll, "rosreestr_quarter_poll"),
|
||||
"deals_freshness_monitor": Handler(_job_deals_freshness_monitor, "deals_freshness_monitor"),
|
||||
"sber_freshness_monitor": Handler(_job_sber_freshness_monitor, "sber_freshness_monitor"),
|
||||
"newbuilding_enrich": Handler(_job_newbuilding_enrich, "newbuilding_enrich"),
|
||||
"yandex_newbuilding_sweep": Handler(
|
||||
_job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep"
|
||||
|
|
|
|||
|
|
@ -5,31 +5,62 @@
|
|||
открытых данных Росреестра.
|
||||
- ALERT: залогировать actionable-сообщение, если квартал доступен.
|
||||
- НЕ скачивает много-гигабайтный ZIP и НЕ вызывает shell-loaders — это ручной
|
||||
ops-шаг. После алерта оператор запускает:
|
||||
ops-шаг. После алерта оператор запускает (оба скрипта живут вне этого
|
||||
репозитория — при смене базового пути их тоже нужно поправить вручную):
|
||||
data/sql/02_load_all_quarters.sh
|
||||
tradein-mvp/deploy/import-rosreestr.sh
|
||||
|
||||
Архив открытых данных Росреестра (сделки ДКП/ДДУ) публикуется на портале:
|
||||
https://rosreestr.gov.ru/opendata/
|
||||
ИСТОРИЯ (важно для будущих правок): раньше архив открытых данных Росреестра
|
||||
(сделки ДКП/ДДУ) публиковался на https://rosreestr.gov.ru/opendata/ с индексом
|
||||
/opendata/f.json. Этот портал был переделан на Bitrix — прямые ссылки на ZIP
|
||||
теперь отвечают HTTP 200 с Content-Type: text/html (soft-404 "заглушка"), а
|
||||
/opendata/f.json больше не отдаёт JSON. Старая версия поллера трактовала любой
|
||||
HTTP 200 как "квартал доступен" и из-за soft-404 стала ложно репортить КАЖДЫЙ
|
||||
квартал как доступный — это и есть баг #issue, который чинит этот модуль.
|
||||
|
||||
Файлы именуются по шаблону:
|
||||
dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{N}.csv.zip
|
||||
АКТУАЛЬНОЕ (2026-07) расположение датасетов — открытый Apache autoindex без
|
||||
авторизации:
|
||||
https://rosreestr.gov.ru/data-sets/
|
||||
Структура:
|
||||
- Папки кварталов названы в кодировке Windows-1251 (percent-encoded href —
|
||||
именно cp1251, НЕ utf-8), например "1 квартал 2026г." →
|
||||
href="1%20%EA%E2%E0%F0%F2%E0%EB%202026%E3./". Плюс папка
|
||||
"Архив до 2023г. включительно/" для старых периодов.
|
||||
- Внутри папки квартала лежит dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{N}.csv.zip
|
||||
(тоже cp1251-кодированный href, например
|
||||
"dataset_%D1%C4%C5%CB%CA%C8_r-r_01-92_y_2026_q_1.csv.zip"), рядом —
|
||||
dataset_КАДАСТРСТОИМОСТЬ_* файлы (не наши).
|
||||
|
||||
Индексный JSON живёт по адресу:
|
||||
https://rosreestr.gov.ru/opendata/f.json (список всех датасетов)
|
||||
Вместо того чтобы вручную высчитывать percent-encoding (хрупко: имена папок
|
||||
расставляют руками, возможны отличия в пробелах/пунктуации между кварталами) —
|
||||
поллер запрашивает autoindex HTML и парсит href'ы, декодируя каждый
|
||||
percent-encoded href как cp1251 (urllib.parse.unquote(href, encoding="cp1251")),
|
||||
затем ищет папку/файл по совпадению с ожидаемым (год, квартал) в декодированном
|
||||
имени. См. check_new_quarter_available().
|
||||
|
||||
Проверяем HEAD-запросом (без скачивания) прямой ссылки на следующий квартал.
|
||||
Формат ссылки:
|
||||
https://rosreestr.gov.ru/opendata/dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{N}.csv.zip
|
||||
КЛЮЧЕВАЯ проверка доступности (защита от soft-404, см. "ИСТОРИЯ" выше): найденный
|
||||
файл датасета считается ДОСТУПНЫМ только если HEAD-ответ имеет
|
||||
Content-Type: application/zip И Content-Length больше ~100 KB. Голый HTTP 200
|
||||
недостаточен.
|
||||
|
||||
При сетевой ошибке / HTTP 5xx — логируем warning, возвращаем available=False.
|
||||
Статус 404 → квартал не опубликован → available=False (штатный случай до начала августа).
|
||||
rosreestr.gov.ru отдаёт сертификат от российского TLS root CA, которому нет
|
||||
доверия в стандартном trust store — соединение делаем с verify=False, как
|
||||
sber_index.py для sberindex.ru (см. #922, тот же паттерн: публичные
|
||||
неавторизованные открытые данные, TLS verify отключаем осознанно). Сервер также
|
||||
отвечает HTTP 403 без браузерного User-Agent — шлём Chrome UA (тот же паттерн,
|
||||
что DEFAULT_UA в zhkh_flats_loader.py).
|
||||
|
||||
При сетевой ошибке / HTTP 5xx / таймауте — логируем warning, возвращаем
|
||||
available=False. Отсутствие папки/файла квартала → available=False (штатный
|
||||
случай до публикации квартала, до начала следующего месяца после конца квартала).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import quote, unquote, urljoin
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
|
|
@ -37,16 +68,24 @@ from sqlalchemy.orm import Session
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Базовый URL открытых данных Росреестра (прямая ссылка на ZIP-файл квартала).
|
||||
# Шаблон подтверждён по документации портала opendata.rosreestr.gov.ru.
|
||||
_ROSREESTR_DATASET_URL_TEMPLATE = (
|
||||
"https://rosreestr.gov.ru/opendata/"
|
||||
"dataset_%D0%A1%D0%94%D0%95%D0%9B%D0%9A%D0%98_r-r_01-92_y_{year}_q_{quarter}.csv.zip"
|
||||
# Открытый Apache autoindex открытых данных Росреестра (см. докстринг модуля).
|
||||
_DATA_SETS_BASE_URL = "https://rosreestr.gov.ru/data-sets/"
|
||||
|
||||
# Браузерный UA — без него rosreestr.gov.ru отвечает HTTP 403.
|
||||
# Тот же паттерн, что DEFAULT_UA в app/services/zhkh_flats_loader.py.
|
||||
_BROWSER_USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
# Таймаут HEAD-запроса. Росреестр может быть медленным — 15s достаточно.
|
||||
# Порог отличия реального ZIP-архива от soft-404 заглушки/пустого файла.
|
||||
_MIN_DATASET_SIZE_BYTES = 100_000 # 100 KB
|
||||
|
||||
# Таймаут запросов. Росреестр может быть медленным — 15s достаточно.
|
||||
_HTTP_TIMEOUT = 15.0
|
||||
|
||||
_HREF_RE = re.compile(r'href="([^"]+)"', re.IGNORECASE)
|
||||
|
||||
|
||||
def _next_quarter(year: int, quarter: int) -> tuple[int, int]:
|
||||
"""Вернуть (year, quarter) для следующего квартала.
|
||||
|
|
@ -117,11 +156,68 @@ def latest_loaded_quarter(db: Session) -> tuple[int, int] | None:
|
|||
|
||||
|
||||
def rosreestr_dataset_url(year: int, quarter: int) -> str:
|
||||
"""Сформировать URL датасета Росреестра для заданного квартала.
|
||||
"""Сформировать best-effort прямую ссылку на ZIP датасета (для alert-сообщения оператору).
|
||||
|
||||
Возвращает прямую ссылку на ZIP-архив (percent-encoded шаблон).
|
||||
ВНИМАНИЕ: это ДЕТЕРМИНИРОВАННАЯ догадка по известному шаблону именования —
|
||||
папка "{quarter} квартал {year}г.", файл
|
||||
"dataset_СДЕЛКИ_r-r_01-92_y_{year}_q_{quarter}.csv.zip" — закодированная как
|
||||
cp1251 percent-encoding (подтверждено вручную против реального URL портала).
|
||||
Она НЕ используется для самой проверки доступности: check_new_quarter_available()
|
||||
парсит реальный autoindex и не полагается на угаданный URL, потому что
|
||||
Росреестр расставляет имена папок вручную и они могут отличаться в мелочах
|
||||
(лишний пробел, пунктуация) от квартала к кварталу.
|
||||
|
||||
Если угаданная ссылка не откроется — ориентир для оператора: _DATA_SETS_BASE_URL
|
||||
(https://rosreestr.gov.ru/data-sets/), там нужно найти папку глазами.
|
||||
"""
|
||||
return _ROSREESTR_DATASET_URL_TEMPLATE.format(year=year, quarter=quarter)
|
||||
folder_name = f"{quarter} квартал {year}г."
|
||||
file_name = f"dataset_СДЕЛКИ_r-r_01-92_y_{year}_q_{quarter}.csv.zip"
|
||||
folder_enc = quote(folder_name.encode("cp1251"), safe="")
|
||||
file_enc = quote(file_name.encode("cp1251"), safe="")
|
||||
return f"{_DATA_SETS_BASE_URL}{folder_enc}/{file_enc}"
|
||||
|
||||
|
||||
def _decode_cp1251_href(href: str) -> str:
|
||||
"""Декодировать percent-encoded href как cp1251 (портал кодирует кириллицу так, НЕ utf-8)."""
|
||||
try:
|
||||
return unquote(href, encoding="cp1251", errors="strict")
|
||||
except (UnicodeDecodeError, LookupError):
|
||||
# href без кириллицы (например "../") или неожиданная кодировка — fallback.
|
||||
return unquote(href)
|
||||
|
||||
|
||||
def _normalize_ws(s: str) -> str:
|
||||
"""Схлопнуть повторяющиеся пробелы — Росреестр расставляет имена папок вручную."""
|
||||
return " ".join(s.split())
|
||||
|
||||
|
||||
def _extract_hrefs(html: str) -> list[str]:
|
||||
"""Достать все href="..." из HTML autoindex-страницы (простой regex, не наш HTML)."""
|
||||
return _HREF_RE.findall(html)
|
||||
|
||||
|
||||
def _find_quarter_folder_href(html: str, year: int, quarter: int) -> str | None:
|
||||
"""Найти (не декодированный) href папки квартала (year, quarter) в HTML /data-sets/.
|
||||
|
||||
Сравнение — по декодированному (cp1251) и нормализованному по пробелам имени
|
||||
папки, ожидаемый паттерн "{quarter} квартал {year}г.".
|
||||
"""
|
||||
expected_name = f"{quarter} квартал {year}г."
|
||||
for href in _extract_hrefs(html):
|
||||
decoded = _normalize_ws(_decode_cp1251_href(href).rstrip("/"))
|
||||
if decoded == expected_name:
|
||||
return href
|
||||
return None
|
||||
|
||||
|
||||
def _find_dataset_file_href(html: str, year: int, quarter: int) -> str | None:
|
||||
"""Найти href файла dataset_СДЕЛКИ_..._y_{year}_q_{quarter}.csv.zip в HTML папки квартала."""
|
||||
expected_suffix = f"_y_{year}_q_{quarter}.csv.zip"
|
||||
for href in _extract_hrefs(html):
|
||||
decoded = _decode_cp1251_href(href)
|
||||
if decoded.startswith("dataset_СДЕЛКИ") and decoded.endswith(expected_suffix):
|
||||
return href
|
||||
return None
|
||||
|
||||
|
||||
async def check_new_quarter_available(
|
||||
|
|
@ -129,48 +225,109 @@ async def check_new_quarter_available(
|
|||
year: int,
|
||||
quarter: int,
|
||||
) -> bool:
|
||||
"""Проверить, опубликован ли датасет Росреестра для (year, quarter).
|
||||
"""Проверить, опубликован ли датасет СДЕЛКИ Росреестра для (year, quarter).
|
||||
|
||||
HEAD-запрос к прямой ссылке на ZIP — без скачивания файла.
|
||||
Шаги (см. докстринг модуля за подробностями):
|
||||
1. GET autoindex _DATA_SETS_BASE_URL, найти папку квартала по декодированному
|
||||
(cp1251) имени "{quarter} квартал {year}г.".
|
||||
2. GET найденную папку, найти файл dataset_СДЕЛКИ_..._y_{year}_q_{quarter}.csv.zip.
|
||||
3. HEAD найденный файл — считаем ДОСТУПНЫМ, только если Content-Type
|
||||
начинается с application/zip И Content-Length > _MIN_DATASET_SIZE_BYTES.
|
||||
Голый HTTP 200 НЕ считается доступностью — это была причина бага
|
||||
(Bitrix-заглушка /opendata/ тоже отвечала 200).
|
||||
|
||||
Возвращает True если сервер ответил 200 (или 2xx/3xx с redirect).
|
||||
Возвращает False при 404 (квартал ещё не опубликован) или сетевой ошибке.
|
||||
Отсутствие папки/файла квартала → available=False (нормально до публикации).
|
||||
Сетевая ошибка / таймаут / неожиданное исключение → warning, available=False.
|
||||
Никогда не поднимает исключения в вызывающий код (scheduler-safe).
|
||||
"""
|
||||
url = rosreestr_dataset_url(year, quarter)
|
||||
try:
|
||||
resp = await client.head(url, follow_redirects=True)
|
||||
if resp.status_code == 200:
|
||||
logger.info(
|
||||
"rosreestr_poll: Q%d %d available at %s (HTTP %d)",
|
||||
index_resp = await client.get(_DATA_SETS_BASE_URL, follow_redirects=True)
|
||||
if index_resp.status_code != 200:
|
||||
logger.warning(
|
||||
"rosreestr_poll: unexpected HTTP %d listing %s — treating Q%d %d as unavailable",
|
||||
index_resp.status_code,
|
||||
_DATA_SETS_BASE_URL,
|
||||
quarter,
|
||||
year,
|
||||
url,
|
||||
resp.status_code,
|
||||
)
|
||||
return False
|
||||
|
||||
folder_href = _find_quarter_folder_href(index_resp.text, year, quarter)
|
||||
if folder_href is None:
|
||||
logger.info(
|
||||
"rosreestr_poll: quarter folder for Q%d %d not found under %s — not yet published",
|
||||
quarter,
|
||||
year,
|
||||
_DATA_SETS_BASE_URL,
|
||||
)
|
||||
return False
|
||||
|
||||
folder_url = urljoin(_DATA_SETS_BASE_URL, folder_href)
|
||||
folder_resp = await client.get(folder_url, follow_redirects=True)
|
||||
if folder_resp.status_code != 200:
|
||||
logger.warning(
|
||||
"rosreestr_poll: unexpected HTTP %d listing folder %s — "
|
||||
"treating Q%d %d as unavailable",
|
||||
folder_resp.status_code,
|
||||
folder_url,
|
||||
quarter,
|
||||
year,
|
||||
)
|
||||
return False
|
||||
|
||||
file_href = _find_dataset_file_href(folder_resp.text, year, quarter)
|
||||
if file_href is None:
|
||||
logger.info(
|
||||
"rosreestr_poll: dataset_СДЕЛКИ file for Q%d %d not found in folder %s",
|
||||
quarter,
|
||||
year,
|
||||
folder_url,
|
||||
)
|
||||
return False
|
||||
|
||||
file_url = urljoin(folder_url, file_href)
|
||||
file_resp = await client.head(file_url, follow_redirects=True)
|
||||
|
||||
content_type = file_resp.headers.get("content-type", "")
|
||||
try:
|
||||
content_length = int(file_resp.headers.get("content-length") or "0")
|
||||
except ValueError:
|
||||
content_length = 0
|
||||
|
||||
if (
|
||||
file_resp.status_code == 200
|
||||
and content_type.startswith("application/zip")
|
||||
and content_length > _MIN_DATASET_SIZE_BYTES
|
||||
):
|
||||
logger.info(
|
||||
"rosreestr_poll: Q%d %d available at %s (HTTP 200, %s, %d bytes)",
|
||||
quarter,
|
||||
year,
|
||||
file_url,
|
||||
content_type,
|
||||
content_length,
|
||||
)
|
||||
return True
|
||||
if resp.status_code == 404:
|
||||
|
||||
logger.info(
|
||||
"rosreestr_poll: Q%d %d not yet published (HTTP 404)",
|
||||
"rosreestr_poll: Q%d %d file found (%s) but failed availability check "
|
||||
"(HTTP %d, Content-Type=%r, Content-Length=%d) — soft-404 guard, "
|
||||
"treating as unavailable",
|
||||
quarter,
|
||||
year,
|
||||
file_url,
|
||||
file_resp.status_code,
|
||||
content_type,
|
||||
content_length,
|
||||
)
|
||||
return False
|
||||
# Другие коды (403, 5xx, etc.) — логируем как warning, не кидаем
|
||||
logger.warning(
|
||||
"rosreestr_poll: unexpected HTTP %d for Q%d %d url=%s — treating as unavailable",
|
||||
resp.status_code,
|
||||
quarter,
|
||||
year,
|
||||
url,
|
||||
)
|
||||
return False
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.warning(
|
||||
"rosreestr_poll: timeout checking Q%d %d url=%s — treating as unavailable",
|
||||
"rosreestr_poll: timeout checking Q%d %d under %s — treating as unavailable",
|
||||
quarter,
|
||||
year,
|
||||
url,
|
||||
_DATA_SETS_BASE_URL,
|
||||
)
|
||||
return False
|
||||
except httpx.RequestError as exc:
|
||||
|
|
@ -197,7 +354,7 @@ async def poll_rosreestr_new_quarter(db: Session) -> dict[str, Any]:
|
|||
Шаги:
|
||||
1. Найти MAX(deal_date) WHERE source='rosreestr' → (loaded_year, loaded_quarter).
|
||||
2. Вычислить next = _next_quarter(loaded_year, loaded_quarter).
|
||||
3. HEAD-запросом проверить наличие датасета на rosreestr.gov.ru.
|
||||
3. Проверить наличие датасета на rosreestr.gov.ru/data-sets/ (autoindex + Content-Type).
|
||||
4. Если доступен — логировать actionable INFO с инструкцией по запуску ingest.
|
||||
5. Вернуть dict с результатом.
|
||||
|
||||
|
|
@ -230,18 +387,27 @@ async def poll_rosreestr_new_quarter(db: Session) -> dict[str, Any]:
|
|||
loaded_year,
|
||||
)
|
||||
|
||||
# 3. Проверка наличия
|
||||
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT) as client:
|
||||
# 3. Проверка наличия (verify=False: см. докстринг модуля — RU root CA не в trust store;
|
||||
# браузерный UA: без него rosreestr.gov.ru отвечает 403)
|
||||
async with httpx.AsyncClient(
|
||||
timeout=_HTTP_TIMEOUT,
|
||||
verify=False,
|
||||
headers={"User-Agent": _BROWSER_USER_AGENT},
|
||||
) as client:
|
||||
available = await check_new_quarter_available(client, next_year, next_quarter)
|
||||
|
||||
# 4. Алерт если доступен
|
||||
if available:
|
||||
logger.info(
|
||||
"rosreestr_poll: NEW QUARTER AVAILABLE — Q%d %d. "
|
||||
"Run ingest to load: "
|
||||
"data/sql/02_load_all_quarters.sh + tradein-mvp/deploy/import-rosreestr.sh",
|
||||
"rosreestr_poll: NEW QUARTER AVAILABLE — Q%d %d (%s). "
|
||||
"Run ingest to load: data/sql/02_load_all_quarters.sh + "
|
||||
"tradein-mvp/deploy/import-rosreestr.sh "
|
||||
"(both live outside this repo — if they still point at the old "
|
||||
"rosreestr.gov.ru/opendata/ base, update them to %s first)",
|
||||
next_quarter,
|
||||
next_year,
|
||||
rosreestr_dataset_url(next_year, next_quarter),
|
||||
_DATA_SETS_BASE_URL,
|
||||
)
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@ from app.services import scrape_runs as runs_mod
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# import_rosreestr_dkp: доля per-row INSERT-ошибок (rows_errored / rows_fetched), выше
|
||||
# которой прогон помечается FAILED, а не silent-green (Fix C). Единичные битые строки
|
||||
# (редкий bad row) не валят импорт; систематический сбой (≈100% ошибок) — валит.
|
||||
DKP_IMPORT_ERROR_RATE_THRESHOLD = 0.05
|
||||
|
||||
|
||||
def compute_next_run_at(
|
||||
window_start_hour: int,
|
||||
|
|
@ -180,9 +185,16 @@ def import_rosreestr_dkp(
|
|||
SERVER gendesign_remote настроен в 060_postgres_fdw_extension.sql.
|
||||
USER MAPPING создаётся при startup в core/fdw.py (tradein_fdw_reader).
|
||||
|
||||
Область покрытия: вся Свердловская область (region_code=66), не только Екатеринбург —
|
||||
прежний ILIKE-фильтр по подстроке города (ограничивавший импорт одним Екатеринбургом)
|
||||
снят (Mera trade-in расширяется на весь регион, unlocks +47183 сделок вне ЕКБ уже
|
||||
сидящих в source foreign table). address и deals.city строятся из реального city
|
||||
источника (не хардкод "Екатеринбург"), deals.region_code заполняется из строки
|
||||
источника (= 66 при текущем фильтре).
|
||||
|
||||
Фильтры (совпадают с import-rosreestr.sh + Fix_Rosreestr_Dkp_Filter_May24):
|
||||
- region_code = 66 (Свердловская область)
|
||||
- city ILIKE '%катеринбург%'
|
||||
- region_code = 66 (вся Свердловская область, все города)
|
||||
- city IS NOT NULL AND trim(city) != '' (непустой город → корректный address)
|
||||
- realestate_type_code = '002001003000' (квартира)
|
||||
- area BETWEEN 18 AND 200
|
||||
- deal_price BETWEEN 1000000 AND 100000000
|
||||
|
|
@ -210,7 +222,16 @@ def import_rosreestr_dkp(
|
|||
counters: dict[str, int] = {
|
||||
"rows_fetched": 0,
|
||||
"rows_inserted": 0,
|
||||
# rows_updated: ON CONFLICT DO UPDATE обновил существующую строку
|
||||
# (исправленный/переопубликованный квартал — Fix D).
|
||||
"rows_updated": 0,
|
||||
# rows_skipped: ТОЛЬКО легитимный dedup-пропуск (строка уже есть, факты
|
||||
# идентичны — DO UPDATE ... WHERE distinct не сработал).
|
||||
"rows_skipped": 0,
|
||||
# rows_errored: реальные per-row INSERT-ошибки, отделены от dedup-skip (Fix C),
|
||||
# раньше обе категории клались в rows_skipped → систематический сбой выглядел
|
||||
# как обычный дедуп и прогон рапортовал success.
|
||||
"rows_errored": 0,
|
||||
"batches_done": 0,
|
||||
}
|
||||
|
||||
|
|
@ -248,8 +269,9 @@ def import_rosreestr_dkp(
|
|||
# #1182 Phase 2: кооперативный SIGTERM-drain (деплой recreate scraper).
|
||||
# Это НЕ user-cancel → mark_done (partial), не mark_cancelled. Курсор
|
||||
# уже зафиксирован update_heartbeat'ом каждый батч; следующий run
|
||||
# пере-сканирует с id=0 (INSERT ... ON CONFLICT DO NOTHING идемпотентен)
|
||||
# → уже вставленные строки пропускаются, остаток до-импортируется.
|
||||
# пере-сканирует с id=0 — INSERT ... ON CONFLICT DO UPDATE идемпотентен
|
||||
# для неизменных строк (WHERE ... IS DISTINCT FROM пропускает совпадающие
|
||||
# факты), поэтому повторный проход не плодит дубликаты и не трогает данные.
|
||||
# mark_done выводит run из 'running' → reap_zombies его не тронет.
|
||||
logger.info(
|
||||
"rosreestr_dkp_import run_id=%d: SIGTERM-drain — committing partial "
|
||||
|
|
@ -273,7 +295,9 @@ def import_rosreestr_dkp(
|
|||
id,
|
||||
id AS source_id_src,
|
||||
'ros:dkp:' || CAST(id AS text) AS dedup_hash,
|
||||
'Екатеринбург, ' || trim(street) AS address,
|
||||
trim(city) || ', ' || trim(street) AS address,
|
||||
region_code,
|
||||
trim(city) AS city,
|
||||
CASE
|
||||
WHEN area < 30 THEN 0
|
||||
WHEN area < 44 THEN 1
|
||||
|
|
@ -296,7 +320,7 @@ def import_rosreestr_dkp(
|
|||
period_start_date AS deal_date
|
||||
FROM gendesign_rosreestr_deals
|
||||
WHERE region_code = 66
|
||||
AND city ILIKE '%катеринбург%'
|
||||
AND city IS NOT NULL AND trim(city) <> ''
|
||||
AND realestate_type_code = '002001003000'
|
||||
AND area BETWEEN 18 AND 200
|
||||
AND deal_price BETWEEN 1000000 AND 100000000
|
||||
|
|
@ -318,7 +342,9 @@ def import_rosreestr_dkp(
|
|||
|
||||
total_batches += 1
|
||||
batch_inserted = 0
|
||||
batch_updated = 0
|
||||
batch_skipped = 0
|
||||
batch_errored = 0
|
||||
batch_max_id = last_id
|
||||
|
||||
for row in batch_rows:
|
||||
|
|
@ -328,17 +354,30 @@ def import_rosreestr_dkp(
|
|||
|
||||
try:
|
||||
with db.begin_nested(): # SAVEPOINT per row
|
||||
inserted = db.execute(
|
||||
# Fix D: ON CONFLICT DO UPDATE вместо прежнего no-op-дедупа — при
|
||||
# повторном импорте ИСПРАВЛЕННОГО/переопубликованного квартала обновляем
|
||||
# сырые факты Росреестра. WHERE ... IS DISTINCT FROM оставляет
|
||||
# неизменные строки нетронутыми (идемпотентность resume: строка
|
||||
# без изменений → 0 returned → legit dedup-skip). source/source_id
|
||||
# (identity) и dedup_hash (ключ конфликта) стабильны, не трогаем.
|
||||
# Обогащение (lat/lon/geom/geocode_tried_at, cadastral_number,
|
||||
# total_floors, house_type ...) НЕ в EXCLUDED-списке → сохраняется.
|
||||
# RETURNING (xmax = 0): freshly-inserted → xmax=0 (was_inserted),
|
||||
# обновлённая по ON CONFLICT → xmax<>0; отличаем insert от update.
|
||||
result = db.execute(
|
||||
text("""
|
||||
INSERT INTO deals (
|
||||
source, dedup_hash, source_id, address, rooms, area_m2,
|
||||
floor, year_built, price_rub, price_per_m2, deal_date
|
||||
source, dedup_hash, source_id, address, region_code, city,
|
||||
rooms, area_m2, floor, year_built, price_rub, price_per_m2,
|
||||
deal_date
|
||||
)
|
||||
VALUES (
|
||||
'rosreestr',
|
||||
CAST(:dedup_hash AS text),
|
||||
CAST(:source_id AS text),
|
||||
CAST(:address AS text),
|
||||
CAST(:region_code AS int),
|
||||
CAST(:city AS text),
|
||||
CAST(:rooms AS int),
|
||||
CAST(:area_m2 AS numeric),
|
||||
CAST(:floor_num AS int),
|
||||
|
|
@ -347,13 +386,35 @@ def import_rosreestr_dkp(
|
|||
CAST(:price_per_m2 AS int),
|
||||
CAST(:deal_date AS date)
|
||||
)
|
||||
ON CONFLICT (dedup_hash) DO NOTHING
|
||||
RETURNING id
|
||||
ON CONFLICT (dedup_hash) DO UPDATE SET
|
||||
address = EXCLUDED.address,
|
||||
region_code = EXCLUDED.region_code,
|
||||
city = EXCLUDED.city,
|
||||
rooms = EXCLUDED.rooms,
|
||||
area_m2 = EXCLUDED.area_m2,
|
||||
floor = EXCLUDED.floor,
|
||||
year_built = EXCLUDED.year_built,
|
||||
price_rub = EXCLUDED.price_rub,
|
||||
price_per_m2 = EXCLUDED.price_per_m2,
|
||||
deal_date = EXCLUDED.deal_date
|
||||
WHERE deals.address IS DISTINCT FROM EXCLUDED.address
|
||||
OR deals.region_code IS DISTINCT FROM EXCLUDED.region_code
|
||||
OR deals.city IS DISTINCT FROM EXCLUDED.city
|
||||
OR deals.rooms IS DISTINCT FROM EXCLUDED.rooms
|
||||
OR deals.area_m2 IS DISTINCT FROM EXCLUDED.area_m2
|
||||
OR deals.floor IS DISTINCT FROM EXCLUDED.floor
|
||||
OR deals.year_built IS DISTINCT FROM EXCLUDED.year_built
|
||||
OR deals.price_rub IS DISTINCT FROM EXCLUDED.price_rub
|
||||
OR deals.price_per_m2 IS DISTINCT FROM EXCLUDED.price_per_m2
|
||||
OR deals.deal_date IS DISTINCT FROM EXCLUDED.deal_date
|
||||
RETURNING (xmax = 0) AS was_inserted
|
||||
"""),
|
||||
{
|
||||
"dedup_hash": row["dedup_hash"],
|
||||
"source_id": str(row["source_id_src"]),
|
||||
"address": row["address"],
|
||||
"region_code": row["region_code"],
|
||||
"city": row["city"],
|
||||
"rooms": row["rooms"],
|
||||
"area_m2": row["area_m2"],
|
||||
"floor_num": row["floor_num"],
|
||||
|
|
@ -363,25 +424,33 @@ def import_rosreestr_dkp(
|
|||
"deal_date": row["deal_date"],
|
||||
},
|
||||
).fetchone()
|
||||
if inserted is not None:
|
||||
if result is None:
|
||||
# DO UPDATE ... WHERE distinct не сработал → строка есть и
|
||||
# факты идентичны = легитимный dedup-skip (Fix C: только это
|
||||
# теперь считается skip, INSERT-ошибки — отдельно ниже).
|
||||
batch_skipped += 1
|
||||
elif result[0]:
|
||||
batch_inserted += 1
|
||||
else:
|
||||
batch_skipped += 1
|
||||
batch_updated += 1
|
||||
except Exception as exc:
|
||||
# Fix C: реальная per-row INSERT-ошибка — ОТДЕЛЬНЫЙ счётчик, не skip.
|
||||
logger.warning(
|
||||
"rosreestr_dkp_import run_id=%d: row id=%d INSERT failed: %s",
|
||||
run_id,
|
||||
row_id,
|
||||
exc,
|
||||
)
|
||||
batch_skipped += 1
|
||||
batch_errored += 1
|
||||
|
||||
last_id = batch_max_id
|
||||
db.commit()
|
||||
|
||||
counters["rows_fetched"] += len(batch_rows)
|
||||
counters["rows_inserted"] += batch_inserted
|
||||
counters["rows_updated"] += batch_updated
|
||||
counters["rows_skipped"] += batch_skipped
|
||||
counters["rows_errored"] += batch_errored
|
||||
counters["batches_done"] = total_batches
|
||||
counters["last_id"] = last_id # type: ignore[assignment]
|
||||
|
||||
|
|
@ -389,26 +458,42 @@ def import_rosreestr_dkp(
|
|||
runs_mod.update_heartbeat(db, run_id, counters)
|
||||
logger.info(
|
||||
"rosreestr_dkp_import run_id=%d: batch=%d fetched=%d "
|
||||
"inserted=%d skipped=%d last_id=%d",
|
||||
"inserted=%d updated=%d skipped=%d errored=%d last_id=%d",
|
||||
run_id,
|
||||
total_batches,
|
||||
len(batch_rows),
|
||||
batch_inserted,
|
||||
batch_updated,
|
||||
batch_skipped,
|
||||
batch_errored,
|
||||
last_id,
|
||||
)
|
||||
|
||||
if len(batch_rows) < batch_size:
|
||||
break # Last partial batch — no more rows
|
||||
|
||||
# Fix C: систематический per-row INSERT-сбой больше не рапортует success.
|
||||
# Отделив rows_errored от rows_skipped, проверяем долю ошибок: выше порога —
|
||||
# прогон FAILED (raise → внешний except → mark_failed), а не silent-green.
|
||||
fetched = counters["rows_fetched"]
|
||||
errored = counters["rows_errored"]
|
||||
if fetched > 0 and errored / fetched > DKP_IMPORT_ERROR_RATE_THRESHOLD:
|
||||
raise RuntimeError(
|
||||
f"rosreestr_dkp_import: per-row INSERT error rate "
|
||||
f"{errored}/{fetched} ({errored / fetched:.1%}) exceeds "
|
||||
f"{DKP_IMPORT_ERROR_RATE_THRESHOLD:.0%} threshold — marking run failed"
|
||||
)
|
||||
|
||||
runs_mod.mark_done(db, run_id, counters)
|
||||
logger.info(
|
||||
"rosreestr_dkp_import run_id=%d done: "
|
||||
"total_fetched=%d inserted=%d skipped=%d batches=%d",
|
||||
"total_fetched=%d inserted=%d updated=%d skipped=%d errored=%d batches=%d",
|
||||
run_id,
|
||||
counters["rows_fetched"],
|
||||
counters["rows_inserted"],
|
||||
counters["rows_updated"],
|
||||
counters["rows_skipped"],
|
||||
counters["rows_errored"],
|
||||
total_batches,
|
||||
)
|
||||
|
||||
|
|
|
|||
9
tradein-mvp/backend/app/services/tgbot/__init__.py
Normal file
9
tradein-mvp/backend/app/services/tgbot/__init__.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"""Telegram support bridge (@MERAsupport_bot) — long-polling мост клиент↔оператор.
|
||||
|
||||
Клиент пишет боту в личку → зеркалится в топик support-группы (`client.py` —
|
||||
тонкая HTTP-обёртка над Bot API; `bridge.py` — маршрутизация апдейтов и
|
||||
персистентность через tg_support_* таблицы). Standalone entrypoint —
|
||||
`app.tgbot_main` (long-polling воркер, НЕ webhook, отдельный контейнер/процесс).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
728
tradein-mvp/backend/app/services/tgbot/bridge.py
Normal file
728
tradein-mvp/backend/app/services/tgbot/bridge.py
Normal file
|
|
@ -0,0 +1,728 @@
|
|||
"""Маршрутизация Telegram-апдейтов для support-моста (#tgsupport, #tgsupport-web).
|
||||
|
||||
Поток:
|
||||
A) Клиент пишет боту в личку (chat.type == 'private') →
|
||||
upsert tg_support_users → (если первое сообщение за последний час — шапка
|
||||
с идентификацией клиента в топик) → copyMessage контента в support-топик →
|
||||
запись в tg_support_messages (direction='in', topic_message_id — ключ
|
||||
маршрутизации ответа).
|
||||
A') Пользователь сайта пишет через `app.api.v1.support` (веб-чат поддержки,
|
||||
#tgsupport-web) → тот эндпоинт САМ зеркалит sendMessage'ом в топик и пишет
|
||||
web_support_messages (direction='in') — этот модуль в этой ветке не участвует,
|
||||
только в разборе ответа (B ниже).
|
||||
B) Оператор отвечает РЕПЛАЕМ в support-группе на зеркало клиента →
|
||||
резолвим topic_message_id ОБЕ стороны (tg_support_messages И
|
||||
web_support_messages), скоупя к ТЕКУЩЕМУ TELEGRAM_SUPPORT_CHAT_ID
|
||||
(#tgsupport-web review M1 — если группу когда-нибудь сменят/пересоздадут,
|
||||
Telegram message_id стартует заново и может совпасть со старым числом из
|
||||
другой таблицы; без скоупинга это была бы ТИХАЯ доставка постороннему
|
||||
клиенту). Совпадение НА ОБЕИХ сторонах одновременно — громкий отказ
|
||||
(`logger.error`, ничего не доставляем) вместо произвольного выбора одной из
|
||||
них. Иначе: chat_id найден → copyMessage ответа в личку клиента → запись
|
||||
(direction='out'); thread_id найден (веб-зеркало) → доставка идёт НЕ в
|
||||
Telegram (у веб-клиента нет личного чата с ботом), а записью direction='out'
|
||||
в web_support_messages (веб-фронт вычитывает её обычным polling'ом); реплай
|
||||
медиа-типом на веб-зеркало — веб-чат текстовый MVP, доставка целиком
|
||||
отклоняется (не частично — фото с подписью НЕ превращается в "ответ = только
|
||||
подпись"), оператор получает уведомление в топике (review M2). Реплай не на
|
||||
зеркало (или не реплай вообще) — обычная болтовня в топике, тихий игнор.
|
||||
Telegram 403 (клиент заблокировал бота) → is_blocked=true + уведомление в
|
||||
топике (только для Telegram-ветки — у веб-клиента нет "заблокировал бота").
|
||||
C) Дедуп: update_id <= сохранённого offset — skip. Offset сохраняется И
|
||||
коммитится в той же транзакции, что и запись сообщения (см. `process_update`
|
||||
`finally`), после КАЖДОГО апдейта — рестарт воркера не переигрывает уже
|
||||
обработанные апдейты и не подвисает вечно на «ядовитом» апдейте.
|
||||
D) TELEGRAM_BOT_TOKEN пуст → бот выключен — проверяется в `app.tgbot_main`
|
||||
(entrypoint), не здесь.
|
||||
E) /start клиенту → короткое приветствие МЕРЫ, без зеркалирования в топик
|
||||
(команда — не содержательное обращение, не должна засорять топик).
|
||||
F) Флуд-лимит на отправителя (низкий приоритет, per-chat_id): воркер
|
||||
long-polling однопоточный и обрабатывает апдейты СТРОГО последовательно, а
|
||||
Telegram ограничивает саму support-группу ~20 сообщениями/минуту — ОДНИМ
|
||||
бюджетом на ВСЕХ клиентов разом (зеркала + шапки + ответы оператора).
|
||||
Превышение — 429 с ожиданием 30-60с, на которые воркер не может обработать
|
||||
НИ ОДНОГО следующего апдейта — один флудящий клиент подвешивает доставку
|
||||
всем остальным. `_flood_limiter` (тот же `SlidingWindowLimiter`, что и
|
||||
веб-чат поддержки, ключ — TELEGRAM chat_id) режет per-sender поток заметно
|
||||
ниже группового лимита; сообщения сверх бюджета НЕ зеркалируются (иначе
|
||||
сам факт мирроринга уже съедает групповой бюджет, который мы и защищаем) и
|
||||
НЕ пишутся в tg_support_messages (нечего маршрутизировать без
|
||||
topic_message_id). Клиент получает уведомление, что сообщение НЕ
|
||||
доставлено (молчать нельзя — иначе клиент решит, что оператор его получил),
|
||||
но не чаще ОДНОГО РАЗА за то же окно (`_flood_notify_limiter`, limit=1) —
|
||||
иначе само уведомление стало бы вторым источником флуда.
|
||||
|
||||
Персистентность вынесена за `BridgeStorage`-протокол — маршрутизирующая логика
|
||||
(`process_update` и приватные `_handle_*`) не завязана на реальную БД, тестируется
|
||||
на in-memory fake storage + mock httpx (см. tests/services/tgbot/). Веб-чат
|
||||
таблицы (web_support_threads/web_support_messages) сознательно ОТДЕЛЬНЫ от
|
||||
tg_support_* — обоснование в data/sql/187_web_support_chat.sql; здесь `BridgeStorage`
|
||||
несёт два дополнительных метода (`find_web_thread_by_topic_message`,
|
||||
`record_web_out_message`), делегирующих в `web_support_storage`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Protocol
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.ratelimit import SlidingWindowLimiter
|
||||
from app.core.shutdown import shutdown_requested
|
||||
from app.services.tgbot import web_support_storage
|
||||
from app.services.tgbot.client import TelegramApiError, TelegramClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Ключ в tg_support_state под который сохраняется last processed update_id
|
||||
# (см. data/sql/186_tg_support.sql — комментарий на колонке .key).
|
||||
_OFFSET_KEY = "last_update_id"
|
||||
|
||||
# Окно, за которое повторное сообщение клиента НЕ дублирует шапку-идентификацию
|
||||
# в топике (одна шапка на "сессию" обращения).
|
||||
_HEADER_THROTTLE_WINDOW_S = 3600
|
||||
|
||||
GREETING_TEXT = (
|
||||
"Здравствуйте! Это служба поддержки МЕРА (сервис trade-in квартир). "
|
||||
"Опишите ваш вопрос — оператор ответит вам в этом чате в ближайшее время."
|
||||
)
|
||||
|
||||
# Отправляется клиенту вместо тихой потери сообщения, если TELEGRAM_SUPPORT_CHAT_ID
|
||||
# не сконфигурирован (иначе клиент ждёт ответа, которого никогда не будет — #5 review).
|
||||
SERVICE_UNAVAILABLE_TEXT = (
|
||||
"Служба поддержки временно недоступна. Пожалуйста, попробуйте написать позже."
|
||||
)
|
||||
|
||||
# Kinds, задокументированные в data/sql/186_tg_support.sql (COMMENT ON COLUMN
|
||||
# tg_support_messages.kind): "text | photo | document | video | voice | other".
|
||||
_KNOWN_KINDS = ("text", "photo", "document", "video", "voice")
|
||||
|
||||
# (низкий приоритет, флуд-защита) — см. пункт F) в докстринге модуля. Порог
|
||||
# НАМЕРЕННО заметно ниже группового лимита Telegram (~20 msg/min): бюджет
|
||||
# делится с шапками-идентификациями и ответами оператора, и с другими
|
||||
# одновременными клиентами — щедрый лимит одного отправителя всё равно упёрся
|
||||
# бы в общий групповой 429. Тот же примитив, что и веб-чат поддержки
|
||||
# (app/api/v1/support.py `_send_limiter`), ключ здесь — TELEGRAM chat_id
|
||||
# отправителя (не username — у Telegram-клиента username может отсутствовать).
|
||||
_FLOOD_LIMIT = 5
|
||||
_FLOOD_WINDOW_S = 60.0
|
||||
_flood_limiter = SlidingWindowLimiter(limit=_FLOOD_LIMIT, window_s=_FLOOD_WINDOW_S)
|
||||
|
||||
# Уведомление о флуде — не чаще ОДНОГО раза за то же окно, иначе само
|
||||
# уведомление стало бы вторым источником флуда. Отдельный лимитер с limit=1 на
|
||||
# то же окно: `check()` возвращает None (и фиксирует попытку) ровно один раз за
|
||||
# окно, дальше молчит до его истечения — без отдельной структуры "когда в
|
||||
# последний раз уведомляли".
|
||||
_flood_notify_limiter = SlidingWindowLimiter(limit=1, window_s=_FLOOD_WINDOW_S)
|
||||
|
||||
FLOOD_LIMITED_TEXT = (
|
||||
"Сообщение не доставлено — вы отправляете сообщения слишком часто. "
|
||||
"Пожалуйста, подождите немного и напишите ещё раз."
|
||||
)
|
||||
|
||||
# #tgsupport-web review M2: реплай оператора медиа-типом (в т.ч. фото С ПОДПИСЬЮ)
|
||||
# на веб-зеркало НЕ доставляется частично — веб-чат текстовый MVP, оператор
|
||||
# получает это уведомление в топике вместо тихого игнора (иначе уверен, что ответил).
|
||||
_WEB_UNSUPPORTED_MEDIA_REPLY_TEXT = "Веб-чат поддерживает только текст, сообщение не доставлено."
|
||||
|
||||
|
||||
# ── Storage abstraction (testable без реальной БД) ──────────────────────────
|
||||
class BridgeStorage(Protocol):
|
||||
"""Persistence-контракт моста. `SqlBridgeStorage` — прод-реализация поверх
|
||||
tg_support_* (см. data/sql/186_tg_support.sql). Тесты используют in-memory fake."""
|
||||
|
||||
def get_offset(self) -> int: ...
|
||||
|
||||
def save_offset(self, update_id: int) -> None: ...
|
||||
|
||||
def commit(self) -> None: ...
|
||||
|
||||
def rollback(self) -> None: ...
|
||||
|
||||
def upsert_user(
|
||||
self,
|
||||
*,
|
||||
chat_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
language_code: str | None,
|
||||
) -> None: ...
|
||||
|
||||
def had_recent_inbound(self, chat_id: int, window_seconds: int) -> bool: ...
|
||||
|
||||
def record_message(
|
||||
self,
|
||||
*,
|
||||
chat_id: int,
|
||||
direction: str,
|
||||
tg_message_id: int | None,
|
||||
topic_message_id: int | None,
|
||||
kind: str,
|
||||
text_body: str | None,
|
||||
operator_tg_id: int | None,
|
||||
support_chat_id: int | None = None,
|
||||
) -> int | None: ...
|
||||
|
||||
def find_chat_by_topic_message(
|
||||
self, topic_message_id: int, support_chat_id: int
|
||||
) -> int | None: ...
|
||||
|
||||
def mark_blocked(self, chat_id: int) -> None: ...
|
||||
|
||||
def find_web_thread_by_topic_message(
|
||||
self, topic_message_id: int, support_chat_id: int
|
||||
) -> int | None: ...
|
||||
|
||||
def record_web_out_message(
|
||||
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
|
||||
) -> None: ...
|
||||
|
||||
|
||||
class SqlBridgeStorage:
|
||||
"""`BridgeStorage` поверх SQLAlchemy Session (psycopg v3), tg_support_* таблицы.
|
||||
|
||||
Методы исполняют SQL немедленно, но НЕ коммитят по отдельности — коммит
|
||||
один раз в конце `process_update` (после записи сообщения И offset'а), чтобы
|
||||
оба изменения фиксировались атомарно в одной транзакции (требование C).
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self._db = db
|
||||
|
||||
def get_offset(self) -> int:
|
||||
row = self._db.execute(
|
||||
text("SELECT value FROM tg_support_state WHERE key = CAST(:key AS text)"),
|
||||
{"key": _OFFSET_KEY},
|
||||
).fetchone()
|
||||
if row is None or row[0] is None:
|
||||
return 0
|
||||
try:
|
||||
return int(row[0])
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("tgbot storage: невалидный offset в БД (%r) — считаем 0", row[0])
|
||||
return 0
|
||||
|
||||
def save_offset(self, update_id: int) -> None:
|
||||
self._db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO tg_support_state (key, value, updated_at)
|
||||
VALUES (CAST(:key AS text), CAST(:value AS text), NOW())
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET value = EXCLUDED.value, updated_at = NOW()
|
||||
"""
|
||||
),
|
||||
{"key": _OFFSET_KEY, "value": str(update_id)},
|
||||
)
|
||||
|
||||
def commit(self) -> None:
|
||||
self._db.commit()
|
||||
|
||||
def rollback(self) -> None:
|
||||
"""Откатывает текущую (возможно failed-transaction) сессию перед save_offset.
|
||||
|
||||
Нужно, когда исключение пришло от самой БД (напр. обрыв коннекта к
|
||||
postgres при деплое) — SQLAlchemy Session после такого исключения
|
||||
переходит в failed-transaction state, и ЛЮБОЙ следующий `execute()`
|
||||
(включая `save_offset`) кидает `PendingRollbackError` без явного
|
||||
rollback() (#3 review — иначе update_id не сдвигается, апдейт
|
||||
переигрывается на следующей итерации, copyMessage дублирует зеркало
|
||||
клиента в топик на каждый повтор).
|
||||
"""
|
||||
self._db.rollback()
|
||||
|
||||
def upsert_user(
|
||||
self,
|
||||
*,
|
||||
chat_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
language_code: str | None,
|
||||
) -> None:
|
||||
self._db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO tg_support_users
|
||||
(chat_id, username, first_name, last_name, language_code,
|
||||
created_at, last_seen_at, is_blocked)
|
||||
VALUES
|
||||
(CAST(:chat_id AS bigint), :username, :first_name, :last_name,
|
||||
:language_code, NOW(), NOW(), FALSE)
|
||||
ON CONFLICT (chat_id) DO UPDATE
|
||||
SET username = EXCLUDED.username,
|
||||
first_name = EXCLUDED.first_name,
|
||||
last_name = EXCLUDED.last_name,
|
||||
language_code = EXCLUDED.language_code,
|
||||
last_seen_at = NOW(),
|
||||
is_blocked = FALSE
|
||||
"""
|
||||
),
|
||||
{
|
||||
"chat_id": chat_id,
|
||||
"username": username,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"language_code": language_code,
|
||||
},
|
||||
)
|
||||
|
||||
def had_recent_inbound(self, chat_id: int, window_seconds: int) -> bool:
|
||||
row = self._db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM tg_support_messages
|
||||
WHERE chat_id = CAST(:chat_id AS bigint)
|
||||
AND direction = 'in'
|
||||
AND created_at > NOW() - make_interval(secs => CAST(:window_seconds AS integer))
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"chat_id": chat_id, "window_seconds": window_seconds},
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
def record_message(
|
||||
self,
|
||||
*,
|
||||
chat_id: int,
|
||||
direction: str,
|
||||
tg_message_id: int | None,
|
||||
topic_message_id: int | None,
|
||||
kind: str,
|
||||
text_body: str | None,
|
||||
operator_tg_id: int | None,
|
||||
support_chat_id: int | None = None,
|
||||
) -> int | None:
|
||||
row = self._db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO tg_support_messages
|
||||
(chat_id, direction, tg_message_id, topic_message_id, kind,
|
||||
text_body, operator_tg_id, support_chat_id, created_at)
|
||||
VALUES
|
||||
(CAST(:chat_id AS bigint), CAST(:direction AS text),
|
||||
CAST(:tg_message_id AS bigint), CAST(:topic_message_id AS bigint),
|
||||
CAST(:kind AS text), :text_body, CAST(:operator_tg_id AS bigint),
|
||||
CAST(:support_chat_id AS bigint), NOW())
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"chat_id": chat_id,
|
||||
"direction": direction,
|
||||
"tg_message_id": tg_message_id,
|
||||
"topic_message_id": topic_message_id,
|
||||
"kind": kind,
|
||||
"text_body": text_body,
|
||||
"operator_tg_id": operator_tg_id,
|
||||
"support_chat_id": support_chat_id,
|
||||
},
|
||||
).fetchone()
|
||||
return int(row[0]) if row is not None else None
|
||||
|
||||
def find_chat_by_topic_message(self, topic_message_id: int, support_chat_id: int) -> int | None:
|
||||
"""Скоупим к ТЕКУЩЕМУ support_chat_id (#tgsupport-web review M1) — строка
|
||||
со ЧУЖИМ (не NULL, не текущим) support_chat_id — исторический артефакт
|
||||
ротации support-группы, не валидный маршрут сегодня. NULL (строки до
|
||||
миграции 188, если есть) — лениентный wildcard-матч (единственный
|
||||
действовавший чат на тот момент)."""
|
||||
row = self._db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT chat_id
|
||||
FROM tg_support_messages
|
||||
WHERE topic_message_id = CAST(:topic_message_id AS bigint)
|
||||
AND direction = 'in'
|
||||
AND (support_chat_id = CAST(:support_chat_id AS bigint)
|
||||
OR support_chat_id IS NULL)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"topic_message_id": topic_message_id, "support_chat_id": support_chat_id},
|
||||
).fetchone()
|
||||
return int(row[0]) if row is not None else None
|
||||
|
||||
def mark_blocked(self, chat_id: int) -> None:
|
||||
self._db.execute(
|
||||
text(
|
||||
"UPDATE tg_support_users SET is_blocked = TRUE "
|
||||
"WHERE chat_id = CAST(:chat_id AS bigint)"
|
||||
),
|
||||
{"chat_id": chat_id},
|
||||
)
|
||||
|
||||
def find_web_thread_by_topic_message(
|
||||
self, topic_message_id: int, support_chat_id: int
|
||||
) -> int | None:
|
||||
"""Делегирует в `web_support_storage` (#tgsupport-web) — то же соединение/
|
||||
транзакцию, что и tg-путь, коммитится вместе offset'ом в `process_update`."""
|
||||
return web_support_storage.find_thread_by_topic_message(
|
||||
self._db, topic_message_id, support_chat_id
|
||||
)
|
||||
|
||||
def record_web_out_message(
|
||||
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
|
||||
) -> None:
|
||||
web_support_storage.record_outbound(
|
||||
self._db,
|
||||
thread_id=thread_id,
|
||||
text_body=text_body,
|
||||
operator_tg_id=operator_tg_id,
|
||||
)
|
||||
|
||||
|
||||
# ── Pure helpers ──────────────────────────────────────────────────────────────
|
||||
def _infer_kind(message: dict[str, Any]) -> str:
|
||||
"""Content-type сообщения → kind-строка. Неизвестные типы (voice/sticker/location/
|
||||
etc.) сворачиваются в 'other' — см. документированный набор в COMMENT ON COLUMN."""
|
||||
for field in _KNOWN_KINDS:
|
||||
if field in message:
|
||||
return field
|
||||
return "other"
|
||||
|
||||
|
||||
def _format_topic_header(
|
||||
chat_id: int, username: str | None, first_name: str | None, last_name: str | None
|
||||
) -> str:
|
||||
"""Короткая шапка-идентификация клиента для support-топика."""
|
||||
display_name = " ".join(p for p in (first_name, last_name) if p) or "Без имени"
|
||||
username_part = f", @{username}" if username else ""
|
||||
return f"Новое обращение от {display_name}{username_part} (chat_id={chat_id})"
|
||||
|
||||
|
||||
# ── Update routing ────────────────────────────────────────────────────────────
|
||||
async def _handle_private_message(
|
||||
message: dict[str, Any], client: TelegramClient, storage: BridgeStorage
|
||||
) -> None:
|
||||
"""A) Личка клиента → бот. upsert user → (опц. шапка) → зеркало в топик."""
|
||||
chat = message.get("chat") or {}
|
||||
chat_id = chat.get("id")
|
||||
if not isinstance(chat_id, int):
|
||||
logger.warning("tgbot bridge: приватное сообщение без валидного chat.id — игнор")
|
||||
return
|
||||
|
||||
from_user = message.get("from") or {}
|
||||
username = from_user.get("username")
|
||||
first_name = from_user.get("first_name")
|
||||
last_name = from_user.get("last_name")
|
||||
language_code = from_user.get("language_code")
|
||||
|
||||
storage.upsert_user(
|
||||
chat_id=chat_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language_code=language_code,
|
||||
)
|
||||
|
||||
text_body = message.get("text")
|
||||
if text_body == "/start":
|
||||
# E) команда — не содержательное обращение, топик не засоряем.
|
||||
await client.send_message(chat_id=chat_id, text=GREETING_TEXT)
|
||||
return
|
||||
|
||||
if not settings.telegram_support_chat_id:
|
||||
logger.warning(
|
||||
"tgbot bridge: TELEGRAM_SUPPORT_CHAT_ID не задан — сообщение от chat_id=%d "
|
||||
"не может быть зеркалировано; отвечаем клиенту вместо тихой потери (#5 review)",
|
||||
chat_id,
|
||||
)
|
||||
# Не молчим клиенту (#5 review) — иначе он ждёт ответа, которого никогда не будет.
|
||||
await client.send_message(chat_id=chat_id, text=SERVICE_UNAVAILABLE_TEXT)
|
||||
return
|
||||
|
||||
message_id = message.get("message_id")
|
||||
if not isinstance(message_id, int):
|
||||
logger.warning("tgbot bridge: приватное сообщение без message_id — игнор")
|
||||
return
|
||||
|
||||
# F) Флуд-лимит на отправителя — peek БЕЗ расхода бюджета (тот же паттерн,
|
||||
# что `_send_limiter` в app/api/v1/support.py: под лимитом ниже сразу
|
||||
# `.record()`-им попытку). Над лимитом — НЕ зеркалируем (иначе сам мирроринг
|
||||
# уже съедает групповой Telegram-бюджет, который лимит и защищает) и НЕ
|
||||
# пишем в tg_support_messages (без topic_message_id маршрутизировать ответ
|
||||
# всё равно нечего).
|
||||
flood_key = str(chat_id) # SlidingWindowLimiter — ключ str (см. app/core/ratelimit.py)
|
||||
if _flood_limiter.retry_after(flood_key) is not None:
|
||||
logger.warning(
|
||||
"tgbot bridge: chat_id=%d превысил флуд-лимит (%d msg/%.0fs) — "
|
||||
"сообщение НЕ зеркалируется в топик (защита группового Telegram-лимита)",
|
||||
chat_id,
|
||||
_FLOOD_LIMIT,
|
||||
_FLOOD_WINDOW_S,
|
||||
)
|
||||
# Уведомляем клиента, что сообщение НЕ доставлено (молчать нельзя —
|
||||
# иначе клиент решит, что оператор его получил), но не чаще одного раза
|
||||
# за окно — `_flood_notify_limiter.check()` возвращает None (и сам
|
||||
# фиксирует попытку) ровно один раз за окно.
|
||||
if _flood_notify_limiter.check(flood_key) is None:
|
||||
await client.send_message(chat_id=chat_id, text=FLOOD_LIMITED_TEXT)
|
||||
return
|
||||
_flood_limiter.record(flood_key)
|
||||
|
||||
# Шапка — только на первое сообщение клиента за окно, иначе топик засоряется.
|
||||
if not storage.had_recent_inbound(chat_id, window_seconds=_HEADER_THROTTLE_WINDOW_S):
|
||||
header = _format_topic_header(chat_id, username, first_name, last_name)
|
||||
await client.send_message(
|
||||
chat_id=settings.telegram_support_chat_id,
|
||||
text=header,
|
||||
message_thread_id=settings.telegram_support_topic_id or None,
|
||||
)
|
||||
|
||||
mirrored = await client.copy_message(
|
||||
chat_id=settings.telegram_support_chat_id,
|
||||
from_chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
message_thread_id=settings.telegram_support_topic_id or None,
|
||||
)
|
||||
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
|
||||
|
||||
storage.record_message(
|
||||
chat_id=chat_id,
|
||||
direction="in",
|
||||
tg_message_id=message_id,
|
||||
topic_message_id=topic_message_id,
|
||||
kind=_infer_kind(message),
|
||||
text_body=text_body or message.get("caption"),
|
||||
operator_tg_id=None,
|
||||
support_chat_id=settings.telegram_support_chat_id,
|
||||
)
|
||||
|
||||
|
||||
async def _handle_group_reply(
|
||||
message: dict[str, Any], client: TelegramClient, storage: BridgeStorage
|
||||
) -> None:
|
||||
"""B) Реплай оператора в support-группе → доставка ответа клиенту (Telegram
|
||||
ЛИБО веб-чат, #tgsupport-web — см. модульный docstring)."""
|
||||
reply_to = message.get("reply_to_message")
|
||||
if not isinstance(reply_to, dict):
|
||||
return # не реплай вообще — обычная болтовня в топике, тихий игнор
|
||||
|
||||
mirror_message_id = reply_to.get("message_id")
|
||||
if not isinstance(mirror_message_id, int):
|
||||
return
|
||||
|
||||
# #tgsupport-web review M1: резолвим ОБЕ стороны с текущим support_chat_id
|
||||
# (НЕ short-circuit на первом найденном) — если topic_message_id совпал в
|
||||
# ОБЕИХ таблицах одновременно, это значит инвариант "уникален в пределах
|
||||
# текущей support-группы" нарушен (баг/ручная правка данных) — отказываем в
|
||||
# доставке ГРОМКО, вместо того чтобы молча выбрать tg-путь и отправить ответ
|
||||
# постороннему Telegram-клиенту (152-ФЗ misroute risk).
|
||||
current_chat_id = settings.telegram_support_chat_id
|
||||
target_chat_id = storage.find_chat_by_topic_message(mirror_message_id, current_chat_id)
|
||||
web_thread_id = storage.find_web_thread_by_topic_message(mirror_message_id, current_chat_id)
|
||||
|
||||
if target_chat_id is not None and web_thread_id is not None:
|
||||
logger.error(
|
||||
"tgbot bridge: topic_message_id=%d резолвится ОДНОВРЕМЕННО в Telegram "
|
||||
"(chat_id=%d) и веб-чат (thread_id=%d) под support_chat_id=%d — отказ в "
|
||||
"доставке, требуется ручной разбор tg_support_messages/web_support_messages",
|
||||
mirror_message_id,
|
||||
target_chat_id,
|
||||
web_thread_id,
|
||||
current_chat_id,
|
||||
)
|
||||
return
|
||||
|
||||
if target_chat_id is not None:
|
||||
# Существующий Telegram-путь — НЕ ТРОНУТ.
|
||||
message_id = message.get("message_id")
|
||||
if not isinstance(message_id, int):
|
||||
return
|
||||
|
||||
operator = message.get("from") or {}
|
||||
operator_id = operator.get("id")
|
||||
|
||||
try:
|
||||
delivered = await client.copy_message(
|
||||
chat_id=target_chat_id,
|
||||
from_chat_id=settings.telegram_support_chat_id,
|
||||
message_id=message_id,
|
||||
)
|
||||
except TelegramApiError as exc:
|
||||
if exc.error_code == 403:
|
||||
# Клиент заблокировал бота — фиксируем и уведомляем оператора в топике.
|
||||
storage.mark_blocked(target_chat_id)
|
||||
await client.send_message(
|
||||
chat_id=settings.telegram_support_chat_id,
|
||||
text=(
|
||||
f"Не удалось доставить сообщение клиенту (chat_id={target_chat_id}) — "
|
||||
"бот заблокирован."
|
||||
),
|
||||
message_thread_id=settings.telegram_support_topic_id or None,
|
||||
reply_to_message_id=message_id,
|
||||
)
|
||||
return
|
||||
raise
|
||||
|
||||
tg_message_id = delivered.get("message_id") if isinstance(delivered, dict) else None
|
||||
storage.record_message(
|
||||
chat_id=target_chat_id,
|
||||
direction="out",
|
||||
tg_message_id=tg_message_id,
|
||||
topic_message_id=None,
|
||||
kind=_infer_kind(message),
|
||||
text_body=message.get("text") or message.get("caption"),
|
||||
operator_tg_id=operator_id,
|
||||
)
|
||||
return
|
||||
|
||||
if web_thread_id is not None:
|
||||
message_id = message.get("message_id")
|
||||
kind = _infer_kind(message)
|
||||
if kind != "text":
|
||||
# #tgsupport-web review M2: НЕ доставляем частично (фото С ПОДПИСЬЮ
|
||||
# молча превратилось бы в "ответ = только текст подписи", клиент решил
|
||||
# бы что это весь ответ) — отказ целиком + явное уведомление оператору
|
||||
# в топике (тот же паттерн, что 403-уведомление выше), иначе оператор
|
||||
# уверен, что ответ доставлен, хотя веб-чат не поддерживает медиа.
|
||||
logger.warning(
|
||||
"tgbot bridge: реплай на веб-зеркало (thread_id=%d) содержит %s, "
|
||||
"не текст — веб-чат поддерживает только текст, доставка отклонена",
|
||||
web_thread_id,
|
||||
kind,
|
||||
)
|
||||
await client.send_message(
|
||||
chat_id=settings.telegram_support_chat_id,
|
||||
text=_WEB_UNSUPPORTED_MEDIA_REPLY_TEXT,
|
||||
message_thread_id=settings.telegram_support_topic_id or None,
|
||||
reply_to_message_id=message_id if isinstance(message_id, int) else None,
|
||||
)
|
||||
return
|
||||
|
||||
text_body = message.get("text")
|
||||
if not text_body:
|
||||
# Текстовый kind, но пустой text (защитный edge case) — нечего доставлять.
|
||||
return
|
||||
|
||||
operator = message.get("from") or {}
|
||||
operator_id = operator.get("id")
|
||||
storage.record_web_out_message(
|
||||
thread_id=web_thread_id,
|
||||
text_body=text_body,
|
||||
operator_tg_id=operator_id,
|
||||
)
|
||||
return
|
||||
|
||||
# Обычная болтовня в топике (реплай на чьё-то ещё сообщение) — не логируем,
|
||||
# это ожидаемый шум. НО реплай на сообщение, отправленное САМИМ БОТОМ
|
||||
# (is_bot=True) и при этом отсутствующее ни в tg_support_messages, ни в
|
||||
# web_support_messages — подозрительно: вероятная причина — осиротевшее
|
||||
# зеркало (воркер/API упал МЕЖДУ отправкой зеркала и commit'ом записи в БД).
|
||||
# Дискриминатор неидеальный (шапка-идентификация тоже от бота, но не
|
||||
# routing-ключ — тоже даст этот WARNING), но лучше редкий ложный WARNING, чем
|
||||
# оператор молча решает, что ответ доставлен, хотя реплай тихо утонул
|
||||
# (#4 review — двухфазный протокол НЕ делаем, overkill).
|
||||
reply_from = reply_to.get("from") or {}
|
||||
if reply_from.get("is_bot"):
|
||||
logger.warning(
|
||||
"tgbot bridge: реплай на сообщение бота (message_id=%d) не найден ни в "
|
||||
"tg_support_messages, ни в web_support_messages как зеркало — возможно, "
|
||||
"осиротевшее зеркало (крах между отправкой и commit'ом) или "
|
||||
"шапка-идентификация; ответ оператора НЕ доставлен",
|
||||
mirror_message_id,
|
||||
)
|
||||
|
||||
|
||||
async def process_update(
|
||||
update: dict[str, Any], client: TelegramClient, storage: BridgeStorage
|
||||
) -> None:
|
||||
"""Маршрутизирует один Telegram update. Дедуп (C) + атомарный offset-commit.
|
||||
|
||||
Дедуп: update_id <= сохранённого offset — skip без side-effects. Offset
|
||||
сохраняется и коммитится ПОСЛЕ обработки (в т.ч. если обработка упала —
|
||||
иначе «ядовитый» апдейт блокировал бы весь поток навсегда).
|
||||
|
||||
Различаем сбой БД (`SQLAlchemyError`) от прочих (Telegram API и т.п.):
|
||||
сбой БД оставляет сессию в failed-transaction state — `rollback()` ОБЯЗАН
|
||||
отработать ПЕРЕД `save_offset`, иначе тот сам кинет `PendingRollbackError`,
|
||||
`process_update` вылетит без сохранения offset'а, следующая итерация
|
||||
`run_poll_loop` получит СТАРЫЙ offset от `get_offset()` и переиграет тот же
|
||||
апдейт заново — copyMessage задублирует зеркало клиента в топике на
|
||||
каждый повтор поллинга (#3 review, воспроизведено).
|
||||
"""
|
||||
update_id = update.get("update_id")
|
||||
if not isinstance(update_id, int):
|
||||
logger.warning("tgbot bridge: update без валидного update_id — игнор")
|
||||
return
|
||||
|
||||
current_offset = storage.get_offset()
|
||||
if update_id <= current_offset:
|
||||
logger.debug(
|
||||
"tgbot bridge: update_id=%d уже обработан (offset=%d) — skip",
|
||||
update_id,
|
||||
current_offset,
|
||||
)
|
||||
return
|
||||
|
||||
message = update.get("message")
|
||||
try:
|
||||
if isinstance(message, dict):
|
||||
chat = message.get("chat") or {}
|
||||
chat_type = chat.get("type")
|
||||
chat_id = chat.get("id")
|
||||
if chat_type == "private":
|
||||
await _handle_private_message(message, client, storage)
|
||||
elif settings.telegram_support_chat_id and chat_id == settings.telegram_support_chat_id:
|
||||
await _handle_group_reply(message, client, storage)
|
||||
# иначе — необрабатываемый тип чата/апдейта (edited_message, канал и
|
||||
# т.п.) — тихий игнор, но offset всё равно сдвигаем ниже.
|
||||
except SQLAlchemyError:
|
||||
logger.exception(
|
||||
"tgbot bridge: DB-ошибка на update_id=%d — rollback перед сохранением "
|
||||
"offset (иначе save_offset сам упадёт на failed-transaction state)",
|
||||
update_id,
|
||||
)
|
||||
storage.rollback()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"tgbot bridge: обработка update_id=%d упала — offset всё равно сдвигаем "
|
||||
"(не блокируем поток на 'ядовитом' апдейте)",
|
||||
update_id,
|
||||
)
|
||||
finally:
|
||||
storage.save_offset(update_id)
|
||||
storage.commit()
|
||||
|
||||
|
||||
# ── Long-polling loop ─────────────────────────────────────────────────────────
|
||||
async def run_poll_loop(
|
||||
client: TelegramClient,
|
||||
session_factory: Callable[[], Session],
|
||||
poll_timeout_s: int = 30,
|
||||
) -> None:
|
||||
"""Бесконечный long-polling цикл до `shutdown_requested()`.
|
||||
|
||||
Свежая DB-сессия на каждую итерацию (одна итерация = один getUpdates-вызов +
|
||||
обработка полученной пачки апдейтов) — не держим соединение открытым на
|
||||
неопределённый срок между итерациями.
|
||||
"""
|
||||
logger.info("tgbot bridge: старт poll loop (timeout=%ds)", poll_timeout_s)
|
||||
consecutive_errors = 0
|
||||
while not shutdown_requested():
|
||||
try:
|
||||
with session_factory() as db:
|
||||
storage = SqlBridgeStorage(db)
|
||||
offset = storage.get_offset()
|
||||
updates = await client.get_updates(
|
||||
offset=offset + 1, timeout=poll_timeout_s, allowed_updates=["message"]
|
||||
)
|
||||
for update in updates:
|
||||
await process_update(update, client, storage)
|
||||
consecutive_errors = 0
|
||||
except Exception:
|
||||
consecutive_errors += 1
|
||||
backoff = min(5 * consecutive_errors, 60)
|
||||
logger.exception("tgbot bridge: итерация poll loop упала — retry через %ds", backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
logger.info("tgbot bridge: poll loop остановлен (shutdown)")
|
||||
265
tradein-mvp/backend/app/services/tgbot/client.py
Normal file
265
tradein-mvp/backend/app/services/tgbot/client.py
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
"""Тонкая httpx-обёртка над Telegram Bot API (#tgsupport).
|
||||
|
||||
Зачем свой клиент, а не aiogram: единственные нужные методы — `getUpdates`
|
||||
(long-polling), `copyMessage` (зеркалирование ЛЮБОГО типа контента без ре-аплоада)
|
||||
и `sendMessage` (заголовки/приветствия/уведомления). aiogram — избыточная
|
||||
зависимость (webhook-framework, dispatcher, FSM) ради трёх HTTP-вызовов; в стеке
|
||||
уже есть httpx (см. `app.services.dadata`, `app.services.geocoder` — тот же паттерн
|
||||
retry/timeout).
|
||||
|
||||
Docs: https://core.telegram.org/bots/api
|
||||
|
||||
Ретраи:
|
||||
- HTTP 429 (Too Many Requests) — уважаем `parameters.retry_after` из тела ответа
|
||||
(Telegram сам говорит сколько ждать), fallback на `_DEFAULT_RETRY_AFTER_S`.
|
||||
- HTTP 5xx / сетевые ошибки (timeout/connect) — экспоненциальный backoff,
|
||||
`capped` на `_MAX_BACKOFF_S`.
|
||||
- Любая другая 4xx (400/401/403/404) — НЕ ретраится, сразу `TelegramApiError`
|
||||
(запрос некорректен или прав нет — повтор не поможет).
|
||||
|
||||
БЕЗОПАСНОСТЬ: наши `logger.*`-вызовы здесь содержат только имя метода API,
|
||||
HTTP-статус и `description` из ответа Telegram — токен туда не пишем.
|
||||
Это НЕ гарантирует, что токен не утечёт по другим стокам: он живёт в
|
||||
`self._base`/`url` (локальные переменные stack-фрейма `_request`), а GlitchTip
|
||||
(sentry_sdk) по умолчанию прикладывает locals к traceback и Httpx-интеграция
|
||||
кладёт полный URL в span data. Эти стоки закрываются НЕ здесь, а в
|
||||
`app.tgbot_main` (`include_local_variables=False`, `before_send`-редактор,
|
||||
`traces_sample_rate=0.0`) и подавлением INFO-логов самого `httpx`-логгера
|
||||
(который печатает полный request URL, включая токен, на уровне INFO).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_TIMEOUT_S = 15.0
|
||||
_DEFAULT_RETRY_AFTER_S = 5.0
|
||||
_MAX_BACKOFF_S = 30.0
|
||||
_DEFAULT_MAX_RETRIES = 5
|
||||
|
||||
|
||||
class TelegramApiError(Exception):
|
||||
"""Telegram Bot API ответил `ok: false` (после исчерпания ретраев, если применимо)."""
|
||||
|
||||
def __init__(self, method: str, error_code: int, description: str) -> None:
|
||||
self.method = method
|
||||
self.error_code = error_code
|
||||
self.description = description
|
||||
super().__init__(f"Telegram API {method} failed: {error_code} {description}")
|
||||
|
||||
|
||||
def _extract_retry_after(
|
||||
response: httpx.Response, default: float = _DEFAULT_RETRY_AFTER_S
|
||||
) -> float:
|
||||
"""Достаёт `parameters.retry_after` из тела 429-ответа. Fallback — `default`."""
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
return default
|
||||
if not isinstance(data, dict):
|
||||
return default
|
||||
params = data.get("parameters")
|
||||
if isinstance(params, dict):
|
||||
retry_after = params.get("retry_after")
|
||||
if isinstance(retry_after, int | float):
|
||||
return float(retry_after)
|
||||
return default
|
||||
|
||||
|
||||
def _error_from_body(response: httpx.Response) -> tuple[int, str]:
|
||||
"""Парсит (error_code, description) из тела ответа Telegram; fallback на HTTP-статус."""
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
return response.status_code, (response.text or "")[:200]
|
||||
if not isinstance(data, dict):
|
||||
return response.status_code, str(data)[:200]
|
||||
error_code = data.get("error_code", response.status_code)
|
||||
description = data.get("description", "")
|
||||
code = int(error_code) if isinstance(error_code, int | float) else response.status_code
|
||||
return code, str(description)
|
||||
|
||||
|
||||
class TelegramClient:
|
||||
"""Bot API клиент на httpx.AsyncClient. Каждый вызов — отдельное короткоживущее соединение."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token: str,
|
||||
base_url: str = "https://api.telegram.org",
|
||||
timeout: float = _DEFAULT_TIMEOUT_S,
|
||||
) -> None:
|
||||
self._base = f"{base_url}/bot{token}"
|
||||
self._timeout = timeout
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
timeout: float | None = None,
|
||||
max_retries: int = _DEFAULT_MAX_RETRIES,
|
||||
) -> Any:
|
||||
"""POST `method` с JSON-телом `payload`. Ретраит 429/5xx/network, иначе raise сразу."""
|
||||
url = f"{self._base}/{method}"
|
||||
effective_timeout = timeout if timeout is not None else self._timeout
|
||||
attempt = 0
|
||||
|
||||
while True:
|
||||
attempt += 1
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=effective_timeout) as client:
|
||||
response = await client.post(url, json=payload)
|
||||
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
||||
if attempt > max_retries:
|
||||
logger.error(
|
||||
"tg client: %s — network error после %d попыток: %s", method, attempt, exc
|
||||
)
|
||||
raise
|
||||
backoff = min(2.0**attempt, _MAX_BACKOFF_S)
|
||||
logger.warning(
|
||||
"tg client: %s — network error (попытка %d/%d): %s — retry через %.0fs",
|
||||
method,
|
||||
attempt,
|
||||
max_retries,
|
||||
exc,
|
||||
backoff,
|
||||
)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
|
||||
if response.status_code == 429:
|
||||
retry_after = _extract_retry_after(response)
|
||||
if attempt > max_retries:
|
||||
error_code, description = _error_from_body(response)
|
||||
logger.error("tg client: %s — 429 после %d попыток, сдаёмся", method, attempt)
|
||||
raise TelegramApiError(method, error_code, description)
|
||||
logger.warning(
|
||||
"tg client: %s — HTTP 429 (попытка %d/%d), retry_after=%.0fs",
|
||||
method,
|
||||
attempt,
|
||||
max_retries,
|
||||
retry_after,
|
||||
)
|
||||
await asyncio.sleep(retry_after)
|
||||
continue
|
||||
|
||||
if response.status_code >= 500:
|
||||
if attempt > max_retries:
|
||||
error_code, description = _error_from_body(response)
|
||||
logger.error(
|
||||
"tg client: %s — HTTP %d после %d попыток, сдаёмся",
|
||||
method,
|
||||
response.status_code,
|
||||
attempt,
|
||||
)
|
||||
raise TelegramApiError(method, error_code, description)
|
||||
backoff = min(2.0**attempt, _MAX_BACKOFF_S)
|
||||
logger.warning(
|
||||
"tg client: %s — HTTP %d (попытка %d/%d) — retry через %.0fs",
|
||||
method,
|
||||
response.status_code,
|
||||
attempt,
|
||||
max_retries,
|
||||
backoff,
|
||||
)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
|
||||
if response.status_code >= 400:
|
||||
# 4xx кроме 429 — запрос некорректен/прав нет, повтор не поможет.
|
||||
error_code, description = _error_from_body(response)
|
||||
raise TelegramApiError(method, error_code, description)
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError as exc:
|
||||
raise TelegramApiError(
|
||||
method, response.status_code, f"invalid json: {exc}"
|
||||
) from exc
|
||||
|
||||
if not isinstance(data, dict) or not data.get("ok"):
|
||||
error_code, description = _error_from_body(response)
|
||||
raise TelegramApiError(method, error_code, description)
|
||||
|
||||
return data.get("result")
|
||||
|
||||
async def get_updates(
|
||||
self,
|
||||
offset: int,
|
||||
timeout: int = 30,
|
||||
allowed_updates: list[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Long-polling getUpdates. `timeout` — сколько Telegram держит запрос открытым (сек).
|
||||
|
||||
HTTP-таймаут запроса берётся с запасом (`timeout + 10s`), чтобы не обрывать
|
||||
соединение раньше, чем ответит сам Telegram long-poll.
|
||||
"""
|
||||
payload: dict[str, Any] = {"offset": offset, "timeout": timeout}
|
||||
if allowed_updates is not None:
|
||||
payload["allowed_updates"] = allowed_updates
|
||||
result = await self._request(
|
||||
"getUpdates", payload, timeout=float(timeout) + 10.0, max_retries=3
|
||||
)
|
||||
return result if isinstance(result, list) else []
|
||||
|
||||
async def copy_message(
|
||||
self,
|
||||
*,
|
||||
chat_id: int,
|
||||
from_chat_id: int,
|
||||
message_id: int,
|
||||
message_thread_id: int | None = None,
|
||||
reply_to_message_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""copyMessage — зеркалит ЛЮБОЙ тип контента без ре-аплоада файла."""
|
||||
payload: dict[str, Any] = {
|
||||
"chat_id": chat_id,
|
||||
"from_chat_id": from_chat_id,
|
||||
"message_id": message_id,
|
||||
}
|
||||
if message_thread_id:
|
||||
payload["message_thread_id"] = message_thread_id
|
||||
if reply_to_message_id:
|
||||
payload["reply_to_message_id"] = reply_to_message_id
|
||||
result = await self._request("copyMessage", payload)
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
*,
|
||||
chat_id: int,
|
||||
text: str,
|
||||
message_thread_id: int | None = None,
|
||||
reply_to_message_id: int | None = None,
|
||||
timeout: float | None = None,
|
||||
max_retries: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""sendMessage — текстовое сообщение (заголовки, приветствия, уведомления об ошибке).
|
||||
|
||||
`timeout`/`max_retries` — по умолчанию наследуют воркерную политику
|
||||
(`_DEFAULT_TIMEOUT_S`/`_DEFAULT_MAX_RETRIES`: на 429 спим Telegram-овский
|
||||
`retry_after` — для группы это штатные 30-60с, на 5xx backoff до 30с).
|
||||
Это ПРИЕМЛЕМО для `tgbot_main.py` (изолированный long-polling воркер), но
|
||||
ФАТАЛЬНО для интерактивного HTTP-запроса (#tgsupport-web review H1) —
|
||||
синхронный request/response путь не может легально висеть минуты. Вызывающая
|
||||
сторона на interactive-пути ОБЯЗАНА передать узкий бюджет явно (см.
|
||||
`app.api.v1.support.send_support_message`)."""
|
||||
payload: dict[str, Any] = {"chat_id": chat_id, "text": text}
|
||||
if message_thread_id:
|
||||
payload["message_thread_id"] = message_thread_id
|
||||
if reply_to_message_id:
|
||||
payload["reply_to_message_id"] = reply_to_message_id
|
||||
kwargs: dict[str, Any] = {}
|
||||
if timeout is not None:
|
||||
kwargs["timeout"] = timeout
|
||||
if max_retries is not None:
|
||||
kwargs["max_retries"] = max_retries
|
||||
result = await self._request("sendMessage", payload, **kwargs)
|
||||
return result if isinstance(result, dict) else {}
|
||||
216
tradein-mvp/backend/app/services/tgbot/web_support_storage.py
Normal file
216
tradein-mvp/backend/app/services/tgbot/web_support_storage.py
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
"""Persistence для веб-чата поддержки (#tgsupport-web) — web_support_threads /
|
||||
web_support_messages (см. data/sql/187_web_support_chat.sql, комментарий там же
|
||||
объясняет, почему отдельные таблицы, а не `channel`-колонка в tg_support_*).
|
||||
|
||||
Чистые SQL-функции поверх SQLAlchemy `Session` (psycopg v3) — никакой
|
||||
Telegram-логики здесь. Используется ДВУМЯ вызывающими сторонами:
|
||||
- `app.api.v1.support` (FastAPI-роутер) — создание/поиск треда, запись
|
||||
inbound-сообщения, чтение истории/unread/read.
|
||||
- `app.services.tgbot.bridge._handle_group_reply` — резолвит
|
||||
topic_message_id реплая оператора в web-тред и пишет outbound-ответ.
|
||||
|
||||
Тред резолвится ТОЛЬКО по username (X-Authenticated-User) — ни один метод
|
||||
здесь не принимает thread_id снаружи, поэтому IDOR (чтение чужого треда)
|
||||
структурно невозможен на уровне API (см. app/api/v1/support.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def find_thread_id(db: Session, username: str) -> int | None:
|
||||
"""Возвращает id треда для *username*, если он уже существует, иначе None.
|
||||
|
||||
Используется read-путями (GET .../messages, .../unread) — они НЕ должны
|
||||
создавать тред просто фактом обращения (иначе каждое открытие виджета
|
||||
поддержки создавало бы пустой тред для любого пользователя сайта).
|
||||
"""
|
||||
row = db.execute(
|
||||
text("SELECT id FROM web_support_threads WHERE username = CAST(:username AS text)"),
|
||||
{"username": username},
|
||||
).fetchone()
|
||||
return int(row[0]) if row is not None else None
|
||||
|
||||
|
||||
def get_or_create_thread(db: Session, username: str) -> int:
|
||||
"""Гарантирует существование треда для *username*, обновляя last_seen_at.
|
||||
|
||||
Вызывается ТОЛЬКО из send-пути (POST .../messages) — отправка сообщения
|
||||
это единственное действие, которое должно "создавать" тред.
|
||||
"""
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO web_support_threads (username, created_at, last_seen_at, last_read_at)
|
||||
VALUES (CAST(:username AS text), NOW(), NOW(), NOW())
|
||||
ON CONFLICT (username) DO UPDATE
|
||||
SET last_seen_at = NOW()
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{"username": username},
|
||||
).fetchone()
|
||||
assert row is not None # INSERT ... RETURNING всегда отдаёт строку
|
||||
return int(row[0])
|
||||
|
||||
|
||||
def record_inbound(
|
||||
db: Session,
|
||||
*,
|
||||
thread_id: int,
|
||||
text_body: str,
|
||||
topic_message_id: int | None,
|
||||
support_chat_id: int | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Записывает сообщение пользователя сайта (direction='in'). `topic_message_id` —
|
||||
id зеркала (sendMessage) в support-топике, ключ маршрутизации ответа оператора.
|
||||
`support_chat_id` — TELEGRAM_SUPPORT_CHAT_ID В МОМЕНТ отправки (#tgsupport-web
|
||||
review M1): скоупит будущий резолв `find_thread_by_topic_message` к ТЕКУЩЕЙ
|
||||
support-группе — если группу когда-нибудь сменят/пересоздадут, Telegram
|
||||
message_id стартует заново с 1 в новом чате и может совпасть с числом из
|
||||
старого — без этого поля коллизия была бы ТИХОЙ (см. миграцию 187/188)."""
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO web_support_messages
|
||||
(thread_id, direction, text_body, topic_message_id,
|
||||
support_chat_id, operator_tg_id, created_at)
|
||||
VALUES
|
||||
(CAST(:thread_id AS bigint), 'in', :text_body,
|
||||
CAST(:topic_message_id AS bigint),
|
||||
CAST(:support_chat_id AS bigint), NULL, NOW())
|
||||
RETURNING id, direction, text_body, operator_tg_id, created_at
|
||||
"""
|
||||
),
|
||||
{
|
||||
"thread_id": thread_id,
|
||||
"text_body": text_body,
|
||||
"topic_message_id": topic_message_id,
|
||||
"support_chat_id": support_chat_id,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.one()
|
||||
)
|
||||
return dict(row)
|
||||
|
||||
|
||||
def find_thread_by_topic_message(
|
||||
db: Session, topic_message_id: int, support_chat_id: int
|
||||
) -> int | None:
|
||||
"""Резолвит id зеркала (сообщения оператора reply_to) в thread_id — только
|
||||
среди direction='in' записей, зеркало-конвенция как в tg_support_messages (186).
|
||||
|
||||
Скоупим к ТЕКУЩЕМУ `support_chat_id` (#tgsupport-web review M1): строка со
|
||||
ЧУЖИМ (не NULL и не текущим) support_chat_id — это исторический артефакт
|
||||
ротации support-группы, НЕ валидный маршрут для сегодняшнего реплая. NULL
|
||||
(легаси-строки до этой колонки, если такие есть) — лениентно матчатся как
|
||||
"любой чат", т.к. до введения этого поля был ровно один действующий чат."""
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT thread_id
|
||||
FROM web_support_messages
|
||||
WHERE topic_message_id = CAST(:topic_message_id AS bigint)
|
||||
AND direction = 'in'
|
||||
AND (support_chat_id = CAST(:support_chat_id AS bigint) OR support_chat_id IS NULL)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"topic_message_id": topic_message_id, "support_chat_id": support_chat_id},
|
||||
).fetchone()
|
||||
return int(row[0]) if row is not None else None
|
||||
|
||||
|
||||
def record_outbound(
|
||||
db: Session, *, thread_id: int, text_body: str, operator_tg_id: int | None
|
||||
) -> int | None:
|
||||
"""Записывает ответ оператора (реплай на веб-зеркало) как direction='out'.
|
||||
`topic_message_id` всегда NULL — маршрутизирующий ключ живёт только на
|
||||
inbound-записи (см. tg_support_messages-конвенцию, 186)."""
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO web_support_messages
|
||||
(thread_id, direction, text_body, topic_message_id, operator_tg_id, created_at)
|
||||
VALUES
|
||||
(CAST(:thread_id AS bigint), 'out', :text_body, NULL,
|
||||
CAST(:operator_tg_id AS bigint), NOW())
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"thread_id": thread_id,
|
||||
"text_body": text_body,
|
||||
"operator_tg_id": operator_tg_id,
|
||||
},
|
||||
).fetchone()
|
||||
return int(row[0]) if row is not None else None
|
||||
|
||||
|
||||
def list_messages(
|
||||
db: Session, *, thread_id: int, since_id: int, limit: int = 200
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Сообщения треда с id > since_id, по возрастанию (обычный polling с фронта).
|
||||
|
||||
`limit` (#tgsupport-web review M5): без него КАЖДОЕ монтирование виджета на
|
||||
старом треде отдавало бы ВЕСЬ лог переписки. Берём последние `limit` (ORDER
|
||||
BY id DESC + LIMIT), потом разворачиваем в хронологический порядок — так
|
||||
incremental-polling (`since_id` = последний известный id, обычно единицы
|
||||
новых строк) не страдает, а первый холодный load длинного треда получает
|
||||
последние `limit`, а не самые старые."""
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, direction, text_body, operator_tg_id, created_at
|
||||
FROM web_support_messages
|
||||
WHERE thread_id = CAST(:thread_id AS bigint)
|
||||
AND id > CAST(:since_id AS bigint)
|
||||
ORDER BY id DESC
|
||||
LIMIT CAST(:limit AS integer)
|
||||
"""
|
||||
),
|
||||
{"thread_id": thread_id, "since_id": since_id, "limit": limit},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
return [dict(r) for r in reversed(rows)]
|
||||
|
||||
|
||||
def count_unread(db: Session, *, thread_id: int) -> int:
|
||||
"""Кол-во ответов оператора (direction='out'), пришедших после last_read_at."""
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM web_support_messages m
|
||||
JOIN web_support_threads t ON t.id = m.thread_id
|
||||
WHERE m.thread_id = CAST(:thread_id AS bigint)
|
||||
AND m.direction = 'out'
|
||||
AND m.created_at > t.last_read_at
|
||||
"""
|
||||
),
|
||||
{"thread_id": thread_id},
|
||||
).scalar()
|
||||
return int(row or 0)
|
||||
|
||||
|
||||
def mark_read(db: Session, *, thread_id: int) -> None:
|
||||
db.execute(
|
||||
text(
|
||||
"UPDATE web_support_threads SET last_read_at = NOW() "
|
||||
"WHERE id = CAST(:thread_id AS bigint)"
|
||||
),
|
||||
{"thread_id": thread_id},
|
||||
)
|
||||
146
tradein-mvp/backend/app/services/user_events.py
Normal file
146
tradein-mvp/backend/app/services/user_events.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"""Сервис записи в `user_events` — Feature 2 (login/IP audit) + база для Feature 3
|
||||
(behavior analytics).
|
||||
|
||||
`user_events` (migration `184_user_events.sql`) — unified append-only event log,
|
||||
admin-read-only. Запись события НИКОГДА не должна ронять реальный HTTP-запрос:
|
||||
любая ошибка (БД недоступна, сетевой сбой, схема разъехалась) ловится и логируется
|
||||
через `logger.warning`, без re-raise — вызывающий код (middleware / handler)
|
||||
продолжает работать так, как если бы аудит был выключен.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def record_event(
|
||||
*,
|
||||
event_type: str,
|
||||
username: str,
|
||||
ip: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
path: str | None = None,
|
||||
method: str | None = None,
|
||||
estimate_id: str | None = None,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Синхронно вставляет одну строку в `user_events`.
|
||||
|
||||
Открывает СОБСТВЕННУЮ сессию (`SessionLocal()`), декаплённую от транзакции
|
||||
вызывающего запроса — событие коммитится независимо и переживает rollback
|
||||
основного хендлера (и наоборот: сбой записи события не трогает основную tx,
|
||||
т.к. она уже закоммичена/не связана с этой сессией).
|
||||
|
||||
Никогда не поднимает исключение — вызывающий код (middleware / endpoint)
|
||||
не должен падать из-за проблем с аудит-логом.
|
||||
"""
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO user_events
|
||||
(event_type, username, ip_address, user_agent, path, method,
|
||||
estimate_id, payload)
|
||||
VALUES
|
||||
(:event_type, :username, CAST(:ip AS inet), :user_agent, :path,
|
||||
:method, CAST(:estimate_id AS uuid), CAST(:payload AS jsonb))
|
||||
"""
|
||||
),
|
||||
{
|
||||
"event_type": event_type,
|
||||
"username": username,
|
||||
"ip": ip,
|
||||
"user_agent": user_agent,
|
||||
"path": path,
|
||||
"method": method,
|
||||
"estimate_id": estimate_id,
|
||||
"payload": json.dumps(payload or {}, ensure_ascii=False),
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"user_events: failed to record event_type=%r username=%r path=%r",
|
||||
event_type,
|
||||
username,
|
||||
path,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def schedule_event(**kwargs: Any) -> None:
|
||||
"""Fire-and-forget обёртка над `record_event` — никогда не блокирует запрос и
|
||||
никогда не поднимает исключение наружу.
|
||||
|
||||
- Есть running event loop (обычный async FastAPI handler / middleware) →
|
||||
INSERT уводится в отдельный поток через `asyncio.to_thread`, вызывающая
|
||||
корутина не ждёт результата. Ошибки ловятся done-callback'ом и просто
|
||||
логируются (иначе asyncio ругался бы "Task exception was never retrieved").
|
||||
- Нет running loop (sync-контекст: тесты, Celery task, скрипт) → вызывает
|
||||
`record_event(**kwargs)` синхронно inline (она сама никогда не raises).
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
record_event(**kwargs)
|
||||
return
|
||||
|
||||
try:
|
||||
task = loop.create_task(asyncio.to_thread(record_event, **kwargs))
|
||||
except Exception:
|
||||
logger.warning("user_events: failed to schedule background event", exc_info=True)
|
||||
return
|
||||
|
||||
def _log_task_exception(t: asyncio.Task[None]) -> None:
|
||||
if t.cancelled():
|
||||
return
|
||||
exc = t.exception()
|
||||
if exc is not None:
|
||||
logger.warning("user_events: background record_event failed: %s", exc)
|
||||
|
||||
task.add_done_callback(_log_task_exception)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-process best-effort dedup для login-события.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_LOGIN_DEDUP_LOCK = threading.Lock()
|
||||
_LOGIN_DEDUP_SEEN: set[str] = set()
|
||||
_LOGIN_DEDUP_MAX = 50_000
|
||||
|
||||
|
||||
def should_log_login(username: str, ip: str | None, user_agent: str | None) -> bool:
|
||||
"""True максимум один раз в сутки на комбинацию (username, ip, user_agent) в
|
||||
рамках жизни процесса — держит объём `event_type='login'` строк на уровне
|
||||
~одной записи на аккаунт+устройство в день, а не одной на каждый API-запрос.
|
||||
|
||||
Best-effort/не персистентно: рестарт процесса (деплой, worker respawn)
|
||||
сбрасывает in-memory set, так что после рестарта первая комбинация дня
|
||||
залогируется заново — недо-дедуп, не потеря данных (для отчётов по
|
||||
user_events всё равно используется `DISTINCT (username, ip_address,
|
||||
user_agent, date_trunc('day', created_at))`, так что дубликаты не искажают
|
||||
метрики, лишь чуть увеличивают объём записи).
|
||||
"""
|
||||
day = datetime.now(UTC).strftime("%Y-%m-%d")
|
||||
key = f"{username}|{ip or ''}|{user_agent or ''}|{day}"
|
||||
|
||||
with _LOGIN_DEDUP_LOCK:
|
||||
if key in _LOGIN_DEDUP_SEEN:
|
||||
return False
|
||||
if len(_LOGIN_DEDUP_SEEN) >= _LOGIN_DEDUP_MAX:
|
||||
_LOGIN_DEDUP_SEEN.clear()
|
||||
_LOGIN_DEDUP_SEEN.add(key)
|
||||
return True
|
||||
|
|
@ -40,6 +40,14 @@ from app.services import scrape_runs as runs_mod
|
|||
|
||||
# Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
|
||||
_PPM2_MIN: int = 30_000
|
||||
|
||||
# #C2 — asking-сторона (listings) покрыта скрейпом ТОЛЬКО по ЕКБ (per-city scrape B1/B2
|
||||
# ещё нет; в listings даже нет колонки city). Миграция 177 залила ДКП-сделки по всей
|
||||
# обл.66 (368 городов) → sold-медиана смешивала дешёвую область с ЕКБ-asking и обваливала
|
||||
# ratio (0.877→0.62, «выкупная» −29% системно). Скоупим SOLD-сторону (deal_side/deal_global)
|
||||
# на ЕКБ, чтобы sold и asking считались по ОДНОМУ рынку. Когда появятся oblast-листинги —
|
||||
# заменить на per-city ratio через зарезервированный столбец `district` (#647).
|
||||
_ASKING_CITY_PATTERN: str = "%Екатеринбург%"
|
||||
# Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
|
||||
# QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals
|
||||
# WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок.
|
||||
|
|
@ -80,6 +88,7 @@ _REDERIVE_SQL = text(
|
|||
FROM deals
|
||||
WHERE source = 'rosreestr'
|
||||
AND rooms IS NOT NULL
|
||||
AND city ILIKE :asking_city -- #C2 SOLD-сторона на ЕКБ (match asking-рынок)
|
||||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
||||
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
|
||||
GROUP BY LEAST(GREATEST(rooms, 0), 4)
|
||||
|
|
@ -128,6 +137,7 @@ _REDERIVE_SQL = text(
|
|||
FROM deals
|
||||
WHERE source = 'rosreestr'
|
||||
AND rooms IS NOT NULL
|
||||
AND city ILIKE :asking_city -- #C2 SOLD-сторона на ЕКБ (match asking-рынок)
|
||||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
||||
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
|
||||
),
|
||||
|
|
@ -213,7 +223,11 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
|
|||
db.execute(_DELETE_SQL)
|
||||
db.execute(
|
||||
_REDERIVE_SQL,
|
||||
{"ppm2_min": _PPM2_MIN, "ppm2_max": settings.asking_ratio_ppm2_max},
|
||||
{
|
||||
"ppm2_min": _PPM2_MIN,
|
||||
"ppm2_max": settings.asking_ratio_ppm2_max,
|
||||
"asking_city": _ASKING_CITY_PATTERN,
|
||||
},
|
||||
)
|
||||
|
||||
row = db.execute(_COUNTERS_SQL).mappings().first()
|
||||
|
|
|
|||
|
|
@ -158,6 +158,18 @@ async def backfill_cian_history(
|
|||
"cian_detail save failed for listing_id=%s: %s", listing_id, exc
|
||||
)
|
||||
result.listings_failed_save += 1
|
||||
# Roll back to clean session state so next listing can proceed.
|
||||
# save_detail_enrichment commits on success; on failure the
|
||||
# transaction is left open/dirty — rollback to avoid session poison
|
||||
# (same class of defect as the houses block below).
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception as rb_exc:
|
||||
logger.warning(
|
||||
"cian_detail rollback failed for listing_id=%s: %s",
|
||||
listing_id,
|
||||
rb_exc,
|
||||
)
|
||||
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
|
|
|
|||
124
tradein-mvp/backend/app/tasks/domrf_kapremont_load.py
Normal file
124
tradein-mvp/backend/app/tasks/domrf_kapremont_load.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""CLI: ДОМ.РФ капремонт open data → houses.year_built/material_walls (issue #2013).
|
||||
|
||||
Тянет КР1.1 (house registry) + КР1.2 (constructive elements) с открытых данных
|
||||
ДОМ.РФ (region 66), UPSERT'ит в staging `domrf_kapremont` (мигр. 176), затем:
|
||||
1. backfill_houses_from_domrf — COALESCE(houses.year_built, domrf.commission_year,
|
||||
houses.zhkh_year) + material_walls + total_floors (fold-in уже загруженного
|
||||
ГИС-ЖКХ года — houses без domrf-матча тоже выигрывают).
|
||||
2. propagate_listings_year_from_houses — UPDATE listings.year_built = houses.year_built
|
||||
там, где listing ещё NULL. Именно это открывает estimator'у (year_built BETWEEN
|
||||
когорта-фильтр) реальные годы на источниках вроде avito (0% year_built, 52.5%
|
||||
linked к houses).
|
||||
|
||||
Запуск из контейнера tradein-backend (у прода есть интернет к домрф.рф):
|
||||
|
||||
python -m app.tasks.domrf_kapremont_load # полный прогон
|
||||
python -m app.tasks.domrf_kapremont_load --dry-run # без записи, только подсчёт
|
||||
python -m app.tasks.domrf_kapremont_load --load-only # только staging, без backfill
|
||||
python -m app.tasks.domrf_kapremont_load --backfill-only # staging уже загружена — только
|
||||
# backfill+propagate
|
||||
python -m app.tasks.domrf_kapremont_load \\
|
||||
--kr11-path /tmp/kr11.csv --kr12-path /tmp/kr12.csv # локальные CSV, без скачивания
|
||||
|
||||
В режиме --dry-run скачивание/парс (если применимо) происходят, но НИ ОДНОЙ записи
|
||||
в БД не делается.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.services.domrf_kapremont_loader import (
|
||||
backfill_houses_from_domrf,
|
||||
load_domrf_kapremont,
|
||||
propagate_listings_year_from_houses,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""Парсер CLI (вынесен для тестируемости флагов без запуска main)."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"ДОМ.РФ капремонт loader: КР1.1+КР1.2 → domrf_kapremont staging → "
|
||||
"houses.year_built/material_walls/total_floors → listings.year_built"
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"--kr11-path", default=None, help="локальный CSV КР1.1 (пропустить скачивание)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--kr12-path", default=None, help="локальный CSV КР1.2 (пропустить скачивание)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--work-dir",
|
||||
default=None,
|
||||
help="каталог для скачанных/распакованных CSV (по умолчанию — временный, удаляется после)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true", help="без записи в БД — только скачивание/парс/подсчёт"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--load-only",
|
||||
action="store_true",
|
||||
help="только скачать+распарсить+UPSERT staging, без backfill houses/propagate listings",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backfill-only",
|
||||
action="store_true",
|
||||
help="пропустить скачивание/load staging (считаем что уже загружена этим же "
|
||||
"или предыдущим прогоном) — только backfill houses + propagate listings",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.load_only and args.backfill_only:
|
||||
parser.error("--load-only и --backfill-only взаимоисключающи")
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
load_counts: dict[str, int] = {}
|
||||
if not args.backfill_only:
|
||||
load_counts = load_domrf_kapremont(
|
||||
db,
|
||||
kr11_path=args.kr11_path,
|
||||
kr12_path=args.kr12_path,
|
||||
work_dir=args.work_dir,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
if not args.dry_run:
|
||||
db.commit()
|
||||
logger.info("domrf_kapremont_load: load stage done: %s", load_counts)
|
||||
|
||||
houses_counts: dict[str, int] = {}
|
||||
listings_counts: dict[str, int] = {}
|
||||
if not args.load_only:
|
||||
houses_counts = backfill_houses_from_domrf(db, dry_run=args.dry_run)
|
||||
listings_counts = propagate_listings_year_from_houses(db, dry_run=args.dry_run)
|
||||
if not args.dry_run:
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
"domrf_kapremont_load DONE: dry_run=%s load=%s houses=%s listings=%s",
|
||||
args.dry_run,
|
||||
load_counts,
|
||||
houses_counts,
|
||||
listings_counts,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
"""OSM POI local-mirror refresh (#2045 BE-3, LocationDrawer location-coef).
|
||||
"""OSM POI local-mirror refresh (#2045 BE-3, LocationDrawer location-index).
|
||||
|
||||
Populates `osm_poi_ekb_local` (empty at deploy, migration 169) via a single bulk scan of the
|
||||
`gendesign_osm_poi_ekb` FDW foreign table (migration 168 — live view of gendesign's
|
||||
|
|
@ -8,9 +8,11 @@ of #2045, already merged + deployed on gendesign).
|
|||
WHY a local mirror (perf fact measured for the analogous gendesign_cad_buildings FDW — see
|
||||
`app/tasks/cadastral_geo_match.py`): a per-request FDW nearest-POI query pays a per-row FDW
|
||||
round-trip (~1.16s/row without a geom index on the remote table) — UNUSABLE for a synchronous
|
||||
endpoint (`GET /api/v1/trade-in/location-coef`). We materialize the FDW once (single bulk
|
||||
endpoint (`GET /api/v1/trade-in/location-index`). We materialize the FDW once (single bulk
|
||||
scan) into `osm_poi_ekb_local` with a real Point geom + GIST index, then
|
||||
`app/services/location_coef.py` runs fast LOCAL ST_DWithin/ST_Distance queries per estimate.
|
||||
`app/services/location_index.py` runs fast LOCAL ST_DWithin/ST_Distance queries per estimate
|
||||
(nearby-POI qualitative list only — the numeric index itself comes from `listings`, not POI;
|
||||
see that module's docstring for the location-coef → location-index rewrite history).
|
||||
|
||||
Scheduler source='osm_poi_ekb_refresh' (daily — OSM POI data changes rarely). Pure internal
|
||||
DB op — one FDW read + local TRUNCATE+INSERT, no HTTP/anti-bot.
|
||||
|
|
|
|||
196
tradein-mvp/backend/app/tasks/sber_freshness_monitor.py
Normal file
196
tradein-mvp/backend/app/tasks/sber_freshness_monitor.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
"""Мониторинг свежести ДАННЫХ СберИндекса (не статуса джобы) — audit п.1.
|
||||
|
||||
Проблема аудита: estimator._load_sber_index_series (#794/#audit-5a) применяет
|
||||
СберИндекс time-adjustment к ДКП-сделкам и лишь ЛОГИРУЕТ per-estimate warning,
|
||||
когда latest месяц серии старее settings.sber_index_max_age_days (35д). Джоба
|
||||
`sber_index_pull` крутится ежемесячно (enabled), а источник СберИндекса публикует
|
||||
данные с лагом ~1-2 месяца, поэтому `sber_price_index.period_month` дрейфит
|
||||
(на 2026-07-12 latest=2026-05-01, ~72д). Это НЕ silent failure, но staleness
|
||||
видна только в debug-подобном per-estimate warning'е, тонущем в логах оценок.
|
||||
|
||||
Этот монитор смотрит на `max(period_month)` вторичного сегмента по региону и
|
||||
поднимает per-day WARNING-алерт, когда данные устарели СВЕРХ допустимого лага
|
||||
публикации — так ops видит дрейф на MONITOR-частоте, а не по крупицам в логах.
|
||||
|
||||
Порог алерта (документирование выбора):
|
||||
Per-estimate guard (estimator): age > settings.sber_index_max_age_days (35д).
|
||||
Монитор: age > sber_index_max_age_days + lag_allowance.
|
||||
lag_allowance (DEFAULT_LAG_ALLOWANCE_DAYS=25) — запас на ИНХЕРЕНТНЫЙ лаг
|
||||
публикации СберИндекса: источник отстаёт на 1-2 месяца, period_month — лейбл
|
||||
ПЕРВОГО числа месяца, а месячный pull ещё не подтянул новейший период. Итог:
|
||||
35 + 25 = 60д. Ниже 60д latest считается «нормально отстающим» → алерта нет
|
||||
(иначе daily-шум на штатном лаге). Выше 60д данные застряли сверх ~2 месяцев
|
||||
→ алерт. Проверено на проде 2026-07-12: max=2026-05-01, age=72д > 60 → alert=1.
|
||||
|
||||
Задача синхронная (DB-only, один SELECT max(period_month)) — запускается
|
||||
kit-scheduler'ом через product_handlers._job_sber_freshness_monitor в
|
||||
run_in_executor, по образцу deals_freshness_monitor. Вердикт вычисляет ЧИСТАЯ
|
||||
функция evaluate_sber_freshness() (frozen-now тестируется без БД).
|
||||
|
||||
Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) — WARNING
|
||||
достаточен. mark_failed только если sber_price_index недоступна/пуста (нечего
|
||||
оценивать).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, date, datetime
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services import scrape_runs as runs_mod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_LAG_ALLOWANCE_DAYS",
|
||||
"SberFreshnessVerdict",
|
||||
"check_sber_freshness",
|
||||
"evaluate_sber_freshness",
|
||||
]
|
||||
|
||||
# Запас на инхерентный лаг публикации СберИндекса (дней) СВЕРХ per-estimate
|
||||
# guard'а settings.sber_index_max_age_days. Читается из default_params.lag_allowance_days.
|
||||
DEFAULT_LAG_ALLOWANCE_DAYS = 25
|
||||
|
||||
# Регион продукта (Trade-in — Свердловская область). Совпадает с city-значениями
|
||||
# sber_price_index для областного уровня.
|
||||
SBER_MONITOR_CITY = "Свердловская область"
|
||||
|
||||
_LATEST_SBER_PERIOD_SQL = text("""
|
||||
SELECT max(period_month) AS latest
|
||||
FROM sber_price_index
|
||||
WHERE city = CAST(:city AS text)
|
||||
-- #R2-H1: только вторичный рынок (эстиматор — вторичка); первичка
|
||||
-- (новостройки) = направленно неверная коррекция. Зеркалит фильтр
|
||||
-- estimator._load_sber_index_series.
|
||||
AND (segment IS NULL OR segment ILIKE '%вторичн%')
|
||||
""")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SberFreshnessVerdict:
|
||||
"""Вердикт свежести СберИндекса по max(period_month)."""
|
||||
|
||||
latest_period: date
|
||||
age_days: int
|
||||
stale: bool
|
||||
|
||||
|
||||
def evaluate_sber_freshness(
|
||||
latest_period: date,
|
||||
now: datetime,
|
||||
max_age_days: int,
|
||||
) -> SberFreshnessVerdict:
|
||||
"""Чистая логика: устарел ли latest период СберИндекса.
|
||||
|
||||
stale = age_days > max_age_days, где age_days = now.date() - latest_period.
|
||||
`max_age_days` — ПОЛНЫЙ порог монитора (per-estimate guard + lag_allowance),
|
||||
вычисляется вызывающим check_sber_freshness. Тестируется с frozen `now` без БД.
|
||||
"""
|
||||
age_days = (now.date() - latest_period).days
|
||||
stale = age_days > max_age_days
|
||||
return SberFreshnessVerdict(
|
||||
latest_period=latest_period,
|
||||
age_days=age_days,
|
||||
stale=stale,
|
||||
)
|
||||
|
||||
|
||||
def check_sber_freshness(
|
||||
db: Session,
|
||||
run_id: int,
|
||||
params: dict | None = None, # type: ignore[type-arg]
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, int]:
|
||||
"""Проверить свежесть СберИндекса по max(period_month) и алертить при staleness.
|
||||
|
||||
Sync (вызывается scheduler-триггером в executor, как check_deals_freshness).
|
||||
Читает один SELECT max(period_month) вторичного сегмента по региону, считает
|
||||
вердикт чистой функцией, логирует WARNING при stale (per-day surfacing для ops)
|
||||
и финализирует run.
|
||||
|
||||
Params (default_params jsonb):
|
||||
lag_allowance_days: int — запас сверх sber_index_max_age_days (default 25).
|
||||
`now` инъектируется в тестах (frozen); в проде — None → datetime.now(UTC).
|
||||
|
||||
Returns counters {latest_year, latest_month, age_days, alert}.
|
||||
mark_failed только если sber_price_index пуста/недоступна (нечего оценивать);
|
||||
при алерте прогон помечается done (это монитор, не сбой джобы).
|
||||
"""
|
||||
params = params or {}
|
||||
now = now or datetime.now(UTC)
|
||||
counters: dict[str, int] = {
|
||||
"latest_year": 0,
|
||||
"latest_month": 0,
|
||||
"age_days": 0,
|
||||
"alert": 0,
|
||||
}
|
||||
try:
|
||||
runs_mod.update_heartbeat(db, run_id, counters)
|
||||
|
||||
row = db.execute(_LATEST_SBER_PERIOD_SQL, {"city": SBER_MONITOR_CITY}).first()
|
||||
latest: date | None = row.latest if row is not None else None
|
||||
if latest is None:
|
||||
logger.warning(
|
||||
"sber freshness: sber_price_index пуст/недоступен для region=%s "
|
||||
"(вторичка) — оценить свежесть нельзя",
|
||||
SBER_MONITOR_CITY,
|
||||
)
|
||||
runs_mod.mark_failed(db, run_id, "sber_price_index empty or unavailable", counters)
|
||||
return counters
|
||||
|
||||
lag_days = int(params.get("lag_allowance_days", DEFAULT_LAG_ALLOWANCE_DAYS))
|
||||
max_age_days = settings.sber_index_max_age_days + lag_days
|
||||
verdict = evaluate_sber_freshness(latest, now, max_age_days)
|
||||
|
||||
counters = {
|
||||
"latest_year": latest.year,
|
||||
"latest_month": latest.month,
|
||||
"age_days": verdict.age_days,
|
||||
"alert": int(verdict.stale),
|
||||
}
|
||||
|
||||
if verdict.stale:
|
||||
logger.warning(
|
||||
"sber freshness: max(period_month)=%s устарел на %d дней "
|
||||
"(> порога %d = sber_index_max_age_days %d + lag %d); "
|
||||
"СберИндекс time-adjustment ДКП-сделок мог отстать — "
|
||||
"проверь sber_index_pull и доступность новых периодов источника",
|
||||
latest,
|
||||
verdict.age_days,
|
||||
max_age_days,
|
||||
settings.sber_index_max_age_days,
|
||||
lag_days,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"sber freshness: max(period_month)=%s свежий (age=%d дней ≤ порога %d) "
|
||||
"region=%s — алерта нет",
|
||||
latest,
|
||||
verdict.age_days,
|
||||
max_age_days,
|
||||
SBER_MONITOR_CITY,
|
||||
)
|
||||
|
||||
runs_mod.mark_done(db, run_id, counters)
|
||||
logger.info(
|
||||
"check_sber_freshness run_id=%d done: latest=%s alert=%d age_days=%d",
|
||||
run_id,
|
||||
latest,
|
||||
counters["alert"],
|
||||
counters["age_days"],
|
||||
)
|
||||
return counters
|
||||
except Exception as exc:
|
||||
logger.exception("check_sber_freshness run_id=%d failed", run_id)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
|
||||
raise
|
||||
180
tradein-mvp/backend/app/tgbot_main.py
Normal file
180
tradein-mvp/backend/app/tgbot_main.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"""Standalone entrypoint для Telegram support-bridge воркера (#tgsupport).
|
||||
|
||||
Зачем отдельный процесс/контейнер: `getUpdates` long-polling держит открытый
|
||||
HTTP-запрос к Telegram до 30с за раз в бесконечном цикле — деплой основного API
|
||||
(docker restart tradein-backend) не должен обрывать эту петлю на середине, как и
|
||||
API не должен блокироваться долгим poll'ом. Тот же паттерн, что и
|
||||
`scheduler_main.py` (#1182) для scraper'ов — отдельный контейнер с тем же образом,
|
||||
другая команда.
|
||||
|
||||
Запуск: python -m app.tgbot_main
|
||||
|
||||
Kill-switch: TELEGRAM_BOT_TOKEN пуст (дефолт) → воркер логирует «disabled» и
|
||||
блокируется на `wait_for_shutdown()` (idle, ~0 CPU) — НЕ `sys.exit(0)`. Сервис в
|
||||
compose поднят с `restart: unless-stopped`, который рестартует контейнер
|
||||
независимо от кода выхода — чистый exit(0) без токена дал бы бесконечный
|
||||
рестарт-луп. Idle-блокировка держит процесс живым (автозапуск после ребута VPS
|
||||
работает штатно через restart-policy) без CPU-луп и без спама рестартов;
|
||||
SIGTERM просто убивает процесс — восстанавливать здесь нечего (bridge-задача
|
||||
не запущена).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import SessionLocal
|
||||
from app.core.shutdown import request_shutdown, shutdown_requested, wait_for_shutdown
|
||||
from app.services.tgbot.bridge import run_poll_loop
|
||||
from app.services.tgbot.client import TelegramClient
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
# httpx INFO-логи печатают ПОЛНЫЙ request URL, включая Telegram Bot API токен
|
||||
# в пути (https://api.telegram.org/bot<id>:<secret>/...) — `httpx: HTTP Request:
|
||||
# POST https://api.telegram.org/bot<TOKEN>/getMe "HTTP/1.1 401 Unauthorized"`.
|
||||
# При бесконечном long-polling'e это боевой токен в `docker logs` каждые ~30с.
|
||||
# WARNING+ у httpx не логирует URL запроса (#tgsupport review, воспроизведено).
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Тот же safety-net паттерн, что scheduler_main.py — ниже docker stop_grace_period.
|
||||
_DRAIN_TIMEOUT_S = 100.0
|
||||
|
||||
# Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396). Только integrations
|
||||
# без Starlette/FastAPI — здесь нет ASGI-приложения (тот же выбор что scheduler_main).
|
||||
if settings.glitchtip_dsn:
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.httpx import HttpxIntegration
|
||||
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||
|
||||
from app.observability.sentry_scrub import redact_telegram_bot_token, scrub_pii_event
|
||||
|
||||
def _before_send(event: Any, hint: dict[str, Any]) -> Any:
|
||||
"""Композиция PII-scrub (form-данные) + Telegram bot-токен redaction
|
||||
(#tgsupport review). Токен утекает ДВУМЯ независимыми векторами, которые
|
||||
`include_local_variables=False` ниже и этот хук закрывают вместе:
|
||||
1. `include_local_variables=True` (sentry_sdk default) кладёт stack-frame
|
||||
locals (`self._base`/`url` в `TelegramClient._request`) в traceback —
|
||||
закрыто через `include_local_variables=False` в `sentry_sdk.init`.
|
||||
2. `HttpxIntegration` кладёт полный request URL в span `data` (не только
|
||||
traceback) — `traces_sample_rate=0.0` спасает СЕЙЧАС, но молча
|
||||
перестанет спасать, если трейсинг когда-нибудь включат. Regex-редактор
|
||||
— belt-and-suspenders на случай #1 (если include_local_variables
|
||||
случайно вернут) И на span data.
|
||||
"""
|
||||
scrubbed = scrub_pii_event(event, hint)
|
||||
if scrubbed is None:
|
||||
return None
|
||||
return redact_telegram_bot_token(scrubbed, hint)
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=settings.glitchtip_dsn,
|
||||
environment=settings.environment,
|
||||
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
|
||||
traces_sample_rate=0.0,
|
||||
send_default_pii=False,
|
||||
include_local_variables=False,
|
||||
before_send=_before_send,
|
||||
integrations=[
|
||||
HttpxIntegration(),
|
||||
LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),
|
||||
],
|
||||
)
|
||||
logger.info("GlitchTip monitoring enabled (tgbot_main)")
|
||||
|
||||
|
||||
def _should_run() -> bool:
|
||||
"""Kill-switch: TELEGRAM_BOT_TOKEN не задан → бот выключен (dev/staging без секрета)."""
|
||||
return bool(settings.telegram_bot_token)
|
||||
|
||||
|
||||
async def _run_bridge() -> None:
|
||||
client = TelegramClient(settings.telegram_bot_token)
|
||||
await run_poll_loop(client, SessionLocal)
|
||||
|
||||
|
||||
async def _await_bridge(task: asyncio.Task[None]) -> None:
|
||||
"""Кооперативный SIGTERM-drain — идентичная семантика scheduler_main._await_scheduler.
|
||||
|
||||
`run_poll_loop` сам проверяет `shutdown_requested()` между итерациями (между
|
||||
getUpdates-вызовами) — long-polling запрос к Telegram (до 30с) докручивается,
|
||||
затем цикл выходит сам. Safety-net здесь на случай зависшего HTTP-вызова.
|
||||
"""
|
||||
shutdown_waiter = asyncio.create_task(wait_for_shutdown())
|
||||
try:
|
||||
await asyncio.wait({task, shutdown_waiter}, return_when=asyncio.FIRST_COMPLETED)
|
||||
finally:
|
||||
shutdown_waiter.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await shutdown_waiter
|
||||
|
||||
if task.done():
|
||||
task.result()
|
||||
logger.info("tgbot_main: bridge task exited cleanly")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"tgbot_main: SIGTERM-drain — waiting up to %.0fs for current poll iteration to finish",
|
||||
_DRAIN_TIMEOUT_S,
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(task, timeout=_DRAIN_TIMEOUT_S)
|
||||
logger.info("tgbot_main: bridge drained and exited cleanly")
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"tgbot_main: drain exceeded %.0fs grace — hard-cancelling bridge task",
|
||||
_DRAIN_TIMEOUT_S,
|
||||
)
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
async def _run() -> None:
|
||||
task = asyncio.create_task(_run_bridge())
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _on_signal(signum: int) -> None:
|
||||
logger.info("tgbot_main: signal %d received — requesting cooperative drain", signum)
|
||||
request_shutdown()
|
||||
|
||||
try:
|
||||
loop.add_signal_handler(signal.SIGTERM, lambda: _on_signal(signal.SIGTERM))
|
||||
loop.add_signal_handler(signal.SIGINT, lambda: _on_signal(signal.SIGINT))
|
||||
except NotImplementedError:
|
||||
# Windows dev: signal handlers через loop не поддерживаются
|
||||
logger.warning("tgbot_main: loop.add_signal_handler not supported (Windows dev)")
|
||||
|
||||
await _await_bridge(task)
|
||||
|
||||
if shutdown_requested():
|
||||
logger.info("tgbot_main: bridge drained cleanly (SIGTERM)")
|
||||
else:
|
||||
logger.info("tgbot_main: bridge task exited")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if not _should_run():
|
||||
# NOT sys.exit(0): compose service has `restart: unless-stopped`, который
|
||||
# рестартует контейнер независимо от кода выхода — чистый exit(0) без
|
||||
# токена дал бы бесконечный рестарт-луп. Idle-блокировка вместо этого:
|
||||
# ~0 CPU, SIGTERM просто убивает процесс (нечего дренировать).
|
||||
logger.warning(
|
||||
"tgbot_main: TELEGRAM_BOT_TOKEN не задан — бот выключен, "
|
||||
"блокируемся на idle (не exit, чтобы не было рестарт-лупа с restart:unless-stopped)"
|
||||
)
|
||||
asyncio.run(wait_for_shutdown())
|
||||
else:
|
||||
asyncio.run(_run())
|
||||
|
|
@ -22,6 +22,19 @@
|
|||
--
|
||||
-- Dependencies: 002_core_tables.sql (house_id space). No backend schema coupling.
|
||||
-- Re-run safe: CREATE TABLE IF NOT EXISTS + idempotent upsert.
|
||||
--
|
||||
-- LOW audit #4 — EKB scope (doc-only, no behavior change): this seed and its
|
||||
-- `premium_houses` MV fallback (data/sql/139) are both curated/calibrated against
|
||||
-- Ekaterinburg secondary-market listings ONLY. Non-EKB descriptions encountered
|
||||
-- during curation were treated as noise and actively DEMOTED (is_premium=false) —
|
||||
-- e.g. house_id=372944 below ("г. Нижний Новгород, Александровская слобода... это
|
||||
-- НЕ ЕКБ" — mislabeled, not premium). Consequence: an oblast (non-EKB) elite
|
||||
-- building will NOT receive the `premium_building` flag from this table or its MV
|
||||
-- fallback — the estimator's `_is_premium_building` (app/services/estimator.py)
|
||||
-- has no per-city variant of this overlay. This is not a silent gap: the estimator's
|
||||
-- `_manual_review` still routes genuinely high-value objects to manual review via
|
||||
-- absolute-value thresholds (estimate_manual_review_high_value_rub,
|
||||
-- estimate_manual_review_elite_ppm2) independent of the premium_building flag.
|
||||
|
||||
BEGIN;
|
||||
|
||||
|
|
|
|||
67
tradein-mvp/backend/data/sql/176_domrf_kapremont.sql
Normal file
67
tradein-mvp/backend/data/sql/176_domrf_kapremont.sql
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
-- 176_domrf_kapremont.sql
|
||||
--
|
||||
-- CONTEXT: issue #2013 — houses.year_built заполнен только на 38%, houses.material_walls
|
||||
-- на 0%. Существующий ГИС-ЖКХ loader (app/tasks/zhkh_flats_load.py, мигр. 146/149)
|
||||
-- заполняет houses.zhkh_year (70% = 5002/7128) и houses.zhkh_floors (70%), НО НИКОГДА
|
||||
-- не копирует их в houses.year_built/total_floors — это основной пробел. material_walls
|
||||
-- вообще ни разу не заполнялся никаким источником (0%). Estimator
|
||||
-- (app/services/estimator.py) фильтрует когорту по `year_built BETWEEN ...` — реальные
|
||||
-- годы напрямую двигают точность оценки. Это REAL fix для #2013 (взамен запаркованного
|
||||
-- когорт-исключения PR #2474).
|
||||
--
|
||||
-- Источник: ДОМ.РФ капремонт open data (free, no auth), region 66 (Свердловская обл.):
|
||||
-- КР1.1 house registry (export/190) — zip → CSV. mkd_code, houseguid (ФИАС GUID дома),
|
||||
-- address, commission_year (год ввода, int), total_sq, number_floors_max.
|
||||
-- КР1.2 constructive elements (export/275) — zip → CSV, LONG FORMAT (строка на
|
||||
-- конструктивный элемент на mkd_code); wall_material заполнен только на строке
|
||||
-- элемента «фасад».
|
||||
-- listings→houses линковка через listings.house_id_fk (avito 52.5% linked, year_built
|
||||
-- 0% на avito → backfill поднимет до ~36-52% через houses.year_built propagation).
|
||||
--
|
||||
-- WHAT:
|
||||
-- Таблица domrf_kapremont — staging-приёмник КР1.1+КР1.2 (region 66), ключ mkd_code.
|
||||
-- Заполняется отдельным loader'ом (app/services/domrf_kapremont_loader.py,
|
||||
-- app/tasks/domrf_kapremont_load.py). Backfill houses.year_built/material_walls/
|
||||
-- total_floors и propagation listings.year_built — отдельный код-путь той же таски,
|
||||
-- НЕ в этой миграции (только схема здесь, никакого DML над houses/listings).
|
||||
--
|
||||
-- DEPENDENCIES (existing prod objects): houses (gar_house_guid, house_fias_id,
|
||||
-- zhkh_house_guid, zhkh_year, zhkh_floors, year_built, material_walls, total_floors —
|
||||
-- все уже существуют: мигр. 009/010/070/146/149/152), listings (house_id_fk, year_built —
|
||||
-- мигр. 002/130).
|
||||
--
|
||||
-- SAFETY / IDEMPOTENCY: только CREATE TABLE/INDEX IF NOT EXISTS + COMMENT. Повторный
|
||||
-- прогон — no-op. Деплой-раннер гонит файл через psql -v ON_ERROR_STOP=on БЕЗ
|
||||
-- --single-transaction → транзакцию открывает САМ файл (BEGIN/COMMIT ниже), как 143/149.
|
||||
--
|
||||
-- NB по нумерации: последний занятый = 175 (ls data/sql | grep '^17[0-9]'); следующий
|
||||
-- свободный sequential = 176.
|
||||
--
|
||||
-- Deploy order: после 175_scrape_schedules_seed_domclick_detail_backfill.sql.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS domrf_kapremont (
|
||||
mkd_code text PRIMARY KEY, -- ДОМ.РФ код МКД (КР1.1/КР1.2 join-ключ)
|
||||
houseguid text, -- ФИАС GUID дома (КР1.1 houseguid)
|
||||
address text,
|
||||
commission_year smallint, -- год ввода в эксплуатацию (КР1.1 commission_year)
|
||||
number_floors_max int, -- этажность (КР1.1 number_floors_max)
|
||||
total_sq numeric(10, 2), -- общая площадь дома, м² (КР1.1 total_sq)
|
||||
wall_material text, -- материал стен (КР1.2, строка «фасад»; wall_material)
|
||||
loaded_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS domrf_kapremont_houseguid_idx
|
||||
ON domrf_kapremont (houseguid)
|
||||
WHERE houseguid IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE domrf_kapremont IS
|
||||
'ДОМ.РФ капремонт open data (КР1.1 house registry export/190 + КР1.2 constructive '
|
||||
'elements export/275), region 66 (Свердловская обл.). Ключ mkd_code. houseguid — ФИАС '
|
||||
'GUID дома, матчится к houses через COALESCE(gar_house_guid, house_fias_id, '
|
||||
'zhkh_house_guid) (app/services/domrf_kapremont_loader.py). wall_material берётся из '
|
||||
'КР1.2 со строки конструктивного элемента «фасад» (единственная строка на mkd_code, '
|
||||
'где это поле непустое). Заполняется app/tasks/domrf_kapremont_load.py (issue #2013).';
|
||||
|
||||
COMMIT;
|
||||
51
tradein-mvp/backend/data/sql/177_deals_city_region.sql
Normal file
51
tradein-mvp/backend/data/sql/177_deals_city_region.sql
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
-- 177_deals_city_region.sql
|
||||
-- deals.city + region_code backfill — расширение Mera trade-in estimator с
|
||||
-- Екатеринбурга на ВСЮ Свердловскую область (region_code=66, все города).
|
||||
--
|
||||
-- WHY:
|
||||
-- Ночной import_rosreestr_dkp (scheduler.py, source='rosreestr_dkp_import')
|
||||
-- до сих пор фильтровал `city ILIKE '%катеринбург%'` и хардкодил
|
||||
-- 'Екатеринбург, ' в address, а deals.region_code никогда не заполнялся
|
||||
-- (NULL на всех 49791 текущих строках). Foreign table
|
||||
-- gendesign_rosreestr_deals уже отдаёт чистые city + region_code из
|
||||
-- source-таблицы (migration 072) — снятие фильтра открывает +47183
|
||||
-- не-ЕКБ сделок, уже сидящих в источнике.
|
||||
--
|
||||
-- WHAT:
|
||||
-- - deals.region_code уже существует (002_core_tables.sql), но deals.city
|
||||
-- отсутствовал (было только текстовое поле address с хардкод-префиксом
|
||||
-- 'Екатеринбург, '). Добавляем city text — импортёр (scheduler.py,
|
||||
-- тот же PR) теперь заполняет его из реального source.city.
|
||||
-- - Индекс (city, deal_date) — под будущие per-city аналитические запросы
|
||||
-- (аналог deals_source_idx для (source, deal_date)).
|
||||
-- - Backfill существующих 49791 строк: все текущие deals с source='rosreestr'
|
||||
-- импортированы ДО этой миграции старым EKB-only кодом, т.е. фактически
|
||||
-- все они — Екатеринбург (то же допущение, что зашито в address
|
||||
-- 'Екатеринбург, ' + street). Простановка region_code=66, city='Екатеринбург'
|
||||
-- задним числом делает старые строки self-describing наравне с новыми.
|
||||
--
|
||||
-- SCOPE: только schema (city column + index) + backfill старых rosreestr-строк.
|
||||
-- Новые импорты (после деплоя scheduler.py) заполняют оба поля сами.
|
||||
--
|
||||
-- IDEMPOTENCY / SAFETY:
|
||||
-- - ADD COLUMN IF NOT EXISTS / CREATE INDEX IF NOT EXISTS — безопасный re-run.
|
||||
-- - Backfill UPDATE ограничен `region_code IS NULL OR city IS NULL` —
|
||||
-- повторный прогон no-op (после первого раза условие уже false).
|
||||
--
|
||||
-- Dependencies: 002_core_tables.sql (deals, deals.region_code)
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE deals ADD COLUMN IF NOT EXISTS city text;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deals_city_deal_date_idx ON deals (city, deal_date);
|
||||
|
||||
-- Backfill: все существующие rosreestr-сделки импортированы старым EKB-only
|
||||
-- кодом (до этой миграции) => гарантированно Екатеринбург, region_code=66.
|
||||
UPDATE deals
|
||||
SET region_code = 66,
|
||||
city = 'Екатеринбург'
|
||||
WHERE source = 'rosreestr'
|
||||
AND (region_code IS NULL OR city IS NULL);
|
||||
|
||||
COMMIT;
|
||||
87
tradein-mvp/backend/data/sql/178_deal_city_price_bands.sql
Normal file
87
tradein-mvp/backend/data/sql/178_deal_city_price_bands.sql
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
-- 178_deal_city_price_bands.sql
|
||||
-- Per-city ppm² plausible-deal guard-bands — снятие Екатеринбург-центричного
|
||||
-- глобального DEAL_MIN_PPM2=50_000 (estimator.py) для остальной Свердловской обл.
|
||||
--
|
||||
-- WHY:
|
||||
-- Миграция 177 открыла +47183 не-ЕКБ сделок (deals.city заполнен для всей
|
||||
-- области). Но estimator.py всё ещё режет ДКП-сделки абсолютным guard-band
|
||||
-- [DEAL_MIN_PPM2=50_000, DEAL_MAX_PPM2=800_000] ₽/м² — калиброванным
|
||||
-- ИСКЛЮЧИТЕЛЬНО под ЕКБ-вторичку (см. комментарий у DEAL_MIN_PPM2 в
|
||||
-- estimator.py). Для дешёвых городов области этот пол — не anti-outlier
|
||||
-- guard, а cut-off легитимного рынка:
|
||||
-- Североуральск median ≈ 21.7k ₽/м², Новоуральск ≈ 36k, Асбест ≈ 41k —
|
||||
-- ВСЕ ниже 50k → 46.6% не-ЕКБ сделок молча дропаются как «доля/ошибка»,
|
||||
-- хотя это нормальная цена для города.
|
||||
--
|
||||
-- WHAT:
|
||||
-- Таблица deal_city_price_bands — per-city [ppm2_min, ppm2_max] band,
|
||||
-- вычисленный из РЕАЛЬНЫХ rosreestr-сделок города (p1/p99 персентиль,
|
||||
-- зажатый абсолютными hard-floor/ceiling 8000/800000 — доли и опечатки
|
||||
-- режутся даже в самых дешёвых городах). Екатеринбург НАМЕРЕННО исключён
|
||||
-- из популяции — estimator.py при отсутствии строки в этой таблице
|
||||
-- fallback'ит на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2 константы, т.е.
|
||||
-- ЕКБ-поведение byte-identical сегодняшнему (см. invariant в PR).
|
||||
--
|
||||
-- Города с < 30 сделок исключены (HAVING count(*) >= 30) — слишком тонкая
|
||||
-- выборка для устойчивого перцентиля, estimator fallback'ит на global band.
|
||||
--
|
||||
-- USAGE (estimator.py, тот же PR):
|
||||
-- - SQL enforcement (_fetch_dkp_corridor, ~line 1263): LEFT JOIN на
|
||||
-- deals.city = deal_city_price_bands.city, COALESCE(b.ppm2_min/max, global).
|
||||
-- - Python enforcement (_is_plausible_deal via _fetch_deals): bands dict,
|
||||
-- загружен один раз через _load_city_price_bands(db), .get(city, global).
|
||||
--
|
||||
-- IDEMPOTENCY / SAFETY:
|
||||
-- CREATE TABLE IF NOT EXISTS — безопасный re-run. Populate — INSERT ...
|
||||
-- ON CONFLICT (city) DO UPDATE, т.е. повторный прогон РЕФРЕШИТ бэнды под
|
||||
-- свежие сделки (не no-op по дизайну — таблица предполагает периодический
|
||||
-- refresh по мере роста deals; scheduler для авто-рефреша НЕ входит в этот
|
||||
-- PR, см. follow-up в PR description).
|
||||
--
|
||||
-- Dependencies: 177_deals_city_region.sql (deals.city, backfill).
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS deal_city_price_bands (
|
||||
city text PRIMARY KEY,
|
||||
ppm2_min int NOT NULL,
|
||||
ppm2_max int NOT NULL,
|
||||
n_deals int NOT NULL,
|
||||
refreshed_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE deal_city_price_bands IS
|
||||
'Per-city ppm² plausible-deal guard-bands (estimator.py _is_plausible_deal / '
|
||||
'_fetch_dkp_corridor). Екатеринбург НЕ включён — estimator fallback на глобальные '
|
||||
'DEAL_MIN_PPM2=50000/DEAL_MAX_PPM2=800000 сохраняет ЕКБ-поведение byte-identical. '
|
||||
'Refresh: повторный прогон этого файла (ON CONFLICT DO UPDATE); авто-scheduler '
|
||||
'пока не реализован (см. issue follow-up).';
|
||||
|
||||
INSERT INTO deal_city_price_bands (city, ppm2_min, ppm2_max, n_deals, refreshed_at)
|
||||
SELECT
|
||||
city,
|
||||
GREATEST(round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, 8000)
|
||||
AS ppm2_min,
|
||||
LEAST(round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int, 800000)
|
||||
AS ppm2_max,
|
||||
count(*) AS n_deals,
|
||||
now() AS refreshed_at
|
||||
FROM deals
|
||||
WHERE source = 'rosreestr'
|
||||
AND price_per_m2 IS NOT NULL
|
||||
AND city IS NOT NULL
|
||||
AND city <> 'Екатеринбург'
|
||||
GROUP BY city
|
||||
HAVING count(*) >= 30
|
||||
-- Инвариант ppm2_max >= ppm2_min: если весь распределение города < 8000 ₽/м²
|
||||
-- (p99 < 8000), LEAST(p99,800000) < GREATEST(p1,8000) → band [8000, <8000] не
|
||||
-- матчит ничего → все сделки города молча дропались бы. Гейтим такие города
|
||||
-- (для жилой ДКП недостижимо, но airtight против мусорного города).
|
||||
AND round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int >= 8000
|
||||
ON CONFLICT (city) DO UPDATE
|
||||
SET ppm2_min = EXCLUDED.ppm2_min,
|
||||
ppm2_max = EXCLUDED.ppm2_max,
|
||||
n_deals = EXCLUDED.n_deals,
|
||||
refreshed_at = EXCLUDED.refreshed_at;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
-- 179_scrape_schedules_seed_oblast_city_sweeps.sql
|
||||
-- Seed rows для oblast-wide city-sweep (Свердловская область, region 66) — B1 rollout:
|
||||
-- avito/cian/yandex city-sweep за пределами Екатеринбурга для 5 городов области.
|
||||
-- Domclick (BFF, city_id-based) — отдельный B2 rollout, сюда НЕ входит.
|
||||
--
|
||||
-- !!! DORMANT BY DESIGN !!! Все 15 строк ship enabled = false.
|
||||
-- Причина: в проксях-пуле сейчас только ОДИН здоровый прокси (см. scrape_proxies,
|
||||
-- 157/173) — параллельный/резкий rollout на 5 новых городов рискует забанить его.
|
||||
-- Capability полностью wired (pipeline.CITY_ANCHORS/get_city_anchors,
|
||||
-- scheduler._job_{avito,cian,yandex}_city_sweep читают default_params->>'city' и
|
||||
-- резолвят anchors города, wildcard-registry "*_city_sweep_*" в
|
||||
-- scraper_kit.orchestration.scheduler._default_kit_handlers), но schedule'ы
|
||||
-- намеренно ВЫКЛЮЧЕНЫ. Оператор включает ВРУЧНУЮ по одному городу за раз:
|
||||
-- UPDATE scrape_schedules SET enabled = true WHERE source = 'avito_city_sweep_nizhniy_tagil';
|
||||
--
|
||||
-- WHY отдельный source на город (а не один source + params.city со списком городов):
|
||||
-- scrape_schedules.source — UNIQUE (052_scrape_schedules.sql). Несколько строк с
|
||||
-- одним source='avito_city_sweep' невозможны (второй INSERT/UPDATE тем же source
|
||||
-- либо конфликтует, либо перезаписывает первую строку — по факту только один город
|
||||
-- был бы шедулирован). Поэтому каждый (source_type, город) — своя строка с source =
|
||||
-- '<source_type>_<city_slug>' (например 'cian_city_sweep_kamensk_uralskiy'), а
|
||||
-- `resolve_handler` в scraper_kit.orchestration.scheduler резолвит её на тот же
|
||||
-- handler, что и EKB source, через wildcard-запись "avito_city_sweep_*" (и т.п.,
|
||||
-- тот же механизм, что уже используется для "deactivate_stale_*"). Job читает
|
||||
-- default_params->>'city' и подставляет anchors города вместо EKB_ANCHORS.
|
||||
--
|
||||
-- default_params: source's текущие прод-дефолты (см. SELECT ниже) + {"city": "<slug>"}.
|
||||
-- Никакие другие поля (radius_m/pages_per_anchor/...) НЕ меняются в этой миграции —
|
||||
-- один anchor на город (pipeline.CITY_ANCHORS) может не покрывать город целиком на
|
||||
-- текущем radius_m (1500м у avito/cian) — ОПЕРАТОР должен пересмотреть radius_m перед
|
||||
-- enable (город крупнее одного 1500м-круга — напр. Н.Тагил/Каменск-Уральский).
|
||||
--
|
||||
-- window_start_hour/window_end_hour (UTC, 1-часовые окна) — расставлены НЕ пересекаясь
|
||||
-- ни друг с другом, ни с уже enabled EKB-окнами (avito 6-7, cian 2-5, yandex 16-17) —
|
||||
-- на случай если оператор включит несколько строк сразу (proxy-pool = 1, конкурентные
|
||||
-- sweep'ы разных источников делили бы тот же прокси).
|
||||
--
|
||||
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)).
|
||||
-- Idempotent: ON CONFLICT (source) DO NOTHING — каждый source в этой миграции уникален
|
||||
-- по построению (SELECT ниже подтверждает текущие прод-дефолты трёх EKB source'ов,
|
||||
-- НЕ вставляется повторно и не конфликтует с ними — city_sweep_<slug> != city_sweep).
|
||||
|
||||
BEGIN;
|
||||
|
||||
INSERT INTO scrape_schedules (
|
||||
source,
|
||||
enabled,
|
||||
window_start_hour,
|
||||
window_end_hour,
|
||||
next_run_at,
|
||||
default_params
|
||||
)
|
||||
VALUES
|
||||
-- ── avito_city_sweep_<city> (база: radius_m 1500, detail_top_n 20, enrich_houses true,
|
||||
-- pages_per_anchor 3, request_delay_sec 7 — прод-дефолты avito_city_sweep) ──────────
|
||||
(
|
||||
'avito_city_sweep_nizhniy_tagil',
|
||||
false,
|
||||
0,
|
||||
1,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "nizhniy_tagil"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_kamensk_uralskiy',
|
||||
false,
|
||||
1,
|
||||
2,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "kamensk_uralskiy"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_pervouralsk',
|
||||
false,
|
||||
5,
|
||||
6,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "pervouralsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_verkhnyaya_pyshma',
|
||||
false,
|
||||
7,
|
||||
8,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "verkhnyaya_pyshma"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_serov',
|
||||
false,
|
||||
8,
|
||||
9,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "serov"}'::jsonb
|
||||
),
|
||||
-- ── cian_city_sweep_<city> (база: radius_m 1500, detail_top_n 10, enrich_houses true,
|
||||
-- pages_per_anchor 3, request_delay_sec 5 — прод-дефолты cian_city_sweep) ─────────
|
||||
(
|
||||
'cian_city_sweep_nizhniy_tagil',
|
||||
false,
|
||||
9,
|
||||
10,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "nizhniy_tagil"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_kamensk_uralskiy',
|
||||
false,
|
||||
10,
|
||||
11,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 10)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "kamensk_uralskiy"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_pervouralsk',
|
||||
false,
|
||||
11,
|
||||
12,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "pervouralsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_verkhnyaya_pyshma',
|
||||
false,
|
||||
12,
|
||||
13,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 12)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "verkhnyaya_pyshma"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_serov',
|
||||
false,
|
||||
13,
|
||||
14,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 13)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "serov"}'::jsonb
|
||||
),
|
||||
-- ── yandex_city_sweep_<city> (база: radius_m 25000, pages_per_anchor 3,
|
||||
-- request_delay_sec 9 — прод-дефолты yandex_city_sweep; radius_m большой ok —
|
||||
-- per 119_yandex_city_sweep_center_combos.sql это лишь fallback-фильтр, не
|
||||
-- query-ограничение, лишний охват не увеличивает load) ────────────────────────
|
||||
(
|
||||
'yandex_city_sweep_nizhniy_tagil',
|
||||
false,
|
||||
14,
|
||||
15,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "nizhniy_tagil"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_kamensk_uralskiy',
|
||||
false,
|
||||
15,
|
||||
16,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 15)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "kamensk_uralskiy"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_pervouralsk',
|
||||
false,
|
||||
17,
|
||||
18,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "pervouralsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_verkhnyaya_pyshma',
|
||||
false,
|
||||
18,
|
||||
19,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 18)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "verkhnyaya_pyshma"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_serov',
|
||||
false,
|
||||
19,
|
||||
20,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 19)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "serov"}'::jsonb
|
||||
)
|
||||
ON CONFLICT (source) DO NOTHING;
|
||||
|
||||
COMMENT ON TABLE scrape_schedules IS
|
||||
'In-app scheduler config (заменяет cron-script setup). '
|
||||
'Sources: avito_city_sweep, yandex_city_sweep, cian_city_sweep (EKB, enabled), '
|
||||
'avito_full_load, avito_full_load_exhaustive, cian_full_load, domclick_city_sweep, '
|
||||
'avito_city_sweep_<city>/cian_city_sweep_<city>/yandex_city_sweep_<city> '
|
||||
'(oblast B1 rollout — nizhniy_tagil/kamensk_uralskiy/pervouralsk/'
|
||||
'verkhnyaya_pyshma/serov, dormant, #179 — resolve_handler wildcard "*_city_sweep_*"), '
|
||||
'cian_history_backfill, rosreestr_dkp_import, listing_source_snapshot, '
|
||||
'asking_to_sold_ratio_refresh, refresh_search_matview, yandex_address_backfill, '
|
||||
'sber_index_pull, rosreestr_quarter_poll, deals_freshness_monitor, newbuilding_enrich, '
|
||||
'yandex_newbuilding_sweep, geoportal_coords_backfill, geocode_missing_listings, '
|
||||
'avito_detail_backfill, yandex_detail_backfill, cadastral_geo_match, '
|
||||
'osm_poi_ekb_refresh, house_imv_backfill, house_dedup_merge, proxy_healthcheck, '
|
||||
'deactivate_stale_avito/yandex/cian.';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
-- 180_seed_sber_freshness_monitor.sql
|
||||
-- Audit п.1 — seed scrape_schedules row for the daily СберИндекс data-freshness monitor.
|
||||
--
|
||||
-- Context (verified on prod 2026-07-12):
|
||||
-- estimator._load_sber_index_series (#794/#audit-5a) applies a СберИндекс time-adjustment
|
||||
-- to ДКП deals and only LOGS a per-estimate warning when the latest series month is older
|
||||
-- than settings.sber_index_max_age_days (35d). The `sber_index_pull` job runs monthly
|
||||
-- (enabled), but СберИндекс publishes with an inherent ~1-2 month lag, so
|
||||
-- sber_price_index.period_month drifts. Current max(period_month) for the oblast secondary
|
||||
-- segment = 2026-05-01 (~72d old). NOT a silent failure, but the staleness is buried in
|
||||
-- per-estimate warnings — invisible at monitor cadence.
|
||||
--
|
||||
-- Audit gap: ops has no monitor-frequency signal for СберИндекс staleness. This monitor
|
||||
-- watches max(period_month) directly and raises a per-day WARNING when the data is stale
|
||||
-- beyond the allowed publication lag.
|
||||
--
|
||||
-- Staleness threshold (app/tasks/sber_freshness_monitor.py::evaluate_sber_freshness):
|
||||
-- age_days = now() - max(period_month)
|
||||
-- max_age_days = settings.sber_index_max_age_days (35) + lag_allowance_days (25) = 60
|
||||
-- stale = age_days > max_age_days
|
||||
-- The +25 lag_allowance covers the INHERENT СберИндекс publication lag (source is 1-2mo
|
||||
-- behind; period_month is the first-of-month label; the monthly pull has not yet caught the
|
||||
-- newest period). Below 60d the latest period is treated as normally-lagging -> NO alert
|
||||
-- (avoids daily noise on штатный lag). Above 60d the data is stuck beyond ~2 months -> alert.
|
||||
-- At 2026-07-12: max=2026-05-01, age=72d > 60 -> alert=1 (surfaces the current drift).
|
||||
--
|
||||
-- Components deployed together:
|
||||
-- 1. app/tasks/sber_freshness_monitor.py — check_sber_freshness() + evaluate_sber_freshness()
|
||||
-- 2. app/services/product_handlers.py — kit Handler 'sber_freshness_monitor'
|
||||
-- 3. This migration — seeds the scrape_schedules row (enabled=true, daily window)
|
||||
--
|
||||
-- Schedule window 09:00-10:00 UTC:
|
||||
-- Daily cadence, placed AFTER deals_freshness_monitor (08:00-09:00 UTC) so the two
|
||||
-- data-freshness monitors do not overlap. Cheap: one SELECT max(period_month), no external
|
||||
-- HTTP — safe to run daily even though sber_index_pull itself is monthly.
|
||||
--
|
||||
-- default_params.lag_allowance_days = 25:
|
||||
-- Allowance for СберИндекс publication lag on top of the 35d per-estimate guard (-> 60d).
|
||||
--
|
||||
-- next_run_at bootstrapped to tomorrow 09:00 UTC — scheduler will not fire immediately
|
||||
-- on deploy (same pattern as 162/093/096/160).
|
||||
--
|
||||
-- Idempotent: ON CONFLICT (source) DO NOTHING — safe to re-apply.
|
||||
--
|
||||
-- Dependencies:
|
||||
-- 052_scrape_schedules.sql (table + UNIQUE(source))
|
||||
-- sber_price_index table (populated by app.tasks.sber_index_pull)
|
||||
-- app/services/product_handlers.py + tasks/sber_freshness_monitor.py deployed
|
||||
--
|
||||
-- Deploy order:
|
||||
-- Apply after deploying the product_handlers.py + task changes so the dispatch can resolve
|
||||
-- 'sber_freshness_monitor' correctly on first fire.
|
||||
|
||||
BEGIN;
|
||||
|
||||
INSERT INTO scrape_schedules (
|
||||
source,
|
||||
enabled,
|
||||
window_start_hour,
|
||||
window_end_hour,
|
||||
next_run_at,
|
||||
default_params
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'sber_freshness_monitor',
|
||||
true, -- SAFE: single SELECT max(period_month), no ext calls
|
||||
9, -- window 09:00-10:00 UTC (after deals_freshness_monitor)
|
||||
10,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC',
|
||||
'{"lag_allowance_days": 25}'::jsonb -- publication-lag allowance over the 35d per-estimate guard
|
||||
)
|
||||
ON CONFLICT (source) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
16
tradein-mvp/backend/data/sql/181_clamp_bad_listing_dates.sql
Normal file
16
tradein-mvp/backend/data/sql/181_clamp_bad_listing_dates.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
-- 181_clamp_bad_listing_dates.sql
|
||||
-- LOW audit R2 (#6): null out implausible listing_date/publish_date left by scrapers
|
||||
-- before the clamp guard landed (Avito 1970-epoch: ~698 rows; near-future: ~79 rows).
|
||||
-- Idempotent: re-running is a no-op once values are already NULL / in-window.
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE listings SET listing_date = NULL
|
||||
WHERE listing_date IS NOT NULL
|
||||
AND (listing_date < DATE '2010-01-01' OR listing_date > CURRENT_DATE + 2);
|
||||
|
||||
UPDATE listings SET publish_date = NULL
|
||||
WHERE publish_date IS NOT NULL
|
||||
AND (publish_date < DATE '2010-01-01' OR publish_date > CURRENT_DATE + 2);
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
-- 182_trade_in_leads_consent_proof.sql
|
||||
-- LOW audit R2 (#9): durable 152-ФЗ proof-of-consent for trade_in_leads.
|
||||
--
|
||||
-- WHY:
|
||||
-- #2497 already computes client_ip (X-Forwarded-For, fallback direct peer)
|
||||
-- and pins _CONSENT_POLICY_VERSION = "2026-07" in app/api/v1/lead.py, but
|
||||
-- both were written ONLY to the audit log — trade_in_leads had no columns
|
||||
-- to hold them (explicit TODO at the INSERT in lead.py). A bare boolean
|
||||
-- `consent` column plus a log line is not durable proof: logs rotate/expire,
|
||||
-- the row itself carries no evidence of what was agreed to, under what
|
||||
-- policy version, or from what IP.
|
||||
--
|
||||
-- WHAT:
|
||||
-- Three nullable columns on trade_in_leads:
|
||||
-- - client_ip (inet) — same value already computed in
|
||||
-- create_trade_in_lead, now persisted
|
||||
-- per-row instead of log-only.
|
||||
-- - consent_policy_version (text) — snapshot of _CONSENT_POLICY_VERSION
|
||||
-- at the time this lead was captured.
|
||||
-- - consent_text_snapshot (text) — snapshot of the exact consent sentence
|
||||
-- shown to the user (_CONSENT_TEXT_SNAPSHOT
|
||||
-- in lead.py, same PR).
|
||||
--
|
||||
-- IDEMPOTENCY / SAFETY:
|
||||
-- ADD COLUMN IF NOT EXISTS x3 — safe re-run. All nullable, no DEFAULT, no
|
||||
-- backfill: existing rows keep NULL (no consent proof was captured for them
|
||||
-- pre-migration — that's an honest reflection of what actually happened,
|
||||
-- not a schema gap). Purely additive: no existing reads/writes break.
|
||||
--
|
||||
-- Dependencies: 172_trade_in_leads.sql (trade_in_leads table).
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE trade_in_leads
|
||||
ADD COLUMN IF NOT EXISTS client_ip inet,
|
||||
ADD COLUMN IF NOT EXISTS consent_policy_version text,
|
||||
ADD COLUMN IF NOT EXISTS consent_text_snapshot text;
|
||||
|
||||
COMMENT ON COLUMN trade_in_leads.client_ip IS '152-ФЗ proof-of-consent: клиентский IP на момент согласия (ранее только в audit-логе, #2497 TODO).';
|
||||
COMMENT ON COLUMN trade_in_leads.consent_policy_version IS '152-ФЗ proof-of-consent: снимок _CONSENT_POLICY_VERSION на момент согласия.';
|
||||
COMMENT ON COLUMN trade_in_leads.consent_text_snapshot IS '152-ФЗ proof-of-consent: снимок текста согласия, показанного пользователю (_CONSENT_TEXT_SNAPSHOT).';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
-- 183_reenable_deactivate_stale_domklik.sql
|
||||
--
|
||||
-- ПРОДУКТОВОЕ РЕШЕНИЕ (user, 2026-07-13): ВКЛЮЧАЕМ обратно TTL-деактивацию
|
||||
-- deactivate_stale_domklik (была отключена миграцией 163 «до возобновления
|
||||
-- регулярных domclick-свипов»).
|
||||
--
|
||||
-- Условие из 163 снято: свипы domclick_city_sweep + domclick_detail_backfill
|
||||
-- возобновлены и стабильно бегают ежедневно (scraped_at свежий, диапазон
|
||||
-- последней недели), т.е. причина отключения больше не действует.
|
||||
--
|
||||
-- Безопасность re-enable (LOW audit R2 #10):
|
||||
-- • Джоб ключуется на scraped_at (whitelist last_seen_at/scraped_at в
|
||||
-- deactivate_stale_avito.py::_ALLOWED_STALENESS_COLUMNS), НЕ на listing_date —
|
||||
-- исходная гипотеза «выпадет весь domklik по NULL listing_date» неприменима.
|
||||
-- • При живых свипах scraped_at < NOW()-14d только у реально протухших строк,
|
||||
-- а не у всего пула (в отличие от ситуации 163, где свипы стояли) —
|
||||
-- деактивация корректна, а не тотальна. Чинит завышенный freshness domklik.
|
||||
--
|
||||
-- Идемпотентно: повторный прогон — no-op (enabled уже true). Row создаётся
|
||||
-- миграцией 160; если отсутствует — UPDATE затронет 0 строк (безопасно).
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE scrape_schedules
|
||||
SET enabled = true
|
||||
WHERE source = 'deactivate_stale_domklik';
|
||||
|
||||
COMMIT;
|
||||
60
tradein-mvp/backend/data/sql/184_user_events.sql
Normal file
60
tradein-mvp/backend/data/sql/184_user_events.sql
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
-- 184_user_events.sql
|
||||
-- Foundation schema for Features 2 & 3: unified user-event tracking.
|
||||
--
|
||||
-- WHY:
|
||||
-- trade-in had no durable event log for login/IP audit or behavior
|
||||
-- analytics. A near-identical design already existed once — `audit_log`
|
||||
-- (event_type, ip_address inet, user_agent, estimate_id uuid, payload
|
||||
-- jsonb, created_at) was defined in 002_core_tables.sql and DROPPED in
|
||||
-- 095_dead_schema.sql as unused dead schema at the time. Product now
|
||||
-- needs exactly that shape again, so this migration resurrects the
|
||||
-- design under a new name, `user_events`, as the ONE unified
|
||||
-- append-only table serving:
|
||||
-- - login-audit (who logged in, from what IP/UA, when)
|
||||
-- - search-audit (estimate requests, listing lookups)
|
||||
-- - behavior-analytics (pdf_download, listing_click, page_view,
|
||||
-- drawer_open, and future event_type values)
|
||||
--
|
||||
-- WHAT:
|
||||
-- `user_events` — append-only, admin-read-only. No 152-ФЗ consent
|
||||
-- gating and no mandatory retention policy for MVP (product-owner
|
||||
-- decision): we log IP/UA/path/method/payload unconditionally for every
|
||||
-- tracked event. `estimate_id` is a plain uuid column with NO FK
|
||||
-- constraint on purpose — this keeps the log decoupled/append-only so
|
||||
-- estimate deletion (or any future estimate lifecycle change) never
|
||||
-- blocks or cascades into event rows.
|
||||
--
|
||||
-- IDEMPOTENCY / SAFETY:
|
||||
-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS throughout —
|
||||
-- safe re-run. Purely additive: no existing table/view/column is
|
||||
-- touched.
|
||||
--
|
||||
-- Dependencies: none (new standalone table). Auto-applied on deploy via
|
||||
-- _schema_migrations tracking (tradein-mvp/backend/data/sql convention).
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_events (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
event_type text NOT NULL,
|
||||
username text NOT NULL,
|
||||
ip_address inet,
|
||||
user_agent text,
|
||||
path text,
|
||||
method text,
|
||||
estimate_id uuid,
|
||||
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE user_events IS 'Unified append-only event log (login/IP audit + behavior analytics), admin-read-only. Resurrects the design of audit_log (002_core_tables.sql, dropped in 095_dead_schema.sql).';
|
||||
COMMENT ON COLUMN user_events.event_type IS 'e.g. login / estimate_request / pdf_download / listing_click / page_view / drawer_open.';
|
||||
COMMENT ON COLUMN user_events.username IS 'X-Authenticated-User value at the time of the event.';
|
||||
COMMENT ON COLUMN user_events.estimate_id IS 'No FK constraint by design — keeps the log decoupled/append-only from trade_in_estimates lifecycle.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_events_username_created_at_idx ON user_events (username, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS user_events_event_type_created_at_idx ON user_events (event_type, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS user_events_ip_address_idx ON user_events (ip_address);
|
||||
CREATE INDEX IF NOT EXISTS user_events_created_at_idx ON user_events (created_at DESC);
|
||||
|
||||
COMMIT;
|
||||
59
tradein-mvp/backend/data/sql/185_account_quota_overrides.sql
Normal file
59
tradein-mvp/backend/data/sql/185_account_quota_overrides.sql
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
-- Migration 185: account_quota_overrides — персональные месячные лимиты оценок
|
||||
--
|
||||
-- WHY:
|
||||
-- Бонусные попытки для платящих пилотов раздавались хаком через SQL-runbook:
|
||||
-- `UPDATE account_estimate_usage SET used = used - N` (negative used). Для user2
|
||||
-- (Брусника) это дало used=-35 при глобальном limit=15 → GET /quota отдавал
|
||||
-- {limit:15, used:-35, remaining:50} → фронт (EstimateForm.tsx) рендерил абсурд
|
||||
-- «Осталось 50 из 15». Правильное решение — персональный лимит, а не отрицательный
|
||||
-- счётчик использования.
|
||||
--
|
||||
-- WHAT:
|
||||
-- 1. Таблица account_quota_overrides(username PK, monthly_limit, note, updated_at) —
|
||||
-- per-user override глобального settings.estimate_quota_limit. Читается через
|
||||
-- app.services.account_quota.user_limit().
|
||||
-- 2. Seed: user2 (Брусника) → monthly_limit=50, пилотный грант 2026-07-13.
|
||||
-- 3. Сброс хака: account_estimate_usage.used < 0 → 0 (для user2, единственного
|
||||
-- затронутого аккаунта). После этой миграции user2: limit=50, used=0,
|
||||
-- remaining=50 — корректно вместо «50 из 15».
|
||||
--
|
||||
-- IDEMPOTENCY:
|
||||
-- CREATE TABLE IF NOT EXISTS + INSERT ... ON CONFLICT DO UPDATE (upsert, безопасен
|
||||
-- при повторном применении) + UPDATE ... WHERE used < 0 (после первого прогона
|
||||
-- used=0, условие больше не матчит — безопасный no-op при ре-apply).
|
||||
--
|
||||
-- Dependencies: 076_account_estimate_quota.sql (account_estimate_usage).
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account_quota_overrides (
|
||||
username text PRIMARY KEY,
|
||||
monthly_limit integer NOT NULL,
|
||||
note text,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE account_quota_overrides IS
|
||||
'Персональный override месячного лимита оценок trade-in (вместо глобального '
|
||||
'settings.estimate_quota_limit). Читается app.services.account_quota.user_limit().';
|
||||
|
||||
COMMENT ON COLUMN account_quota_overrides.monthly_limit IS
|
||||
'Персональный лимит успешных оценок в календарный месяц для username.';
|
||||
|
||||
COMMENT ON COLUMN account_quota_overrides.note IS
|
||||
'Причина override (grant / pilot / manual bump) — для аудита ручных изменений.';
|
||||
|
||||
INSERT INTO account_quota_overrides (username, monthly_limit, note)
|
||||
VALUES ('user2', 50, 'Брусника — пилот, грант 2026-07-13')
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
monthly_limit = EXCLUDED.monthly_limit,
|
||||
note = EXCLUDED.note,
|
||||
updated_at = now();
|
||||
|
||||
-- Сбрасываем прежний хак бонусных попыток (negative used), который ломал /quota:
|
||||
-- limit=15, used=-35 → remaining=50 → фронт рендерил «Осталось 50 из 15».
|
||||
UPDATE account_estimate_usage
|
||||
SET used = 0, updated_at = now()
|
||||
WHERE username = 'user2' AND used < 0;
|
||||
|
||||
COMMIT;
|
||||
93
tradein-mvp/backend/data/sql/186_tg_support.sql
Normal file
93
tradein-mvp/backend/data/sql/186_tg_support.sql
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
-- 186_tg_support.sql
|
||||
-- Telegram support bridge: @MERAsupport_bot mirrors client DMs into a
|
||||
-- support-group topic; an operator replies in-thread; the bot relays the
|
||||
-- reply back to the client's private chat.
|
||||
--
|
||||
-- WHY:
|
||||
-- No durable state existed for this flow. Two things are required to make
|
||||
-- it work reliably:
|
||||
-- 1. A mapping from "message mirrored into the support topic" back to
|
||||
-- "which client chat_id it came from" — this is how an operator's
|
||||
-- reply (a Telegram reply-to a topic message) gets routed to the
|
||||
-- right client. `topic_message_id` on tg_support_messages is that
|
||||
-- routing key.
|
||||
-- 2. A durable long-polling offset (`tg_support_state`) so a worker
|
||||
-- restart does not replay already-processed Telegram updates.
|
||||
--
|
||||
-- WHAT:
|
||||
-- - tg_support_users — one row per client Telegram private chat
|
||||
-- (chat_id is the Telegram chat id, stable per
|
||||
-- client, used directly as PK — no surrogate key
|
||||
-- needed).
|
||||
-- - tg_support_messages — full conversation log, both directions.
|
||||
-- - tg_support_state — singleton key/value store for worker offsets
|
||||
-- (e.g. key='last_update_id').
|
||||
--
|
||||
-- 152-FZ:
|
||||
-- tg_support_users / tg_support_messages hold personal data (Telegram
|
||||
-- username/name + free-text conversation content). ON DELETE CASCADE from
|
||||
-- tg_support_users -> tg_support_messages makes client erasure a single
|
||||
-- `DELETE FROM tg_support_users WHERE chat_id = :chat_id` statement, no
|
||||
-- separate cleanup pass needed.
|
||||
--
|
||||
-- IDEMPOTENCY / SAFETY:
|
||||
-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS throughout —
|
||||
-- safe re-run. Purely additive: no existing table/view/column touched.
|
||||
--
|
||||
-- Dependencies: none (new standalone tables). Auto-applied on deploy via
|
||||
-- _schema_migrations tracking (tradein-mvp/backend/data/sql convention).
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tg_support_users (
|
||||
chat_id bigint PRIMARY KEY,
|
||||
username text,
|
||||
first_name text,
|
||||
last_name text,
|
||||
language_code text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
||||
is_blocked boolean NOT NULL DEFAULT false
|
||||
);
|
||||
|
||||
COMMENT ON TABLE tg_support_users IS '152-ФЗ: ПДн клиентов Telegram-поддержки (@MERAsupport_bot). Удаление клиента — DELETE FROM tg_support_users WHERE chat_id=...; ON DELETE CASCADE в tg_support_messages подчищает переписку одной операцией.';
|
||||
COMMENT ON COLUMN tg_support_users.chat_id IS 'Telegram private chat id клиента (стабильный, используется как PK напрямую).';
|
||||
COMMENT ON COLUMN tg_support_users.is_blocked IS 'true, если клиент заблокировал бота (Telegram 403 на отправку) — бот перестаёт пытаться слать сообщения.';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tg_support_messages (
|
||||
id bigserial PRIMARY KEY,
|
||||
chat_id bigint NOT NULL REFERENCES tg_support_users (chat_id) ON DELETE CASCADE,
|
||||
direction text NOT NULL CHECK (direction IN ('in', 'out')),
|
||||
tg_message_id bigint,
|
||||
topic_message_id bigint,
|
||||
kind text NOT NULL,
|
||||
text_body text,
|
||||
operator_tg_id bigint,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE tg_support_messages IS '152-ФЗ: полный лог переписки Telegram-поддержки (ПДн, содержимое сообщений). Каскадно удаляется вместе с tg_support_users по chat_id.';
|
||||
COMMENT ON COLUMN tg_support_messages.direction IS '''in'' — сообщение от клиента боту; ''out'' — ответ бота/оператора клиенту.';
|
||||
COMMENT ON COLUMN tg_support_messages.tg_message_id IS 'id сообщения в личном чате с клиентом (Telegram message_id в chat_id).';
|
||||
COMMENT ON COLUMN tg_support_messages.topic_message_id IS 'id зеркала сообщения в support-топике группы — ключ маршрутизации: реплай оператора на это сообщение адресуется данному chat_id.';
|
||||
COMMENT ON COLUMN tg_support_messages.kind IS 'text | photo | document | video | voice | other.';
|
||||
COMMENT ON COLUMN tg_support_messages.text_body IS 'Текст сообщения или caption медиа; NULL для медиа без подписи.';
|
||||
COMMENT ON COLUMN tg_support_messages.operator_tg_id IS 'Telegram user id оператора, ответившего в топике; заполняется только для direction=''out''.';
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS tg_support_messages_topic_message_id_uq
|
||||
ON tg_support_messages (topic_message_id)
|
||||
WHERE topic_message_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS tg_support_messages_chat_id_created_at_idx
|
||||
ON tg_support_messages (chat_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tg_support_state (
|
||||
key text PRIMARY KEY,
|
||||
value text NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE tg_support_state IS 'Singleton key/value store для состояния Telegram-поддержки (например last_update_id для long-polling), переживает рестарт воркера.';
|
||||
COMMENT ON COLUMN tg_support_state.key IS 'e.g. ''last_update_id''.';
|
||||
|
||||
COMMIT;
|
||||
122
tradein-mvp/backend/data/sql/187_web_support_chat.sql
Normal file
122
tradein-mvp/backend/data/sql/187_web_support_chat.sql
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
-- 187_web_support_chat.sql
|
||||
-- Web-чат поддержки (сайт МЕРА) поверх УЖЕ существующего Telegram support-моста
|
||||
-- (data/sql/186_tg_support.sql, app/services/tgbot/bridge.py). Источник обращения
|
||||
-- меняется (сайт вместо Telegram-лички клиента), маршрутизация ответа оператора
|
||||
-- (реплай на зеркало в топике супергруппы) остаётся ТОЙ ЖЕ — оператор ничего
|
||||
-- нового не учит.
|
||||
--
|
||||
-- ПОЧЕМУ ОТДЕЛЬНЫЕ ТАБЛИЦЫ, А НЕ "tg_support_* + channel"
|
||||
-- (взвешено явно, per code review requirement):
|
||||
--
|
||||
-- Вариант А (отклонён) — добавить channel text ('telegram'|'web') в
|
||||
-- tg_support_users/tg_support_messages:
|
||||
-- - tg_support_users.chat_id bigint PRIMARY KEY — это Telegram private
|
||||
-- chat id клиента. У веб-пользователя сайта ЕГО НЕТ (клиент никогда не
|
||||
-- писал боту в личку) — пришлось бы либо (а) городить синтетический
|
||||
-- chat_id для веб-юзера (напр. отрицательный hash от username) — это
|
||||
-- вводит ВТОРУЮ систему идентификации внутри одной PK-колонки,
|
||||
-- семантика которой документирована как "Telegram chat id" (186:54),
|
||||
-- либо (б) делать chat_id NULLABLE и городить ещё одну колонку
|
||||
-- username NULLABLE рядом — таблица с двумя взаимоисключающими
|
||||
-- identity-схемами и кучей CHECK-ограничений вида
|
||||
-- "chat_id XOR username NOT NULL".
|
||||
-- - Доставка обратно ТОЖЕ разная: Telegram-путь шлёт copyMessage в личку
|
||||
-- клиента, веб-путь просто пишет строку в БД (personal chat не
|
||||
-- существует) — код в bridge.py и так ветвится по каналу, общая
|
||||
-- таблица не убирает эту ветку, только добавляет NULL-поля.
|
||||
-- - Риск регрессии: tg_support_* уже покрыты test_bridge.py (14+
|
||||
-- кейсов) и работают в проде (PR #2526) — трогать рабочую, протестированную
|
||||
-- схему ради ещё не запущенной фичи повышает blast radius без выгоды.
|
||||
--
|
||||
-- Вариант Б (выбран) — новые web_support_threads/web_support_messages:
|
||||
-- - Идентификатор клиента — username (X-Authenticated-User, сайт закрыт
|
||||
-- Caddy basic_auth, публичного доступа нет — см. app/main.py rbac_guard)
|
||||
-- — чистый, не smoke-и-зеркала не переиспользующий Telegram identity.
|
||||
-- - topic_message_id-маршрутизация (ключевой механизм моста) СОХРАНЕНА
|
||||
-- 1-в-1 по конвенции 186: partial UNIQUE на topic_message_id,
|
||||
-- заполняется только для direction='in', NULL для direction='out'.
|
||||
-- - bridge.py меняется МИНИМАЛЬНО: _handle_group_reply получает одну
|
||||
-- дополнительную ветку (резолвит tg-путь И web-путь, потом
|
||||
-- existing orphan-warning) — существующий Telegram-путь не трогается.
|
||||
--
|
||||
-- ⚠️ CROSS-TABLE КОЛЛИЗИЯ topic_message_id (review M1, зафиксировано ДО
|
||||
-- первого прод-использования, пока обе таблицы пусты):
|
||||
-- Инвариант "topic_message_id уникален между tg_support_messages и
|
||||
-- web_support_messages" на самом деле звучит так: "уникален, ПОКА
|
||||
-- TELEGRAM_SUPPORT_CHAT_ID не менялся". Это OPS-инвариант, а НЕ DB-инвариант —
|
||||
-- ничем не гарантирован. Смена/пересоздание support-группы обнуляет счётчик
|
||||
-- Telegram message_id в новом чате; когда он дорастёт до диапазона,
|
||||
-- использованного старым чатом, — number, ранее занятый ОДНОЙ таблицей,
|
||||
-- может совпасть с числом, занятым ДРУГОЙ. Внутри одной таблицы partial
|
||||
-- UNIQUE превращает такую коллизию в громкий отказ INSERT — это ок. МЕЖДУ
|
||||
-- таблицами constraint'а нет: без доп. скоупинга бот молча доставил бы ответ
|
||||
-- оператора НЕ ТОМУ клиенту (152-ФЗ-инцидент, происходящий тихо).
|
||||
-- Фикс: колонка `support_chat_id` на web_support_messages (симметричная
|
||||
-- колонка для УЖЕ применённой tg_support_messages — отдельная миграция
|
||||
-- 188_tg_support_chat_id_scope.sql, эту таблицу нельзя трогать здесь, она
|
||||
-- уже применена/задеплоена как часть 186). Резолв (bridge.py) матчит ПАРУ
|
||||
-- (support_chat_id, topic_message_id), а не topic_message_id в одиночку;
|
||||
-- NULL (легаси-строки без этой колонки) — лениентный wildcard, т.к. на тот
|
||||
-- момент действовал ровно один чат.
|
||||
--
|
||||
-- ЧТО:
|
||||
-- - web_support_threads — один тред на username (сайт = 1 логин = 1 линия
|
||||
-- переписки с поддержкой, без под-тредов).
|
||||
-- - web_support_messages — лог переписки, direction='in' (от юзера) |
|
||||
-- 'out' (ответ оператора, реплай из bridge.py).
|
||||
--
|
||||
-- 152-ФЗ:
|
||||
-- Переписка (text_body) — ПДн (может содержать любые данные, которые юзер
|
||||
-- решит написать). ON DELETE CASCADE от web_support_threads делает erasure
|
||||
-- ОДНОЙ операцией (DELETE FROM web_support_threads WHERE username = :u) ДЛЯ
|
||||
-- КОПИИ В ЭТОЙ БД. Копия того же текста уже ушла в Telegram-топик (sendMessage
|
||||
-- зеркало) и живёт ТАМ вне зоны действия этого DELETE — реальное "право на
|
||||
-- забвение" по всей цепочке требует ОТДЕЛЬНОЙ процедуры (удаление сообщений в
|
||||
-- Telegram-супергруппе через Bot API deleteMessage, вне scope этой миграции).
|
||||
-- Не ссылаться на этот комментарий как на доказательство полного erasure.
|
||||
--
|
||||
-- IDEMPOTENCY: CREATE TABLE/INDEX IF NOT EXISTS — безопасный re-run.
|
||||
-- Зависимости: нет (новые standalone таблицы, никакие существующие
|
||||
-- tg_support_*/иные таблицы не трогаются — см. 188 для ALTER на tg_support_messages).
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS web_support_threads (
|
||||
id bigserial PRIMARY KEY,
|
||||
username text NOT NULL UNIQUE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_read_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE web_support_threads IS '152-ФЗ: одна строка на username (сайт МЕРА, X-Authenticated-User) — единый тред переписки с поддержкой через веб-чат. Удаление клиента — DELETE FROM web_support_threads WHERE username=...; ON DELETE CASCADE в web_support_messages подчищает переписку одной операцией.';
|
||||
COMMENT ON COLUMN web_support_threads.username IS 'X-Authenticated-User (Caddy basic_auth) — сайт закрыт, анонимов нет, см. app/main.py rbac_guard.';
|
||||
COMMENT ON COLUMN web_support_threads.last_seen_at IS 'Обновляется при отправке юзером нового сообщения (send-активность, НЕ на чтение истории).';
|
||||
COMMENT ON COLUMN web_support_threads.last_read_at IS 'Отметка "прочитано до" (POST /api/v1/trade-in/support/read) — используется для счётчика непрочитанного (GET /support/unread).';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS web_support_messages (
|
||||
id bigserial PRIMARY KEY,
|
||||
thread_id bigint NOT NULL REFERENCES web_support_threads (id) ON DELETE CASCADE,
|
||||
direction text NOT NULL CHECK (direction IN ('in', 'out')),
|
||||
text_body text NOT NULL CHECK (char_length(btrim(text_body)) > 0),
|
||||
topic_message_id bigint,
|
||||
support_chat_id bigint,
|
||||
operator_tg_id bigint,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE web_support_messages IS '152-ФЗ: полный лог веб-чата поддержки (ПДн — содержимое сообщений; удаление подчищает КОПИЮ В ЭТОЙ БД, не Telegram-топик — см. блок 152-ФЗ выше). Каскадно удаляется вместе с web_support_threads по username.';
|
||||
COMMENT ON COLUMN web_support_messages.direction IS '''in'' — сообщение от пользователя сайта; ''out'' — ответ оператора (доставлен через реплай в Telegram-топике, см. bridge.py _handle_group_reply).';
|
||||
COMMENT ON COLUMN web_support_messages.text_body IS 'Текст сообщения. Веб-чат — текстовый MVP, медиа не поддерживается (в отличие от tg_support_messages.kind).';
|
||||
COMMENT ON COLUMN web_support_messages.topic_message_id IS 'id зеркала (sendMessage) в support-топике — ключ маршрутизации ответа, только для direction=''in''. NULL для ''out'' (конвенция 186: маршрутизирующий ключ живёт исключительно на inbound-записи).';
|
||||
COMMENT ON COLUMN web_support_messages.support_chat_id IS 'TELEGRAM_SUPPORT_CHAT_ID в момент отправки — скоупит резолв topic_message_id к ТЕКУЩЕЙ support-группе (review M1: без этого поля ротация группы даёт тихую cross-table коллизию, см. блок выше). NULL — лениентный wildcard для строк без этого поля.';
|
||||
COMMENT ON COLUMN web_support_messages.operator_tg_id IS 'Telegram user id оператора, ответившего в топике; заполняется только для direction=''out''.';
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS web_support_messages_topic_message_id_uq
|
||||
ON web_support_messages (topic_message_id)
|
||||
WHERE topic_message_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS web_support_messages_thread_id_created_at_idx
|
||||
ON web_support_messages (thread_id, created_at DESC);
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
-- 188_tg_support_chat_id_scope.sql
|
||||
-- Симметричная колонка для web_support_messages.support_chat_id (см.
|
||||
-- data/sql/187_web_support_chat.sql — полный разбор проблемы в блоке "CROSS-TABLE
|
||||
-- КОЛЛИЗИЯ topic_message_id" там же).
|
||||
--
|
||||
-- ПОЧЕМУ ОТДЕЛЬНАЯ МИГРАЦИЯ, А НЕ ПРАВКА 186:
|
||||
-- tg_support_messages создана в data/sql/186_tg_support.sql — миграция, которая
|
||||
-- к моменту написания этого файла уже смержена в main отдельным PR (#2526) и,
|
||||
-- по конвенции проекта (deploy-tradein.yml применяет каждый data/sql/*.sql РОВНО
|
||||
-- ОДИН РАЗ по bare filename через _schema_migrations), скорее всего уже
|
||||
-- применена на проде. Редактирование СОДЕРЖИМОГО уже применённого файла НЕ
|
||||
-- долетает до прода повторным прогоном — прод просто пропустит файл с тем же
|
||||
-- именем. Единственный корректный способ добавить колонку в уже существующую
|
||||
-- таблицу — новый ALTER-файл.
|
||||
--
|
||||
-- ЧТО: tg_support_messages.support_chat_id bigint (nullable) — TELEGRAM_SUPPORT_
|
||||
-- CHAT_ID в момент записи 'in'-сообщения. bridge.py.find_chat_by_topic_message
|
||||
-- матчит (support_chat_id, topic_message_id) вместо topic_message_id в одиночку;
|
||||
-- NULL (все строки ДО этой миграции) — лениентный wildcard-матч, т.к. до
|
||||
-- появления этой колонки действовал ровно один support-чат за раз.
|
||||
--
|
||||
-- Бэкфилл существующих строк текущим TELEGRAM_SUPPORT_CHAT_ID НЕ делаем: значение
|
||||
-- живёт в Python `settings`/env, разное на каждом окружении (dev/staging/prod), а
|
||||
-- plain-SQL миграция не имеет доступа к процессным env vars — хардкодить
|
||||
-- конкретный chat_id в SQL-файл было бы хрупко и окружение-специфично. NULL
|
||||
-- (wildcard) для существующих строк — безопасный дефолт: они писались, когда
|
||||
-- support-чат был ровно один, коллизии из-за смены чата у НИХ по определению
|
||||
-- невозможны (см. 187 — только смена чата ПОСЛЕ появления этой колонки создаёт
|
||||
-- сценарий, который она защищает).
|
||||
--
|
||||
-- IDEMPOTENCY: ADD COLUMN IF NOT EXISTS — безопасный re-run. Не трогает
|
||||
-- существующие данные/constraints tg_support_messages.
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE tg_support_messages ADD COLUMN IF NOT EXISTS support_chat_id bigint;
|
||||
|
||||
COMMENT ON COLUMN tg_support_messages.support_chat_id IS 'TELEGRAM_SUPPORT_CHAT_ID в момент записи ''in''-сообщения — скоупит резолв topic_message_id к ТЕКУЩЕЙ support-группе (review M1, см. data/sql/187_web_support_chat.sql). NULL — строки до этой колонки (лениентный wildcard-матч).';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
-- Migration 189: account_estimate_usage.used >= 0 — защита от бонус-хака (negative used)
|
||||
--
|
||||
-- WHY:
|
||||
-- Прежний SQL-runbook хак раздачи бонусных попыток (`UPDATE account_estimate_usage
|
||||
-- SET used = used - N`) уводил used в отрицательные значения. Migration 185 сбросила
|
||||
-- это ТОЛЬКО для user2 (used=-35 -> 0, WHERE username = 'user2'). На проде остаётся
|
||||
-- минимум ещё один затронутый аккаунт тем же классом порчи (praktika, период 2026-06:
|
||||
-- used=-3 при 42 фактических успешных оценках — расхождение объясняется именно этим
|
||||
-- хаком, не кодовым багом).
|
||||
--
|
||||
-- Аудит app.services.account_quota подтверждает: декремента `used` в текущем коде
|
||||
-- НЕТ. increment() делает только `used + 1` под предикатом `WHERE used < :lim`
|
||||
-- (#747, atomic conditional increment) — этот путь не может уйти в минус. Значит
|
||||
-- источник отрицательных значений исключительно внешний (ручной UPDATE через
|
||||
-- runbook), а не баг в приложении.
|
||||
--
|
||||
-- WHAT:
|
||||
-- 1. Сброс ВСЕХ оставшихся negative used -> 0 (не только user2, как в 185) —
|
||||
-- закрывает praktika и любой другой пропущенный аккаунт.
|
||||
-- 2. CHECK (used >= 0) — защита на уровне схемы: любой будущий ручной UPDATE/хак,
|
||||
-- уводящий used < 0, теперь падает на уровне БД вместо тихой порчи /quota
|
||||
-- (GET /quota мог отдать remaining > limit — «Осталось 50 из 15», см. 185).
|
||||
--
|
||||
-- IDEMPOTENCY:
|
||||
-- - UPDATE ... WHERE used < 0 — no-op при повторном прогоне (после первого раза
|
||||
-- условие больше не матчит).
|
||||
-- - ADD CONSTRAINT через DO-блок с проверкой pg_constraint — Postgres не
|
||||
-- поддерживает `ADD CONSTRAINT IF NOT EXISTS` напрямую для CHECK, поэтому
|
||||
-- оборачиваем в идемпотентную проверку по имени constraint.
|
||||
--
|
||||
-- Dependencies: 076_account_estimate_quota.sql (account_estimate_usage),
|
||||
-- 185_account_quota_overrides.sql (первый частичный сброс, только user2).
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE account_estimate_usage
|
||||
SET used = 0, updated_at = now()
|
||||
WHERE used < 0;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'account_estimate_usage_used_nonnegative'
|
||||
) THEN
|
||||
ALTER TABLE account_estimate_usage
|
||||
ADD CONSTRAINT account_estimate_usage_used_nonnegative CHECK (used >= 0);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
-- 190_sale_share_price_bucket_signature.sql
|
||||
--
|
||||
-- CONTEXT: аудит МЕРЫ. Числитель v_building_sale_share (мигр. 148) дедупит
|
||||
-- листинги по сигнатуре (rooms, round(area_m2), floor) — убирает кросс-
|
||||
-- площадочные дубли одной физической квартиры (avito+cian+domclick). Но в
|
||||
-- типовом секционном доме 4 РАЗНЫЕ квартиры на одном этаже в разных
|
||||
-- подъездах имеют ТУ ЖЕ тройку признаков (подъезда в данных нет) → ложно
|
||||
-- схлопываются в одну.
|
||||
--
|
||||
-- Прод-замер (снят вручную, до этой миграции; не переснят в рамках неё —
|
||||
-- нет доступа к БД из этой сессии, см. ниже):
|
||||
-- · без дедупа (активные вторичные, house_id_fk/rooms/area_m2/floor/
|
||||
-- price_rub все NOT NULL): 15 424 записи;
|
||||
-- · текущая сигнатура (rooms, round(area_m2), floor): 11 324 «квартиры»
|
||||
-- (−4 100 против raw — почти весь эффект дедупа, но и false-merge тоже);
|
||||
-- · та же сигнатура + price_bucket round(price_rub/100000): 12 497
|
||||
-- (+1 173 против текущей, +10.4%) — возвращает часть false-merge'ов.
|
||||
-- Внутри 3 220 групп, схлопнутых текущей сигнатурой:
|
||||
-- · 1 252 группы (1 756 записей) имеют РАЗНЫЕ цены — почти наверняка
|
||||
-- разные квартиры, не кросс-пост;
|
||||
-- · 233 группы (249 записей) пришли с ОДНОЙ площадки — одна площадка
|
||||
-- редко публикует одну и ту же квартиру дважды, тоже почти наверняка
|
||||
-- разные квартиры (см. "residual risk" ниже — этот класс НЕ решается
|
||||
-- одним лишь price_bucket, если у них к тому же совпала цена).
|
||||
--
|
||||
-- РЕШЕНИЕ ВЛАДЕЛЬЦА ПРОДУКТА: схлопывать записи, только если они совпадают
|
||||
-- ЕЩЁ И по цене (round(price_rub/100000) — тот же бакет, что уже
|
||||
-- используется в backend/app/services/estimator.py::_DEDUP_PRICE_BUCKET_RUB
|
||||
-- для кросс-source физ-дедупа аналогов; ~±0.5% допуска при 21М, ~±2% при
|
||||
-- 2.5М — см. app/core/config.py:265). Разные квартиры в одном доме
|
||||
-- почти никогда не стоят ровно одинаково, кросс-пост одного лота — стоит.
|
||||
--
|
||||
-- ЧТО НЕ ВОШЛО (source-distinctness) и почему:
|
||||
-- Продуктовое решение также просило требовать "с разных площадок". Честно
|
||||
-- выразить это внутри count(DISTINCT ...) НЕЛЬЗЯ без перестройки CTE
|
||||
-- listing_agg в двухуровневую агрегацию (сначала GROUP BY house_id +
|
||||
-- расширенная сигнатура + count(DISTINCT source) per группа, потом per-house
|
||||
-- SUM(CASE WHEN distinct_sources>=2 THEN 1 ELSE listing_count END)) — это
|
||||
-- затронуло бы ВСЕ 6 агрегатов CTE (active_secondary, listings_45d,
|
||||
-- median_price_rub, median_price_per_m2, avg_days_on_market,
|
||||
-- listings_med_floors), которые сейчас делят один плоский FILTER-паттерн,
|
||||
-- накопленный за 6 миграций (148-153). Риск регрессии от такой перестройки
|
||||
-- в одной миграции выше, чем ценность второго guard'а поверх уже сильно
|
||||
-- сузившего false-merge price_bucket. Берём только price-часть.
|
||||
--
|
||||
-- RESIDUAL RISK (направление ошибки после этой миграции):
|
||||
-- 1) НЕ решено — 233 группы/249 записей с ОДНОЙ площадкой: если у них
|
||||
-- внутри группы цена ТОЖЕ совпадает (не проверено, нет прод-доступа
|
||||
-- в этой сессии), они останутся ложно схлопнуты (недосчёт числителя,
|
||||
-- sale_share_pct ЗАНИЖЕН для этих домов) — тот же вид ошибки, что и
|
||||
-- раньше, но у существенно меньшего подмножества.
|
||||
-- 2) НОВЫЙ вид ошибки, которого не было: настоящий кросс-пост одного
|
||||
-- физлота, где цена УСПЕЛА измениться между скрейпами разных площадок
|
||||
-- (снизили цену на avito, domclick ещё не досканирован) — теперь НЕ
|
||||
-- схлопнется (разные price_bucket) → числитель ЗАВЫШЕН для этих домов.
|
||||
-- Раньше такая пара схлопывалась верно (без price в ключе). Прямого
|
||||
-- прод-замера размера этого класса нет.
|
||||
-- Итого: миграция МЕНЯЕТ баланс ошибки с «сильный недосчёт от false-merge
|
||||
-- по этажу/подъезду» на «слабый недосчёт по одноплощадочным совпадениям +
|
||||
-- небольшой new-пересчёт по кросс-постам с ценовым дрейфом» — чище, но не
|
||||
-- идеально в обе стороны.
|
||||
--
|
||||
-- price_rub NULL/0 handling: listings.price_rub объявлена `bigint NOT NULL`
|
||||
-- (002_core_tables.sql), но код уже трактует её defensively как потенциально
|
||||
-- отсутствующую (146/148: `l.price_rub IS NOT NULL` в median FILTER) — то же
|
||||
-- делаем здесь. price_bucket-компонент = NULL, когда price_rub IS NULL ИЛИ
|
||||
-- <= 0 (0/отрицательное — sentinel нераспарсенной цены, не реальная цена).
|
||||
-- Партиально-NULL кортеж (rooms/area/floor есть, price_bucket NULL)
|
||||
-- count(DISTINCT ROW(...)) трактует как СВОЙ отдельный кортеж (см. NULL-
|
||||
-- handling мигр. 148) — т.е. листинг без подтверждённой цены НЕ схлопывается
|
||||
-- ни с чем, считается один. Консервативно (не создаёт ложных совпадений по
|
||||
-- цене) и совпадает с философией estimator.py::_lot_dedup_components
|
||||
-- (`if not price: composite = None` → лот не участвует в физ-дедупе).
|
||||
--
|
||||
-- WHAT: в CTE listing_agg расширяем сигнатуру DISTINCT В ОБОИХ числителях
|
||||
-- (active_secondary, listings_45d) с (rooms, round(area_m2), floor) до
|
||||
-- (rooms, round(area_m2), floor, price_bucket), где price_bucket = CASE
|
||||
-- WHEN l.price_rub IS NULL OR l.price_rub <= 0 THEN NULL
|
||||
-- ELSE round(l.price_rub / 100000.0) END.
|
||||
-- Остальные 4 агрегата CTE (median_price_rub, median_price_per_m2,
|
||||
-- avg_days_on_market, listings_med_floors) — НЕ дедуп-based (считают по
|
||||
-- сырым листингам, прошедшим FILTER), не трогаем. Весь top-level SELECT /
|
||||
-- WHERE / плаузибилити-гейт (мигр. 145/153) / appended-колонки
|
||||
-- (zhkh_flat_count, flat_count_source) / гео(≤300м, мигр.150) / floors-guard
|
||||
-- (±3, мигр.152) — БАЙТ-В-БАЙТ как в мигр. 153.
|
||||
--
|
||||
-- DEPENDENCIES: 143 (view + houses.gar_*), 144 (canon match → gar_flat_count),
|
||||
-- 145 (плаузибилити-гейт знаменателя), 146 (listings_45d + sale_share_pct_45d
|
||||
-- + zhkh в COALESCE), 147 (canon strip geo-prefixes), 148 (дедуп
|
||||
-- кросс-площадочных дублей — база сигнатуры, которую здесь расширяем), 149
|
||||
-- (ЖКХ-приоритет знаменателя), 150 (гео-фильтр ≤300м в CTE), 151 (bare-street
|
||||
-- aliases — view не трогала), 152 (floors-guard ±3), 153 (плаузибилити по
|
||||
-- листинговой медианной этажности). Базируется на текущем (153) определении
|
||||
-- view — меняем ТОЛЬКО DISTINCT-выражение в active_secondary/listings_45d.
|
||||
--
|
||||
-- SAFETY / IDEMPOTENCY: CREATE OR REPLACE VIEW ONLY (структура top-level
|
||||
-- колонок не меняется — те же позиции/типы/имена, что в 153) + COMMENT.
|
||||
-- Никакого DDL над таблицами. Повторный прогон — no-op (REPLACE на
|
||||
-- идентичное определение). Деплой-раннер гонит файл через
|
||||
-- psql -v ON_ERROR_STOP=on БЕЗ --single-transaction → транзакцию открывает
|
||||
-- САМ файл (BEGIN/COMMIT ниже), как 146/148/149/150/152/153.
|
||||
--
|
||||
-- CONSUMERS (грепнуто по backend+frontend, не тронуты этой миграцией):
|
||||
-- backend/app/services/buildings_query.py — SELECT * колонок view (список,
|
||||
-- summary, гистограмма) — тот же набор колонок, не ломается;
|
||||
-- backend/app/schemas/buildings.py, backend/app/api/v1/buildings.py —
|
||||
-- Pydantic-схема поверх тех же колонок, не ломается;
|
||||
-- backend/tests/test_buildings_api.py — тестирует ТОЛЬКО текст SQL-билдеров
|
||||
-- (строку "FROM v_building_sale_share" и т.п.), не внутренний DISTINCT view
|
||||
-- → не ломается этой миграцией;
|
||||
-- ⚠ backend/app/services/buildings_query.py::build_listings_query — ОТДЕЛЬНЫЙ
|
||||
-- SQL (не читает view), реализует ТУ ЖЕ (rooms, round(area_m2), floor)
|
||||
-- сигнатуру САМОСТОЯТЕЛЬНО (DISTINCT ON) для панели листингов одного дома.
|
||||
-- После этой миграции сигнатуры /buildings/sale-share (список, через view,
|
||||
-- теперь +price_bucket) и /buildings/{id}/listings (панель, старая 3-тройка)
|
||||
-- РАСХОДЯТСЯ — на детальной панели дома возможен чуть меньший count уникальных
|
||||
-- квартир, чем active_secondary в списке. НЕ трогаем buildings_query.py в
|
||||
-- этой миграции (вне границ задачи) — фиксируем расхождение как known
|
||||
-- follow-up для отдельной задачи.
|
||||
--
|
||||
-- NB по нумерации: последний занятый = 188 (187/188 заняты веб-чатом);
|
||||
-- следующий свободный sequential = 189 (проверено `ls tradein-mvp/backend/
|
||||
-- data/sql | grep '^18'` — 187, 188 заняты, 189 свободен; дубля basename нет).
|
||||
--
|
||||
-- Deploy order: после 188_tg_support_chat_id_scope.sql.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE VIEW v_building_sale_share AS
|
||||
WITH listing_agg AS (
|
||||
SELECT l.house_id_fk AS house_id,
|
||||
count(DISTINCT (l.rooms, round(l.area_m2), l.floor,
|
||||
CASE WHEN l.price_rub IS NULL OR l.price_rub <= 0 THEN NULL
|
||||
ELSE round(l.price_rub / 100000.0) END))
|
||||
FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND (l.geom IS NULL OR hg.geom IS NULL OR ST_DistanceSphere(l.geom, hg.geom) <= 300) AND (l.total_floors IS NULL OR COALESCE(hg.zhkh_floors, hg.total_floors) IS NULL OR abs(l.total_floors - COALESCE(hg.zhkh_floors, hg.total_floors)) <= 3)) AS active_secondary,
|
||||
count(DISTINCT (l.rooms, round(l.area_m2), l.floor,
|
||||
CASE WHEN l.price_rub IS NULL OR l.price_rub <= 0 THEN NULL
|
||||
ELSE round(l.price_rub / 100000.0) END)) FILTER (
|
||||
WHERE l.listing_segment = 'vtorichka'::text
|
||||
AND l.last_seen_at >= (now() - interval '45 days')
|
||||
AND (l.geom IS NULL OR hg.geom IS NULL OR ST_DistanceSphere(l.geom, hg.geom) <= 300)
|
||||
AND (l.total_floors IS NULL OR COALESCE(hg.zhkh_floors, hg.total_floors) IS NULL OR abs(l.total_floors - COALESCE(hg.zhkh_floors, hg.total_floors)) <= 3)
|
||||
) AS listings_45d,
|
||||
percentile_cont(0.5::double precision) WITHIN GROUP (ORDER BY (l.price_rub::double precision))
|
||||
FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND l.price_rub IS NOT NULL AND (l.geom IS NULL OR hg.geom IS NULL OR ST_DistanceSphere(l.geom, hg.geom) <= 300) AND (l.total_floors IS NULL OR COALESCE(hg.zhkh_floors, hg.total_floors) IS NULL OR abs(l.total_floors - COALESCE(hg.zhkh_floors, hg.total_floors)) <= 3)) AS median_price_rub,
|
||||
percentile_cont(0.5::double precision) WITHIN GROUP (ORDER BY (l.price_per_m2::double precision))
|
||||
FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND l.price_per_m2 IS NOT NULL AND (l.geom IS NULL OR hg.geom IS NULL OR ST_DistanceSphere(l.geom, hg.geom) <= 300) AND (l.total_floors IS NULL OR COALESCE(hg.zhkh_floors, hg.total_floors) IS NULL OR abs(l.total_floors - COALESCE(hg.zhkh_floors, hg.total_floors)) <= 3)) AS median_price_per_m2,
|
||||
avg(l.days_on_market)
|
||||
FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND l.days_on_market IS NOT NULL AND (l.geom IS NULL OR hg.geom IS NULL OR ST_DistanceSphere(l.geom, hg.geom) <= 300) AND (l.total_floors IS NULL OR COALESCE(hg.zhkh_floors, hg.total_floors) IS NULL OR abs(l.total_floors - COALESCE(hg.zhkh_floors, hg.total_floors)) <= 3)) AS avg_days_on_market,
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY l.total_floors)
|
||||
FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND (l.geom IS NULL OR hg.geom IS NULL OR ST_DistanceSphere(l.geom, hg.geom) <= 300) AND (l.total_floors IS NULL OR COALESCE(hg.zhkh_floors, hg.total_floors) IS NULL OR abs(l.total_floors - COALESCE(hg.zhkh_floors, hg.total_floors)) <= 3)) AS listings_med_floors
|
||||
FROM listings l
|
||||
JOIN houses hg ON hg.id = l.house_id_fk
|
||||
WHERE l.house_id_fk IS NOT NULL
|
||||
GROUP BY l.house_id_fk
|
||||
)
|
||||
SELECT h.id AS house_id,
|
||||
h.short_address,
|
||||
h.full_address,
|
||||
h.address,
|
||||
h.lat,
|
||||
h.lon,
|
||||
h.year_built,
|
||||
h.house_type,
|
||||
h.total_floors,
|
||||
h.series_name,
|
||||
h.is_emergency,
|
||||
COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0)) AS flat_count_effective,
|
||||
h.gar_flat_count,
|
||||
h.gar_match_method,
|
||||
la.active_secondary,
|
||||
la.median_price_rub,
|
||||
la.median_price_per_m2,
|
||||
la.avg_days_on_market,
|
||||
CASE
|
||||
WHEN COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))
|
||||
>= GREATEST(COALESCE(h.total_floors, 0), COALESCE(la.listings_med_floors, 0)::int, 8)
|
||||
AND la.active_secondary <= COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))
|
||||
THEN round(100.0 * la.active_secondary::numeric
|
||||
/ COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))::numeric, 1)
|
||||
ELSE NULL::numeric
|
||||
END AS sale_share_pct,
|
||||
la.listings_45d,
|
||||
CASE
|
||||
WHEN COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))
|
||||
>= GREATEST(COALESCE(h.total_floors, 0), COALESCE(la.listings_med_floors, 0)::int, 8)
|
||||
AND la.listings_45d <= COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))
|
||||
THEN round(100.0 * la.listings_45d::numeric
|
||||
/ COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))::numeric, 1)
|
||||
ELSE NULL::numeric
|
||||
END AS sale_share_pct_45d,
|
||||
h.zhkh_flat_count,
|
||||
CASE
|
||||
WHEN h.zhkh_flat_count IS NOT NULL THEN 'zhkh'
|
||||
WHEN h.gar_flat_count IS NOT NULL THEN 'gar'
|
||||
WHEN NULLIF(h.total_units, 0) IS NOT NULL THEN 'total_units'
|
||||
WHEN NULLIF(h.flat_count, 0) IS NOT NULL THEN 'flat_count'
|
||||
ELSE NULL::text
|
||||
END AS flat_count_source
|
||||
FROM houses h
|
||||
JOIN listing_agg la ON la.house_id = h.id
|
||||
WHERE h.geom IS NOT NULL AND (la.active_secondary > 0 OR la.listings_45d > 0);
|
||||
|
||||
COMMENT ON VIEW v_building_sale_share IS
|
||||
'Per-building rollup вторички для «доли квартир дома в продаже» (мигр. 143; знаменатель — '
|
||||
'ГАР canon-match мигр. 144; 2-й источник ЖКХ + окно 45д мигр. 146; дедуп кросс-площадочных '
|
||||
'дублей мигр. 148 + price_bucket мигр. 189; ЖКХ-приоритет знаменателя мигр. 149; гео-фильтр '
|
||||
'числителя ≤300м мигр. 150). flat_count_effective = '
|
||||
'COALESCE(zhkh_flat_count, gar_flat_count, NULLIF(total_units,0), NULLIF(flat_count,0)) — '
|
||||
'ЖКХ ПРИОРИТЕТ (ГИС ЖКХ точнее ГАР, который дико недосчитывает квартиры в МКД; мигр. 149). '
|
||||
'Колонки zhkh_flat_count (сырой ЖКХ-счёт) + flat_count_source (zhkh|gar|total_units|flat_count|'
|
||||
'NULL — какой источник реально дал знаменатель) добавлены для прозрачности. Оба числителя '
|
||||
'считают УНИКАЛЬНЫЕ КВАРТИРЫ по сигнатуре count(DISTINCT (rooms, round(area_m2), floor, '
|
||||
'price_bucket)), где price_bucket = round(price_rub/100000) ИЛИ NULL при price_rub NULL/<=0 '
|
||||
'(мигр. 189: одна тройка rooms/area/floor не отличает соседние квартиры на одном этаже в разных '
|
||||
'подъездах — совпадение ЕЩЁ И по цене резко снижает false-merge; NULL-цена не схлопывается ни с '
|
||||
'чем, считается отдельно — та же партиально-NULL философия, что и в мигр. 148 для rooms/area/'
|
||||
'floor, и что в estimator.py::_lot_dedup_components для физ-дедупа аналогов). Требование '
|
||||
'«разных площадок» из продуктового решения НЕ выражено в SQL (потребовало бы двухуровневой '
|
||||
'агрегации across всех 6 FILTER-агрегатов CTE) — residual risk: однисточниковые группы с '
|
||||
'совпавшей ценой остаются ложно схлопнуты; кросс-посты с ценовым дрейфом между скрейпами '
|
||||
'перестают схлопываться (см. комментарий мигр. 189 в файле). active_secondary = FILTER '
|
||||
'(is_active AND vtorichka); listings_45d = FILTER (vtorichka AND last_seen_at>=now()-45d). '
|
||||
'sale_share_pct = active_secondary/denom; sale_share_pct_45d = listings_45d/denom. Оба под '
|
||||
'плаузибилити-гейтом (denom>=GREATEST(total_floors, листинговая-медианная-этажность, 8) AND '
|
||||
'числитель<=denom; мигр. 145 + 153), иначе NULL. Фильтр: geom NOT NULL AND (active_secondary>0 '
|
||||
'OR listings_45d>0) — churn-only дома тоже видны. active_secondary/listings_45d/медианы цены и '
|
||||
'срока считают ТОЛЬКО листинги ≤300м от geom своего дома (мигр. 150) с floors-guard ±3 (мигр. '
|
||||
'152). Листинги/дома без geom — кепим. Знаменатель НЕ изменён мигр. 189.';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
-- Migration 191: account_quota_overrides.unlimited — безлимит как данные, не хардкод
|
||||
--
|
||||
-- WHY:
|
||||
-- app.services.account_quota.is_unlimited() до этой миграции проверял ровно два
|
||||
-- условия: роль admin ИЛИ literal `username == 'kopylov'` — захардкоженное сравнение
|
||||
-- строки прямо в коде. Восстановление пилота praktika (ГК «Практика», доступ вернул
|
||||
-- владелец продукта 2026-07-27 — см. auth/roles.yaml) с безлимитным грантом сделало
|
||||
-- бы это хардкодом ВТОРОГО имени: не масштабируется (каждый следующий безлимитный
|
||||
-- клиент требовал бы code-change + review + deploy вместо data-change) и плохо само
|
||||
-- по себе как паттерн (магическая строка вместо конфигурируемых данных).
|
||||
--
|
||||
-- unlimited — отдельная boolean-колонка, а не sentinel-значение monthly_limit
|
||||
-- (-1 / 0): `0` неоднозначен («ноль оценок в месяц» vs «без лимита»), explicit
|
||||
-- boolean честнее и не требует специального парсинга в user_limit()/is_unlimited().
|
||||
--
|
||||
-- kopylov ПЕРЕНЕСЁН в данные этой же миграцией (хардкод в коде убран, не оставлен
|
||||
-- параллельно) — единый источник правды для non-admin unlimited-аккаунтов вместо
|
||||
-- двух параллельных механизмов (код-константа + таблица). Порядок деплоя
|
||||
-- (SQL-миграция применяется РАНЬШЕ, чем стартует новый код — см.
|
||||
-- .claude/rules/sql.md "Migration order") гарантирует, что строка kopylov уже в
|
||||
-- таблице к моменту, когда новый is_unlimited() (без хардкода) начинает работать —
|
||||
-- поведение kopylov не меняется ни на секунду простоя.
|
||||
--
|
||||
-- WHAT:
|
||||
-- 1. account_quota_overrides.unlimited boolean NOT NULL DEFAULT false.
|
||||
-- 2. Seed: kopylov (перенос хардкода) + praktika (новый грант, пилот восстановлен
|
||||
-- 2026-07-27) — оба unlimited=true. monthly_limit=999999 — placeholder: get_status()
|
||||
-- безусловно читает monthly_limit через user_limit() даже для unlimited-аккаунтов
|
||||
-- (чтобы вернуть какое-то "limit" поле в /quota), а само enforcement для unlimited
|
||||
-- обходит этот лимит (is_unlimited() гейтит раньше в check_and_raise()/increment()).
|
||||
-- Значение просто не должно выглядеть абсурдным, если когда-либо surfaced напрямую.
|
||||
--
|
||||
-- IDEMPOTENCY:
|
||||
-- ALTER TABLE ... ADD COLUMN IF NOT EXISTS (новая колонка) + INSERT ... ON CONFLICT
|
||||
-- DO UPDATE (повторный прогон сходится к тому же состоянию, не дублирует строки).
|
||||
--
|
||||
-- Dependencies: 185_account_quota_overrides.sql (создаёт account_quota_overrides).
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE account_quota_overrides
|
||||
ADD COLUMN IF NOT EXISTS unlimited boolean NOT NULL DEFAULT false;
|
||||
|
||||
COMMENT ON COLUMN account_quota_overrides.unlimited IS
|
||||
'Безлимитный грант (нет месячного лимита оценок) — читается '
|
||||
'app.services.account_quota.is_unlimited(). Заменяет прежний хардкод username в коде.';
|
||||
|
||||
INSERT INTO account_quota_overrides (username, monthly_limit, unlimited, note)
|
||||
VALUES ('kopylov', 999999, true,
|
||||
'Личный аккаунт — безлимит перенесён из хардкода is_unlimited() в данные '
|
||||
'(migration 191, 2026-07-27), поведение не изменилось')
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
monthly_limit = EXCLUDED.monthly_limit,
|
||||
unlimited = EXCLUDED.unlimited,
|
||||
note = EXCLUDED.note,
|
||||
updated_at = now();
|
||||
|
||||
INSERT INTO account_quota_overrides (username, monthly_limit, unlimited, note)
|
||||
VALUES ('praktika', 999999, true,
|
||||
'ГК «Практика» — пилот восстановлен 2026-07-27 (решение владельца продукта), '
|
||||
'безлимитный грант')
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
monthly_limit = EXCLUDED.monthly_limit,
|
||||
unlimited = EXCLUDED.unlimited,
|
||||
note = EXCLUDED.note,
|
||||
updated_at = now();
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -176,3 +176,31 @@
|
|||
169_osm_poi_ekb_local.sql
|
||||
170_scrape_schedules_seed_osm_poi_ekb_refresh.sql
|
||||
172_trade_in_leads.sql
|
||||
173_scrape_proxies_add_domclick_affinity.sql
|
||||
174_domclick_session_cookies.sql
|
||||
175_scrape_schedules_seed_domclick_detail_backfill.sql
|
||||
176_domrf_kapremont.sql
|
||||
177_deals_city_region.sql
|
||||
178_deal_city_price_bands.sql
|
||||
179_scrape_schedules_seed_oblast_city_sweeps.sql
|
||||
180_seed_sber_freshness_monitor.sql
|
||||
181_clamp_bad_listing_dates.sql
|
||||
182_trade_in_leads_consent_proof.sql
|
||||
183_reenable_deactivate_stale_domklik.sql
|
||||
184_user_events.sql
|
||||
185_account_quota_overrides.sql
|
||||
186_tg_support.sql
|
||||
#
|
||||
# 187_web_support_chat.sql / 188_tg_support_chat_id_scope.sql — НАМЕРЕННО НЕ
|
||||
# добавлены (2026-07-27, devops-аудит). Прецедент из ЭТОГО же репо:
|
||||
# commit 5eadae1e (fix(tradein/support): address deep-review ... L5) добавил
|
||||
# и тут же убрал "187_web_support_chat.sql" из этого файла с формулировкой
|
||||
# "keeping an unmerged migration name out of it preserves the option to
|
||||
# rename before merge without tripping the "can't rename applied
|
||||
# migrations" test". Обе миграции — часть веб-чата поддержки (#2532/#2533),
|
||||
# который на момент этой правки ещё активно дорабатывается в параллельной
|
||||
# сессии/окне (тот же фиче-набор, соседняя задача). Дописывать их сюда сейчас
|
||||
# повторило бы именно ту ошибку, которую L5 исправил: заморозить имя файла
|
||||
# ДО того как он гарантированно осел на проде в финальном виде. Когда фича
|
||||
# стабилизируется и подтверждено, что 187/188 применены (_schema_migrations
|
||||
# на проде) — дописать одной строкой в отдельном PR.
|
||||
|
|
|
|||
|
|
@ -86,6 +86,9 @@ USAGE
|
|||
|
||||
# machine-readable:
|
||||
python -m scripts.backtest_estimator --json
|
||||
|
||||
# oblast D: per-city validation (exact deals.city name, not a slug):
|
||||
python -m scripts.backtest_estimator --city "Нижний Тагил" --sample 300
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -181,6 +184,14 @@ logger = logging.getLogger("backtest_estimator")
|
|||
# Price-per-m² sanity band — shared by the deal sample and the listings
|
||||
# subquery. Mirrors the estimator's working range for EKB вторичка and drops
|
||||
# obvious data-entry garbage / commercial outliers.
|
||||
# (oblast D) When --city is set, _load_sample sources a PER-CITY band from
|
||||
# deal_city_price_bands instead (see _resolve_city_ppm2_band) — these globals
|
||||
# remain the fallback (default/unscoped sample, and any city with no band row,
|
||||
# e.g. Екатеринбург — intentionally excluded from that table).
|
||||
# NB: the listings subquery (_CANDIDATES_SQL, asking-core engine only) still
|
||||
# always uses these globals — left global deliberately (not city-scoped) to
|
||||
# keep the change minimal; the asking-core engine is the legacy comparison
|
||||
# path, not the primary oblast-D target (the full engine, default).
|
||||
PPM2_MIN = 30_000
|
||||
PPM2_MAX = 600_000
|
||||
|
||||
|
|
@ -1043,22 +1054,115 @@ _CANDIDATES_SQL = text(
|
|||
"""
|
||||
)
|
||||
|
||||
# (oblast D) Per-city PPM2 sanity band — same table the estimator's
|
||||
# _fetch_dkp_corridor COALESCEs against (migration 178). Looked up only when
|
||||
# --city is set (see _resolve_city_ppm2_band); the default (city=None) path
|
||||
# never issues this query.
|
||||
_CITY_PPM2_BAND_SQL = text(
|
||||
"""
|
||||
SELECT ppm2_min, ppm2_max
|
||||
FROM deal_city_price_bands
|
||||
WHERE city = CAST(:city AS text)
|
||||
"""
|
||||
)
|
||||
|
||||
def _load_sample(db: Session, *, sample: int, since: str) -> list[DealSample]:
|
||||
"""Run the held-out ДКП deal sampling SELECT → list[DealSample]."""
|
||||
rows = (
|
||||
db.execute(
|
||||
_SAMPLE_SQL,
|
||||
{
|
||||
"ppm2_min": PPM2_MIN,
|
||||
"ppm2_max": PPM2_MAX,
|
||||
|
||||
def _sample_sql(city: str | None) -> Any:
|
||||
"""ДКП deal-sample SELECT — optionally scoped to one ``deals.city`` (oblast D).
|
||||
|
||||
``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object used
|
||||
before this change — literal identity, not just equal text — so the
|
||||
default CLI invocation (and the frozen EKB regression gate, which never
|
||||
calls this path at all) see byte-identical SQL.
|
||||
|
||||
When ``city`` is set, an extra ``AND city = CAST(:city AS text)`` predicate
|
||||
scopes the sample to that ``deals.city`` value — populated oblast-wide by
|
||||
the #C1 migration (368 cities, 96,974 deals). Pass the EXACT canonical
|
||||
Russian name as stored in ``deals.city`` (e.g. ``'Нижний Тагил'``), not a
|
||||
slug — see the ``--city`` CLI help for the naming decision.
|
||||
"""
|
||||
if city is None:
|
||||
return _SAMPLE_SQL
|
||||
return text(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
ST_X(geom::geometry) AS lon,
|
||||
ST_Y(geom::geometry) AS lat,
|
||||
rooms,
|
||||
price_per_m2 AS sold_ppm2,
|
||||
deal_date,
|
||||
area_m2,
|
||||
address,
|
||||
floor,
|
||||
total_floors,
|
||||
year_built,
|
||||
house_type
|
||||
FROM deals
|
||||
WHERE source = 'rosreestr'
|
||||
AND geom IS NOT NULL
|
||||
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
||||
AND rooms IS NOT NULL
|
||||
AND area_m2 IS NOT NULL
|
||||
AND area_m2 > 0
|
||||
AND deal_date >= CAST(:since AS date)
|
||||
AND city = CAST(:city AS text)
|
||||
ORDER BY id DESC
|
||||
LIMIT CAST(:sample AS integer)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _resolve_city_ppm2_band(db: Session, city: str | None) -> tuple[float, float]:
|
||||
"""Per-city PPM2 sanity band (oblast D) — falls back to the module globals.
|
||||
|
||||
``city is None`` → ``(PPM2_MIN, PPM2_MAX)``, unchanged default behaviour,
|
||||
NO extra query. Otherwise looks up ``deal_city_price_bands`` for that
|
||||
city's own ``[ppm2_min, ppm2_max]`` — a region-66 town can have a
|
||||
materially different sane ₽/m² range than the EKB-tuned 30k..600k globals
|
||||
(e.g. Нижний Тагил: 16 955..108 175). No row for the city (e.g.
|
||||
Екатеринбург — intentionally excluded from the table, mirrors
|
||||
estimator._fetch_dkp_corridor's own COALESCE fallback) or any DB error →
|
||||
the globals; read-only best-effort, never raises.
|
||||
"""
|
||||
if city is None:
|
||||
return float(PPM2_MIN), float(PPM2_MAX)
|
||||
try:
|
||||
row = db.execute(_CITY_PPM2_BAND_SQL, {"city": city}).mappings().first()
|
||||
except Exception as exc: # pragma: no cover — defensive, read-only best-effort
|
||||
logger.warning("city PPM2 band lookup failed for %r (fallback to global): %s", city, exc)
|
||||
return float(PPM2_MIN), float(PPM2_MAX)
|
||||
if row is None or row["ppm2_min"] is None or row["ppm2_max"] is None:
|
||||
return float(PPM2_MIN), float(PPM2_MAX)
|
||||
return float(row["ppm2_min"]), float(row["ppm2_max"])
|
||||
|
||||
|
||||
def _load_sample(
|
||||
db: Session, *, sample: int, since: str, city: str | None = None
|
||||
) -> list[DealSample]:
|
||||
"""Run the held-out ДКП deal sampling SELECT → list[DealSample].
|
||||
|
||||
``city`` (oblast D, default None) scopes the sample to one ``deals.city``
|
||||
value via ``_sample_sql`` and sources the PPM2 sanity band from
|
||||
``deal_city_price_bands`` for that city (``_resolve_city_ppm2_band``,
|
||||
falls back to the module globals). Default None is byte-identical to the
|
||||
pre-oblast-D behaviour: same SQL object, same PPM2_MIN/PPM2_MAX globals,
|
||||
no extra query.
|
||||
"""
|
||||
if city is None:
|
||||
ppm2_min: float = PPM2_MIN
|
||||
ppm2_max: float = PPM2_MAX
|
||||
else:
|
||||
ppm2_min, ppm2_max = _resolve_city_ppm2_band(db, city)
|
||||
params: dict[str, Any] = {
|
||||
"ppm2_min": ppm2_min,
|
||||
"ppm2_max": ppm2_max,
|
||||
"since": since,
|
||||
"sample": sample,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
}
|
||||
if city is not None:
|
||||
params["city"] = city
|
||||
rows = db.execute(_sample_sql(city), params).mappings().all()
|
||||
out: list[DealSample] = []
|
||||
for r in rows:
|
||||
if r["lon"] is None or r["lat"] is None or r["sold_ppm2"] is None:
|
||||
|
|
@ -1516,11 +1620,22 @@ def _predict_full_spine(
|
|||
)
|
||||
|
||||
# ── Pre-fetch the spine inputs (same calls estimate_quality hoists) ───────
|
||||
dkp_raw = m._fetch_dkp_corridor(db, address=deal.address, rooms=deal.rooms, area=deal.area_m2)
|
||||
# (oblast C2 parity): estimate_quality now resolves a target city and passes
|
||||
# it to _fetch_dkp_corridor (estimator.py:3210-3220) — deals.city is
|
||||
# populated oblast-wide (368 cities), and an unscoped corridor lets
|
||||
# same-named streets in OTHER region-66 towns (e.g. "Ленина" exists in many)
|
||||
# contaminate the corridor. Mirror that resolve+pass here UNCONDITIONALLY
|
||||
# (independent of --city, which only scopes WHICH deals get sampled) so this
|
||||
# harness measures the SAME corridor prod actually computes today, not the
|
||||
# pre-C2 unscoped behaviour — else the backtest validates stale semantics.
|
||||
target_city = m._resolve_target_city(deal.address)
|
||||
dkp_raw = m._fetch_dkp_corridor(
|
||||
db, address=deal.address, rooms=deal.rooms, area=deal.area_m2, city=target_city
|
||||
)
|
||||
# #1966 prod parity: same-building anchor pre-fetch is GATED exactly like
|
||||
# estimate_quality (estimator.py L2862-2881) — disabled / no-area / no-address
|
||||
# → ([], None) instead of an unconditional fetch.
|
||||
if settings.estimate_same_building_anchor_enabled and deal.area_m2 and deal.address:
|
||||
# estimate_quality — no-area / no-address → ([], None) instead of an
|
||||
# unconditional fetch.
|
||||
if deal.area_m2 and deal.address:
|
||||
# NB(#2002): kept at None deliberately — the deliverable scopes the
|
||||
# resolved-id threading to the Tier-S analog ladder + the house IMV anchor.
|
||||
# Prod ALSO resolves this same-building anchor (estimator.py:2782); flip to
|
||||
|
|
@ -1733,6 +1848,7 @@ def run_backtest(
|
|||
radius: int,
|
||||
rooms_tolerance: int,
|
||||
holdout_split: bool = False,
|
||||
city: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Drive the full read-only backtest and return a metrics dict.
|
||||
|
||||
|
|
@ -1747,9 +1863,13 @@ def run_backtest(
|
|||
so its bias is near-zero by construction — it proves the MECHANISM, not
|
||||
out-of-sample accuracy (see _derive_room_ratios). Pass ``holdout_split=True``
|
||||
to fit on even-id deals and evaluate on the odd-id half for an honest number.
|
||||
|
||||
``city`` (oblast D, default None) scopes the deal sample to one
|
||||
``deals.city`` value — see ``_load_sample``. Default None is unscoped
|
||||
(byte-identical to the pre-oblast-D behaviour).
|
||||
"""
|
||||
deals = _load_sample(db, sample=sample, since=since)
|
||||
logger.info("loaded sample: %d ДКП deals (since=%s)", len(deals), since)
|
||||
deals = _load_sample(db, sample=sample, since=since, city=city)
|
||||
logger.info("loaded sample: %d ДКП deals (since=%s, city=%s)", len(deals), since, city)
|
||||
|
||||
matched_rows: list[tuple[float, float, int]] = []
|
||||
matched_ids: list[int] = []
|
||||
|
|
@ -1809,6 +1929,7 @@ def run_backtest(
|
|||
"n_matched": len(matched_rows),
|
||||
"n_no_analogs": n_no_analogs,
|
||||
"holdout_split": holdout_split,
|
||||
"city": city,
|
||||
}
|
||||
return metrics
|
||||
|
||||
|
|
@ -1820,6 +1941,7 @@ def run_backtest_full(
|
|||
since: str,
|
||||
dump_fixture: str | None = None,
|
||||
resolve_house_id: bool = False,
|
||||
city: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Drive the FULL-spine read-only backtest and return a metrics dict (#1966).
|
||||
|
||||
|
|
@ -1844,10 +1966,18 @@ def run_backtest_full(
|
|||
the Tier-S analog ladder + the house Avito-IMV anchor, mirroring prod, and a
|
||||
``house_id_resolution`` coverage block (resolved / total / imv_reachable) is
|
||||
attached to the returned metrics. Default False → byte-identical prior output.
|
||||
|
||||
``city`` (oblast D, default None) scopes the deal sample to one
|
||||
``deals.city`` value — see ``_load_sample``. Default None is unscoped
|
||||
(byte-identical to the pre-oblast-D behaviour). Independently of this flag,
|
||||
``_predict_full_spine`` ALWAYS resolves + passes a per-deal target city to
|
||||
``_fetch_dkp_corridor`` (oblast C2 parity fix) — see its docstring.
|
||||
"""
|
||||
est = _import_estimator_full()
|
||||
deals = _load_sample(db, sample=sample, since=since)
|
||||
logger.info("loaded sample: %d ДКП deals (since=%s) [full spine]", len(deals), since)
|
||||
deals = _load_sample(db, sample=sample, since=since, city=city)
|
||||
logger.info(
|
||||
"loaded sample: %d ДКП deals (since=%s, city=%s) [full spine]", len(deals), since, city
|
||||
)
|
||||
|
||||
predictions: list[Prediction] = []
|
||||
n_no_prediction = 0
|
||||
|
|
@ -1922,6 +2052,7 @@ def run_backtest_full(
|
|||
"n_matched": len(predictions),
|
||||
"n_no_prediction": n_no_prediction,
|
||||
"price_segments_ppm2": [list(seg) for seg in _price_segments()],
|
||||
"city": city,
|
||||
}
|
||||
|
||||
# #2002: house_id resolution coverage — the key Tier-S + IMV reach number.
|
||||
|
|
@ -2011,6 +2142,20 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||
default="2025-06-01",
|
||||
help="Only deals with deal_date >= this ISO date (default 2025-06-01).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--city",
|
||||
default=None,
|
||||
metavar="NAME",
|
||||
help="Oblast D: scope the held-out ДКП deal sample to ONE deals.city value "
|
||||
"(region 66 is oblast-wide — deals.city covers 368 cities, 96,974 deals). "
|
||||
"Pass the EXACT canonical Russian name as stored in deals.city, e.g. "
|
||||
"'Нижний Тагил' or 'Каменск-Уральский' — NOT a slug/transliteration (run "
|
||||
"`SELECT DISTINCT city FROM deals` to check the exact spelling). Also "
|
||||
"sources the deal-sample PPM2 sanity band from deal_city_price_bands for "
|
||||
"that city (falls back to the global PPM2_MIN/PPM2_MAX when the city has "
|
||||
"no band row, e.g. Екатеринбург — intentionally excluded from that table). "
|
||||
"Default None → unscoped sample, byte-identical to the pre-oblast-D SQL.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--radius",
|
||||
type=int,
|
||||
|
|
@ -2115,7 +2260,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||
|
||||
logger.info(
|
||||
"backtest start: engine=%s sample=%d since=%s radius=%dm "
|
||||
"rooms_tolerance=%d holdout_split=%s dump_fixture=%s resolve_house_id=%s",
|
||||
"rooms_tolerance=%d holdout_split=%s dump_fixture=%s resolve_house_id=%s city=%s",
|
||||
args.engine,
|
||||
args.sample,
|
||||
args.since,
|
||||
|
|
@ -2124,6 +2269,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||
args.holdout_split,
|
||||
args.dump_fixture,
|
||||
args.resolve_house_id,
|
||||
args.city,
|
||||
)
|
||||
|
||||
db = _session()
|
||||
|
|
@ -2135,6 +2281,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||
since=args.since,
|
||||
dump_fixture=args.dump_fixture,
|
||||
resolve_house_id=args.resolve_house_id,
|
||||
city=args.city,
|
||||
)
|
||||
else:
|
||||
metrics = run_backtest(
|
||||
|
|
@ -2144,6 +2291,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||
radius=args.radius,
|
||||
rooms_tolerance=args.rooms_tolerance,
|
||||
holdout_split=args.holdout_split,
|
||||
city=args.city,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
|
|
|||
18
tradein-mvp/backend/tests/conftest.py
Normal file
18
tradein-mvp/backend/tests/conftest.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
"""Repo-wide test config for tradein-mvp/backend.
|
||||
|
||||
Currently only registers custom pytest markers so they don't emit
|
||||
PytestUnknownMarkWarning when used (`--strict-markers` is not enabled in
|
||||
pyproject.toml, so an unregistered marker would only warn, not fail — this
|
||||
just keeps output clean and documents intent in one place).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def pytest_configure(config) -> None:
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"pdf_render: real (non-mocked) WeasyPrint render — needs native "
|
||||
"Pango/cairo/GObject libs, self-skips where unavailable (see "
|
||||
"tests/test_pdf_real_render.py docstring for how to run it for real).",
|
||||
)
|
||||
|
|
@ -7,16 +7,16 @@
|
|||
"n_covered": 0
|
||||
},
|
||||
"low": {
|
||||
"coverage_pct": 81.82,
|
||||
"mape_pct": 13.23,
|
||||
"n": 275,
|
||||
"n_covered": 225
|
||||
"coverage_pct": 81.88,
|
||||
"mape_pct": 13.25,
|
||||
"n": 276,
|
||||
"n_covered": 226
|
||||
},
|
||||
"medium": {
|
||||
"coverage_pct": 100.0,
|
||||
"mape_pct": 14.64,
|
||||
"n": 2,
|
||||
"n_covered": 2
|
||||
"mape_pct": 6.99,
|
||||
"n": 1,
|
||||
"n_covered": 1
|
||||
}
|
||||
},
|
||||
"confidence_order": [
|
||||
|
|
@ -136,14 +136,14 @@
|
|||
"n_covered": 0
|
||||
},
|
||||
"low": {
|
||||
"coverage_pct": 81.82,
|
||||
"n": 275,
|
||||
"n_covered": 225
|
||||
"coverage_pct": 81.88,
|
||||
"n": 276,
|
||||
"n_covered": 226
|
||||
},
|
||||
"medium": {
|
||||
"coverage_pct": 100.0,
|
||||
"n": 2,
|
||||
"n_covered": 2
|
||||
"n": 1,
|
||||
"n_covered": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
60
tradein-mvp/backend/tests/scrapers/test_base_clamp_date.py
Normal file
60
tradein-mvp/backend/tests/scrapers/test_base_clamp_date.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""LOW audit R2 (#6) — `_clamp_plausible_date` в `scraper_kit.base`.
|
||||
|
||||
Choke point для `listing_date`/`publish_date` в `save_listings()` (единый для
|
||||
avito/cian/yandex/domclick). Проверено в БД: avito пишет 1970-01-01
|
||||
(epoch-sentinel, ~698 строк) и даты до +11 дней в будущем (~79 строк); у
|
||||
yandex — единичные аномально старые годы. Правдоподобный диапазон —
|
||||
[2010-01-01, today+2d]; всё остальное → None (колонка nullable).
|
||||
|
||||
Офлайн: без сети/curl_cffi/БД, только импорт + чистая функция.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import date, timedelta
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
|
||||
from scraper_kit.base import _clamp_plausible_date
|
||||
|
||||
|
||||
def test_avito_epoch_sentinel_rejected() -> None:
|
||||
"""1970-01-01 — классический Avito 0-epoch sentinel."""
|
||||
assert _clamp_plausible_date(date(1970, 1, 1)) is None
|
||||
|
||||
|
||||
def test_just_before_min_plausible_rejected() -> None:
|
||||
assert _clamp_plausible_date(date(2009, 12, 31)) is None
|
||||
|
||||
|
||||
def test_far_future_rejected() -> None:
|
||||
assert _clamp_plausible_date(date(2099, 1, 1)) is None
|
||||
|
||||
|
||||
def test_none_passes_through_as_none() -> None:
|
||||
assert _clamp_plausible_date(None) is None
|
||||
|
||||
|
||||
def test_normal_recent_date_passes_through() -> None:
|
||||
d = date(2024, 6, 1)
|
||||
assert _clamp_plausible_date(d) == d
|
||||
|
||||
|
||||
def test_today_passes() -> None:
|
||||
today = date.today()
|
||||
assert _clamp_plausible_date(today) == today
|
||||
|
||||
|
||||
def test_today_plus_one_day_passes() -> None:
|
||||
d = date.today() + timedelta(days=1)
|
||||
assert _clamp_plausible_date(d) == d
|
||||
|
||||
|
||||
def test_today_plus_three_days_rejected() -> None:
|
||||
assert _clamp_plausible_date(date.today() + timedelta(days=3)) is None
|
||||
|
||||
|
||||
def test_min_plausible_boundary_passes() -> None:
|
||||
"""2010-01-01 сам по себе — граница включительно (в окне)."""
|
||||
assert _clamp_plausible_date(date(2010, 1, 1)) == date(2010, 1, 1)
|
||||
|
|
@ -652,6 +652,55 @@ async def test_suggest_addresses_sends_proper_request() -> None:
|
|||
assert captured["x_secret"] is None
|
||||
|
||||
|
||||
async def test_suggest_addresses_region_body_shape_no_restrict_value() -> None:
|
||||
"""region= constrains `locations` by region, WITHOUT a restrict_value key.
|
||||
|
||||
`restrict_value` is a top-level DaData body param (not a per-location dict
|
||||
key) — putting it inside the locations object would be a silent no-op.
|
||||
The region constraint in `locations` is already a hard filter on its own.
|
||||
"""
|
||||
from app.services import dadata
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
import json as _json
|
||||
|
||||
captured["body"] = _json.loads(request.content.decode("utf-8"))
|
||||
return httpx.Response(200, json=SAMPLE_SUGGEST_PAYLOAD)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with _patch_settings(token="my-token"), _patch_async_client(transport):
|
||||
await dadata.suggest_addresses("Ленина", limit=5, city=None, region="Свердловская область")
|
||||
|
||||
body = captured["body"]
|
||||
assert isinstance(body, dict)
|
||||
assert body["locations"] == [{"region": "Свердловская область"}]
|
||||
assert "restrict_value" not in body["locations"][0]
|
||||
assert "restrict_value" not in body
|
||||
|
||||
|
||||
async def test_suggest_addresses_region_overrides_city() -> None:
|
||||
"""Когда задан и `city`, и `region` — region побеждает, city игнорируется."""
|
||||
from app.services import dadata
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
import json as _json
|
||||
|
||||
captured["body"] = _json.loads(request.content.decode("utf-8"))
|
||||
return httpx.Response(200, json=SAMPLE_SUGGEST_PAYLOAD)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with _patch_settings(), _patch_async_client(transport):
|
||||
await dadata.suggest_addresses("Ленина", city="Екатеринбург", region="Свердловская область")
|
||||
|
||||
body = captured["body"]
|
||||
assert isinstance(body, dict)
|
||||
assert body["locations"] == [{"region": "Свердловская область"}]
|
||||
|
||||
|
||||
async def test_suggest_addresses_clamps_limit() -> None:
|
||||
"""count должен быть clamped в [1, 20] — DaData ограничение."""
|
||||
from app.services import dadata
|
||||
|
|
@ -671,3 +720,168 @@ async def test_suggest_addresses_clamps_limit() -> None:
|
|||
body = captured["body"]
|
||||
assert isinstance(body, dict)
|
||||
assert body["count"] == 20 # clamped to max
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# #dadata-403 — clean feature-disabled log + enrich_address (clean → suggest fallback)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
import logging as _logging # noqa: E402
|
||||
|
||||
# Real prod 403 body when услуга CLEAN не подключена (не отклонённый токен).
|
||||
CLEAN_FEATURE_DISABLED_BODY = {
|
||||
"timestamp": "2026-07-12T16:37:54.585+00:00",
|
||||
"status": 403,
|
||||
"error": "Forbidden",
|
||||
"message": (
|
||||
"Feature 'CLEAN' disabled for token 'xxx'. "
|
||||
"See https://dadata.userecho.com/topics/7784 for help."
|
||||
),
|
||||
"path": "/api/v1/clean/address",
|
||||
}
|
||||
|
||||
# House-level suggest candidate carrying house_fias_id/kladr_id/qc_geo (что нужно fallback'у).
|
||||
SUGGEST_HOUSE_PAYLOAD = {
|
||||
"suggestions": [
|
||||
{
|
||||
"value": "г Екатеринбург, ул Малышева, д 4",
|
||||
"unrestricted_value": "620014, Свердловская обл, г Екатеринбург, ул Малышева, д 4",
|
||||
"data": {
|
||||
"geo_lat": "56.831463",
|
||||
"geo_lon": "60.580943",
|
||||
"fias_id": "bfa7153e-fc05-40a4-a86d-779e65faaade",
|
||||
"house_fias_id": "bfa7153e-fc05-40a4-a86d-779e65faaade",
|
||||
"kladr_id": "6600000100006480017",
|
||||
"qc_geo": "0",
|
||||
"fias_level": "8",
|
||||
"house": "4",
|
||||
"street": "Малышева",
|
||||
"city": "Екатеринбург",
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _mock_enrich_transport(
|
||||
clean_status: int, clean_body: object, suggest_body: object
|
||||
) -> httpx.MockTransport:
|
||||
"""Routes /clean vs /suggest by URL — enrich_address зовёт оба через один AsyncClient."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
url = str(request.url)
|
||||
if "cleaner.dadata.ru" in url:
|
||||
return httpx.Response(clean_status, json=clean_body)
|
||||
if "suggestions.dadata.ru" in url:
|
||||
return httpx.Response(200, json=suggest_body)
|
||||
return httpx.Response(404, json={"error": "unexpected url"})
|
||||
|
||||
return httpx.MockTransport(handler)
|
||||
|
||||
|
||||
async def test_clean_address_logs_feature_disabled_distinctly(caplog) -> None:
|
||||
"""403 «Feature CLEAN disabled» → None + сообщение про выключенную услугу (не про токен)."""
|
||||
from app.services import dadata
|
||||
|
||||
transport = _mock_transport_returning(403, CLEAN_FEATURE_DISABLED_BODY)
|
||||
with _patch_settings(), _patch_async_client(transport):
|
||||
with caplog.at_level(_logging.ERROR, logger="app.services.dadata"):
|
||||
result = await dadata.clean_address("Екатеринбург, Малышева 4")
|
||||
|
||||
assert result is None
|
||||
text = caplog.text
|
||||
assert "Стандартизация" in text or "выключена" in text
|
||||
# Не должны обвинять токен при feature-disabled.
|
||||
assert "auth/secret rejected" not in text
|
||||
|
||||
|
||||
async def test_clean_address_logs_real_auth_rejection_as_auth(caplog) -> None:
|
||||
"""401 (или 403 без 'disabled') → сообщение про креды."""
|
||||
from app.services import dadata
|
||||
|
||||
transport = _mock_transport_returning(401, {"message": "Unauthorized"})
|
||||
with _patch_settings(), _patch_async_client(transport):
|
||||
with caplog.at_level(_logging.ERROR, logger="app.services.dadata"):
|
||||
result = await dadata.clean_address("Екатеринбург, Малышева 4")
|
||||
|
||||
assert result is None
|
||||
assert "auth/secret rejected" in caplog.text
|
||||
|
||||
|
||||
async def test_suggest_addresses_captures_house_fias_kladr_qc() -> None:
|
||||
"""_parse_suggestion теперь тянет house_fias_id / kladr_id / qc_geo из data."""
|
||||
from app.services import dadata
|
||||
|
||||
transport = _mock_suggest_transport(200, SUGGEST_HOUSE_PAYLOAD)
|
||||
with _patch_settings(), _patch_async_client(transport):
|
||||
results = await dadata.suggest_addresses("Малышева 4")
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].house_fias_id == "bfa7153e-fc05-40a4-a86d-779e65faaade"
|
||||
assert results[0].kladr_id == "6600000100006480017"
|
||||
assert results[0].qc_geo == 0
|
||||
|
||||
|
||||
async def test_enrich_address_uses_clean_when_available() -> None:
|
||||
"""CLEAN 200 → берём его результат (canonical/cadnum), suggest не подменяет."""
|
||||
from app.services import dadata
|
||||
|
||||
transport = _mock_enrich_transport(200, SAMPLE_OK_PAYLOAD, {"suggestions": []})
|
||||
with _patch_settings(), _patch_async_client(transport):
|
||||
result = await dadata.enrich_address("Екатеринбург, ул. Малышева, 125")
|
||||
|
||||
assert result is not None
|
||||
assert result.canonical_address == "г Екатеринбург, ул Малышева, д 125"
|
||||
assert result.house_cadnum == "66:41:0704045:350"
|
||||
assert result.raw.get("_source") != "suggest_fallback"
|
||||
|
||||
|
||||
async def test_enrich_address_falls_back_to_suggest_when_clean_unavailable() -> None:
|
||||
"""CLEAN 403 disabled → suggest-fallback даёт house_fias_id/гео; canonical/cadnum=None."""
|
||||
from app.services import dadata
|
||||
|
||||
transport = _mock_enrich_transport(403, CLEAN_FEATURE_DISABLED_BODY, SUGGEST_HOUSE_PAYLOAD)
|
||||
with _patch_settings(), _patch_async_client(transport):
|
||||
result = await dadata.enrich_address("Екатеринбург, ул. Малышева, 4")
|
||||
|
||||
assert result is not None
|
||||
assert result.house_fias_id == "bfa7153e-fc05-40a4-a86d-779e65faaade"
|
||||
assert result.kladr_id == "6600000100006480017"
|
||||
assert result.qc_geo == 0
|
||||
assert result.lat is not None and abs(result.lat - 56.831463) < 1e-6
|
||||
# Честная деградация: без CLEAN нет canonical/cadnum — не выдумываем.
|
||||
assert result.canonical_address is None
|
||||
assert result.house_cadnum is None
|
||||
assert result.raw.get("_source") == "suggest_fallback"
|
||||
|
||||
|
||||
async def test_enrich_address_ignores_non_house_suggestions() -> None:
|
||||
"""Если suggest вернул только street/city (не house) → fallback не даёт house_fias_id → None."""
|
||||
from app.services import dadata
|
||||
|
||||
street_only = {
|
||||
"suggestions": [
|
||||
{
|
||||
"value": "г Екатеринбург, ул Малышева",
|
||||
"unrestricted_value": "Свердловская обл, г Екатеринбург, ул Малышева",
|
||||
"data": {"geo_lat": "56.83", "geo_lon": "60.58", "fias_id": "s", "fias_level": "7"},
|
||||
}
|
||||
]
|
||||
}
|
||||
transport = _mock_enrich_transport(403, CLEAN_FEATURE_DISABLED_BODY, street_only)
|
||||
with _patch_settings(), _patch_async_client(transport):
|
||||
result = await dadata.enrich_address("Екатеринбург, ул. Малышева")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_enrich_address_returns_none_when_clean_and_suggest_both_fail() -> None:
|
||||
"""CLEAN недоступен + suggest пустой → None (graceful)."""
|
||||
from app.services import dadata
|
||||
|
||||
transport = _mock_enrich_transport(403, CLEAN_FEATURE_DISABLED_BODY, {"suggestions": []})
|
||||
with _patch_settings(), _patch_async_client(transport):
|
||||
result = await dadata.enrich_address("Малышева 4")
|
||||
|
||||
assert result is None
|
||||
|
|
|
|||
112
tradein-mvp/backend/tests/services/test_image_sanitizer.py
Normal file
112
tradein-mvp/backend/tests/services/test_image_sanitizer.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"""Unit tests for `app.services.image_sanitizer.sanitize_image`.
|
||||
|
||||
Coverage:
|
||||
- happy path: a small RGBA PNG re-encodes to a clean JPEG (RGB, EXIF stripped)
|
||||
- resize path: an image above _MAX_DIMENSION is thumbnailed down, still decodes
|
||||
- pixel-flood DoS: an image declaring > _MAX_PIXELS is rejected via img.size
|
||||
BEFORE img.load() is ever called (no full decode → no OOM) — #2214
|
||||
- byte-size backstop: content over _MAX_BYTES rejected before Image.open
|
||||
- Pillow's own bomb threshold (MAX_IMAGE_PIXELS) is lowered to our cap
|
||||
- garbage bytes still raise ImageSanitizationError (regression guard)
|
||||
|
||||
The oversized-dimension case is asserted via monkeypatch rather than a real
|
||||
100 MP buffer: allocating the bomb is exactly the OOM we are defending against,
|
||||
so the test proves img.load() is NOT reached.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# DATABASE_URL required by config before any app import (см. sibling service tests).
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.services import image_sanitizer
|
||||
from app.services.image_sanitizer import (
|
||||
SANITIZED_CONTENT_TYPE,
|
||||
ImageSanitizationError,
|
||||
sanitize_image,
|
||||
)
|
||||
|
||||
|
||||
def _png_bytes(width: int, height: int, mode: str = "RGBA") -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new(mode, (width, height), color=(120, 60, 30, 255)[: len(mode)]).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_small_image_passes_and_becomes_jpeg() -> None:
|
||||
out, ctype = sanitize_image(_png_bytes(100, 80))
|
||||
assert ctype == SANITIZED_CONTENT_TYPE
|
||||
# Output is a valid JPEG, decoded back in RGB (transparency dropped).
|
||||
reloaded = Image.open(io.BytesIO(out))
|
||||
assert reloaded.format == "JPEG"
|
||||
assert reloaded.mode == "RGB"
|
||||
assert reloaded.size == (100, 80)
|
||||
|
||||
|
||||
def test_oversized_but_under_pixel_cap_is_resized() -> None:
|
||||
# 3000x2000 = 6 MP (< 40 MP cap) but longest edge > _MAX_DIMENSION → thumbnailed.
|
||||
out, _ = sanitize_image(_png_bytes(3000, 2000, mode="RGB"))
|
||||
reloaded = Image.open(io.BytesIO(out))
|
||||
assert max(reloaded.size) == image_sanitizer._MAX_DIMENSION
|
||||
assert reloaded.size == (2400, 1600)
|
||||
|
||||
|
||||
def test_pixel_flood_rejected_without_decode(monkeypatch) -> None:
|
||||
"""Image declaring > cap pixels is rejected via img.size, never img.load()."""
|
||||
fake_img = MagicMock()
|
||||
fake_img.size = (10_000, 10_000) # 100 MP >> 40 MP cap
|
||||
fake_img.__enter__.return_value = fake_img
|
||||
fake_img.__exit__.return_value = False
|
||||
monkeypatch.setattr(image_sanitizer.Image, "open", lambda *a, **k: fake_img)
|
||||
|
||||
with pytest.raises(ImageSanitizationError) as excinfo:
|
||||
sanitize_image(b"\x89PNG\r\n\x1a\n-tiny-header")
|
||||
|
||||
assert "exceeds" in str(excinfo.value)
|
||||
# The whole point of the fix: no full decode happened → no hundreds-of-MB buffer.
|
||||
fake_img.load.assert_not_called()
|
||||
|
||||
|
||||
def test_pixel_cap_boundary_allows_at_cap(monkeypatch) -> None:
|
||||
"""Exactly _MAX_PIXELS is allowed through to load() (strict '>' comparison)."""
|
||||
fake_img = MagicMock()
|
||||
fake_img.size = (image_sanitizer._MAX_PIXELS, 1) # width*height == cap
|
||||
fake_img.mode = "RGB"
|
||||
fake_img.__enter__.return_value = fake_img
|
||||
fake_img.__exit__.return_value = False
|
||||
monkeypatch.setattr(image_sanitizer.Image, "open", lambda *a, **k: fake_img)
|
||||
monkeypatch.setattr(image_sanitizer.ImageOps, "exif_transpose", lambda im: im)
|
||||
|
||||
sanitize_image(b"whatever")
|
||||
fake_img.load.assert_called_once()
|
||||
|
||||
|
||||
def test_byte_size_backstop_rejects_before_open(monkeypatch) -> None:
|
||||
called = {"open": False}
|
||||
|
||||
def _guard(*_a, **_k):
|
||||
called["open"] = True
|
||||
raise AssertionError("Image.open must not be called for oversized bytes")
|
||||
|
||||
monkeypatch.setattr(image_sanitizer.Image, "open", _guard)
|
||||
with pytest.raises(ImageSanitizationError):
|
||||
sanitize_image(b"\x00" * (image_sanitizer._MAX_BYTES + 1))
|
||||
assert called["open"] is False
|
||||
|
||||
|
||||
def test_pillow_bomb_threshold_lowered_to_cap() -> None:
|
||||
assert image_sanitizer._MAX_PIXELS == 40_000_000
|
||||
assert Image.MAX_IMAGE_PIXELS == image_sanitizer._MAX_PIXELS
|
||||
|
||||
|
||||
def test_garbage_bytes_rejected() -> None:
|
||||
with pytest.raises(ImageSanitizationError):
|
||||
sanitize_image(b"definitely not an image")
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
"""Unit tests for app.services.location_coef (#2045 BE-3, LocationDrawer).
|
||||
|
||||
No live Postgres needed — DB is a minimal fake returning queued results (mirrors the
|
||||
convention in tests/tasks/test_cadastral_geo_match.py). Covers:
|
||||
- pure functions: _category_weight, _score_to_coef, normalization constants
|
||||
- compute_location_coef: weighted top-N scoring, empty-mirror graceful fallback,
|
||||
no-POI-in-radius (legit zero-score, NOT "unavailable")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
# psycopg v3 driver required; stub DATABASE_URL before any app import (settings needs a DSN).
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.services import location_coef as lc
|
||||
|
||||
# ── Pure functions ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_category_weight_known_categories() -> None:
|
||||
assert lc._category_weight("metro_stop") == 6.0
|
||||
assert lc._category_weight("school") == 5.0
|
||||
assert lc._category_weight("kindergarten") == 4.5
|
||||
assert lc._category_weight("hospital") == 4.0
|
||||
assert lc._category_weight("shop_mall") == 4.0
|
||||
assert lc._category_weight("shop_supermarket") == 3.5
|
||||
assert lc._category_weight("bus_stop") == 4.5
|
||||
assert lc._category_weight("park") == 3.5
|
||||
assert lc._category_weight("pharmacy") == 2.5
|
||||
assert lc._category_weight("tram_stop") == 2.0
|
||||
assert lc._category_weight("shop_small") == 2.0
|
||||
|
||||
|
||||
def test_category_weight_unknown_and_none_fall_back_to_default() -> None:
|
||||
assert lc._category_weight("unknown_category") == 1.0
|
||||
assert lc._category_weight(None) == 1.0
|
||||
|
||||
|
||||
def test_top7_weight_sum_matches_ptica() -> None:
|
||||
"""Same category set as Site Finder → identical top-7 normalization constant (31.5)."""
|
||||
assert lc._TOP7_WEIGHT_SUM == 31.5
|
||||
assert abs(lc._MAX_STRAIGHT_SCORE - 0.315) < 1e-9
|
||||
|
||||
|
||||
def test_score_to_coef_bounds() -> None:
|
||||
assert lc._score_to_coef(0.0) == 0.95
|
||||
assert lc._score_to_coef(100.0) == 1.05
|
||||
|
||||
|
||||
def test_score_to_coef_midpoint() -> None:
|
||||
assert lc._score_to_coef(50.0) == 1.0
|
||||
|
||||
|
||||
def test_score_to_coef_is_monotonic() -> None:
|
||||
scores = [0.0, 10.0, 25.0, 50.0, 75.0, 90.0, 100.0]
|
||||
coefs = [lc._score_to_coef(s) for s in scores]
|
||||
assert coefs == sorted(coefs)
|
||||
|
||||
|
||||
# ── compute_location_coef with a fake DB ─────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(self, *, scalar_value: Any = None, mapping_rows: list[dict] | None = None):
|
||||
self._scalar_value = scalar_value
|
||||
self._mapping_rows = mapping_rows or []
|
||||
|
||||
def scalar(self) -> Any:
|
||||
return self._scalar_value
|
||||
|
||||
def mappings(self) -> Any:
|
||||
class _Mappings:
|
||||
def __init__(self, rows: list[dict]) -> None:
|
||||
self._rows = rows
|
||||
|
||||
def all(self) -> list[dict]:
|
||||
return self._rows
|
||||
|
||||
return _Mappings(self._mapping_rows)
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""Minimal Session stand-in: execute() returns queued results in order."""
|
||||
|
||||
def __init__(self, results: list[_FakeResult]) -> None:
|
||||
self._results = list(results)
|
||||
self.executed: list[Any] = []
|
||||
|
||||
def execute(self, clause: Any, params: dict | None = None) -> _FakeResult:
|
||||
self.executed.append((clause, params))
|
||||
return self._results.pop(0)
|
||||
|
||||
|
||||
def test_compute_location_coef_empty_mirror_returns_unavailable() -> None:
|
||||
"""osm_poi_ekb_local not yet refreshed (count=0) → unavailable, no fabricated factors."""
|
||||
db = _FakeDB([_FakeResult(scalar_value=0)])
|
||||
result = lc.compute_location_coef(db, lat=56.84, lon=60.6)
|
||||
assert result.coef == 1.0
|
||||
assert result.factors == []
|
||||
assert result.geo_source == "unavailable"
|
||||
# Only the count probe ran — no nearest-POI query issued against an empty mirror.
|
||||
assert len(db.executed) == 1
|
||||
|
||||
|
||||
def test_compute_location_coef_no_poi_in_radius_is_legit_zero_score() -> None:
|
||||
"""Mirror populated (count>0) but nothing within radius → coef floor, NOT unavailable."""
|
||||
db = _FakeDB(
|
||||
[
|
||||
_FakeResult(scalar_value=500), # mirror has rows elsewhere
|
||||
_FakeResult(mapping_rows=[]), # nothing near this point
|
||||
]
|
||||
)
|
||||
result = lc.compute_location_coef(db, lat=56.84, lon=60.6)
|
||||
assert result.factors == []
|
||||
assert result.geo_source == "osm_poi_ekb"
|
||||
assert result.coef == lc._score_to_coef(0.0) == 0.95
|
||||
|
||||
|
||||
def test_compute_location_coef_weights_and_ranks_top_n() -> None:
|
||||
"""Nearer + higher-weight-category POI ranks above farther/lower-weight ones."""
|
||||
rows = [
|
||||
{"name": "Школа №1", "category": "school", "distance_m": 300.0},
|
||||
{"name": "ТЦ Мега", "category": "shop_mall", "distance_m": 900.0},
|
||||
{"name": "Метро Ботаническая", "category": "metro_stop", "distance_m": 150.0},
|
||||
{"name": "Аптека", "category": "pharmacy", "distance_m": 50.0},
|
||||
]
|
||||
db = _FakeDB([_FakeResult(scalar_value=1000), _FakeResult(mapping_rows=rows)])
|
||||
result = lc.compute_location_coef(db, lat=56.84, lon=60.6, top_n=7)
|
||||
|
||||
assert result.geo_source == "osm_poi_ekb"
|
||||
assert len(result.factors) == 4
|
||||
# metro_stop (weight 6.0) at 150m beats school (5.0) at 300m despite being closer only
|
||||
# marginally — sanity check the ranking is weight-driven, not distance-only.
|
||||
assert result.factors[0].poi_type == "metro_stop"
|
||||
# Weights strictly descending (sorted DESC by weight before slicing to top_n).
|
||||
weights = [f.weight for f in result.factors]
|
||||
assert weights == sorted(weights, reverse=True)
|
||||
# coef must land inside the documented [0.95, 1.05] MVP range.
|
||||
assert 0.95 <= result.coef <= 1.05
|
||||
|
||||
|
||||
def test_compute_location_coef_limits_to_top_n() -> None:
|
||||
"""More than top_n candidates → only top_n factors surface in the response."""
|
||||
rows = [
|
||||
{"name": f"POI {i}", "category": "shop_small", "distance_m": float(100 + i * 10)}
|
||||
for i in range(20)
|
||||
]
|
||||
db = _FakeDB([_FakeResult(scalar_value=20), _FakeResult(mapping_rows=rows)])
|
||||
result = lc.compute_location_coef(db, lat=56.84, lon=60.6, top_n=7)
|
||||
assert len(result.factors) == 7
|
||||
|
||||
|
||||
def test_compute_location_coef_unknown_category_uses_default_weight() -> None:
|
||||
rows = [{"name": "Неизвестный POI", "category": "some_new_osm_tag", "distance_m": 200.0}]
|
||||
db = _FakeDB([_FakeResult(scalar_value=1), _FakeResult(mapping_rows=rows)])
|
||||
result = lc.compute_location_coef(db, lat=56.84, lon=60.6)
|
||||
assert len(result.factors) == 1
|
||||
expected_weight = (1.0 / (200.0 + 100.0)) * lc.CATEGORY_WEIGHTS["default"]
|
||||
assert abs(result.factors[0].weight - round(expected_weight, 6)) < 1e-9
|
||||
|
||||
|
||||
def test_compute_location_coef_passes_radius_param() -> None:
|
||||
"""radius_m is forwarded as a bound param (psycopg v3 CAST discipline, no :p::type)."""
|
||||
db = _FakeDB([_FakeResult(scalar_value=1), _FakeResult(mapping_rows=[])])
|
||||
lc.compute_location_coef(db, lat=56.84, lon=60.6, radius_m=1500)
|
||||
_clause, params = db.executed[1]
|
||||
assert params is not None
|
||||
assert params["radius_m"] == 1500
|
||||
|
||||
|
||||
def test_no_psycopg_v3_colon_colon_cast() -> None:
|
||||
"""psycopg v3: never :param::type — must use CAST(:param AS type)."""
|
||||
import re
|
||||
|
||||
assert not re.search(r":\w+::", str(lc._NEAREST_POI_SQL.text))
|
||||
346
tradein-mvp/backend/tests/services/test_location_index.py
Normal file
346
tradein-mvp/backend/tests/services/test_location_index.py
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
"""Unit tests for app.services.location_index (replaces test_location_coef.py).
|
||||
|
||||
No live Postgres needed — DB is a minimal fake returning queued results (mirrors the
|
||||
convention in tests/tasks/test_cadastral_geo_match.py / the deleted test_location_coef.py).
|
||||
Covers:
|
||||
- pure functions: _category_weight, _in_ekb_bbox, _pct_deviation (incl. monotonicity)
|
||||
- _fetch_nearby_poi: qualitative POI ranking (unchanged behaviour from the old module)
|
||||
- compute_location_index: out-of-coverage degradation, insufficient-sample degradation
|
||||
(citywide AND local), radius-ladder expansion, explicit radius_m override, happy path
|
||||
- SQL discipline: psycopg v3 CAST, percentile_cont (not naive AVG/MIN/MAX) for outlier
|
||||
robustness
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
# psycopg v3 driver required; stub DATABASE_URL before any app import (settings needs a DSN).
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.services import location_index as lc
|
||||
|
||||
# A point well inside the EKB coverage bbox (city centre, Ploshchad 1905 goda area).
|
||||
_LAT_IN_EKB = 56.838
|
||||
_LON_IN_EKB = 60.605
|
||||
|
||||
# ── Pure functions ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_category_weight_known_categories() -> None:
|
||||
assert lc._category_weight("metro_stop") == 6.0
|
||||
assert lc._category_weight("school") == 5.0
|
||||
assert lc._category_weight("kindergarten") == 4.5
|
||||
assert lc._category_weight("hospital") == 4.0
|
||||
assert lc._category_weight("shop_mall") == 4.0
|
||||
assert lc._category_weight("shop_supermarket") == 3.5
|
||||
assert lc._category_weight("bus_stop") == 4.5
|
||||
assert lc._category_weight("park") == 3.5
|
||||
assert lc._category_weight("pharmacy") == 2.5
|
||||
assert lc._category_weight("tram_stop") == 2.0
|
||||
assert lc._category_weight("shop_small") == 2.0
|
||||
|
||||
|
||||
def test_category_weight_unknown_and_none_fall_back_to_default() -> None:
|
||||
assert lc._category_weight("unknown_category") == 1.0
|
||||
assert lc._category_weight(None) == 1.0
|
||||
|
||||
|
||||
def test_in_ekb_bbox_center_is_inside() -> None:
|
||||
assert lc._in_ekb_bbox(_LAT_IN_EKB, _LON_IN_EKB) is True
|
||||
|
||||
|
||||
def test_in_ekb_bbox_bounds_are_inclusive() -> None:
|
||||
assert lc._in_ekb_bbox(56.70, 60.50) is True
|
||||
assert lc._in_ekb_bbox(56.95, 60.75) is True
|
||||
|
||||
|
||||
def test_in_ekb_bbox_outside_is_rejected() -> None:
|
||||
# Nizhny Tagil — same oblast (region_code=66), well outside the EKB product bbox.
|
||||
assert lc._in_ekb_bbox(57.910, 59.970) is False
|
||||
# Just past each edge of the bbox.
|
||||
assert lc._in_ekb_bbox(56.69, 60.60) is False
|
||||
assert lc._in_ekb_bbox(56.96, 60.60) is False
|
||||
assert lc._in_ekb_bbox(56.80, 60.49) is False
|
||||
assert lc._in_ekb_bbox(56.80, 60.76) is False
|
||||
|
||||
|
||||
def test_pct_deviation_above_and_below_city_median() -> None:
|
||||
assert lc._pct_deviation(165_000.0, 150_000.0) == 10.0
|
||||
assert lc._pct_deviation(135_000.0, 150_000.0) == -10.0
|
||||
assert lc._pct_deviation(150_000.0, 150_000.0) == 0.0
|
||||
|
||||
|
||||
def test_pct_deviation_not_artificially_clamped() -> None:
|
||||
"""Owner requirement: a genuinely +40% district must read as +40%, not clamped."""
|
||||
assert lc._pct_deviation(210_000.0, 150_000.0) == 40.0
|
||||
|
||||
|
||||
def test_pct_deviation_guards_zero_division() -> None:
|
||||
assert lc._pct_deviation(100_000.0, 0.0) == 0.0
|
||||
|
||||
|
||||
def test_pct_deviation_is_monotonic_in_local_median() -> None:
|
||||
"""Индекс строго монотонен по локальной медиане при фиксированной городской — в отличие
|
||||
от старого coef (немонотонные бакеты на реальных данных, см. модуль docstring).
|
||||
|
||||
Значения ниже — медианы ₽/м² по дистанционным бакетам от центра ЕКБ, измеренные на 31
|
||||
тыс. лотов (аудит владельца продукта), отсортированные по возрастанию. Индекс,
|
||||
построенный на этих же локальных медианах, обязан сохранить порядок.
|
||||
"""
|
||||
city_median = 155_000.0
|
||||
local_medians_ascending = [
|
||||
93_677.0,
|
||||
136_729.0,
|
||||
150_063.0,
|
||||
159_382.0,
|
||||
159_486.0,
|
||||
191_682.0,
|
||||
249_686.0,
|
||||
]
|
||||
pct_values = [lc._pct_deviation(m, city_median) for m in local_medians_ascending]
|
||||
assert pct_values == sorted(pct_values)
|
||||
|
||||
|
||||
# ── SQL discipline ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_no_psycopg_v3_colon_colon_cast() -> None:
|
||||
"""psycopg v3: never :param::type — must use CAST(:param AS type)."""
|
||||
import re
|
||||
|
||||
for sql in (
|
||||
lc._MEDIAN_PPM2_LOCAL_SQL,
|
||||
lc._MEDIAN_PPM2_CITYWIDE_SQL,
|
||||
lc._NEAREST_POI_SQL,
|
||||
):
|
||||
assert not re.search(r":\w+::", str(sql.text))
|
||||
|
||||
|
||||
def test_median_queries_use_percentile_not_naive_minmax() -> None:
|
||||
"""Outlier robustness requirement: percentile_cont(0.5) (median), not AVG/MIN/MAX."""
|
||||
for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL):
|
||||
sql_text = str(sql.text).lower()
|
||||
assert "percentile_cont(0.5)" in sql_text
|
||||
assert "avg(" not in sql_text
|
||||
assert "min(" not in sql_text
|
||||
assert "max(" not in sql_text
|
||||
|
||||
|
||||
def test_median_queries_exclude_city_centroid_and_bound_bbox() -> None:
|
||||
"""Comparable-selection quality control (owner requirement #1): city-centroid geocodes
|
||||
excluded (mirrors estimator.py #769 Part E), sample bounded to the EKB bbox."""
|
||||
for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL):
|
||||
sql_text = str(sql.text)
|
||||
assert "geo_precision IS DISTINCT FROM 'city'" in sql_text
|
||||
assert "bbox_south" in sql_text and "bbox_north" in sql_text
|
||||
assert "bbox_west" in sql_text and "bbox_east" in sql_text
|
||||
|
||||
|
||||
# ── _fetch_nearby_poi (qualitative "что рядом" list) ─────────────────────────
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
scalar_value: Any = None,
|
||||
mapping_rows: list[dict] | None = None,
|
||||
mapping_one: dict | None = None,
|
||||
):
|
||||
self._scalar_value = scalar_value
|
||||
self._mapping_rows = mapping_rows or []
|
||||
self._mapping_one = mapping_one
|
||||
|
||||
def scalar(self) -> Any:
|
||||
return self._scalar_value
|
||||
|
||||
def mappings(self) -> Any:
|
||||
outer = self
|
||||
|
||||
class _Mappings:
|
||||
def all(self) -> list[dict]:
|
||||
return outer._mapping_rows
|
||||
|
||||
def first(self) -> dict | None:
|
||||
return outer._mapping_one
|
||||
|
||||
return _Mappings()
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""Minimal Session stand-in: execute() returns queued results in order."""
|
||||
|
||||
def __init__(self, results: list[_FakeResult]) -> None:
|
||||
self._results = list(results)
|
||||
self.executed: list[Any] = []
|
||||
|
||||
def execute(self, clause: Any, params: dict | None = None) -> _FakeResult:
|
||||
self.executed.append((clause, params))
|
||||
return self._results.pop(0)
|
||||
|
||||
|
||||
def test_fetch_nearby_poi_empty_mirror_returns_unavailable() -> None:
|
||||
db = _FakeDB([_FakeResult(scalar_value=0)])
|
||||
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7)
|
||||
assert poi == []
|
||||
assert status == "unavailable"
|
||||
assert len(db.executed) == 1 # only the count probe ran
|
||||
|
||||
|
||||
def test_fetch_nearby_poi_no_poi_in_radius_is_legit_ok() -> None:
|
||||
db = _FakeDB([_FakeResult(scalar_value=500), _FakeResult(mapping_rows=[])])
|
||||
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7)
|
||||
assert poi == []
|
||||
assert status == "ok"
|
||||
|
||||
|
||||
def test_fetch_nearby_poi_ranks_by_weight_not_distance_only() -> None:
|
||||
rows = [
|
||||
{"name": "Школа №1", "category": "school", "distance_m": 300.0},
|
||||
{"name": "ТЦ Мега", "category": "shop_mall", "distance_m": 900.0},
|
||||
{"name": "Метро Ботаническая", "category": "metro_stop", "distance_m": 150.0},
|
||||
{"name": "Аптека", "category": "pharmacy", "distance_m": 50.0},
|
||||
]
|
||||
db = _FakeDB([_FakeResult(scalar_value=1000), _FakeResult(mapping_rows=rows)])
|
||||
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7)
|
||||
assert status == "ok"
|
||||
assert len(poi) == 4
|
||||
# metro_stop (weight 6.0) at 150m outranks school (5.0) at 300m — weight-driven, not
|
||||
# distance-only ranking.
|
||||
assert poi[0].poi_type == "metro_stop"
|
||||
|
||||
|
||||
def test_fetch_nearby_poi_limits_to_top_n() -> None:
|
||||
rows = [
|
||||
{"name": f"POI {i}", "category": "shop_small", "distance_m": float(100 + i * 10)}
|
||||
for i in range(20)
|
||||
]
|
||||
db = _FakeDB([_FakeResult(scalar_value=20), _FakeResult(mapping_rows=rows)])
|
||||
poi, _status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7)
|
||||
assert len(poi) == 7
|
||||
|
||||
|
||||
# ── compute_location_index: degradation + ladder logic ───────────────────────
|
||||
|
||||
|
||||
def test_compute_location_index_out_of_coverage_skips_all_db_calls() -> None:
|
||||
"""Owner requirement #2: point outside EKB → honest 'no data', never a fallback number.
|
||||
|
||||
Also a perf/honesty check: no DB round-trip at all for an out-of-scope point.
|
||||
"""
|
||||
db = _FakeDB([])
|
||||
result = lc.compute_location_index(db, lat=57.910, lon=59.970) # Nizhny Tagil
|
||||
assert result.status == "out_of_coverage"
|
||||
assert result.location_index_pct is None
|
||||
assert result.local_median_price_per_m2 is None
|
||||
assert result.city_median_price_per_m2 is None
|
||||
assert result.sample_size == 0
|
||||
assert result.nearby_poi == []
|
||||
assert result.poi_status == "unavailable"
|
||||
assert db.executed == []
|
||||
|
||||
|
||||
def test_compute_location_index_citywide_sample_too_small_short_circuits() -> None:
|
||||
"""Degenerate citywide reference (e.g. empty dev DB) → insufficient_data without ever
|
||||
issuing a local-radius query (nothing to compare against anyway)."""
|
||||
db = _FakeDB(
|
||||
[
|
||||
_FakeResult(scalar_value=0), # POI mirror empty
|
||||
_FakeResult(mapping_one={"median_ppm2": None, "n": 3}), # citywide: n < MIN
|
||||
]
|
||||
)
|
||||
result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB)
|
||||
assert result.status == "insufficient_data"
|
||||
assert result.location_index_pct is None
|
||||
assert result.sample_size == 3
|
||||
assert len(db.executed) == 2 # poi-count + citywide only — no radius-ladder queries
|
||||
|
||||
|
||||
def test_compute_location_index_first_radius_rung_sufficient() -> None:
|
||||
db = _FakeDB(
|
||||
[
|
||||
_FakeResult(scalar_value=0), # poi mirror empty
|
||||
_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}), # citywide
|
||||
_FakeResult(mapping_one={"median_ppm2": 165_000.0, "n": 25}), # radius[0]=800
|
||||
]
|
||||
)
|
||||
result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB)
|
||||
assert result.status == "ok"
|
||||
assert result.radius_m == lc.RADIUS_LADDER_M[0]
|
||||
assert result.sample_size == 25
|
||||
assert result.local_median_price_per_m2 == 165_000
|
||||
assert result.city_median_price_per_m2 == 150_000
|
||||
assert result.location_index_pct == 10.0
|
||||
assert len(db.executed) == 3 # ladder stopped at rung 1 — no further radius queries
|
||||
|
||||
|
||||
def test_compute_location_index_expands_ladder_when_first_rung_insufficient() -> None:
|
||||
db = _FakeDB(
|
||||
[
|
||||
_FakeResult(scalar_value=0),
|
||||
_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}),
|
||||
_FakeResult(mapping_one={"median_ppm2": 200_000.0, "n": 10}), # 800m: too few
|
||||
_FakeResult(mapping_one={"median_ppm2": 180_000.0, "n": 30}), # 1500m: enough
|
||||
]
|
||||
)
|
||||
result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB)
|
||||
assert result.status == "ok"
|
||||
assert result.radius_m == lc.RADIUS_LADDER_M[1]
|
||||
assert result.sample_size == 30
|
||||
assert len(db.executed) == 4
|
||||
|
||||
|
||||
def test_compute_location_index_insufficient_even_at_max_radius() -> None:
|
||||
db = _FakeDB(
|
||||
[
|
||||
_FakeResult(scalar_value=0),
|
||||
_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}),
|
||||
_FakeResult(mapping_one={"median_ppm2": 200_000.0, "n": 5}), # 800m
|
||||
_FakeResult(mapping_one={"median_ppm2": 195_000.0, "n": 12}), # 1500m
|
||||
_FakeResult(mapping_one={"median_ppm2": 190_000.0, "n": 15}), # 2500m — still < 20
|
||||
]
|
||||
)
|
||||
result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB)
|
||||
assert result.status == "insufficient_data"
|
||||
assert result.location_index_pct is None
|
||||
assert result.local_median_price_per_m2 is None
|
||||
assert result.city_median_price_per_m2 == 150_000
|
||||
assert result.radius_m == lc.RADIUS_LADDER_M[-1]
|
||||
assert result.sample_size == 15 # honest: shows how close it got, not just "no data"
|
||||
assert len(db.executed) == 5 # exhausted the full ladder
|
||||
|
||||
|
||||
def test_compute_location_index_explicit_radius_skips_ladder() -> None:
|
||||
"""An explicit radius_m must be used AS-IS — no adaptive expansion (caller-controlled)."""
|
||||
db = _FakeDB(
|
||||
[
|
||||
_FakeResult(scalar_value=0),
|
||||
_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}),
|
||||
_FakeResult(mapping_one={"median_ppm2": 160_000.0, "n": 50}), # single query only
|
||||
]
|
||||
)
|
||||
result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB, radius_m=1000)
|
||||
assert result.status == "ok"
|
||||
assert result.radius_m == 1000
|
||||
assert len(db.executed) == 3 # exactly one radius query, no ladder rungs tried
|
||||
nearest_call_params = db.executed[2][1]
|
||||
assert nearest_call_params["radius_m"] == 1000
|
||||
|
||||
|
||||
def test_compute_location_index_poi_unavailable_does_not_block_index() -> None:
|
||||
"""poi_status and status degrade INDEPENDENTLY — an empty POI mirror must not prevent a
|
||||
perfectly computable price-based index."""
|
||||
db = _FakeDB(
|
||||
[
|
||||
_FakeResult(scalar_value=0), # poi mirror empty
|
||||
_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}),
|
||||
_FakeResult(mapping_one={"median_ppm2": 172_500.0, "n": 40}),
|
||||
]
|
||||
)
|
||||
result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB)
|
||||
assert result.status == "ok"
|
||||
assert result.poi_status == "unavailable"
|
||||
assert result.nearby_poi == []
|
||||
assert result.location_index_pct == 15.0
|
||||
|
|
@ -30,7 +30,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
|
|||
_wp_mock = MagicMock()
|
||||
sys.modules.setdefault("weasyprint", _wp_mock)
|
||||
|
||||
from app.schemas.trade_in import AggregatedEstimate # noqa: E402
|
||||
from app.schemas.trade_in import AggregatedEstimate, AnalogLot # noqa: E402
|
||||
from app.services.brand import Brand # noqa: E402
|
||||
from app.services.exporters import trade_in_pdf as mod # noqa: E402
|
||||
|
||||
|
|
@ -79,7 +79,16 @@ class _WellFormed(HTMLParser):
|
|||
"""Minimal balanced-tag check: every non-void tag that opens must close."""
|
||||
|
||||
_VOID: ClassVar[set[str]] = {
|
||||
"br", "img", "hr", "meta", "input", "rect", "path", "circle", "line", "polyline",
|
||||
"br",
|
||||
"img",
|
||||
"hr",
|
||||
"meta",
|
||||
"input",
|
||||
"rect",
|
||||
"path",
|
||||
"circle",
|
||||
"line",
|
||||
"polyline",
|
||||
}
|
||||
|
||||
def __init__(self) -> None:
|
||||
|
|
@ -224,7 +233,9 @@ def test_build_cover_with_sold_contains_dual_block() -> None:
|
|||
assert "Ожидаемая цена продажи" in html
|
||||
# existing sections untouched
|
||||
assert "Диапазон цен в объявлениях" in html
|
||||
assert "Диапазон цен по фактическим сделкам" in html
|
||||
# #pdf-empty-pages: дубль-бар «по фактическим сделкам» убран с cover
|
||||
# (остался на стр. 03) — cover ужат до одной A4-страницы.
|
||||
assert "Диапазон цен по фактическим сделкам" not in html
|
||||
|
||||
|
||||
def test_build_cover_without_sold_renders_only_asking() -> None:
|
||||
|
|
@ -234,4 +245,151 @@ def test_build_cover_without_sold_renders_only_asking() -> None:
|
|||
assert "Ожидаемая цена продажи" not in html
|
||||
# existing layout intact
|
||||
assert "Диапазон цен в объявлениях" in html
|
||||
assert "Диапазон цен по фактическим сделкам" in html
|
||||
# #pdf-empty-pages: дубль-бар «по фактическим сделкам» убран с cover (см. выше).
|
||||
assert "Диапазон цен по фактическим сделкам" not in html
|
||||
|
||||
|
||||
# ── _days_on_market_range — no fabricated (4, 118) corridor (#pdf-honesty) ─────
|
||||
|
||||
|
||||
def _lot(days_on_market: int | None, price_rub: int = 10_000_000) -> AnalogLot:
|
||||
return AnalogLot(
|
||||
address="Екатеринбург, ул. Ленина, 1",
|
||||
area_m2=50.0,
|
||||
rooms=2,
|
||||
floor=3,
|
||||
total_floors=9,
|
||||
price_rub=price_rub,
|
||||
price_per_m2=int(price_rub / 50),
|
||||
listing_date=None,
|
||||
days_on_market=days_on_market,
|
||||
)
|
||||
|
||||
|
||||
def test_days_range_none_when_no_lots() -> None:
|
||||
# Раньше возвращал хардкод (4, 118) — выдуманный срок экспозиции.
|
||||
assert mod._days_on_market_range([]) is None
|
||||
|
||||
|
||||
def test_days_range_none_when_all_days_missing() -> None:
|
||||
assert mod._days_on_market_range([_lot(None), _lot(None)]) is None
|
||||
|
||||
|
||||
def test_days_range_min_max_when_data_present() -> None:
|
||||
assert mod._days_on_market_range([_lot(12), _lot(None), _lot(88)]) == (12, 88)
|
||||
|
||||
|
||||
def test_cover_without_days_data_still_renders_range_chart() -> None:
|
||||
# Нет days_on_market → show_days=False, но ценовой бар всё равно строится.
|
||||
est = _estimate(analogs=[_lot(None)])
|
||||
html = mod._build_cover(est, _SNAPSHOT, _BRAND)
|
||||
_assert_well_formed(html)
|
||||
assert "Диапазон цен в объявлениях" in html
|
||||
|
||||
|
||||
# ── _report_number — city-aware prefix, never hardcoded «EKБ» (#pdf-honesty) ───
|
||||
|
||||
|
||||
def test_report_number_ekb_prefix_for_ekb_address() -> None:
|
||||
est = _estimate(target_address="Екатеринбург, ул. Ленина, 1")
|
||||
assert mod._report_number(est).startswith("ЕКБ-")
|
||||
|
||||
|
||||
def test_report_number_city_prefix_for_non_ekb_address() -> None:
|
||||
assert mod._report_number(_estimate(target_address="Нижний Тагил, ул. Мира, 5")).startswith(
|
||||
"НТ-"
|
||||
)
|
||||
assert mod._report_number(_estimate(target_address="Серов, ул. Ленина, 3")).startswith("СЕР-")
|
||||
|
||||
|
||||
def test_report_number_neutral_prefix_when_city_unknown() -> None:
|
||||
# None адрес и адрес вне региона → нейтральный «МЕРА», НЕ ложный «EKБ».
|
||||
assert mod._report_number(_estimate(target_address=None)).startswith("МЕРА-")
|
||||
assert mod._report_number(_estimate(target_address="Москва, ул. Тверская, 1")).startswith(
|
||||
"МЕРА-"
|
||||
)
|
||||
|
||||
|
||||
def test_report_number_never_hardcodes_old_ekb_literal() -> None:
|
||||
# Старый баг: «EKБ-» (латинские EK + кириллическая Б) для ЛЮБОГО адреса.
|
||||
for addr in (None, "Серов, ул. Ленина, 3", "Москва, ул. Тверская, 1"):
|
||||
assert not mod._report_number(_estimate(target_address=addr)).startswith("EK")
|
||||
|
||||
|
||||
def test_report_number_falls_back_to_canonical_address() -> None:
|
||||
est = _estimate(target_address=None, canonical_address="Первоуральск, ул. Вайнера, 2")
|
||||
assert mod._report_number(est).startswith("ПРВ-")
|
||||
|
||||
|
||||
# ── Static «10–18%» haircut replaced by computed «−N%» (#pdf-honesty) ──────────
|
||||
|
||||
|
||||
def test_cover_advice_uses_computed_discount_not_static_range() -> None:
|
||||
est = _estimate(expected_sold_price_rub=8_000_000, asking_to_sold_ratio=0.80) # −20%
|
||||
html = mod._build_cover(est, _SNAPSHOT, _BRAND)
|
||||
assert "10–18%" not in html
|
||||
assert "на 20%" in html
|
||||
|
||||
|
||||
def test_cover_advice_neutral_when_no_discount() -> None:
|
||||
html = mod._build_cover(_estimate(expected_sold_price_rub=None), _SNAPSHOT, _BRAND)
|
||||
assert "10–18%" not in html
|
||||
assert "проходят ниже цен в объявлениях" in html
|
||||
|
||||
|
||||
def test_deals_banner_uses_computed_discount_not_static_range() -> None:
|
||||
est = _estimate(expected_sold_price_rub=8_000_000, asking_to_sold_ratio=0.80) # −20%
|
||||
html = mod._build_deals_page(est, _SNAPSHOT, _BRAND)
|
||||
_assert_well_formed(html)
|
||||
assert "10–18%" not in html
|
||||
# Ложный локейшн-хвост «(Екатеринбург, 2026)» удалён.
|
||||
assert "Екатеринбург, 2026" not in html
|
||||
assert "на 20%" in html
|
||||
|
||||
|
||||
def test_deals_banner_neutral_when_no_discount() -> None:
|
||||
html = mod._build_deals_page(_estimate(expected_sold_price_rub=None), _SNAPSHOT, _BRAND)
|
||||
_assert_well_formed(html)
|
||||
assert "10–18%" not in html
|
||||
assert "продаются дешевле, чем заявлено" in html
|
||||
|
||||
|
||||
# ── _build_offer_page — rent scales with room count, not a flat 1-room figure ──
|
||||
# (#pdf-honesty LOW audit R2 #5: «Общие финансовые потери» hardcoded a 1-room
|
||||
# 3-month rent for EVERY object, including 3-4-room ones.)
|
||||
|
||||
|
||||
def test_offer_page_rent_scales_up_for_larger_objects() -> None:
|
||||
est = _estimate()
|
||||
snapshot_1room = {**_SNAPSHOT, "rooms": 1}
|
||||
snapshot_3room = {**_SNAPSHOT, "rooms": 3}
|
||||
|
||||
html_1room = mod._build_offer_page(est, snapshot_1room, _BRAND)
|
||||
html_3room = mod._build_offer_page(est, snapshot_3room, _BRAND)
|
||||
_assert_well_formed(html_1room)
|
||||
_assert_well_formed(html_3room)
|
||||
|
||||
# Base (1-room) rent range unchanged: 84 000 – 135 000.
|
||||
assert mod._fmt_rub(84_000) in html_1room
|
||||
assert mod._fmt_rub(135_000) in html_1room
|
||||
# 3-room rent scaled up (×1.8 heuristic): 151 000 – 243 000.
|
||||
assert mod._fmt_rub(151_000) in html_3room
|
||||
assert mod._fmt_rub(243_000) in html_3room
|
||||
# The flat 1-room figure must NOT leak into the 3-room object's report.
|
||||
assert mod._fmt_rub(135_000) not in html_3room
|
||||
|
||||
|
||||
def test_offer_page_rent_subtitle_reflects_room_count() -> None:
|
||||
est = _estimate()
|
||||
|
||||
html_1room = mod._build_offer_page(est, {**_SNAPSHOT, "rooms": 1}, _BRAND)
|
||||
assert "1-комн. квартиры на 3 месяца" in html_1room
|
||||
assert "однокомнатной квартиры на 3 месяца" not in html_1room
|
||||
|
||||
html_3room = mod._build_offer_page(est, {**_SNAPSHOT, "rooms": 3}, _BRAND)
|
||||
assert "3-комн. квартиры на 3 месяца" in html_3room
|
||||
|
||||
# Unknown room count (0/missing) → neutral subtitle, no fabricated room number.
|
||||
html_unknown = mod._build_offer_page(est, {**_SNAPSHOT, "rooms": 0}, _BRAND)
|
||||
assert "квартиры на 3 месяца" in html_unknown
|
||||
assert "-комн. квартиры на 3 месяца" not in html_unknown
|
||||
|
|
|
|||
0
tradein-mvp/backend/tests/services/tgbot/__init__.py
Normal file
0
tradein-mvp/backend/tests/services/tgbot/__init__.py
Normal file
975
tradein-mvp/backend/tests/services/tgbot/test_bridge.py
Normal file
975
tradein-mvp/backend/tests/services/tgbot/test_bridge.py
Normal file
|
|
@ -0,0 +1,975 @@
|
|||
"""Unit tests for `app.services.tgbot.bridge` — чистая логика роутинга.
|
||||
|
||||
Coverage (per task spec + review follow-up):
|
||||
- user → topic (личка клиента зеркалится в support-топик, с шапкой на первое
|
||||
сообщение за throttle-окно, без шапки на повторное В окне, и снова с шапкой
|
||||
после истечения окна — #6 review)
|
||||
- реплай оператора → user (доставка ответа клиенту + запись direction='out')
|
||||
- реплай оператора → веб-чат (#tgsupport-web): зеркало веб-сообщения резолвится
|
||||
в web-тред (скоуп по (topic_message_id, support_chat_id) — review M1), ответ
|
||||
пишется direction='out' БЕЗ Telegram-доставки; медиа-реплай (в т.ч. фото С
|
||||
ПОДПИСЬЮ) на веб-зеркало — отказ ЦЕЛИКОМ + уведомление оператору в топике
|
||||
(review M2, никакой частичной доставки одной подписи); зеркало от ЧУЖОГО/
|
||||
устаревшего support_chat_id — не матчится (ротация группы); NULL
|
||||
support_chat_id (легаси) — wildcard-матч; совпадение ОБЕИХ сторон
|
||||
одновременно (tg И web) — громкий отказ (logger.error), а не молчаливый
|
||||
выбор tg-пути (152-ФЗ misroute risk)
|
||||
- реплай не на зеркало (или не реплай вообще) — тихий игнор, не мусорим в чат;
|
||||
реплай на СООБЩЕНИЕ БОТА без записи в БД — WARNING про осиротевшее зеркало
|
||||
(#4 review)
|
||||
- дедуп update_id (<=offset — skip без side-effects; poison-pill апдейт всё
|
||||
равно сдвигает offset, чтобы не подвесить весь поток)
|
||||
- сбой БД (SQLAlchemyError) во время обработки → rollback() ПЕРЕД save_offset,
|
||||
offset всё равно сдвигается — без этого следующий поллинг переиграл бы тот
|
||||
же апдейт и задублировал зеркало клиента в топике (#3 review)
|
||||
- /start → приветствие без зеркалирования
|
||||
- Telegram 403 на доставку оператору → is_blocked + уведомление в топике
|
||||
- TELEGRAM_SUPPORT_CHAT_ID не задан → клиенту уходит "сервис недоступен"
|
||||
вместо тихой потери сообщения (#5 review)
|
||||
|
||||
NEVER calls real Telegram API — все HTTP-запросы mock'аются через
|
||||
httpx.MockTransport (consistent с tests/services/test_dadata.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
# DATABASE_URL required by app.core.config before any app import (см. test_dadata.py).
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.services.tgbot import bridge
|
||||
from app.services.tgbot.client import TelegramClient
|
||||
|
||||
SUPPORT_CHAT_ID = -100123456789
|
||||
SUPPORT_TOPIC_ID = 42
|
||||
|
||||
|
||||
# ── Fake in-memory storage (БД не нужна) ─────────────────────────────────────
|
||||
class FakeBridgeStorage:
|
||||
"""In-memory `BridgeStorage` — никакой реальной БД, чистая логика роутинга.
|
||||
|
||||
`clock_s` — управляемые тестом "фейковые часы" (просто float, продвигается
|
||||
вручную через `storage.clock_s += ...`), чтобы честно проверить throttle-окно
|
||||
в `had_recent_inbound` (#6 review) без реального `time.sleep`/datetime-моков.
|
||||
|
||||
`fail_next_record_message` — если True, следующий вызов `record_message`
|
||||
кидает `SQLAlchemyError` (симулирует обрыв коннекта к БД) и сбрасывается в
|
||||
False — для теста rollback-пути в `process_update` (#3 review).
|
||||
"""
|
||||
|
||||
def __init__(self, offset: int = 0) -> None:
|
||||
self._offset = offset
|
||||
self.users: dict[int, dict[str, Any]] = {}
|
||||
self.messages: list[dict[str, Any]] = []
|
||||
self.blocked: set[int] = set()
|
||||
self.commits = 0
|
||||
self.rollbacks = 0
|
||||
self._next_id = 1
|
||||
self.clock_s: float = 0.0
|
||||
self.fail_next_record_message = False
|
||||
# #tgsupport-web: web_support_messages-эквивалент, topic_message_id ->
|
||||
# (thread_id, support_chat_id) — второй элемент моделирует колонку
|
||||
# web_support_messages.support_chat_id (review M1); None = легаси wildcard.
|
||||
# + журнал outbound-записей, записанных через реплай оператора.
|
||||
self.web_topic_to_thread: dict[int, tuple[int, int | None]] = {}
|
||||
self.web_out_messages: list[dict[str, Any]] = []
|
||||
|
||||
def get_offset(self) -> int:
|
||||
return self._offset
|
||||
|
||||
def save_offset(self, update_id: int) -> None:
|
||||
self._offset = update_id
|
||||
|
||||
def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
||||
def rollback(self) -> None:
|
||||
self.rollbacks += 1
|
||||
|
||||
def upsert_user(
|
||||
self,
|
||||
*,
|
||||
chat_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
language_code: str | None,
|
||||
) -> None:
|
||||
self.users[chat_id] = {
|
||||
"username": username,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"language_code": language_code,
|
||||
}
|
||||
|
||||
def had_recent_inbound(self, chat_id: int, window_seconds: int) -> bool:
|
||||
return any(
|
||||
m["chat_id"] == chat_id
|
||||
and m["direction"] == "in"
|
||||
and (self.clock_s - m["recorded_at_s"]) < window_seconds
|
||||
for m in self.messages
|
||||
)
|
||||
|
||||
def record_message(
|
||||
self,
|
||||
*,
|
||||
chat_id: int,
|
||||
direction: str,
|
||||
tg_message_id: int | None,
|
||||
topic_message_id: int | None,
|
||||
kind: str,
|
||||
text_body: str | None,
|
||||
operator_tg_id: int | None,
|
||||
support_chat_id: int | None = None,
|
||||
) -> int:
|
||||
if self.fail_next_record_message:
|
||||
self.fail_next_record_message = False
|
||||
raise SQLAlchemyError("simulated DB failure (deploy connection reset)")
|
||||
row_id = self._next_id
|
||||
self._next_id += 1
|
||||
self.messages.append(
|
||||
{
|
||||
"id": row_id,
|
||||
"chat_id": chat_id,
|
||||
"direction": direction,
|
||||
"tg_message_id": tg_message_id,
|
||||
"topic_message_id": topic_message_id,
|
||||
"kind": kind,
|
||||
"text_body": text_body,
|
||||
"operator_tg_id": operator_tg_id,
|
||||
"support_chat_id": support_chat_id,
|
||||
"recorded_at_s": self.clock_s,
|
||||
}
|
||||
)
|
||||
return row_id
|
||||
|
||||
def find_chat_by_topic_message(self, topic_message_id: int, support_chat_id: int) -> int | None:
|
||||
"""support_chat_id-скоуп (review M1): запись со ЧУЖИМ (не None, не текущим)
|
||||
support_chat_id не матчится — None (легаси/дефолт) матчится всегда."""
|
||||
for m in reversed(self.messages):
|
||||
if m["direction"] != "in" or m["topic_message_id"] != topic_message_id:
|
||||
continue
|
||||
entry_chat_id = m.get("support_chat_id")
|
||||
if entry_chat_id is not None and entry_chat_id != support_chat_id:
|
||||
continue
|
||||
return m["chat_id"]
|
||||
return None
|
||||
|
||||
def mark_blocked(self, chat_id: int) -> None:
|
||||
self.blocked.add(chat_id)
|
||||
|
||||
# ── #tgsupport-web ────────────────────────────────────────────────────
|
||||
def find_web_thread_by_topic_message(
|
||||
self, topic_message_id: int, support_chat_id: int
|
||||
) -> int | None:
|
||||
"""Тот же support_chat_id-скоуп, что и `find_chat_by_topic_message` (review M1)."""
|
||||
entry = self.web_topic_to_thread.get(topic_message_id)
|
||||
if entry is None:
|
||||
return None
|
||||
thread_id, entry_chat_id = entry
|
||||
if entry_chat_id is not None and entry_chat_id != support_chat_id:
|
||||
return None
|
||||
return thread_id
|
||||
|
||||
def record_web_out_message(
|
||||
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
|
||||
) -> None:
|
||||
self.web_out_messages.append(
|
||||
{
|
||||
"thread_id": thread_id,
|
||||
"text_body": text_body,
|
||||
"operator_tg_id": operator_tg_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ── httpx mocking helpers (mirrors tests/services/test_dadata.py) ───────────
|
||||
_REAL_ASYNC_CLIENT = httpx.AsyncClient
|
||||
|
||||
|
||||
def _method_from_url(url: httpx.URL) -> str:
|
||||
return str(url).rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def _make_client(
|
||||
responses: dict[str, Any], calls: list[tuple[str, dict[str, Any]]]
|
||||
) -> TelegramClient:
|
||||
"""TelegramClient wired to a MockTransport. `responses[method]` may be a dict
|
||||
(returned as Bot API `result`), an int (HTTP error status), or a callable
|
||||
`(payload) -> dict`. Every request is recorded into `calls`."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
import json as _json
|
||||
|
||||
method = _method_from_url(request.url)
|
||||
payload = _json.loads(request.content.decode("utf-8")) if request.content else {}
|
||||
calls.append((method, payload))
|
||||
|
||||
canned = responses.get(method)
|
||||
if isinstance(canned, int):
|
||||
return httpx.Response(
|
||||
canned, json={"ok": False, "error_code": canned, "description": "mocked error"}
|
||||
)
|
||||
if callable(canned):
|
||||
canned = canned(payload)
|
||||
result = canned if canned is not None else {"message_id": 999}
|
||||
return httpx.Response(200, json={"ok": True, "result": result})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
|
||||
def factory(*_: object, **__: object) -> httpx.AsyncClient:
|
||||
return _REAL_ASYNC_CLIENT(transport=transport)
|
||||
|
||||
client = TelegramClient(token="fake-token")
|
||||
import unittest.mock as mock
|
||||
|
||||
# Патчим httpx.AsyncClient ТОЛЬКО внутри client-модуля — не трогаем глобальный httpx.
|
||||
patcher = mock.patch("app.services.tgbot.client.httpx.AsyncClient", factory)
|
||||
patcher.start()
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _support_chat_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Все тесты по умолчанию считают support-группу/топик настроенными."""
|
||||
monkeypatch.setattr(bridge.settings, "telegram_support_chat_id", SUPPORT_CHAT_ID)
|
||||
monkeypatch.setattr(bridge.settings, "telegram_support_topic_id", SUPPORT_TOPIC_ID)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_flood_limiters(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""F) `_flood_limiter`/`_flood_notify_limiter` — module-level singletons (тот же
|
||||
паттерн, что `_send_limiter` в app/api/v1/support.py); большинство тестов в
|
||||
этом файле шлют сообщения от одного и того же chat_id=555, поэтому без сброса
|
||||
накопленные хиты одного теста бы протекали в следующий и ломали его
|
||||
предположения (тест флуда должен видеть ЧИСТЫЙ бюджет)."""
|
||||
monkeypatch.setattr(
|
||||
bridge,
|
||||
"_flood_limiter",
|
||||
bridge.SlidingWindowLimiter(limit=bridge._FLOOD_LIMIT, window_s=bridge._FLOOD_WINDOW_S),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
bridge,
|
||||
"_flood_notify_limiter",
|
||||
bridge.SlidingWindowLimiter(limit=1, window_s=bridge._FLOOD_WINDOW_S),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stop_patches():
|
||||
"""Останавливает httpx.AsyncClient monkeypatch после каждого теста (unittest.mock.patch.start()
|
||||
без контекст-менеджера требует явного stop, чтобы не утекать в соседние тесты)."""
|
||||
import unittest.mock as mock
|
||||
|
||||
yield
|
||||
mock.patch.stopall()
|
||||
|
||||
|
||||
def _private_message(
|
||||
*,
|
||||
message_id: int = 1,
|
||||
chat_id: int = 555,
|
||||
text: str | None = "Здравствуйте, вопрос по trade-in",
|
||||
username: str | None = "client_ivan",
|
||||
first_name: str | None = "Иван",
|
||||
last_name: str | None = "Петров",
|
||||
) -> dict[str, Any]:
|
||||
msg: dict[str, Any] = {
|
||||
"message_id": message_id,
|
||||
"chat": {"id": chat_id, "type": "private"},
|
||||
"from": {
|
||||
"id": chat_id,
|
||||
"username": username,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"language_code": "ru",
|
||||
},
|
||||
}
|
||||
if text is not None:
|
||||
msg["text"] = text
|
||||
return msg
|
||||
|
||||
|
||||
def _group_reply_message(
|
||||
*,
|
||||
message_id: int = 200,
|
||||
reply_to_message_id: int | None = 100,
|
||||
text: str = "Ответ оператора",
|
||||
operator_id: int = 777,
|
||||
) -> dict[str, Any]:
|
||||
msg: dict[str, Any] = {
|
||||
"message_id": message_id,
|
||||
"chat": {"id": SUPPORT_CHAT_ID, "type": "supergroup"},
|
||||
"from": {"id": operator_id, "username": "operator1"},
|
||||
"text": text,
|
||||
}
|
||||
if reply_to_message_id is not None:
|
||||
msg["reply_to_message"] = {"message_id": reply_to_message_id}
|
||||
return msg
|
||||
|
||||
|
||||
# ── A) user → topic ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_private_message_mirrors_to_topic_with_header_on_first_contact() -> None:
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({"copyMessage": {"message_id": 555}}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
|
||||
update = {"update_id": 10, "message": _private_message()}
|
||||
await bridge.process_update(update, client, storage)
|
||||
|
||||
methods = [m for m, _ in calls]
|
||||
# Первое сообщение за окно → шапка ПЕРЕД зеркалом контента.
|
||||
assert methods == ["sendMessage", "copyMessage"]
|
||||
|
||||
header_call = calls[0][1]
|
||||
assert header_call["chat_id"] == SUPPORT_CHAT_ID
|
||||
assert header_call["message_thread_id"] == SUPPORT_TOPIC_ID
|
||||
assert "Иван Петров" in header_call["text"]
|
||||
assert "@client_ivan" in header_call["text"]
|
||||
|
||||
mirror_call = calls[1][1]
|
||||
assert mirror_call["from_chat_id"] == 555
|
||||
assert mirror_call["chat_id"] == SUPPORT_CHAT_ID
|
||||
assert mirror_call["message_id"] == 1
|
||||
assert mirror_call["message_thread_id"] == SUPPORT_TOPIC_ID
|
||||
|
||||
assert len(storage.messages) == 1
|
||||
rec = storage.messages[0]
|
||||
assert rec["direction"] == "in"
|
||||
assert rec["chat_id"] == 555
|
||||
assert rec["topic_message_id"] == 555 # copyMessage result.message_id
|
||||
assert rec["kind"] == "text"
|
||||
assert rec["text_body"] == "Здравствуйте, вопрос по trade-in"
|
||||
|
||||
assert storage.get_offset() == 10
|
||||
assert storage.commits == 1
|
||||
assert 555 in storage.users
|
||||
|
||||
|
||||
async def test_private_message_second_message_within_window_skips_header() -> None:
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({"copyMessage": {"message_id": 556}}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
# Симулируем уже существующее inbound-сообщение за последний час.
|
||||
storage.record_message(
|
||||
chat_id=555,
|
||||
direction="in",
|
||||
tg_message_id=0,
|
||||
topic_message_id=100,
|
||||
kind="text",
|
||||
text_body="первое сообщение",
|
||||
operator_tg_id=None,
|
||||
)
|
||||
|
||||
update = {"update_id": 11, "message": _private_message(message_id=2)}
|
||||
await bridge.process_update(update, client, storage)
|
||||
|
||||
methods = [m for m, _ in calls]
|
||||
# Шапка НЕ отправляется повторно — только зеркало.
|
||||
assert methods == ["copyMessage"]
|
||||
assert len(storage.messages) == 2
|
||||
|
||||
|
||||
async def test_private_message_header_resent_after_window_expires() -> None:
|
||||
"""#6 review: throttle-окно (3600с) реально проверяется по времени — после
|
||||
истечения окна шапка отправляется заново (не одна на весь чат навсегда)."""
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({"copyMessage": {"message_id": 557}}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
storage.record_message(
|
||||
chat_id=555,
|
||||
direction="in",
|
||||
tg_message_id=0,
|
||||
topic_message_id=100,
|
||||
kind="text",
|
||||
text_body="первое сообщение (час назад)",
|
||||
operator_tg_id=None,
|
||||
)
|
||||
# Продвигаем фейковые часы за throttle-окно (3600с).
|
||||
storage.clock_s += bridge._HEADER_THROTTLE_WINDOW_S + 1
|
||||
|
||||
update = {"update_id": 13, "message": _private_message(message_id=3)}
|
||||
await bridge.process_update(update, client, storage)
|
||||
|
||||
methods = [m for m, _ in calls]
|
||||
assert methods == ["sendMessage", "copyMessage"] # шапка снова отправлена
|
||||
assert len(storage.messages) == 2
|
||||
|
||||
|
||||
async def test_private_message_start_sends_greeting_without_mirroring() -> None:
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
|
||||
update = {"update_id": 12, "message": _private_message(text="/start")}
|
||||
await bridge.process_update(update, client, storage)
|
||||
|
||||
methods = [m for m, _ in calls]
|
||||
assert methods == ["sendMessage"]
|
||||
greeting_call = calls[0][1]
|
||||
assert greeting_call["chat_id"] == 555
|
||||
assert "МЕРА" in greeting_call["text"]
|
||||
# /start не зеркалируется и не попадает в лог переписки.
|
||||
assert storage.messages == []
|
||||
assert storage.get_offset() == 12
|
||||
|
||||
|
||||
async def test_private_message_notifies_client_when_support_chat_unset(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""#5 review: TELEGRAM_SUPPORT_CHAT_ID не задан → клиент получает "сервис
|
||||
недоступен" вместо того, чтобы молча ждать ответа, который никогда не придёт."""
|
||||
monkeypatch.setattr(bridge.settings, "telegram_support_chat_id", 0)
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
|
||||
update = {"update_id": 14, "message": _private_message()}
|
||||
await bridge.process_update(update, client, storage)
|
||||
|
||||
methods = [m for m, _ in calls]
|
||||
assert methods == ["sendMessage"]
|
||||
notify_call = calls[0][1]
|
||||
assert notify_call["chat_id"] == 555
|
||||
assert notify_call["text"] == bridge.SERVICE_UNAVAILABLE_TEXT
|
||||
# Ничего не зеркалируется и не пишется в лог переписки — support-группа не настроена.
|
||||
assert storage.messages == []
|
||||
assert storage.get_offset() == 14
|
||||
|
||||
|
||||
# ── F) флуд-лимит на отправителя (низкий приоритет) ─────────────────────────
|
||||
|
||||
|
||||
async def test_private_message_flood_limit_blocks_excess_and_notifies_once() -> None:
|
||||
"""Больше `_FLOOD_LIMIT` сообщений от ОДНОГО chat_id за окно — зеркалирование
|
||||
сверх лимита отключается (никакого copyMessage, никакой записи в
|
||||
tg_support_messages — маршрутизировать ответ всё равно нечего без
|
||||
topic_message_id). Клиент получает уведомление о недоставке РОВНО один раз
|
||||
за окно, а не на каждое следующее превышение — иначе само уведомление стало
|
||||
бы вторым источником флуда."""
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({"copyMessage": {"message_id": 900}}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
|
||||
update_id = 100
|
||||
for i in range(bridge._FLOOD_LIMIT):
|
||||
update = {"update_id": update_id, "message": _private_message(message_id=i + 1)}
|
||||
await bridge.process_update(update, client, storage)
|
||||
update_id += 1
|
||||
|
||||
# Ровно _FLOOD_LIMIT сообщений прошли мирроринг: первое — шапка + зеркало,
|
||||
# остальные — только зеркало.
|
||||
mirrored_calls = [m for m, _ in calls if m == "copyMessage"]
|
||||
assert len(mirrored_calls) == bridge._FLOOD_LIMIT
|
||||
assert len(storage.messages) == bridge._FLOOD_LIMIT
|
||||
|
||||
calls.clear()
|
||||
over_limit_update = {
|
||||
"update_id": update_id,
|
||||
"message": _private_message(message_id=bridge._FLOOD_LIMIT + 1),
|
||||
}
|
||||
await bridge.process_update(over_limit_update, client, storage)
|
||||
update_id += 1
|
||||
|
||||
# Сверх лимита — НЕ зеркалируется, НЕ пишется в лог переписки, клиент
|
||||
# получает уведомление о недоставке (не тихий игнор — клиент не должен
|
||||
# решить, что оператор получил сообщение).
|
||||
assert len(calls) == 1
|
||||
method, payload = calls[0]
|
||||
assert method == "sendMessage"
|
||||
assert payload["chat_id"] == 555
|
||||
assert payload["text"] == bridge.FLOOD_LIMITED_TEXT
|
||||
assert len(storage.messages) == bridge._FLOOD_LIMIT
|
||||
|
||||
calls.clear()
|
||||
second_over_limit_update = {
|
||||
"update_id": update_id,
|
||||
"message": _private_message(message_id=bridge._FLOOD_LIMIT + 2),
|
||||
}
|
||||
await bridge.process_update(second_over_limit_update, client, storage)
|
||||
|
||||
# Повторное превышение в ТОМ ЖЕ окне — уведомление подавлено (не второй
|
||||
# источник флуда), никаких Telegram-вызовов вообще.
|
||||
assert calls == []
|
||||
assert len(storage.messages) == bridge._FLOOD_LIMIT
|
||||
|
||||
|
||||
async def test_private_message_flood_limit_does_not_block_other_client() -> None:
|
||||
"""Флуд-лимит — per-chat_id: клиент А исчерпал свой бюджет, но клиент Б
|
||||
(другой chat_id) продолжает получать зеркалирование как обычно — один
|
||||
флудящий клиент не блокирует доставку сообщений остальным (сама суть
|
||||
задачи — воркер однопоточный, но лимит не даёт флудеру монополизировать
|
||||
его через Telegram 429)."""
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({"copyMessage": {"message_id": 901}}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
|
||||
flooding_chat_id = 555
|
||||
update_id = 300
|
||||
for i in range(bridge._FLOOD_LIMIT + 2):
|
||||
update = {
|
||||
"update_id": update_id,
|
||||
"message": _private_message(chat_id=flooding_chat_id, message_id=i + 1),
|
||||
}
|
||||
await bridge.process_update(update, client, storage)
|
||||
update_id += 1
|
||||
|
||||
calls.clear()
|
||||
|
||||
other_chat_id = 777001
|
||||
other_update = {
|
||||
"update_id": update_id,
|
||||
"message": _private_message(chat_id=other_chat_id, message_id=1, username="another_client"),
|
||||
}
|
||||
await bridge.process_update(other_update, client, storage)
|
||||
|
||||
methods = [m for m, _ in calls]
|
||||
# Другой клиент получает шапку (первое обращение) + зеркало как обычно —
|
||||
# флуд первого клиента на него не влияет.
|
||||
assert methods == ["sendMessage", "copyMessage"]
|
||||
mirror_call = calls[1][1]
|
||||
assert mirror_call["from_chat_id"] == other_chat_id
|
||||
|
||||
|
||||
# ── B) реплай оператора → user ──────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_group_reply_delivers_to_client_and_records_outbound() -> None:
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({"copyMessage": {"message_id": 42}}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
storage.record_message(
|
||||
chat_id=555,
|
||||
direction="in",
|
||||
tg_message_id=1,
|
||||
topic_message_id=100,
|
||||
kind="text",
|
||||
text_body="вопрос клиента",
|
||||
operator_tg_id=None,
|
||||
)
|
||||
|
||||
update = {
|
||||
"update_id": 20,
|
||||
"message": _group_reply_message(reply_to_message_id=100),
|
||||
}
|
||||
await bridge.process_update(update, client, storage)
|
||||
|
||||
methods = [m for m, _ in calls]
|
||||
assert methods == ["copyMessage"]
|
||||
delivery = calls[0][1]
|
||||
assert delivery["chat_id"] == 555
|
||||
assert delivery["from_chat_id"] == SUPPORT_CHAT_ID
|
||||
|
||||
assert len(storage.messages) == 2
|
||||
out_rec = storage.messages[-1]
|
||||
assert out_rec["direction"] == "out"
|
||||
assert out_rec["chat_id"] == 555
|
||||
assert out_rec["operator_tg_id"] == 777
|
||||
assert out_rec["text_body"] == "Ответ оператора"
|
||||
assert storage.get_offset() == 20
|
||||
|
||||
|
||||
async def test_group_reply_not_a_reply_is_ignored() -> None:
|
||||
"""Обычное сообщение в топике (не реплай) — тихий игнор, никаких Telegram-вызовов."""
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
|
||||
update = {
|
||||
"update_id": 21,
|
||||
"message": _group_reply_message(reply_to_message_id=None),
|
||||
}
|
||||
await bridge.process_update(update, client, storage)
|
||||
|
||||
assert calls == []
|
||||
assert storage.messages == []
|
||||
# offset всё равно сдвигается — апдейт "обработан" (даже если ничего не сделано).
|
||||
assert storage.get_offset() == 21
|
||||
|
||||
|
||||
async def test_group_reply_to_unknown_message_is_ignored() -> None:
|
||||
"""Реплай на сообщение, которого нет в tg_support_messages как зеркало клиента —
|
||||
тихий игнор (обычная болтовня в топике на постороннее сообщение, не от бота)."""
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
|
||||
update = {
|
||||
"update_id": 22,
|
||||
"message": _group_reply_message(reply_to_message_id=999999),
|
||||
}
|
||||
await bridge.process_update(update, client, storage)
|
||||
|
||||
assert calls == []
|
||||
assert storage.messages == []
|
||||
assert storage.get_offset() == 22
|
||||
|
||||
|
||||
async def test_group_reply_to_bot_message_without_record_logs_orphaned_mirror_warning(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""#4 review: реплай на сообщение БОТА, которого нет в tg_support_messages, —
|
||||
вероятное осиротевшее зеркало (крах между copyMessage и commit). WARNING, не
|
||||
тихий игнор — оператор иначе решит, что ответ клиенту доставлен."""
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
|
||||
message = _group_reply_message(reply_to_message_id=100)
|
||||
message["reply_to_message"]["from"] = {"id": 999, "is_bot": True, "username": "MERAsupport_bot"}
|
||||
update = {"update_id": 24, "message": message}
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="app.services.tgbot.bridge"):
|
||||
await bridge.process_update(update, client, storage)
|
||||
|
||||
assert calls == [] # ответ НЕ доставлен — routing-ключ потерян
|
||||
assert "осиротевшее" in caplog.text
|
||||
assert storage.get_offset() == 24
|
||||
|
||||
|
||||
async def test_group_reply_to_non_bot_message_without_record_stays_silent(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Обычный реплай на сообщение ДРУГОГО ЧЕЛОВЕКА (не бота) в топике — реальная
|
||||
болтовня, никакого WARNING (дискриминатор `is_bot` работает в обе стороны)."""
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
|
||||
message = _group_reply_message(reply_to_message_id=101)
|
||||
message["reply_to_message"]["from"] = {"id": 42, "is_bot": False, "username": "colleague"}
|
||||
update = {"update_id": 25, "message": message}
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="app.services.tgbot.bridge"):
|
||||
await bridge.process_update(update, client, storage)
|
||||
|
||||
assert calls == []
|
||||
assert caplog.text == ""
|
||||
|
||||
|
||||
async def test_group_reply_403_marks_blocked_and_notifies_topic() -> None:
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({"copyMessage": 403}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
storage.record_message(
|
||||
chat_id=555,
|
||||
direction="in",
|
||||
tg_message_id=1,
|
||||
topic_message_id=100,
|
||||
kind="text",
|
||||
text_body="вопрос клиента",
|
||||
operator_tg_id=None,
|
||||
)
|
||||
|
||||
update = {
|
||||
"update_id": 23,
|
||||
"message": _group_reply_message(reply_to_message_id=100),
|
||||
}
|
||||
await bridge.process_update(update, client, storage)
|
||||
|
||||
methods = [m for m, _ in calls]
|
||||
assert methods == ["copyMessage", "sendMessage"]
|
||||
notify_call = calls[1][1]
|
||||
assert notify_call["chat_id"] == SUPPORT_CHAT_ID
|
||||
assert "заблокирован" in notify_call["text"]
|
||||
|
||||
assert 555 in storage.blocked
|
||||
# Неудачная доставка НЕ должна создавать фейковую запись 'out'.
|
||||
assert len(storage.messages) == 1
|
||||
assert storage.get_offset() == 23
|
||||
|
||||
|
||||
# ── B') реплай оператора → веб-чат (#tgsupport-web) ──────────────────────────
|
||||
|
||||
|
||||
async def test_group_reply_to_web_mirror_records_outbound_web_message() -> None:
|
||||
"""Реплай на зеркало веб-сообщения (не найдено в tg_support_messages, найдено
|
||||
среди web_support_messages) → записывается в веб-тред, БЕЗ Telegram-доставки
|
||||
(у веб-клиента нет личного чата с ботом)."""
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
# topic_message_id=300 -> thread_id=42, под ТЕКУЩИМ support_chat_id.
|
||||
storage.web_topic_to_thread[300] = (42, SUPPORT_CHAT_ID)
|
||||
|
||||
update = {
|
||||
"update_id": 60,
|
||||
"message": _group_reply_message(reply_to_message_id=300, text="Ответ по веб-чату"),
|
||||
}
|
||||
await bridge.process_update(update, client, storage)
|
||||
|
||||
# Никакого Telegram API вызова — веб-клиент не имеет личного чата с ботом.
|
||||
assert calls == []
|
||||
assert len(storage.web_out_messages) == 1
|
||||
rec = storage.web_out_messages[0]
|
||||
assert rec["thread_id"] == 42
|
||||
assert rec["text_body"] == "Ответ по веб-чату"
|
||||
assert rec["operator_tg_id"] == 777
|
||||
# tg-путь тоже не тронут — ни одной записи в tg_support_messages.
|
||||
assert storage.messages == []
|
||||
assert storage.get_offset() == 60
|
||||
|
||||
|
||||
async def test_group_reply_to_web_mirror_with_null_support_chat_id_matches_current_chat() -> None:
|
||||
"""Легаси-строка (до 187/188, support_chat_id=None) — лениентный wildcard,
|
||||
матчится под ЛЮБЫМ текущим support_chat_id (review M1)."""
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
storage.web_topic_to_thread[305] = (46, None)
|
||||
|
||||
update = {
|
||||
"update_id": 63,
|
||||
"message": _group_reply_message(reply_to_message_id=305, text="Ответ по легаси-зеркалу"),
|
||||
}
|
||||
await bridge.process_update(update, client, storage)
|
||||
|
||||
assert len(storage.web_out_messages) == 1
|
||||
assert storage.web_out_messages[0]["thread_id"] == 46
|
||||
|
||||
|
||||
async def test_group_reply_to_web_mirror_from_stale_support_chat_is_not_matched() -> None:
|
||||
"""#tgsupport-web review M1: зеркало, записанное под ДРУГИМ (не текущим,
|
||||
не None) support_chat_id — исторический артефакт ротации группы, НЕ валидный
|
||||
маршрут сегодня. Не матчится → падает в orphan-check (не-bot реплай — тихий
|
||||
игнор, никакой доставки в чужой/устаревший тред)."""
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
stale_chat_id = -999999999999
|
||||
storage.web_topic_to_thread[306] = (47, stale_chat_id)
|
||||
|
||||
update = {
|
||||
"update_id": 64,
|
||||
"message": _group_reply_message(reply_to_message_id=306),
|
||||
}
|
||||
await bridge.process_update(update, client, storage)
|
||||
|
||||
assert calls == []
|
||||
assert storage.web_out_messages == [] # НЕ доставлено в устаревший тред
|
||||
|
||||
|
||||
async def test_group_reply_to_web_mirror_without_text_is_refused_with_operator_notice(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Веб-чат — текстовый MVP: реплай медиа-типом (нет text/caption) на веб-зеркало
|
||||
не может быть доставлен — WARNING в лог И явное уведомление оператору в топике
|
||||
(review M2: раньше был тихий игнор, оператор был уверен что ответил)."""
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
storage.web_topic_to_thread[301] = (43, SUPPORT_CHAT_ID)
|
||||
|
||||
message = _group_reply_message(reply_to_message_id=301, message_id=201)
|
||||
del message["text"] # медиа-реплай без текста/caption
|
||||
message["voice"] = {"file_id": "x"}
|
||||
update = {"update_id": 61, "message": message}
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="app.services.tgbot.bridge"):
|
||||
await bridge.process_update(update, client, storage)
|
||||
|
||||
assert storage.web_out_messages == []
|
||||
assert "не текст" in caplog.text
|
||||
assert storage.get_offset() == 61
|
||||
|
||||
methods = [m for m, _ in calls]
|
||||
assert methods == ["sendMessage"]
|
||||
notice_call = calls[0][1]
|
||||
assert notice_call["chat_id"] == SUPPORT_CHAT_ID
|
||||
assert notice_call["text"] == bridge._WEB_UNSUPPORTED_MEDIA_REPLY_TEXT
|
||||
assert notice_call["reply_to_message_id"] == 201
|
||||
|
||||
|
||||
async def test_group_reply_to_web_mirror_with_photo_and_caption_is_refused_not_partial() -> None:
|
||||
"""Фото С ПОДПИСЬЮ на веб-зеркало — НЕ доставляем только подпись молча
|
||||
(клиент решил бы, что подпись — весь ответ): отказ целиком, как и без caption
|
||||
(review M2)."""
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
storage.web_topic_to_thread[302] = (44, SUPPORT_CHAT_ID)
|
||||
|
||||
message = _group_reply_message(reply_to_message_id=302, message_id=202)
|
||||
del message["text"]
|
||||
message["photo"] = [{"file_id": "x"}]
|
||||
message["caption"] = "Смотрите скриншот"
|
||||
update = {"update_id": 65, "message": message}
|
||||
|
||||
await bridge.process_update(update, client, storage)
|
||||
|
||||
assert storage.web_out_messages == [] # подпись НЕ доставлена как "весь ответ"
|
||||
methods = [m for m, _ in calls]
|
||||
assert methods == ["sendMessage"]
|
||||
assert calls[0][1]["text"] == bridge._WEB_UNSUPPORTED_MEDIA_REPLY_TEXT
|
||||
|
||||
|
||||
async def test_group_reply_refuses_delivery_when_both_tg_and_web_match(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""#tgsupport-web review M1: если topic_message_id одновременно резолвится и в
|
||||
tg_support_messages, И в web_support_messages (под ОДНИМ и тем же
|
||||
support_chat_id — целостность нарушена) — ГРОМКИЙ отказ (logger.error), НИКАКОЙ
|
||||
доставки ни в Telegram-личку, ни в веб-тред. Раньше tg-путь выбирался молча —
|
||||
misroute постороннему Telegram-клиенту (152-ФЗ risk)."""
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({"copyMessage": {"message_id": 999}}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
storage.record_message(
|
||||
chat_id=555,
|
||||
direction="in",
|
||||
tg_message_id=1,
|
||||
topic_message_id=400,
|
||||
kind="text",
|
||||
text_body="вопрос клиента",
|
||||
operator_tg_id=None,
|
||||
support_chat_id=SUPPORT_CHAT_ID,
|
||||
)
|
||||
storage.web_topic_to_thread[400] = (99, SUPPORT_CHAT_ID)
|
||||
|
||||
update = {
|
||||
"update_id": 62,
|
||||
"message": _group_reply_message(reply_to_message_id=400),
|
||||
}
|
||||
with caplog.at_level(logging.ERROR, logger="app.services.tgbot.bridge"):
|
||||
await bridge.process_update(update, client, storage)
|
||||
|
||||
assert calls == [] # ничего не доставлено НИ В ОДНУ сторону
|
||||
assert storage.web_out_messages == []
|
||||
assert len(storage.messages) == 1 # только исходное 'in', никакого 'out'
|
||||
assert "ОДНОВРЕМЕННО" in caplog.text
|
||||
assert storage.get_offset() == 62
|
||||
|
||||
|
||||
# ── C) дедуп ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_dedup_update_id_leq_offset_is_skipped_without_side_effects() -> None:
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({}, calls)
|
||||
storage = FakeBridgeStorage(offset=50)
|
||||
|
||||
update = {"update_id": 50, "message": _private_message()}
|
||||
await bridge.process_update(update, client, storage)
|
||||
|
||||
assert calls == []
|
||||
assert storage.messages == []
|
||||
assert storage.commits == 0 # ранний return — offset уже актуален, коммитить нечего
|
||||
assert storage.get_offset() == 50
|
||||
|
||||
update_older = {"update_id": 10, "message": _private_message()}
|
||||
await bridge.process_update(update_older, client, storage)
|
||||
assert calls == []
|
||||
assert storage.get_offset() == 50
|
||||
|
||||
|
||||
async def test_poison_pill_update_still_advances_offset() -> None:
|
||||
"""Апдейт, на котором обработчик упал (например, message без chat), не должен
|
||||
подвесить весь поток — offset сдвигается даже при исключении внутри handler'а."""
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
|
||||
malformed_message = {"message_id": 1, "chat": {"type": "private"}} # нет chat.id
|
||||
update = {"update_id": 30, "message": malformed_message}
|
||||
await bridge.process_update(update, client, storage)
|
||||
|
||||
assert storage.get_offset() == 30
|
||||
assert storage.commits == 1
|
||||
assert storage.messages == []
|
||||
|
||||
|
||||
async def test_db_error_during_processing_rolls_back_and_still_advances_offset() -> None:
|
||||
"""#3 review: SQLAlchemyError (напр. обрыв коннекта к БД при деплое) во время
|
||||
`record_message` → storage.rollback() ПЕРЕД save_offset, offset всё равно
|
||||
сдвигается. Без rollback() save_offset сам кинул бы PendingRollbackError →
|
||||
process_update вылетел бы без сохранения offset'а → следующая итерация
|
||||
переиграла бы тот же апдейт → copyMessage задублировал бы зеркало в топике
|
||||
на каждый повтор поллинга (воспроизведено ревьюером)."""
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({"copyMessage": {"message_id": 558}}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
storage.fail_next_record_message = True
|
||||
|
||||
update = {"update_id": 15, "message": _private_message()}
|
||||
await bridge.process_update(update, client, storage)
|
||||
|
||||
# copyMessage успел уйти в Telegram (реальная утечка мирроринга при DB-сбое —
|
||||
# известное ограничение атомарности между внешним API и БД, вне scope этого фикса),
|
||||
# но rollback() отработал, offset сдвинут, commit вызван РОВНО один раз (в finally).
|
||||
assert storage.rollbacks == 1
|
||||
assert storage.commits == 1
|
||||
assert storage.get_offset() == 15
|
||||
# Запись сообщения НЕ попала в storage (record_message упал до append).
|
||||
assert storage.messages == []
|
||||
|
||||
# Повторный вызов с тем же update_id теперь корректно дедупится — НЕ переигрывается.
|
||||
calls.clear()
|
||||
await bridge.process_update(update, client, storage)
|
||||
assert calls == []
|
||||
assert storage.get_offset() == 15
|
||||
|
||||
|
||||
async def test_update_without_update_id_is_ignored() -> None:
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
|
||||
await bridge.process_update({"message": _private_message()}, client, storage)
|
||||
|
||||
assert calls == []
|
||||
assert storage.commits == 0
|
||||
assert storage.get_offset() == 0
|
||||
|
||||
|
||||
# ── kind inference ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("message_extra", "expected_kind"),
|
||||
[
|
||||
({"text": "hi"}, "text"),
|
||||
({"photo": [{"file_id": "x"}]}, "photo"),
|
||||
({"document": {"file_id": "x"}}, "document"),
|
||||
({"video": {"file_id": "x"}}, "video"),
|
||||
({"voice": {"file_id": "x"}}, "voice"),
|
||||
({"sticker": {"file_id": "x"}}, "other"),
|
||||
({"location": {"latitude": 1, "longitude": 2}}, "other"),
|
||||
({}, "other"),
|
||||
],
|
||||
)
|
||||
def test_infer_kind(message_extra: dict[str, Any], expected_kind: str) -> None:
|
||||
message = {"message_id": 1, "chat": {"id": 1, "type": "private"}, **message_extra}
|
||||
assert bridge._infer_kind(message) == expected_kind
|
||||
|
||||
|
||||
# ── unrelated chat types ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_update_from_unrelated_chat_is_ignored_but_offset_advances() -> None:
|
||||
"""Апдейт не из личного чата и не из support-группы (например, другой чат/канал)
|
||||
— молча игнорируется, offset всё равно сдвигается."""
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
|
||||
message = {
|
||||
"message_id": 1,
|
||||
"chat": {"id": -999, "type": "group"},
|
||||
"from": {"id": 1},
|
||||
"text": "болтовня в постороннем чате",
|
||||
}
|
||||
update = {"update_id": 40, "message": message}
|
||||
await bridge.process_update(update, client, storage)
|
||||
|
||||
assert calls == []
|
||||
assert storage.messages == []
|
||||
assert storage.get_offset() == 40
|
||||
164
tradein-mvp/backend/tests/services/tgbot/test_client.py
Normal file
164
tradein-mvp/backend/tests/services/tgbot/test_client.py
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
"""Unit tests for `app.services.tgbot.client.TelegramClient` retry/backoff logic.
|
||||
|
||||
NEVER calls real Telegram API — httpx.MockTransport only (consistent с
|
||||
tests/services/test_dadata.py). `asyncio.sleep` is patched to a no-op so retry
|
||||
tests run instantly regardless of configured backoff/retry_after durations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.services.tgbot.client import TelegramApiError, TelegramClient
|
||||
|
||||
_REAL_ASYNC_CLIENT = httpx.AsyncClient
|
||||
|
||||
|
||||
def _install_transport(handler) -> None:
|
||||
transport = httpx.MockTransport(handler)
|
||||
|
||||
def factory(*_: object, **__: object) -> httpx.AsyncClient:
|
||||
return _REAL_ASYNC_CLIENT(transport=transport)
|
||||
|
||||
mock.patch("app.services.tgbot.client.httpx.AsyncClient", factory).start()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stop_patches_and_noop_sleep():
|
||||
sleep_patcher = mock.patch("app.services.tgbot.client.asyncio.sleep", return_value=None)
|
||||
sleep_patcher.start()
|
||||
yield
|
||||
mock.patch.stopall()
|
||||
|
||||
|
||||
async def test_get_updates_happy_path_returns_list() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path.endswith("/getUpdates")
|
||||
return httpx.Response(200, json={"ok": True, "result": [{"update_id": 1}]})
|
||||
|
||||
_install_transport(handler)
|
||||
client = TelegramClient(token="fake-token")
|
||||
updates = await client.get_updates(offset=1)
|
||||
assert updates == [{"update_id": 1}]
|
||||
|
||||
|
||||
async def test_never_logs_or_leaks_token_in_request_url_host() -> None:
|
||||
"""Sanity: token lives only in the path, base host stays api.telegram.org."""
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured["url"] = str(request.url)
|
||||
return httpx.Response(200, json={"ok": True, "result": {}})
|
||||
|
||||
_install_transport(handler)
|
||||
client = TelegramClient(token="super-secret-token")
|
||||
await client.send_message(chat_id=1, text="hi")
|
||||
assert "bot" + "super-secret-token" in captured["url"] # goes over the wire, not logged
|
||||
|
||||
|
||||
async def test_copy_message_retries_on_429_then_succeeds() -> None:
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
return httpx.Response(
|
||||
429,
|
||||
json={
|
||||
"ok": False,
|
||||
"error_code": 429,
|
||||
"description": "Too Many Requests",
|
||||
"parameters": {"retry_after": 3},
|
||||
},
|
||||
)
|
||||
return httpx.Response(200, json={"ok": True, "result": {"message_id": 5}})
|
||||
|
||||
_install_transport(handler)
|
||||
client = TelegramClient(token="fake-token")
|
||||
result = await client.copy_message(chat_id=1, from_chat_id=2, message_id=3)
|
||||
|
||||
assert result == {"message_id": 5}
|
||||
assert calls["n"] == 2
|
||||
|
||||
|
||||
async def test_send_message_retries_on_5xx_then_succeeds() -> None:
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls["n"] += 1
|
||||
if calls["n"] < 3:
|
||||
return httpx.Response(
|
||||
502, json={"ok": False, "error_code": 502, "description": "bad gw"}
|
||||
)
|
||||
return httpx.Response(200, json={"ok": True, "result": {"message_id": 9}})
|
||||
|
||||
_install_transport(handler)
|
||||
client = TelegramClient(token="fake-token")
|
||||
result = await client.send_message(chat_id=1, text="retrying")
|
||||
|
||||
assert result == {"message_id": 9}
|
||||
assert calls["n"] == 3
|
||||
|
||||
|
||||
async def test_send_message_raises_immediately_on_non_retryable_4xx() -> None:
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls["n"] += 1
|
||||
return httpx.Response(
|
||||
403, json={"ok": False, "error_code": 403, "description": "Forbidden: bot blocked"}
|
||||
)
|
||||
|
||||
_install_transport(handler)
|
||||
client = TelegramClient(token="fake-token")
|
||||
|
||||
with pytest.raises(TelegramApiError) as exc_info:
|
||||
await client.send_message(chat_id=1, text="hi")
|
||||
|
||||
assert exc_info.value.error_code == 403
|
||||
assert calls["n"] == 1 # НЕ ретраится
|
||||
|
||||
|
||||
async def test_copy_message_gives_up_after_max_retries_on_persistent_5xx() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(500, json={"ok": False, "error_code": 500, "description": "boom"})
|
||||
|
||||
_install_transport(handler)
|
||||
client = TelegramClient(token="fake-token")
|
||||
|
||||
with pytest.raises(TelegramApiError) as exc_info:
|
||||
await client.copy_message(chat_id=1, from_chat_id=2, message_id=3)
|
||||
|
||||
assert exc_info.value.error_code == 500
|
||||
|
||||
|
||||
async def test_get_updates_returns_empty_list_on_malformed_result() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"ok": True, "result": "not-a-list"})
|
||||
|
||||
_install_transport(handler)
|
||||
client = TelegramClient(token="fake-token")
|
||||
assert await client.get_updates(offset=1) == []
|
||||
|
||||
|
||||
async def test_optional_thread_and_reply_params_omitted_when_falsy() -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
import json as _json
|
||||
|
||||
captured["body"] = _json.loads(request.content.decode("utf-8"))
|
||||
return httpx.Response(200, json={"ok": True, "result": {"message_id": 1}})
|
||||
|
||||
_install_transport(handler)
|
||||
client = TelegramClient(token="fake-token")
|
||||
await client.copy_message(chat_id=1, from_chat_id=2, message_id=3, message_thread_id=0)
|
||||
|
||||
assert "message_thread_id" not in captured["body"]
|
||||
|
|
@ -82,7 +82,6 @@ def _run_qa_estimate(
|
|||
ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"),
|
||||
radius_analogs: list[dict[str, Any]] | None = None,
|
||||
) -> Any:
|
||||
from app.core.config import settings
|
||||
from app.schemas.trade_in import TradeInEstimateInput
|
||||
from app.services.estimator import estimate_quality
|
||||
from app.services.geocoder import GeocodeResult
|
||||
|
|
@ -105,7 +104,6 @@ def _run_qa_estimate(
|
|||
|
||||
async def _run() -> Any:
|
||||
with (
|
||||
patch.object(settings, "estimate_same_building_anchor_enabled", True),
|
||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
|
||||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||||
|
|
|
|||
|
|
@ -1,20 +1,30 @@
|
|||
"""Tests for app.services.account_quota — monthly estimate quota enforcement.
|
||||
|
||||
Coverage:
|
||||
(a) admin и kopylov unlimited — не блокируются, increment является no-op
|
||||
(a) admin, kopylov, praktika unlimited — не блокируются, increment является no-op
|
||||
(b) обычный pilot-юзер блокируется на 16-м запросе (429 + нужный detail)
|
||||
(c) increment растит used счётчик
|
||||
(d) get_status корректен для different сценариев
|
||||
(e) отсутствие заголовка X-Authenticated-User = unlimited (fail-open)
|
||||
(f) #747 — атомарно-условный increment (TOCTOU fix)
|
||||
(g) account_quota_overrides.monthly_limit — персональный лимит вместо negative-used
|
||||
хака
|
||||
(h) insufficient_data результат НЕ инкрементит квоту (geocode-fail не сжигает слот)
|
||||
(i) account_quota_overrides.unlimited — data-driven безлимит (migration 191):
|
||||
kopylov (перенесён из хардкода) и praktika (восстановленный пилот) безлимитны
|
||||
через таблицу, не через код
|
||||
|
||||
DB мокируется через MagicMock — реальная БД не требуется.
|
||||
DB мокируется через _FakeDB (роутинг по SQL-тексту, см. ниже) — реальная БД не
|
||||
требуется.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
# psycopg v3 driver required; stub DATABASE_URL before any app import
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
|
@ -29,6 +39,7 @@ import pytest # noqa: E402
|
|||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from app.schemas.trade_in import AggregatedEstimate # noqa: E402
|
||||
from app.services.account_quota import ( # noqa: E402
|
||||
LIMIT_EXHAUSTED_MESSAGE,
|
||||
MONTHLY_LIMIT,
|
||||
|
|
@ -36,81 +47,173 @@ from app.services.account_quota import ( # noqa: E402
|
|||
get_status,
|
||||
increment,
|
||||
is_unlimited,
|
||||
user_limit,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# is_unlimited() теперь (migration 191) может как шорткатиться БЕЗ похода в БД
|
||||
# (роль admin, ИЛИ username вообще не в roles.yaml → KeyError), так и делать
|
||||
# реальный SELECT unlimited FROM account_quota_overrides (обычный pilot / kopylov /
|
||||
# praktika). Это значит, что порядок/количество db.execute() вызовов зависит от
|
||||
# username, а не только от вызываемой функции — позиционные side_effect-списки
|
||||
# были бы хрупкими. Вместо этого _FakeDB роутит execute() по ТЕКСТУ SQL, что
|
||||
# устойчиво к тому, сколько раз и в каком порядке реально стучимся в БД.
|
||||
|
||||
|
||||
def _db_with_used(used: int) -> MagicMock:
|
||||
"""DB session mock whose execute().fetchone() returns row with .used = N."""
|
||||
row = MagicMock()
|
||||
row.used = used
|
||||
execute_result = MagicMock()
|
||||
execute_result.fetchone.return_value = row
|
||||
db = MagicMock()
|
||||
db.execute.return_value = execute_result
|
||||
return db
|
||||
class _Row:
|
||||
"""Row stand-in: произвольные named-поля + позиционный доступ row[0]
|
||||
(нужен increment() для RETURNING used в debug-логе)."""
|
||||
|
||||
def __init__(self, **fields: int | bool) -> None:
|
||||
for name, value in fields.items():
|
||||
setattr(self, name, value)
|
||||
self._t = tuple(fields.values())
|
||||
|
||||
def __getitem__(self, i: int) -> int:
|
||||
return self._t[i]
|
||||
|
||||
|
||||
def _db_no_row() -> MagicMock:
|
||||
"""DB session mock where no row exists yet (first estimate of the month)."""
|
||||
execute_result = MagicMock()
|
||||
execute_result.fetchone.return_value = None
|
||||
db = MagicMock()
|
||||
db.execute.return_value = execute_result
|
||||
return db
|
||||
class _FakeDB:
|
||||
"""DB session mock, роутит execute() по подстроке в SQL-тексте, а не по
|
||||
порядку вызова — устойчив к тому, что is_unlimited() иногда обращается к БД
|
||||
(обычный pilot / kopylov / praktika), а иногда шорткатится без неё (admin /
|
||||
неизвестный username)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
unlimited: bool | None = None,
|
||||
override_limit: int | None = None,
|
||||
used: int | None = None,
|
||||
upsert_used: int | None = None,
|
||||
) -> None:
|
||||
self.unlimited = unlimited
|
||||
self.override_limit = override_limit
|
||||
self.used = used
|
||||
self.upsert_used = upsert_used
|
||||
self.commits = 0
|
||||
self.execute_calls: list[tuple[str, dict]] = []
|
||||
|
||||
def execute(self, stmt: object, params: dict | None = None) -> MagicMock:
|
||||
sql = str(stmt)
|
||||
self.execute_calls.append((sql, dict(params or {})))
|
||||
result = MagicMock()
|
||||
if "SELECT unlimited FROM account_quota_overrides" in sql:
|
||||
result.fetchone.return_value = (
|
||||
None if self.unlimited is None else _Row(unlimited=self.unlimited)
|
||||
)
|
||||
elif "SELECT monthly_limit FROM account_quota_overrides" in sql:
|
||||
result.fetchone.return_value = (
|
||||
None if self.override_limit is None else _Row(monthly_limit=self.override_limit)
|
||||
)
|
||||
elif "INSERT INTO account_estimate_usage" in sql:
|
||||
result.fetchone.return_value = (
|
||||
None if self.upsert_used is None else _Row(used=self.upsert_used)
|
||||
)
|
||||
elif "SELECT used FROM account_estimate_usage" in sql:
|
||||
result.fetchone.return_value = None if self.used is None else _Row(used=self.used)
|
||||
else:
|
||||
raise AssertionError(f"_FakeDB: unrecognized SQL: {sql!r}")
|
||||
return result
|
||||
|
||||
def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
||||
|
||||
def _override_result(limit: int | None) -> MagicMock:
|
||||
"""Mock результата запроса account_quota_overrides.monthly_limit — для тестов,
|
||||
вызывающих user_limit() напрямую (без is_unlimited в цепочке)."""
|
||||
result = MagicMock()
|
||||
result.fetchone.return_value = None if limit is None else _Row(monthly_limit=limit)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (a) admin and kopylov are unlimited
|
||||
# (a) admin / kopylov / praktika unlimited
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_is_unlimited_admin() -> None:
|
||||
assert is_unlimited("admin") is True
|
||||
"""admin шорткатится по роли — БЕЗ похода в БД."""
|
||||
db = MagicMock()
|
||||
assert is_unlimited(db, "admin") is True
|
||||
db.execute.assert_not_called()
|
||||
|
||||
|
||||
def test_is_unlimited_kopylov() -> None:
|
||||
assert is_unlimited("kopylov") is True
|
||||
"""kopylov — unlimited через account_quota_overrides.unlimited=true (migration
|
||||
191), не через хардкод в коде."""
|
||||
db = _FakeDB(unlimited=True)
|
||||
assert is_unlimited(db, "kopylov") is True
|
||||
|
||||
|
||||
def test_is_unlimited_praktika() -> None:
|
||||
"""praktika — восстановленный пилот с безлимитным грантом (migration 191)."""
|
||||
db = _FakeDB(unlimited=True)
|
||||
assert is_unlimited(db, "praktika") is True
|
||||
|
||||
|
||||
def test_is_unlimited_pilot_user1() -> None:
|
||||
assert is_unlimited("user1") is False
|
||||
"""user1 — обычный pilot, нет override-строки → limited."""
|
||||
db = _FakeDB()
|
||||
assert is_unlimited(db, "user1") is False
|
||||
|
||||
|
||||
def test_is_unlimited_unknown_user() -> None:
|
||||
"""Неизвестный пользователь → False (KeyError трактуется как limited)."""
|
||||
assert is_unlimited("ghost_unknown_xyz") is False
|
||||
"""Неизвестный пользователь → False (KeyError трактуется как limited), БЕЗ
|
||||
похода в БД."""
|
||||
db = MagicMock()
|
||||
assert is_unlimited(db, "ghost_unknown_xyz") is False
|
||||
db.execute.assert_not_called()
|
||||
|
||||
|
||||
def test_check_and_raise_admin_not_blocked() -> None:
|
||||
"""admin с used=15 не получает 429."""
|
||||
db = _db_with_used(MONTHLY_LIMIT)
|
||||
# Should not raise
|
||||
check_and_raise(db, "admin")
|
||||
db = MagicMock()
|
||||
check_and_raise(db, "admin") # не должно поднять исключение
|
||||
db.execute.assert_not_called()
|
||||
|
||||
|
||||
def test_check_and_raise_kopylov_not_blocked() -> None:
|
||||
"""kopylov с used=100 не получает 429."""
|
||||
db = _db_with_used(100)
|
||||
check_and_raise(db, "kopylov")
|
||||
"""kopylov с used=100 (гипотетически) не получает 429 — is_unlimited гейтит
|
||||
раньше usage-lookup."""
|
||||
db = _FakeDB(unlimited=True)
|
||||
check_and_raise(db, "kopylov") # не должно поднять исключение
|
||||
|
||||
|
||||
def test_check_and_raise_praktika_not_blocked() -> None:
|
||||
"""praktika (unlimited=true) не получает 429 независимо от used."""
|
||||
db = _FakeDB(unlimited=True)
|
||||
check_and_raise(db, "praktika") # не должно поднять исключение
|
||||
|
||||
|
||||
def test_increment_admin_is_noop() -> None:
|
||||
"""increment для admin → никаких db.execute вызовов."""
|
||||
db = MagicMock()
|
||||
increment(db, "admin")
|
||||
assert increment(db, "admin") is True
|
||||
db.execute.assert_not_called()
|
||||
db.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_increment_kopylov_is_noop() -> None:
|
||||
"""increment для kopylov → no-op."""
|
||||
db = MagicMock()
|
||||
increment(db, "kopylov")
|
||||
db.execute.assert_not_called()
|
||||
"""increment для kopylov (unlimited=true) → True, БЕЗ UPSERT/commit —
|
||||
is_unlimited() гейтит раньше инкремента (единственный execute — проверка
|
||||
unlimited-флага, не usage-UPSERT)."""
|
||||
db = _FakeDB(unlimited=True)
|
||||
assert increment(db, "kopylov") is True
|
||||
assert db.commits == 0
|
||||
assert not any("INSERT INTO account_estimate_usage" in sql for sql, _ in db.execute_calls)
|
||||
|
||||
|
||||
def test_increment_praktika_is_noop() -> None:
|
||||
"""increment для praktika (unlimited=true) → True, без UPSERT/commit."""
|
||||
db = _FakeDB(unlimited=True)
|
||||
assert increment(db, "praktika") is True
|
||||
assert db.commits == 0
|
||||
assert not any("INSERT INTO account_estimate_usage" in sql for sql, _ in db.execute_calls)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -120,17 +223,17 @@ def test_increment_kopylov_is_noop() -> None:
|
|||
|
||||
def test_check_and_raise_pilot_not_blocked_at_14() -> None:
|
||||
"""used=14 < 15 → не блокируется."""
|
||||
db = _db_with_used(14)
|
||||
db = _FakeDB(used=14)
|
||||
check_and_raise(db, "user1") # должно пройти без исключения
|
||||
|
||||
|
||||
def test_check_and_raise_pilot_not_blocked_at_15_boundary() -> None:
|
||||
"""used=15 == MONTHLY_LIMIT → 429 (15-я — последняя допустимая, 16-я блокируется).
|
||||
|
||||
Логика: used >= MONTHLY_LIMIT → block. После 15-й успешной оценки
|
||||
Логика: used >= limit → block. После 15-й успешной оценки
|
||||
increment делает used=15, поэтому следующий запрос (16-й) блокируется.
|
||||
"""
|
||||
db = _db_with_used(MONTHLY_LIMIT)
|
||||
db = _FakeDB(used=MONTHLY_LIMIT)
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
|
|
@ -141,7 +244,7 @@ def test_check_and_raise_pilot_not_blocked_at_15_boundary() -> None:
|
|||
|
||||
def test_check_and_raise_pilot_blocked_exact_detail() -> None:
|
||||
"""Проверяем точный текст сообщения 429."""
|
||||
db = _db_with_used(MONTHLY_LIMIT)
|
||||
db = _FakeDB(used=MONTHLY_LIMIT)
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
|
|
@ -153,8 +256,8 @@ def test_check_and_raise_pilot_blocked_exact_detail() -> None:
|
|||
|
||||
|
||||
def test_check_and_raise_pilot_blocked_over_limit() -> None:
|
||||
"""used=20 тоже блокируется."""
|
||||
db = _db_with_used(20)
|
||||
"""used=20 тоже блокируется (нет override → глобальный лимит)."""
|
||||
db = _FakeDB(used=20)
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
|
|
@ -168,17 +271,17 @@ def test_check_and_raise_pilot_blocked_over_limit() -> None:
|
|||
|
||||
|
||||
def test_increment_pilot_calls_upsert() -> None:
|
||||
"""increment для pilot → выполняет db.execute (UPSERT) и db.commit."""
|
||||
db = MagicMock()
|
||||
increment(db, "user1")
|
||||
db.execute.assert_called_once()
|
||||
db.commit.assert_called_once()
|
||||
"""increment для pilot → is_unlimited-lookup + user_limit-lookup + UPSERT
|
||||
(3 execute), 1 commit."""
|
||||
db = _FakeDB(upsert_used=1)
|
||||
result = increment(db, "user1")
|
||||
assert result is True
|
||||
assert len(db.execute_calls) == 3
|
||||
assert db.commits == 1
|
||||
|
||||
# Проверяем что SQL содержит ON CONFLICT ... DO UPDATE
|
||||
call_args = db.execute.call_args
|
||||
sql_text = str(call_args[0][0]) # first positional arg — text() object
|
||||
assert "ON CONFLICT" in sql_text
|
||||
assert "used" in sql_text
|
||||
upsert_calls = [sql for sql, _ in db.execute_calls if "ON CONFLICT" in sql]
|
||||
assert len(upsert_calls) == 1
|
||||
assert "used" in upsert_calls[0]
|
||||
|
||||
|
||||
def test_increment_none_username_is_noop() -> None:
|
||||
|
|
@ -190,9 +293,9 @@ def test_increment_none_username_is_noop() -> None:
|
|||
|
||||
def test_increment_pilot_first_estimate_of_month() -> None:
|
||||
"""Первый инкремент (нет строки в БД) — должен всё равно выполнить UPSERT."""
|
||||
db = MagicMock()
|
||||
db = _FakeDB(upsert_used=1)
|
||||
increment(db, "user5")
|
||||
db.execute.assert_called_once()
|
||||
assert len(db.execute_calls) == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -212,17 +315,29 @@ def test_get_status_none_username() -> None:
|
|||
|
||||
|
||||
def test_get_status_admin() -> None:
|
||||
"""admin → unlimited, remaining=15 вне зависимости от used."""
|
||||
db = _db_with_used(7)
|
||||
"""admin → unlimited, remaining=limit вне зависимости от used."""
|
||||
db = _FakeDB(used=7)
|
||||
status = get_status(db, "admin")
|
||||
assert status["unlimited"] is True
|
||||
assert status["remaining"] == MONTHLY_LIMIT
|
||||
assert status["used"] == 7 # фактический used из БД
|
||||
|
||||
|
||||
def test_get_status_praktika_unlimited() -> None:
|
||||
"""praktika (unlimited=true) → unlimited=True, remaining=limit, без деления
|
||||
на ноль и без «Осталось N из 0» (limit берётся из user_limit(), не 0)."""
|
||||
db = _FakeDB(unlimited=True, override_limit=999_999, used=42)
|
||||
status = get_status(db, "praktika")
|
||||
assert status["unlimited"] is True
|
||||
assert status["limit"] == 999_999
|
||||
assert status["remaining"] == 999_999
|
||||
assert status["used"] == 42
|
||||
assert status["limit"] > 0 # защита от «N из 0»
|
||||
|
||||
|
||||
def test_get_status_pilot_with_used() -> None:
|
||||
"""pilot с used=10 → remaining=5."""
|
||||
db = _db_with_used(10)
|
||||
db = _FakeDB(used=10)
|
||||
status = get_status(db, "user2")
|
||||
assert status["unlimited"] is False
|
||||
assert status["used"] == 10
|
||||
|
|
@ -232,7 +347,7 @@ def test_get_status_pilot_with_used() -> None:
|
|||
|
||||
def test_get_status_pilot_no_row_yet() -> None:
|
||||
"""Новый месяц — строки нет → used=0, remaining=15."""
|
||||
db = _db_no_row()
|
||||
db = _FakeDB()
|
||||
status = get_status(db, "user3")
|
||||
assert status["used"] == 0
|
||||
assert status["remaining"] == MONTHLY_LIMIT
|
||||
|
|
@ -241,7 +356,7 @@ def test_get_status_pilot_no_row_yet() -> None:
|
|||
|
||||
def test_get_status_pilot_exhausted() -> None:
|
||||
"""used=15 → remaining=0."""
|
||||
db = _db_with_used(MONTHLY_LIMIT)
|
||||
db = _FakeDB(used=MONTHLY_LIMIT)
|
||||
status = get_status(db, "user4")
|
||||
assert status["remaining"] == 0
|
||||
assert status["unlimited"] is False
|
||||
|
|
@ -249,7 +364,7 @@ def test_get_status_pilot_exhausted() -> None:
|
|||
|
||||
def test_get_status_pilot_over_limit_remaining_zero() -> None:
|
||||
"""used=20 → remaining=0 (не отрицательное)."""
|
||||
db = _db_with_used(20)
|
||||
db = _FakeDB(used=20)
|
||||
status = get_status(db, "user5")
|
||||
assert status["remaining"] == 0
|
||||
|
||||
|
|
@ -281,7 +396,7 @@ def quota_app() -> FastAPI:
|
|||
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
|
||||
|
||||
def _override_db():
|
||||
yield _db_no_row()
|
||||
yield _FakeDB()
|
||||
|
||||
application.dependency_overrides[get_db] = _override_db
|
||||
return application
|
||||
|
|
@ -324,6 +439,39 @@ def test_quota_endpoint_admin_unlimited(quota_app: FastAPI) -> None:
|
|||
assert data["unlimited"] is True
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def quota_app_praktika_unlimited() -> FastAPI:
|
||||
"""FastAPI app где БД отдаёт unlimited=true для praktika."""
|
||||
from app.api.v1 import trade_in as trade_in_module
|
||||
from app.core.db import get_db
|
||||
|
||||
application = FastAPI()
|
||||
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
|
||||
|
||||
def _override_db():
|
||||
yield _FakeDB(unlimited=True, override_limit=999_999, used=100)
|
||||
|
||||
application.dependency_overrides[get_db] = _override_db
|
||||
return application
|
||||
|
||||
|
||||
def test_quota_endpoint_praktika_unlimited(quota_app_praktika_unlimited: FastAPI) -> None:
|
||||
"""GET /quota с praktika (unlimited grant) → unlimited=True, осмысленный
|
||||
(не нулевой) limit/remaining — фронт (page.tsx) всё равно скрывает эти числа
|
||||
при unlimited=True, но backend не должен отдавать «0 из 0»."""
|
||||
client = TestClient(quota_app_praktika_unlimited)
|
||||
resp = client.get(
|
||||
"/api/v1/trade-in/quota",
|
||||
headers={"X-Authenticated-User": "praktika"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["unlimited"] is True
|
||||
assert data["limit"] > 0
|
||||
assert data["remaining"] > 0
|
||||
assert data["used"] == 100
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: POST /estimate quota enforcement через TestClient
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -339,7 +487,7 @@ def estimate_app_exhausted() -> FastAPI:
|
|||
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
|
||||
|
||||
def _override_db():
|
||||
yield _db_with_used(MONTHLY_LIMIT)
|
||||
yield _FakeDB(used=MONTHLY_LIMIT)
|
||||
|
||||
application.dependency_overrides[get_db] = _override_db
|
||||
return application
|
||||
|
|
@ -391,23 +539,45 @@ def test_estimate_no_header_not_blocked(estimate_app_exhausted: FastAPI) -> None
|
|||
assert resp.status_code != 429
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def estimate_app_praktika_unlimited() -> FastAPI:
|
||||
"""FastAPI app где БД отдаёт unlimited=true для praktika (used заведомо
|
||||
«за пределами» обычного лимита — проверяем что это НЕ блокирует)."""
|
||||
from app.api.v1 import trade_in as trade_in_module
|
||||
from app.core.db import get_db
|
||||
|
||||
application = FastAPI()
|
||||
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
|
||||
|
||||
def _override_db():
|
||||
yield _FakeDB(unlimited=True, override_limit=999_999, used=MONTHLY_LIMIT + 500)
|
||||
|
||||
application.dependency_overrides[get_db] = _override_db
|
||||
return application
|
||||
|
||||
|
||||
def test_estimate_praktika_not_blocked_429(estimate_app_praktika_unlimited: FastAPI) -> None:
|
||||
"""POST /estimate с praktika (unlimited=true, used far above обычного лимита)
|
||||
→ НЕ 429 — восстановленный пилот с безлимитным грантом не упирается в квоту."""
|
||||
client = TestClient(estimate_app_praktika_unlimited, raise_server_exceptions=False)
|
||||
resp = client.post(
|
||||
"/api/v1/trade-in/estimate",
|
||||
json={
|
||||
"address": "г. Екатеринбург, ул. Малышева, 1",
|
||||
"area_m2": 50.0,
|
||||
"rooms": 2,
|
||||
},
|
||||
headers={"X-Authenticated-User": "praktika"},
|
||||
)
|
||||
assert resp.status_code != 429
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (f) #747 — атомарно-условный increment (TOCTOU fix)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _Row:
|
||||
"""Row stand-in поддерживающий и .used, и индексный доступ row[0]."""
|
||||
|
||||
def __init__(self, used: int) -> None:
|
||||
self.used = used
|
||||
self._t = (used,)
|
||||
|
||||
def __getitem__(self, i: int) -> int:
|
||||
return self._t[i]
|
||||
|
||||
|
||||
class _Result:
|
||||
class _AtomicResult:
|
||||
def __init__(self, row: _Row | None) -> None:
|
||||
self._row = row
|
||||
|
||||
|
|
@ -416,28 +586,54 @@ class _Result:
|
|||
|
||||
|
||||
class _AtomicQuotaFakeDB:
|
||||
"""In-memory fake, моделирующий атомарный conditional UPSERT (#747).
|
||||
"""In-memory fake, воспроизводящий:
|
||||
- is_unlimited(): SELECT unlimited FROM account_quota_overrides ->
|
||||
unlimited_override (None → строки нет → not unlimited);
|
||||
- user_limit(): SELECT monthly_limit FROM account_quota_overrides ->
|
||||
override_limit (None → глобальный MONTHLY_LIMIT);
|
||||
- increment(): атомарный conditional UPSERT (#747) — WHERE used < :lim в SQL,
|
||||
эмулируется через params["lim"].
|
||||
|
||||
Воспроизводит семантику `INSERT ... ON CONFLICT DO UPDATE SET used=used+1
|
||||
WHERE used < :lim RETURNING used`:
|
||||
- строки ещё нет (used=None) → INSERT used=1, RETURNING row (WHERE не для INSERT);
|
||||
- used < lim → used+=1, RETURNING row;
|
||||
- used >= lim → конфликтная строка НЕ обновлена, RETURNING пуст (None).
|
||||
Роутинг по ТЕКСТУ SQL (не по наличию "lim" в params) — обе override-lookup
|
||||
query (is_unlimited и user_limit) не содержат "lim" в params, поэтому их нужно
|
||||
различать по содержимому запроса, а не по форме params.
|
||||
"""
|
||||
|
||||
def __init__(self, *, used: int | None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
used: int | None,
|
||||
override_limit: int | None = None,
|
||||
unlimited_override: bool | None = None,
|
||||
) -> None:
|
||||
self.used = used
|
||||
self.override_limit = override_limit
|
||||
self.unlimited_override = unlimited_override
|
||||
self.commits = 0
|
||||
|
||||
def execute(self, _stmt: object, params: dict) -> _Result:
|
||||
def execute(self, stmt: object, params: dict) -> _AtomicResult:
|
||||
sql = str(stmt)
|
||||
if "SELECT unlimited FROM account_quota_overrides" in sql:
|
||||
row = (
|
||||
_Row(unlimited=self.unlimited_override)
|
||||
if self.unlimited_override is not None
|
||||
else None
|
||||
)
|
||||
return _AtomicResult(row)
|
||||
if "SELECT monthly_limit FROM account_quota_overrides" in sql:
|
||||
row = (
|
||||
_Row(monthly_limit=self.override_limit) if self.override_limit is not None else None
|
||||
)
|
||||
return _AtomicResult(row)
|
||||
# UPSERT — WHERE used < :lim
|
||||
lim = params["lim"]
|
||||
if self.used is None:
|
||||
self.used = 1
|
||||
elif self.used < lim:
|
||||
self.used += 1
|
||||
else:
|
||||
return _Result(None)
|
||||
return _Result(_Row(self.used))
|
||||
return _AtomicResult(None)
|
||||
return _AtomicResult(_Row(used=self.used))
|
||||
|
||||
def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
|
@ -472,3 +668,219 @@ def test_increment_atomic_unlimited_noop_returns_true() -> None:
|
|||
assert increment(db, "admin") is True
|
||||
assert db.commits == 0
|
||||
assert db.used == MONTHLY_LIMIT # не тронут
|
||||
|
||||
|
||||
def test_increment_atomic_respects_override_limit() -> None:
|
||||
"""override=50 (user2): used=49 → increment True (used→50); used=50 → False.
|
||||
Глобальный MONTHLY_LIMIT (15) НЕ применяется — используется персональный override."""
|
||||
db = _AtomicQuotaFakeDB(used=49, override_limit=50)
|
||||
assert increment(db, "user2") is True
|
||||
assert db.used == 50
|
||||
assert increment(db, "user2") is False
|
||||
assert db.used == 50 # не 51
|
||||
|
||||
|
||||
def test_increment_atomic_override_below_global_blocks_early() -> None:
|
||||
"""override=5 (ниже глобального 15): used=5 уже блокирует, хотя < MONTHLY_LIMIT.
|
||||
|
||||
Демонстрирует что per-user override заменяет глобальный лимит полностью —
|
||||
не является дополнительным потолком поверх него. username вымышленный (не в
|
||||
roles.yaml) — is_unlimited() шорткатится на KeyError без похода в БД.
|
||||
"""
|
||||
db = _AtomicQuotaFakeDB(used=5, override_limit=5)
|
||||
assert increment(db, "user_low_override") is False
|
||||
assert db.used == 5 # не выросло
|
||||
|
||||
|
||||
def test_increment_atomic_kopylov_unlimited_bypasses_upsert() -> None:
|
||||
"""kopylov (unlimited=true через account_quota_overrides) — increment() True
|
||||
без похода в UPSERT-ветку, used в фейке не растёт."""
|
||||
db = _AtomicQuotaFakeDB(used=MONTHLY_LIMIT, unlimited_override=True)
|
||||
assert increment(db, "kopylov") is True
|
||||
assert db.commits == 0
|
||||
assert db.used == MONTHLY_LIMIT # не тронут — is_unlimited гейтит раньше UPSERT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (g) account_quota_overrides.monthly_limit — персональный лимит (замена
|
||||
# negative-used хака)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_user_limit_no_override_returns_global() -> None:
|
||||
"""Нет строки в account_quota_overrides → user_limit() возвращает MONTHLY_LIMIT."""
|
||||
db = MagicMock()
|
||||
db.execute.return_value = _override_result(None)
|
||||
assert user_limit(db, "user1") == MONTHLY_LIMIT
|
||||
|
||||
|
||||
def test_user_limit_with_override_returns_override() -> None:
|
||||
"""Есть строка override → user_limit() возвращает monthly_limit из неё."""
|
||||
db = MagicMock()
|
||||
db.execute.return_value = _override_result(50)
|
||||
assert user_limit(db, "user2") == 50
|
||||
|
||||
|
||||
def test_get_status_override_limit() -> None:
|
||||
"""user2 с override=50, used=0 (после сброса хака) → limit=50, remaining=50."""
|
||||
db = _FakeDB(used=0, override_limit=50)
|
||||
status = get_status(db, "user2")
|
||||
assert status["limit"] == 50
|
||||
assert status["remaining"] == 50
|
||||
assert status["used"] == 0
|
||||
|
||||
|
||||
def test_get_status_override_remaining_clamped_even_if_used_negative() -> None:
|
||||
"""Кламп: даже если used снова просочится отрицательным (regression прежнего
|
||||
negative-used хака), remaining НЕ превышает limit — не «Осталось 50 из 15»."""
|
||||
db = _FakeDB(used=-35, override_limit=50)
|
||||
status = get_status(db, "user2")
|
||||
assert status["limit"] == 50
|
||||
assert status["used"] == -35 # raw used не скрываем — диагностическая честность
|
||||
assert status["remaining"] == 50 # clamp: max(0, 50 - max(0, -35)) == 50
|
||||
assert status["remaining"] <= status["limit"]
|
||||
|
||||
|
||||
def test_check_and_raise_override_blocks_below_global_limit() -> None:
|
||||
"""override=5 (ниже глобального 15) — used=5 блокируется, хотя < MONTHLY_LIMIT.
|
||||
username вымышленный (не в roles.yaml) — is_unlimited() KeyError-шорткат."""
|
||||
db = _FakeDB(used=5, override_limit=5)
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
check_and_raise(db, "user_low_override")
|
||||
assert exc_info.value.status_code == 429
|
||||
|
||||
|
||||
def test_check_and_raise_override_allows_above_global_limit() -> None:
|
||||
"""override=50 — used=20 (> глобального 15) НЕ блокируется."""
|
||||
db = _FakeDB(used=20, override_limit=50)
|
||||
check_and_raise(db, "user2") # не должно поднять исключение
|
||||
|
||||
|
||||
def test_increment_override_blocks_at_override_not_global() -> None:
|
||||
"""increment уважает per-user override: used>=override → False, даже если
|
||||
used < MONTHLY_LIMIT (15)."""
|
||||
db = _FakeDB(override_limit=5, upsert_used=None) # WHERE used<5 не матчит
|
||||
assert increment(db, "user_low_override") is False
|
||||
|
||||
|
||||
def test_increment_override_allows_above_global_limit() -> None:
|
||||
"""increment с override=50: used=20 (>15 глобального) успешно инкрементит."""
|
||||
db = _FakeDB(override_limit=50, upsert_used=21)
|
||||
assert increment(db, "user2") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (h) insufficient_data результат НЕ инкрементит квоту (geocode-fail не сжигает слот)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _insufficient_estimate() -> AggregatedEstimate:
|
||||
"""Пустой результат — как _empty_estimate() в estimator.py: median=0 → #697
|
||||
computed_field insufficient_data=True."""
|
||||
return AggregatedEstimate(
|
||||
estimate_id=uuid4(),
|
||||
median_price_rub=0,
|
||||
range_low_rub=0,
|
||||
range_high_rub=0,
|
||||
median_price_per_m2=0,
|
||||
confidence="low",
|
||||
confidence_explanation="address_not_geocoded",
|
||||
n_analogs=0,
|
||||
period_months=24,
|
||||
analogs=[],
|
||||
actual_deals=[],
|
||||
expires_at=datetime.now(tz=UTC) + timedelta(hours=24),
|
||||
)
|
||||
|
||||
|
||||
def _real_estimate() -> AggregatedEstimate:
|
||||
"""Непустой результат — insufficient_data=False (median_price_rub > 0)."""
|
||||
return AggregatedEstimate(
|
||||
estimate_id=uuid4(),
|
||||
median_price_rub=5_000_000,
|
||||
range_low_rub=4_500_000,
|
||||
range_high_rub=5_500_000,
|
||||
median_price_per_m2=100_000,
|
||||
confidence="medium",
|
||||
n_analogs=8,
|
||||
period_months=24,
|
||||
analogs=[],
|
||||
actual_deals=[],
|
||||
expires_at=datetime.now(tz=UTC) + timedelta(hours=24),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def estimate_app_ok() -> FastAPI:
|
||||
"""FastAPI app где check_and_raise проходит (used=0, без override) —
|
||||
estimate_quality мокается отдельно в каждом тесте."""
|
||||
from app.api.v1 import trade_in as trade_in_module
|
||||
from app.core.db import get_db
|
||||
|
||||
application = FastAPI()
|
||||
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
|
||||
|
||||
def _override_db():
|
||||
yield _FakeDB()
|
||||
|
||||
application.dependency_overrides[get_db] = _override_db
|
||||
return application
|
||||
|
||||
|
||||
def test_estimate_insufficient_data_does_not_increment_quota(
|
||||
estimate_app_ok: FastAPI,
|
||||
) -> None:
|
||||
"""POST /estimate с insufficient_data=True результатом (нерезолвящийся адрес) →
|
||||
account_quota.increment НЕ вызывается (пустой результат не списывает платный слот)."""
|
||||
client = TestClient(estimate_app_ok, raise_server_exceptions=False)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.estimator.estimate_quality",
|
||||
new=AsyncMock(return_value=_insufficient_estimate()),
|
||||
),
|
||||
patch("app.services.account_quota.increment") as mock_increment,
|
||||
):
|
||||
resp = client.post(
|
||||
"/api/v1/trade-in/estimate",
|
||||
json={
|
||||
"address": "г. Екатеринбург, несуществующий адрес xyz",
|
||||
"area_m2": 50.0,
|
||||
"rooms": 2,
|
||||
},
|
||||
headers={"X-Authenticated-User": "user1"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["median_price_rub"] == 0
|
||||
assert resp.json()["insufficient_data"] is True
|
||||
mock_increment.assert_not_called()
|
||||
|
||||
|
||||
def test_estimate_real_result_still_increments_quota(estimate_app_ok: FastAPI) -> None:
|
||||
"""Контрольный тест: непустой результат (insufficient_data=False) — increment
|
||||
вызывается как обычно (регресс-guard, не ломаем #747 TOCTOU-семантику)."""
|
||||
client = TestClient(estimate_app_ok, raise_server_exceptions=False)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.estimator.estimate_quality",
|
||||
new=AsyncMock(return_value=_real_estimate()),
|
||||
),
|
||||
patch("app.services.account_quota.increment", return_value=True) as mock_increment,
|
||||
):
|
||||
resp = client.post(
|
||||
"/api/v1/trade-in/estimate",
|
||||
json={
|
||||
"address": "г. Екатеринбург, ул. Малышева, 1",
|
||||
"area_m2": 50.0,
|
||||
"rooms": 2,
|
||||
},
|
||||
headers={"X-Authenticated-User": "user1"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["insufficient_data"] is False
|
||||
mock_increment.assert_called_once()
|
||||
|
|
|
|||
|
|
@ -113,6 +113,22 @@ def test_rederivation_cte_blocks_match_080() -> None:
|
|||
assert f"{cte} AS" in _REDERIVE_SQL, f"missing CTE {cte!r}"
|
||||
|
||||
|
||||
def test_rederivation_scopes_sold_side_to_asking_city() -> None:
|
||||
"""#C2: SOLD-сторона (deal_side + deal_global) скоупится на город asking-стороны (ЕКБ).
|
||||
|
||||
Миграция 177 залила ДКП по всей обл.66, а asking (listings) — только ЕКБ. Без скоупа
|
||||
sold-медиана смешивала дешёвую область → ratio 0.877→0.62, «выкупная» −29%. Оба
|
||||
deal-CTE (per-rooms + global) должны нести предикат; ask-стороны — НЕ трогаем.
|
||||
"""
|
||||
assert ratio_mod._ASKING_CITY_PATTERN == "%Екатеринбург%"
|
||||
# Оба deal-CTE (deal_side + deal_global) скоупятся — ровно 2 вхождения.
|
||||
assert _REDERIVE_SQL.count("AND city ILIKE :asking_city") == 2
|
||||
# ask-стороны (listings) НЕ фильтруются по городу (в listings нет колонки city).
|
||||
_a = _REDERIVE_SQL.index("ask_side AS")
|
||||
_b = _REDERIVE_SQL.index("per_bucket AS")
|
||||
assert "asking_city" not in _REDERIVE_SQL[_a:_b]
|
||||
|
||||
|
||||
def _strip_sql(s: str) -> str:
|
||||
"""Drop -- line comments and collapse whitespace — leaves only the executable SQL.
|
||||
|
||||
|
|
@ -159,9 +175,14 @@ def test_migration_080_derivation_is_subset_of_refresh_sql() -> None:
|
|||
s,
|
||||
)
|
||||
|
||||
assert _strip_sql(_normalise_ppm2(_drop_segment_guard(seed_derivation))) in _strip_sql(
|
||||
_normalise_ppm2(_drop_segment_guard(_REDERIVE_SQL))
|
||||
)
|
||||
def _drop_city_guard(s: str) -> str:
|
||||
"""Remove the #C2 EKB city-scope predicate on the SOLD side (absent in the 080 seed)."""
|
||||
return re.sub(r"AND\s+city\s+ILIKE\s+:asking_city", "", s)
|
||||
|
||||
def _norm(s: str) -> str:
|
||||
return _strip_sql(_normalise_ppm2(_drop_city_guard(_drop_segment_guard(s))))
|
||||
|
||||
assert _norm(seed_derivation) in _norm(_REDERIVE_SQL)
|
||||
|
||||
|
||||
# ── Counters query ────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
344
tradein-mvp/backend/tests/test_audit_api.py
Normal file
344
tradein-mvp/backend/tests/test_audit_api.py
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
"""Tests for app.api.v1.audit — admin read API over `user_events` (Feature 2/3).
|
||||
|
||||
Coverage:
|
||||
(a) static SQL guard — CAST(:x AS type), never `:x::type` (psycopg v3 trap).
|
||||
(b) GET /audit/accounts — empty table → [] (never errors); populated → shape-valid
|
||||
AccountSummary rows pass through response_model unchanged.
|
||||
(c) GET /audit/accounts/{username} — empty table (incl. unknown username, NOT 404) →
|
||||
all 4 lists == []; populated → shape-valid drilldown rows.
|
||||
(d) GET /analytics — empty table → zeroed summary + empty lists (never errors);
|
||||
populated → shape-valid dashboard bundle, `days` query param clamped [1, 365].
|
||||
(e) optional real-Postgres round trip — inserts a couple of user_events rows and
|
||||
asserts aggregation through the real DB; self-SKIPS without a reachable,
|
||||
non-placeholder Postgres (mirrors tests/test_house_dedup_merge.py's live-DB
|
||||
pattern).
|
||||
|
||||
Router is tested in isolation on a minimal FastAPI app (mirrors tests/test_ratelimit.py
|
||||
and the quota_app fixture in tests/test_user_events.py) — no need to pull in the full
|
||||
app.main (sentry/scheduler/CORS/rate-limit wiring).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import UTC, date, datetime
|
||||
from typing import Any
|
||||
|
||||
# psycopg v3 driver required; stub DATABASE_URL before any app import (mirrors other tests).
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1 import audit as audit_module
|
||||
from app.core.db import get_db
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (a) Static SQL guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_AUDIT_SRC = inspect.getsource(audit_module)
|
||||
|
||||
|
||||
def test_no_psycopg_v3_doublecolon_cast() -> None:
|
||||
"""CAST(:x AS type) — НИКОГДА `:x::type` (psycopg v3 trap, .claude/rules/backend.md)."""
|
||||
assert not re.search(r":\w+::\w", _AUDIT_SRC)
|
||||
|
||||
|
||||
def test_days_param_uses_cast_as_int() -> None:
|
||||
assert "CAST(:days AS int)" in _AUDIT_SRC
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fakes — mirror the mocked-DB convention used across tests/test_user_events.py etc.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeMappingResult:
|
||||
def __init__(self, rows: list[dict[str, Any]]) -> None:
|
||||
self._rows = rows
|
||||
|
||||
def all(self) -> list[dict[str, Any]]:
|
||||
return list(self._rows)
|
||||
|
||||
def one(self) -> dict[str, Any]:
|
||||
return self._rows[0]
|
||||
|
||||
|
||||
class _FakeExecResult:
|
||||
def __init__(self, rows: list[dict[str, Any]]) -> None:
|
||||
self._rows = rows
|
||||
|
||||
def mappings(self) -> _FakeMappingResult:
|
||||
return _FakeMappingResult(self._rows)
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""Returns queued row-lists in the exact order audit.py issues db.execute() calls."""
|
||||
|
||||
def __init__(self, responses: list[list[dict[str, Any]]]) -> None:
|
||||
self._responses = list(responses)
|
||||
self._i = 0
|
||||
|
||||
def execute(self, _stmt: object, _params: dict[str, Any] | None = None) -> _FakeExecResult:
|
||||
rows = self._responses[self._i]
|
||||
self._i += 1
|
||||
return _FakeExecResult(rows)
|
||||
|
||||
|
||||
def _make_app(responses: list[list[dict[str, Any]]]) -> FastAPI:
|
||||
application = FastAPI()
|
||||
application.include_router(audit_module.router, prefix="/api/v1/admin")
|
||||
|
||||
def _override_db() -> Any:
|
||||
yield _FakeSession(responses)
|
||||
|
||||
application.dependency_overrides[get_db] = _override_db
|
||||
return application
|
||||
|
||||
|
||||
_NOW = datetime(2026, 7, 13, 12, 0, 0, tzinfo=UTC)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (b) GET /audit/accounts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_accounts_empty_table() -> None:
|
||||
app = _make_app(responses=[[]])
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/admin/audit/accounts")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
def test_list_accounts_populated_shape() -> None:
|
||||
row = {
|
||||
"username": "user1",
|
||||
"first_seen_at": _NOW,
|
||||
"last_seen_at": _NOW,
|
||||
"distinct_ips": 3,
|
||||
"distinct_devices": 2,
|
||||
"login_count": 5,
|
||||
"request_count": 40,
|
||||
"search_count": 7,
|
||||
}
|
||||
app = _make_app(responses=[[row]])
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/admin/audit/accounts")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["username"] == "user1"
|
||||
assert data[0]["distinct_ips"] == 3
|
||||
assert data[0]["login_count"] == 5
|
||||
assert data[0]["request_count"] == 40
|
||||
assert data[0]["search_count"] == 7
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (c) GET /audit/accounts/{username}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_account_drilldown_empty_table_unknown_user_not_404() -> None:
|
||||
"""Unknown username → 200 with all 4 lists empty, NOT a 404."""
|
||||
app = _make_app(responses=[[], [], [], []])
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/admin/audit/accounts/ghost_user_xyz")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data == {"ips": [], "devices": [], "searches": [], "recent_activity": []}
|
||||
|
||||
|
||||
def test_account_drilldown_populated_shape() -> None:
|
||||
ips = [{"ip_address": "1.2.3.4", "event_count": 10, "first_seen": _NOW, "last_seen": _NOW}]
|
||||
devices = [
|
||||
{"user_agent": "pytest-agent", "event_count": 10, "first_seen": _NOW, "last_seen": _NOW}
|
||||
]
|
||||
searches = [
|
||||
{
|
||||
"address": "ул. Малышева, 1",
|
||||
"area_m2": "50.0",
|
||||
"rooms": "2",
|
||||
"estimate_id": str(uuid.uuid4()),
|
||||
"ip_address": "1.2.3.4",
|
||||
"created_at": _NOW,
|
||||
}
|
||||
]
|
||||
recent_activity = [
|
||||
{
|
||||
"event_type": "api_request",
|
||||
"path": "/api/v1/trade-in/estimate",
|
||||
"method": "POST",
|
||||
"ip_address": "1.2.3.4",
|
||||
"created_at": _NOW,
|
||||
}
|
||||
]
|
||||
app = _make_app(responses=[ips, devices, searches, recent_activity])
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/admin/audit/accounts/user1")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ips"][0]["ip_address"] == "1.2.3.4"
|
||||
assert data["ips"][0]["event_count"] == 10
|
||||
assert data["devices"][0]["user_agent"] == "pytest-agent"
|
||||
assert data["searches"][0]["address"] == "ул. Малышева, 1"
|
||||
assert data["searches"][0]["area_m2"] == "50.0"
|
||||
assert data["recent_activity"][0]["event_type"] == "api_request"
|
||||
assert data["recent_activity"][0]["path"] == "/api/v1/trade-in/estimate"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (d) GET /analytics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ZERO_SUMMARY = {
|
||||
"total_events": 0,
|
||||
"distinct_users": 0,
|
||||
"events_last_24h": 0,
|
||||
"active_users_last_24h": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_analytics_empty_table_never_errors() -> None:
|
||||
app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []])
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/admin/analytics")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["summary"] == _ZERO_SUMMARY
|
||||
assert data["daily"] == []
|
||||
assert data["top_searches"] == []
|
||||
assert data["top_paths"] == []
|
||||
assert data["by_account"] == []
|
||||
|
||||
|
||||
def test_analytics_populated_shape() -> None:
|
||||
summary = {
|
||||
"total_events": 120,
|
||||
"distinct_users": 4,
|
||||
"events_last_24h": 15,
|
||||
"active_users_last_24h": 2,
|
||||
}
|
||||
daily = [{"day": date(2026, 7, 13), "events": 15, "users": 2}]
|
||||
top_searches = [{"address": "ул. Малышева, 1", "n": 3}]
|
||||
top_paths = [{"path": "/api/v1/trade-in/estimate", "n": 40}]
|
||||
by_account = [{"username": "user1", "events": 60, "searches": 7, "last_seen": _NOW}]
|
||||
app = _make_app(responses=[[summary], daily, top_searches, top_paths, by_account])
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/admin/analytics")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["summary"]["total_events"] == 120
|
||||
assert data["daily"][0]["events"] == 15
|
||||
assert data["daily"][0]["day"] == "2026-07-13"
|
||||
assert data["top_searches"][0]["address"] == "ул. Малышева, 1"
|
||||
assert data["top_paths"][0]["n"] == 40
|
||||
assert data["by_account"][0]["username"] == "user1"
|
||||
|
||||
|
||||
def test_analytics_days_query_param_clamped() -> None:
|
||||
"""days=0 (below ge=1) → 422; days=9999 (above le=365) → 422."""
|
||||
app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []])
|
||||
client = TestClient(app)
|
||||
assert client.get("/api/v1/admin/analytics?days=0").status_code == 422
|
||||
assert client.get("/api/v1/admin/analytics?days=9999").status_code == 422
|
||||
|
||||
|
||||
def test_analytics_days_query_param_default_30() -> None:
|
||||
app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []])
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/admin/analytics")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_analytics_days_query_param_accepted_in_range() -> None:
|
||||
app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []])
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/admin/analytics?days=7")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (e) Optional real-Postgres round trip (self-skips without a reachable DB)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _live_session() -> Any | None:
|
||||
"""Return a SQLAlchemy Session if a non-placeholder Postgres is reachable, else None."""
|
||||
try:
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import text as _t
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
|
||||
if not dsn or "localhost:5432/test" in dsn:
|
||||
return None
|
||||
engine = create_engine(dsn, future=True)
|
||||
conn = engine.connect()
|
||||
conn.execute(_t("SELECT 1"))
|
||||
conn.close()
|
||||
return sessionmaker(bind=engine, future=True)()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||||
def test_real_accounts_and_analytics_aggregate_inserted_rows() -> None:
|
||||
"""End-to-end on a real DB: insert 2 user_events rows for a throwaway test username,
|
||||
assert /audit/accounts, /audit/accounts/{username} and /analytics reflect them."""
|
||||
from sqlalchemy import text as _t
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
db = _live_session()
|
||||
assert db is not None
|
||||
username = f"audit_test_{uuid.uuid4().hex[:8]}"
|
||||
try:
|
||||
db.execute(
|
||||
_t(
|
||||
"""
|
||||
INSERT INTO user_events
|
||||
(event_type, username, ip_address, user_agent, path, method, payload)
|
||||
VALUES
|
||||
('login', :u, CAST('9.9.9.9' AS inet), 'pytest-ua', NULL, NULL,
|
||||
CAST('{}' AS jsonb)),
|
||||
('estimate_request', :u, CAST('9.9.9.9' AS inet), 'pytest-ua',
|
||||
'/api/v1/trade-in/estimate', 'POST',
|
||||
CAST(:payload AS jsonb))
|
||||
"""
|
||||
),
|
||||
{"u": username, "payload": '{"address": "ул. Тестовая, 1", "area_m2": "42.0"}'},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
application = FastAPI()
|
||||
application.include_router(audit_module.router, prefix="/api/v1/admin")
|
||||
|
||||
def _override_db() -> Any:
|
||||
yield SessionLocal()
|
||||
|
||||
application.dependency_overrides[get_db] = _override_db
|
||||
client = TestClient(application)
|
||||
|
||||
accounts = client.get("/api/v1/admin/audit/accounts").json()
|
||||
row = next(r for r in accounts if r["username"] == username)
|
||||
assert row["login_count"] == 1
|
||||
assert row["search_count"] == 1
|
||||
|
||||
drilldown = client.get(f"/api/v1/admin/audit/accounts/{username}").json()
|
||||
assert len(drilldown["searches"]) == 1
|
||||
assert drilldown["searches"][0]["address"] == "ул. Тестовая, 1"
|
||||
assert len(drilldown["recent_activity"]) == 2
|
||||
|
||||
dashboard = client.get("/api/v1/admin/analytics?days=1").json()
|
||||
assert dashboard["summary"]["total_events"] >= 2
|
||||
finally:
|
||||
db.rollback()
|
||||
db.execute(_t("DELETE FROM user_events WHERE username = :u"), {"u": username})
|
||||
db.commit()
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
"""Audit-scrapers finding 3: Avito detail publish_date year-boundary rollover.
|
||||
|
||||
Avito не показывает год для дат текущего года («20 декабря в 15:30»). Раньше
|
||||
`_extract_meta` всегда брал ТЕКУЩИЙ год момента парсинга — объявлению, опубликованному
|
||||
в декабре и прочитанному в январе следующего года, ставился год парсинга (будущая
|
||||
дата), завышая свежесть лота. Фикс: если получившаяся дата оказалась в будущем
|
||||
относительно момента парсинга — откатываем на год назад.
|
||||
|
||||
Refs: audit-scrapers 2026-07-26, finding 3 (low).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import date as real_date
|
||||
|
||||
import pytest
|
||||
from selectolax.parser import HTMLParser
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from scraper_kit.providers.avito import detail as kit_detail
|
||||
|
||||
|
||||
def _freeze_today(monkeypatch: pytest.MonkeyPatch, frozen: real_date) -> None:
|
||||
"""Подменяет `date` в scraper_kit.providers.avito.detail так, что date.today()
|
||||
детерминированно возвращает `frozen` (date — immutable C-тип, .today нельзя
|
||||
monkeypatch'нуть напрямую — подменяем ссылку на класс в модуле)."""
|
||||
|
||||
class _FrozenDate(real_date):
|
||||
@classmethod
|
||||
def today(cls) -> real_date: # type: ignore[override]
|
||||
return frozen
|
||||
|
||||
monkeypatch.setattr(kit_detail, "date", _FrozenDate)
|
||||
|
||||
|
||||
def _tree_with_publish_text(text: str) -> HTMLParser:
|
||||
html = f'<html><body><div data-marker="item-view/item-id">{text}</div></body></html>'
|
||||
return HTMLParser(html)
|
||||
|
||||
|
||||
def test_december_publish_date_read_in_january_rolls_back_a_year(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Объявление '20 декабря' парсится 5 января СЛЕДУЮЩЕГО года: без фикса
|
||||
дата была бы 2027-12-20 (в будущем относительно today=2027-01-05) — теперь
|
||||
откатывается на 2026-12-20."""
|
||||
_freeze_today(monkeypatch, real_date(2027, 1, 5))
|
||||
tree = _tree_with_publish_text("№ 4291500000 · 20 декабря в 15:30")
|
||||
|
||||
publish_date, _, _ = kit_detail._extract_meta(tree)
|
||||
|
||||
assert publish_date == real_date(2026, 12, 20)
|
||||
|
||||
|
||||
def test_same_year_past_publish_date_not_rolled_back(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Control: дата в прошлом (не будущем) в том же году — год НЕ откатывается."""
|
||||
_freeze_today(monkeypatch, real_date(2027, 1, 5))
|
||||
tree = _tree_with_publish_text("№ 4291500001 · 3 января в 09:00")
|
||||
|
||||
publish_date, _, _ = kit_detail._extract_meta(tree)
|
||||
|
||||
assert publish_date == real_date(2027, 1, 3)
|
||||
|
||||
|
||||
def test_publish_date_equal_to_today_not_rolled_back(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Control: дата ровно = today (не строго будущее) — год НЕ откатывается."""
|
||||
_freeze_today(monkeypatch, real_date(2027, 1, 5))
|
||||
tree = _tree_with_publish_text("№ 4291500002 · 5 января в 12:00")
|
||||
|
||||
publish_date, _, _ = kit_detail._extract_meta(tree)
|
||||
|
||||
assert publish_date == real_date(2027, 1, 5)
|
||||
|
||||
|
||||
def test_mid_year_publish_date_not_rolled_back(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Обычный случай вдали от границы года — поведение не меняется."""
|
||||
_freeze_today(monkeypatch, real_date(2027, 6, 15))
|
||||
tree = _tree_with_publish_text("№ 4291500003 · 20 марта в 10:00")
|
||||
|
||||
publish_date, _, _ = kit_detail._extract_meta(tree)
|
||||
|
||||
assert publish_date == real_date(2027, 3, 20)
|
||||
|
||||
|
||||
def test_no_publish_date_in_text_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Regression guard: отсутствие даты в тексте по-прежнему даёт None (не падает)."""
|
||||
_freeze_today(monkeypatch, real_date(2027, 1, 5))
|
||||
tree = _tree_with_publish_text("№ 4291500004")
|
||||
|
||||
publish_date, _, _ = kit_detail._extract_meta(tree)
|
||||
|
||||
assert publish_date is None
|
||||
84
tradein-mvp/backend/tests/test_avito_serp_city_slug.py
Normal file
84
tradein-mvp/backend/tests/test_avito_serp_city_slug.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""Avito SERP city-slug filter (#2487 oblast rollout).
|
||||
|
||||
`_parse_html` drops padding cards ("по всей России") when `avito_serp_ekb_only`
|
||||
is on. The kept city-slug used to be hardcoded `/ekaterinburg/`, so an oblast
|
||||
per-city sweep (`_job_avito_city_sweep` with a `city` param) discarded 100% of the
|
||||
target city's cards. The slug is now parameterized via `AvitoScraper.target_city_slug`
|
||||
(default → "ekaterinburg", i.e. ЕКБ behavior unchanged when no target is set).
|
||||
|
||||
No network / no DB — `_parse_html` runs on inline HTML with a stub config.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from scraper_kit.providers.avito.serp import AvitoScraper
|
||||
|
||||
_BASE_URL = "https://www.avito.ru/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ?p=1"
|
||||
|
||||
|
||||
def _card(slug: str, item_id: str, price: int = 5_000_000) -> str:
|
||||
"""Minimal Avito SERP card whose per-card href carries `slug` as the city segment."""
|
||||
href = f"/{slug}/kvartiry/2k_kvartira_50_m_5_5et_{item_id}"
|
||||
return (
|
||||
f'<div data-marker="item" data-item-id="{item_id}">'
|
||||
f'<a data-marker="item-title" href="{href}">2-к. квартира, 50 м², 5/9 эт.</a>'
|
||||
f'<meta itemprop="price" content="{price}">'
|
||||
f"</div>"
|
||||
)
|
||||
|
||||
|
||||
def _html(*cards: str) -> str:
|
||||
return "<html><body>" + "".join(cards) + "</body></html>"
|
||||
|
||||
|
||||
def _ekb_only_cfg() -> SimpleNamespace:
|
||||
# _parse_html only reads config.avito_serp_ekb_only; a stub keeps the test
|
||||
# deterministic regardless of AVITO_SERP_EKB_ONLY env in the runner.
|
||||
return SimpleNamespace(avito_serp_ekb_only=True)
|
||||
|
||||
|
||||
def test_parse_keeps_target_oblast_city_drops_off_target_padding() -> None:
|
||||
"""Sweep targeting Н.Тагил keeps its cards; ЕКБ/Москва padding is dropped."""
|
||||
s = AvitoScraper(_ekb_only_cfg(), target_city_slug="nizhniy_tagil") # type: ignore[arg-type]
|
||||
html = _html(
|
||||
_card("nizhniy_tagil", "nt1"), # target city → KEPT
|
||||
_card("ekaterinburg", "ekb1"), # off-target padding → DROPPED
|
||||
_card("moskva", "msk1"), # off-target padding → DROPPED
|
||||
)
|
||||
lots = s._parse_html(html, source_url_base=_BASE_URL)
|
||||
urls = [lot.source_url or "" for lot in lots]
|
||||
|
||||
assert len(lots) == 1, urls
|
||||
assert "/nizhniy_tagil/" in urls[0]
|
||||
assert all("/ekaterinburg/" not in u for u in urls)
|
||||
assert all("/moskva/" not in u for u in urls)
|
||||
|
||||
|
||||
def test_parse_default_target_is_ekb_and_drops_oblast_padding() -> None:
|
||||
"""No target (ЕКБ sweep/full-load): kept slug defaults to 'ekaterinburg' — behavior
|
||||
identical to the previous hardcode. ЕКБ card kept, oblast padding dropped."""
|
||||
s = AvitoScraper(_ekb_only_cfg(), target_city_slug=None) # type: ignore[arg-type]
|
||||
html = _html(
|
||||
_card("ekaterinburg", "e1"), # ЕКБ → KEPT
|
||||
_card("nizhniy_tagil", "nt1"), # off-target padding → DROPPED
|
||||
)
|
||||
lots = s._parse_html(html, source_url_base=_BASE_URL)
|
||||
urls = [lot.source_url or "" for lot in lots]
|
||||
|
||||
assert len(lots) == 1, urls
|
||||
assert "/ekaterinburg/" in urls[0]
|
||||
assert all("/nizhniy_tagil/" not in u for u in urls)
|
||||
|
||||
|
||||
def test_parse_ekb_only_disabled_keeps_all_cities() -> None:
|
||||
"""avito_serp_ekb_only=False → no city filter at all, every card is kept."""
|
||||
cfg = SimpleNamespace(avito_serp_ekb_only=False)
|
||||
s = AvitoScraper(cfg, target_city_slug="nizhniy_tagil") # type: ignore[arg-type]
|
||||
html = _html(_card("nizhniy_tagil", "nt1"), _card("ekaterinburg", "e1"))
|
||||
lots = s._parse_html(html, source_url_base=_BASE_URL)
|
||||
assert len(lots) == 2
|
||||
206
tradein-mvp/backend/tests/test_avito_sweep_dom_drift.py
Normal file
206
tradein-mvp/backend/tests/test_avito_sweep_dom_drift.py
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
"""Audit-scrapers finding 1: Avito citywide/byrooms/exhaustive sweep DOM-drift detection.
|
||||
|
||||
Раньше 0 карточек на page=1 (обход всего города / категории комнатности / ценового
|
||||
бакета exhaustive-сбора) молча трактовалось как «объявлений действительно нет» —
|
||||
неотличимо от content-block/captcha или дрейфа DOM-маркера карточки (`data-marker=
|
||||
"item-*"`). Фикс переиспользует существующий механизм `AvitoContentBlockedError`
|
||||
(см. `fetch_around`, #754/#779) + новый `_is_unexpected_empty_page()` — независимый
|
||||
сигнал `_extract_total_count` (счётчик `page-title/count` либо no-results маркер):
|
||||
|
||||
- page=1, 0 карточек, НЕТ no-results маркера/счётчика → аномалия → raise.
|
||||
- page=1, 0 карточек, ЕСТЬ no-results маркер (total=0) → валидная пустая выборка.
|
||||
- page>1, 0 карточек → всегда graceful end-of-pagination (не regressed).
|
||||
- exhaustive leaf-бакет: probe независимо утверждал total>0, но после пагинации
|
||||
всех страниц собрано 0 карточек → аномалия → raise (даже без per-page проверки
|
||||
внутри _paginate_leaf_bucket, т.к. там нет break-on-empty цикла).
|
||||
|
||||
Refs: audit-scrapers 2026-07-26, finding 1 (medium).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from scraper_kit.avito_exceptions import AvitoContentBlockedError
|
||||
from scraper_kit.base import ScrapedLot
|
||||
from scraper_kit.providers.avito.serp import ROOM_SLUGS, AvitoScraper
|
||||
|
||||
from app.services.scraper_adapters import RealScraperConfig
|
||||
|
||||
# HTML "успешно получен, разумного размера", но БЕЗ data-marker="item-*" карточек
|
||||
# И без no-results маркера/счётчика — неотличимо от content-block/DOM-drift.
|
||||
_NO_MARKER_HTML = "<html><body>" + ("x" * 500) + "</body></html>"
|
||||
|
||||
# Валидная пустая выборка: no-results маркер присутствует (_AVITO_NO_RESULTS_MARKERS).
|
||||
_NO_RESULTS_HTML = (
|
||||
"<html><body>По вашему запросу ничего не найдено. Попробуйте изменить фильтры."
|
||||
+ ("y" * 200)
|
||||
+ "</body></html>"
|
||||
)
|
||||
|
||||
# Firewall/captcha-страница (переиспользуем существующий fixture-паттерн из #754) —
|
||||
# используется только для проверки, что page>1 остаётся graceful независимо от
|
||||
# содержимого (проверка применяется ТОЛЬКО к page==1).
|
||||
_BLOCKPAGE_HTML = "<html><body><h1>Доступ ограничен</h1></body></html>"
|
||||
|
||||
|
||||
def _make_lot(source_id: str) -> ScrapedLot:
|
||||
return ScrapedLot(
|
||||
source="avito",
|
||||
source_url=f"https://www.avito.ru/ekaterinburg/kvartiry/{source_id}",
|
||||
source_id=source_id,
|
||||
price_rub=6_000_000,
|
||||
)
|
||||
|
||||
|
||||
# ── fetch_city_wide (_paginate_sweep) ────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_citywide_page1_zero_cards_no_marker_raises() -> None:
|
||||
s = AvitoScraper(RealScraperConfig())
|
||||
with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_MARKER_HTML)):
|
||||
with pytest.raises(AvitoContentBlockedError):
|
||||
await s.fetch_city_wide(pages=5, delay_override_sec=0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_citywide_page1_zero_cards_with_no_results_marker_is_valid_empty() -> None:
|
||||
s = AvitoScraper(RealScraperConfig())
|
||||
with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_RESULTS_HTML)):
|
||||
result = await s.fetch_city_wide(pages=5, delay_override_sec=0)
|
||||
assert result == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_citywide_page_gt1_zero_cards_stays_graceful() -> None:
|
||||
"""page=1 реально возвращает карточки (mock _parse_html) — page=2 пустой
|
||||
firewall-текст без карточек НЕ должен поднимать исключение (только page==1)."""
|
||||
s = AvitoScraper(RealScraperConfig())
|
||||
call_n = 0
|
||||
|
||||
async def _fetch(url: str, page: int) -> str:
|
||||
return "<html>page1</html>" if page == 1 else _BLOCKPAGE_HTML
|
||||
|
||||
def _parse(html: str, source_url_base: str) -> list[ScrapedLot]:
|
||||
nonlocal call_n
|
||||
call_n += 1
|
||||
return [_make_lot("A"), _make_lot("B")] if call_n == 1 else []
|
||||
|
||||
with patch.object(s, "_fetch_serp_html", AsyncMock(side_effect=_fetch)):
|
||||
with patch.object(s, "_parse_html", side_effect=_parse):
|
||||
with patch.object(s, "sleep_between_requests", AsyncMock(return_value=None)):
|
||||
result = await s.fetch_city_wide(pages=5, delay_override_sec=0)
|
||||
|
||||
assert len(result) == 2
|
||||
assert call_n == 2 # page1(2 lots) + page2(0 lots) → stop, no raise
|
||||
|
||||
|
||||
# ── fetch_by_rooms ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_byrooms_category_page1_zero_cards_no_marker_raises() -> None:
|
||||
s = AvitoScraper(RealScraperConfig())
|
||||
with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_MARKER_HTML)):
|
||||
with pytest.raises(AvitoContentBlockedError):
|
||||
await s.fetch_by_rooms(pages=5, delay_override_sec=0, room_slugs=ROOM_SLUGS[:1])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_byrooms_category_page1_zero_cards_with_marker_is_valid_empty() -> None:
|
||||
s = AvitoScraper(RealScraperConfig())
|
||||
with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_RESULTS_HTML)):
|
||||
result = await s.fetch_by_rooms(pages=5, delay_override_sec=0, room_slugs=ROOM_SLUGS[:1])
|
||||
assert result == []
|
||||
|
||||
|
||||
# ── _paginate_leaf_bucket (exhaustive/fetch_all_secondary) ───────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_leaf_bucket_expected_total_positive_but_zero_parsed_raises() -> None:
|
||||
"""Probe независимо утверждал total=5 (bucket не может быть легитимно пустым),
|
||||
но парсинг всех страниц дал 0 карточек — DOM-drift, не пустой бакет."""
|
||||
s = AvitoScraper(RealScraperConfig())
|
||||
seen: dict[str, ScrapedLot] = {}
|
||||
|
||||
with patch.object(s, "_parse_html", return_value=[]):
|
||||
with pytest.raises(AvitoContentBlockedError):
|
||||
await s._paginate_leaf_bucket(
|
||||
room_slug="studii-ASgBAgICAUSSA8YQ",
|
||||
room_label="studio",
|
||||
lo=0,
|
||||
hi=3_000_000,
|
||||
html="<html>probe-page-1</html>",
|
||||
max_pages=1,
|
||||
seen=seen,
|
||||
price_cap_per_bucket=1400,
|
||||
max_pages_per_bucket=100,
|
||||
concurrency=5,
|
||||
secondary_only=True,
|
||||
on_bucket=None,
|
||||
skip_buckets=None,
|
||||
expected_total=5,
|
||||
)
|
||||
assert seen == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_leaf_bucket_expected_total_none_zero_parsed_no_raise() -> None:
|
||||
"""Probe провалился (expected_total=None, best-effort пагинация) — 0 карточек
|
||||
здесь НЕ аномалия (мы не знаем, есть ли реально данные в бакете)."""
|
||||
s = AvitoScraper(RealScraperConfig())
|
||||
seen: dict[str, ScrapedLot] = {}
|
||||
|
||||
with patch.object(s, "_parse_html", return_value=[]):
|
||||
# Не должно поднимать исключение.
|
||||
await s._paginate_leaf_bucket(
|
||||
room_slug="studii-ASgBAgICAUSSA8YQ",
|
||||
room_label="studio",
|
||||
lo=0,
|
||||
hi=3_000_000,
|
||||
html=None,
|
||||
max_pages=1,
|
||||
seen=seen,
|
||||
price_cap_per_bucket=1400,
|
||||
max_pages_per_bucket=100,
|
||||
concurrency=5,
|
||||
secondary_only=True,
|
||||
on_bucket=None,
|
||||
skip_buckets=None,
|
||||
expected_total=None,
|
||||
)
|
||||
assert seen == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_leaf_bucket_expected_total_matches_collected_no_raise() -> None:
|
||||
"""Нормальный путь: probe total=2, парсинг реально даёт 2 карточки — не аномалия."""
|
||||
s = AvitoScraper(RealScraperConfig())
|
||||
seen: dict[str, ScrapedLot] = {}
|
||||
lots = [_make_lot("L1"), _make_lot("L2")]
|
||||
|
||||
with patch.object(s, "_parse_html", return_value=lots):
|
||||
await s._paginate_leaf_bucket(
|
||||
room_slug="studii-ASgBAgICAUSSA8YQ",
|
||||
room_label="studio",
|
||||
lo=0,
|
||||
hi=3_000_000,
|
||||
html="<html>probe-page-1</html>",
|
||||
max_pages=1,
|
||||
seen=seen,
|
||||
price_cap_per_bucket=1400,
|
||||
max_pages_per_bucket=100,
|
||||
concurrency=5,
|
||||
secondary_only=True,
|
||||
on_bucket=None,
|
||||
skip_buckets=None,
|
||||
expected_total=2,
|
||||
)
|
||||
assert set(seen.keys()) == {"L1", "L2"}
|
||||
|
|
@ -832,3 +832,177 @@ def test_render_full_table_handles_empty_sample() -> None:
|
|||
out = bt._render_full_table(m)
|
||||
assert "n/a" in out # None metrics render as n/a, no crash
|
||||
assert "BACKTEST" in out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Oblast D — `--city` deal-sample scoping (parse + SQL predicate + PPM2 band).
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_argparse_city_defaults_none() -> None:
|
||||
assert bt._parse_args([]).city is None
|
||||
|
||||
|
||||
def test_argparse_city_override() -> None:
|
||||
ns = bt._parse_args(["--city", "Нижний Тагил"])
|
||||
assert ns.city == "Нижний Тагил"
|
||||
|
||||
|
||||
def test_sample_sql_default_is_unscoped_and_same_object() -> None:
|
||||
# city=None must return the SAME _SAMPLE_SQL object used before oblast D —
|
||||
# literal identity, not just equal text — so the default CLI invocation (and
|
||||
# the frozen EKB regression gate, which never calls this path at all) see a
|
||||
# byte-identical query.
|
||||
assert bt._sample_sql(None) is bt._SAMPLE_SQL
|
||||
|
||||
|
||||
def test_sample_sql_default_has_no_city_predicate() -> None:
|
||||
assert ":city" not in bt._SAMPLE_SQL.text
|
||||
|
||||
|
||||
def test_sample_sql_city_scoped_adds_city_predicate() -> None:
|
||||
sql = bt._sample_sql("Нижний Тагил")
|
||||
assert sql is not bt._SAMPLE_SQL
|
||||
built = sql.text
|
||||
assert "AND city = CAST(:city AS text)" in built
|
||||
# City-scoping is additive — the base predicates are still present.
|
||||
assert "source = 'rosreestr'" in built
|
||||
assert "deal_date >= CAST(:since AS date)" in built
|
||||
assert "ORDER BY id DESC" in built
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Oblast D — _resolve_city_ppm2_band (per-city PPM2 sanity band, no live DB).
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class _FakeBandResult:
|
||||
"""Minimal stand-in for a SQLAlchemy Result exposing .mappings().first()."""
|
||||
|
||||
def __init__(self, row: dict[str, object] | None) -> None:
|
||||
self._row = row
|
||||
|
||||
def mappings(self) -> "_FakeBandResult":
|
||||
return self
|
||||
|
||||
def first(self) -> dict[str, object] | None:
|
||||
return self._row
|
||||
|
||||
|
||||
class _FakeBandSession:
|
||||
"""Minimal stand-in for a Session — only .execute() is exercised here."""
|
||||
|
||||
def __init__(self, row: dict[str, object] | None = None, *, raise_exc: bool = False) -> None:
|
||||
self._row = row
|
||||
self._raise = raise_exc
|
||||
|
||||
def execute(self, *_args: object, **_kwargs: object) -> _FakeBandResult:
|
||||
if self._raise:
|
||||
raise RuntimeError("boom")
|
||||
return _FakeBandResult(self._row)
|
||||
|
||||
|
||||
def test_resolve_city_ppm2_band_none_city_returns_globals_no_query() -> None:
|
||||
# city=None must not even touch the DB (no _FakeBandSession.execute call).
|
||||
class _NoExecuteSession:
|
||||
def execute(self, *_a: object, **_kw: object) -> None:
|
||||
raise AssertionError("must not query DB when city is None")
|
||||
|
||||
assert bt._resolve_city_ppm2_band(_NoExecuteSession(), None) == (
|
||||
float(bt.PPM2_MIN),
|
||||
float(bt.PPM2_MAX),
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_city_ppm2_band_found_row() -> None:
|
||||
db = _FakeBandSession({"ppm2_min": 16955, "ppm2_max": 108175})
|
||||
assert bt._resolve_city_ppm2_band(db, "Нижний Тагил") == (16955.0, 108175.0)
|
||||
|
||||
|
||||
def test_resolve_city_ppm2_band_no_row_falls_back_to_globals() -> None:
|
||||
db = _FakeBandSession(None)
|
||||
assert bt._resolve_city_ppm2_band(db, "Екатеринбург") == (
|
||||
float(bt.PPM2_MIN),
|
||||
float(bt.PPM2_MAX),
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_city_ppm2_band_db_error_falls_back_to_globals() -> None:
|
||||
db = _FakeBandSession(raise_exc=True)
|
||||
assert bt._resolve_city_ppm2_band(db, "Нижний Тагил") == (
|
||||
float(bt.PPM2_MIN),
|
||||
float(bt.PPM2_MAX),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Oblast C2 parity — _predict_full_spine must resolve + pass a target city to
|
||||
# _fetch_dkp_corridor, mirroring estimate_quality (estimator.py:3210-3220).
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_predict_full_spine_passes_resolved_city_to_corridor(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.services import estimator as est_mod
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _fake_resolve_target_city(address: str | None) -> str | None:
|
||||
return "нижний тагил"
|
||||
|
||||
def _fake_fetch_dkp_corridor(
|
||||
_db: object,
|
||||
*,
|
||||
address: object,
|
||||
rooms: object,
|
||||
area: object,
|
||||
city: object = None,
|
||||
**_kw: object,
|
||||
) -> None:
|
||||
captured["called"] = True
|
||||
captured["city"] = city
|
||||
return None
|
||||
|
||||
def _fake_price_from_inputs(**_kwargs: object) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
median_price=100_000.0,
|
||||
median_ppm2=100_000.0,
|
||||
confidence="low",
|
||||
anchor_tier=None,
|
||||
expected_sold_per_m2=95_000.0,
|
||||
expected_sold_price=4_750_000.0,
|
||||
expected_sold_range_low=4_000_000.0,
|
||||
expected_sold_range_high=5_500_000.0,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(est_mod, "_resolve_target_city", _fake_resolve_target_city)
|
||||
monkeypatch.setattr(est_mod, "_fetch_dkp_corridor", _fake_fetch_dkp_corridor)
|
||||
monkeypatch.setattr(est_mod, "_fetch_anchor_comps", lambda *a, **kw: ([], None))
|
||||
monkeypatch.setattr(est_mod, "_fetch_house_imv_anchor", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(est_mod, "_price_from_inputs", _fake_price_from_inputs)
|
||||
monkeypatch.setattr(bt, "_select_analogs_full", lambda *a, **kw: ([], "W", False, False))
|
||||
|
||||
est = bt._import_estimator_full()
|
||||
deal = bt.DealSample(
|
||||
id=1,
|
||||
lon=60.6,
|
||||
lat=58.05,
|
||||
rooms=2,
|
||||
sold_ppm2=90_000.0,
|
||||
deal_date=None,
|
||||
area_m2=50.0,
|
||||
address="Нижний Тагил, ул. Ленина, 5",
|
||||
floor=3,
|
||||
total_floors=9,
|
||||
year_built=2000,
|
||||
house_type="панель",
|
||||
)
|
||||
|
||||
pred = bt._predict_full_spine(None, deal, est)
|
||||
|
||||
assert captured.get("called") is True
|
||||
assert captured.get("city") == "нижний тагил"
|
||||
assert pred is not None
|
||||
|
|
|
|||
|
|
@ -0,0 +1,145 @@
|
|||
"""Regression test: listings block of cian_history_backfill must rollback on
|
||||
save_detail_enrichment failure, same as the houses block already does.
|
||||
|
||||
Bug (audit finding #1, fix/tradein-audit-backfill-quota): save_detail_enrichment()
|
||||
runs several unprotected db.execute() calls and only commits at the very end. If
|
||||
any of them fails (e.g. a malformed `change_time` from Cian hits
|
||||
`CAST(:ct AS timestamptz)` with a DataError), the session is left in a failed
|
||||
in-transaction state (PendingRollbackError). Without `db.rollback()` in the except
|
||||
branch, EVERY subsequent listing in the batch (up to 49 more) raises the same
|
||||
PendingRollbackError — one real failure looks like N independent failures in the
|
||||
logs, and the batch effectively stops processing after the first bad row.
|
||||
|
||||
This test proves: (a) db.rollback() is called after a save failure, (b) the batch
|
||||
continues past the failing row and still succeeds on the next one — the exact
|
||||
"cascading failure" scenario this fix prevents.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
|
||||
from app.tasks import cian_history_backfill
|
||||
|
||||
|
||||
class _FakeBrowserFetcher:
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self) -> _FakeBrowserFetcher:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _enrichment() -> SimpleNamespace:
|
||||
return SimpleNamespace(price_changes=[])
|
||||
|
||||
|
||||
async def test_listings_save_failure_rolls_back_session() -> None:
|
||||
"""save_detail_enrichment raising -> db.rollback() called (session un-poisoned)."""
|
||||
db = MagicMock()
|
||||
db.execute.return_value.mappings.return_value.all.return_value = [
|
||||
{"id": 1, "source_url": "https://cian.ru/1"},
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(cian_history_backfill, "BrowserFetcher", _FakeBrowserFetcher),
|
||||
patch.object(cian_history_backfill, "fetch_detail", AsyncMock(return_value=_enrichment())),
|
||||
patch.object(
|
||||
cian_history_backfill,
|
||||
"save_detail_enrichment",
|
||||
side_effect=Exception("DataError: invalid timestamptz"),
|
||||
),
|
||||
patch("asyncio.sleep", new_callable=AsyncMock),
|
||||
):
|
||||
result = await cian_history_backfill.backfill_cian_history(
|
||||
db, do_listings=True, do_houses=False, do_valuations=False
|
||||
)
|
||||
|
||||
db.rollback.assert_called_once()
|
||||
assert result.listings_failed_save == 1
|
||||
assert result.listings_succeeded == 0
|
||||
|
||||
|
||||
async def test_listings_batch_continues_after_one_bad_row() -> None:
|
||||
"""The exact cascading-failure repro: row 1 fails save, row 2 must still
|
||||
succeed — proves rollback actually un-poisons the session for later rows,
|
||||
not just that rollback was called."""
|
||||
db = MagicMock()
|
||||
db.execute.return_value.mappings.return_value.all.return_value = [
|
||||
{"id": 1, "source_url": "https://cian.ru/1"},
|
||||
{"id": 2, "source_url": "https://cian.ru/2"},
|
||||
]
|
||||
|
||||
save_mock = MagicMock(side_effect=[Exception("DataError: invalid timestamptz"), None])
|
||||
|
||||
with (
|
||||
patch.object(cian_history_backfill, "BrowserFetcher", _FakeBrowserFetcher),
|
||||
patch.object(cian_history_backfill, "fetch_detail", AsyncMock(return_value=_enrichment())),
|
||||
patch.object(cian_history_backfill, "save_detail_enrichment", save_mock),
|
||||
patch("asyncio.sleep", new_callable=AsyncMock),
|
||||
):
|
||||
result = await cian_history_backfill.backfill_cian_history(
|
||||
db, do_listings=True, do_houses=False, do_valuations=False
|
||||
)
|
||||
|
||||
assert save_mock.call_count == 2
|
||||
db.rollback.assert_called_once()
|
||||
assert result.listings_failed_save == 1
|
||||
assert result.listings_succeeded == 1
|
||||
assert result.listings_processed == 2
|
||||
|
||||
|
||||
async def test_listings_rollback_failure_itself_does_not_crash_the_batch() -> None:
|
||||
"""If db.rollback() ALSO raises (e.g. connection already dropped), the batch
|
||||
logs a warning and keeps going — mirrors the houses block's same guard."""
|
||||
db = MagicMock()
|
||||
db.rollback.side_effect = Exception("connection already closed")
|
||||
db.execute.return_value.mappings.return_value.all.return_value = [
|
||||
{"id": 1, "source_url": "https://cian.ru/1"},
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(cian_history_backfill, "BrowserFetcher", _FakeBrowserFetcher),
|
||||
patch.object(cian_history_backfill, "fetch_detail", AsyncMock(return_value=_enrichment())),
|
||||
patch.object(
|
||||
cian_history_backfill,
|
||||
"save_detail_enrichment",
|
||||
side_effect=Exception("DataError: invalid timestamptz"),
|
||||
),
|
||||
patch("asyncio.sleep", new_callable=AsyncMock),
|
||||
):
|
||||
result = await cian_history_backfill.backfill_cian_history(
|
||||
db, do_listings=True, do_houses=False, do_valuations=False
|
||||
)
|
||||
|
||||
assert result.listings_failed_save == 1
|
||||
|
||||
|
||||
async def test_listings_no_rollback_on_success() -> None:
|
||||
"""Successful save must NOT trigger a rollback (would discard the commit
|
||||
that save_detail_enrichment already made)."""
|
||||
db = MagicMock()
|
||||
db.execute.return_value.mappings.return_value.all.return_value = [
|
||||
{"id": 1, "source_url": "https://cian.ru/1"},
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(cian_history_backfill, "BrowserFetcher", _FakeBrowserFetcher),
|
||||
patch.object(cian_history_backfill, "fetch_detail", AsyncMock(return_value=_enrichment())),
|
||||
patch.object(cian_history_backfill, "save_detail_enrichment", MagicMock()),
|
||||
patch("asyncio.sleep", new_callable=AsyncMock),
|
||||
):
|
||||
result = await cian_history_backfill.backfill_cian_history(
|
||||
db, do_listings=True, do_houses=False, do_valuations=False
|
||||
)
|
||||
|
||||
db.rollback.assert_not_called()
|
||||
assert result.listings_succeeded == 1
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
"""Audit-scrapers finding 2: Cian totalOffers vs results.offers length mismatch.
|
||||
|
||||
`_parse_serp_html` извлекает `totalOffers` и `results.offers` из ОДНОГО Redux
|
||||
state-блоба (одна SSR-выдача). Раньше `results.offers` пустой при `totalOffers>0`
|
||||
логировался WARNING'ом и тихо возвращался `[]` — не считался schema-regression, не
|
||||
попадал в мониторинг (`_report_schema_regression`/Glitchtip).
|
||||
|
||||
Порог: 0 vs >0 — единственный позиционно-независимый сигнал, который можно
|
||||
проверить без номера страницы внутри `_parse_serp_html` (эта функция не знает,
|
||||
какая это страница пагинации — дробный порог типа "< 50% от totalOffers" ложно
|
||||
сработал бы на легитимной последней частичной странице exhaustive-пагинации,
|
||||
которую эта функция не различает). totalOffers=0 (реально пустой поиск) НЕ
|
||||
считается регрессией.
|
||||
|
||||
Refs: audit-scrapers 2026-07-26, finding 2 (low).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from scraper_kit.providers.cian.serp import CianScraper
|
||||
|
||||
from app.services.scraper_adapters import RealScraperConfig
|
||||
|
||||
|
||||
def _scraper() -> CianScraper:
|
||||
return CianScraper(RealScraperConfig())
|
||||
|
||||
|
||||
def test_total_offers_positive_but_offers_empty_reports_regression() -> None:
|
||||
"""totalOffers=5, results.offers=[] — internal contradiction, must report."""
|
||||
s = _scraper()
|
||||
state = {"results": {"totalOffers": 5, "offers": []}}
|
||||
with patch("scraper_kit.providers.cian.serp.extract_state", return_value=state):
|
||||
with patch.object(s, "_report_schema_regression") as mock_report:
|
||||
lots = s._parse_serp_html("<html>irrelevant</html>")
|
||||
|
||||
assert lots == []
|
||||
mock_report.assert_called_once()
|
||||
(msg,), _ = mock_report.call_args
|
||||
assert "totalOffers=5" in msg
|
||||
|
||||
|
||||
def test_total_offers_zero_and_offers_empty_is_valid_empty_search() -> None:
|
||||
"""totalOffers=0, offers=[] — легитимная пустая выборка, НЕ регрессия."""
|
||||
s = _scraper()
|
||||
state = {"results": {"totalOffers": 0, "offers": []}}
|
||||
with patch("scraper_kit.providers.cian.serp.extract_state", return_value=state):
|
||||
with patch.object(s, "_report_schema_regression") as mock_report:
|
||||
lots = s._parse_serp_html("<html>irrelevant</html>")
|
||||
|
||||
assert lots == []
|
||||
mock_report.assert_not_called()
|
||||
|
||||
|
||||
def test_total_offers_none_and_offers_empty_is_not_reported_as_regression() -> None:
|
||||
"""totalOffers отсутствует/None в state — недостаточно сигнала для regression-репорта
|
||||
(могла быть частично битая state-структура без явного totalOffers>0 контр-сигнала)."""
|
||||
s = _scraper()
|
||||
state = {"results": {"offers": []}}
|
||||
with patch("scraper_kit.providers.cian.serp.extract_state", return_value=state):
|
||||
with patch.object(s, "_report_schema_regression") as mock_report:
|
||||
lots = s._parse_serp_html("<html>irrelevant</html>")
|
||||
|
||||
assert lots == []
|
||||
mock_report.assert_not_called()
|
||||
|
||||
|
||||
def test_offers_present_normal_path_unaffected() -> None:
|
||||
"""totalOffers=1, offers содержит 1 запись без cianId/id — не проходит
|
||||
_offer_to_lot, но это уже существующая (0/N offer-level) охрана, не finding 2."""
|
||||
s = _scraper()
|
||||
state = {"results": {"totalOffers": 1, "offers": [{"noId": True}]}}
|
||||
with patch("scraper_kit.providers.cian.serp.extract_state", return_value=state):
|
||||
with patch.object(s, "_report_schema_regression") as mock_report:
|
||||
lots = s._parse_serp_html("<html>irrelevant</html>")
|
||||
|
||||
# offers_data непустой → finding 2 guard не участвует; существующая offer-level
|
||||
# охрана (raw_count>0 and saved_count==0) должна отработать вместо неё.
|
||||
assert lots == []
|
||||
mock_report.assert_called_once()
|
||||
(msg,), _ = mock_report.call_args
|
||||
assert "_offer_to_lot" in msg
|
||||
|
||||
|
||||
def test_state_none_extraction_failed_no_regression_report() -> None:
|
||||
"""extract_state вернул None (captcha/структура целиком не найдена) — уже
|
||||
существующая ветка, НЕ должна триггерить finding-2 regression report."""
|
||||
s = _scraper()
|
||||
with patch("scraper_kit.providers.cian.serp.extract_state", return_value=None):
|
||||
with patch.object(s, "_report_schema_regression") as mock_report:
|
||||
lots = s._parse_serp_html("<html>irrelevant</html>")
|
||||
|
||||
assert lots == []
|
||||
mock_report.assert_not_called()
|
||||
|
||||
|
||||
def test_report_schema_regression_swallows_missing_glitchtip_dsn() -> None:
|
||||
"""_report_schema_regression не должен падать, если glitchtip_dsn не настроен."""
|
||||
s = _scraper()
|
||||
s._config = MagicMock(glitchtip_dsn=None)
|
||||
s._report_schema_regression("test message") # не должно бросить исключение
|
||||
|
|
@ -10,6 +10,8 @@ import pytest
|
|||
from app.services.cian_session import (
|
||||
CIAN_REQUIRED_COOKIES,
|
||||
VERIFY_BAN_SENTINEL,
|
||||
VERIFY_MARKUP_CHANGED_SENTINEL,
|
||||
VERIFY_SOURCE_UNAVAILABLE_SENTINEL,
|
||||
_classify_verify_response,
|
||||
load_session,
|
||||
mark_session_invalid,
|
||||
|
|
@ -211,14 +213,40 @@ def test_classify_200_authenticated_returns_state(monkeypatch: pytest.MonkeyPatc
|
|||
assert result == expected
|
||||
|
||||
|
||||
def test_classify_200_state_missing_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""200 but extract_state returns None → None."""
|
||||
def test_classify_200_state_missing_returns_markup_changed_sentinel(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""audit-scrapers finding 4: HTTP 200 но extract_state не нашёл auth-state
|
||||
(Cian сменил вёрстку/MFE-схему header-frontend) → VERIFY_MARKUP_CHANGED_SENTINEL,
|
||||
НЕ None. Раньше это конфлировалось с "cookies expired" (реальный логаут)."""
|
||||
monkeypatch.setattr(
|
||||
"app.services.cian_session.extract_state",
|
||||
lambda html, mfe, key: None,
|
||||
)
|
||||
result = _classify_verify_response(200, "<html></html>")
|
||||
assert result is None
|
||||
assert result is VERIFY_MARKUP_CHANGED_SENTINEL
|
||||
assert result is not None # НЕ должно триггерить cookie-refresh alert
|
||||
|
||||
|
||||
def test_classify_5xx_returns_source_unavailable_sentinel() -> None:
|
||||
"""audit-scrapers finding 4: HTTP 500 (источник недоступен) →
|
||||
VERIFY_SOURCE_UNAVAILABLE_SENTINEL, НЕ None (cookies тут ни при чём)."""
|
||||
result = _classify_verify_response(500, None)
|
||||
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
|
||||
assert result is not None
|
||||
|
||||
|
||||
def test_classify_502_returns_source_unavailable_sentinel() -> None:
|
||||
"""Любой non-200/403/401 статус (напр. 502 bad gateway) — источник недоступен."""
|
||||
result = _classify_verify_response(502, None)
|
||||
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
|
||||
|
||||
|
||||
def test_classify_status_200_html_none_returns_source_unavailable_sentinel() -> None:
|
||||
"""Defensive: status=200 но html=None (не должно случаться в проде, но
|
||||
classifier не должен молча вернуть None='expired') → source-unavailable."""
|
||||
result = _classify_verify_response(200, None)
|
||||
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
|
||||
|
||||
|
||||
def test_classify_403_is_distinct_from_401() -> None:
|
||||
|
|
@ -230,6 +258,28 @@ def test_classify_403_is_distinct_from_401() -> None:
|
|||
assert expired is None
|
||||
|
||||
|
||||
def test_classify_all_four_outcomes_are_mutually_distinct() -> None:
|
||||
"""audit-scrapers finding 4: expired (401) / ban (403) / source-unavailable (5xx)
|
||||
/ markup-changed (200+extract_state=None) — четыре РАЗНЫХ сигнала, ни один не
|
||||
коллапсирует в другой. Только expired (None) должен триггерить re-login alert."""
|
||||
expired = _classify_verify_response(401, None)
|
||||
ban = _classify_verify_response(403, None)
|
||||
source_down = _classify_verify_response(500, None)
|
||||
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setattr("app.services.cian_session.extract_state", lambda html, mfe, key: None)
|
||||
markup_changed = _classify_verify_response(200, "<html></html>")
|
||||
|
||||
outcomes = [expired, ban, source_down, markup_changed]
|
||||
# None встречается ровно один раз (только expired) — остальные три truthy sentinel'а
|
||||
# и все различны между собой (identity, не equality — это разные dict-объекты).
|
||||
assert outcomes.count(None) == 1
|
||||
assert expired is None
|
||||
non_none = [o for o in outcomes if o is not None]
|
||||
assert len(non_none) == 3
|
||||
assert len({id(o) for o in non_none}) == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# verify_session (async) — integration with curl_cffi mock
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -317,10 +367,12 @@ async def test_verify_session_not_authenticated_returns_none(
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_session_state_missing_returns_none(
|
||||
async def test_verify_session_state_missing_returns_markup_changed_sentinel(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""200 + extract_state returns None → None."""
|
||||
"""audit-scrapers finding 4: 200 + extract_state returns None (markup changed)
|
||||
→ VERIFY_MARKUP_CHANGED_SENTINEL, НЕ None. Раньше ложно триггерило "cookies
|
||||
expired, please re-upload" для реальной причины "Cian сменил вёрстку"."""
|
||||
monkeypatch.setattr(
|
||||
"app.services.cian_session.extract_state",
|
||||
lambda html, mfe, key: None,
|
||||
|
|
@ -334,7 +386,42 @@ async def test_verify_session_state_missing_returns_none(
|
|||
with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
|
||||
result = await verify_session({"DMIR_AUTH": "x"})
|
||||
|
||||
assert result is None
|
||||
assert result is VERIFY_MARKUP_CHANGED_SENTINEL
|
||||
assert result is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_session_5xx_returns_source_unavailable_sentinel() -> None:
|
||||
"""audit-scrapers finding 4: HTTP 500 → VERIFY_SOURCE_UNAVAILABLE_SENTINEL,
|
||||
НЕ None. Источник временно недоступен — cookies тут ни при чём, вызывающий
|
||||
не должен помечать сессию invalid / просить re-upload."""
|
||||
mock_session = AsyncMock()
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_session.get = AsyncMock(return_value=_make_cffi_resp(500))
|
||||
|
||||
with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
|
||||
result = await verify_session({"DMIR_AUTH": "x"})
|
||||
|
||||
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
|
||||
assert result is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_session_network_error_returns_source_unavailable_sentinel() -> None:
|
||||
"""audit-scrapers finding 4: сетевой/транспортный сбой (timeout, connection
|
||||
reset и т.п.) → VERIFY_SOURCE_UNAVAILABLE_SENTINEL, НЕ None. Раньше generic
|
||||
except возвращал None — конфлировал сетевой сбой с протухшими cookies."""
|
||||
mock_session = AsyncMock()
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_session.get = AsyncMock(side_effect=ConnectionError("connection reset by peer"))
|
||||
|
||||
with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
|
||||
result = await verify_session({"DMIR_AUTH": "x"})
|
||||
|
||||
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
|
||||
assert result is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
443
tradein-mvp/backend/tests/test_domrf_kapremont_loader.py
Normal file
443
tradein-mvp/backend/tests/test_domrf_kapremont_loader.py
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
"""Тесты ДОМ.РФ капремонт loader'а (app/services/domrf_kapremont_loader.py, мигр. 176, #2013).
|
||||
|
||||
Coverage:
|
||||
- Чистые парс-хелперы: parse_decimal_comma / parse_int_field.
|
||||
- parse_kr11_csv / parse_kr12_wall_materials — фикстуры собраны из РЕАЛЬНЫХ строк
|
||||
региона 66 (export-kr1_1-66-20260701.csv / export-kr1_2-66-20260701.csv, дом
|
||||
2170017185 «Ломоносова 3» и 2170025270 «Ломоносова 4»): КР1.2 long-format,
|
||||
wall_material непустой только на строке construction_element_type='фасад'.
|
||||
- build_domrf_houses — join КР1.1+КР1.2 по mkd_code.
|
||||
- fetch_domrf_csvs — скачивание+распаковка zip, HTTP замокан через httpx.MockTransport
|
||||
(никакой живой сети).
|
||||
- Статические asserts по SQL: _UPSERT_SQL (ON CONFLICT, IS DISTINCT FROM, CAST),
|
||||
backfill houses (COALESCE-порядок year_built/domrf/zhkh_year, only-fill-NULL),
|
||||
propagate listings (только NULL listings.year_built).
|
||||
- backfill_houses_from_domrf / propagate_listings_year_from_houses — dry_run вызывает
|
||||
COUNT-SQL и не пишет, real-run суммирует rowcount обоих шагов (MagicMock db).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# settings/SessionLocal не импортируем, но пакет app.* на импорте может тронуть DSN — как в
|
||||
# sibling-тестах (test_zhkh_flats_loader) задаём заглушку (ядро статично/без реальной БД).
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.services import domrf_kapremont_loader as dkl
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Фикстуры: РЕАЛЬНЫЕ строки региона 66 (export-kr1_1/kr1_2-66-20260701.csv)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# КР1.1 house registry — 2 дома (Ломоносова 3 / Ломоносова 4, п. Арамиль).
|
||||
_KR11_HEADER = (
|
||||
"subject_rf;mun_obr_oktmo;mun_obr;mkd_code;houseguid;address;commission_year;"
|
||||
"architectural_monument_category;total_sq;total_rooms_amount;living_rooms_amount;"
|
||||
"total_rooms_sq;living_rooms_sq;total_ppl;number_floors_max;money_collecting_way;"
|
||||
"money_ppl_collected;money_ppl_collected_debts;overhaul_funds_spent_all;"
|
||||
"overhaul_funds_spent_subsidy;overhaul_fund_spent_other;overhaul_funds_balance;"
|
||||
"update_date_of_information;money_ppl_collected_date;owners_payment;energy_efficiency;"
|
||||
"alarm_document_date;exclude_date_from_program;inclusion_date_to_program;comment;"
|
||||
"last_update;house_id"
|
||||
)
|
||||
_KR11_ROW_3 = (
|
||||
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
|
||||
"2170017185;04b98bcb-3232-4b26-b0fa-3c4a47adf394;"
|
||||
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 3";'
|
||||
'1964;;399,90;;;366,60;333,30;20;2;"Счет регионального оператора";486,84407;72,59638;'
|
||||
"1580,29712;0,00000;0,00000;-1093,45305;2026-05-31;01.10.2014;18,81;"
|
||||
'"не присвоен";;;22.04.2014;;15.05.2026;8857551'
|
||||
)
|
||||
_KR11_ROW_4 = (
|
||||
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
|
||||
"2170025270;15064d04-3aec-4266-985b-da26e9a5f392;"
|
||||
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 4";'
|
||||
'1984;;906,40;;;;;;2;"Счет регионального оператора";1046,79431;204,75296;0,00000;'
|
||||
'0,00000;0,00000;1046,79431;2026-05-31;01.10.2014;18,81;"не присвоен";;;22.04.2014;;'
|
||||
"15.05.2026;8857552"
|
||||
)
|
||||
KR11_CSV = "\n".join([_KR11_HEADER, _KR11_ROW_3, _KR11_ROW_4]) + "\n"
|
||||
|
||||
# КР1.2 constructive elements — long-format: обычная строка (wall_material пуст) +
|
||||
# строка "фасад" (wall_material='кирпич') на каждый из тех же 2 домов.
|
||||
_KR12_HEADER = (
|
||||
"subject_rf;mun_obr_oktmo;mun_obr;mkd_code;address;construction_element_code;"
|
||||
"construction_element_type;system_type;lift_type;stops_count;commissioning_date;"
|
||||
"decommissioning_date;roof_type;roofing_area;basement_area;facade_type;facade_area;"
|
||||
"foundation_type;wall_material;comment;last_update"
|
||||
)
|
||||
_KR12_ROW_3_OTHER = (
|
||||
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
|
||||
"2170017185;"
|
||||
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 3";'
|
||||
'580000;"система дымоудаления и (или) противопожарной автоматики";;;;;;;;;;;;;;15.05.2026'
|
||||
)
|
||||
_KR12_ROW_3_FACADE = (
|
||||
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
|
||||
"2170017185;"
|
||||
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 3";'
|
||||
'60000;фасад;;;;;;;;;"облицованный камнем";480,00;;кирпич;;15.05.2026'
|
||||
)
|
||||
_KR12_ROW_4_OTHER = (
|
||||
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
|
||||
"2170025270;"
|
||||
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 4";'
|
||||
'100000;"инженерная система водоснабжения (ХВС)";центральная;;;;;;;;;;;;;15.05.2026'
|
||||
)
|
||||
_KR12_ROW_4_FACADE = (
|
||||
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
|
||||
"2170025270;"
|
||||
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 4";'
|
||||
"60000;фасад;;;;;;;;;;;;кирпич;;15.05.2026"
|
||||
)
|
||||
KR12_CSV = (
|
||||
"\n".join(
|
||||
[_KR12_HEADER, _KR12_ROW_3_OTHER, _KR12_ROW_3_FACADE, _KR12_ROW_4_OTHER, _KR12_ROW_4_FACADE]
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kr11_path(tmp_path: Path) -> Path:
|
||||
p = tmp_path / "kr11.csv"
|
||||
p.write_text(KR11_CSV, encoding="utf-8-sig")
|
||||
return p
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kr12_path(tmp_path: Path) -> Path:
|
||||
p = tmp_path / "kr12.csv"
|
||||
p.write_text(KR12_CSV, encoding="utf-8-sig")
|
||||
return p
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# parse_decimal_comma / parse_int_field
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_parse_decimal_comma() -> None:
|
||||
assert dkl.parse_decimal_comma("909,80") == pytest.approx(909.80)
|
||||
assert dkl.parse_decimal_comma("399,90") == pytest.approx(399.90)
|
||||
assert dkl.parse_decimal_comma("100.5") == pytest.approx(100.5)
|
||||
assert dkl.parse_decimal_comma("") is None
|
||||
assert dkl.parse_decimal_comma(None) is None
|
||||
assert dkl.parse_decimal_comma("не число") is None
|
||||
|
||||
|
||||
def test_parse_int_field() -> None:
|
||||
assert dkl.parse_int_field("1964") == 1964
|
||||
assert dkl.parse_int_field("2") == 2
|
||||
assert dkl.parse_int_field("") is None
|
||||
assert dkl.parse_int_field(None) is None
|
||||
assert dkl.parse_int_field("x") is None
|
||||
assert dkl.parse_int_field("5,0") == 5 # decimal-фоллбек
|
||||
|
||||
|
||||
def test_parse_int_field_year_sanity_gate() -> None:
|
||||
# Fix A: значение вне [min_value, max_value] → None (мусорный год не в staging).
|
||||
ymax = dkl.plausible_year_max()
|
||||
assert dkl.parse_int_field("1841", min_value=1850, max_value=ymax) is None # слишком старый
|
||||
assert dkl.parse_int_field("0", min_value=1850, max_value=ymax) is None # ноль
|
||||
assert dkl.parse_int_field(str(ymax + 5), min_value=1850, max_value=ymax) is None # будущее
|
||||
assert dkl.parse_int_field("1964", min_value=1850, max_value=ymax) == 1964 # валидный
|
||||
assert dkl.parse_int_field("1850", min_value=1850, max_value=ymax) == 1850 # граница вкл.
|
||||
# Без границ (number_floors_max) — гейт не применяется.
|
||||
assert dkl.parse_int_field("1841") == 1841
|
||||
|
||||
|
||||
def test_parse_kr11_gates_impossible_commission_year(tmp_path: Path) -> None:
|
||||
# Fix A: строка КР1.1 с невозможным годом (1200) → commission_year=None.
|
||||
bad_row = _KR11_ROW_3.replace("1964;", "1200;", 1)
|
||||
csv_text = "\n".join([_KR11_HEADER, bad_row]) + "\n"
|
||||
p = tmp_path / "bad_year.csv"
|
||||
p.write_text(csv_text, encoding="utf-8-sig")
|
||||
rows = dkl.parse_kr11_csv(p)
|
||||
assert rows["2170017185"].commission_year is None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# parse_kr11_csv — house registry
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_parse_kr11_csv_real_rows(kr11_path: Path) -> None:
|
||||
rows = dkl.parse_kr11_csv(kr11_path)
|
||||
assert set(rows.keys()) == {"2170017185", "2170025270"}
|
||||
|
||||
house3 = rows["2170017185"]
|
||||
assert house3.houseguid == "04b98bcb-3232-4b26-b0fa-3c4a47adf394"
|
||||
assert house3.commission_year == 1964
|
||||
assert house3.number_floors_max == 2
|
||||
assert house3.total_sq == pytest.approx(399.90)
|
||||
assert "Ломоносова, д. 3" in (house3.address or "")
|
||||
|
||||
house4 = rows["2170025270"]
|
||||
assert house4.houseguid == "15064d04-3aec-4266-985b-da26e9a5f392"
|
||||
assert house4.commission_year == 1984
|
||||
assert house4.total_sq == pytest.approx(906.40)
|
||||
|
||||
|
||||
def test_parse_kr11_csv_skips_blank_mkd_code(tmp_path: Path) -> None:
|
||||
csv_text = _KR11_HEADER + "\n" + (";" * (_KR11_HEADER.count(";"))) + "\n"
|
||||
p = tmp_path / "blank.csv"
|
||||
p.write_text(csv_text, encoding="utf-8-sig")
|
||||
assert dkl.parse_kr11_csv(p) == {}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# parse_kr12_wall_materials — long-format, wall_material только на строке 'фасад'
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_parse_kr12_wall_materials_extracts_facade_row_only(kr12_path: Path) -> None:
|
||||
materials = dkl.parse_kr12_wall_materials(kr12_path)
|
||||
# Обе non-facade строки (дымоудаление / ХВС) имели wall_material='' — исключены.
|
||||
assert materials == {"2170017185": "кирпич", "2170025270": "кирпич"}
|
||||
|
||||
|
||||
def test_parse_kr12_wall_materials_empty_when_no_facade_row(tmp_path: Path) -> None:
|
||||
csv_text = "\n".join([_KR12_HEADER, _KR12_ROW_3_OTHER]) + "\n"
|
||||
p = tmp_path / "no_facade.csv"
|
||||
p.write_text(csv_text, encoding="utf-8-sig")
|
||||
assert dkl.parse_kr12_wall_materials(p) == {}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# build_domrf_houses — join КР1.1 + КР1.2(wall_material)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_build_domrf_houses_joins_wall_material(kr11_path: Path, kr12_path: Path) -> None:
|
||||
kr11 = dkl.parse_kr11_csv(kr11_path)
|
||||
walls = dkl.parse_kr12_wall_materials(kr12_path)
|
||||
houses = dkl.build_domrf_houses(kr11, walls)
|
||||
|
||||
by_code = {h.mkd_code: h for h in houses}
|
||||
assert len(houses) == 2
|
||||
assert by_code["2170017185"].wall_material == "кирпич"
|
||||
assert by_code["2170017185"].commission_year == 1964
|
||||
assert by_code["2170025270"].wall_material == "кирпич"
|
||||
|
||||
|
||||
# Дом без строки 'фасад' в КР1.2 → wall_material остаётся None (не KeyError/пустая строка).
|
||||
def test_wall_material_none_when_house_missing_from_kr12(tmp_path: Path, kr11_path: Path) -> None:
|
||||
kr11 = dkl.parse_kr11_csv(kr11_path)
|
||||
houses = dkl.build_domrf_houses(kr11, {}) # пустой КР1.2 lookup
|
||||
assert all(h.wall_material is None for h in houses)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# fetch_domrf_csvs — скачивание+распаковка zip, HTTP замокан (MockTransport)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def _zip_bytes(inner_name: str, content: str) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr(inner_name, content.encode("utf-8-sig"))
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_fetch_domrf_csvs_downloads_and_extracts(tmp_path: Path) -> None:
|
||||
kr11_zip = _zip_bytes("export-kr1_1-66-20260701.csv", KR11_CSV)
|
||||
kr12_zip = _zip_bytes("export-kr1_2-66-20260701.csv", KR12_CSV)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
url = str(request.url)
|
||||
if url == dkl.KR11_URL:
|
||||
return httpx.Response(200, content=kr11_zip)
|
||||
if url == dkl.KR12_URL:
|
||||
return httpx.Response(200, content=kr12_zip)
|
||||
raise AssertionError(f"unexpected URL {url}")
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(transport=transport) as client:
|
||||
kr11_out, kr12_out = dkl.fetch_domrf_csvs(tmp_path, client=client)
|
||||
|
||||
assert kr11_out.exists() and kr11_out.name.endswith(".csv")
|
||||
assert kr12_out.exists() and kr12_out.name.endswith(".csv")
|
||||
|
||||
# Круглый путь: распакованные файлы парсятся так же, как локальные фикстуры.
|
||||
rows = dkl.parse_kr11_csv(kr11_out)
|
||||
assert set(rows.keys()) == {"2170017185", "2170025270"}
|
||||
materials = dkl.parse_kr12_wall_materials(kr12_out)
|
||||
assert materials == {"2170017185": "кирпич", "2170025270": "кирпич"}
|
||||
|
||||
|
||||
def test_extract_csv_from_zip_raises_on_no_csv_entry(tmp_path: Path) -> None:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("readme.txt", "not a csv")
|
||||
with pytest.raises(ValueError, match="csv"):
|
||||
dkl._extract_csv_from_zip(buf.getvalue(), tmp_path)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Статические asserts по _UPSERT_SQL (psycopg v3, ON CONFLICT, идемпотентность)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
_UPSERT_SQL = str(dkl._UPSERT_SQL.text)
|
||||
|
||||
|
||||
def test_upsert_sql_uses_psycopg_v3_cast_not_double_colon() -> None:
|
||||
assert not re.search(r":\w+::", _UPSERT_SQL)
|
||||
assert "CAST(:mkd_code AS text)" in _UPSERT_SQL
|
||||
assert "CAST(:commission_year AS smallint)" in _UPSERT_SQL
|
||||
assert "CAST(:total_sq AS numeric)" in _UPSERT_SQL
|
||||
|
||||
|
||||
def test_upsert_sql_idempotent_on_conflict() -> None:
|
||||
flat = re.sub(r"\s+", " ", _UPSERT_SQL)
|
||||
assert "INSERT INTO domrf_kapremont" in flat
|
||||
assert "ON CONFLICT (mkd_code) DO UPDATE SET" in flat
|
||||
assert "domrf_kapremont.houseguid IS DISTINCT FROM EXCLUDED.houseguid" in flat
|
||||
assert "domrf_kapremont.wall_material IS DISTINCT FROM EXCLUDED.wall_material" in flat
|
||||
|
||||
|
||||
def test_upsert_uses_savepoint_per_chunk() -> None:
|
||||
import inspect
|
||||
|
||||
src = inspect.getsource(dkl.upsert_domrf_kapremont)
|
||||
assert "with db.begin_nested():" in src
|
||||
assert "db.commit()" not in src # не коммитит сам (коммитит caller)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Статические asserts: backfill houses — COALESCE(year_built, domrf, zhkh_year)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
_BACKFILL_DOMRF_SQL = re.sub(r"\s+", " ", str(dkl._BACKFILL_HOUSES_FROM_DOMRF_SQL.text))
|
||||
_BACKFILL_ZHKH_SQL = re.sub(r"\s+", " ", str(dkl._BACKFILL_HOUSES_ZHKH_FALLBACK_SQL.text))
|
||||
|
||||
|
||||
def test_backfill_domrf_sql_year_priority_and_plausibility_guard() -> None:
|
||||
bound = "BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)"
|
||||
# Fix A: year_built — валидное существующее (в границах :ymin..:ymax) выигрывает,
|
||||
# иначе (NULL/impossible) заменяется авторитетным commission_year → zhkh_year.
|
||||
assert f"WHEN h.year_built {bound}" in _BACKFILL_DOMRF_SQL
|
||||
assert "THEN h.year_built" in _BACKFILL_DOMRF_SQL
|
||||
# commission_year и zhkh_year тоже проходят плаузибилити-гейт перед записью.
|
||||
assert f"CASE WHEN d.commission_year {bound}" in _BACKFILL_DOMRF_SQL
|
||||
# Невозможный существующий год НЕ экранирует замену (WHERE ловит NOT BETWEEN).
|
||||
assert f"h.year_built NOT {bound}" in _BACKFILL_DOMRF_SQL
|
||||
# material_walls / total_floors — прежняя COALESCE-семантика (только заполнить NULL).
|
||||
assert "COALESCE(h.material_walls, d.wall_material)" in _BACKFILL_DOMRF_SQL
|
||||
assert "COALESCE(h.total_floors, d.number_floors_max, h.zhkh_floors)" in _BACKFILL_DOMRF_SQL
|
||||
|
||||
|
||||
def test_backfill_domrf_sql_matches_by_guid_coalesce_priority() -> None:
|
||||
# gar_house_guid первый (78% coverage), затем house_fias_id, затем zhkh_house_guid.
|
||||
assert (
|
||||
"d.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)"
|
||||
in _BACKFILL_DOMRF_SQL
|
||||
)
|
||||
|
||||
|
||||
def test_backfill_domrf_sql_only_fills_null_never_overwrites() -> None:
|
||||
# Гейт: каждый SET-таргет требует h.<col> IS NULL — COALESCE никогда не перезапишет
|
||||
# существующее non-null значение (SQL COALESCE тоже это гарантирует, но гейт защищает
|
||||
# от no-op UPDATE на уже заполненных строках).
|
||||
assert "h.year_built IS NULL" in _BACKFILL_DOMRF_SQL
|
||||
assert "h.material_walls IS NULL" in _BACKFILL_DOMRF_SQL
|
||||
assert "h.total_floors IS NULL" in _BACKFILL_DOMRF_SQL
|
||||
|
||||
|
||||
def test_backfill_zhkh_fallback_sql_folds_zhkh_for_unmatched_houses() -> None:
|
||||
# Шаг 2 — без JOIN на domrf_kapremont вообще: применяется к ЛЮБОМУ дому, включая
|
||||
# те, что не заматчились в шаге 1 (issue: "houses without a ДОМ.РФ match still benefit").
|
||||
assert "domrf_kapremont" not in _BACKFILL_ZHKH_SQL
|
||||
assert "COALESCE(year_built, zhkh_year)" in _BACKFILL_ZHKH_SQL
|
||||
assert "COALESCE(total_floors, zhkh_floors)" in _BACKFILL_ZHKH_SQL
|
||||
assert "year_built IS NULL AND zhkh_year IS NOT NULL" in _BACKFILL_ZHKH_SQL
|
||||
|
||||
|
||||
def test_backfill_sql_no_psycopg_v3_colon_colon_cast() -> None:
|
||||
assert not re.search(r":\w+::", _BACKFILL_DOMRF_SQL)
|
||||
assert not re.search(r":\w+::", _BACKFILL_ZHKH_SQL)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# backfill_houses_from_domrf — dry_run vs real-run (MagicMock db)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_backfill_houses_real_run_sums_both_steps_rowcount() -> None:
|
||||
db = MagicMock()
|
||||
results = [MagicMock(rowcount=3), MagicMock(rowcount=2)]
|
||||
db.execute.side_effect = results
|
||||
|
||||
out = dkl.backfill_houses_from_domrf(db, dry_run=False)
|
||||
|
||||
assert out == {"domrf_matched": 3, "zhkh_fallback": 2, "houses_updated": 5}
|
||||
assert db.execute.call_count == 2
|
||||
executed_sql = [str(call.args[0]) for call in db.execute.call_args_list]
|
||||
assert "UPDATE houses" in executed_sql[0]
|
||||
assert "UPDATE houses" in executed_sql[1]
|
||||
# Fix A: domrf-шаг прокидывает плаузибилити-границы года как bind-параметры.
|
||||
domrf_params = db.execute.call_args_list[0].args[1]
|
||||
assert domrf_params["ymin"] == dkl.YEAR_BUILT_MIN
|
||||
assert domrf_params["ymax"] == dkl.plausible_year_max()
|
||||
|
||||
|
||||
def test_backfill_houses_dry_run_only_counts_no_update() -> None:
|
||||
db = MagicMock()
|
||||
db.execute.return_value.scalar_one.side_effect = [7, 4]
|
||||
|
||||
out = dkl.backfill_houses_from_domrf(db, dry_run=True)
|
||||
|
||||
assert out == {"domrf_matched": 7, "zhkh_fallback": 4, "houses_updated": 0}
|
||||
for call in db.execute.call_args_list:
|
||||
sql_text = str(call.args[0]).upper()
|
||||
assert "UPDATE" not in sql_text
|
||||
assert "SELECT COUNT(*)" in sql_text
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Статические asserts + behavior: propagate_listings_year_from_houses
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
_PROPAGATE_SQL = re.sub(r"\s+", " ", str(dkl._PROPAGATE_LISTINGS_YEAR_SQL.text))
|
||||
|
||||
|
||||
def test_propagate_sql_only_fills_null_listings_year() -> None:
|
||||
assert "SET year_built = h.year_built" in _PROPAGATE_SQL
|
||||
assert "l.year_built IS NULL" in _PROPAGATE_SQL
|
||||
assert "h.year_built IS NOT NULL" in _PROPAGATE_SQL
|
||||
assert "l.house_id_fk = h.id" in _PROPAGATE_SQL
|
||||
|
||||
|
||||
def test_propagate_sql_has_link_consistency_distance_guard() -> None:
|
||||
# Fix B: пропагация только если координаты объявления в пределах порога от дома
|
||||
# (ST_DWithin, geography → метры) — перепривязанный FK не впрыснет чужой год.
|
||||
assert "ST_DWithin(" in _PROPAGATE_SQL
|
||||
assert "CAST(l.geom AS geography)" in _PROPAGATE_SQL
|
||||
assert "CAST(h.geom AS geography)" in _PROPAGATE_SQL
|
||||
assert str(dkl.PROPAGATE_MAX_DISTANCE_M) in _PROPAGATE_SQL
|
||||
# Оба SQL (UPDATE и COUNT) несут один и тот же guard.
|
||||
count_sql = re.sub(r"\s+", " ", str(dkl._PROPAGATE_LISTINGS_YEAR_COUNT_SQL.text))
|
||||
assert "ST_DWithin(" in count_sql
|
||||
|
||||
|
||||
def test_propagate_sql_no_coords_falls_back_to_address_check() -> None:
|
||||
# Fix B: объявление без координат → консервативный address-фоллбек
|
||||
# (адрес объявления содержит short_address дома), не пропагируем «вслепую».
|
||||
assert "l.geom IS NULL" in _PROPAGATE_SQL
|
||||
assert "h.short_address IS NOT NULL" in _PROPAGATE_SQL
|
||||
assert "l.address ILIKE" in _PROPAGATE_SQL
|
||||
|
||||
|
||||
def test_propagate_listings_year_real_run_returns_rowcount() -> None:
|
||||
db = MagicMock()
|
||||
db.execute.return_value.rowcount = 42
|
||||
|
||||
out = dkl.propagate_listings_year_from_houses(db, dry_run=False)
|
||||
|
||||
assert out == {"listings_updated": 42}
|
||||
sql_text = str(db.execute.call_args[0][0])
|
||||
assert "UPDATE listings" in sql_text
|
||||
|
||||
|
||||
def test_propagate_listings_year_dry_run_uses_count_sql() -> None:
|
||||
db = MagicMock()
|
||||
db.execute.return_value.scalar_one.return_value = 99
|
||||
|
||||
out = dkl.propagate_listings_year_from_houses(db, dry_run=True)
|
||||
|
||||
assert out == {"listings_updated": 0, "would_update": 99}
|
||||
sql_text = str(db.execute.call_args[0][0]).upper()
|
||||
assert "UPDATE" not in sql_text
|
||||
|
|
@ -139,6 +139,9 @@ def _stub_precision_and_pdf():
|
|||
_fetch_price_trend=lambda *a, **k: None,
|
||||
_fetch_dkp_corridor=lambda *a, **k: None,
|
||||
_fetch_house_imv_anchor=lambda *a, **k: None,
|
||||
# (oblast C2): GET-rehydrate also resolves the target city (for
|
||||
# _fetch_dkp_corridor city-scoping) before calling the corridor helper.
|
||||
_resolve_target_city=lambda *a, **k: None,
|
||||
# #2043 (BE-1): GET-rehydrate also recomputes cv / source_counts from the
|
||||
# persisted analogs. Empty analogs in the fixture → None / {} (real behaviour).
|
||||
_cv_from_ppm2=lambda *a, **k: None,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,261 @@
|
|||
"""Display-consistency fix — показанные карточки аналогов согласованы с n_analogs
|
||||
и не содержат ни MAD-clip-отсечённых ценовых выбросов, ни кросс-source дублей
|
||||
одного физлота (live QA прод-смоук: «Екатеринбург, ул. Малышева, 51»).
|
||||
|
||||
Два независимых root cause (см. PR):
|
||||
|
||||
1. `n_analogs` считался ПОСЛЕ MAD-clip (_compute_same_building_anchor: n = len
|
||||
выживших ppm2), но отображаемые карточки строились из ИСХОДНОГО (пред-clip)
|
||||
anchor_comps — клип-отсечённый ценовой выброс оставался видимым в UI, даже
|
||||
когда он не участвовал в headline/n_analogs. Фикс: anchor_comps_used теперь =
|
||||
anchor["comps"] (тот же пост-clip пул, что дал n).
|
||||
|
||||
2. Статистический кросс-source дедуп (_dedup_cross_source) требует совпадения
|
||||
ЦЕНОВОГО бакета (price_bucket) — реальный кросс-пост одного физлота на разных
|
||||
площадках почти всегда имеет небольшой ценовой дрейф (перевыставили дешевле/
|
||||
дороже), который может увести пару в разные бакеты → дубль выживает в
|
||||
популяции. Фикс: отдельный display-only дедуп (_dedup_display_lots) — тот же
|
||||
физический ключ (building/floor/area), но БЕЗ price_bucket — ловит именно эти
|
||||
остаточные дубли ТОЛЬКО на уровне отображаемых карточек, не трогая
|
||||
n_analogs/median/cv (frozen backtest regression gate не задет — см.
|
||||
tests/test_backtest_regression_gate.py, который эту ветку кода не вызывает).
|
||||
|
||||
Test A воспроизводит буквальный прод-паттерн (кросс-source дубль-пара, которая
|
||||
ЦЕЛИКОМ является ценовым выбросом, — «Гоголя 18» из QA-смоука): MAD-clip убирает
|
||||
ОБЕ копии из anchor["comps"] → они пропадают из карточек естественным образом
|
||||
(fix #1 самодостаточен) → len(analogs) == n_analogs И нет дубль-адресов.
|
||||
|
||||
Test B покрывает менее тривиальный случай — дубль-пара НЕ является ценовым
|
||||
выбросом (обычная цена, просто с небольшим дрейфом между площадками) и
|
||||
переживает MAD-clip как 2 отдельные записи (её и ловит fix #2, display-only
|
||||
дедуп). Здесь len(analogs) < n_analogs ЧЕСТНО (статистика не трогается) — тест
|
||||
документирует именно это, а не искусственное равенство.
|
||||
|
||||
Полный estimate-путь со всеми I/O застаблен через harness _run_estimate из
|
||||
test_same_building_anchor.py (anchor_tier="A" → same-building anchor ветка, где
|
||||
живёт MAD-clip).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
|
||||
from app.services import estimator
|
||||
|
||||
_ANCHOR_TEST = Path(__file__).parent / "test_same_building_anchor.py"
|
||||
_spec = importlib.util.spec_from_file_location("_anchor_harness_display_consistency", _ANCHOR_TEST)
|
||||
assert _spec is not None and _spec.loader is not None
|
||||
_h = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_h)
|
||||
|
||||
|
||||
def _comp(
|
||||
*,
|
||||
source: str,
|
||||
address: str,
|
||||
ppm2: float,
|
||||
area_m2: float = 146.2,
|
||||
rooms: int = 4,
|
||||
scraped_at: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Минимальный same-building comp-dict (без floor/total_floors → нейтральный
|
||||
floor-вес; area_m2 по умолчанию = target площадь _make_payload(), area-вес ~1.0).
|
||||
"""
|
||||
return {
|
||||
"source": source,
|
||||
"source_url": f"https://{source}.example/offer/{address}/{ppm2}",
|
||||
"address": address,
|
||||
"area_m2": area_m2,
|
||||
"rooms": rooms,
|
||||
"price_per_m2": ppm2,
|
||||
"price_rub": ppm2 * area_m2,
|
||||
"scraped_at": scraped_at or datetime(2026, 6, 1, tzinfo=UTC),
|
||||
"photo_urls": [],
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Test A — outlier дубль-пара («Гоголя 18»-паттерн): MAD-clip убирает ОБЕ копии
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_outlier_duplicate_pair_excluded_from_both_count_and_cards() -> None:
|
||||
"""5 обычных комплов (разные дома, MAD-clip не трогает) + кросс-source
|
||||
дубль-пара одного физлота, которая ЦЕЛИКОМ ценовой выброс (~4.4× медианы,
|
||||
как «Гоголя 18» в QA-смоуке) → MAD-clip убирает ОБЕ копии из
|
||||
anchor["comps"]. Т.к. карточки теперь строятся из anchor["comps"] (не из
|
||||
исходного anchor_comps), выброс не просачивается в UI — количество карточек
|
||||
равно n_analogs, и обеих копий дубля нет ни в счётчике, ни в карточках.
|
||||
"""
|
||||
clean = [
|
||||
_comp(source="cian", address="Екатеринбург, ул. Хохрякова, 48", ppm2=195_000.0),
|
||||
_comp(source="avito", address="Екатеринбург, ул. Хохрякова, 50", ppm2=200_000.0),
|
||||
_comp(source="domklik", address="Екатеринбург, ул. Хохрякова, 52", ppm2=205_000.0),
|
||||
_comp(source="yandex", address="Екатеринбург, ул. Хохрякова, 54", ppm2=210_000.0),
|
||||
_comp(source="cian", address="Екатеринбург, ул. Хохрякова, 58", ppm2=215_000.0),
|
||||
]
|
||||
# Кросс-source дубль одного физлота — ~4.4-4.5× дороже остальных (целиком
|
||||
# ценовой выброс, как реальный прод-кейс «Гоголя 18»).
|
||||
outlier_dup = [
|
||||
_comp(source="avito", address="Екатеринбург, ул. Гоголя, 18", ppm2=900_000.0),
|
||||
_comp(source="domklik", address="Екатеринбург, ул. Гоголя, 18", ppm2=920_000.0),
|
||||
]
|
||||
|
||||
est = _h._run_estimate(anchor_comps=clean + outlier_dup, anchor_tier="A")
|
||||
|
||||
# Выброс не участвовал в headline/n_analogs (MAD-clip) — фикс #1 не менял эту
|
||||
# часть: сверяем, что n_analogs действительно = 5 (7 - 2 отсечённых).
|
||||
assert est.n_analogs == 5
|
||||
|
||||
# Главный инвариант display-consistency: показанные карточки == n_analogs.
|
||||
assert len(est.analogs) == est.n_analogs == 5
|
||||
|
||||
# Ни одна из копий выброса не попала в карточки.
|
||||
shown_addresses = {a.address for a in est.analogs}
|
||||
assert "Екатеринбург, ул. Гоголя, 18" not in shown_addresses
|
||||
shown_ppm2 = {a.price_per_m2 for a in est.analogs}
|
||||
assert 900_000 not in shown_ppm2
|
||||
assert 920_000 not in shown_ppm2
|
||||
|
||||
# Нет дублей адресов среди карточек.
|
||||
assert len(shown_addresses) == len(est.analogs)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Test B — non-outlier дубль-пара («Пушкина 9»-паттерн): переживает MAD-clip,
|
||||
# ловится ТОЛЬКО display-only дедупом (price_bucket-строгий стат-дедуп её
|
||||
# пропускает из-за ценового дрейфа, бакет-straddle).
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_non_outlier_cross_source_duplicate_collapses_in_cards_only() -> None:
|
||||
"""4 обычных компла + кросс-source дубль-пара ОБЫЧНОЙ цены (небольшой дрейф
|
||||
2-25к, «Пушкина 9»-паттерн из QA-смоука) — НЕ выброс, переживает MAD-clip как
|
||||
2 отдельные записи (n_analogs их считает по отдельности, статистика не
|
||||
тронута). display-only дедуп (без price_bucket) схлопывает пару в ОДНУ
|
||||
карточку — карточек становится МЕНЬШЕ n_analogs (честно: это НЕ баг, а
|
||||
следствие того, что стат-пайплайн и display-дедуп используют разные ключи
|
||||
по дизайну — см. docstring _dedup_display_lots). Инвариант, который держит
|
||||
этот тест: адрес дубля встречается в карточках РОВНО один раз.
|
||||
"""
|
||||
clean = [
|
||||
_comp(source="cian", address="Екатеринбург, ул. Хохрякова, 48", ppm2=195_000.0),
|
||||
_comp(source="avito", address="Екатеринбург, ул. Хохрякова, 50", ppm2=200_000.0),
|
||||
_comp(source="domklik", address="Екатеринбург, ул. Хохрякова, 52", ppm2=205_000.0),
|
||||
_comp(source="yandex", address="Екатеринбург, ул. Хохрякова, 54", ppm2=210_000.0),
|
||||
]
|
||||
# Один физлот, кросс-source (yandex/domklik), лёгкий ценовой дрейф ~1.6% —
|
||||
# достаточно, чтобы price_bucket (_DEDUP_PRICE_BUCKET_RUB=100_000 руб.)
|
||||
# развёл пару по РАЗНЫМ бакетам (311 vs 316), но НЕ выброс (в пределах
|
||||
# MAD-clip диапазона остальных 4).
|
||||
dup_a = _comp(
|
||||
source="yandex",
|
||||
address="Екатеринбург, ул. Пушкина, 9",
|
||||
ppm2=219_000.0,
|
||||
area_m2=142.0,
|
||||
scraped_at=datetime(2026, 6, 1, tzinfo=UTC),
|
||||
)
|
||||
dup_b = _comp(
|
||||
source="domklik",
|
||||
address="Екатеринбург, ул. Пушкина, 9",
|
||||
ppm2=222_500.0,
|
||||
area_m2=142.0,
|
||||
scraped_at=datetime(2026, 6, 3, tzinfo=UTC), # свежее → представитель
|
||||
)
|
||||
|
||||
# sanity: стат-дедуп (price_bucket-строгий) ДЕЙСТВИТЕЛЬНО пропускает пару —
|
||||
# иначе тест проверял бы не то, что заявлено в docstring.
|
||||
assert estimator.settings.estimate_dedup_analogs_enabled is True
|
||||
deduped_upstream = estimator._dedup_cross_source([dup_a, dup_b])
|
||||
assert len(deduped_upstream) == 2, "price_bucket dedup ошибочно поймал дрейф — тест не то мерит"
|
||||
|
||||
est = _h._run_estimate(anchor_comps=[*clean, dup_a, dup_b], anchor_tier="A")
|
||||
|
||||
# Статистика ЧЕСТНО считает обе копии (стат-дедуп их не поймал, MAD-clip не
|
||||
# выброс) — n_analogs = 6.
|
||||
assert est.n_analogs == 6
|
||||
|
||||
# display-only дедуп схлопнул пару → карточек МЕНЬШЕ n_analogs.
|
||||
assert len(est.analogs) == 5
|
||||
assert len(est.analogs) < est.n_analogs
|
||||
|
||||
# Инвариант: адрес дубля встречается РОВНО один раз среди карточек.
|
||||
shown_addresses = [a.address for a in est.analogs]
|
||||
assert shown_addresses.count("Екатеринбург, ул. Пушкина, 9") == 1
|
||||
# Представитель — свежайший scraped_at (domklik, 222_500).
|
||||
pushkina_card = next(a for a in est.analogs if a.address == "Екатеринбург, ул. Пушкина, 9")
|
||||
assert pushkina_card.price_per_m2 == 222_500
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Unit-level: _dedup_display_lots — прямые проверки поведения helper'а
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _lot(
|
||||
*,
|
||||
source: str,
|
||||
address: str = "ул. Ленина, 5",
|
||||
area: float = 60.0,
|
||||
price: float = 12_000_000.0,
|
||||
floor: int | None = 5,
|
||||
scraped_at: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"source": source,
|
||||
"address": address,
|
||||
"area_m2": area,
|
||||
"price_rub": price,
|
||||
"price_per_m2": price / area if area else 0,
|
||||
"floor": floor,
|
||||
"scraped_at": scraped_at or datetime(2026, 6, 1, tzinfo=UTC),
|
||||
"photo_urls": [],
|
||||
}
|
||||
|
||||
|
||||
def test_dedup_display_lots_collapses_price_drifted_cross_source_pair() -> None:
|
||||
# Тот же building/floor/area, РАЗНЫЙ source, цена отличается на 20% — стат-
|
||||
# дедуп (price_bucket) не поймал бы такой дрейф, display-дедуп игнорирует
|
||||
# цену вовсе.
|
||||
lots = [
|
||||
_lot(source="avito", price=12_000_000.0, scraped_at=datetime(2026, 6, 1, tzinfo=UTC)),
|
||||
_lot(source="cian", price=14_400_000.0, scraped_at=datetime(2026, 6, 5, tzinfo=UTC)),
|
||||
]
|
||||
out = estimator._dedup_display_lots(lots)
|
||||
assert len(out) == 1
|
||||
assert out[0]["source"] == "cian" # свежайший scraped_at
|
||||
|
||||
|
||||
def test_dedup_display_lots_same_source_pair_not_merged() -> None:
|
||||
# Тот же building/floor/area, ОДИНАКОВЫЙ source, разная цена — НЕ кросс-пост
|
||||
# (либо два разных реальных юнита, случайно совпавших по округлённой
|
||||
# площади/этажу), display-дедуп НЕ должен их схлопывать (require_diff_source
|
||||
# guard).
|
||||
lots = [
|
||||
_lot(source="cian", price=12_000_000.0),
|
||||
_lot(source="cian", price=13_000_000.0),
|
||||
]
|
||||
out = estimator._dedup_display_lots(lots)
|
||||
assert len(out) == 2
|
||||
|
||||
|
||||
def test_dedup_display_lots_different_addresses_stay_distinct() -> None:
|
||||
lots = [
|
||||
_lot(source="avito", address="ул. Ленина, 5", price=12_000_000.0),
|
||||
_lot(source="cian", address="ул. Мира, 3", price=12_000_000.0),
|
||||
]
|
||||
out = estimator._dedup_display_lots(lots)
|
||||
assert len(out) == 2
|
||||
|
||||
|
||||
def test_dedup_display_lots_noop_under_two_lots() -> None:
|
||||
lots = [_lot(source="avito")]
|
||||
out = estimator._dedup_display_lots(lots)
|
||||
assert out is lots
|
||||
|
|
@ -56,8 +56,8 @@ def test_fix1_low_conf_anchor_suppressed_by_gate() -> None:
|
|||
assert anchor_raw is not None, "Должен построить якорь без гейта"
|
||||
assert anchor_raw["confidence"] == "low", "Ожидаем low confidence при большом разбросе"
|
||||
|
||||
# Гейт: settings.estimate_sb_low_conf_gate_enabled=True
|
||||
# Это применяется в estimate_quality (выше уровня _compute_same_building_anchor),
|
||||
# Гейт low-confidence якоря применяется в estimate_quality (выше уровня
|
||||
# _compute_same_building_anchor),
|
||||
# поэтому тестируем сигнал: если confidence=low — гейт должен подавить.
|
||||
assert anchor_raw["confidence"] == "low"
|
||||
# Проверяем что гейт-условие срабатывает:
|
||||
|
|
@ -299,17 +299,15 @@ def test_fix4_floor_sigma_zero_does_not_crash() -> None:
|
|||
def test_fix4_premium_comp_survives_post_weight_clip() -> None:
|
||||
"""Видовой компл (высокий ppm²) с правильными весами не выкидывается.
|
||||
|
||||
Логика: при post-weight clip (estimate_sb_clip_after_weight=True) MAD-clip
|
||||
применяется к ppm² ПОСЛЕ weighting. Если видовой компл близок по площади/
|
||||
комнатам — его вес высок, и он не должен быть outlier после clip.
|
||||
Логика: post-weight MAD-clip применяется к ppm² ПОСЛЕ weighting. Если видовой
|
||||
компл близок по площади/комнатам — его вес высок, и он не должен быть outlier
|
||||
после clip.
|
||||
"""
|
||||
# 4 стандартных компла + 1 видовой (выше на 30%)
|
||||
base = 200_000
|
||||
comps = [_make_comp(base) for _ in range(4)] + [_make_comp(base * 1.3)]
|
||||
|
||||
with patch("app.services.estimator.settings") as mock_settings:
|
||||
# Активируем post-weight clip
|
||||
mock_settings.estimate_sb_clip_after_weight = True
|
||||
mock_settings.estimate_sb_mad_k_small_n = 2.5
|
||||
mock_settings.estimate_sb_small_n_threshold = 10
|
||||
mock_settings.avito_imv_thin_market_threshold = 10
|
||||
|
|
|
|||
|
|
@ -0,0 +1,238 @@
|
|||
"""Tests for the oblast-D deals-headline-fallback (non-EKB accuracy gap).
|
||||
|
||||
Context: `listings` coverage is ~40k EKB / ~0 non-EKB (Нижний Тагил / Серов /
|
||||
Каменск-Уральский). Before this fix, a non-EKB subject either:
|
||||
(a) leaked a DISTANT EKB listing median via the Tier S address-prefix
|
||||
fallback (no geo bound — see `_fetch_analogs` Tier S fix), or
|
||||
(b) surfaced an empty ("n/a") headline once (a) was fixed and the
|
||||
geo-bound radius tiers legitimately found 0 local listings.
|
||||
|
||||
This fallback builds the headline from the ДКП deal corridor (`dkp_raw`,
|
||||
already city+street-scoped via `_resolve_target_city`/`_fetch_dkp_corridor`)
|
||||
instead — honestly flagged 'low' confidence, deals-only.
|
||||
|
||||
These tests exercise `estimate_quality` end-to-end (real spine code, mocked
|
||||
DB-facing helpers only) — the same pattern as test_estimator_radius_floor.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import anyio
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_listing(*, price_per_m2: float, area_m2: float = 45.0) -> dict[str, Any]:
|
||||
"""An EKB analog — used only in the "EKB unaffected" control test."""
|
||||
return {
|
||||
"source": "cian",
|
||||
"source_url": "https://cian.ru/offer/1",
|
||||
"address": "ЕКБ, ул. Малышева, 30",
|
||||
"lat": 56.838,
|
||||
"lon": 60.595,
|
||||
"rooms": 2,
|
||||
"area_m2": area_m2,
|
||||
"floor": 5,
|
||||
"total_floors": 16,
|
||||
"price_rub": price_per_m2 * area_m2,
|
||||
"price_per_m2": price_per_m2,
|
||||
"listing_date": datetime(2026, 5, 1),
|
||||
"days_on_market": 10,
|
||||
"photo_urls": [],
|
||||
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
|
||||
"distance_m": 150.0,
|
||||
"relevance_score": 0.1,
|
||||
}
|
||||
|
||||
|
||||
def _make_geo_tagil():
|
||||
from app.services.geocoder import GeocodeResult
|
||||
|
||||
return GeocodeResult(
|
||||
lat=57.9094,
|
||||
lon=59.9789,
|
||||
full_address="Свердловская обл., Нижний Тагил, ул. Ленина, 5",
|
||||
provider="nominatim",
|
||||
)
|
||||
|
||||
|
||||
def _make_payload_tagil():
|
||||
from app.schemas.trade_in import TradeInEstimateInput
|
||||
|
||||
return TradeInEstimateInput(
|
||||
address="Нижний Тагил, ул. Ленина, 5",
|
||||
area_m2=45.0,
|
||||
rooms=2,
|
||||
floor=5,
|
||||
total_floors=9,
|
||||
)
|
||||
|
||||
|
||||
def _run_estimate(
|
||||
*,
|
||||
analogs: list[dict[str, Any]],
|
||||
dkp_raw: dict[str, Any] | None,
|
||||
geo: Any,
|
||||
payload: Any,
|
||||
) -> Any:
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
db = MagicMock()
|
||||
|
||||
async def _run() -> Any:
|
||||
with (
|
||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
|
||||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||||
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
|
||||
patch(
|
||||
"app.services.estimator._fetch_analogs",
|
||||
# Post Part-1 (geo-bound Tier S) reality for a non-EKB town: the
|
||||
# radius/tier ladder legitimately returns NOTHING — tier='W' is
|
||||
# what the always-executed final fallback tier returns.
|
||||
return_value=(list(analogs), False, "W"),
|
||||
),
|
||||
patch("app.services.estimator._fetch_deals", return_value=[]),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_imv_cached",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator.estimate_via_cian_valuation",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
|
||||
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
||||
):
|
||||
return await estimate_quality(payload, db)
|
||||
|
||||
return anyio.run(_run)
|
||||
|
||||
|
||||
# ── Нижний Тагил: no local listings, deal corridor present ───────────────────
|
||||
|
||||
|
||||
def test_non_ekb_empty_listings_uses_deals_headline() -> None:
|
||||
"""0 local listings + a usable ДКП corridor → headline comes from deals.
|
||||
|
||||
Mirrors the reported Нижний Тагил gap: deal_median ≈ 85 911 ₽/м² (accurate)
|
||||
vs the old EKB-leaked asking headline ≈ 186 461 (~6x over). After the fix,
|
||||
the headline must equal the deal corridor's median — nowhere near the
|
||||
EKB-range figure — and confidence must be honestly 'low' (deals-only, zero
|
||||
scraped analogs).
|
||||
"""
|
||||
dkp_raw = {
|
||||
"count": 12,
|
||||
"low_ppm2": 70_000,
|
||||
"median_ppm2": 85_911,
|
||||
"high_ppm2": 100_000,
|
||||
"period_months": 12,
|
||||
}
|
||||
est = _run_estimate(
|
||||
analogs=[], # 0 listings — the honest post-geo-bound-fix reality
|
||||
dkp_raw=dkp_raw,
|
||||
geo=_make_geo_tagil(),
|
||||
payload=_make_payload_tagil(),
|
||||
)
|
||||
|
||||
assert est.median_price_per_m2 == 85_911, (
|
||||
f"headline={est.median_price_per_m2} must equal the deal corridor "
|
||||
f"median, not 0/n-a and nowhere near an EKB-range figure (~186k)"
|
||||
)
|
||||
assert est.median_price_per_m2 < 120_000, "must NOT be EKB-leaked (~186k)"
|
||||
assert est.n_analogs == 0, "honest: zero scraped-listing analogs were used"
|
||||
assert est.confidence == "low", "deals-only headline must be honestly low-confidence"
|
||||
assert est.median_price_rub == round(85_911 * 45.0)
|
||||
# Range should bracket the corridor's P10/P90, not collapse to a point.
|
||||
assert est.range_low_rub <= est.median_price_rub <= est.range_high_rub
|
||||
|
||||
|
||||
def test_non_ekb_empty_listings_no_deals_stays_insufficient() -> None:
|
||||
"""0 listings + NO deal corridor either → stays honest n/a (median=0).
|
||||
|
||||
Guards against the fallback inventing a number when there is truly no
|
||||
signal at all (e.g. Каменск-Уральский with an unresolvable street).
|
||||
"""
|
||||
est = _run_estimate(
|
||||
analogs=[],
|
||||
dkp_raw=None,
|
||||
geo=_make_geo_tagil(),
|
||||
payload=_make_payload_tagil(),
|
||||
)
|
||||
assert est.median_price_per_m2 == 0
|
||||
assert est.n_analogs == 0
|
||||
assert est.confidence == "low"
|
||||
|
||||
|
||||
def test_non_ekb_thin_deal_corridor_below_min_n_stays_insufficient() -> None:
|
||||
"""Deal corridor exists but below DEALS_HEADLINE_FALLBACK_MIN_N → no fallback.
|
||||
|
||||
A single stale sold price should not become the town's headline.
|
||||
"""
|
||||
dkp_raw = {
|
||||
"count": 1,
|
||||
"low_ppm2": 70_000,
|
||||
"median_ppm2": 85_911,
|
||||
"high_ppm2": 100_000,
|
||||
"period_months": 12,
|
||||
}
|
||||
est = _run_estimate(
|
||||
analogs=[],
|
||||
dkp_raw=dkp_raw,
|
||||
geo=_make_geo_tagil(),
|
||||
payload=_make_payload_tagil(),
|
||||
)
|
||||
assert est.median_price_per_m2 == 0
|
||||
assert est.n_analogs == 0
|
||||
|
||||
|
||||
# ── EKB control: dense local listings → deals-fallback must NOT engage ───────
|
||||
|
||||
|
||||
def test_ekb_with_dense_listings_ignores_deals_fallback() -> None:
|
||||
"""EKB has plenty of local listings — the radius-path headline must win,
|
||||
NOT the deal corridor, even though dkp_raw is present (byte-green guard:
|
||||
EKB must stay on the existing listings-median path unconditionally).
|
||||
"""
|
||||
from app.schemas.trade_in import TradeInEstimateInput
|
||||
from app.services.geocoder import GeocodeResult
|
||||
|
||||
analogs = [
|
||||
_make_listing(price_per_m2=140_000.0),
|
||||
_make_listing(price_per_m2=145_000.0),
|
||||
_make_listing(price_per_m2=150_000.0),
|
||||
]
|
||||
dkp_raw = {
|
||||
"count": 20,
|
||||
"low_ppm2": 120_000,
|
||||
"median_ppm2": 144_000,
|
||||
"high_ppm2": 160_000,
|
||||
"period_months": 12,
|
||||
}
|
||||
geo = GeocodeResult(
|
||||
lat=56.838,
|
||||
lon=60.595,
|
||||
full_address="Свердловская обл., Екатеринбург, ул. Малышева, 30",
|
||||
provider="nominatim",
|
||||
)
|
||||
payload = TradeInEstimateInput(
|
||||
address="ЕКБ, ул. Малышева, 30", area_m2=45.0, rooms=2, floor=5, total_floors=16
|
||||
)
|
||||
est = _run_estimate(analogs=analogs, dkp_raw=dkp_raw, geo=geo, payload=payload)
|
||||
|
||||
# Headline built from the LISTINGS median (~145k), not silently replaced —
|
||||
# n_analogs must reflect the real listing count (deals-fallback never ran).
|
||||
assert est.n_analogs == len(analogs)
|
||||
assert 140_000 <= est.median_price_per_m2 <= 150_000
|
||||
|
|
@ -313,7 +313,6 @@ def _run_estimate_anchor_only(
|
|||
anchor_tier: str | None = "A",
|
||||
):
|
||||
"""estimate_quality: пустой радиус (listings_clean=[]), якорь задаёт median_price."""
|
||||
from app.core.config import settings
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
db = MagicMock()
|
||||
|
|
@ -322,7 +321,6 @@ def _run_estimate_anchor_only(
|
|||
|
||||
async def _run():
|
||||
with (
|
||||
patch.object(settings, "estimate_same_building_anchor_enabled", True),
|
||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())),
|
||||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||||
|
|
|
|||
|
|
@ -5,22 +5,19 @@
|
|||
blend, rehydrate, миграция), который наполнит median/sources_used без единого
|
||||
реального аналога ("ghost-anchor", напр. legacy-строка 5fcc1e99: n=0, high,
|
||||
38.45M, yandex_valuation). Mainline уже честен — это belt-and-suspenders.
|
||||
|
||||
Гард за флагом settings.estimate_confidence_floor_no_analogs (дефолт True).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# pydantic Settings требует DATABASE_URL при инициализации.
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
# WeasyPrint stubbed in CI.
|
||||
sys.modules.setdefault("weasyprint", MagicMock())
|
||||
|
||||
import app.services.estimator as est # noqa: E402
|
||||
from app.services.estimator import _enforce_zero_analog_low # noqa: E402
|
||||
|
||||
# Suffix-маркер из гарда — проверяем его наличие/отсутствие, не дублирование.
|
||||
|
|
@ -108,23 +105,8 @@ def test_none_explanation_returns_empty_string_not_none() -> None:
|
|||
assert isinstance(explanation, str)
|
||||
|
||||
|
||||
def test_flag_default_true() -> None:
|
||||
"""estimate_confidence_floor_no_analogs по умолчанию True (гард активен)."""
|
||||
from app.core.config import settings
|
||||
|
||||
assert settings.estimate_confidence_floor_no_analogs is True
|
||||
|
||||
|
||||
def test_flag_off_does_not_downgrade() -> None:
|
||||
"""Флаг OFF: n_analogs=0 + confidence='high' → guard пропускается, high сохраняется.
|
||||
|
||||
Проверяет escape-hatch: при estimate_confidence_floor_no_analogs=False
|
||||
_enforce_zero_analog_low не вызывается (вызов обёрнут в if-гейт в estimator.py).
|
||||
Тест патчит settings через patch.object, вызывает функцию напрямую — убеждается
|
||||
что сам гард всё ещё понижает (без флага); интеграционный эффект флага проверяется
|
||||
через patch в estimate_quality (ниже).
|
||||
"""
|
||||
# Напрямую: _enforce_zero_analog_low всегда понижает (флаг только в if-гейте caller'а)
|
||||
def test_enforce_zero_analog_low_always_downgrades() -> None:
|
||||
"""_enforce_zero_analog_low всегда понижает high→low при n_analogs=0 (гард всегда ON)."""
|
||||
confidence, _ = _enforce_zero_analog_low(
|
||||
"high",
|
||||
0,
|
||||
|
|
@ -133,19 +115,3 @@ def test_flag_off_does_not_downgrade() -> None:
|
|||
sources_used=["yandex_valuation"],
|
||||
)
|
||||
assert confidence == "low" # сама функция не смотрит на settings
|
||||
|
||||
# Через патч: гейт в estimate_quality при флаге OFF пропускает вызов.
|
||||
with patch.object(est.settings, "estimate_confidence_floor_no_analogs", False):
|
||||
# _enforce_zero_analog_low не должна быть вызвана —
|
||||
# мокаем её и убеждаемся что мок не тронут.
|
||||
mock_guard = MagicMock(return_value=("high", "unchanged"))
|
||||
with patch.object(est, "_enforce_zero_analog_low", mock_guard):
|
||||
# Вызываем блок кода напрямую: повторяем if-гейт как в estimate_quality.
|
||||
confidence_in = "high"
|
||||
n = 0
|
||||
expl = "test"
|
||||
if est.settings.estimate_confidence_floor_no_analogs:
|
||||
confidence_in, expl = est._enforce_zero_analog_low(
|
||||
confidence_in, n, expl, median_price=0, sources_used=[]
|
||||
)
|
||||
mock_guard.assert_not_called()
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@
|
|||
- комбинированный случай (несколько причин сразу);
|
||||
- all-clear (нет причин → recommended False);
|
||||
- граничные значения порогов (>= high_value, >= wide_ratio);
|
||||
- div-guard на диапазон (range_low None/0 → без деления на ноль);
|
||||
- выключенный флаг estimate_manual_review_enabled → (False, []).
|
||||
- div-guard на диапазон (range_low None/0 → без деления на ноль).
|
||||
|
||||
Плюс smoke-проверка дефолтов AggregatedEstimate (флаг — метаданные: сериализация
|
||||
без изменений, когда ручная оценка не рекомендована).
|
||||
|
|
@ -36,14 +35,12 @@ ELITE_REASON = (
|
|||
|
||||
def _settings(
|
||||
*,
|
||||
enabled: bool = True,
|
||||
high_value: int = 20_000_000,
|
||||
wide_ratio: float = 1.9,
|
||||
elite_ppm2: int = 250_000,
|
||||
) -> SimpleNamespace:
|
||||
"""Duck-typed заглушка Settings — helper читает только эти поля."""
|
||||
return SimpleNamespace(
|
||||
estimate_manual_review_enabled=enabled,
|
||||
estimate_manual_review_high_value_rub=high_value,
|
||||
estimate_manual_review_wide_range_ratio=wide_ratio,
|
||||
estimate_manual_review_elite_ppm2=elite_ppm2,
|
||||
|
|
@ -320,23 +317,6 @@ def test_wide_range_div_guard(range_low: int | None, range_high: int | None) ->
|
|||
assert WIDE_RANGE_REASON not in reasons
|
||||
|
||||
|
||||
# ── Выключенный флаг ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_disabled_flag_suppresses_all() -> None:
|
||||
"""estimate_manual_review_enabled=False → (False, []) даже когда всё триггерит."""
|
||||
rec, reasons = _manual_review(
|
||||
premium_building=True,
|
||||
expected_sold_price=99_000_000,
|
||||
confidence="low",
|
||||
range_low=10_000_000,
|
||||
range_high=99_000_000,
|
||||
settings=_settings(enabled=False),
|
||||
)
|
||||
assert rec is False
|
||||
assert reasons == []
|
||||
|
||||
|
||||
# ── invariant recommended == bool(reasons) ───────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""Mera-audit fix-3: cross-source dedup в _fetch_price_trend.
|
||||
|
||||
Проверяет что при dedup_enabled=True один объект на avito_imv + yandex_valuation
|
||||
с разными ext_item_id не double-count'ится в месячной медиане.
|
||||
Проверяет что один объект на avito_imv + yandex_valuation с разными ext_item_id
|
||||
не double-count'ится в месячной медиане (dedup всегда включён).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -40,19 +40,11 @@ def _make_mock_db(source1_rows: list[dict], source2_rows: list[dict]) -> MagicMo
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_dedup_enabled_flag_is_true_by_default() -> None:
|
||||
"""estimate_price_trend_dedup_enabled дефолт True."""
|
||||
from app.core.config import settings
|
||||
|
||||
assert settings.estimate_price_trend_dedup_enabled is True
|
||||
|
||||
|
||||
def test_price_trend_dedup_sql_uses_distinct_on_when_enabled() -> None:
|
||||
"""Когда dedup включён, SQL-запрос к house_placement_history содержит DISTINCT ON."""
|
||||
def test_price_trend_dedup_sql_uses_distinct_on() -> None:
|
||||
"""SQL-запрос к house_placement_history всегда содержит DISTINCT ON (dedup)."""
|
||||
mock_db = _make_mock_db([], [{"month": "2026-01", "ppm2": 150_000}])
|
||||
|
||||
with patch("app.services.estimator.settings") as mock_settings:
|
||||
mock_settings.estimate_price_trend_dedup_enabled = True
|
||||
mock_settings.estimate_price_trend_max_age_months = 6
|
||||
|
||||
_fetch_price_trend(mock_db, target_house_id=42, min_points=1)
|
||||
|
|
@ -64,24 +56,8 @@ def test_price_trend_dedup_sql_uses_distinct_on_when_enabled() -> None:
|
|||
assert "DISTINCT ON" in second_sql.upper() or "distinct on" in second_sql.lower()
|
||||
|
||||
|
||||
def test_price_trend_dedup_disabled_no_distinct_on() -> None:
|
||||
"""Когда dedup выключен, SQL НЕ содержит DISTINCT ON (legacy path)."""
|
||||
mock_db = _make_mock_db([], [{"month": "2026-01", "ppm2": 150_000}])
|
||||
|
||||
with patch("app.services.estimator.settings") as mock_settings:
|
||||
mock_settings.estimate_price_trend_dedup_enabled = False
|
||||
mock_settings.estimate_price_trend_max_age_months = 6
|
||||
|
||||
_fetch_price_trend(mock_db, target_house_id=42, min_points=1)
|
||||
|
||||
calls = mock_db.execute.call_args_list
|
||||
assert len(calls) >= 2
|
||||
second_sql = str(calls[1].args[0].text) if calls[1].args else ""
|
||||
assert "distinct on" not in second_sql.lower()
|
||||
|
||||
|
||||
def test_price_trend_returns_result_with_dedup_enabled() -> None:
|
||||
"""Функция корректно возвращает точки тренда при dedup_enabled=True."""
|
||||
def test_price_trend_returns_result_with_dedup() -> None:
|
||||
"""Функция корректно возвращает точки тренда (dedup включён)."""
|
||||
rows = [
|
||||
{"month": "2025-11", "ppm2": 148_000},
|
||||
{"month": "2025-12", "ppm2": 152_000},
|
||||
|
|
@ -90,7 +66,6 @@ def test_price_trend_returns_result_with_dedup_enabled() -> None:
|
|||
mock_db = _make_mock_db([], rows)
|
||||
|
||||
with patch("app.services.estimator.settings") as mock_settings:
|
||||
mock_settings.estimate_price_trend_dedup_enabled = True
|
||||
mock_settings.estimate_price_trend_max_age_months = 6
|
||||
|
||||
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)
|
||||
|
|
@ -101,25 +76,6 @@ def test_price_trend_returns_result_with_dedup_enabled() -> None:
|
|||
assert result[0]["ppm2"] == 148_000
|
||||
|
||||
|
||||
def test_price_trend_dedup_disabled_returns_same_points() -> None:
|
||||
"""Флаг OFF не ломает возврат данных (backward-compat)."""
|
||||
rows = [
|
||||
{"month": "2025-11", "ppm2": 148_000},
|
||||
{"month": "2025-12", "ppm2": 152_000},
|
||||
{"month": "2026-01", "ppm2": 155_000},
|
||||
]
|
||||
mock_db = _make_mock_db([], rows)
|
||||
|
||||
with patch("app.services.estimator.settings") as mock_settings:
|
||||
mock_settings.estimate_price_trend_dedup_enabled = False
|
||||
mock_settings.estimate_price_trend_max_age_months = 6
|
||||
|
||||
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)
|
||||
|
||||
assert result is not None
|
||||
assert len(result) == 3
|
||||
|
||||
|
||||
def test_price_trend_source1_preferred_over_dedup_path() -> None:
|
||||
"""Если Source1 (houses_price_dynamics) вернул ≥ min_points → Source2 не вызывается."""
|
||||
source1_rows = [
|
||||
|
|
@ -130,7 +86,6 @@ def test_price_trend_source1_preferred_over_dedup_path() -> None:
|
|||
mock_db = _make_mock_db(source1_rows, [])
|
||||
|
||||
with patch("app.services.estimator.settings") as mock_settings:
|
||||
mock_settings.estimate_price_trend_dedup_enabled = True
|
||||
mock_settings.estimate_price_trend_max_age_months = 6
|
||||
|
||||
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)
|
||||
|
|
|
|||
|
|
@ -181,18 +181,49 @@ def test_confidence_high_with_7_unique_addresses_and_tight_iqr() -> None:
|
|||
|
||||
|
||||
def test_confidence_medium_via_4_unique_addresses() -> None:
|
||||
# 4 unique addresses → medium branch (independent of IQR).
|
||||
# Wide IQR ensures it is NOT "high". avg = 4/4 = 1.0 (no downgrade).
|
||||
# #R2-H2: 4 unique addresses AND IQR/median < 0.35 → medium. avg = 4/4 = 1.0 (no downgrade).
|
||||
listings = _addr_lots(["a", "b", "c", "d"])
|
||||
level, _ = estimator._compute_confidence(
|
||||
n_analogs=4,
|
||||
median_ppm2=100,
|
||||
q1=90,
|
||||
q3=115, # IQR/median = 0.25 < 0.35 → medium
|
||||
fallback_radius_used=False,
|
||||
listings=listings,
|
||||
)
|
||||
assert level == "medium"
|
||||
|
||||
|
||||
def test_confidence_4_addresses_wide_iqr_now_low() -> None:
|
||||
# #R2-H2: 4 unique addresses but IQR/median = 0.60 (huge dispersion) → low, NOT medium.
|
||||
# Dispersion ceiling: the badge must not contradict a ±30% spread in its own explanation.
|
||||
listings = _addr_lots(["a", "b", "c", "d"])
|
||||
level, _ = estimator._compute_confidence(
|
||||
n_analogs=4,
|
||||
median_ppm2=100,
|
||||
q1=70,
|
||||
q3=130, # IQR/median = 0.60 → not high
|
||||
q3=130, # IQR/median = 0.60
|
||||
fallback_radius_used=False,
|
||||
listings=listings,
|
||||
)
|
||||
assert level == "medium"
|
||||
assert level == "low"
|
||||
|
||||
|
||||
def test_confidence_force_low_on_radius_widen_with_dispersion() -> None:
|
||||
# #R2-H2: a pool that would be medium (4 addr, IQR 0.32 < 0.35) is FORCED low when it
|
||||
# was radius-widened due to sparse data AND spread > 0.30 — badge can't say "medium"
|
||||
# while the explanation admits "расширили радиус … из-за нехватки данных".
|
||||
listings = _addr_lots(["a", "b", "c", "d"])
|
||||
level, expl = estimator._compute_confidence(
|
||||
n_analogs=4,
|
||||
median_ppm2=100,
|
||||
q1=84,
|
||||
q3=116, # IQR/median = 0.32 → medium base, then forced low
|
||||
fallback_radius_used=True,
|
||||
listings=listings,
|
||||
)
|
||||
assert level == "low"
|
||||
assert "расширили радиус" in expl
|
||||
|
||||
|
||||
def test_confidence_medium_via_2_unique_addresses_and_tight_iqr() -> None:
|
||||
|
|
@ -313,7 +344,6 @@ def _clamp(
|
|||
tier: str | None,
|
||||
slack: float = 0.25,
|
||||
min_n: int = 10,
|
||||
enabled: bool = True,
|
||||
median_price: int = 0,
|
||||
range_low: int = 0,
|
||||
range_high: int = 0,
|
||||
|
|
@ -328,7 +358,6 @@ def _clamp(
|
|||
anchor_tier=tier,
|
||||
slack=slack,
|
||||
min_n=min_n,
|
||||
enabled=enabled,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -374,15 +403,6 @@ def test_corridor_clamp_low_n_is_noop() -> None:
|
|||
assert new_ppm2 == 296_000
|
||||
|
||||
|
||||
def test_corridor_clamp_disabled_flag_is_noop() -> None:
|
||||
# enabled=False → точно старое поведение.
|
||||
new_ppm2, _, _, _, clamped = _clamp(
|
||||
median_ppm2=296_000, corridor_high=138_000, count=20, tier="C", enabled=False
|
||||
)
|
||||
assert clamped is False
|
||||
assert new_ppm2 == 296_000
|
||||
|
||||
|
||||
def test_corridor_clamp_tier_none_clamps() -> None:
|
||||
# anchor не сработал (tier=None, чистый радиус) — клампим как и Tier C.
|
||||
new_ppm2, _, _, _, clamped = _clamp(
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue