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
Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
253 lines
9.8 KiB
Python
253 lines
9.8 KiB
Python
"""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,
|
||
}
|