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 00000000..fa52e480 Binary files /dev/null and b/backend/tests/fixtures/rosstat_construction_stroitel.xlsx differ 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