gendesign/backend/app/workers/tasks/rosstat_macro_sync.py
Light1YT 1f66dfd400
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
Deploy / changes (push) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
Deploy / build-backend (push) Successful in 1m31s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m33s
Deploy / deploy (push) Successful in 1m12s
CI / backend-tests (push) Successful in 6m23s
CI / backend-tests (pull_request) Successful in 6m31s
feat(macro): ЕМИСС среднедушевые доходы (id=57039) -> macro_indicator (#946 part2)
Расширяет Росстат-скрейпер ЕМИСС/fedstat-рядом «Среднедушевые денежные доходы
населения» (fedstat id=57039, OKATO 65=Свердл, квартальный, руб). Добавляет pure
SDMX-ML v1.0 GenericData-парсер (stdlib ElementTree, dev-тестируем на фикстурах) +
EMISS-ветку Celery-таски rosstat_macro_sync (open-data + ЕМИСС, per-source guard,
source='emiss', frequency параметризован, CAST not ::, SAVEPOINT per-row).
income_per_capita проброшен в site_finder/macro (region='sverdl').

ЕМИСС за WAF с dev -> fetch исполняется на проде (verified: httpx POST из
gendesign-worker-1 -> 200 + SDMX, парсер извлёк 9 Свердл-строк). ИПЦ (id=31074) /
индекс цен СМР НЕ landed: многомерные, дефолт-экспорт data.do без Свердл, нужна
dataGrid-выборка (selectedFilterIds + filter-tree AJAX + reCAPTCHA) -- задокументировано.

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

222 lines
9.5 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).
Тянет региональные ряды Росстата для Свердловской обл. из ДВУХ источников и апсертит
каждую обсервацию в ``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'.
Каждый источник в своём try/except (per-source guard): сбой одного НЕ роняет другой и
не валит таску — частичная поставка допустима (ТЗ §7.11). ЕМИСС-fetch доступен только
с прода (fedstat за WAF с dev); ИПЦ/индекс цен на СМР пока не 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,
)
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()
"""
)
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_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 + ЕМИСС) и апсертить в macro_indicator.
Источники выполняются НЕЗАВИСИМО (per-source try/except): сбой одного источника
логируется, апсертит здоровый источник, и в конце ошибка ПРОБРАСЫВАЕТСЯ (чтобы фейл
был виден в Celery / GlitchTip, а не молча проглочен). Re-run идемпотентен (ON CONFLICT),
поэтому Celery-retry безопасно повторит оба источника.
Returns:
Счётчики ``{"fetched": N, "upserted": M, "rosstat": ..., "emiss": ...}``.
Raises:
Первую пойманную ошибку любого источника — ПОСЛЕ попытки обоих (surfaces в
Celery/GlitchTip). Частичный сбой отдельного датасета/индикатора внутри источника
НЕ роняет источник (fetch_all_* graceful per-item).
"""
db = SessionLocal()
first_error: Exception | None = None
rosstat_fetched = rosstat_upserted = 0
emiss_fetched = emiss_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)
if first_error is not None:
raise first_error
return {
"fetched": rosstat_fetched + emiss_fetched,
"upserted": rosstat_upserted + emiss_upserted,
"rosstat": {"fetched": rosstat_fetched, "upserted": rosstat_upserted},
"emiss": {"fetched": emiss_fetched, "upserted": emiss_upserted},
}
finally:
db.close()