From f9e045028c8e799a7159e00af48d7904a234cea4 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Tue, 9 Jun 2026 11:28:05 +0500 Subject: [PATCH] =?UTF-8?q?feat(macro):=20land=20construction=20price=20in?= =?UTF-8?q?dex=20(=D0=A1=D0=9C=D0=A0)=20via=20open=20Rosstat=20xlsx=20(#94?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last #946 gap (СМР). Fetches «Индексы цен производителей на строительную продукцию по РФ» from the OPEN Rosstat xlsx (rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_MM-YYYY.xlsx), parses the monthly «к предыдущему месяцу» section → macro_indicator (indicator_type='construction_price_index', region='rf', monthly, %). The reCAPTCHA-gated fedstat dataGrid.do path is DELIBERATELY NOT used — the open Rosstat source needs no captcha. EMISS id=31108 documented as a prod-only SDMX alternative (not wired). URL is date-derived with bounded look-back (filename changes monthly; _fetch_construction_latest walks current month back to 6, first 200 wins; year-boundary handled, exhaustion raises, 404 breaks fast — no hang). Wired into rosstat_macro_sync with per-source try/except (can't break demography/income) + SAVEPOINT-per-row. None-not-0 on blank months. 33 tests (incl look-back + year-boundary + graceful), ruff clean. Note: no construction-cost channel in macro_coefficient yet (demand-side only) — series is ingested/available for future cost-side use + display. Refs #946 --- .../app/services/scrapers/rosstat_emiss.py | 290 +++++++++++++++++- .../app/workers/tasks/rosstat_macro_sync.py | 101 +++++- .../rosstat_construction_stroitel.xlsx | Bin 0 -> 6239 bytes backend/tests/scrapers/test_rosstat_emiss.py | 143 +++++++++ .../tests/workers/test_rosstat_macro_sync.py | 86 ++++++ 5 files changed, 601 insertions(+), 19 deletions(-) create mode 100644 backend/tests/fixtures/rosstat_construction_stroitel.xlsx diff --git a/backend/app/services/scrapers/rosstat_emiss.py b/backend/app/services/scrapers/rosstat_emiss.py index 239d70e7..181f4a8b 100644 --- a/backend/app/services/scrapers/rosstat_emiss.py +++ b/backend/app/services/scrapers/rosstat_emiss.py @@ -7,7 +7,7 @@ Celery-таска ``rosstat_macro_sync`` через SAVEPOINT-per-row. ──────────────────────────────────────────────────────────────────────────────── -ИСТОЧНИК ДАННЫХ — open-data + ЕМИСС-SDMX (разведано live 2026-06-07) +ИСТОЧНИК ДАННЫХ — open-data + ЕМИСС-SDMX + открытый xlsx (разведано live 2026-06-07/08) ──────────────────────────────────────────────────────────────────────────────── ЕМИСС / fedstat.ru — за WAF с DEV-машины (hard-403), но ДОСТУПЕН С ПРОДА (verified 2026-06-07: ``POST https://www.fedstat.ru/indicator/data.do`` body ``id=&format=sdmx`` @@ -30,9 +30,11 @@ Celery-таска ``rosstat_macro_sync`` через SAVEPOINT-per-row. id кода (НЕ значение ОКАТО), берётся отдельным filter-tree AJAX; плюс на странице индикатора reCAPTCHA. Это глубокий RE — отложено (закрывается тем же EmissIndicatorSpec + параметризацией data.do-выборки, когда добудем filter_id-маппинг). - • construction_price_index (индекс цен на СМР) — id не подтверждён: кандидаты - (31448 «Средние потреб. цены», 37426 «Еженедельные цены») — много-мерные и БЕЗ - Свердл в дефолт-экспорте (та же проблема, что у ИПЦ). НЕ landed. + • construction_price_index (индекс цен на СМР) — из ЕМИСС НЕ берётся (кандидат + EMISS id=31108 «Индексы цен производителей на строительную продукцию» доступен тем + же открытым SDMX-путём POST data.do, но fedstat за WAF с dev — не верифицируем + здесь). ПРИЗЕМЛЯЕТСЯ из ОТКРЫТОГО xlsx Росстата (см. блок «Индекс цен на СМР» ниже, + region='rf'), reCAPTCHA-gated dataGrid.do НАМЕРЕННО НЕ используется. ``rosstat.gov.ru/opendata`` — БЕЗ WAF, машиночитаем (российский стандарт открытых данных 4.0): @@ -50,9 +52,16 @@ Celery-таска ``rosstat_macro_sync`` через SAVEPOINT-per-row. муниципальный срез; берём субъект-агрегат ОКТМО ``6500000000`` + г. Екатеринбург ``657010000011000``). Это § 7.11 «демография (численность)». -Что НЕ приземляется из open-data (но среднедушевые доходы теперь идут через ЕМИСС-прод): - • ИПЦ, индекс цен на СМР — EMISS-only и в open-data их нет (0 совпадений по реестру); - из ЕМИСС пока не landed (много-мерные, см. EMISS_INDICATORS-блок выше). +Индекс цен на СМР приземляется из ОТКРЫТОГО xlsx Росстата (НЕ из opendata-реестра — там +0 совпадений; и НЕ из ЕМИСС/dataGrid.do): + • construction_price_index — «Индексы цен производителей на строительную продукцию по + РФ» (region='rf', месячный, к предыдущему месяцу, %). Прямая ссылка на xlsx со + страницы rosstat.gov.ru «Цены»; reCAPTCHA-gated dataGrid.do НАМЕРЕННО НЕ используется + (см. блок «Индекс цен на СМР» ниже + HARD SAFETY CONSTRAINT #946). + +Что НЕ приземляется из open-data (но среднедушевые доходы идут через ЕМИСС-прод): + • ИПЦ — EMISS-only и в open-data его нет (0 совпадений по реестру); из ЕМИСС пока не + landed (много-мерный, см. EMISS_INDICATORS-блок выше). Standalone smoke (ручная проверка; open-data — c dev, ЕМИСС-fetch — только с прода): python -m app.services.scrapers.rosstat_emiss @@ -70,8 +79,11 @@ import xml.etree.ElementTree as ET from dataclasses import dataclass from datetime import date from decimal import Decimal, InvalidOperation +from io import BytesIO +from typing import Any import httpx +from openpyxl import load_workbook logger = logging.getLogger(__name__) @@ -83,6 +95,19 @@ ROSSTAT_OPENDATA_BASE = "https://rosstat.gov.ru/opendata" # dev (403), доступен с прода (см. шапку модуля). verify off — тот же российский гос-УЦ. FEDSTAT_DATA_URL = "https://www.fedstat.ru/indicator/data.do" +# Индекс цен на строительную продукцию (СМР) — ОТКРЫТЫЙ xlsx Росстата (БЕЗ captcha, +# БЕЗ dataGrid.do): страница «Цены» → «Индексы цен и средние цены строительной +# продукции» → «Индексы цен производителей на строительную продукцию по РФ». Имя файла +# несёт месяц-вёрстки (Invest_ind_stroitel_MM-YYYY.xlsx) и обновляется ежемесячно → +# URL НЕ хардкодим (иначе протух бы каждый месяц и ingest тихо перестал бы тянуться). +# Резолвим от текущей даты с look-back (Росстат публикует с лагом 1-3 мес): пробуем +# текущий месяц и назад до _CONSTRUCTION_MAX_LOOKBACK, берём первый 200 — см. +# _fetch_construction_latest. Доступен с dev (verify off, российский гос-УЦ). live 2026-06-08. +ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE = ( + "https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_{mm:02d}-{yyyy}.xlsx" +) +_CONSTRUCTION_MAX_LOOKBACK = 6 # мес назад от текущего (запас на лаг публикации Росстата) + # Браузероподобный UA: rosstat.gov.ru отдаёт open-data без WAF, но «голый» httpx-UA # иногда ловит 403 на edge-кэше — даём обычный desktop-Chrome UA (как nspd_client). USER_AGENT = ( @@ -637,8 +662,9 @@ INCOME_PER_CAPITA_SPEC = EmissIndicatorSpec( comment="ЕМИСС id=57039 'Среднедушевые денежные доходы населения' (#946 §7.11)", ) -# Активные ЕМИСС-индикаторы. ИПЦ (id=31074) / индекс цен на СМР пока НЕ здесь — -# многомерные, дефолт-экспорт data.do без Свердл, нужна dataGrid-выборка (см. шапку). +# Активные ЕМИСС-индикаторы. ИПЦ (id=31074) пока НЕ здесь — многомерный, дефолт-экспорт +# data.do без Свердл, нужна dataGrid-выборка (см. шапку). Индекс цен на СМР берётся НЕ из +# ЕМИСС, а из открытого xlsx Росстата (CONSTRUCTION_PRICE_SPEC ниже). EMISS_INDICATORS: tuple[EmissIndicatorSpec, ...] = (INCOME_PER_CAPITA_SPEC,) @@ -846,6 +872,239 @@ def fetch_all_emiss( return out +# ══════════════════════════════════════════════════════════════════════════════════ +# Индекс цен на строительную продукцию (СМР) — ОТКРЫТЫЙ xlsx Росстата (#946, EPIC2) +# ══════════════════════════════════════════════════════════════════════════════════ +# Источник (разведано live 2026-06-08, fetch работает С DEV): страница +# https://rosstat.gov.ru/statistics/price → раздел «строительная продукция» → +# xlsx ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE «Индексы цен производителей на строительную +# продукцию по РФ в 1995-2025 гг.». reCAPTCHA-gated dataGrid.do НЕ используется — +# это статический файл прямой ссылкой (см. HARD SAFETY CONSTRAINT в шапке #946). +# Форма листа «1» (широкий pivot, верифицировано на реальном файле): +# r3 — заголовок; r5 — «на конец периода, в %»; +# r6 — годы по колонкам (B..) c возможным сноска-маркером ('2022 1)' и т.п.); +# r7 — секция «К предыдущему месяцу» (то, что берём: ИСТИННЫЙ месячный ряд); +# r8..r19 — месяцы Январь..Декабрь, ячейка = индекс % за (месяц, год-колонка); +# r20 — секция «К декабрю предыдущего года» (НЕ берём — иной базис, годовой). +# Один (месяц, год) → одно наблюдение; obs_date = date(year, month, 1) — как и прочие +# месячные ряды нормализуем к 1-му числу. Регион = 'rf' (файл — общефедеральный). +# Будущие/неопубликованные месяцы текущего года приходят пустой ячейкой → None → скип +# (None-not-0: пропуск, НИКОГДА не подставляем 0). Парсер pure → dev-тестируем на фикстуре. + +# Заголовки секций листа: какую берём (месячная), какую игнорируем (годовая к декабрю). +_CONSTR_SECTION_MONTHLY = "к предыдущему месяцу" +_CONSTR_SECTION_TO_DEC = "к декабрю предыдущего года" + + +@dataclass(frozen=True) +class XlsxIndicatorSpec: + """Описание приземляемого ряда из открытого xlsx Росстата (широкий pivot год×месяц). + + Атрибуты: + url_template: шаблон ссылки на xlsx с плейсхолдерами {mm:02d}/{yyyy} + (ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE) — резолвится по дате в fetch. + sheet: имя листа с данными (для construction — '1'). + indicator_type: тип ряда в macro_indicator ('construction_price_index'). + region: метка region (общефедеральный файл → 'rf'). + unit: единица (для индекса цен — '%'; справочно, в PK не участвует). + frequency: 'monthly' — берём секцию «К предыдущему месяцу». + comment: пояснение для колонки comment macro_indicator. + """ + + url_template: str + sheet: str + indicator_type: str + region: str + unit: str + frequency: str + comment: str = "" + + +CONSTRUCTION_PRICE_SPEC = XlsxIndicatorSpec( + url_template=ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE, + sheet="1", + indicator_type="construction_price_index", + region="rf", + unit="%", + frequency="monthly", + comment=( + "Росстат 'Индексы цен производителей на строительную продукцию по РФ', " + "к предыдущему месяцу (#946 §7.11; СМР, open-data, без dataGrid.do/captcha)" + ), +) + + +def _parse_year_cell(raw: Any) -> int | None: + """Год из ячейки-заголовка → int. Допускает сноска-маркер ('2022 1)' → 2022) и + числовой год (openpyxl отдаёт int). Не-год / пусто → None (колонка пропускается).""" + if raw is None: + return None + if isinstance(raw, int): + return raw if 1900 <= raw <= 2100 else None + digits = str(raw).strip()[:4] + if digits.isdigit(): + year = int(digits) + return year if 1900 <= year <= 2100 else None + return None + + +def parse_construction_index_xlsx( + content: bytes, spec: XlsxIndicatorSpec +) -> list[MacroRow]: + """Pure-парсер открытого xlsx Росстата (индекс цен на СМР) → список MacroRow. + + Алгоритм (форма листа — см. блок выше): + 1. Открывает лист spec.sheet. Находит строку годов-заголовков (первая, где ≥2 + ячеек распарсились как год через _parse_year_cell) → карта колонка→год. + 2. Идёт по строкам ПОСЛЕ заголовка только внутри секции «К предыдущему месяцу» + (по достижении секции «К декабрю предыдущего года» / иной — останавливается). + Строка месяца (ячейка A ∈ _MONTH_NAME_TO_NUM) → для каждой год-колонки берёт + значение через _parse_value; пусто/нечисло → пропуск (None-not-0). + 3. obs_date = date(year, month, 1). Дубли по (region, obs_date) схлопываются. + + Graceful: пустой/битый xlsx, нет листа, нет строки годов → [] (+warning), не бросает. + """ + if not content: + return [] + try: + wb = load_workbook(BytesIO(content), read_only=True, data_only=True) + except Exception as e: # openpyxl бросает разные типы на битом файле + logger.warning("Rosstat construction: не удалось открыть xlsx: %s", e) + return [] + try: + if spec.sheet not in wb.sheetnames: + logger.warning("Rosstat construction: нет листа %r в xlsx", spec.sheet) + return [] + rows = list(wb[spec.sheet].iter_rows(values_only=True)) + finally: + wb.close() + if not rows: + return [] + + # 1. Строка годов: первая, где ≥2 ячеек (после A) распарсились как год. + year_by_col: dict[int, int] = {} + header_idx: int | None = None + for r_idx, row in enumerate(rows): + cols = {c: y for c in range(1, len(row)) if (y := _parse_year_cell(row[c]))} + if len(cols) >= 2: + header_idx, year_by_col = r_idx, cols + break + if header_idx is None: + logger.warning("Rosstat construction: не найдена строка годов-заголовков") + return [] + + # 2. Месяцы внутри секции «К предыдущему месяцу» (до начала годовой секции). + by_key: dict[tuple[str, date], MacroRow] = {} + in_monthly = False + for row in rows[header_idx + 1 :]: + if not row: + continue + label = str(row[0] or "").strip().lower() + if label == _CONSTR_SECTION_MONTHLY: + in_monthly = True + continue + if label == _CONSTR_SECTION_TO_DEC or (label and "к декабрю" in label): + break # дальше идёт годовой базис — нам не нужен + month = _MONTH_NAME_TO_NUM.get(label) + if month is None or not in_monthly: + continue + for col, year in year_by_col.items(): + if col >= len(row): + continue + value = _parse_value(str(row[col]) if row[col] is not None else "") + if value is None: + continue # пустой/будущий месяц → пропуск (None-not-0) + obs_date = date(year, month, 1) + by_key[(spec.region, obs_date)] = MacroRow( + indicator_type=spec.indicator_type, + region=spec.region, + obs_date=obs_date, + value=value, + unit=spec.unit, + comment=spec.comment, + ) + + return [by_key[k] for k in sorted(by_key, key=lambda k: (k[0], k[1]))] + + +def _fetch_construction_latest( + client: httpx.Client, + *, + url_template: str, + today: date, + retries: int = DEFAULT_RETRIES, + max_lookback: int = _CONSTRUCTION_MAX_LOOKBACK, +) -> bytes: + """Тело свежайшего xlsx индекса цен на СМР: резолв от today с look-back по месяцам. + + Имя файла Росстата несёт месяц-вёрстки (Invest_ind_stroitel_MM-YYYY.xlsx) и меняется + ежемесячно с лагом публикации. Пробуем текущий месяц и назад до max_lookback мес — + возвращаем тело первого ответившего 200 (404 у _http_get ломается сразу, без ретрая, + поэтому отсутствующие месяцы дёшевы). dataGrid.do/captcha НЕ задействованы. + + Raises: + RosstatScraperError: если ни один кандидат (today..-max_lookback мес) не отдал 200. + """ + year, month = today.year, today.month + last_exc: Exception | None = None + for _ in range(max_lookback + 1): + url = url_template.format(mm=month, yyyy=year) + try: + return _http_get(client, url, retries=retries) + except RosstatScraperError as exc: + last_exc = exc + logger.info("Rosstat СМР xlsx нет за %02d-%d, пробуем предыдущий месяц", month, year) + month -= 1 + if month == 0: + month, year = 12, year - 1 + raise RosstatScraperError( + f"Rosstat СМР xlsx: не найден за {max_lookback + 1} мес от {today.isoformat()}: {last_exc}" + ) + + +def fetch_construction_index( + spec: XlsxIndicatorSpec = CONSTRUCTION_PRICE_SPEC, + *, + today: date | None = None, + timeout_s: float = DEFAULT_TIMEOUT_S, + retries: int = DEFAULT_RETRIES, + client: httpx.Client | None = None, +) -> list[MacroRow]: + """Загрузить открытый xlsx Росстата (индекс цен на СМР) → список MacroRow. + + URL резолвится по дате с look-back (имя файла меняется ежемесячно — см. + _fetch_construction_latest), затем pure-парсинг. Работает С DEV (verify off, + российский гос-УЦ — как и прочие rosstat.gov.ru-эндпоинты). dataGrid.do/captcha + НЕ задействованы. today=None → date.today() (инъекция для тестов). + + Raises: + RosstatScraperError: на сетевой/HTTP-ошибке после ретраев / если файл не найден. + """ + own_client = client is None + if client is None: + client = httpx.Client( + timeout=timeout_s, + headers={"User-Agent": USER_AGENT, "Accept-Language": "ru-RU,ru;q=0.9"}, + follow_redirects=True, + verify=_VERIFY_TLS, + ) + try: + content = _fetch_construction_latest( + client, url_template=spec.url_template, today=today or date.today(), retries=retries + ) + rows = parse_construction_index_xlsx(content, spec) + logger.info( + "Rosstat construction (%s): распарсено %d месячных наблюдений (region=%s)", + spec.indicator_type, + len(rows), + spec.region, + ) + return rows + finally: + if own_client: + client.close() + + # ── standalone smoke ──────────────────────────────────────────────────────────── @@ -867,6 +1126,19 @@ def _main() -> None: if not rows: logger.warning("No rows — проверь доступность rosstat.gov.ru/opendata.") + # Индекс цен на СМР — открытый xlsx, тоже доступен с dev (печатаем хвост ряда). + constr = fetch_construction_index() + logger.info("Rosstat construction rows fetched: %d", len(constr)) + for r in constr[-6:]: + logger.info( + " %s / %s @ %s = %s %s", + r.indicator_type, + r.region, + r.obs_date.isoformat(), + r.value, + r.unit, + ) + if __name__ == "__main__": _main() diff --git a/backend/app/workers/tasks/rosstat_macro_sync.py b/backend/app/workers/tasks/rosstat_macro_sync.py index 81dcc0ab..3cbec22a 100644 --- a/backend/app/workers/tasks/rosstat_macro_sync.py +++ b/backend/app/workers/tasks/rosstat_macro_sync.py @@ -1,15 +1,20 @@ """Celery task: синхронизация макро-показателей Росстата в ``macro_indicator`` (#946, EPIC2 «macro-ingest», GG-форсайт / Site Finder v2). -Тянет региональные ряды Росстата для Свердловской обл. из ДВУХ источников и апсертит +Тянет региональные/федеральные ряды Росстата из ТРЁХ источников и апсертит каждую обсервацию в ``macro_indicator``: • open-data (``rosstat_emiss.fetch_all_datasets``) — демография ``population_total`` (ОКТМО 65 / ЕКБ), ``source='rosstat'``, frequency='yearly'; • ЕМИСС / fedstat (``rosstat_emiss.fetch_all_emiss``) — ``income_per_capita`` - (среднедушевые доходы, ОКАТО 65), ``source='emiss'``, frequency='quarterly'. + (среднедушевые доходы, ОКАТО 65), ``source='emiss'``, frequency='quarterly'; + • открытый xlsx Росстата (``rosstat_emiss.fetch_construction_index``) — + ``construction_price_index`` (индекс цен на СМР, region='rf'), ``source='rosstat'``, + frequency='monthly'. ОТКРЫТЫЙ источник (без dataGrid.do / reCAPTCHA), fetch с dev. Каждый источник в своём try/except (per-source guard): сбой одного НЕ роняет другой и не валит таску — частичная поставка допустима (ТЗ §7.11). ЕМИСС-fetch доступен только -с прода (fedstat за WAF с dev); ИПЦ/индекс цен на СМР пока не landed (см. rosstat_emiss). +с прода (fedstat за WAF с dev); индекс цен на СМР теперь landed через открытый +rosstat.gov.ru-xlsx (reCAPTCHA-gated dataGrid.do НАМЕРЕННО НЕ используется); ИПЦ пока +не landed (см. rosstat_emiss). Детерминированно, без LLM. ON CONFLICT делает повторные прогоны идемпотентными (re-run обновляет value + updated_at). Расписание — ежемесячно (эти ряды @@ -35,6 +40,7 @@ from app.services.scrapers.rosstat_emiss import ( MacroRow, fetch_all_datasets, fetch_all_emiss, + fetch_construction_index, ) from app.workers.celery_app import celery_app @@ -88,6 +94,30 @@ UPSERT_EMISS_SQL = text( """ ) +# Индекс цен на СМР — открытый xlsx Росстата: source='rosstat' (rosstat.gov.ru-файл), +# НО frequency='monthly' (в отличие от open-data демографии — там фикс 'yearly'), +# поэтому frequency параметризована. MacroRow не несёт frequency-поля, подставляем +# литералом в bind. Тот же PK и ON CONFLICT-контракт. CAST(:x AS type) — psycopg v3. +UPSERT_ROSSTAT_MONTHLY_SQL = text( + """ + INSERT INTO macro_indicator ( + indicator_type, region, obs_date, value, + source, frequency, unit, comment + ) VALUES ( + CAST(:itype AS text), CAST(:region AS text), CAST(:d AS date), + CAST(:v AS numeric), + 'rosstat', 'monthly', CAST(:unit AS text), CAST(:comment AS text) + ) + ON CONFLICT (indicator_type, region, obs_date) DO UPDATE SET + value = EXCLUDED.value, + source = EXCLUDED.source, + frequency = EXCLUDED.frequency, + unit = EXCLUDED.unit, + comment = EXCLUDED.comment, + updated_at = now() + """ +) + def _upsert_rows(db: Session, rows: list[MacroRow]) -> int: """Апсертит MacroRow в macro_indicator. SAVEPOINT per-row, чтобы один битый @@ -121,6 +151,39 @@ def _upsert_rows(db: Session, rows: list[MacroRow]) -> int: return upserted +def _upsert_monthly_rows(db: Session, rows: list[MacroRow]) -> int: + """Апсертит месячные MacroRow (индекс цен на СМР) — source='rosstat', + frequency='monthly'. SAVEPOINT per-row, чтобы один битый ряд не откатывал всю + транзакцию. Возвращает число успешных upsert'ов.""" + upserted = 0 + for r in rows: + try: + with db.begin_nested(): + db.execute( + UPSERT_ROSSTAT_MONTHLY_SQL, + { + "itype": r.indicator_type, + "region": r.region, + "d": r.obs_date, + "v": r.value, + "unit": r.unit, + "comment": r.comment, + }, + ) + upserted += 1 + except Exception as e: + logger.warning( + "upsert rosstat-monthly %s/%s@%s=%s failed: %s", + r.indicator_type, + r.region, + r.obs_date, + r.value, + e, + ) + db.commit() + return upserted + + def _upsert_emiss_rows(db: Session, rows: list[EmissRow]) -> int: """Апсертит EmissRow в macro_indicator (source='emiss', frequency per-row). SAVEPOINT per-row, чтобы один битый ряд не откатывал всю транзакцию. Возвращает @@ -161,25 +224,27 @@ def _upsert_emiss_rows(db: Session, rows: list[EmissRow]) -> int: max_retries=2, ) def rosstat_macro_sync(self: Any) -> dict[str, Any]: - """Загрузить региональные ряды Росстата (open-data + ЕМИСС) и апсертить в macro_indicator. + """Загрузить ряды Росстата (open-data + ЕМИСС + xlsx-СМР) и апсертить в macro_indicator. Источники выполняются НЕЗАВИСИМО (per-source try/except): сбой одного источника логируется, апсертит здоровый источник, и в конце ошибка ПРОБРАСЫВАЕТСЯ (чтобы фейл был виден в Celery / GlitchTip, а не молча проглочен). Re-run идемпотентен (ON CONFLICT), - поэтому Celery-retry безопасно повторит оба источника. + поэтому Celery-retry безопасно повторит все источники. Returns: - Счётчики ``{"fetched": N, "upserted": M, "rosstat": ..., "emiss": ...}``. + Счётчики ``{"fetched": N, "upserted": M, "rosstat": ..., "emiss": ..., + "construction": ...}``. Raises: - Первую пойманную ошибку любого источника — ПОСЛЕ попытки обоих (surfaces в + Первую пойманную ошибку любого источника — ПОСЛЕ попытки всех трёх (surfaces в Celery/GlitchTip). Частичный сбой отдельного датасета/индикатора внутри источника - НЕ роняет источник (fetch_all_* graceful per-item). + НЕ роняет источник (fetch_all_* / fetch_construction_index graceful per-item). """ db = SessionLocal() first_error: Exception | None = None rosstat_fetched = rosstat_upserted = 0 emiss_fetched = emiss_upserted = 0 + constr_fetched = constr_upserted = 0 try: # ── Источник 1: Росстат open-data (демография) ────────────────────────── try: @@ -209,14 +274,30 @@ def rosstat_macro_sync(self: Any) -> dict[str, Any]: first_error = first_error or e logger.exception("rosstat_macro_sync: emiss source failed: %s", e) + # ── Источник 3: открытый xlsx Росстата (индекс цен на СМР, region='rf') ── + # Открытый источник (без dataGrid.do/captcha); месячный → _upsert_monthly_rows. + try: + constr_rows = fetch_construction_index() + constr_fetched = len(constr_rows) + constr_upserted = _upsert_monthly_rows(db, constr_rows) + logger.info( + "rosstat_macro_sync: construction upserted %d/%d rows", + constr_upserted, + constr_fetched, + ) + except Exception as e: + first_error = first_error or e + logger.exception("rosstat_macro_sync: construction source failed: %s", e) + if first_error is not None: raise first_error return { - "fetched": rosstat_fetched + emiss_fetched, - "upserted": rosstat_upserted + emiss_upserted, + "fetched": rosstat_fetched + emiss_fetched + constr_fetched, + "upserted": rosstat_upserted + emiss_upserted + constr_upserted, "rosstat": {"fetched": rosstat_fetched, "upserted": rosstat_upserted}, "emiss": {"fetched": emiss_fetched, "upserted": emiss_upserted}, + "construction": {"fetched": constr_fetched, "upserted": constr_upserted}, } finally: db.close() diff --git a/backend/tests/fixtures/rosstat_construction_stroitel.xlsx b/backend/tests/fixtures/rosstat_construction_stroitel.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..fa52e480ee1d082504c201291b6bcf18b656a0bf GIT binary patch literal 6239 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 VLV(Vd0RU9!FAN&A`UrkK{Rd^S^)Ubd literal 0 HcmV?d00001 diff --git a/backend/tests/scrapers/test_rosstat_emiss.py b/backend/tests/scrapers/test_rosstat_emiss.py index d3316a69..bb3c2c30 100644 --- a/backend/tests/scrapers/test_rosstat_emiss.py +++ b/backend/tests/scrapers/test_rosstat_emiss.py @@ -11,19 +11,32 @@ from __future__ import annotations from datetime import date from decimal import Decimal +from pathlib import Path + +import pytest from app.services.scrapers.rosstat_emiss import ( + CONSTRUCTION_PRICE_SPEC, OKTMO_EKATERINBURG, OKTMO_SVERDLOVSK_OBLAST, POPULATION_SPEC, DatasetSpec, RegionTarget, _parse_value, + _parse_year_cell, + parse_construction_index_xlsx, parse_dataset_rows, parse_meta_data_url, parse_meta_modified_year, ) +# Фикстура: обрезанный РЕАЛЬНЫЙ xlsx Росстата «Индексы цен производителей на строительную +# продукцию по РФ» (лист '1', секции «К предыдущему месяцу» 2022-2025 + «К декабрю +# предыдущего года»; снято live 2026-06-08 с Invest_ind_stroitel_*.xlsx). Декабрь-2025 +# намеренно ПУСТ (будущий/неопубликованный месяц → проверка None-not-0 пропуска). +_FIXTURES = Path(__file__).resolve().parent.parent / "fixtures" +CONSTRUCTION_XLSX = (_FIXTURES / "rosstat_construction_stroitel.xlsx").read_bytes() + # Реальный shape meta.csv Росстата (open-data 4.0): property,value; строка данных — # property 'data--structure-.csv' → абсолютный URL CSV-данных. FIXTURE_META = ( @@ -171,3 +184,133 @@ def test_oktmo_constants_used_in_spec() -> None: oktmos = {t.oktmo for t in POPULATION_SPEC.targets} assert OKTMO_SVERDLOVSK_OBLAST in oktmos assert OKTMO_EKATERINBURG in oktmos + + +# ── индекс цен на СМР (открытый xlsx Росстата) ──────────────────────────────────── + + +def test_parse_year_cell_plain_and_footnote() -> None: + """Год из ячейки: чистый int, строка-год и сноска-маркер ('2022 1)' → 2022).""" + assert _parse_year_cell(2024) == 2024 + assert _parse_year_cell("2022 1)") == 2022 + assert _parse_year_cell("2025") == 2025 + # не-год / пусто / вне диапазона → None + assert _parse_year_cell(None) is None + assert _parse_year_cell("Январь") is None + assert _parse_year_cell(1800) is None + + +def test_construction_spec_contract() -> None: + """Спека СМР: тип, region='rf', monthly, % — открытый xlsx, без dataGrid.do/captcha.""" + assert CONSTRUCTION_PRICE_SPEC.indicator_type == "construction_price_index" + assert CONSTRUCTION_PRICE_SPEC.region == "rf" + assert CONSTRUCTION_PRICE_SPEC.frequency == "monthly" + assert CONSTRUCTION_PRICE_SPEC.unit == "%" + assert "dataGrid" not in CONSTRUCTION_PRICE_SPEC.url_template + assert CONSTRUCTION_PRICE_SPEC.url_template.startswith("https://rosstat.gov.ru/") + assert "{mm:02d}" in CONSTRUCTION_PRICE_SPEC.url_template # резолв по дате, НЕ хардкод месяца + + +def test_construction_url_resolves_with_lookback(monkeypatch: pytest.MonkeyPatch) -> None: + """Имя файла меняется ежемесячно → резолвер идёт назад до первого 200 (не хардкод).""" + from app.services.scrapers import rosstat_emiss as mod + + tried: list[str] = [] + + def fake_get(client: object, url: str, *, retries: int = 0) -> bytes: + tried.append(url) + if len(tried) < 3: # первые два месяца → 404 + raise mod.RosstatScraperError(f"HTTP 404 {url}") + return CONSTRUCTION_XLSX + + monkeypatch.setattr(mod, "_http_get", fake_get) + content = mod._fetch_construction_latest( + client=object(), # type: ignore[arg-type] # не используется (get замокан) + url_template=mod.ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE, + today=date(2026, 6, 15), + ) + assert content == CONSTRUCTION_XLSX + assert tried == [ + "https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_06-2026.xlsx", + "https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_05-2026.xlsx", + "https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_04-2026.xlsx", + ] + + +def test_construction_url_lookback_year_boundary_and_exhaustion( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Все кандидаты 404 → RosstatScraperError; look-back корректно переходит через год.""" + from app.services.scrapers import rosstat_emiss as mod + + tried: list[str] = [] + + def fake_get(client: object, url: str, *, retries: int = 0) -> bytes: + tried.append(url) + raise mod.RosstatScraperError(f"HTTP 404 {url}") + + monkeypatch.setattr(mod, "_http_get", fake_get) + with pytest.raises(mod.RosstatScraperError): + mod._fetch_construction_latest( + client=object(), # type: ignore[arg-type] + url_template=mod.ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE, + today=date(2026, 2, 15), + max_lookback=3, + ) + assert tried == [ # переход через год: 02-2026 → 01-2026 → 12-2025 → 11-2025 + "https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_02-2026.xlsx", + "https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_01-2026.xlsx", + "https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_12-2025.xlsx", + "https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_11-2025.xlsx", + ] + + +def test_parse_construction_monthly_series() -> None: + """xlsx → месячный ряд СМР: region='rf', obs_date к 1-му числу, значение в %.""" + rows = parse_construction_index_xlsx(CONSTRUCTION_XLSX, CONSTRUCTION_PRICE_SPEC) + assert rows, "ожидались строки из фикстуры" + assert all(r.region == "rf" for r in rows) + assert all(r.indicator_type == "construction_price_index" for r in rows) + assert all(r.unit == "%" for r in rows) + assert all(r.obs_date.day == 1 for r in rows) + by_date = {r.obs_date: r.value for r in rows} + # реальные значения «к предыдущему месяцу» из фикстуры (2022-2025) + assert by_date[date(2022, 1, 1)] == Decimal("100.6") + assert by_date[date(2025, 1, 1)] == Decimal("100.8") + # годы покрывают 2022..2025 + assert {d.year for d in by_date} == {2022, 2023, 2024, 2025} + + +def test_parse_construction_skips_blank_future_month() -> None: + """Пустая ячейка будущего/неопубликованного месяца → пропуск (None-not-0).""" + rows = parse_construction_index_xlsx(CONSTRUCTION_XLSX, CONSTRUCTION_PRICE_SPEC) + dates = {r.obs_date for r in rows} + # Декабрь-2025 в фикстуре намеренно пуст → его НЕ должно быть (а не 0) + assert date(2025, 12, 1) not in dates + assert date(2025, 11, 1) in dates # соседний заполненный — есть + + +def test_parse_construction_ignores_annual_section() -> None: + """Секция «К декабрю предыдущего года» (годовой базис) НЕ попадает в месячный ряд. + + Годовые декабрьские значения большие (>105), месячные — около 100. Убеждаемся, что + ни одно декабрьское наблюдение не унаследовало годовое значение. + """ + rows = parse_construction_index_xlsx(CONSTRUCTION_XLSX, CONSTRUCTION_PRICE_SPEC) + dec_vals = [r.value for r in rows if r.obs_date.month == 12] + assert dec_vals, "ожидались декабрьские месячные наблюдения" + assert all(v < Decimal("105") for v in dec_vals) + + +def test_parse_construction_empty_and_garbage_graceful() -> None: + """Пустые байты / не-xlsx → [] (+warning), не бросает.""" + assert parse_construction_index_xlsx(b"", CONSTRUCTION_PRICE_SPEC) == [] + assert parse_construction_index_xlsx(b"not a zip", CONSTRUCTION_PRICE_SPEC) == [] + + +def test_parse_construction_missing_sheet_graceful() -> None: + """Если запрошенного листа нет в книге → [] (+warning).""" + from dataclasses import replace + + spec_bad = replace(CONSTRUCTION_PRICE_SPEC, sheet="НЕТ_ТАКОГО") + assert parse_construction_index_xlsx(CONSTRUCTION_XLSX, spec_bad) == [] diff --git a/backend/tests/workers/test_rosstat_macro_sync.py b/backend/tests/workers/test_rosstat_macro_sync.py index 3d10262e..267239c5 100644 --- a/backend/tests/workers/test_rosstat_macro_sync.py +++ b/backend/tests/workers/test_rosstat_macro_sync.py @@ -69,15 +69,18 @@ def test_task_upserts_each_row_with_correct_params() -> None: patch.object(task_mod, "SessionLocal", return_value=db), patch.object(task_mod, "fetch_all_datasets", return_value=fake_rows) as mock_fetch, patch.object(task_mod, "fetch_all_emiss", return_value=[]) as mock_emiss, + patch.object(task_mod, "fetch_construction_index", return_value=[]) as mock_constr, ): result = task_mod.rosstat_macro_sync.run() mock_fetch.assert_called_once() mock_emiss.assert_called_once() + mock_constr.assert_called_once() assert result["fetched"] == 2 assert result["upserted"] == 2 assert result["rosstat"] == {"fetched": 2, "upserted": 2} assert result["emiss"] == {"fetched": 0, "upserted": 0} + assert result["construction"] == {"fetched": 0, "upserted": 0} assert captured == [ { "itype": "population_total", @@ -113,6 +116,7 @@ def test_task_reraises_fetch_failure() -> None: patch.object(task_mod, "SessionLocal", return_value=db), patch.object(task_mod, "fetch_all_datasets", side_effect=RuntimeError("rosstat down")), patch.object(task_mod, "fetch_all_emiss", return_value=[]), + patch.object(task_mod, "fetch_construction_index", return_value=[]), ): try: task_mod.rosstat_macro_sync.run() @@ -137,6 +141,7 @@ def test_task_emiss_failure_does_not_block_opendata() -> None: patch.object(task_mod, "SessionLocal", return_value=db), patch.object(task_mod, "fetch_all_datasets", return_value=rosstat_rows), patch.object(task_mod, "fetch_all_emiss", side_effect=RuntimeError("emiss down")), + patch.object(task_mod, "fetch_construction_index", return_value=[]), ): try: task_mod.rosstat_macro_sync.run() @@ -166,6 +171,7 @@ def test_task_upserts_emiss_rows_with_correct_params() -> None: patch.object(task_mod, "SessionLocal", return_value=db), patch.object(task_mod, "fetch_all_datasets", return_value=[]), patch.object(task_mod, "fetch_all_emiss", return_value=emiss_rows), + patch.object(task_mod, "fetch_construction_index", return_value=[]), ): result = task_mod.rosstat_macro_sync.run() @@ -198,6 +204,86 @@ def test_upsert_emiss_sql_contract() -> None: assert "::" not in sql +def test_upsert_rosstat_monthly_sql_contract() -> None: + """UPSERT_ROSSTAT_MONTHLY_SQL: source='rosstat', frequency='monthly', CAST not ::.""" + from app.workers.tasks.rosstat_macro_sync import UPSERT_ROSSTAT_MONTHLY_SQL + + sql = str(UPSERT_ROSSTAT_MONTHLY_SQL) + upper = sql.upper() + assert "INTO MACRO_INDICATOR" in upper + assert "'rosstat'" in sql + assert "'monthly'" in sql + assert "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE) DO UPDATE" in upper + assert "CAST(:ITYPE AS TEXT)" in upper + assert "CAST(:D AS DATE)" in upper + assert "CAST(:V AS NUMERIC)" in upper + assert "::" not in sql + + +def test_task_upserts_construction_rows_with_correct_params() -> None: + """Каждая construction MacroRow → execute UPSERT_ROSSTAT_MONTHLY_SQL c bind-параметрами.""" + from app.services.scrapers.rosstat_emiss import MacroRow + from app.workers.tasks import rosstat_macro_sync as task_mod + + db, captured = _make_mock_db() + constr_rows = [ + MacroRow( + "construction_price_index", "rf", date(2025, 1, 1), + Decimal("100.8"), "%", "smr", + ), + ] + + with ( + patch.object(task_mod, "SessionLocal", return_value=db), + patch.object(task_mod, "fetch_all_datasets", return_value=[]), + patch.object(task_mod, "fetch_all_emiss", return_value=[]), + patch.object(task_mod, "fetch_construction_index", return_value=constr_rows), + ): + result = task_mod.rosstat_macro_sync.run() + + assert result["construction"] == {"fetched": 1, "upserted": 1} + assert result["fetched"] == 1 + assert captured == [ + { + "itype": "construction_price_index", + "region": "rf", + "d": date(2025, 1, 1), + "v": Decimal("100.8"), + "unit": "%", + "comment": "smr", + }, + ] + + +def test_task_construction_failure_does_not_block_others() -> None: + """Per-source guard: сбой СМР-источника НЕ мешает open-data/ЕМИСС, ошибка пробрасывается.""" + from app.services.scrapers.rosstat_emiss import MacroRow + from app.workers.tasks import rosstat_macro_sync as task_mod + + db, captured = _make_mock_db() + rosstat_rows = [ + MacroRow("population_total", "sverdl", date(2014, 1, 1), Decimal("4320677"), "чел", "c"), + ] + + with ( + patch.object(task_mod, "SessionLocal", return_value=db), + patch.object(task_mod, "fetch_all_datasets", return_value=rosstat_rows), + patch.object(task_mod, "fetch_all_emiss", return_value=[]), + patch.object( + task_mod, "fetch_construction_index", side_effect=RuntimeError("rosstat xlsx down") + ), + ): + try: + task_mod.rosstat_macro_sync.run() + raise AssertionError("expected RuntimeError to propagate") + except RuntimeError as e: + assert "rosstat xlsx down" in str(e) + + # open-data всё равно апсертнулся ДО проброса construction-ошибки + assert any(p.get("itype") == "population_total" for p in captured) + db.close.assert_called_once() + + def test_task_registered_in_beat_monthly() -> None: """Задача зарегистрирована в beat как ежемесячная (1-е число).""" from app.workers.beat_schedule import build_beat_schedule