"""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'; • открытый 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.gov.ru-xlsx (reCAPTCHA-gated dataGrid.do НАМЕРЕННО НЕ используется); ИПЦ пока не landed (см. rosstat_emiss). Детерминированно, без LLM. ON CONFLICT делает повторные прогоны идемпотентными (re-run обновляет value + updated_at). Расписание — ежемесячно (эти ряды обновляются редко), регистрируется в beat_schedule.py. Mirror conventions (как cbr_macro_sync): ``SessionLocal()`` + try/finally close, ``logger`` (не print), SAVEPOINT per-row в цикле upsert (backend.md), ``CAST(:x AS type)`` — никогда ``:x::type`` (psycopg v3). На fetch-фейле — логируем и пробрасываем (surfaces в Celery/GlitchTip), не глотаем. """ from __future__ import annotations import logging from typing import Any from sqlalchemy import text from sqlalchemy.orm import Session from app.core.db import SessionLocal from app.services.scrapers.rosstat_emiss import ( EmissRow, MacroRow, fetch_all_datasets, fetch_all_emiss, fetch_construction_index, ) from app.workers.celery_app import celery_app logger = logging.getLogger(__name__) # psycopg v3: CAST(:x AS type) — НИКОГДА :x::type (SQLAlchemy+psycopg3 роняет # синтаксис на ::). Контракт колонок совпадает с macro_indicator (migration 123): # (indicator_type, region, obs_date, value, source, frequency, unit, comment, # updated_at) PK (indicator_type, region, obs_date). source='rosstat', # frequency='yearly' (текущие ряды Росстата — годовые: obs_date = 1 января года). UPSERT_ROSSTAT_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', 'yearly', 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() """ ) # ЕМИСС-ряды (source='emiss'): frequency параметризована (quarterly для доходов, # monthly для ИПЦ когда добавится) — в отличие от open-data, где она фикс 'yearly'. # Тот же PK и ON CONFLICT-контракт. CAST(:x AS type) — НИКОГДА :x::type (psycopg v3). UPSERT_EMISS_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), 'emiss', CAST(:freq AS text), 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() """ ) # Индекс цен на СМР — открытый 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, чтобы один битый ряд не откатывал всю транзакцию. Возвращает число успешных upsert'ов.""" upserted = 0 for r in rows: try: with db.begin_nested(): db.execute( UPSERT_ROSSTAT_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 %s/%s@%s=%s failed: %s", r.indicator_type, r.region, r.obs_date, r.value, e, ) db.commit() 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, чтобы один битый ряд не откатывал всю транзакцию. Возвращает число успешных upsert'ов.""" upserted = 0 for r in rows: try: with db.begin_nested(): db.execute( UPSERT_EMISS_SQL, { "itype": r.indicator_type, "region": r.region, "d": r.obs_date, "v": r.value, "freq": r.frequency, "unit": r.unit, "comment": r.comment, }, ) upserted += 1 except Exception as e: logger.warning( "upsert emiss %s/%s@%s=%s failed: %s", r.indicator_type, r.region, r.obs_date, r.value, e, ) db.commit() return upserted @celery_app.task( bind=True, name="tasks.rosstat_macro_sync.rosstat_macro_sync", max_retries=2, ) def rosstat_macro_sync(self: Any) -> dict[str, Any]: """Загрузить ряды Росстата (open-data + ЕМИСС + xlsx-СМР) и апсертить в macro_indicator. Источники выполняются НЕЗАВИСИМО (per-source try/except): сбой одного источника логируется, апсертит здоровый источник, и в конце ошибка ПРОБРАСЫВАЕТСЯ (чтобы фейл был виден в Celery / GlitchTip, а не молча проглочен). Re-run идемпотентен (ON CONFLICT), поэтому Celery-retry безопасно повторит все источники. Returns: Счётчики ``{"fetched": N, "upserted": M, "rosstat": ..., "emiss": ..., "construction": ...}``. Raises: Первую пойманную ошибку любого источника — ПОСЛЕ попытки всех трёх (surfaces в Celery/GlitchTip). Частичный сбой отдельного датасета/индикатора внутри источника НЕ роняет источник (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: rows = fetch_all_datasets() rosstat_fetched = len(rows) rosstat_upserted = _upsert_rows(db, rows) logger.info( "rosstat_macro_sync: open-data upserted %d/%d rows", rosstat_upserted, rosstat_fetched, ) except Exception as e: first_error = first_error or e logger.exception("rosstat_macro_sync: open-data source failed: %s", e) # ── Источник 2: ЕМИСС / fedstat (доходы; fetch только с прода) ─────────── try: emiss_rows = fetch_all_emiss() emiss_fetched = len(emiss_rows) emiss_upserted = _upsert_emiss_rows(db, emiss_rows) logger.info( "rosstat_macro_sync: emiss upserted %d/%d rows", emiss_upserted, emiss_fetched, ) except Exception as e: 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 + 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()