gendesign/backend/app/workers/tasks/rosstat_macro_sync.py
Light1YT 2da8a285b3
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m18s
CI / backend-tests (pull_request) Successful in 6m33s
feat(macro): Росстат open-data macro scraper -> macro_indicator (#946)
EPIC2 macro-ingest: региональный (Свердловская обл. ОКТМО 65 / ЕКБ) скрейпер
Росстата в унифицированную macro_indicator (м.123). Зеркалит форму cbr_macro:
pure-парсеры + тонкий httpx, Celery-таска с SAVEPOINT-per-row upsert
(CAST not ::, ON CONFLICT по PK indicator_type/region/obs_date).

ЕМИСС/fedstat.ru (SDMX) за WAF (hard-403 на всех путях из dev-IP) -> ИПЦ/доходы/
СМР-цены пока недоступны (документировано в коде). rosstat.gov.ru/opendata
(стандарт 4.0) WAF-free -> приземляет демографию population_total
(sverdl 4.32M / ekb 1.41M, §7.11).

- services/scrapers/rosstat_emiss.py — fetch+parse open-data (registry->meta->data CSV)
- workers/tasks/rosstat_macro_sync.py — Celery upsert (source=rosstat)
- beat: rosstat-macro-sync-monthly; include в celery_app
- site_finder/macro.py: population_total -> region-aware default sverdl (additive)
- tests: 18 offline-тестов парсера + контракта таски

Без миграции (вписано в существующую схему), без новых зависимостей.

Refs #946.
2026-06-07 18:07:56 +05:00

122 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Celery task: синхронизация макро-показателей Росстата в ``macro_indicator``
(#946, EPIC2 «macro-ingest», GG-форсайт / Site Finder v2).
Тянет региональные ряды Росстата (open-data, см. ``rosstat_emiss.fetch_all_datasets``)
для Свердловской обл. (ОКТМО 65) / ЕКБ и апсертит каждую обсервацию в
``macro_indicator`` под ``source='rosstat'``. Сейчас приземляется демография
(``population_total``); ИПЦ/доходы/СМР-цены — EMISS-only за WAF (документировано в
``rosstat_emiss``), добавятся новым DatasetSpec, когда появится рабочий доступ.
Детерминированно, без 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 MacroRow, fetch_all_datasets
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()
"""
)
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
@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]:
"""Загрузить региональные ряды Росстата и апсертить в macro_indicator.
Returns:
Счётчики ``{"fetched": N, "upserted": M}``.
Raises:
Пробрасывает любую ошибку fetch'а (RosstatScraperError и пр.) — чтобы фейл
был виден в Celery / GlitchTip, а не молча проглочен. Частичный сбой
отдельного датасета НЕ роняет таску (fetch_all_datasets graceful per-dataset).
"""
db = SessionLocal()
try:
rows = fetch_all_datasets()
fetched = len(rows)
logger.info("rosstat_macro_sync: fetched %d rosstat rows", fetched)
upserted = _upsert_rows(db, rows)
logger.info("rosstat_macro_sync: upserted %d/%d rosstat rows", upserted, fetched)
return {"fetched": fetched, "upserted": upserted}
except Exception as e:
logger.exception("rosstat_macro_sync failed: %s", e)
raise
finally:
db.close()