From 8aca820a464187702c7674d0b07f28fb28bc03ff Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 9 Sep 2026 00:32:20 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(mera):=20=D0=B7=D0=B0=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=B7=D1=87=D0=B8=D0=BA=20=D1=80=D0=B5=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B0=20=D0=9C=D0=9A=D0=94=20=D0=90=D0=98=D0=A1=20=D0=A4?= =?UTF-8?q?=D0=A0=D0=A2=20=E2=86=92=20=D0=BE=D0=B1=D0=BE=D0=B3=D0=B0=D1=89?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20houses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Открытые данные АИС ППК «ФРТ» (бывш. Реформа ЖКХ), node 110 = реестр МКД региона 66: 41 790 строк, houseguid (ФИАС GUID) заполнен на 100% → join к houses.gar_house_guid без канонизации адреса. Анонимный GET, без ЕСИА. Заполняет ТОЛЬКО NULL-поля houses: year_built, material_walls, material_floors, total_floors, entrances, is_emergency, flat_count, heat_supply_type, gas_supply_type, hot_water + новые area_land, foundation_type, elevators_total. Замер по ЕКБ: wall_material 92.5% (было 75% от ДОМ.РФ), built_year 92.0% (было 86%), area_land 86.8% и is_emergency — признаков, которых не давал ни один из текущих источников. Осознанно НЕ мапится: - project_type → series_name: свободный текст и фактически дубль материала стен (пусто у 10 635 строк, «кирпичный» 1754, «нет данных» 1496); - energy_efficiency: решение миграции 284 + реальный класс лишь у ~10% домов (у 25 595 из 41 790 значение «Не присвоен»); - elevators_count → passenger_elevators: в источнике это ОБЩЕЕ число лифтов, в houses раздельно пассажирские и грузовые → отдельная колонка; - playground/sportsground: в источнике id справочника (498/499/500), не флаг. Дубликаты houseguid реальны и взаимодополняющи: 912 guid'ов, 968 лишних строк, у одной строки пары заполнен area_total, у парной нет. Строки СЛИВАЮТСЯ по полям (первое непустое побеждает), иначе бэкфилл терял бы данные ~2% домов. estimator.py не тронут: аналоги подбираются FROM listings без JOIN к houses, встраивание признаков в подбор когорты — отдельная задача. Миграции 290 (staging frt_mkd + колонки houses) и 291 (сид расписания, enabled=false, interval_days 30). robots.txt источника требует Crawl-delay 10. --- .../backend/app/services/frt_mkd_loader.py | 688 ++++++++++++++++++ .../backend/app/services/product_handlers.py | 44 ++ tradein-mvp/backend/app/tasks/frt_mkd_load.py | 127 ++++ .../backend/data/sql/290_frt_mkd_staging.sql | 100 +++ ...291_scrape_schedules_seed_frt_mkd_load.sql | 37 + .../backend/tests/test_frt_mkd_loader.py | 387 ++++++++++ 6 files changed, 1383 insertions(+) create mode 100644 tradein-mvp/backend/app/services/frt_mkd_loader.py create mode 100644 tradein-mvp/backend/app/tasks/frt_mkd_load.py create mode 100644 tradein-mvp/backend/data/sql/290_frt_mkd_staging.sql create mode 100644 tradein-mvp/backend/data/sql/291_scrape_schedules_seed_frt_mkd_load.sql create mode 100644 tradein-mvp/backend/tests/test_frt_mkd_loader.py 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 f6cb9b4a..34f31ba3 100644 --- a/tradein-mvp/backend/app/services/product_handlers.py +++ b/tradein-mvp/backend/app/services/product_handlers.py @@ -661,6 +661,49 @@ async def _job_domrf_kapremont_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], {}) + + # ── 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 @@ -833,6 +876,7 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]: "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"), "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/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/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 == "Кирпич" From 2abab8218ef0bf04624faacaad6df33b532d0af0 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 9 Sep 2026 00:32:36 +0300 Subject: [PATCH 2/2] =?UTF-8?q?feat(mera):=20=D0=BC=D0=B0=D0=BA=D1=80?= =?UTF-8?q?=D0=BE-=D1=80=D1=8F=D0=B4=D1=8B=20=D0=A6=D0=91=20=D0=A0=D0=A4?= =?UTF-8?q?=20=E2=80=94=20=D0=B8=D0=BF=D0=BE=D1=82=D0=B5=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=81=D1=83=D0=B1=D1=8A=D0=B5=D0=BA=D1=82=D0=B0?= =?UTF-8?q?=D0=BC=20=D0=B8=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=B2=D0=B0?= =?UTF-8?q?=D1=8F=20=D1=81=D1=82=D0=B0=D0=B2=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Три XLSX ЦБ (выдачи, ставка, задолженность) в разрезе субъектов, помесячно с 01.2019, + ключевая ставка через SOAP DailyInfo.asmx (метод KeyRate; GET на нём не работает, только POST). Всё анонимно, без ключа. Таблицы: cbr_mortgage_series (region, period_month, series) и cbr_key_rate. fetched_at НЕ обновляется в DO UPDATE — по уроку #2846 колонка значит «когда мы ВПЕРВЫЕ увидели период» и по ней меряется такт публикации источника. Раскладка листов у ЦБ РАЗНАЯ, и это ловушка: в 02_11/02_13 шапка периодов в строке 3 текстом («Январь 2019»), а в 02_14 (задолженность) — уже в строке 2 и настоящими datetime. С захардкоженным индексом строки серия debt_rub молча давала НОЛЬ строк при зелёных тестах. Теперь строка шапки ищется динамически: берётся строка с наибольшим числом распознанных периодов среди первых шести. Проверено на живых файлах: каждая из трёх серий даёт 8 736 точек (96 территорий × 91 месяц, 01.2019–07.2026); по Свердловской области 91 месяц, последняя ставка 11.49. estimator.py не тронут: как ипотечная ставка войдёт в оценку — отдельное продуктовое решение, этот PR только про данные. Миграции 292 (таблицы) и 293 (сид расписания, enabled=false, interval_days 7). Новая зависимость: openpyxl. --- tradein-mvp/backend/app/services/cbr_macro.py | 464 ++++++++++++++++++ .../backend/app/services/product_handlers.py | 38 ++ .../backend/app/tasks/cbr_macro_pull.py | 95 ++++ .../backend/data/sql/292_cbr_macro_series.sql | 87 ++++ ...3_scrape_schedules_seed_cbr_macro_pull.sql | 41 ++ tradein-mvp/backend/pyproject.toml | 1 + tradein-mvp/backend/tests/test_cbr_macro.py | 380 ++++++++++++++ tradein-mvp/uv.lock | 23 + 8 files changed, 1129 insertions(+) create mode 100644 tradein-mvp/backend/app/services/cbr_macro.py create mode 100644 tradein-mvp/backend/app/tasks/cbr_macro_pull.py create mode 100644 tradein-mvp/backend/data/sql/292_cbr_macro_series.sql create mode 100644 tradein-mvp/backend/data/sql/293_scrape_schedules_seed_cbr_macro_pull.sql create mode 100644 tradein-mvp/backend/tests/test_cbr_macro.py 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/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py index f6cb9b4a..f0e154e6 100644 --- a/tradein-mvp/backend/app/services/product_handlers.py +++ b/tradein-mvp/backend/app/services/product_handlers.py @@ -661,6 +661,43 @@ async def _job_domrf_kapremont_load( 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 @@ -833,6 +870,7 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]: "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"), + "cbr_macro_pull": Handler(_job_cbr_macro_pull, "cbr_macro_pull"), "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/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/pyproject.toml b/tradein-mvp/backend/pyproject.toml index beaf308b..e8857222 100644 --- a/tradein-mvp/backend/pyproject.toml +++ b/tradein-mvp/backend/pyproject.toml @@ -31,6 +31,7 @@ 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) "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/uv.lock b/tradein-mvp/uv.lock index 828bd97f..6e9ed147 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" @@ -983,6 +992,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" @@ -1692,6 +1713,7 @@ dependencies = [ { name = "jinja2" }, { name = "lxml" }, { name = "matplotlib" }, + { name = "openpyxl" }, { name = "pillow" }, { name = "playwright" }, { name = "prometheus-client" }, @@ -1729,6 +1751,7 @@ requires-dist = [ { 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" },