gendesign/tradein-mvp/backend/app/services/rosreestr_poll.py
bot-backend c2888ac52d
Some checks failed
Deploy Trade-In / build-backend (push) Successful in 2m21s
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 33s
Deploy Trade-In / deploy (push) Has been cancelled
feat(tradein): scheduled monthly Rosreestr new-quarter poll → ingest alert (#888) (#894)
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-05-31 13:13:55 +00:00

253 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Scheduled poll: detect when a new Rosreestr quarter becomes available (#888).
Граница ответственности модуля:
- DETECT: определить, появился ли новый квартал (next after latest loaded) в
открытых данных Росреестра.
- ALERT: залогировать actionable-сообщение, если квартал доступен.
- НЕ скачивает много-гигабайтный ZIP и НЕ вызывает shell-loaders — это ручной
ops-шаг. После алерта оператор запускает:
data/sql/02_load_all_quarters.sh
tradein-mvp/deploy/import-rosreestr.sh
Архив открытых данных Росреестра (сделки ДКП/ДДУ) публикуется на портале:
https://rosreestr.gov.ru/opendata/
Файлы именуются по шаблону:
dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{N}.csv.zip
Индексный JSON живёт по адресу:
https://rosreestr.gov.ru/opendata/f.json (список всех датасетов)
Проверяем HEAD-запросом (без скачивания) прямой ссылки на следующий квартал.
Формат ссылки:
https://rosreestr.gov.ru/opendata/dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{N}.csv.zip
При сетевой ошибке / HTTP 5xx — логируем warning, возвращаем available=False.
Статус 404 → квартал не опубликован → available=False (штатный случай до начала августа).
"""
from __future__ import annotations
import logging
from typing import Any
import httpx
from sqlalchemy import text
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"
)
# Таймаут HEAD-запроса. Росреестр может быть медленным — 15s достаточно.
_HTTP_TIMEOUT = 15.0
def _next_quarter(year: int, quarter: int) -> tuple[int, int]:
"""Вернуть (year, quarter) для следующего квартала.
>>> _next_quarter(2026, 1)
(2026, 2)
>>> _next_quarter(2026, 4)
(2027, 1)
"""
if quarter < 4:
return year, quarter + 1
return year + 1, 1
def _period_start_to_quarter(period_start_date: Any) -> tuple[int, int]:
"""Конвертировать дату начала периода в (year, quarter).
period_start_date — дата первого числа квартала (e.g. 2026-01-01 → Q1 2026).
Принимает datetime.date, datetime.datetime или ISO-строку.
"""
if hasattr(period_start_date, "year"):
year = period_start_date.year
month = period_start_date.month
else:
# ISO string fallback
import datetime
d = datetime.date.fromisoformat(str(period_start_date)[:10])
year = d.year
month = d.month
quarter = (month - 1) // 3 + 1
return year, quarter
def latest_loaded_quarter(db: Session) -> tuple[int, int] | None:
"""Вернуть (year, quarter) последнего загруженного квартала Росреестра.
Запрашивает MAX(period_start_date) FROM deals WHERE source='rosreestr'.
Возвращает None если таблица пуста или данных от rosreestr нет.
Использует синхронный Session (SQLAlchemy, psycopg v3 через SQLAlchemy dialect).
"""
row = db.execute(
text(
"""
SELECT MAX(deal_date) AS max_date
FROM deals
WHERE source = 'rosreestr'
AND deal_date IS NOT NULL
"""
)
).fetchone()
if row is None or row[0] is None:
logger.info(
"rosreestr_poll: no rosreestr deals found in DB — cannot determine latest quarter"
)
return None
year, quarter = _period_start_to_quarter(row[0])
logger.info(
"rosreestr_poll: latest loaded quarter = Q%d %d (derived from max deal_date=%s)",
quarter,
year,
row[0],
)
return year, quarter
def rosreestr_dataset_url(year: int, quarter: int) -> str:
"""Сформировать URL датасета Росреестра для заданного квартала.
Возвращает прямую ссылку на ZIP-архив (percent-encoded шаблон).
"""
return _ROSREESTR_DATASET_URL_TEMPLATE.format(year=year, quarter=quarter)
async def check_new_quarter_available(
client: httpx.AsyncClient,
year: int,
quarter: int,
) -> bool:
"""Проверить, опубликован ли датасет Росреестра для (year, quarter).
HEAD-запрос к прямой ссылке на ZIP — без скачивания файла.
Возвращает True если сервер ответил 200 (или 2xx/3xx с redirect).
Возвращает False при 404 (квартал ещё не опубликован) или сетевой ошибке.
Никогда не поднимает исключения в вызывающий код (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)",
quarter,
year,
url,
resp.status_code,
)
return True
if resp.status_code == 404:
logger.info(
"rosreestr_poll: Q%d %d not yet published (HTTP 404)",
quarter,
year,
)
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",
quarter,
year,
url,
)
return False
except httpx.RequestError as exc:
logger.warning(
"rosreestr_poll: network error checking Q%d %d: %s — treating as unavailable",
quarter,
year,
exc,
)
return False
except Exception as exc:
logger.warning(
"rosreestr_poll: unexpected error checking Q%d %d: %s — treating as unavailable",
quarter,
year,
exc,
)
return False
async def poll_rosreestr_new_quarter(db: Session) -> dict[str, Any]:
"""Orchestrator: определить следующий квартал после загруженного, проверить наличие.
Шаги:
1. Найти MAX(deal_date) WHERE source='rosreestr' → (loaded_year, loaded_quarter).
2. Вычислить next = _next_quarter(loaded_year, loaded_quarter).
3. HEAD-запросом проверить наличие датасета на rosreestr.gov.ru.
4. Если доступен — логировать actionable INFO с инструкцией по запуску ingest.
5. Вернуть dict с результатом.
Если в БД нет данных rosreestr → предполагаем 2025Q4, проверяем 2026Q1.
Это fallback — в штатном режиме данные всегда есть после initial load.
Возвращает:
{"available": bool, "year": int, "quarter": int,
"latest_loaded_year": int | None, "latest_loaded_quarter": int | None}
"""
# 1. Текущий максимальный загруженный квартал
latest = latest_loaded_quarter(db)
if latest is None:
# Fallback: нет данных — проверяем 2026Q1 как первую точку
loaded_year, loaded_quarter = 2025, 4
logger.warning(
"rosreestr_poll: no deals in DB — using fallback baseline 2025Q4, "
"checking 2026Q1 availability"
)
else:
loaded_year, loaded_quarter = latest
# 2. Следующий квартал
next_year, next_quarter = _next_quarter(loaded_year, loaded_quarter)
logger.info(
"rosreestr_poll: checking Q%d %d (next after loaded Q%d %d)",
next_quarter,
next_year,
loaded_quarter,
loaded_year,
)
# 3. Проверка наличия
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT) 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",
next_quarter,
next_year,
)
return {
"available": available,
"year": next_year,
"quarter": next_quarter,
"latest_loaded_year": loaded_year if latest is not None else None,
"latest_loaded_quarter": loaded_quarter if latest is not None else None,
}