diff --git a/tradein-mvp/backend/app/services/cbr_macro.py b/tradein-mvp/backend/app/services/cbr_macro.py
new file mode 100644
index 00000000..9815d847
--- /dev/null
+++ b/tradein-mvp/backend/app/services/cbr_macro.py
@@ -0,0 +1,464 @@
+"""Макро-ряды ЦБ РФ: ипотека по субъектам (XLSX) + ключевая ставка (SOAP).
+
+CONTEXT: продукту нужен региональный макро-контекст ипотечного рынка (динамика ставок,
+объёмов выдач и задолженности по субъектам РФ) и дневная ключевая ставка. Этот модуль
+только собирает данные в cbr_mortgage_series / cbr_key_rate (292_cbr_macro_series.sql) —
+подключение к estimator вынесено в отдельное продуктовое решение (out of scope).
+
+ИСТОЧНИК (ипотека): три XLSX-дашборда cbr.ru, wide-формат (территория × месяц),
+строка 1 пустая, строка 2 — заголовок-описание, строка 3 — шапка периодов
+(«Январь 2019», «Февраль 2019», …), строки 4+ — территории в колонке A
+(РФ целиком → федеральные округа → субъекты). Unpivot в длинный ряд
+(region, period_month, series, value).
+
+ИСТОЧНИК (ключевая ставка): POST https://www.cbr.ru/DailyInfoWebServ/DailyInfo.asmx,
+SOAP 1.1 (Content-Type: text/xml; charset=utf-8, SOAPAction: http://web.cbr.ru/KeyRate),
+метод KeyRate(fromDate, ToDate). Ответ — DataSet-XML со строками
+датазначение (namespace-агностичный парсинг: берём по
+локальному имени тега, т.к. .NET DataSet оборачивает их в diffgram/reset-default-ns).
+
+TLS: cbr.ru отдаёт RU-сертификат → verify=False (open data, без auth/PII).
+psycopg v3: CAST(:x AS type), НИКОГДА :x::type.
+
+Дизайн-инвариант (как domrf_kapremont_loader): сервис-функции НЕ коммитят — коммитит
+caller (app/tasks/cbr_macro_pull.py). Изоляция сбоев — try/except на серию,
+db.rollback() в except (иначе аборченная транзакция каскадит на следующие серии).
+"""
+
+from __future__ import annotations
+
+import io
+import logging
+import xml.etree.ElementTree as ET
+from dataclasses import dataclass
+from datetime import date, datetime
+
+import httpx
+import openpyxl
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+logger = logging.getLogger(__name__)
+
+DOWNLOAD_TIMEOUT_SEC = 60.0
+
+# ---------------------------------------------------------------------------
+# Ипотека по субъектам — конфиг серий
+# ---------------------------------------------------------------------------
+
+_MORTGAGE_BASE_URL = "https://www.cbr.ru/vfs/statistics/BankSector/Mortgage"
+NEW_LOANS_URL = f"{_MORTGAGE_BASE_URL}/02_11_New_loans_mortgage.xlsx"
+RATES_URL = f"{_MORTGAGE_BASE_URL}/02_13_Rates_mortgage.xlsx"
+DEBT_URL = f"{_MORTGAGE_BASE_URL}/02_14_Debt_mortgage.xlsx"
+
+
+@dataclass(frozen=True)
+class CbrMortgageSeries:
+ slug: str
+ url: str
+ sheet: str
+
+
+# По одной серии на файл (лист «в рублях» — основной ряд каждого дашборда).
+CBR_MORTGAGE_SERIES: list[CbrMortgageSeries] = [
+ CbrMortgageSeries("new_loans_rub", NEW_LOANS_URL, "в рублях"),
+ CbrMortgageSeries("rate_rub", RATES_URL, "ставка в рублях"),
+ CbrMortgageSeries("debt_rub", DEBT_URL, "в рублях"),
+]
+
+
+@dataclass(slots=True, frozen=True)
+class CbrMortgageRow:
+ region: str
+ period_month: date
+ series: str
+ value: float
+
+
+# ---------------------------------------------------------------------------
+# Период: русские имена месяцев → date(y, m, 1)
+# ---------------------------------------------------------------------------
+
+RU_MONTHS: dict[str, int] = {
+ "Январь": 1,
+ "Февраль": 2,
+ "Март": 3,
+ "Апрель": 4,
+ "Май": 5,
+ "Июнь": 6,
+ "Июль": 7,
+ "Август": 8,
+ "Сентябрь": 9,
+ "Октябрь": 10,
+ "Ноябрь": 11,
+ "Декабрь": 12,
+}
+
+
+def parse_ru_period(period_str: str) -> date:
+ """Разобрать шапку периода «Январь 2019» → date(2019, 1, 1)."""
+ parts = str(period_str).strip().split()
+ if len(parts) != 2:
+ raise ValueError(f"cbr_macro: не удалось разобрать период {period_str!r}")
+ month_name, year_str = parts
+ month = RU_MONTHS.get(month_name)
+ if month is None:
+ raise ValueError(f"cbr_macro: неизвестное имя месяца {month_name!r} в {period_str!r}")
+ try:
+ year = int(year_str)
+ except ValueError as exc:
+ raise ValueError(f"cbr_macro: неверный год в периоде {period_str!r}") from exc
+ return date(year, month, 1)
+
+
+def parse_period_cell(cell: object) -> date:
+ """Разобрать ячейку шапки периода в первое число месяца.
+
+ ЦБ отдаёт шапку в двух формах, зависящих от файла:
+ * текст «Январь 2019» — 02_11_New_loans_mortgage, 02_13_Rates_mortgage;
+ * настоящий datetime/date — 02_14_Debt_mortgage.
+ Обе приводятся к date(y, m, 1).
+ """
+ if isinstance(cell, datetime):
+ return date(cell.year, cell.month, 1)
+ if isinstance(cell, date):
+ return date(cell.year, cell.month, 1)
+ return parse_ru_period(str(cell))
+
+
+# ---------------------------------------------------------------------------
+# Unpivot листа (чистая функция — тестируется без сети/файла)
+# ---------------------------------------------------------------------------
+
+HEADER_SCAN_ROWS = 6
+"""Сколько первых строк просматривать в поисках шапки периодов."""
+
+MIN_HEADER_PERIODS = 6
+"""Минимум распознанных периодов, чтобы считать строку шапкой (а не данными)."""
+
+
+def _locate_header_row(rows: list[tuple[object, ...]]) -> tuple[int, dict[int, date]]:
+ """Найти строку шапки периодов и колонки-периоды в ней.
+
+ Раскладка у файлов ЦБ РАЗНАЯ и жёстко фиксировать индекс строки нельзя:
+ * 02_11 / 02_13 — строка 1 пустая, строка 2 — описание, шапка в строке 3 (idx 2);
+ * 02_14 (задолженность) — строка 1 описание, шапка уже в строке 2 (idx 1),
+ и периоды там datetime, а не «Январь 2019».
+ Ранее индекс был захардкожен на idx 2, из-за чего серия задолженности молча
+ давала ноль строк. Поэтому шапку ищем: берём строку с наибольшим числом
+ распознанных периодов среди первых HEADER_SCAN_ROWS.
+ """
+ best_idx = -1
+ best_periods: dict[int, date] = {}
+ for row_idx, row in enumerate(rows[:HEADER_SCAN_ROWS]):
+ periods: dict[int, date] = {}
+ for col_idx, cell in enumerate(row):
+ if col_idx == 0 or cell is None or str(cell).strip() == "":
+ continue
+ try:
+ periods[col_idx] = parse_period_cell(cell)
+ except ValueError:
+ continue
+ if len(periods) > len(best_periods):
+ best_idx, best_periods = row_idx, periods
+ if len(best_periods) < MIN_HEADER_PERIODS:
+ return -1, {}
+ return best_idx, best_periods
+
+
+def parse_mortgage_sheet(
+ rows: list[tuple[object, ...]], *, series_slug: str
+) -> list[CbrMortgageRow]:
+ """Unpivot wide-листа ЦБ (территория × месяц) в длинный ряд.
+
+ rows — результат ws.iter_rows(values_only=True). Строка шапки периодов ищется
+ динамически (`_locate_header_row`), территории идут сразу после неё.
+ """
+ if len(rows) < 3:
+ return []
+
+ header_idx, periods = _locate_header_row(rows)
+ if header_idx < 0:
+ logger.warning(
+ "cbr_macro: шапка периодов не найдена для series=%s — лист пропущен", series_slug
+ )
+ return []
+
+ out: list[CbrMortgageRow] = []
+ for data_row in rows[header_idx + 1 :]:
+ if not data_row or data_row[0] is None:
+ continue
+ region = str(data_row[0]).strip()
+ if not region:
+ continue
+ for col_idx, period_month in periods.items():
+ if col_idx >= len(data_row):
+ continue
+ raw_value = data_row[col_idx]
+ if raw_value is None:
+ continue
+ try:
+ value = float(raw_value)
+ except (TypeError, ValueError):
+ logger.warning(
+ "cbr_macro: пропуск ячейки region=%r period=%s series=%s — не число: %r",
+ region,
+ period_month,
+ series_slug,
+ raw_value,
+ )
+ continue
+ out.append(
+ CbrMortgageRow(
+ region=region, period_month=period_month, series=series_slug, value=value
+ )
+ )
+ return out
+
+
+# ---------------------------------------------------------------------------
+# Загрузка XLSX + upsert
+# ---------------------------------------------------------------------------
+
+_UPSERT_MORTGAGE_SQL = text("""
+ INSERT INTO cbr_mortgage_series (region, period_month, series, value, source, fetched_at)
+ VALUES (
+ CAST(:region AS text),
+ CAST(:period_month AS date),
+ CAST(:series AS text),
+ CAST(:value AS double precision),
+ 'cbr',
+ now()
+ )
+ ON CONFLICT (region, period_month, series)
+ DO UPDATE SET
+ value = EXCLUDED.value
+ -- fetched_at НЕ трогаем (#2846 у sber_price_index): означает
+ -- «когда мы ВПЕРВЫЕ увидели этот период», а не время последней загрузки.
+""")
+
+
+def _upsert_mortgage_rows(db: Session, rows: list[CbrMortgageRow]) -> int:
+ for row in rows:
+ db.execute(
+ _UPSERT_MORTGAGE_SQL,
+ {
+ "region": row.region,
+ "period_month": row.period_month.isoformat(),
+ "series": row.series,
+ "value": row.value,
+ },
+ )
+ return len(rows)
+
+
+def _download(url: str, *, client: httpx.Client) -> bytes:
+ resp = client.get(url, timeout=DOWNLOAD_TIMEOUT_SEC)
+ resp.raise_for_status()
+ return resp.content
+
+
+def load_cbr_mortgage_series(
+ db: Session,
+ series: CbrMortgageSeries,
+ *,
+ client: httpx.Client | None = None,
+ dry_run: bool = False,
+) -> dict[str, int]:
+ """Скачать один XLSX-дашборд, разобрать один лист, upsert-нуть ряд.
+
+ Не коммитит — коммитит caller.
+ """
+ own_client = client is None
+ if own_client:
+ # cbr.ru отдаёт RU-сертификат НУЦ Минцифры → verify=False. Открытые данные,
+ # без auth/PII (прецедент: sber_index.py, domrf_kapremont_loader.py).
+ client = httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False)
+ try:
+ data = _download(series.url, client=client)
+ finally:
+ if own_client:
+ client.close()
+
+ wb = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True)
+ try:
+ if series.sheet not in wb.sheetnames:
+ raise ValueError(
+ f"cbr_macro: лист {series.sheet!r} отсутствует в {series.url} "
+ f"(есть: {wb.sheetnames!r})"
+ )
+ ws = wb[series.sheet]
+ rows_raw = list(ws.iter_rows(values_only=True))
+ finally:
+ wb.close()
+
+ parsed = parse_mortgage_sheet(rows_raw, series_slug=series.slug)
+ upserted = 0 if dry_run else _upsert_mortgage_rows(db, parsed)
+ return {"rows": len(parsed), "upserted": upserted}
+
+
+def pull_cbr_mortgage(
+ db: Session,
+ *,
+ series_list: list[CbrMortgageSeries] | None = None,
+ client: httpx.Client | None = None,
+ dry_run: bool = False,
+) -> dict[str, int]:
+ """Забрать все (или выбранные) серии ипотечной статистики.
+
+ Per-series try/except: одна сбойнувшая серия логируется и не роняет остальные
+ (урок #1345 у sber_index: без rollback аборченная транзакция каскадит дальше).
+ """
+ if series_list is None:
+ series_list = CBR_MORTGAGE_SERIES
+
+ counters = {"upserted": 0, "skipped": 0, "errors": 0}
+ own_client = client is None
+ if own_client:
+ client = httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False)
+ try:
+ for series in series_list:
+ try:
+ result = load_cbr_mortgage_series(db, series, client=client, dry_run=dry_run)
+ if result["rows"] == 0:
+ logger.info("cbr_macro: пустой результат для series=%s", series.slug)
+ counters["skipped"] += 1
+ else:
+ counters["upserted"] += result["upserted"]
+ logger.info(
+ "cbr_macro: upserted %d rows for series=%s", result["upserted"], series.slug
+ )
+ except Exception:
+ db.rollback()
+ logger.exception("cbr_macro: series=%s сбойнула — пропуск", series.slug)
+ counters["errors"] += 1
+ finally:
+ if own_client:
+ client.close()
+ return counters
+
+
+# ---------------------------------------------------------------------------
+# Ключевая ставка — SOAP
+# ---------------------------------------------------------------------------
+
+CBR_SOAP_URL = "https://www.cbr.ru/DailyInfoWebServ/DailyInfo.asmx"
+KEY_RATE_START = date(2013, 9, 13) # инструмент введён Советом директоров ЦБ РФ
+
+_KEY_RATE_ENVELOPE = """
+
+
+
+ {from_date}
+ {to_date}
+
+
+"""
+
+
+def build_key_rate_envelope(from_date: date, to_date: date) -> str:
+ return _KEY_RATE_ENVELOPE.format(from_date=from_date.isoformat(), to_date=to_date.isoformat())
+
+
+def _local_tag(tag: str) -> str:
+ """Локальное имя тега без namespace-префикса ('{ns}KR' → 'KR')."""
+ return tag.rsplit("}", 1)[-1]
+
+
+def parse_key_rate_response(xml_text: str) -> list[tuple[date, float]]:
+ """Разобрать SOAP-ответ KeyRate: строки …….
+
+ Namespace-агностично (ищем по локальному имени тега) — .NET DataSet
+ оборачивает строки в diffgram/reset-default-ns, точный путь не гарантирован.
+ """
+ root = ET.fromstring(xml_text)
+ out: list[tuple[date, float]] = []
+ for el in root.iter():
+ if _local_tag(el.tag) != "KR":
+ continue
+ dt_text: str | None = None
+ rate_text: str | None = None
+ for child in el:
+ name = _local_tag(child.tag)
+ if name == "DT":
+ dt_text = child.text
+ elif name == "Rate":
+ rate_text = child.text
+ if not dt_text or rate_text is None:
+ continue
+ try:
+ rate_date = datetime.fromisoformat(dt_text).date()
+ rate = float(rate_text)
+ except (ValueError, TypeError):
+ logger.warning(
+ "cbr_macro: пропуск строки KeyRate — не распознана: DT=%r Rate=%r",
+ dt_text,
+ rate_text,
+ )
+ continue
+ out.append((rate_date, rate))
+ return out
+
+
+def fetch_key_rate(
+ client: httpx.Client, *, from_date: date, to_date: date
+) -> list[tuple[date, float]]:
+ body = build_key_rate_envelope(from_date, to_date)
+ headers = {
+ "Content-Type": "text/xml; charset=utf-8",
+ "SOAPAction": "http://web.cbr.ru/KeyRate",
+ }
+ resp = client.post(CBR_SOAP_URL, content=body.encode("utf-8"), headers=headers)
+ resp.raise_for_status()
+ return parse_key_rate_response(resp.text)
+
+
+_UPSERT_KEY_RATE_SQL = text("""
+ INSERT INTO cbr_key_rate (rate_date, rate, fetched_at)
+ VALUES (CAST(:rate_date AS date), CAST(:rate AS double precision), now())
+ ON CONFLICT (rate_date)
+ DO UPDATE SET
+ rate = EXCLUDED.rate
+ -- fetched_at НЕ трогаем — та же логика, что и у cbr_mortgage_series.
+""")
+
+
+def _upsert_key_rate_rows(db: Session, rows: list[tuple[date, float]]) -> int:
+ for rate_date, rate in rows:
+ db.execute(_UPSERT_KEY_RATE_SQL, {"rate_date": rate_date.isoformat(), "rate": rate})
+ return len(rows)
+
+
+def load_key_rate(
+ db: Session,
+ *,
+ from_date: date | None = None,
+ to_date: date | None = None,
+ client: httpx.Client | None = None,
+ dry_run: bool = False,
+) -> dict[str, int]:
+ """Скачать ключевую ставку за диапазон дат, upsert-нуть в cbr_key_rate.
+
+ Не коммитит — коммитит caller. from_date/to_date по умолчанию — вся история
+ инструмента (KEY_RATE_START) до сегодня.
+ """
+ if from_date is None:
+ from_date = KEY_RATE_START
+ if to_date is None:
+ to_date = date.today()
+
+ own_client = client is None
+ if own_client:
+ client = httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False)
+ try:
+ rows = fetch_key_rate(client, from_date=from_date, to_date=to_date)
+ finally:
+ if own_client:
+ client.close()
+
+ upserted = 0 if dry_run else _upsert_key_rate_rows(db, rows)
+ result = {"rows": len(rows), "upserted": upserted}
+ logger.info("cbr_macro: key_rate load DONE (dry_run=%s): %s", dry_run, result)
+ return result
diff --git a/tradein-mvp/backend/app/services/dtp_index.py b/tradein-mvp/backend/app/services/dtp_index.py
new file mode 100644
index 00000000..36c3c406
--- /dev/null
+++ b/tradein-mvp/backend/app/services/dtp_index.py
@@ -0,0 +1,157 @@
+"""Радиусные агрегаты ДТП вокруг точки — `dtp_incidents` (#3410).
+
+Отдельный модуль, не расширение `location_index.py`: та модель считает ЦЕНОВОЙ индекс
+(медиана ₽/м² локально vs по городу) с фиксированным bbox продукт-ядра города — другая
+предметная область и другой контракт результата (status/coverage у location_index
+завязан на `regions_mod`/`bbox_product_core`, ДТП покрывает всю область без city-bbox
+ограничения источника). Общее — только сам паттерн запроса (bbox-префильтр +
+`ST_DWithin(geom::geography, …)`) и dtp_incidents/osm_poi_ekb_local roднит один и тот же
+"пустая таблица -> unavailable" graceful fallback (см. `_fetch_nearby_poi` в
+location_index.py) — переиспользован здесь буквально.
+
+Bbox-префильтр здесь, в отличие от location_index (там фиксированный bbox city-ядра),
+строится ВОКРУГ ТОЧКИ по радиусу (`_bbox_from_point`) — dtp_incidents не ограничена
+одним городом, фиксированного bbox покрытия нет. Дешёвый lat/lon BETWEEN перед
+ST_DWithin — тот же приём, что в шаблоне recon_geo.md п.3.
+
+psycopg v3: `CAST(:x AS type)`, никогда `:x::type`.
+"""
+
+from __future__ import annotations
+
+import logging
+import math
+from typing import Any
+
+from pydantic import BaseModel
+from sqlalchemy import text
+
+logger = logging.getLogger(__name__)
+
+# Радиус по умолчанию для "фактора безопасности" района — сопоставим по порядку
+# величины с DEFAULT_POI_RADIUS_M=1200 в location_index.py (тот же масштаб "пешком от
+# дома"), но не импортируется оттуда: разные предметные области, совпадение числа
+# не должно создавать ложную связь между модулями.
+DEFAULT_DTP_RADIUS_M = 1000
+
+# Сколько лет ДТП назад учитывать по умолчанию — источник копит данные с 2015 (см.
+# докстринг dtp_stat_loader.py), но "фактор безопасности" района должен отражать
+# ТЕКУЩУЮ ситуацию, а не десятилетнюю историю.
+DEFAULT_YEARS_LOOKBACK = 5
+
+_METERS_PER_DEGREE_LAT = 111_320.0
+
+
+def _bbox_from_point(lat: float, lon: float, radius_m: int) -> tuple[float, float, float, float]:
+ """(south, north, west, east) — bbox вокруг точки радиусом radius_m (приближённо)."""
+ lat_delta = radius_m / _METERS_PER_DEGREE_LAT
+ lon_scale = math.cos(math.radians(lat))
+ # Защита от вырождения делителя у полюсов — для Свердловской обл. (~56-61° с.ш.)
+ # cos никогда не приближается к нулю, но защита дешёвая и делает функцию safe везде.
+ lon_delta = radius_m / (_METERS_PER_DEGREE_LAT * max(lon_scale, 0.01))
+ return (lat - lat_delta, lat + lat_delta, lon - lon_delta, lon + lon_delta)
+
+
+class DtpAreaStats(BaseModel):
+ """Результат compute_dtp_stats."""
+
+ status: str # "ok" | "unavailable" (dtp_incidents пуста — рефреш ещё не запускался)
+ radius_m: int
+ years: int
+ incidents_count: int
+ severe_count: int
+ dead: int
+ injured: int
+
+
+_DTP_STATS_SQL = text(
+ """
+ SELECT
+ count(*) AS incidents_count,
+ count(*) FILTER (WHERE severity = 'Тяжёлый') AS severe_count,
+ COALESCE(sum(dead), 0) AS dead_total,
+ COALESCE(sum(injured), 0) AS injured_total
+ FROM dtp_incidents
+ WHERE dtp_at >= NOW() - make_interval(years => CAST(:years AS integer))
+ 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)
+ )
+ """
+)
+
+
+def compute_dtp_stats(
+ db: Any,
+ lat: float,
+ lon: float,
+ *,
+ radius_m: int = DEFAULT_DTP_RADIUS_M,
+ years: int = DEFAULT_YEARS_LOOKBACK,
+) -> DtpAreaStats:
+ """Посчитать агрегаты ДТП в радиусе `radius_m` вокруг точки за последние `years` лет.
+
+ Graceful fallback: `dtp_incidents` пуста (рефреш ещё не запускался на этом
+ окружении, source dormant по умолчанию — миграция 295) -> status="unavailable",
+ нули НЕ фабрикуются. Ноль инцидентов при непустой таблице — легитимный "ok"
+ результат (в радиусе правда ничего не было), отличается статусом от "нет данных".
+ """
+ total = db.execute(text("SELECT count(*) FROM dtp_incidents")).scalar() or 0
+ if total == 0:
+ logger.warning(
+ "dtp_index: dtp_incidents пуста (рефреш ещё не запускался на этом "
+ "окружении) — unavailable, без сфабрикованных нулей"
+ )
+ return DtpAreaStats(
+ status="unavailable",
+ radius_m=radius_m,
+ years=years,
+ incidents_count=0,
+ severe_count=0,
+ dead=0,
+ injured=0,
+ )
+
+ bbox_south, bbox_north, bbox_west, bbox_east = _bbox_from_point(lat, lon, radius_m)
+ row = (
+ db.execute(
+ _DTP_STATS_SQL,
+ {
+ "lat": lat,
+ "lon": lon,
+ "radius_m": radius_m,
+ "years": years,
+ "bbox_south": bbox_south,
+ "bbox_north": bbox_north,
+ "bbox_west": bbox_west,
+ "bbox_east": bbox_east,
+ },
+ )
+ .mappings()
+ .first()
+ )
+ if row is None:
+ return DtpAreaStats(
+ status="ok",
+ radius_m=radius_m,
+ years=years,
+ incidents_count=0,
+ severe_count=0,
+ dead=0,
+ injured=0,
+ )
+
+ return DtpAreaStats(
+ status="ok",
+ radius_m=radius_m,
+ years=years,
+ incidents_count=int(row["incidents_count"] or 0),
+ severe_count=int(row["severe_count"] or 0),
+ dead=int(row["dead_total"] or 0),
+ injured=int(row["injured_total"] or 0),
+ )
diff --git a/tradein-mvp/backend/app/services/dtp_stat_loader.py b/tradein-mvp/backend/app/services/dtp_stat_loader.py
new file mode 100644
index 00000000..a9936373
--- /dev/null
+++ b/tradein-mvp/backend/app/services/dtp_stat_loader.py
@@ -0,0 +1,281 @@
+"""dtp-stat.ru loader: слой ДТП по Свердловской обл. в `dtp_incidents` (#3410).
+
+CONTEXT: карточка района МЕРЫ хочет "фактор безопасности" — сколько ДТП/тяжёлых/
+погибших/раненых было рядом за последние годы. dtp-stat.ru агрегирует открытые данные
+ГИБДД в GeoJSON по регионам, без auth.
+
+ИСТОЧНИК: https://dtp-stat.ru/media/opendata/sverdlovskaia-oblast.geojson.zip — ZIP
+(~5 МБ) с одним файлом `sverdlovskaia-oblast.geojson` (~71 МБ распакованного,
+FeatureCollection, ~31k Point-фич). Проверено живьём 08.09.2026: HTTP 200,
+5 047 685 байт. Домен обычный (не RU-gov минцифровский сертификат) — verify=False
+НЕ ставим, лишний.
+
+МОНИТОРИНГ ПРОТУХАНИЯ (не реализован в этом PR — зафиксировано, чтобы следующий не
+считал пустую дельту багом): на момент проверки 08.09.2026 дамп источника заморожен,
+Last-Modified 26.02.2026. TRUNCATE+INSERT переливает те же ~31k строк каждый рефреш —
+это ожидаемо, а не признак сломанного парсинга. Если нужен freshness-монитор — см.
+паттерн `recon_macro.md` (СберИндекс), здесь не сделан осознанно (вне скоупа #3410).
+
+СТРИМИНГ (обязательно, файл большой): `json.load()` целиком держал бы ~71 МБ + объектный
+граф в памяти разом. Используем `ijson.items(stream, "features.item")` поверх
+файлового объекта из `zipfile.ZipFile.open(name)` — постоянная память вне зависимости
+от размера файла, каждая feature обрабатывается и отбрасывается по одной.
+
+ПДн — НЕ СОХРАНЯЕМ (осознанное решение, зафиксировано и в 294_dtp_incidents.sql):
+`properties.vehicles[].participants[]` источника несёт пол/роль/нарушения физлиц —
+это персональные данные конкретных людей, продукту не нужны и не разрешены. Парсер
+(`_parse_feature`) НИКОГДА не читает ключи `vehicles`/`participants` — ни в raw-jsonb,
+ни отдельной колонкой. Из `properties` берём только неперсональные агрегаты и атрибуты
+происшествия (see DtpIncidentRow).
+
+Идемпотентность: TRUNCATE + bulk INSERT в одной транзакции (тот же паттерн, что
+`app/tasks/osm_poi_ekb_refresh.py` — полная замена, не upsert; повторный прогон с тем
+же дампом даёт тот же результат).
+
+psycopg v3: SQL через `text(...)` использует `CAST(:x AS type)`, НИКОГДА `:x::type`.
+Массивы (`weather`, `nearby`, тип `text[]`) — psycopg v3 адаптирует Python `list[str]`
+в `text[]` напрямую через `CAST(:x AS text[])` (тот же идиом, что
+`app/tasks/deactivate_stale_avito.py`).
+"""
+
+from __future__ import annotations
+
+import io
+import logging
+import zipfile
+from collections.abc import Iterator
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+from typing import IO, Any
+
+import httpx
+import ijson
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+logger = logging.getLogger(__name__)
+
+SRC_URL = "https://dtp-stat.ru/media/opendata/sverdlovskaia-oblast.geojson.zip"
+DOWNLOAD_TIMEOUT_SEC = 180
+INSERT_CHUNK_SIZE = 2000
+
+# properties.datetime — "2015-01-01 03:00:00", без явной таймзоны в источнике.
+# Храним как naive → колонка timestamptz получит его в timezone сессии БД (обычно UTC).
+# Источник не публикует TZ, поэтому точная привязка к МСК не гарантирована — для
+# радиусных агрегатов по годам (dtp_index.py) это несущественно.
+_DT_FMT = "%Y-%m-%d %H:%M:%S"
+
+
+@dataclass(slots=True)
+class DtpIncidentRow:
+ """Одна строка ДТП — ТОЛЬКО неперсональные атрибуты (см. докстринг модуля)."""
+
+ source_id: str
+ dtp_at: datetime | None
+ category: str | None
+ severity: str | None
+ dead: int | None
+ injured: int | None
+ address: str | None
+ light: str | None
+ weather: list[str] | None
+ nearby: list[str] | None
+ lat: float
+ lon: float
+
+
+def _parse_datetime(raw: Any) -> datetime | None:
+ if not isinstance(raw, str) or not raw.strip():
+ return None
+ try:
+ return datetime.strptime(raw.strip(), _DT_FMT)
+ except ValueError:
+ logger.warning("dtp_stat_loader: не распарсен datetime %r", raw)
+ return None
+
+
+def _parse_str_list(raw: Any) -> list[str] | None:
+ if not isinstance(raw, list) or not raw:
+ return None
+ return [str(item) for item in raw if item is not None]
+
+
+def _parse_int(raw: Any) -> int | None:
+ if isinstance(raw, bool):
+ return None
+ if isinstance(raw, int):
+ return raw
+ return None
+
+
+# Санити-рамка координат. Выгрузка dtp-stat по Свердловской области содержит битые
+# точки: на реальном дампе 2026-02-26 из 31 253 записей 74 лежат вне широт региона,
+# в том числе 10 ровно в (0.0, 0.0) и такие значения, как (1.0, 1.0) и (41.0, 47.0).
+# Пускать их в geo-таблицу нельзя: слой кормит радиусные агрегаты локации.
+# Рамка намеренно шире официальных границ области (~56.0-62.3 N, ~57.2-66.2 E),
+# чтобы не срезать легитимные приграничные точки.
+REGION_LAT_RANGE = (55.0, 63.5)
+REGION_LON_RANGE = (56.0, 67.5)
+
+
+def _coords_plausible(lat: float, lon: float) -> bool:
+ """Точка похожа на реальное ДТП в регионе выгрузки, а не на мусор источника."""
+ if lat == 0.0 and lon == 0.0:
+ return False
+ if not (REGION_LAT_RANGE[0] <= lat <= REGION_LAT_RANGE[1]):
+ return False
+ return REGION_LON_RANGE[0] <= lon <= REGION_LON_RANGE[1]
+
+
+def _parse_feature(feature: dict[str, Any]) -> DtpIncidentRow | None:
+ """Чистая функция: одна GeoJSON Feature -> DtpIncidentRow, либо None (пропуск).
+
+ НЕ читает `properties.vehicles` / `properties.participants` — намеренно (ПДн,
+ см. докстринг модуля). Пропускает фичи без валидной Point-геометрии/id, а также
+ с координатами вне санити-рамки региона (см. `_coords_plausible`).
+ """
+ geometry = feature.get("geometry") or {}
+ if geometry.get("type") != "Point":
+ return None
+ coords = geometry.get("coordinates")
+ if not isinstance(coords, list) or len(coords) < 2:
+ return None
+ try:
+ lon, lat = float(coords[0]), float(coords[1])
+ except (TypeError, ValueError):
+ return None
+ if not _coords_plausible(lat, lon):
+ return None
+
+ props = feature.get("properties") or {}
+ source_id = props.get("id")
+ if source_id is None:
+ return None
+
+ return DtpIncidentRow(
+ source_id=str(source_id),
+ dtp_at=_parse_datetime(props.get("datetime")),
+ category=props.get("category") if isinstance(props.get("category"), str) else None,
+ severity=props.get("severity") if isinstance(props.get("severity"), str) else None,
+ dead=_parse_int(props.get("dead_count")),
+ injured=_parse_int(props.get("injured_count")),
+ address=props.get("address") if isinstance(props.get("address"), str) else None,
+ light=props.get("light") if isinstance(props.get("light"), str) else None,
+ weather=_parse_str_list(props.get("weather")),
+ nearby=_parse_str_list(props.get("nearby")),
+ lat=lat,
+ lon=lon,
+ )
+
+
+def iter_incidents(stream: IO[bytes]) -> Iterator[DtpIncidentRow]:
+ """Потоковый парс GeoJSON FeatureCollection -> DtpIncidentRow, по одной фиче за раз.
+
+ `stream` — бинарный файловый объект (из `zipfile.ZipFile.open()` или обычного
+ `open(path, "rb")`). Использует `ijson.items(..., "features.item")` — постоянная
+ память, НЕ читает файл целиком.
+ """
+ for feature in ijson.items(stream, "features.item"):
+ row = _parse_feature(feature)
+ if row is not None:
+ yield row
+
+
+def _open_geojson_in_zip(zf: zipfile.ZipFile) -> IO[bytes]:
+ names = [n for n in zf.namelist() if n.lower().endswith(".geojson")]
+ if not names:
+ raise ValueError(f"zip не содержит .geojson записей: {zf.namelist()!r}")
+ return zf.open(names[0])
+
+
+def download_dtp_zip(*, client: httpx.Client) -> bytes:
+ """Скачать ZIP dtp-stat.ru. Открытые данные, без auth/PII — стандартный verify."""
+ resp = client.get(SRC_URL, timeout=DOWNLOAD_TIMEOUT_SEC)
+ resp.raise_for_status()
+ return resp.content
+
+
+_TRUNCATE_SQL = text("TRUNCATE dtp_incidents")
+
+_INSERT_SQL = text(
+ """
+ INSERT INTO dtp_incidents
+ (source_id, dtp_at, category, severity, dead, injured, address, light,
+ weather, nearby, lat, lon, geom)
+ VALUES
+ (CAST(:source_id AS text), CAST(:dtp_at AS timestamptz),
+ CAST(:category AS text), CAST(:severity AS text),
+ CAST(:dead AS integer), CAST(:injured AS integer),
+ CAST(:address AS text), CAST(:light AS text),
+ CAST(:weather AS text[]), CAST(:nearby AS text[]),
+ CAST(:lat AS double precision), CAST(:lon AS double precision),
+ ST_SetSRID(ST_MakePoint(CAST(:lon AS double precision), CAST(:lat AS double precision)),
+ 4326))
+ ON CONFLICT (source_id) DO NOTHING
+ """
+)
+
+
+def _row_params(row: DtpIncidentRow) -> dict[str, Any]:
+ return {
+ "source_id": row.source_id,
+ "dtp_at": row.dtp_at,
+ "category": row.category,
+ "severity": row.severity,
+ "dead": row.dead,
+ "injured": row.injured,
+ "address": row.address,
+ "light": row.light,
+ "weather": row.weather,
+ "nearby": row.nearby,
+ "lat": row.lat,
+ "lon": row.lon,
+ }
+
+
+def load_dtp_incidents(
+ db: Session,
+ *,
+ src_path: str | Path | None = None,
+ client: httpx.Client | None = None,
+ chunk_size: int = INSERT_CHUNK_SIZE,
+ dry_run: bool = False,
+) -> dict[str, int]:
+ """TRUNCATE dtp_incidents; потоковый парс + bulk INSERT из ZIP dtp-stat.ru.
+
+ `src_path` — локальный ZIP (тесты/ручной прогон); если None — качаем `SRC_URL`
+ через `client` (обязателен параметром, чтобы был чем подменить в тестах;
+ создаётся временный `httpx.Client()` если не передан).
+
+ `dry_run=True` — парсит и считает строки, НЕ трогает БД (ни TRUNCATE, ни INSERT).
+
+ Не коммитит — коммитит caller (app/tasks/dtp_stat_refresh.py).
+ """
+ owns_client = client is None
+ client = client or httpx.Client()
+ try:
+ if src_path is not None:
+ with zipfile.ZipFile(src_path) as zf, _open_geojson_in_zip(zf) as stream:
+ rows = list(iter_incidents(stream))
+ else:
+ data = download_dtp_zip(client=client)
+ with zipfile.ZipFile(io.BytesIO(data)) as zf, _open_geojson_in_zip(zf) as stream:
+ rows = list(iter_incidents(stream))
+ finally:
+ if owns_client:
+ client.close()
+
+ parsed = len(rows)
+ inserted = 0
+
+ if not dry_run:
+ db.execute(_TRUNCATE_SQL)
+ for i in range(0, len(rows), chunk_size):
+ chunk = rows[i : i + chunk_size]
+ result = db.execute(_INSERT_SQL, [_row_params(r) for r in chunk])
+ inserted += result.rowcount or 0
+
+ result_counts = {"parsed": parsed, "inserted": inserted}
+ logger.info("dtp_stat_loader load DONE (dry_run=%s): %s", dry_run, result_counts)
+ return result_counts
diff --git a/tradein-mvp/backend/app/services/frt_mkd_loader.py b/tradein-mvp/backend/app/services/frt_mkd_loader.py
new file mode 100644
index 00000000..ebed940f
--- /dev/null
+++ b/tradein-mvp/backend/app/services/frt_mkd_loader.py
@@ -0,0 +1,688 @@
+"""АИС ППК «ФРТ» (бывш. Реформа ЖКХ) loader: houses.area_land/foundation_type/
+elevators_total + добор year_built/material_walls/total_floors/entrances/is_emergency/
+flat_count/heat_supply_type/gas_supply_type/hot_water (issue #frt-mkd).
+
+CONTEXT: houses.area_land и houses.foundation_type отсутствовали вовсе; elevators_total —
+новая колонка (в источнике — ОБЩЕЕ число лифтов, houses раздельно хранит только
+passenger_elevators/cargo_elevators, поэтому мапить в них нельзя — потеряли бы тип).
+Остальные поля (year_built, material_walls, ...) уже существуют и заполнены частично
+другими источниками (ДОМ.РФ капремонт, ГИС-ЖКХ) — этот loader их ДОБИРАЕТ через
+COALESCE (только NULL), никогда не перезаписывает.
+
+ИСТОЧНИК: АИС ППК ФРТ open data, https://xn--80adsazqn.xn--p1aee.xn--p1ai/opendata/export/{node_id}
+ node 110 = реестр МКД региона 66 (Свердловская обл.) — проверено живьём 08.09.2026:
+ HTTP 200, application/octet-stream, zip → один CSV `export-reestrmkd-66-*.csv`,
+ UTF-8 BOM, разделитель ';', 60 колонок, ~41.8k строк по СО.
+
+robots.txt источника запрещает /opendata/export/ и требует Crawl-delay 10. Один прогон
+этого loader'а делает РОВНО ОДИН GET (один node_id) — задержка не нужна. Если когда-нибудь
+понадобится тянуть несколько node_id за один запуск (напр. node 427 — аварийный фонд РФ,
+node 1 — реестр УО РФ, оба вне скоупа этого PR) — между запросами обязательна пауза
+CRAWL_DELAY_SEC (константа ниже), иначе нарушаем Crawl-delay из robots.txt.
+
+ЧТО НЕ ДЕЛАЕМ (осознанно):
+ * project_type НЕ мапим в houses.series_name — свободный текст, фактический дубль
+ материала стен (замер: пусто 10635, «кирпичный» 1754, «нет данных» 1496,
+ «панельный» 1012, «Блочный»/«блочный» двумя разными строками). Как серию
+ использовать нельзя.
+ * energy_efficiency НЕ добавляем в houses — миграция 284 уже приняла это решение;
+ реальный класс присвоен лишь ~10% домов (у большинства «Не присвоен»).
+ * elevators_count НЕ мапим в houses.passenger_elevators — источник отдаёт общее
+ число лифтов без разбивки, а houses хранит passenger/cargo раздельно. Пишем в
+ отдельную houses.elevators_total.
+ * playground/sportsground — id справочника (498/499/500), не булев — в staging как
+ есть, houses.has_playground не трогаем.
+ * estimator.py не трогаем — встраивание признаков дома в подбор аналогов вне скоупа.
+
+МУСОРНЫЕ ЗНАЧЕНИЯ источника (встречаются как обычные строки текстовых полей):
+ '', 'нет данных', 'Не заполнено', 'отсутствует', 'данные отсутствуют', 'нет'.
+ Единый хелпер `clean_text()` отфильтровывает их при парсе КАЖДОГО текстового поля.
+
+TLS: домен xn--80adsazqn.xn--p1aee.xn--p1ai отдаёт RU-сертификат НУЦ Минцифры, не
+верифицируемый дефолтным trust store'ом httpx — та же ситуация, что sber_index.py и
+domrf_kapremont_loader.py. Открытые данные без auth/PII — verify=False приемлем.
+
+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, fields
+from datetime import date
+from pathlib import Path
+
+import httpx
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+logger = logging.getLogger(__name__)
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Константы
+# ─────────────────────────────────────────────────────────────────────────────
+BASE_URL = "https://xn--80adsazqn.xn--p1aee.xn--p1ai"
+DEFAULT_NODE_ID = 110 # реестр МКД региона 66 — проверено живьём 08.09.2026
+DEFAULT_REGION_CODE = 66
+
+DOWNLOAD_TIMEOUT_SEC = 180
+UPSERT_CHUNK_SIZE = 500
+
+# robots.txt источника: Crawl-delay 10. Используется только если когда-нибудь понадобится
+# тянуть несколько node_id за один запуск (сейчас — ровно один GET на прогон).
+CRAWL_DELAY_SEC = 10
+
+# Мусорные значения текстовых полей источника — не данные, а «пусто» в других обёртках.
+_NOISE_VALUES = frozenset(
+ {"", "нет данных", "не заполнено", "отсутствует", "данные отсутствуют", "нет"}
+)
+
+# Правдоподобные границы, используются и на парсе (staging), и повторно в backfill SQL.
+YEAR_BUILT_MIN = 1850
+YEAR_BUILT_FUTURE_SLACK = 2
+FLOOR_COUNT_MAX_BOUND = 100
+ENTRANCE_COUNT_MAX_BOUND = 50
+ELEVATORS_COUNT_MAX_BOUND = 50
+FLAT_COUNT_MAX_BOUND = 3000
+AREA_LAND_MAX_BOUND = 500_000 # м2, с большим запасом для дворовых территорий МКД
+
+
+def plausible_year_max() -> int:
+ """Верхняя граница правдоподобного года постройки (текущий год + slack)."""
+ return date.today().year + YEAR_BUILT_FUTURE_SLACK
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Чистые хелперы парсинга (юнит-тестируются без сети/БД)
+# ─────────────────────────────────────────────────────────────────────────────
+def clean_text(raw: str | None) -> str | None:
+ """Строка источника → строка|None, мусорные значения-заглушки схлопываются в None."""
+ if raw is None:
+ return None
+ s = raw.strip()
+ if not s or s.lower() in _NOISE_VALUES:
+ return None
+ return s
+
+
+def parse_decimal_comma(raw: str | None) -> float | None:
+ """«930,60» / «930.60» / «» / мусор → float|None. Запятая — decimal separator АИС ФРТ."""
+ s = clean_text(raw)
+ if s is None:
+ 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 с опциональным sanity-гейтом [min_value, max_value]."""
+ s = clean_text(raw)
+ if s is None:
+ 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
+
+
+def parse_bool_da_net(raw: str | None) -> bool | None:
+ """«Да»/«Нет» (регистронезависимо) → bool|None. Пусто/иное → None.
+
+ НЕ идёт через clean_text() — «нет» само по себе входит в _NOISE_VALUES (заглушка
+ текстовых полей типа «отсутствует»/«нет»), и там это совпадение уместно. Но для
+ is_alarm «Нет» — валидный содержательный ответ («не аварийный»), а не отсутствие
+ данных, поэтому здесь разбираем сырую строку напрямую.
+ """
+ if raw is None:
+ return None
+ s = raw.strip()
+ if not s:
+ return None
+ low = s.lower()
+ if low == "да":
+ return True
+ if low == "нет":
+ return False
+ return None
+
+
+@dataclass(slots=True)
+class FrtMkdRow:
+ """Одна строка реестра МКД АИС ФРТ (house-per-row), готова к UPSERT в staging."""
+
+ houseguid: str
+ region_code: int
+ address: str | None
+ built_year: int | None
+ exploitation_start_year: int | None
+ project_type: str | None
+ house_type: str | None
+ is_alarm: bool | None
+ floor_count_max: int | None
+ floor_count_min: int | None
+ entrance_count: int | None
+ elevators_count: int | None
+ energy_efficiency: str | None
+ quarters_count: int | None
+ living_quarters_count: int | None
+ unliving_quarters_count: int | None
+ area_total: float | None
+ area_residential: float | None
+ area_non_residential: float | None
+ area_common_property: float | None
+ area_land: float | None
+ parking_square: float | None
+ playground: int | None
+ sportsground: int | None
+ other_beautification: str | None
+ foundation_type: str | None
+ floor_type: str | None
+ wall_material: str | None
+ basement_area: float | None
+ chute_type: str | None
+ chute_count: int | None
+ heating_type: str | None
+ hot_water_type: str | None
+ cold_water_type: str | None
+ sewerage_type: str | None
+ gas_type: str | None
+ ventilation_type: str | None
+ firefighting_type: str | None
+ drainage_type: str | None
+ management_organization_id: int | None
+ method_of_forming_overhaul_fund: str | None
+
+
+def parse_frt_mkd_csv(path: str | Path, *, region_code: int) -> dict[str, FrtMkdRow]:
+ """CSV реестра МКД АИС ФРТ → dict по houseguid. Строки без houseguid пропускаются.
+
+ Дубликаты houseguid в источнике РЕАЛЬНЫ и взаимодополняющи: в выгрузке региона 66
+ за 2026-09-01 из 41 790 строк 912 guid'ов повторяются (968 лишних строк), причём у
+ одной строки пары заполнен, например, `area_total`, а у другой — нет. Поэтому
+ «последняя побеждает» терять нельзя: дубликаты СЛИВАЮТСЯ по полям, побеждает первое
+ непустое значение (`_merge_rows`). Иначе бэкфилл недосчитывался бы заполненных полей
+ примерно у 2 % домов области.
+ """
+ rows: dict[str, FrtMkdRow] = {}
+ with open(path, encoding="utf-8-sig", newline="") as f:
+ reader = csv.DictReader(f, delimiter=";")
+ for raw in reader:
+ houseguid = clean_text(raw.get("houseguid"))
+ if not houseguid:
+ continue
+ parsed = FrtMkdRow(
+ houseguid=houseguid,
+ region_code=region_code,
+ address=clean_text(raw.get("address")),
+ built_year=parse_int_field(
+ raw.get("built_year"), min_value=YEAR_BUILT_MIN, max_value=plausible_year_max()
+ ),
+ exploitation_start_year=parse_int_field(
+ raw.get("exploitation_start_year"),
+ min_value=YEAR_BUILT_MIN,
+ max_value=plausible_year_max(),
+ ),
+ project_type=clean_text(raw.get("project_type")),
+ house_type=clean_text(raw.get("house_type")),
+ is_alarm=parse_bool_da_net(raw.get("is_alarm")),
+ floor_count_max=parse_int_field(
+ raw.get("floor_count_max"), min_value=1, max_value=FLOOR_COUNT_MAX_BOUND
+ ),
+ floor_count_min=parse_int_field(
+ raw.get("floor_count_min"), min_value=1, max_value=FLOOR_COUNT_MAX_BOUND
+ ),
+ entrance_count=parse_int_field(
+ raw.get("entrance_count"), min_value=1, max_value=ENTRANCE_COUNT_MAX_BOUND
+ ),
+ elevators_count=parse_int_field(
+ raw.get("elevators_count"), min_value=0, max_value=ELEVATORS_COUNT_MAX_BOUND
+ ),
+ energy_efficiency=clean_text(raw.get("energy_efficiency")),
+ quarters_count=parse_int_field(raw.get("quarters_count"), min_value=0),
+ living_quarters_count=parse_int_field(
+ raw.get("living_quarters_count"), min_value=0, max_value=FLAT_COUNT_MAX_BOUND
+ ),
+ unliving_quarters_count=parse_int_field(
+ raw.get("unliving_quarters_count"), min_value=0
+ ),
+ area_total=parse_decimal_comma(raw.get("area_total")),
+ area_residential=parse_decimal_comma(raw.get("area_residential")),
+ area_non_residential=parse_decimal_comma(raw.get("area_non_residential")),
+ area_common_property=parse_decimal_comma(raw.get("area_common_property")),
+ area_land=parse_decimal_comma(raw.get("area_land")),
+ parking_square=parse_decimal_comma(raw.get("parking_square")),
+ # playground/sportsground — id справочника благоустройства, не булев флаг.
+ playground=parse_int_field(raw.get("playground"), min_value=0),
+ sportsground=parse_int_field(raw.get("sportsground"), min_value=0),
+ other_beautification=clean_text(raw.get("other_beautification")),
+ foundation_type=clean_text(raw.get("foundation_type")),
+ floor_type=clean_text(raw.get("floor_type")),
+ wall_material=clean_text(raw.get("wall_material")),
+ basement_area=parse_decimal_comma(raw.get("basement_area")),
+ chute_type=clean_text(raw.get("chute_type")),
+ chute_count=parse_int_field(raw.get("chute_count"), min_value=0),
+ heating_type=clean_text(raw.get("heating_type")),
+ hot_water_type=clean_text(raw.get("hot_water_type")),
+ cold_water_type=clean_text(raw.get("cold_water_type")),
+ sewerage_type=clean_text(raw.get("sewerage_type")),
+ gas_type=clean_text(raw.get("gas_type")),
+ ventilation_type=clean_text(raw.get("ventilation_type")),
+ firefighting_type=clean_text(raw.get("firefighting_type")),
+ drainage_type=clean_text(raw.get("drainage_type")),
+ management_organization_id=parse_int_field(
+ raw.get("management_organization_id"), min_value=0
+ ),
+ method_of_forming_overhaul_fund=clean_text(
+ raw.get("method_of_forming_overhaul_fund")
+ ),
+ )
+ prev = rows.get(houseguid)
+ rows[houseguid] = _merge_rows(prev, parsed) if prev is not None else parsed
+ return rows
+
+
+def _merge_rows(prev: FrtMkdRow, new: FrtMkdRow) -> FrtMkdRow:
+ """Слить дубликат houseguid: для каждого поля берём первое непустое значение.
+
+ Источник отдаёт повторы одного дома разными строками с частично заполненными
+ полями (см. `parse_frt_mkd_csv`). Побеждает уже накопленное значение; новое
+ подставляется только туда, где накопленного нет.
+ """
+ merged: dict[str, object] = {}
+ for field in fields(FrtMkdRow):
+ current = getattr(prev, field.name)
+ merged[field.name] = current if current is not None else getattr(new, field.name)
+ return FrtMkdRow(**merged) # type: ignore[arg-type]
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# 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_frt_mkd_csv(dest_dir: Path, *, node_id: int, client: httpx.Client) -> Path:
+ """Скачивает zip export/{node_id}, распаковывает CSV в dest_dir, возвращает путь."""
+ url = f"{BASE_URL}/opendata/export/{node_id}"
+ return _extract_csv_from_zip(_download_zip(url, client=client), dest_dir)
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# UPSERT staging (frt_mkd, мигр. 290)
+# ─────────────────────────────────────────────────────────────────────────────
+_UPSERT_SQL = text(
+ """
+ INSERT INTO frt_mkd (
+ houseguid, region_code, address, built_year, exploitation_start_year,
+ project_type, house_type, is_alarm, floor_count_max, floor_count_min,
+ entrance_count, elevators_count, energy_efficiency, quarters_count,
+ living_quarters_count, unliving_quarters_count, area_total, area_residential,
+ area_non_residential, area_common_property, area_land, parking_square,
+ playground, sportsground, other_beautification, foundation_type, floor_type,
+ wall_material, basement_area, chute_type, chute_count, heating_type,
+ hot_water_type, cold_water_type, sewerage_type, gas_type, ventilation_type,
+ firefighting_type, drainage_type, management_organization_id,
+ method_of_forming_overhaul_fund, loaded_at
+ )
+ VALUES (
+ CAST(:houseguid AS text), CAST(:region_code AS smallint), CAST(:address AS text),
+ CAST(:built_year AS smallint), CAST(:exploitation_start_year AS smallint),
+ CAST(:project_type AS text), CAST(:house_type AS text), CAST(:is_alarm AS boolean),
+ CAST(:floor_count_max AS smallint), CAST(:floor_count_min AS smallint),
+ CAST(:entrance_count AS smallint), CAST(:elevators_count AS smallint),
+ CAST(:energy_efficiency AS text), CAST(:quarters_count AS int),
+ CAST(:living_quarters_count AS int), CAST(:unliving_quarters_count AS int),
+ CAST(:area_total AS numeric), CAST(:area_residential AS numeric),
+ CAST(:area_non_residential AS numeric), CAST(:area_common_property AS numeric),
+ CAST(:area_land AS numeric), CAST(:parking_square AS numeric),
+ CAST(:playground AS int), CAST(:sportsground AS int),
+ CAST(:other_beautification AS text), CAST(:foundation_type AS text),
+ CAST(:floor_type AS text), CAST(:wall_material AS text),
+ CAST(:basement_area AS numeric), CAST(:chute_type AS text),
+ CAST(:chute_count AS smallint), CAST(:heating_type AS text),
+ CAST(:hot_water_type AS text), CAST(:cold_water_type AS text),
+ CAST(:sewerage_type AS text), CAST(:gas_type AS text),
+ CAST(:ventilation_type AS text), CAST(:firefighting_type AS text),
+ CAST(:drainage_type AS text), CAST(:management_organization_id AS bigint),
+ CAST(:method_of_forming_overhaul_fund AS text), now()
+ )
+ ON CONFLICT (houseguid) DO UPDATE SET
+ region_code = EXCLUDED.region_code,
+ address = EXCLUDED.address,
+ built_year = EXCLUDED.built_year,
+ exploitation_start_year = EXCLUDED.exploitation_start_year,
+ project_type = EXCLUDED.project_type,
+ house_type = EXCLUDED.house_type,
+ is_alarm = EXCLUDED.is_alarm,
+ floor_count_max = EXCLUDED.floor_count_max,
+ floor_count_min = EXCLUDED.floor_count_min,
+ entrance_count = EXCLUDED.entrance_count,
+ elevators_count = EXCLUDED.elevators_count,
+ energy_efficiency = EXCLUDED.energy_efficiency,
+ quarters_count = EXCLUDED.quarters_count,
+ living_quarters_count = EXCLUDED.living_quarters_count,
+ unliving_quarters_count = EXCLUDED.unliving_quarters_count,
+ area_total = EXCLUDED.area_total,
+ area_residential = EXCLUDED.area_residential,
+ area_non_residential = EXCLUDED.area_non_residential,
+ area_common_property = EXCLUDED.area_common_property,
+ area_land = EXCLUDED.area_land,
+ parking_square = EXCLUDED.parking_square,
+ playground = EXCLUDED.playground,
+ sportsground = EXCLUDED.sportsground,
+ other_beautification = EXCLUDED.other_beautification,
+ foundation_type = EXCLUDED.foundation_type,
+ floor_type = EXCLUDED.floor_type,
+ wall_material = EXCLUDED.wall_material,
+ basement_area = EXCLUDED.basement_area,
+ chute_type = EXCLUDED.chute_type,
+ chute_count = EXCLUDED.chute_count,
+ heating_type = EXCLUDED.heating_type,
+ hot_water_type = EXCLUDED.hot_water_type,
+ cold_water_type = EXCLUDED.cold_water_type,
+ sewerage_type = EXCLUDED.sewerage_type,
+ gas_type = EXCLUDED.gas_type,
+ ventilation_type = EXCLUDED.ventilation_type,
+ firefighting_type = EXCLUDED.firefighting_type,
+ drainage_type = EXCLUDED.drainage_type,
+ management_organization_id = EXCLUDED.management_organization_id,
+ method_of_forming_overhaul_fund = EXCLUDED.method_of_forming_overhaul_fund,
+ loaded_at = now()
+ WHERE frt_mkd.address IS DISTINCT FROM EXCLUDED.address
+ OR frt_mkd.built_year IS DISTINCT FROM EXCLUDED.built_year
+ OR frt_mkd.is_alarm IS DISTINCT FROM EXCLUDED.is_alarm
+ OR frt_mkd.wall_material IS DISTINCT FROM EXCLUDED.wall_material
+ OR frt_mkd.foundation_type IS DISTINCT FROM EXCLUDED.foundation_type
+ OR frt_mkd.area_land IS DISTINCT FROM EXCLUDED.area_land
+ OR frt_mkd.elevators_count IS DISTINCT FROM EXCLUDED.elevators_count
+ """
+)
+
+
+def _chunk_rows(items: list[FrtMkdRow], size: int) -> Iterator[list[FrtMkdRow]]:
+ for i in range(0, len(items), size):
+ yield items[i : i + size]
+
+
+def upsert_frt_mkd(
+ db: Session, rows: list[FrtMkdRow], *, chunk_size: int = UPSERT_CHUNK_SIZE
+) -> int:
+ """UPSERT списка FrtMkdRow в frt_mkd, чанками по SAVEPOINT. Не коммитит (caller).
+
+ Идемпотентно (IS DISTINCT FROM gate на ключевых полях в _UPSERT_SQL). Сбойный чанк
+ откатывается изолированно (SAVEPOINT-паттерн domrf_kapremont_loader/zhkh_flats_loader).
+ """
+ upserted = 0
+ for chunk in _chunk_rows(rows, chunk_size):
+ try:
+ with db.begin_nested():
+ for r in chunk:
+ res = db.execute(
+ _UPSERT_SQL,
+ {
+ "houseguid": r.houseguid,
+ "region_code": r.region_code,
+ "address": r.address,
+ "built_year": r.built_year,
+ "exploitation_start_year": r.exploitation_start_year,
+ "project_type": r.project_type,
+ "house_type": r.house_type,
+ "is_alarm": r.is_alarm,
+ "floor_count_max": r.floor_count_max,
+ "floor_count_min": r.floor_count_min,
+ "entrance_count": r.entrance_count,
+ "elevators_count": r.elevators_count,
+ "energy_efficiency": r.energy_efficiency,
+ "quarters_count": r.quarters_count,
+ "living_quarters_count": r.living_quarters_count,
+ "unliving_quarters_count": r.unliving_quarters_count,
+ "area_total": r.area_total,
+ "area_residential": r.area_residential,
+ "area_non_residential": r.area_non_residential,
+ "area_common_property": r.area_common_property,
+ "area_land": r.area_land,
+ "parking_square": r.parking_square,
+ "playground": r.playground,
+ "sportsground": r.sportsground,
+ "other_beautification": r.other_beautification,
+ "foundation_type": r.foundation_type,
+ "floor_type": r.floor_type,
+ "wall_material": r.wall_material,
+ "basement_area": r.basement_area,
+ "chute_type": r.chute_type,
+ "chute_count": r.chute_count,
+ "heating_type": r.heating_type,
+ "hot_water_type": r.hot_water_type,
+ "cold_water_type": r.cold_water_type,
+ "sewerage_type": r.sewerage_type,
+ "gas_type": r.gas_type,
+ "ventilation_type": r.ventilation_type,
+ "firefighting_type": r.firefighting_type,
+ "drainage_type": r.drainage_type,
+ "management_organization_id": r.management_organization_id,
+ "method_of_forming_overhaul_fund": r.method_of_forming_overhaul_fund,
+ },
+ )
+ upserted += res.rowcount
+ except Exception:
+ logger.warning(
+ "frt_mkd upsert: чанк из %d строк сбойнул (откат savepoint)",
+ len(chunk),
+ exc_info=True,
+ )
+ return upserted
+
+
+def load_frt_mkd(
+ db: Session,
+ *,
+ src_path: str | Path | None = None,
+ work_dir: str | Path | None = None,
+ node_id: int = DEFAULT_NODE_ID,
+ region_code: int = DEFAULT_REGION_CODE,
+ chunk_size: int = UPSERT_CHUNK_SIZE,
+ dry_run: bool = False,
+) -> dict[str, int]:
+ """Скачивает (если src_path не задан) реестр МКД node_id, парсит, UPSERT в frt_mkd.
+
+ src_path — локальный CSV (пропустить скачивание; тесты/ops-дебаг). work_dir — куда
+ распаковывать скачанный zip (по умолчанию — временный каталог, удаляется после).
+ dry_run — парс происходит, но НИ ОДНОЙ записи в БД не делается. Не коммитит (caller).
+ """
+ tmp_ctx: tempfile.TemporaryDirectory[str] | None = None
+ if src_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 и
+ # domrf_kapremont_loader.py). Открытые данные без auth/PII — приемлемо.
+ with httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False) as client:
+ src_path = fetch_frt_mkd_csv(dest, node_id=node_id, client=client)
+
+ try:
+ rows_by_guid = parse_frt_mkd_csv(src_path, region_code=region_code)
+ rows = list(rows_by_guid.values())
+ upserted = 0 if dry_run else upsert_frt_mkd(db, rows, chunk_size=chunk_size)
+ finally:
+ if tmp_ctx is not None:
+ tmp_ctx.cleanup()
+
+ result = {"rows": len(rows), "upserted": upserted}
+ logger.info("frt_mkd load DONE (dry_run=%s): %s", dry_run, result)
+ return result
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Backfill houses (COALESCE-семантика — только NULL-поля)
+# ─────────────────────────────────────────────────────────────────────────────
+# Матч: s.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid) —
+# тот же приоритетный COALESCE-джойн, что domrf_kapremont_loader/zhkh_flats_loader.
+# Каждое поле СВОИМ гейтом "h.col IS NULL AND s.col-в-границах" — ни одно поле, уже
+# заполненное другим источником, не перезаписывается. frt_matched_at проставляется один
+# раз (COALESCE(h.frt_matched_at, now())) при ЛЮБОМ найденном матче — метка «дом уже
+# сверялся с этим источником», даже если дозаполнять было уже нечего.
+_BACKFILL_HOUSES_SQL = text(
+ """
+ UPDATE houses h
+ SET year_built = CASE
+ WHEN h.year_built IS NULL
+ AND s.built_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
+ THEN s.built_year
+ ELSE h.year_built
+ END,
+ material_walls = COALESCE(h.material_walls, s.wall_material),
+ material_floors = COALESCE(h.material_floors, s.floor_type),
+ total_floors = CASE
+ WHEN h.total_floors IS NULL
+ AND s.floor_count_max BETWEEN 1 AND CAST(:floor_max AS int)
+ THEN s.floor_count_max
+ ELSE h.total_floors
+ END,
+ entrances = CASE
+ WHEN h.entrances IS NULL
+ AND s.entrance_count BETWEEN 1 AND CAST(:entrance_max AS int)
+ THEN s.entrance_count
+ ELSE h.entrances
+ END,
+ elevators_total = CASE
+ WHEN h.elevators_total IS NULL
+ AND s.elevators_count BETWEEN 0 AND CAST(:elevator_max AS int)
+ THEN s.elevators_count
+ ELSE h.elevators_total
+ END,
+ is_emergency = COALESCE(h.is_emergency, s.is_alarm),
+ flat_count = CASE
+ WHEN h.flat_count IS NULL
+ AND s.living_quarters_count BETWEEN 0 AND CAST(:flat_max AS int)
+ THEN s.living_quarters_count
+ ELSE h.flat_count
+ END,
+ heat_supply_type = COALESCE(h.heat_supply_type, s.heating_type),
+ gas_supply_type = COALESCE(h.gas_supply_type, s.gas_type),
+ hot_water = COALESCE(h.hot_water, s.hot_water_type),
+ area_land = CASE
+ WHEN h.area_land IS NULL
+ AND s.area_land BETWEEN 0 AND CAST(:area_land_max AS numeric)
+ THEN s.area_land
+ ELSE h.area_land
+ END,
+ foundation_type = COALESCE(h.foundation_type, s.foundation_type),
+ frt_matched_at = COALESCE(h.frt_matched_at, now())
+ FROM frt_mkd s
+ WHERE s.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)
+ AND (
+ (h.year_built IS NULL
+ AND s.built_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int))
+ OR (h.material_walls IS NULL AND s.wall_material IS NOT NULL)
+ OR (h.material_floors IS NULL AND s.floor_type IS NOT NULL)
+ OR (h.total_floors IS NULL
+ AND s.floor_count_max BETWEEN 1 AND CAST(:floor_max AS int))
+ OR (h.entrances IS NULL
+ AND s.entrance_count BETWEEN 1 AND CAST(:entrance_max AS int))
+ OR (h.elevators_total IS NULL
+ AND s.elevators_count BETWEEN 0 AND CAST(:elevator_max AS int))
+ OR (h.is_emergency IS NULL AND s.is_alarm IS NOT NULL)
+ OR (h.flat_count IS NULL
+ AND s.living_quarters_count BETWEEN 0 AND CAST(:flat_max AS int))
+ OR (h.heat_supply_type IS NULL AND s.heating_type IS NOT NULL)
+ OR (h.gas_supply_type IS NULL AND s.gas_type IS NOT NULL)
+ OR (h.hot_water IS NULL AND s.hot_water_type IS NOT NULL)
+ OR (h.area_land IS NULL
+ AND s.area_land BETWEEN 0 AND CAST(:area_land_max AS numeric))
+ OR (h.foundation_type IS NULL AND s.foundation_type IS NOT NULL)
+ OR h.frt_matched_at IS NULL
+ )
+ """
+)
+
+_BACKFILL_HOUSES_COUNT_SQL = text(
+ """
+ SELECT count(*)
+ FROM houses h
+ JOIN frt_mkd s
+ ON s.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)
+ WHERE (
+ (h.year_built IS NULL
+ AND s.built_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int))
+ OR (h.material_walls IS NULL AND s.wall_material IS NOT NULL)
+ OR (h.material_floors IS NULL AND s.floor_type IS NOT NULL)
+ OR (h.total_floors IS NULL
+ AND s.floor_count_max BETWEEN 1 AND CAST(:floor_max AS int))
+ OR (h.entrances IS NULL
+ AND s.entrance_count BETWEEN 1 AND CAST(:entrance_max AS int))
+ OR (h.elevators_total IS NULL
+ AND s.elevators_count BETWEEN 0 AND CAST(:elevator_max AS int))
+ OR (h.is_emergency IS NULL AND s.is_alarm IS NOT NULL)
+ OR (h.flat_count IS NULL
+ AND s.living_quarters_count BETWEEN 0 AND CAST(:flat_max AS int))
+ OR (h.heat_supply_type IS NULL AND s.heating_type IS NOT NULL)
+ OR (h.gas_supply_type IS NULL AND s.gas_type IS NOT NULL)
+ OR (h.hot_water IS NULL AND s.hot_water_type IS NOT NULL)
+ OR (h.area_land IS NULL
+ AND s.area_land BETWEEN 0 AND CAST(:area_land_max AS numeric))
+ OR (h.foundation_type IS NULL AND s.foundation_type IS NOT NULL)
+ OR h.frt_matched_at IS NULL
+ )
+ """
+)
+
+
+def backfill_houses(db: Session, *, dry_run: bool = False) -> dict[str, int]:
+ """COALESCE-добор houses.* из frt_mkd (только NULL-поля). Не коммитит (caller).
+
+ dry_run — ноль записей, только SELECT count(*) по тому же предикату.
+ """
+ bounds = {
+ "ymin": YEAR_BUILT_MIN,
+ "ymax": plausible_year_max(),
+ "floor_max": FLOOR_COUNT_MAX_BOUND,
+ "entrance_max": ENTRANCE_COUNT_MAX_BOUND,
+ "elevator_max": ELEVATORS_COUNT_MAX_BOUND,
+ "flat_max": FLAT_COUNT_MAX_BOUND,
+ "area_land_max": AREA_LAND_MAX_BOUND,
+ }
+ if dry_run:
+ would_update = db.execute(_BACKFILL_HOUSES_COUNT_SQL, bounds).scalar_one()
+ result = {"would_update": would_update, "houses_updated": 0}
+ logger.info("backfill_houses (frt_mkd) DRY-RUN: %s", result)
+ return result
+
+ updated = db.execute(_BACKFILL_HOUSES_SQL, bounds).rowcount
+ result = {"houses_updated": updated}
+ logger.info("backfill_houses (frt_mkd) DONE: %s", result)
+ return result
diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py
index 1bb58327..d2b3c9e3 100644
--- a/tradein-mvp/backend/app/services/product_handlers.py
+++ b/tradein-mvp/backend/app/services/product_handlers.py
@@ -544,6 +544,19 @@ async def _job_osm_poi_ekb_refresh(
)
+# ── dtp_stat_refresh — sync ZIP-скачивание+парс ДТП в executor ────────────────
+async def _job_dtp_stat_refresh(
+ db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
+) -> None:
+ from app.tasks.dtp_stat_refresh import run_dtp_stat_refresh
+
+ loop = asyncio.get_event_loop()
+ await loop.run_in_executor(
+ None,
+ lambda: run_dtp_stat_refresh(db, run_id=run_id, params=params),
+ )
+
+
# ── house_imv_backfill — async Avito-IMV с heartbeat, lifecycle в job ─────────
async def _job_house_imv_backfill(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
@@ -708,6 +721,86 @@ async def _job_fns_opendata_load(
ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
+# ── frt_mkd_load — АИС ППК ФРТ, реестр МКД region 66 (issue #frt-mkd) ────────
+async def _job_frt_mkd_load(
+ db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
+) -> None:
+ """Скачать реестр МКД АИС ФРТ (node_id) → staging frt_mkd → backfill houses.
+
+ Тело переиспользует те же функции, что и CLI (app/tasks/frt_mkd_load.py) — не
+ дублируем логику. node_id/region_code берутся из scrape_schedules.default_params
+ (мигр. 291), с фоллбеком на дефолты loader'а.
+ """
+ from app.services.frt_mkd_loader import (
+ DEFAULT_NODE_ID,
+ DEFAULT_REGION_CODE,
+ backfill_houses,
+ load_frt_mkd,
+ )
+
+ node_id = int(params.get("node_id", DEFAULT_NODE_ID))
+ region_code = int(params.get("region_code", DEFAULT_REGION_CODE))
+
+ def _run() -> dict[str, int]:
+ load_counts = load_frt_mkd(db, node_id=node_id, region_code=region_code)
+ db.commit()
+ houses_counts = backfill_houses(db)
+ db.commit()
+ return {
+ "rows": load_counts["rows"],
+ "upserted": load_counts["upserted"],
+ "houses_updated": houses_counts["houses_updated"],
+ "total_seen": load_counts["rows"],
+ "new_count": houses_counts["houses_updated"],
+ }
+
+ loop = asyncio.get_event_loop()
+ try:
+ counters = await loop.run_in_executor(None, _run)
+ ctx.runs.mark_done(db, run_id, counters)
+ except Exception as exc:
+ logger.exception("scheduler: frt_mkd_load crashed run_id=%d", run_id)
+ db.rollback()
+ ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
+
+
+# ── cbr_macro_pull — макро-ряды ЦБ РФ (ипотека по субъектам + ключевая ставка) ─
+async def _job_cbr_macro_pull(
+ db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
+) -> None:
+ """Ипотечные ряды (XLSX) + ключевая ставка (SOAP) → cbr_mortgage_series/cbr_key_rate.
+
+ Тело переиспользует те же функции, что и CLI (app/tasks/cbr_macro_pull.py) —
+ дизайн-инвариант модуля: не дублируем логику. Lifecycle не свой —
+ mark_done/mark_failed здесь, как у _job_domrf_kapremont_load.
+ """
+ from app.services.cbr_macro import load_key_rate, pull_cbr_mortgage
+
+ def _run() -> dict[str, int]:
+ mortgage_counts = pull_cbr_mortgage(db)
+ db.commit()
+ key_rate_counts = load_key_rate(db)
+ db.commit()
+ return {
+ "mortgage_upserted": mortgage_counts["upserted"],
+ "mortgage_skipped": mortgage_counts["skipped"],
+ "mortgage_errors": mortgage_counts["errors"],
+ "key_rate_upserted": key_rate_counts["upserted"],
+ # выделенные колонки прогона + гейт «три подряд нулевых прогона» (#2625).
+ "total_seen": mortgage_counts["upserted"] + key_rate_counts["rows"],
+ "new_count": mortgage_counts["upserted"] + key_rate_counts["upserted"],
+ }
+
+ loop = asyncio.get_event_loop()
+ try:
+ counters = await loop.run_in_executor(None, _run)
+ ctx.runs.mark_done(db, run_id, counters)
+ except Exception as exc:
+ logger.exception("scheduler: cbr_macro_pull crashed run_id=%d", run_id)
+ db.rollback()
+ ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
+
+
# ── purge_expired_trade_in_data — ЭТАП 4 B2C retention (152-ФЗ) ───────────────
async def _job_purge_expired_trade_in_data(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
@@ -877,9 +970,12 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
"domclick_city_sweep": Handler(_job_domclick_city_sweep, "domclick_city_sweep"),
"cadastral_geo_match": Handler(_job_cadastral_geo_match, "cadastral_geo_match"),
"osm_poi_ekb_refresh": Handler(_job_osm_poi_ekb_refresh, "osm_poi_ekb_refresh"),
+ "dtp_stat_refresh": Handler(_job_dtp_stat_refresh, "dtp_stat_refresh"),
"house_imv_backfill": Handler(_job_house_imv_backfill, "house_imv_backfill"),
"house_dedup_merge": Handler(_job_house_dedup_merge, "house_dedup_merge"),
"domrf_kapremont_load": Handler(_job_domrf_kapremont_load, "domrf_kapremont_load"),
+ "frt_mkd_load": Handler(_job_frt_mkd_load, "frt_mkd_load"),
+ "cbr_macro_pull": Handler(_job_cbr_macro_pull, "cbr_macro_pull"),
"fns_opendata_load": Handler(_job_fns_opendata_load, "fns_opendata_load"),
"purge_expired_trade_in_data": Handler(
_job_purge_expired_trade_in_data, "purge_expired_trade_in_data"
diff --git a/tradein-mvp/backend/app/tasks/cbr_macro_pull.py b/tradein-mvp/backend/app/tasks/cbr_macro_pull.py
new file mode 100644
index 00000000..08710246
--- /dev/null
+++ b/tradein-mvp/backend/app/tasks/cbr_macro_pull.py
@@ -0,0 +1,95 @@
+"""CLI: макро-ряды ЦБ РФ — ипотека по субъектам + ключевая ставка (#issue см. vault).
+
+Запуск из контейнера:
+ python -m app.tasks.cbr_macro_pull # всё: ипотека + ставка
+ python -m app.tasks.cbr_macro_pull --dry-run
+ python -m app.tasks.cbr_macro_pull --series rate_rub debt_rub
+ python -m app.tasks.cbr_macro_pull --skip-mortgage --from-date 2024-01-01 --to-date 2024-12-31
+ python -m app.tasks.cbr_macro_pull --skip-key-rate
+
+Делегирует всю логику app.services.cbr_macro (download → parse → upsert); этот
+модуль — только argparse + lifecycle сессии + commit между стадиями.
+"""
+
+from __future__ import annotations
+
+import argparse
+import logging
+from datetime import date
+
+from app.core.db import SessionLocal
+from app.services.cbr_macro import (
+ CBR_MORTGAGE_SERIES,
+ load_key_rate,
+ pull_cbr_mortgage,
+)
+
+logger = logging.getLogger(__name__)
+
+_SERIES_SLUGS = [s.slug for s in CBR_MORTGAGE_SERIES]
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description="Загрузка макро-рядов ЦБ РФ")
+ parser.add_argument("--dry-run", action="store_true", help="только посчитать, без upsert")
+ parser.add_argument(
+ "--series",
+ nargs="*",
+ choices=_SERIES_SLUGS,
+ default=None,
+ help="подмножество серий ипотеки (по умолчанию — все)",
+ )
+ parser.add_argument("--skip-mortgage", action="store_true", help="не тянуть ипотечные серии")
+ parser.add_argument("--skip-key-rate", action="store_true", help="не тянуть ключевую ставку")
+ parser.add_argument(
+ "--from-date", type=date.fromisoformat, default=None, help="ключевая ставка: ISO-дата от"
+ )
+ parser.add_argument(
+ "--to-date", type=date.fromisoformat, default=None, help="ключевая ставка: ISO-дата до"
+ )
+ 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.skip_mortgage and args.skip_key_rate:
+ parser.error("--skip-mortgage и --skip-key-rate вместе не имеют смысла")
+
+ mortgage_counts: dict[str, int] = {}
+ key_rate_counts: dict[str, int] = {}
+
+ db = SessionLocal()
+ try:
+ if not args.skip_mortgage:
+ series_list = None
+ if args.series:
+ series_list = [s for s in CBR_MORTGAGE_SERIES if s.slug in args.series]
+ mortgage_counts = pull_cbr_mortgage(
+ db, series_list=series_list, dry_run=args.dry_run
+ )
+ if not args.dry_run:
+ db.commit()
+
+ if not args.skip_key_rate:
+ key_rate_counts = load_key_rate(
+ db, from_date=args.from_date, to_date=args.to_date, dry_run=args.dry_run
+ )
+ if not args.dry_run:
+ db.commit()
+
+ logger.info(
+ "cbr_macro_pull DONE: dry_run=%s mortgage=%s key_rate=%s",
+ args.dry_run,
+ mortgage_counts,
+ key_rate_counts,
+ )
+ finally:
+ db.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tradein-mvp/backend/app/tasks/dtp_stat_refresh.py b/tradein-mvp/backend/app/tasks/dtp_stat_refresh.py
new file mode 100644
index 00000000..f67c5821
--- /dev/null
+++ b/tradein-mvp/backend/app/tasks/dtp_stat_refresh.py
@@ -0,0 +1,119 @@
+"""ДТП (dtp-stat.ru) refresh — CLI + scheduler run-lifecycle wrapper (#3410).
+
+Наполняет `dtp_incidents` (пустая при деплое, миграция 294) через
+`app/services/dtp_stat_loader.load_dtp_incidents` (скачивание ZIP + потоковый парс +
+TRUNCATE+INSERT). Scheduler source='dtp_stat_refresh' (seed 293, enabled=false —
+источник заморожен, см. докстринг loader'а; первый прогон ручной).
+
+Запуск из контейнера:
+ python -m app.tasks.dtp_stat_refresh
+ python -m app.tasks.dtp_stat_refresh --dry-run
+ python -m app.tasks.dtp_stat_refresh --src-path /path/to/local.zip
+
+psycopg v3: `CAST(:x AS type)`, никогда `:x::type`.
+"""
+
+from __future__ import annotations
+
+import argparse
+import logging
+import time
+from dataclasses import dataclass, field
+
+from sqlalchemy.orm import Session
+
+from app.services import scrape_runs as runs_mod
+from app.services.dtp_stat_loader import load_dtp_incidents
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class DtpStatRefreshResult:
+ parsed: int = 0
+ inserted: int = 0
+ duration_sec: float = field(default=0.0)
+
+ def to_counters(self) -> dict[str, int]:
+ return {
+ "parsed": self.parsed,
+ "inserted": self.inserted,
+ "duration_sec": int(self.duration_sec),
+ }
+
+
+def run_dtp_stat_refresh(db: Session, *, run_id: int, params: dict) -> DtpStatRefreshResult:
+ """Run-lifecycle wrapper для scheduler'а (source='dtp_stat_refresh') и ручного прогона.
+
+ params:
+ dry_run — bool, по умолчанию False. True: парсит и считает, БД не трогает.
+ src_path — str, локальный путь к ZIP (для отладки/тестов, минуя скачивание).
+
+ Финализирует scrape_runs (mark_done / mark_failed) со счётчиками.
+ """
+ dry_run = bool(params.get("dry_run", False))
+ src_path = params.get("src_path")
+
+ counters: dict[str, int] = {"parsed": 0, "inserted": 0}
+ start = time.monotonic()
+
+ try:
+ runs_mod.update_heartbeat(db, run_id, counters)
+
+ load_counts = load_dtp_incidents(db, src_path=src_path, dry_run=dry_run)
+ if not dry_run:
+ db.commit()
+
+ result = DtpStatRefreshResult(
+ parsed=load_counts["parsed"],
+ inserted=load_counts["inserted"],
+ duration_sec=time.monotonic() - start,
+ )
+ counters = result.to_counters()
+ runs_mod.mark_done(db, run_id, counters)
+ logger.info(
+ "run_dtp_stat_refresh: run_id=%d DONE dry_run=%s parsed=%d inserted=%d duration=%.1fs",
+ run_id,
+ dry_run,
+ result.parsed,
+ result.inserted,
+ result.duration_sec,
+ )
+ return result
+ except Exception as exc:
+ logger.exception("run_dtp_stat_refresh: run_id=%d FAILED", run_id)
+ try:
+ db.rollback()
+ except Exception:
+ pass
+ runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
+ raise
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--src-path", default=None, help="Локальный ZIP вместо скачивания SRC_URL")
+ parser.add_argument("--dry-run", action="store_true", help="Парсить и считать, не писать в БД")
+ return parser
+
+
+def main() -> None:
+ logging.basicConfig(
+ level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
+ )
+ args = build_parser().parse_args()
+
+ from app.core.db import SessionLocal
+
+ db = SessionLocal()
+ try:
+ counts = load_dtp_incidents(db, src_path=args.src_path, dry_run=args.dry_run)
+ if not args.dry_run:
+ db.commit()
+ logger.info("dtp_stat_refresh CLI DONE: dry_run=%s %s", args.dry_run, counts)
+ finally:
+ db.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tradein-mvp/backend/app/tasks/frt_mkd_load.py b/tradein-mvp/backend/app/tasks/frt_mkd_load.py
new file mode 100644
index 00000000..10b15df7
--- /dev/null
+++ b/tradein-mvp/backend/app/tasks/frt_mkd_load.py
@@ -0,0 +1,127 @@
+"""CLI: АИС ППК ФРТ (реестр МКД) → houses.area_land/foundation_type/elevators_total +
+добор year_built/material_walls/total_floors/entrances/is_emergency/flat_count/
+heat_supply_type/gas_supply_type/hot_water (issue #frt-mkd).
+
+Тянет CSV реестра МКД (export/{node_id}, по умолчанию 110 = region 66) с открытых
+данных АИС ППК ФРТ, UPSERT'ит в staging `frt_mkd` (мигр. 290), затем backfill_houses —
+COALESCE-добор перечисленных houses.* полей (только NULL, никогда не перезаписывает).
+
+Запуск из контейнера tradein-scraper (у него есть интернет к источнику):
+
+ python -m app.tasks.frt_mkd_load # полный прогон (node 110, region 66)
+ python -m app.tasks.frt_mkd_load --dry-run # без записи, только подсчёт
+ python -m app.tasks.frt_mkd_load --load-only # только staging, без backfill
+ python -m app.tasks.frt_mkd_load --backfill-only # staging уже загружена — только backfill
+ python -m app.tasks.frt_mkd_load --src-path /tmp/frt.csv # локальный CSV, без скачивания
+
+В режиме --dry-run скачивание/парс (если применимо) происходят, но НИ ОДНОЙ записи
+в БД не делается.
+"""
+
+from __future__ import annotations
+
+import argparse
+import logging
+
+from app.core.db import SessionLocal
+from app.services.frt_mkd_loader import (
+ DEFAULT_NODE_ID,
+ DEFAULT_REGION_CODE,
+ backfill_houses,
+ load_frt_mkd,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def build_parser() -> argparse.ArgumentParser:
+ """Парсер CLI (вынесен для тестируемости флагов без запуска main)."""
+ parser = argparse.ArgumentParser(
+ description=(
+ "АИС ППК ФРТ loader: реестр МКД → frt_mkd staging → "
+ "houses.area_land/foundation_type/elevators_total + добор смежных полей"
+ )
+ )
+ parser.add_argument(
+ "--src-path", default=None, help="локальный CSV реестра МКД (пропустить скачивание)"
+ )
+ parser.add_argument(
+ "--work-dir",
+ default=None,
+ help="каталог для скачанного/распакованного CSV (по умолчанию — временный, "
+ "удаляется после)",
+ )
+ parser.add_argument(
+ "--node-id",
+ type=int,
+ default=DEFAULT_NODE_ID,
+ help=f"id ноды export АИС ФРТ (по умолчанию {DEFAULT_NODE_ID} = реестр МКД region 66)",
+ )
+ parser.add_argument(
+ "--region-code",
+ type=int,
+ default=DEFAULT_REGION_CODE,
+ help=f"код региона выгрузки для тега staging-строк (по умолчанию {DEFAULT_REGION_CODE})",
+ )
+ parser.add_argument(
+ "--dry-run", action="store_true", help="без записи в БД — только скачивание/парс/подсчёт"
+ )
+ parser.add_argument(
+ "--load-only",
+ action="store_true",
+ help="только скачать+распарсить+UPSERT staging, без backfill houses",
+ )
+ parser.add_argument(
+ "--backfill-only",
+ action="store_true",
+ help="пропустить скачивание/load staging (считаем что уже загружена этим же "
+ "или предыдущим прогоном) — только backfill houses",
+ )
+ 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_frt_mkd(
+ db,
+ src_path=args.src_path,
+ work_dir=args.work_dir,
+ node_id=args.node_id,
+ region_code=args.region_code,
+ dry_run=args.dry_run,
+ )
+ if not args.dry_run:
+ db.commit()
+ logger.info("frt_mkd_load: load stage done: %s", load_counts)
+
+ houses_counts: dict[str, int] = {}
+ if not args.load_only:
+ houses_counts = backfill_houses(db, dry_run=args.dry_run)
+ if not args.dry_run:
+ db.commit()
+
+ logger.info(
+ "frt_mkd_load DONE: dry_run=%s load=%s houses=%s",
+ args.dry_run,
+ load_counts,
+ houses_counts,
+ )
+ finally:
+ db.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tradein-mvp/backend/data/sql/290_frt_mkd_staging.sql b/tradein-mvp/backend/data/sql/290_frt_mkd_staging.sql
new file mode 100644
index 00000000..b823fc76
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/290_frt_mkd_staging.sql
@@ -0,0 +1,100 @@
+-- 290_frt_mkd_staging.sql
+-- АИС ППК «ФРТ» (бывш. Реформа ЖКХ) — реестр МКД, обогащение houses (issue #frt-mkd).
+--
+-- КОНТЕКСТ: houses.area_land / foundation_type отсутствуют вовсе; elevators_count в
+-- источнике — ОБЩЕЕ число лифтов, а в houses раздельно passenger/cargo — отдельная
+-- колонка elevators_total, НЕ маппинг на passenger_elevators (иначе задвоение/потеря
+-- типа). year_built/material_walls/total_floors/entrances/is_emergency/flat_count/
+-- heat_supply_type/gas_supply_type/hot_water уже существуют — этот источник их
+-- ДОБИРАЕТ (COALESCE, только NULL), не переопределяет.
+--
+-- ИСТОЧНИК: https://xn--80adsazqn.xn--p1aee.xn--p1ai/opendata/export/110 (node 110 =
+-- реестр МКД региона 66), zip → CSV `export-reestrmkd-66-YYYYMMDD.csv`, UTF-8 BOM,
+-- разделитель ';', 60 колонок, ~41.8k строк по СО. Проверено живьём 08.09.2026.
+--
+-- ЧТО НЕ ДЕЛАЕМ (осознанно, см. промпт задачи):
+-- * project_type НЕ мапим в houses.series_name — свободный текст, дубль материала
+-- стен (топ: пусто, «кирпичный», «нет данных», «панельный», разнобой регистра).
+-- * energy_efficiency НЕ добавляем в houses — миграция 284 уже решила НЕ заводить
+-- это поле; у ~61% домов значение «Не присвоен».
+-- * playground/sportsground — id справочника (498/499/500), НЕ булев флаг; в staging
+-- как есть, в houses.has_playground не пишем.
+--
+-- Идемпотентно: CREATE TABLE/ADD COLUMN IF NOT EXISTS, повторный прогон — no-op.
+-- lock_timeout (#2752): ALTER houses берёт ACCESS EXCLUSIVE — SET LOCAL только внутри BEGIN.
+
+BEGIN;
+SET LOCAL lock_timeout = '5s';
+
+CREATE TABLE IF NOT EXISTS frt_mkd (
+ houseguid text PRIMARY KEY, -- ФИАС GUID дома (АИС ФРТ houseguid)
+ region_code smallint NOT NULL, -- код региона выгрузки (66 = Свердловская обл.)
+ address text,
+ built_year smallint,
+ exploitation_start_year smallint,
+ project_type text, -- сырьё; НЕ мапится в houses (см. шапку)
+ house_type text,
+ is_alarm boolean, -- «Да»/«Нет» из источника
+ floor_count_max smallint,
+ floor_count_min smallint,
+ entrance_count smallint,
+ elevators_count smallint, -- ОБЩЕЕ число лифтов (не passenger/cargo)
+ energy_efficiency text, -- сырьё; НЕ мапится в houses (мигр. 284)
+ quarters_count integer,
+ living_quarters_count integer,
+ unliving_quarters_count integer,
+ area_total numeric(12, 2),
+ area_residential numeric(12, 2),
+ area_non_residential numeric(12, 2),
+ area_common_property numeric(12, 2),
+ area_land numeric(12, 2),
+ parking_square numeric(12, 2),
+ playground integer, -- id справочника благоустройства, НЕ булев флаг
+ sportsground integer, -- id справочника благоустройства, НЕ булев флаг
+ other_beautification text,
+ foundation_type text,
+ floor_type text,
+ wall_material text,
+ basement_area numeric(12, 2),
+ chute_type text,
+ chute_count smallint,
+ heating_type text,
+ hot_water_type text,
+ cold_water_type text,
+ sewerage_type text,
+ gas_type text,
+ ventilation_type text,
+ firefighting_type text,
+ drainage_type text,
+ management_organization_id bigint,
+ method_of_forming_overhaul_fund text,
+ loaded_at timestamptz NOT NULL DEFAULT now()
+);
+
+COMMENT ON TABLE frt_mkd IS
+ 'АИС ППК ФРТ (бывш. Реформа ЖКХ) — реестр МКД, open data export/110, region 66. '
+ 'Ключ houseguid (ФИАС GUID). Заполняется app/services/frt_mkd_loader.py, '
+ 'мигр. 290. Матчится к houses через COALESCE(gar_house_guid, house_fias_id, '
+ 'zhkh_house_guid).';
+
+ALTER TABLE houses ADD COLUMN IF NOT EXISTS area_land numeric(12, 2);
+COMMENT ON COLUMN houses.area_land IS
+ 'мигр. 290: площадь земельного участка, м2, источник АИС ППК ФРТ (frt_mkd.area_land).';
+
+ALTER TABLE houses ADD COLUMN IF NOT EXISTS foundation_type text;
+COMMENT ON COLUMN houses.foundation_type IS
+ 'мигр. 290: тип фундамента, источник АИС ППК ФРТ (frt_mkd.foundation_type).';
+
+ALTER TABLE houses ADD COLUMN IF NOT EXISTS elevators_total integer;
+COMMENT ON COLUMN houses.elevators_total IS
+ 'мигр. 290: ОБЩЕЕ число лифтов (без разбивки passenger/cargo), источник АИС ППК ФРТ '
+ '(frt_mkd.elevators_count). НЕ путать с passenger_elevators/cargo_elevators — те '
+ 'раздельные и из других источников.';
+
+ALTER TABLE houses ADD COLUMN IF NOT EXISTS frt_matched_at timestamptz;
+COMMENT ON COLUMN houses.frt_matched_at IS
+ 'мигр. 290: момент, когда backfill_houses() из frt_mkd_loader.py нашёл матч по '
+ 'houseguid для этого дома (проставляется один раз, даже если полей для дозаполнения '
+ 'уже не осталось).';
+
+COMMIT;
diff --git a/tradein-mvp/backend/data/sql/291_scrape_schedules_seed_frt_mkd_load.sql b/tradein-mvp/backend/data/sql/291_scrape_schedules_seed_frt_mkd_load.sql
new file mode 100644
index 00000000..1f64afc2
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/291_scrape_schedules_seed_frt_mkd_load.sql
@@ -0,0 +1,37 @@
+-- 291_scrape_schedules_seed_frt_mkd_load.sql
+-- Расписание для frt_mkd_load (АИС ППК ФРТ, реестр МКД region 66, issue #frt-mkd).
+--
+-- Источник обновляется нерегулярно (реестр МКД, не суточный поток) — interval_days=30
+-- по аналогии с ДОМ.РФ капремонт (мигр. 176/... каденция месячная). node_id/region_code
+-- в default_params — параметры handler'а (app/services/product_handlers.py
+-- _job_frt_mkd_load), сам loader их же принимает как аргументы load_frt_mkd().
+--
+-- ENABLED=false — как у domrf_kapremont_load и domclick_detail_backfill: включение
+-- отдельным осознанным шагом после деплоя и дымовой пробы (ZIP 3.2 МБ / CSV 41 МБ —
+-- смотрим таймауты и нагрузку на первом ручном прогоне, не в фоне).
+--
+-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 290_frt_mkd_staging.sql.
+-- Идемпотентно: ON CONFLICT (source) DO NOTHING.
+
+BEGIN;
+
+INSERT INTO scrape_schedules (
+ source,
+ enabled,
+ window_start_hour,
+ window_end_hour,
+ next_run_at,
+ default_params
+)
+VALUES
+(
+ 'frt_mkd_load',
+ false,
+ 2,
+ 5,
+ ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC',
+ '{"node_id": 110, "region_code": 66, "interval_days": 30}'::jsonb
+)
+ON CONFLICT (source) DO NOTHING;
+
+COMMIT;
diff --git a/tradein-mvp/backend/data/sql/292_cbr_macro_series.sql b/tradein-mvp/backend/data/sql/292_cbr_macro_series.sql
new file mode 100644
index 00000000..69a88481
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/292_cbr_macro_series.sql
@@ -0,0 +1,87 @@
+-- 292_cbr_macro_series.sql
+-- Макро-ряды ЦБ РФ: ипотека по субъектам + ключевая ставка.
+--
+-- Context:
+-- ЦБ РФ публикует месячную статистику по ипотечным жилищным кредитам (объём выдач,
+-- ставки, задолженность) в разрезе субъектов РФ — три XLSX-дашборда на cbr.ru
+-- (см. app/services/cbr_macro.py::CBR_MORTGAGE_SERIES) — и дневную ключевую ставку
+-- через SOAP-сервис DailyInfoWebServ. Обе серии нужны как макро-контекст для
+-- продукта (региональная динамика ипотечного рынка); загрузку в estimator этот
+-- PR не делает — только данные и их сбор (app/tasks/cbr_macro_pull.py).
+--
+-- Schema notes:
+-- cbr_mortgage_series
+-- * region — территория как в источнике (РФ целиком / федеральный округ /
+-- субъект), без нормализации кодов ОКАТО.
+-- * period_month — первое число отчётного месяца (шапка XLSX «Месяц Год»).
+-- * series — slug серии (rate_rub / new_loans_rub / debt_rub, конфиг —
+-- CBR_MORTGAGE_SERIES в коде).
+-- * PRIMARY KEY (region, period_month, series) — одно значение на территорию×месяц×серию.
+-- * fetched_at — «когда мы ВПЕРВЫЕ увидели этот период» (не время последней
+-- загрузки — источник даёт всю серию целиком, отсечки по
+-- периоду нет). Урок #2846 у sber_price_index: НЕ обновлять
+-- fetched_at в ON CONFLICT DO UPDATE, иначе один прогон
+-- перезаписывает метку у всех периодов, включая 2019-01.
+-- cbr_key_rate
+-- * rate_date — дата действия ключевой ставки (PRIMARY KEY).
+-- * rate — значение ставки, % годовых.
+-- * fetched_at — по той же логике, что и выше: не трогаем в DO UPDATE.
+--
+-- Idempotent: CREATE TABLE IF NOT EXISTS — безопасно переприменять. Только ADDITIVE.
+--
+-- Dependencies: нет (самостоятельные таблицы).
+-- Deploy order: применить до app/services/cbr_macro.py + app/tasks/cbr_macro_pull.py
+-- + миграции 291 (сид scrape_schedules).
+
+BEGIN;
+SET LOCAL lock_timeout = '5s';
+
+CREATE TABLE IF NOT EXISTS cbr_mortgage_series (
+ region text NOT NULL,
+ period_month date NOT NULL,
+ series text NOT NULL,
+ value double precision NOT NULL,
+ source text NOT NULL DEFAULT 'cbr',
+ fetched_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (region, period_month, series)
+);
+
+COMMENT ON TABLE cbr_mortgage_series IS
+ 'Месячные ряды ипотечной статистики ЦБ РФ по территориям (РФ / ФО / субъекты): '
+ 'объём выдач, ставки, задолженность. Источник — XLSX-дашборды cbr.ru/vfs/statistics/'
+ 'BankSector/Mortgage/. Загружается app/tasks/cbr_macro_pull.py через in-app '
+ 'планировщик (scrape_schedules.source=''cbr_macro_pull'').';
+
+COMMENT ON COLUMN cbr_mortgage_series.region IS
+ 'Территория как в источнике (например «РОССИЙСКАЯ ФЕДЕРАЦИЯ», «ЦЕНТРАЛЬНЫЙ '
+ 'ФЕДЕРАЛЬНЫЙ ОКРУГ», «Свердловская область»). Без нормализации ОКАТО/ISO.';
+COMMENT ON COLUMN cbr_mortgage_series.period_month IS
+ 'Первое число отчётного месяца (нормализовано из шапки «Месяц Год» XLSX).';
+COMMENT ON COLUMN cbr_mortgage_series.series IS
+ 'Slug серии, см. CBR_MORTGAGE_SERIES в app/services/cbr_macro.py '
+ '(rate_rub, new_loans_rub, debt_rub).';
+COMMENT ON COLUMN cbr_mortgage_series.value IS
+ 'Значение серии в единицах источника (% годовых для ставки, млн руб для объёмов).';
+COMMENT ON COLUMN cbr_mortgage_series.fetched_at IS
+ 'Когда мы ВПЕРВЫЕ увидели этот период (не время последней загрузки). '
+ 'НЕ обновляется в ON CONFLICT DO UPDATE — см. урок #2846 у sber_price_index.';
+
+CREATE INDEX IF NOT EXISTS idx_cbr_mortgage_series_region_series_period
+ ON cbr_mortgage_series (region, series, period_month DESC);
+
+CREATE TABLE IF NOT EXISTS cbr_key_rate (
+ rate_date date PRIMARY KEY,
+ rate double precision NOT NULL,
+ fetched_at timestamptz NOT NULL DEFAULT now()
+);
+
+COMMENT ON TABLE cbr_key_rate IS
+ 'Ключевая ставка ЦБ РФ по датам действия. Источник — SOAP-метод KeyRate '
+ 'сервиса https://www.cbr.ru/DailyInfoWebServ/DailyInfo.asmx. Загружается '
+ 'app/tasks/cbr_macro_pull.py.';
+COMMENT ON COLUMN cbr_key_rate.rate_date IS 'Дата, с которой действует значение ставки.';
+COMMENT ON COLUMN cbr_key_rate.rate IS 'Ключевая ставка, % годовых.';
+COMMENT ON COLUMN cbr_key_rate.fetched_at IS
+ 'Когда мы ВПЕРВЫЕ увидели эту дату. НЕ обновляется в ON CONFLICT DO UPDATE.';
+
+COMMIT;
diff --git a/tradein-mvp/backend/data/sql/293_scrape_schedules_seed_cbr_macro_pull.sql b/tradein-mvp/backend/data/sql/293_scrape_schedules_seed_cbr_macro_pull.sql
new file mode 100644
index 00000000..4729a69a
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/293_scrape_schedules_seed_cbr_macro_pull.sql
@@ -0,0 +1,41 @@
+-- 293_scrape_schedules_seed_cbr_macro_pull.sql
+-- Сид расписания для нового продуктового источника cbr_macro_pull (ипотека по
+-- субъектам + ключевая ставка ЦБ РФ, см. 292_cbr_macro_series.sql).
+--
+-- Такт: interval_days=7 — источник обновляет XLSX-дашборды ипотеки раз в месяц,
+-- ключевую ставку — по решениям Совета директоров (раз в 6 недель), недельный
+-- такт достаточен и не создаёт нагрузки (три XLSX ~800 КБ суммарно + один SOAP-запрос).
+--
+-- Окно 07:00–08:00 UTC — свободно на момент миграции (проверены соседние сиды:
+-- 180 sber_freshness_monitor 09-10, 275 landing_stats_refresh 05-06,
+-- 289 rosreestr_dkp_import_77 04-06); до утра по Екатеринбургу (UTC+5 → 12:00).
+--
+-- ENABLED=false — как и у прочих новых источников (домрф/sber): включение —
+-- отдельный осознанный шаг после деплоя и дымовой пробы.
+--
+-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 290 (таблицы данных).
+-- Идемпотентно: ON CONFLICT (source) DO NOTHING.
+
+BEGIN;
+SET LOCAL lock_timeout = '5s';
+
+INSERT INTO scrape_schedules (
+ source,
+ enabled,
+ window_start_hour,
+ window_end_hour,
+ next_run_at,
+ default_params
+)
+VALUES
+(
+ 'cbr_macro_pull',
+ false,
+ 7,
+ 8,
+ ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
+ '{"interval_days": 7}'::jsonb
+)
+ON CONFLICT (source) DO NOTHING;
+
+COMMIT;
diff --git a/tradein-mvp/backend/data/sql/294_dtp_incidents.sql b/tradein-mvp/backend/data/sql/294_dtp_incidents.sql
new file mode 100644
index 00000000..fd894dfc
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/294_dtp_incidents.sql
@@ -0,0 +1,79 @@
+-- 294_dtp_incidents.sql
+-- ДТП (dtp-stat.ru, первоисточник — открытые данные ГИБДД), Свердловская обл. (#3410).
+--
+-- WHY: слой ДТП нужен как радиусный "фактор безопасности" района (по образцу
+-- osm_poi_ekb_local / 169_osm_poi_ekb_local.sql — та же архитектура: пустая таблица +
+-- индексы на деплое, наполнение отдельным рефреш-джобом, никакого чтения внешнего
+-- источника при прогоне миграций).
+--
+-- ИСТОЧНИК: https://dtp-stat.ru/media/opendata/sverdlovskaia-oblast.geojson.zip — открытый
+-- агрегатор данных ГИБДД, без auth. Дамп заморожен (Last-Modified 26.02.2026 на момент
+-- проверки 08.09.2026) — см. докстринг app/services/dtp_stat_loader.py, пустая дельта на
+-- рефреше это НЕ баг, а факт источника.
+--
+-- ПДн (важно): properties.vehicles[].participants[] источника содержит пол/роль/нарушения
+-- физлиц-участников ДТП. Эта таблица их СОЗНАТЕЛЬНО НЕ хранит — ни колонкой, ни в jsonb.
+-- Загрузчик (dtp_stat_loader.py) участников/транспорт из парсинга просто не извлекает.
+-- Берём только агрегаты (dead/injured count) и не-персональные атрибуты происшествия.
+--
+-- ИНДЕКСЫ (урок 270_houses_geog_gist_idx.sql / 134_listings_geom_geography_gist.sql): весь
+-- код МЕРЫ ходит радиусными запросами через ST_DWithin(geom::geography, …) — обычный GIST
+-- по geometry планировщик под такой предикат не берёт (прод-замер houses: 70.7 мс на
+-- bitmap-фильтр всех строк; после функционального индекса по geography — 38 мс на listings).
+-- Поэтому сразу оба индекса: geometry (на будущее/для прочих операторов) и функциональный
+-- по (geom::geography) — под фактический предикат dtp_index.py.
+--
+-- Идемпотентно: CREATE TABLE/INDEX IF NOT EXISTS. Безопасно перезапускать.
+BEGIN;
+SET LOCAL lock_timeout = '5s';
+
+CREATE TABLE IF NOT EXISTS dtp_incidents (
+ id bigserial PRIMARY KEY,
+ source_id text UNIQUE,
+ dtp_at timestamptz,
+ category text,
+ severity text,
+ dead integer,
+ injured integer,
+ address text,
+ light text,
+ weather text[],
+ nearby text[],
+ lat double precision,
+ lon double precision,
+ geom geometry(Point, 4326),
+ loaded_at timestamptz NOT NULL DEFAULT now()
+);
+
+COMMENT ON TABLE dtp_incidents IS
+ 'ДТП по Свердловской обл. (dtp-stat.ru, первоисточник — открытые данные ГИБДД, #3410). '
+ 'Пишет app/tasks/dtp_stat_refresh.py (TRUNCATE+INSERT, scheduler source=dtp_stat_refresh, '
+ 'seed 293, enabled=false — включение вручную). Читает app/services/dtp_index.py — '
+ 'радиусные агрегаты (число ДТП/тяжёлых/погибших/раненых) для карточки района. '
+ 'ПДн участников (vehicles/participants: пол, роль, нарушения физлиц) осознанно НЕ '
+ 'хранятся — ни колонкой, ни в jsonb; источник даёт их построчно на транспорт/участника, '
+ 'загрузчик их не извлекает вовсе.';
+
+COMMENT ON COLUMN dtp_incidents.source_id IS 'properties.id из dtp-stat GeoJSON — ключ идемпотентности (UNIQUE).';
+COMMENT ON COLUMN dtp_incidents.dtp_at IS 'properties.datetime, распарсено в timestamptz.';
+COMMENT ON COLUMN dtp_incidents.category IS 'properties.category — тип ДТП ("Столкновение" и т.п.).';
+COMMENT ON COLUMN dtp_incidents.severity IS 'properties.severity — тяжесть ("Тяжёлый"/"Лёгкий"/…).';
+COMMENT ON COLUMN dtp_incidents.dead IS 'properties.dead_count — погибшие, агрегат, без ФИО/пола участников.';
+COMMENT ON COLUMN dtp_incidents.injured IS 'properties.injured_count — раненые, агрегат.';
+COMMENT ON COLUMN dtp_incidents.weather IS 'properties.weather — список погодных условий на момент ДТП.';
+COMMENT ON COLUMN dtp_incidents.nearby IS 'properties.nearby — список объектов УДС/притяжения рядом с местом ДТП.';
+COMMENT ON COLUMN dtp_incidents.geom IS 'ST_SetSRID(ST_MakePoint(lon, lat), 4326) — строится явно в загрузчике, без триггера (как osm_poi_ekb_local).';
+
+-- GIST по geometry — на будущее / прочие geometry-операторы.
+CREATE INDEX IF NOT EXISTS dtp_incidents_geom_idx
+ ON dtp_incidents USING GIST (geom);
+
+-- Функциональный GIST по (geom::geography) — под фактический предикат
+-- ST_DWithin(geom::geography, …) в app/services/dtp_index.py (см. WHY выше).
+CREATE INDEX IF NOT EXISTS dtp_incidents_geog_idx
+ ON dtp_incidents USING GIST ((geom::geography));
+
+CREATE INDEX IF NOT EXISTS dtp_incidents_dtp_at_idx
+ ON dtp_incidents (dtp_at);
+
+COMMIT;
diff --git a/tradein-mvp/backend/data/sql/295_scrape_schedules_seed_dtp_stat_refresh.sql b/tradein-mvp/backend/data/sql/295_scrape_schedules_seed_dtp_stat_refresh.sql
new file mode 100644
index 00000000..1e1188e1
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/295_scrape_schedules_seed_dtp_stat_refresh.sql
@@ -0,0 +1,52 @@
+-- 295_scrape_schedules_seed_dtp_stat_refresh.sql
+-- Scheduler seed for the ДТП local-mirror refresh job (#3410).
+--
+-- WHAT (source='dtp_stat_refresh'):
+-- trigger_dtp_stat_refresh_run (scheduler.py) → run_dtp_stat_refresh
+-- (app/tasks/dtp_stat_refresh.py) → app/services/dtp_stat_loader.py: download ZIP
+-- (dtp-stat.ru), потоковый парс GeoJSON, TRUNCATE dtp_incidents; INSERT — построчно
+-- строим geom из lon/lat явно (ST_SetSRID(ST_MakePoint(...))).
+--
+-- ПОЧЕМУ enabled=false (по образцу house_dedup_merge, seed 135): источник — открытый
+-- годовой дамп, а не живой поток; на момент проверки 08.09.2026 Last-Modified
+-- 26.02.2026 — дельта между рефрешами пустая по построению, пустая дельта НЕ баг
+-- (см. докстринг app/services/dtp_stat_loader.py). Первый прогон — вручную/через
+-- orchestrator, включение регулярного расписания — отдельное решение продукта.
+--
+-- interval_days=30 (default_params, НЕ отдельная колонка — compute_next_run_at читает
+-- именно default_params.interval_days, ровно как в 135/212): месячный дамп двигается
+-- редко, ежемесячная проверка — с запасом.
+--
+-- Window 11:00-12:00 UTC (14:00-15:00 МСК): после osm_poi_ekb_refresh (10:00-11:00
+-- UTC, 170), не пересекает другие DB-only/сетевые джобы. Внешний HTTP на dtp-stat.ru,
+-- но разовый один ZIP-запрос ~5 МБ — не конкурирует за прокси-пул (обычные площадки
+-- не трогает).
+--
+-- next_run_at = завтра 11:00 UTC (NOT NULL — конвенция сидов; безвредно при
+-- enabled=false, т.к. get_due_schedules сначала фильтрует enabled=true).
+--
+-- DEPENDENCIES: 052_scrape_schedules.sql (table + UNIQUE(source)), 294_dtp_incidents.sql.
+-- Idempotent: ON CONFLICT (source) DO NOTHING.
+
+BEGIN;
+
+INSERT INTO scrape_schedules (
+ source,
+ enabled,
+ window_start_hour,
+ window_end_hour,
+ next_run_at,
+ default_params
+)
+VALUES
+(
+ 'dtp_stat_refresh',
+ false,
+ 11,
+ 12,
+ ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC',
+ '{"interval_days": 30}'::jsonb
+)
+ON CONFLICT (source) DO NOTHING;
+
+COMMIT;
diff --git a/tradein-mvp/backend/pyproject.toml b/tradein-mvp/backend/pyproject.toml
index beaf308b..c03f8d40 100644
--- a/tradein-mvp/backend/pyproject.toml
+++ b/tradein-mvp/backend/pyproject.toml
@@ -31,6 +31,9 @@ dependencies = [
"bcrypt>=4.2.0", # password hashing для DB-auth (#2550)
"playwright>=1.45", # Playwright client для connect к tradein-browser (#905)
"prometheus-client>=0.21.0", # /metrics — экспозиция и process-коллекторы (#3078)
+ "openpyxl>=3.1.0", # ЦБ РФ XLSX-дашборды ипотеки (app/services/cbr_macro.py)
+ "ijson>=3.3.0", # потоковый парс ~71 МБ GeoJSON dtp-stat.ru —
+ # без json.load() целиком в память
"scraper-kit", # internal workspace-package (#2137) — общие утилиты скрапперов;
# резолвится из workspace (см. [tool.uv.sources]), не с PyPI.
# Docker-context = tradein-mvp root, uv sync ставит editable.
diff --git a/tradein-mvp/backend/tests/test_cbr_macro.py b/tradein-mvp/backend/tests/test_cbr_macro.py
new file mode 100644
index 00000000..28f79d89
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_cbr_macro.py
@@ -0,0 +1,380 @@
+"""Tests for cbr_macro service — pure unit tests, no network/DB.
+
+Coverage:
+ (a) parse_ru_period: русские имена месяцев → date(y, m, 1), включая мусор.
+ (b) parse_mortgage_sheet: unpivot мини-листа (реальные значения Свердловской обл.,
+ замерены вживую 08.09.2026: 11, 10.64, 10.98, 10.84, 9.2, 11.49).
+ (c) parse_key_rate_response: разбор реального SOAP-ответа KeyRate (namespace-агностично).
+ (d) build_key_rate_envelope: SOAP-конверт содержит нужные теги/значения.
+ (e) upsert SQL — статические asserts: ON CONFLICT, CAST(:x AS type), нет `::`-кастов,
+ fetched_at отсутствует в DO UPDATE SET.
+ (f) load_cbr_mortgage_series / load_key_rate — httpx.MockTransport round-trip.
+ (g) db.commit() отсутствует в исходниках сервисных функций (коммитит caller).
+"""
+
+from __future__ import annotations
+
+import inspect
+import os
+import re
+from datetime import date, datetime
+from unittest.mock import MagicMock
+
+# DATABASE_URL required by config before any app import.
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+import httpx
+import openpyxl
+import pytest
+
+from app.services import cbr_macro
+
+# ---------------------------------------------------------------------------
+# parse_ru_period
+# ---------------------------------------------------------------------------
+
+
+class TestParseRuPeriod:
+ def test_january(self) -> None:
+ assert cbr_macro.parse_ru_period("Январь 2019") == date(2019, 1, 1)
+
+ def test_december(self) -> None:
+ assert cbr_macro.parse_ru_period("Декабрь 2026") == date(2026, 12, 1)
+
+ def test_july_2026(self) -> None:
+ assert cbr_macro.parse_ru_period("Июль 2026") == date(2026, 7, 1)
+
+ def test_unknown_month(self) -> None:
+ with pytest.raises(ValueError, match="месяца"):
+ cbr_macro.parse_ru_period("Смогтябрь 2019")
+
+ def test_garbage_no_year(self) -> None:
+ with pytest.raises(ValueError, match="период"):
+ cbr_macro.parse_ru_period("Январь")
+
+ def test_bad_year(self) -> None:
+ with pytest.raises(ValueError, match="период"):
+ cbr_macro.parse_ru_period("Январь двадцать")
+
+
+# ---------------------------------------------------------------------------
+# parse_mortgage_sheet — unpivot
+# ---------------------------------------------------------------------------
+
+# Мини-лист по форме реального 02_13_Rates_mortgage.xlsx («ставка в рублях»):
+# строка 1 пустая, строка 2 заголовок, строка 3 — шапка периодов, строки 4+ территории.
+# Последние 6 значений Свердловской обл. — реальные, замерены 08.09.2026.
+_RATE_SHEET_ROWS: list[tuple] = [
+ (None, None, None, None, None, None, None, None),
+ (
+ "Средневзвешенная ставка по ипотечным жилищным кредитам, %",
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ ),
+ (None, "Февраль 2026", "Март 2026", "Апрель 2026", "Май 2026", "Июнь 2026", "Июль 2026", None),
+ ("РОССИЙСКАЯ ФЕДЕРАЦИЯ", 21.5, 21.1, 20.8, 19.9, 18.6, 17.9, None),
+ ("Свердловская область", 11, 10.64, 10.98, 10.84, 9.2, 11.49, None),
+]
+
+
+class TestParseMortgageSheet:
+ def test_too_short_returns_empty(self) -> None:
+ assert cbr_macro.parse_mortgage_sheet([(1,), (2,)], series_slug="rate_rub") == []
+
+ def test_unpivot_counts(self) -> None:
+ rows = cbr_macro.parse_mortgage_sheet(_RATE_SHEET_ROWS, series_slug="rate_rub")
+ # 2 территории × 6 месяцев (последний столбец None — trailing empty col пропущен)
+ assert len(rows) == 12
+
+ def test_sverdlovsk_values(self) -> None:
+ rows = cbr_macro.parse_mortgage_sheet(_RATE_SHEET_ROWS, series_slug="rate_rub")
+ sverdlovsk = [r for r in rows if r.region == "Свердловская область"]
+ sverdlovsk.sort(key=lambda r: r.period_month)
+ values = [r.value for r in sverdlovsk]
+ assert values == [11.0, 10.64, 10.98, 10.84, 9.2, 11.49]
+ assert sverdlovsk[0].period_month == date(2026, 2, 1)
+ assert sverdlovsk[-1].period_month == date(2026, 7, 1)
+ assert all(r.series == "rate_rub" for r in sverdlovsk)
+
+ def test_skips_empty_region_rows(self) -> None:
+ rows_in = [*_RATE_SHEET_ROWS, (None, 1, 2, 3, 4, 5, 6, None), ("", 1, 2, 3, 4, 5, 6, None)]
+ rows = cbr_macro.parse_mortgage_sheet(rows_in, series_slug="rate_rub")
+ assert len(rows) == 12 # мусорные строки без региона не добавляют записей
+
+ def test_skips_non_numeric_cell(self) -> None:
+ bad_rows = [*_RATE_SHEET_ROWS[:-1], ("Москва", "n/a", 10.0, None, None, None, None, None)]
+ rows = cbr_macro.parse_mortgage_sheet(bad_rows, series_slug="rate_rub")
+ moscow = [r for r in rows if r.region == "Москва"]
+ assert len(moscow) == 1 # только числовая ячейка попала в результат
+ assert moscow[0].value == 10.0
+
+
+# ---------------------------------------------------------------------------
+# SOAP key rate
+# ---------------------------------------------------------------------------
+
+_KEY_RATE_XML = """
+
+
+
+
+
+
+
+ 2024-01-01T00:00:00+03:00
+ 16
+
+
+ 2024-07-29T00:00:00+03:00
+ 18
+
+
+
+
+
+
+"""
+
+
+class TestKeyRateSoap:
+ def test_parse_real_response_shape(self) -> None:
+ rows = cbr_macro.parse_key_rate_response(_KEY_RATE_XML)
+ assert rows == [(date(2024, 1, 1), 16.0), (date(2024, 7, 29), 18.0)]
+
+ def test_parse_empty_response(self) -> None:
+ empty = (
+ ""
+ ""
+ )
+ assert cbr_macro.parse_key_rate_response(empty) == []
+
+ def test_build_envelope_contains_dates_and_soapaction_target(self) -> None:
+ envelope = cbr_macro.build_key_rate_envelope(date(2024, 1, 1), date(2024, 12, 31))
+ assert "2024-01-01" in envelope
+ assert "2024-12-31" in envelope
+ assert "http://web.cbr.ru/" in envelope
+ assert "" in envelope
+
+
+# ---------------------------------------------------------------------------
+# Upsert SQL — статические asserts (без БД)
+# ---------------------------------------------------------------------------
+
+
+class TestUpsertSql:
+ def test_mortgage_sql_no_double_colon_cast(self) -> None:
+ sql = re.sub(r"\s+", " ", str(cbr_macro._UPSERT_MORTGAGE_SQL.text))
+ assert not re.search(r":\w+::", sql)
+
+ def test_mortgage_sql_has_cast_and_on_conflict(self) -> None:
+ sql = re.sub(r"\s+", " ", str(cbr_macro._UPSERT_MORTGAGE_SQL.text))
+ assert "CAST(:region AS text)" in sql
+ assert "CAST(:period_month AS date)" in sql
+ assert "CAST(:value AS double precision)" in sql
+ assert "ON CONFLICT (region, period_month, series)" in sql
+ assert "DO UPDATE SET" in sql
+
+ def test_mortgage_sql_does_not_touch_fetched_at_on_conflict(self) -> None:
+ sql = str(cbr_macro._UPSERT_MORTGAGE_SQL.text)
+ do_update_part = sql.split("DO UPDATE SET", 1)[1]
+ # fetched_at упоминается только в SQL-комментарии (объясняет почему НЕ обновляем),
+ # но не как цель присваивания.
+ assert not re.search(r"fetched_at\s*=", do_update_part)
+
+ def test_key_rate_sql_no_double_colon_cast(self) -> None:
+ sql = re.sub(r"\s+", " ", str(cbr_macro._UPSERT_KEY_RATE_SQL.text))
+ assert not re.search(r":\w+::", sql)
+
+ def test_key_rate_sql_shape(self) -> None:
+ sql = re.sub(r"\s+", " ", str(cbr_macro._UPSERT_KEY_RATE_SQL.text))
+ assert "CAST(:rate_date AS date)" in sql
+ assert "CAST(:rate AS double precision)" in sql
+ assert "ON CONFLICT (rate_date)" in sql
+
+ def test_key_rate_sql_does_not_touch_fetched_at_on_conflict(self) -> None:
+ sql = str(cbr_macro._UPSERT_KEY_RATE_SQL.text)
+ do_update_part = sql.split("DO UPDATE SET", 1)[1]
+ assert not re.search(r"fetched_at\s*=", do_update_part)
+
+
+# ---------------------------------------------------------------------------
+# Services do not commit — caller commits
+# ---------------------------------------------------------------------------
+
+
+class TestNoCommitInService:
+ @pytest.mark.parametrize(
+ "fn",
+ [
+ cbr_macro.load_cbr_mortgage_series,
+ cbr_macro.pull_cbr_mortgage,
+ cbr_macro.load_key_rate,
+ cbr_macro._upsert_mortgage_rows,
+ cbr_macro._upsert_key_rate_rows,
+ ],
+ )
+ def test_no_commit_call(self, fn) -> None:
+ src = inspect.getsource(fn)
+ assert "db.commit()" not in src
+
+
+# ---------------------------------------------------------------------------
+# Network round-trip via httpx.MockTransport
+# ---------------------------------------------------------------------------
+
+
+def _xlsx_bytes_for(rows: list[tuple], *, sheet_name: str) -> bytes:
+ import io
+
+ wb = openpyxl.Workbook()
+ ws = wb.active
+ ws.title = sheet_name
+ for row in rows:
+ ws.append(row)
+ buf = io.BytesIO()
+ wb.save(buf)
+ return buf.getvalue()
+
+
+class TestMortgageNetworkRoundtrip:
+ def test_load_series_upserts_expected_rows(self) -> None:
+ series = cbr_macro.CbrMortgageSeries(
+ "rate_rub", "https://www.cbr.ru/fake.xlsx", "ставка в рублях"
+ )
+ xlsx_bytes = _xlsx_bytes_for(_RATE_SHEET_ROWS, sheet_name="ставка в рублях")
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ assert str(request.url) == series.url
+ return httpx.Response(200, content=xlsx_bytes)
+
+ client = httpx.Client(transport=httpx.MockTransport(handler))
+ db = MagicMock()
+ db.execute.return_value.rowcount = 1
+
+ result = cbr_macro.load_cbr_mortgage_series(db, series, client=client)
+
+ assert result["rows"] == 12
+ assert result["upserted"] == 12
+ assert db.execute.call_count == 12
+
+ def test_load_series_dry_run_skips_upsert(self) -> None:
+ series = cbr_macro.CbrMortgageSeries(
+ "rate_rub", "https://www.cbr.ru/fake.xlsx", "ставка в рублях"
+ )
+ xlsx_bytes = _xlsx_bytes_for(_RATE_SHEET_ROWS, sheet_name="ставка в рублях")
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ return httpx.Response(200, content=xlsx_bytes)
+
+ client = httpx.Client(transport=httpx.MockTransport(handler))
+ db = MagicMock()
+
+ result = cbr_macro.load_cbr_mortgage_series(db, series, client=client, dry_run=True)
+
+ assert result["rows"] == 12
+ assert result["upserted"] == 0
+ db.execute.assert_not_called()
+
+ def test_missing_sheet_raises(self) -> None:
+ series = cbr_macro.CbrMortgageSeries(
+ "rate_rub", "https://www.cbr.ru/fake.xlsx", "нет такого листа"
+ )
+ xlsx_bytes = _xlsx_bytes_for(_RATE_SHEET_ROWS, sheet_name="ставка в рублях")
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ return httpx.Response(200, content=xlsx_bytes)
+
+ client = httpx.Client(transport=httpx.MockTransport(handler))
+ db = MagicMock()
+
+ with pytest.raises(ValueError, match="лист"):
+ cbr_macro.load_cbr_mortgage_series(db, series, client=client)
+
+ def test_pull_isolates_series_failure(self) -> None:
+ good = cbr_macro.CbrMortgageSeries(
+ "rate_rub", "https://www.cbr.ru/good.xlsx", "ставка в рублях"
+ )
+ bad = cbr_macro.CbrMortgageSeries("debt_rub", "https://www.cbr.ru/bad.xlsx", "в рублях")
+ xlsx_bytes = _xlsx_bytes_for(_RATE_SHEET_ROWS, sheet_name="ставка в рублях")
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ if str(request.url) == bad.url:
+ return httpx.Response(500)
+ return httpx.Response(200, content=xlsx_bytes)
+
+ client = httpx.Client(transport=httpx.MockTransport(handler))
+ db = MagicMock()
+ db.execute.return_value.rowcount = 1
+
+ counters = cbr_macro.pull_cbr_mortgage(db, series_list=[good, bad], client=client)
+
+ assert counters["upserted"] == 12
+ assert counters["errors"] == 1
+ db.rollback.assert_called_once()
+
+
+class TestKeyRateNetworkRoundtrip:
+ def test_load_key_rate_upserts_parsed_rows(self) -> None:
+ def handler(request: httpx.Request) -> httpx.Response:
+ assert request.method == "POST"
+ assert request.headers["soapaction"] == "http://web.cbr.ru/KeyRate"
+ return httpx.Response(200, content=_KEY_RATE_XML.encode("utf-8"))
+
+ client = httpx.Client(transport=httpx.MockTransport(handler))
+ db = MagicMock()
+ db.execute.return_value.rowcount = 1
+
+ result = cbr_macro.load_key_rate(
+ db, from_date=date(2024, 1, 1), to_date=date(2024, 12, 31), client=client
+ )
+
+ assert result["rows"] == 2
+ assert result["upserted"] == 2
+ assert db.execute.call_count == 2
+
+
+def test_parse_mortgage_sheet_debt_layout_datetime_header() -> None:
+ """02_14_Debt_mortgage.xlsx: шапка в строке 2 и периоды — datetime, не «Январь 2019».
+
+ Реальная раскладка (проверено на живом файле 08.09.2026): в 02_11/02_13 строка 1
+ пустая, описание в строке 2, шапка в строке 3 текстом; в 02_14 описание в строке 1,
+ а шапка уже в строке 2 и хранит настоящие даты. Захардкоженный индекс строки давал
+ по серии задолженности ноль строк — молча, только с warning'ами.
+ """
+ months = [
+ datetime(2019, 2, 1),
+ datetime(2019, 3, 1),
+ datetime(2019, 4, 1),
+ datetime(2019, 5, 1),
+ datetime(2019, 6, 1),
+ datetime(2019, 7, 1),
+ ]
+ rows: list[tuple[object, ...]] = [
+ ("Задолженность по ипотечным жилищным кредитам", None, None, None, None, None, None),
+ (None, *months),
+ ("РОССИЙСКАЯ ФЕДЕРАЦИЯ", 6605685, 6719335, 6823490, 6937855, 7018162, 7095485),
+ ("Свердловская область", 100, 101, 102, 103, 104, 105),
+ ]
+
+ out = cbr_macro.parse_mortgage_sheet(rows, series_slug="debt_rub")
+
+ sverdlovsk = [r for r in out if r.region == "Свердловская область"]
+ assert len(sverdlovsk) == 6
+ assert sverdlovsk[0].period_month == date(2019, 2, 1)
+ assert sverdlovsk[0].value == 100.0
+ assert {r.region for r in out} == {"РОССИЙСКАЯ ФЕДЕРАЦИЯ", "Свердловская область"}
+
+
+def test_parse_mortgage_sheet_returns_empty_when_no_header_found() -> None:
+ """Нет распознаваемой шапки — пустой результат и предупреждение, а не мусор."""
+ rows: list[tuple[object, ...]] = [
+ ("Какой-то заголовок", None, None),
+ ("РОССИЙСКАЯ ФЕДЕРАЦИЯ", 1, 2),
+ ]
+ assert cbr_macro.parse_mortgage_sheet(rows, series_slug="debt_rub") == []
diff --git a/tradein-mvp/backend/tests/test_dtp_index.py b/tradein-mvp/backend/tests/test_dtp_index.py
new file mode 100644
index 00000000..677fd925
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_dtp_index.py
@@ -0,0 +1,88 @@
+"""Тесты радиусных агрегатов ДТП (app/services/dtp_index.py, мигр. 294, #3410).
+
+Coverage:
+ - _bbox_from_point: геометрически разумный bbox (south None:
+ lat, lon, radius_m = 56.8389, 60.6057, 1000
+ south, north, west, east = di._bbox_from_point(lat, lon, radius_m)
+ assert south < lat < north
+ assert west < lon < east
+ # Порядок величины: ~1000м ~ 0.009° по широте — не на порядок больше/меньше.
+ assert 0.001 < (north - south) < 0.05
+ assert 0.001 < (east - west) < 0.1
+
+
+def test_compute_dtp_stats_unavailable_on_empty_table() -> None:
+ db = MagicMock()
+ db.execute.return_value.scalar.return_value = 0
+
+ result = di.compute_dtp_stats(db, 56.8389, 60.6057)
+
+ assert result.status == "unavailable"
+ assert result.incidents_count == 0
+ assert result.dead == 0
+ assert result.injured == 0
+ # Только один execute — count(*) проверка; агрегатный запрос не выполняется.
+ assert db.execute.call_count == 1
+
+
+def test_compute_dtp_stats_ok_path() -> None:
+ db = MagicMock()
+ count_result = MagicMock()
+ count_result.scalar.return_value = 31253
+
+ stats_row = {
+ "incidents_count": 12,
+ "severe_count": 3,
+ "dead_total": 1,
+ "injured_total": 9,
+ }
+ stats_result = MagicMock()
+ stats_result.mappings.return_value.first.return_value = stats_row
+
+ db.execute.side_effect = [count_result, stats_result]
+
+ result = di.compute_dtp_stats(db, 56.8389, 60.6057, radius_m=800, years=3)
+
+ assert result.status == "ok"
+ assert result.radius_m == 800
+ assert result.years == 3
+ assert result.incidents_count == 12
+ assert result.severe_count == 3
+ assert result.dead == 1
+ assert result.injured == 9
+
+ stats_call = db.execute.call_args_list[1]
+ assert stats_call.args[0] is di._DTP_STATS_SQL
+ params = stats_call.args[1]
+ assert params["radius_m"] == 800
+ assert params["years"] == 3
+
+
+def test_dtp_stats_sql_static_shape() -> None:
+ sql = re.sub(r"\s+", " ", str(di._DTP_STATS_SQL.text))
+ assert "ST_DWithin(" in sql
+ assert "geom::geography" in sql
+ assert "CAST(:radius_m AS double precision)" in sql
+ assert "CAST(:bbox_south AS double precision)" in sql
+ assert "make_interval(years => CAST(:years AS integer))" in sql
+ # psycopg v3 trap: bind-параметр не должен иметь :name::type сразу за собой.
+ assert not re.search(r":[a-zA-Z_]+::[a-zA-Z]", sql)
diff --git a/tradein-mvp/backend/tests/test_dtp_stat_loader.py b/tradein-mvp/backend/tests/test_dtp_stat_loader.py
new file mode 100644
index 00000000..a543b393
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_dtp_stat_loader.py
@@ -0,0 +1,272 @@
+"""Тесты dtp-stat.ru loader'а (app/services/dtp_stat_loader.py, мигр. 294, #3410).
+
+Coverage:
+ - Чистые парс-хелперы: _parse_datetime / _parse_str_list / _parse_int.
+ - _parse_feature на РЕАЛЬНОЙ первой записи sverdlovskaia-oblast.geojson (id=74, "ул
+ Победы, 31") — обязательный тест, что vehicles/participants (ПДн физлиц) НЕ
+ попадают в результат парсинга ни в каком виде.
+ - iter_incidents — потоковый парс (ijson) мини-FeatureCollection, пропуск невалидных
+ фич (нет geometry.Point, нет id).
+ - download_dtp_zip / load_dtp_incidents(src_path=...) — HTTP замокан httpx.MockTransport,
+ zip без .geojson entry -> ValueError.
+ - load_dtp_incidents dry_run: TRUNCATE/INSERT не вызываются, счётчик parsed верный.
+ - load_dtp_incidents real-run: TRUNCATE вызван, INSERT чанками, rowcount суммируется
+ (MagicMock db).
+ - Статические asserts по SQL: _INSERT_SQL (ON CONFLICT DO NOTHING, CAST(...), geom
+ строится явно ST_SetSRID/ST_MakePoint, нет bare :x:: кастов).
+ - Гейт: `load_dtp_incidents` не коммитит (коммитит caller — app/tasks/dtp_stat_refresh.py).
+"""
+
+from __future__ import annotations
+
+import inspect
+import io
+import json
+import os
+import re
+import zipfile
+from pathlib import Path
+from unittest.mock import MagicMock
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+import httpx
+import pytest
+
+from app.services import dtp_stat_loader as dsl
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Фикстура: РЕАЛЬНАЯ первая запись sverdlovskaia-oblast.geojson (проверено 08.09.2026,
+# id=74, "г Екатеринбург, ул Победы, 31") — включает vehicles/participants (ПДн) НАРОЧНО,
+# чтобы доказать, что парсер их не трогает.
+# ─────────────────────────────────────────────────────────────────────────────
+_FEATURE_74 = json.loads(
+ '{"type": "Feature", "geometry": {"type": "Point", "coordinates": [60.603333, 56.896944]}, '
+ '"properties": {"id": 74, "tags": ["Дорожно-транспортные происшествия"], '
+ '"light": "В темное время суток, освещение включено", '
+ '"point": {"lat": 56.896944, "long": 60.603333}, '
+ '"nearby": ["Отсутствие в непосредственной близости объектов УДС и объектов притяжения", '
+ '"Перегон (нет объектов на месте ДТП)"], "region": "Свердловская область", '
+ '"scheme": "200", "address": "г Екатеринбург, ул Победы, 31", "weather": ["Снегопад"], '
+ '"category": "Столкновение", "datetime": "2015-01-01 03:00:00", "severity": "Тяжёлый", '
+ '"vehicles": [{"year": 2006, "brand": "ВАЗ", "color": "Иные цвета", '
+ '"model": "Нива ВАЗ-2121 и модификации", "category": "D-класс (средний) до 4,6 м", '
+ '"participants": [{"id": 1649862, "role": "Водитель", "gender": "Мужской", '
+ '"violations": ["Выезд на полосу встречного движения"], '
+ '"health_status": "Раненый, находящийся (находившийся) на стационарном лечении", '
+ '"years_of_driving_experience": 6}]}, {"year": 2013, "brand": "DAEWOO", "color": "Белый", '
+ '"model": "Pathfinder", "category": "D-класс (средний) до 4,6 м", "participants": '
+ '[{"id": 1649863, "role": "Водитель", "gender": "Мужской", "violations": ["Нет нарушений"], '
+ '"health_status": "Не пострадал", "years_of_driving_experience": 15}, '
+ '{"id": 1649864, "role": "Пассажир", "gender": "Женский", "violations": ["Нет нарушений"], '
+ '"health_status": "Раненый, находящийся (находившийся) на стационарном лечении", '
+ '"years_of_driving_experience": null}, {"id": 1649865, "role": "Пассажир", '
+ '"gender": "Мужской", "violations": ["Нет нарушений"], '
+ '"health_status": "Раненый, находящийся (находившийся) на стационарном лечении", '
+ '"years_of_driving_experience": null}]}], "dead_count": 0, "gibdd_number": "157467063", '
+ '"participants": [], "injured_count": 3, "parent_region": null, '
+ '"road_conditions": ["Не установлены"], "participants_count": 4, '
+ '"participant_categories": ["Все участники"]}}'
+)
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Чистые парс-хелперы
+# ─────────────────────────────────────────────────────────────────────────────
+def test_parse_datetime_happy_and_garbage() -> None:
+ dt = dsl._parse_datetime("2015-01-01 03:00:00")
+ assert dt is not None
+ assert (dt.year, dt.month, dt.day, dt.hour) == (2015, 1, 1, 3)
+ assert dsl._parse_datetime(None) is None
+ assert dsl._parse_datetime("") is None
+ assert dsl._parse_datetime("не дата") is None
+
+
+def test_parse_str_list() -> None:
+ assert dsl._parse_str_list(["a", "b"]) == ["a", "b"]
+ assert dsl._parse_str_list([]) is None
+ assert dsl._parse_str_list(None) is None
+ assert dsl._parse_str_list("не список") is None
+
+
+def test_parse_int() -> None:
+ assert dsl._parse_int(3) == 3
+ assert dsl._parse_int(0) == 0
+ assert dsl._parse_int(None) is None
+ assert dsl._parse_int("3") is None
+ assert dsl._parse_int(True) is None
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# _parse_feature — ПДн-гейт
+# ─────────────────────────────────────────────────────────────────────────────
+def test_parse_feature_real_record_no_pii() -> None:
+ row = dsl._parse_feature(_FEATURE_74)
+ assert row is not None
+ assert row.source_id == "74"
+ assert row.address == "г Екатеринбург, ул Победы, 31"
+ assert row.category == "Столкновение"
+ assert row.severity == "Тяжёлый"
+ assert row.dead == 0
+ assert row.injured == 3
+ assert row.weather == ["Снегопад"]
+ assert len(row.nearby) == 2
+ assert row.lat == pytest.approx(56.896944)
+ assert row.lon == pytest.approx(60.603333)
+ assert (row.dtp_at.year, row.dtp_at.month, row.dtp_at.day) == (2015, 1, 1)
+
+ # ПДн-гейт: dataclass не несёт ни vehicles, ни participants ни в каком поле.
+ field_names = {f for f in dsl.DtpIncidentRow.__dataclass_fields__}
+ assert "vehicles" not in field_names
+ assert "participants" not in field_names
+ row_values = repr(row)
+ assert "Водитель" not in row_values
+ assert "Мужской" not in row_values
+ assert "1649862" not in row_values
+
+
+def test_parse_feature_skips_non_point_geometry() -> None:
+ feature = {"geometry": {"type": "Polygon", "coordinates": []}, "properties": {"id": 1}}
+ assert dsl._parse_feature(feature) is None
+
+
+def test_parse_feature_skips_missing_id() -> None:
+ feature = {
+ "geometry": {"type": "Point", "coordinates": [60.0, 56.0]},
+ "properties": {},
+ }
+ assert dsl._parse_feature(feature) is None
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# iter_incidents — потоковый парс
+# ─────────────────────────────────────────────────────────────────────────────
+def _feature_collection_bytes(features: list[dict]) -> bytes:
+ return json.dumps({"type": "FeatureCollection", "features": features}).encode("utf-8")
+
+
+def test_iter_incidents_streams_and_skips_invalid() -> None:
+ garbage = {
+ "type": "Feature",
+ "geometry": {"type": "Point", "coordinates": []},
+ "properties": {},
+ }
+ data = _feature_collection_bytes([_FEATURE_74, garbage])
+ rows = list(dsl.iter_incidents(io.BytesIO(data)))
+ assert len(rows) == 1
+ assert rows[0].source_id == "74"
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# download_dtp_zip / load_dtp_incidents — HTTP замокан
+# ─────────────────────────────────────────────────────────────────────────────
+def _zip_bytes(inner_name: str, payload: bytes) -> bytes:
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w") as zf:
+ zf.writestr(inner_name, payload)
+ return buf.getvalue()
+
+
+def test_load_dtp_incidents_dry_run_downloads_and_counts() -> None:
+ data = _feature_collection_bytes([_FEATURE_74])
+ zip_bytes = _zip_bytes("sverdlovskaia-oblast.geojson", data)
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ assert str(request.url) == dsl.SRC_URL
+ return httpx.Response(200, content=zip_bytes)
+
+ client = httpx.Client(transport=httpx.MockTransport(handler))
+ db = MagicMock()
+
+ counts = dsl.load_dtp_incidents(db, client=client, dry_run=True)
+
+ assert counts == {"parsed": 1, "inserted": 0}
+ db.execute.assert_not_called()
+
+
+def test_load_dtp_incidents_real_run_truncates_and_inserts(tmp_path: Path) -> None:
+ data = _feature_collection_bytes([_FEATURE_74])
+ zip_path = tmp_path / "local.zip"
+ zip_path.write_bytes(_zip_bytes("sverdlovskaia-oblast.geojson", data))
+
+ db = MagicMock()
+ db.execute.return_value.rowcount = 1
+
+ counts = dsl.load_dtp_incidents(db, src_path=zip_path, dry_run=False)
+
+ assert counts == {"parsed": 1, "inserted": 1}
+ calls = db.execute.call_args_list
+ assert calls[0].args[0] is dsl._TRUNCATE_SQL
+ assert calls[1].args[0] is dsl._INSERT_SQL
+ inserted_rows = calls[1].args[1]
+ assert len(inserted_rows) == 1
+ assert inserted_rows[0]["source_id"] == "74"
+ assert "vehicles" not in inserted_rows[0]
+ assert "participants" not in inserted_rows[0]
+
+
+def test_zip_without_geojson_raises() -> None:
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w") as zf:
+ zf.writestr("readme.txt", "not geojson")
+ with zipfile.ZipFile(buf) as zf, pytest.raises(ValueError, match="geojson"):
+ dsl._open_geojson_in_zip(zf)
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Статические asserts по SQL + гейт "сервис не коммитит"
+# ─────────────────────────────────────────────────────────────────────────────
+def test_insert_sql_static_shape() -> None:
+ sql = re.sub(r"\s+", " ", str(dsl._INSERT_SQL.text))
+ assert "ON CONFLICT (source_id) DO NOTHING" in sql
+ assert "CAST(:source_id AS text)" in sql
+ assert "CAST(:weather AS text[])" in sql
+ assert "CAST(:nearby AS text[])" in sql
+ assert "ST_SetSRID(ST_MakePoint(" in sql
+ # psycopg v3 trap: bind-параметр не должен иметь :name::type сразу за собой.
+ assert not re.search(r":[a-zA-Z_]+::[a-zA-Z]", sql)
+
+
+def test_truncate_sql_static_shape() -> None:
+ assert str(dsl._TRUNCATE_SQL.text).strip() == "TRUNCATE dtp_incidents"
+
+
+def test_load_dtp_incidents_does_not_commit() -> None:
+ src = inspect.getsource(dsl.load_dtp_incidents)
+ assert "db.commit()" not in src
+
+
+def _feature(lon: float, lat: float, source_id: int = 1) -> dict:
+ return {
+ "type": "Feature",
+ "geometry": {"type": "Point", "coordinates": [lon, lat]},
+ "properties": {"id": source_id, "datetime": "2020-05-05 12:00:00"},
+ }
+
+
+@pytest.mark.parametrize(
+ ("lon", "lat"),
+ [
+ (0.0, 0.0), # null island — 10 таких в реальном дампе
+ (1.0, 1.0),
+ (47.0, 41.0),
+ (55.3, 22.183),
+ (59.852, 5.8),
+ ],
+)
+def test_parse_feature_drops_broken_coordinates(lon: float, lat: float) -> None:
+ """Битые координаты источника не должны попадать в geo-слой.
+
+ Реальный дамп по Свердловской области (2026-02-26): из 31 253 записей 86 имеют
+ координаты вне региона, в т.ч. 10 ровно в (0,0). Слой кормит радиусные агрегаты
+ локации, поэтому мусор режем на парсе.
+ """
+ assert dsl._parse_feature(_feature(lon, lat)) is None
+
+
+@pytest.mark.parametrize(("lon", "lat"), [(60.6055, 56.8386), (56.94, 63.283)])
+def test_parse_feature_keeps_region_points_including_northern_tip(lon: float, lat: float) -> None:
+ """Рамка шире официальных границ — север области (~63°) не срезается."""
+ row = dsl._parse_feature(_feature(lon, lat))
+ assert row is not None
+ assert row.lat == lat and row.lon == lon
diff --git a/tradein-mvp/backend/tests/test_frt_mkd_loader.py b/tradein-mvp/backend/tests/test_frt_mkd_loader.py
new file mode 100644
index 00000000..4267fe2d
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_frt_mkd_loader.py
@@ -0,0 +1,387 @@
+"""Тесты АИС ППК ФРТ loader'а (app/services/frt_mkd_loader.py, мигр. 290/291, #frt-mkd).
+
+Coverage:
+ - Чистые парс-хелперы: clean_text (мусорные значения-заглушки), parse_decimal_comma,
+ parse_int_field (+ sanity-гейт границ), parse_bool_da_net.
+ - parse_frt_mkd_csv — фикстуры собраны из РЕАЛЬНЫХ строк export-reestrmkd-66-20260901.csv
+ (id 6556123 «д. 41» — плотно заполненная строка; id 6477794 «д. 99» — почти вся строка
+ из значений-заглушек «Не заполнено»/пусто; id 9090614 — is_alarm='Да', project_type
+ в нижнем регистре 'нет').
+ - fetch_frt_mkd_csv — скачивание+распаковка zip, HTTP замокан через httpx.MockTransport.
+ - Статические asserts по SQL: _UPSERT_SQL (ON CONFLICT, IS DISTINCT FROM, CAST),
+ _BACKFILL_HOUSES_SQL/_COUNT_SQL (COALESCE-only-NULL-гейт, houseguid COALESCE-матч,
+ без `:x::type`).
+ - backfill_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
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+import httpx
+import pytest
+
+from app.services import frt_mkd_loader as fml
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Фикстуры: РЕАЛЬНЫЕ строки export-reestrmkd-66-20260901.csv (region 66)
+# ─────────────────────────────────────────────────────────────────────────────
+_HEADER = (
+ "id;region_id;area_id;city_id;street_id;shortname_region;formalname_region;"
+ "shortname_area;formalname_area;shortname_city;formalname_city;shortname_street;"
+ "formalname_street;house_number;building;block;letter;address;houseguid;"
+ "management_organization_id;built_year;exploitation_start_year;project_type;house_type;"
+ "is_alarm;method_of_forming_overhaul_fund;floor_count_max;floor_count_min;"
+ "entrance_count;elevators_count;energy_efficiency;quarters_count;"
+ "living_quarters_count;unliving_quarters_count;area_total;area_residential;"
+ "area_non_residential;area_common_property;area_land;parking_square;playground;"
+ "sportsground;other_beautification;foundation_type;floor_type;wall_material;"
+ "basement_area;chute_type;chute_count;electrical_type;electrical_entries_count;"
+ "heating_type;hot_water_type;cold_water_type;sewerage_type;"
+ "sewerage_cesspools_volume;gas_type;ventilation_type;firefighting_type;drainage_type"
+)
+# д. 41 — плотно заполненная строка, is_alarm='Нет', elevators_count=0.
+_ROW_DENSE = (
+ '6556123;;;;;;;;;;;;;41;;;;"д. 41";d6bad775-f0c8-b0d6-87ab-07c43513529e;8915908;1917;'
+ '1917;Кирпичный;"Многоквартирный дом";Нет;"На счете регионального оператора";8;6;1;0;'
+ '"Не присвоен";13;13;0;930,60;930,60;0,00;80,40;1100,00;0,00;500;500;;Ленточный;'
+ "Деревянные;Кирпич;0,00;Отсутствует;0;Центральное;2;Центральное;"
+ '"Открытая с отбором сетевой воды на горячее водоснабжение из тепловой сети";'
+ 'Центральное;Центральное;0,00;Отсутствует;"Приточная вентиляция";Отсутствует;'
+ '"Наружные водостоки"'
+)
+# д. 99 — почти вся строка из значений-заглушек «Не заполнено» / пусто.
+_ROW_NOISE = (
+ '6477794;;;;;;;;;;;;;99;;;;"д. 99";c42aa407-54e8-0917-ca15-439b5b784903;;;1954;;'
+ '"Многоквартирный дом";Нет;"Не заполнено";;;;;"Не заполнено";0;;;10156,60;7039,70;;;;;'
+ '498;498;;"Не заполнено";"Не заполнено";"Не заполнено";;"Не заполнено";;'
+ '"Не заполнено";;"Не заполнено";"Не заполнено";"Не заполнено";"Не заполнено";;'
+ '"Не заполнено";"Не заполнено";"Не заполнено";"Не заполнено"'
+)
+# is_alarm='Да' (аварийный), project_type='нет' (нижний регистр — проверка noise-гейта).
+_ROW_ALARM = (
+ "9090614;92b30014-4d52-4e2e-892d-928142b924bf;;a4ab722e-453a-4aed-bb73-728a05e2e27f;"
+ "36a4e8b6-5c87-4f67-9ea1-12ac63f32939;обл.;Свердловская;;;г.;Алапаевск;км;123;3;;;;"
+ '"обл. Свердловская, г. Алапаевск, км. 123, д. 3";'
+ "37e2d973-9a07-4faa-86f5-95e417f86059;8917294;1911;1911;нет;"
+ '"Многоквартирный дом";Да;"Не определен";1;1;0;0;"Не присвоен";5;5;0;110,50;110,50;'
+ "0,00;0,00;2400,00;0,00;500;500;нет;Ленточный;Деревянные;Деревянные;0,00;Отсутствует;"
+ "0;Центральное;1;Печное;Отсутствует;Отсутствует;Отсутствует;0,00;Отсутствует;"
+ "Отсутствует;Отсутствует;Отсутствует"
+)
+FRT_CSV = "\n".join([_HEADER, _ROW_DENSE, _ROW_NOISE, _ROW_ALARM]) + "\n"
+
+
+@pytest.fixture
+def frt_csv_path(tmp_path: Path) -> Path:
+ p = tmp_path / "frt_mkd.csv"
+ p.write_text(FRT_CSV, encoding="utf-8-sig")
+ return p
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# clean_text / parse_decimal_comma / parse_int_field / parse_bool_da_net
+# ─────────────────────────────────────────────────────────────────────────────
+def test_clean_text_filters_noise_values() -> None:
+ assert fml.clean_text("Кирпич") == "Кирпич"
+ assert fml.clean_text("") is None
+ assert fml.clean_text(None) is None
+ assert fml.clean_text("нет данных") is None
+ assert fml.clean_text("Не заполнено") is None
+ assert fml.clean_text("отсутствует") is None
+ assert fml.clean_text("данные отсутствуют") is None
+ assert fml.clean_text("нет") is None
+ assert fml.clean_text(" Кирпич ") == "Кирпич"
+
+
+def test_parse_decimal_comma() -> None:
+ assert fml.parse_decimal_comma("930,60") == pytest.approx(930.60)
+ assert fml.parse_decimal_comma("100.5") == pytest.approx(100.5)
+ assert fml.parse_decimal_comma("") is None
+ assert fml.parse_decimal_comma(None) is None
+ assert fml.parse_decimal_comma("Не заполнено") is None
+
+
+def test_parse_int_field() -> None:
+ assert fml.parse_int_field("1917") == 1917
+ assert fml.parse_int_field("8") == 8
+ assert fml.parse_int_field("") is None
+ assert fml.parse_int_field(None) is None
+ assert fml.parse_int_field("Не заполнено") is None
+ assert fml.parse_int_field("5,0") == 5
+
+
+def test_parse_int_field_sanity_gate() -> None:
+ ymax = fml.plausible_year_max()
+ assert fml.parse_int_field("1200", min_value=1850, max_value=ymax) is None
+ assert fml.parse_int_field(str(ymax + 10), min_value=1850, max_value=ymax) is None
+ assert fml.parse_int_field("1917", min_value=1850, max_value=ymax) == 1917
+
+
+def test_parse_bool_da_net() -> None:
+ assert fml.parse_bool_da_net("Да") is True
+ assert fml.parse_bool_da_net("да") is True
+ assert fml.parse_bool_da_net("Нет") is False
+ assert fml.parse_bool_da_net("") is None
+ assert fml.parse_bool_da_net(None) is None
+ assert fml.parse_bool_da_net("Не заполнено") is None
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# parse_frt_mkd_csv
+# ─────────────────────────────────────────────────────────────────────────────
+def test_parse_frt_mkd_csv_dense_row(frt_csv_path: Path) -> None:
+ rows = fml.parse_frt_mkd_csv(frt_csv_path, region_code=66)
+ assert set(rows.keys()) == {
+ "d6bad775-f0c8-b0d6-87ab-07c43513529e",
+ "c42aa407-54e8-0917-ca15-439b5b784903",
+ "37e2d973-9a07-4faa-86f5-95e417f86059",
+ }
+
+ dense = rows["d6bad775-f0c8-b0d6-87ab-07c43513529e"]
+ assert dense.region_code == 66
+ assert dense.built_year == 1917
+ assert dense.is_alarm is False
+ assert dense.floor_count_max == 8
+ assert dense.entrance_count == 1
+ assert dense.elevators_count == 0
+ assert dense.wall_material == "Кирпич"
+ assert dense.floor_type == "Деревянные"
+ assert dense.area_land == pytest.approx(1100.00)
+ assert dense.area_total == pytest.approx(930.60)
+ assert dense.living_quarters_count == 13
+ assert dense.heating_type == "Центральное"
+ assert dense.playground == 500 # id справочника, не булев
+ assert dense.management_organization_id == 8915908
+
+
+def test_parse_frt_mkd_csv_noise_row_collapses_to_none(frt_csv_path: Path) -> None:
+ rows = fml.parse_frt_mkd_csv(frt_csv_path, region_code=66)
+ noise = rows["c42aa407-54e8-0917-ca15-439b5b784903"]
+ # Почти вся строка — "Не заполнено"/пусто → None, не должно попасть в staging как текст.
+ assert noise.wall_material is None
+ assert noise.foundation_type is None
+ assert noise.floor_type is None
+ assert noise.built_year is None # built_year пуст в этой строке
+ assert noise.exploitation_start_year == 1954
+ assert noise.area_total == pytest.approx(10156.60)
+ assert noise.is_alarm is False
+
+
+def test_parse_frt_mkd_csv_alarm_row_and_lowercase_noise(frt_csv_path: Path) -> None:
+ rows = fml.parse_frt_mkd_csv(frt_csv_path, region_code=66)
+ alarm = rows["37e2d973-9a07-4faa-86f5-95e417f86059"]
+ assert alarm.is_alarm is True
+ # project_type='нет' (нижний регистр) — та же заглушка, что 'Нет'/'НЕТ' → None.
+ assert alarm.project_type is None
+ assert alarm.built_year == 1911
+
+
+def test_parse_frt_mkd_csv_skips_blank_houseguid(tmp_path: Path) -> None:
+ csv_text = _HEADER + "\n" + (";" * _HEADER.count(";")) + "\n"
+ p = tmp_path / "blank.csv"
+ p.write_text(csv_text, encoding="utf-8-sig")
+ assert fml.parse_frt_mkd_csv(p, region_code=66) == {}
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# fetch_frt_mkd_csv — скачивание+распаковка 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_frt_mkd_csv_downloads_and_extracts(tmp_path: Path) -> None:
+ zip_bytes = _zip_bytes("export-reestrmkd-66-20260901.csv", FRT_CSV)
+ expected_url = f"{fml.BASE_URL}/opendata/export/110"
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ if str(request.url) == expected_url:
+ return httpx.Response(200, content=zip_bytes)
+ raise AssertionError(f"unexpected URL {request.url}")
+
+ transport = httpx.MockTransport(handler)
+ with httpx.Client(transport=transport) as client:
+ out_path = fml.fetch_frt_mkd_csv(tmp_path, node_id=110, client=client)
+
+ assert out_path.exists() and out_path.name.endswith(".csv")
+ rows = fml.parse_frt_mkd_csv(out_path, region_code=66)
+ assert len(rows) == 3
+
+
+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"):
+ fml._extract_csv_from_zip(buf.getvalue(), tmp_path)
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# load_frt_mkd — dry_run не пишет (упаковано в тонкий MagicMock-flow)
+# ─────────────────────────────────────────────────────────────────────────────
+def test_load_frt_mkd_dry_run_parses_but_does_not_upsert(frt_csv_path: Path) -> None:
+ db = MagicMock()
+ result = fml.load_frt_mkd(db, src_path=frt_csv_path, region_code=66, dry_run=True)
+ assert result == {"rows": 3, "upserted": 0}
+ db.execute.assert_not_called()
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Статические asserts по _UPSERT_SQL (psycopg v3, ON CONFLICT, идемпотентность)
+# ─────────────────────────────────────────────────────────────────────────────
+_UPSERT_SQL = str(fml._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(:houseguid AS text)" in _UPSERT_SQL
+ assert "CAST(:built_year AS smallint)" in _UPSERT_SQL
+ assert "CAST(:is_alarm AS boolean)" in _UPSERT_SQL
+ assert "CAST(:area_land AS numeric)" in _UPSERT_SQL
+
+
+def test_upsert_sql_idempotent_on_conflict() -> None:
+ flat = re.sub(r"\s+", " ", _UPSERT_SQL)
+ assert "INSERT INTO frt_mkd" in flat
+ assert "ON CONFLICT (houseguid) DO UPDATE SET" in flat
+ assert "frt_mkd.wall_material IS DISTINCT FROM EXCLUDED.wall_material" in flat
+ assert "frt_mkd.area_land IS DISTINCT FROM EXCLUDED.area_land" in flat
+
+
+def test_upsert_uses_savepoint_per_chunk_and_does_not_commit() -> None:
+ import inspect
+
+ src = inspect.getsource(fml.upsert_frt_mkd)
+ assert "with db.begin_nested():" in src
+ assert "db.commit()" not in src
+
+
+def test_load_frt_mkd_does_not_commit() -> None:
+ import inspect
+
+ src = inspect.getsource(fml.load_frt_mkd)
+ assert "db.commit()" not in src
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Статические asserts: _BACKFILL_HOUSES_SQL / _COUNT_SQL
+# ─────────────────────────────────────────────────────────────────────────────
+_BACKFILL_SQL = re.sub(r"\s+", " ", str(fml._BACKFILL_HOUSES_SQL.text))
+_BACKFILL_COUNT_SQL = re.sub(r"\s+", " ", str(fml._BACKFILL_HOUSES_COUNT_SQL.text))
+
+
+def test_backfill_sql_matches_by_houseguid_coalesce_priority() -> None:
+ assert (
+ "s.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)"
+ in _BACKFILL_SQL
+ )
+
+
+def test_backfill_sql_never_overwrites_existing_non_null() -> None:
+ # Каждый таргет своим гейтом h. IS NULL — существующее значение не трогается.
+ assert "WHEN h.year_built IS NULL" in _BACKFILL_SQL
+ assert "COALESCE(h.material_walls, s.wall_material)" in _BACKFILL_SQL
+ assert "COALESCE(h.material_floors, s.floor_type)" in _BACKFILL_SQL
+ assert "WHEN h.total_floors IS NULL" in _BACKFILL_SQL
+ assert "WHEN h.entrances IS NULL" in _BACKFILL_SQL
+ assert "WHEN h.elevators_total IS NULL" in _BACKFILL_SQL
+ assert "COALESCE(h.is_emergency, s.is_alarm)" in _BACKFILL_SQL
+ assert "WHEN h.flat_count IS NULL" in _BACKFILL_SQL
+ assert "COALESCE(h.heat_supply_type, s.heating_type)" in _BACKFILL_SQL
+ assert "COALESCE(h.gas_supply_type, s.gas_type)" in _BACKFILL_SQL
+ assert "COALESCE(h.hot_water, s.hot_water_type)" in _BACKFILL_SQL
+ assert "WHEN h.area_land IS NULL" in _BACKFILL_SQL
+ assert "COALESCE(h.foundation_type, s.foundation_type)" in _BACKFILL_SQL
+
+
+def test_backfill_sql_frt_matched_at_set_once() -> None:
+ assert "frt_matched_at = COALESCE(h.frt_matched_at, now())" in _BACKFILL_SQL
+ assert "h.frt_matched_at IS NULL" in _BACKFILL_SQL
+
+
+def test_backfill_sql_does_not_touch_elevators_or_series_name() -> None:
+ # НЕ мапим elevators_count в passenger_elevators, НЕ мапим project_type в series_name.
+ assert "passenger_elevators" not in _BACKFILL_SQL
+ assert "series_name" not in _BACKFILL_SQL
+ assert "energy_efficiency" not in _BACKFILL_SQL
+
+
+def test_backfill_sql_no_psycopg_v3_colon_colon_cast() -> None:
+ assert not re.search(r":\w+::", _BACKFILL_SQL)
+ assert not re.search(r":\w+::", _BACKFILL_COUNT_SQL)
+
+
+def test_backfill_houses_does_not_commit() -> None:
+ import inspect
+
+ src = inspect.getsource(fml.backfill_houses)
+ assert "db.commit()" not in src
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# backfill_houses — dry_run vs real-run (MagicMock db)
+# ─────────────────────────────────────────────────────────────────────────────
+def test_backfill_houses_real_run_reads_rowcount() -> None:
+ db = MagicMock()
+ db.execute.return_value.rowcount = 17
+
+ out = fml.backfill_houses(db, dry_run=False)
+
+ assert out == {"houses_updated": 17}
+ sql_text = str(db.execute.call_args[0][0])
+ assert "UPDATE houses" in sql_text
+ bind_params = db.execute.call_args[0][1]
+ assert bind_params["ymin"] == fml.YEAR_BUILT_MIN
+ assert bind_params["ymax"] == fml.plausible_year_max()
+ assert bind_params["area_land_max"] == fml.AREA_LAND_MAX_BOUND
+
+
+def test_backfill_houses_dry_run_only_counts_no_update() -> None:
+ db = MagicMock()
+ db.execute.return_value.scalar_one.return_value = 9
+
+ out = fml.backfill_houses(db, dry_run=True)
+
+ assert out == {"would_update": 9, "houses_updated": 0}
+ sql_text = str(db.execute.call_args[0][0]).upper()
+ assert "UPDATE" not in sql_text
+ assert "SELECT COUNT(*)" in sql_text
+
+
+def test_duplicate_houseguid_rows_are_merged_field_wise(tmp_path: Path) -> None:
+ """Дубли houseguid в источнике взаимодополняющи — слить, а не затереть.
+
+ Реальная выгрузка региона 66 (2026-09-01): 41 790 строк, 912 повторяющихся
+ houseguid = 968 лишних строк, причём у одной строки пары заполнен area_total,
+ а у парной — нет. «Последняя побеждает» теряла бы эти значения.
+ """
+ header = "houseguid;address;built_year;area_total;wall_material"
+ csv_text = "\n".join(
+ [
+ header,
+ "6809f339-cf37-4e2c-b689-7ded226a9e51;обл. Свердловская, г. Алапаевск;1960;1698,20;",
+ "6809f339-cf37-4e2c-b689-7ded226a9e51;обл. Свердловская, г. Алапаевск;;;Кирпич",
+ ]
+ )
+ path = tmp_path / "dup.csv"
+ path.write_text(csv_text, encoding="utf-8-sig")
+
+ rows = fml.parse_frt_mkd_csv(path, region_code=66)
+
+ assert len(rows) == 1
+ row = rows["6809f339-cf37-4e2c-b689-7ded226a9e51"]
+ assert row.built_year == 1960
+ assert row.area_total is not None
+ assert row.wall_material == "Кирпич"
diff --git a/tradein-mvp/uv.lock b/tradein-mvp/uv.lock
index 828bd97f..91ebc99f 100644
--- a/tradein-mvp/uv.lock
+++ b/tradein-mvp/uv.lock
@@ -376,6 +376,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" },
]
+[[package]]
+name = "et-xmlfile"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
+]
+
[[package]]
name = "fastapi"
version = "0.139.0"
@@ -607,6 +616,62 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
+[[package]]
+name = "ijson"
+version = "3.5.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3a/06/b31f040a8764336a11152e474a7abcb3782fedb0d1cdf78f442b82878c56/ijson-3.5.1.tar.gz", hash = "sha256:af40bd1a85f55db0b8b30715c858761306bd92d5590148636f75c3309e6e76bd", size = 69913, upload-time = "2026-07-06T17:37:42.923Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5b/6e/f3ded1ebb85ccc89a30f7b10a0076f30db70ae1d1e0b6423ff93c57b7539/ijson-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee60c7741012671867678eae71c51872cac938b76f3d4ca40a778e6c361774d2", size = 88643, upload-time = "2026-07-06T17:36:28.529Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/f2/18f14a1d79ef4898e746b4f50dcdbe60abab317cc2bd8390f043b9553c4e/ijson-3.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:11c1d7d36a13054b5872ecd5d745dc4009d9abdbcba2312de69e66c2f92a46d2", size = 60611, upload-time = "2026-07-06T17:36:29.597Z" },
+ { url = "https://files.pythonhosted.org/packages/30/c7/6e3e591324fd4c7a7a9e1bc23548bacbd84c0d91766b71f09f13e945e7e9/ijson-3.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9517efbe6604bce16f3e50d49b0cd1bdc58917f98cf2eab026599c5c0422991", size = 60447, upload-time = "2026-07-06T17:36:30.747Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/a5/9af7be670381ddac26dd55107ed0110b50f5161673b053311db67f510dcc/ijson-3.5.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ea4fd7bec203a600b1cc88a492dfe6b75ce4b1b87488a66adcd5406022213f64", size = 139092, upload-time = "2026-07-06T17:36:31.749Z" },
+ { url = "https://files.pythonhosted.org/packages/41/fb/f9c1664d75467453e6bd4e5f9cd2211b730b09e049445ab64cbac68cc6a3/ijson-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350caea815e53151994b597abc80cf669454276b5ac6aadcec69ef6d48f7e90b", size = 149921, upload-time = "2026-07-06T17:36:32.912Z" },
+ { url = "https://files.pythonhosted.org/packages/43/80/d20b1c49c4aa7cc6644131e2e57192b45346ef4816566ed1cd9fd05bae38/ijson-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4fcebfe1685bb7ba06a8255a5d428ea6b4b895d7acf979cb637d8bbc9db2f47", size = 149848, upload-time = "2026-07-06T17:36:34.032Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/fc/5baa710869f5ab939e6233583ced1546889b55c35f35b844c518ac10abc3/ijson-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d78f362f51c8691798758a9e6ac3c9d385ee1228cb82987c91562a2fae235cd3", size = 150810, upload-time = "2026-07-06T17:36:35.19Z" },
+ { url = "https://files.pythonhosted.org/packages/54/16/a12b3d987a5c1677b04557c6f9b9feb7e04b7d4171e9a344856cb9136e9b/ijson-3.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0b184180d45f85fd4479659582749b109e49f4a29c21ac700ccc9c2280fe015e", size = 142989, upload-time = "2026-07-06T17:36:36.23Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/63/1026c535671fc334fc85aeb78f0945c825e7a338575edc753c0f455459ae/ijson-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e353891d33a2e6aa5caf72c2a5fbadd7a46f5f9b32dcfd0c84113b2444c255b8", size = 151702, upload-time = "2026-07-06T17:36:37.296Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/af/b58aa3a2bf4d31c388ea78b49826605f60932891ce97e404d196766b4ea3/ijson-3.5.1-cp312-cp312-win32.whl", hash = "sha256:936f28671f018f8ac4d3f003ae9fa01d0467ab4ef4cfd0c97f23beda485b61c6", size = 52613, upload-time = "2026-07-06T17:36:38.345Z" },
+ { url = "https://files.pythonhosted.org/packages/04/66/ce70a92949c2a753dad91fdd5761dc14f3a44517e80cfc3c26612982ed61/ijson-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:322c783f3ee0c6b383bbd4db88370b10172168808cc2a0bf811f1253f7435602", size = 54729, upload-time = "2026-07-06T17:36:39.337Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/ff/e17784240c9cf1d58de2f2853ebaf9cc54f6bce117a1f12a6150bbb4a5aa/ijson-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:e2ac204b59f09e38e16d277f906240e9fd38780e42076599419265af183dc4b4", size = 53714, upload-time = "2026-07-06T17:36:40.308Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/c0/5384ccf4fc497ae3dc79a5a28561b05518b503ade29daf3898168d640406/ijson-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3c0556d628443d3e871f414855313b2ae6cd9faa0104de3316bd8db03aab1589", size = 88652, upload-time = "2026-07-06T17:36:41.278Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/42/58769b8b6d614adb15c2c938c77bcdbfadfba8b1d21a98b5b09cb8961adc/ijson-3.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:12aa7fcf46f0fdc8e9e7cf37541e1dc20ac3f9243a23f4d346ab5395f72b0fe2", size = 60607, upload-time = "2026-07-06T17:36:42.697Z" },
+ { url = "https://files.pythonhosted.org/packages/db/4a/8322c2824c24184880587bbca45531127a21a4b3bfc897f13427fea02424/ijson-3.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a96066d8c12a18ce2fa90579f2bbf991377cb71725874932e4a5d855226c162a", size = 60447, upload-time = "2026-07-06T17:36:43.791Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/43/7bdca8f733c45ce97f61a64fadd3e51d255c4c9b467345cbf71ccc7bb368/ijson-3.5.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a19413a092d458a57aaa574fec08e265851d3b5c6e018377f426cd5e70b91280", size = 138889, upload-time = "2026-07-06T17:36:45.081Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/dc/e8a2e63700ab1d63aaf3fa38c454f8178eaa5b80a6d7c019d1d61b490a6c/ijson-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65974568748678165d7e90e3e7ce2f7c233cfe4de6c37fbb0760941c97e14632", size = 149933, upload-time = "2026-07-06T17:36:46.312Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/56/640a4d980f7f2c11e399a7fd5ccb9e3d3c9e1dec3a1d5a10024570697c25/ijson-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bad5d55c99c89de8cd0a4cded51f86427ba3353c4dccca37ec2e32e06f26b437", size = 149857, upload-time = "2026-07-06T17:36:47.309Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/a1/c953e22c83992b69ae538a83b3678d28768f1a48042fc7794733423a5ce7/ijson-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1a38d503ce343952e88edfd9a27296a4ec96af7073a9db58b3df6233367f75fc", size = 151141, upload-time = "2026-07-06T17:36:48.405Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/ab/8fe5b7269b140e6e5f8837a33ce980fd9b67c70d0f8114289ed1cea4dace/ijson-3.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2f41982c73896acab4a2a14faa14e152e444bd69f37c3139204429fd3fe65a10", size = 143112, upload-time = "2026-07-06T17:36:50.353Z" },
+ { url = "https://files.pythonhosted.org/packages/78/f3/23d1284edcde50ba337ddfba5b5d59f8273084d98b28af94715e73dd2b64/ijson-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3321fede2b638d400de0036889a3a25c3bb689feb8df45e70a393346aad6194f", size = 152184, upload-time = "2026-07-06T17:36:51.536Z" },
+ { url = "https://files.pythonhosted.org/packages/82/4e/df61be89dd295e4da722ec96ba03b1765bcb2becdaaaede9c96a7d2365b6/ijson-3.5.1-cp313-cp313-win32.whl", hash = "sha256:af6ddbd10ac9bce87a835f2de3ec61455ec435c54e7e0ba7b17c31c66de6f164", size = 52607, upload-time = "2026-07-06T17:36:52.596Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/d9/03e5dbd3ef7e0cee06fbef0f87b91d7ce1c07fae9b5a1b0ca8b895de62c4/ijson-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:1de3de278b0ffb40338374ad2a730e1c56f933e0706b1815ebeb07b82239b1a3", size = 54730, upload-time = "2026-07-06T17:36:53.526Z" },
+ { url = "https://files.pythonhosted.org/packages/38/30/4f37076c88a96a1a5e44df38b59fade4f59eaef87ef8b5162d55b2d426d5/ijson-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:c8a36a19b92cb7172c6448ab94f446033cfa3129dc4894aebe205f96b3fabf42", size = 53719, upload-time = "2026-07-06T17:36:54.592Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/17/54f9180c0da9a9e96e5b3791bc74093f029a2344678b4da218c2699465bf/ijson-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21e1a250b254edba2f0dd7272a4c56f0a879aabe328d9e306dd1fc115f560e74", size = 89223, upload-time = "2026-07-06T17:36:55.534Z" },
+ { url = "https://files.pythonhosted.org/packages/09/70/0ee0d2627c534174455a745ca25284797e71b0d6e2b2a1b31cc914e7b462/ijson-3.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e01f95433725e2df62d682ff88e4a57bb694385ff2362bc364adec961167ae04", size = 60831, upload-time = "2026-07-06T17:36:56.554Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/e6/56f64ba7a3e7a25d9a9fbbeb4c30597d6b76c1094cc2041d11a3224b562c/ijson-3.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:539e8d6cca079bcbb68c390e55148f908e0a943a34f7dd321248637c6272adca", size = 60752, upload-time = "2026-07-06T17:36:57.826Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/2b/5a55db881f1b043cd6d5716578937a60ac16348be1a3afbf846b21cf4b44/ijson-3.5.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:32f64051be2f990d8ae7b614b5abdf4a7bead510ce3666568d7403c6c46ce4d8", size = 140783, upload-time = "2026-07-06T17:36:58.984Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/61/f7783cc18672dc31544141139efd187fb34795d24e573fed6abea6b776c7/ijson-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd0dfc5a788d0b0c2f1eab258b9dabdeefc631ca8ef87644a999f633b0b2555a", size = 149976, upload-time = "2026-07-06T17:37:00.235Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/d6/4182dd63b6b70eae4f5208c53558a050895a40734dff283463033c153742/ijson-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42bfda7858d99ee9777ec28cb6d347928249eefeb577f9b0a67503c18f7ebb6a", size = 149317, upload-time = "2026-07-06T17:37:01.476Z" },
+ { url = "https://files.pythonhosted.org/packages/01/b1/a675e4a9b428a0ef556e7d718bf0e6885e3e5543042248a1a7030899a3d4/ijson-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c4b9a28e9719d1aebebe93ad8dc2ba87f4e2d9035043b196c1c07ef8530b44cc", size = 150555, upload-time = "2026-07-06T17:37:02.676Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/69/52686f56b44af63a93c3dc3f5bcfa07f87427d9aea4d2cbe3e1c94188c74/ijson-3.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9a0b25c750a6bde14a0b31f1dcbfc86368e50767e3eaa73bb138e54128055edd", size = 144485, upload-time = "2026-07-06T17:37:03.779Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/46/10554e817dde56300a8414e52c0f5a44a29f3440327cd6d829ece57759b3/ijson-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bd756f7b22df745ac14b7bc2ab9ed7c190a222e4c8e1bef26ef1162af8e54d0f", size = 151470, upload-time = "2026-07-06T17:37:04.901Z" },
+ { url = "https://files.pythonhosted.org/packages/91/82/f37cbb110b48abdb623d169d0e196f2f6e064e2c20fa789ecde6e69b0440/ijson-3.5.1-cp314-cp314-win32.whl", hash = "sha256:e035cdfb2a1446b13881f0dfc0eecd1541cbb17a27a938ded2160ae6ce25051b", size = 53219, upload-time = "2026-07-06T17:37:06.254Z" },
+ { url = "https://files.pythonhosted.org/packages/00/58/792df8f001c246c8ff28f860de81d35ea0d797c0d3276c22a2af83089656/ijson-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:eeb2fb2daa5dd30326f93db465d0855b34aa6b1f52a7c0ff94522aec5ad57dfb", size = 55485, upload-time = "2026-07-06T17:37:07.242Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/3c/db3ccc22c09ed4738787e8d82fff76101aa81ec8de7eaf6572e065e012d3/ijson-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:a96ab35d7ce2129dfde49c4c807596443410e260d7f7a4ca8fe4d0035553b589", size = 54390, upload-time = "2026-07-06T17:37:08.497Z" },
+ { url = "https://files.pythonhosted.org/packages/26/59/eefa5d9488250c03f24152576804205ae40e29cac0dc65cbbc5f3d422008/ijson-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:77b68e91f95fb16ac2e7819903cd545db6cffa308c28833cc34911e6b21e91dd", size = 93177, upload-time = "2026-07-06T17:37:09.71Z" },
+ { url = "https://files.pythonhosted.org/packages/88/db/6329eb7bb9f1906c1906fc10e7074b8f08bf39b7d50baa58f1b597d48898/ijson-3.5.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:94a95065b1ac67602af0cec852b07505abc37b77e3774d1c801d935d05e48f82", size = 62891, upload-time = "2026-07-06T17:37:10.735Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/d0/b3beddb96eef0b20bb9902c36e4de30f145be06d7e5e1d780e1a1689d0ce/ijson-3.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b70b5da6b0571da8f601a437c4fba2d35bc27739637d85f3acdc8f88916ce68e", size = 62575, upload-time = "2026-07-06T17:37:11.681Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/01/95f3a7c27d25bb917954ef0c8e86d0e60f585b9db675cbd05d355f54cce8/ijson-3.5.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0ade373dd765b057b1dec05d7711bfeb5a36f1e825259466d9f545cfd8ef3ba3", size = 200568, upload-time = "2026-07-06T17:37:12.743Z" },
+ { url = "https://files.pythonhosted.org/packages/77/61/c94ee4ea1f22318aab9a49b35d0ce8ac87dd24d508ea4c77dcbde362ba5e/ijson-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:882bc0bdd25d41eae90a15695cd50707edde0978b8b72a2532e30442dd8fd04c", size = 217956, upload-time = "2026-07-06T17:37:14.041Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/82/43e8d225aea5ee00eef7998c8ce41f344f7ba451329dfa9e92f4700813af/ijson-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:451901c36e12fa87cbb1cafe661bd25c08c6bd7900cc738279614f71cea07048", size = 208403, upload-time = "2026-07-06T17:37:15.201Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/6f/375f67fad76677aca9bc0817b2b18fdd231d309fe24e26b19a5556ef6cdd/ijson-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3c5f660658f2ebfba5d4dfe4bafe8cd3a0defcda410ec08d2205fe08c398940", size = 211967, upload-time = "2026-07-06T17:37:16.484Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/53/4c754c3ba18ec70b7086b91a4abd368358fc47cc9b3871afd50deef4fea1/ijson-3.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:29eb8f0c77a296a10843a1714ad4a5d561e604cda3c88585e9012cf2c1729b0a", size = 201020, upload-time = "2026-07-06T17:37:18.017Z" },
+ { url = "https://files.pythonhosted.org/packages/26/2d/3e7191b3222a31c378b827565b4fa64676a293441279f84db3d971720bf5/ijson-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85997568d6b304cfa59d5c3f2b04f95b92e9a8c7f57d312343a7989cf8dfff85", size = 205584, upload-time = "2026-07-06T17:37:19.343Z" },
+ { url = "https://files.pythonhosted.org/packages/24/11/55ae9c915e68f37c8698f8b09355071dc808ced5e9d4abf8238dc363f500/ijson-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:c2e2509dc7f2fa5a2ac9ba7d15dd901f4093bd36b0784f65e04b681b7956651c", size = 54438, upload-time = "2026-07-06T17:37:20.656Z" },
+ { url = "https://files.pythonhosted.org/packages/96/df/5bf2656447f14a923d25a0401b1cd628ca05c23041d3a4c116ae8d44dc39/ijson-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2699e838099d056818c5f8e4ba702b345d0304e58847bdc79c5c1616d5d750a5", size = 56467, upload-time = "2026-07-06T17:37:21.615Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/e4/dec06e84fac704039625039c6b116a44f17ad72fda48b8f88a2493364b77/ijson-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:c388f85cbb9eec022b2bdedd23ffacfe7ab100c1200b1f47bee6e6ea2c3309fa", size = 55774, upload-time = "2026-07-06T17:37:22.958Z" },
+]
+
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -983,6 +1048,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/6a/d3a169aaf8536cf228d56a09e04bcb713a2fe4410d4e2105b9419b5a9c89/numpy-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865", size = 10686451, upload-time = "2026-06-21T20:57:49.313Z" },
]
+[[package]]
+name = "openpyxl"
+version = "3.1.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "et-xmlfile" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
+]
+
[[package]]
name = "packaging"
version = "26.2"
@@ -1689,9 +1766,11 @@ dependencies = [
{ name = "fastapi" },
{ name = "geoalchemy2" },
{ name = "httpx", extra = ["socks"] },
+ { name = "ijson" },
{ name = "jinja2" },
{ name = "lxml" },
{ name = "matplotlib" },
+ { name = "openpyxl" },
{ name = "pillow" },
{ name = "playwright" },
{ name = "prometheus-client" },
@@ -1726,9 +1805,11 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "geoalchemy2", specifier = ">=0.15.0" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.27.0" },
+ { name = "ijson", specifier = ">=3.3.0" },
{ name = "jinja2", specifier = ">=3.1.0" },
{ name = "lxml", specifier = ">=5.0.0" },
{ name = "matplotlib", specifier = ">=3.9.0" },
+ { name = "openpyxl", specifier = ">=3.1.0" },
{ name = "pillow", specifier = ">=10.3.0" },
{ name = "playwright", specifier = ">=1.45" },
{ name = "prometheus-client", specifier = ">=0.21.0" },