gendesign/backend/app/workers/tasks/rosstat_macro_sync.py
bot-backend 5170107405
Some checks failed
CI / backend-tests (push) Has been cancelled
CI / frontend-tests (push) Has been cancelled
CI / openapi-codegen-check (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been cancelled
CI / frontend-tests (pull_request) Has been cancelled
CI / openapi-codegen-check (pull_request) Has been cancelled
fix(sql): EMISS PK includes period_type so yearly+Q1 coexist (#1606 follow-up)
Adds period_type TEXT NOT NULL DEFAULT 'unknown' to macro_indicator and
widens the PRIMARY KEY to (indicator_type, region, obs_date, period_type).

Before: yearly aggregate ('год'→obs_date YYYY-01-01) and Q1 ('I квартал'→
same date) shared the same PK slot → ON CONFLICT DO UPDATE made them
overwrite each other despite the in-memory dedup fix in #1687.

After: each EmissRow carries period_type from _emiss_period_granularity
('year'/'quarter'/'month'); DB ON CONFLICT targets include it, so
yearly+Q1 rows genuinely coexist.

Non-EMISS sources (CBR, rosstat open-data, domrf) use period_type='unknown'
(literal in SQL) — they are disambiguated by obs_date already, so the
wider PK is backward-compatible. All ON CONFLICT clauses in
cbr_macro_sync.py and rosstat_macro_sync.py updated accordingly.

Migration: 163_emiss_pk_period_type.sql (idempotent BEGIN/COMMIT).
Tests: 49 passed (emiss + rosstat suite), ruff clean, py_compile OK.
2026-06-17 21:27:34 +03:00

316 lines
14 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';
• открытый 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 163):
# (indicator_type, region, obs_date, period_type, value, source, frequency, unit,
# comment, updated_at). PK (indicator_type, region, obs_date, period_type).
# Не-ЕМИСС источники (open-data, СМР) используют period_type='unknown' (литерал) —
# они различаются по obs_date без sub-period granularity.
# source='rosstat', frequency='yearly' (текущие ряды Росстата — годовые).
UPSERT_ROSSTAT_SQL = text(
"""
INSERT INTO macro_indicator (
indicator_type, region, obs_date, period_type, value,
source, frequency, unit, comment
) VALUES (
CAST(:itype AS text), CAST(:region AS text), CAST(:d AS date),
'unknown', CAST(:v AS numeric),
'rosstat', 'yearly', CAST(:unit AS text), CAST(:comment AS text)
)
ON CONFLICT (indicator_type, region, obs_date, period_type) DO UPDATE SET
value = EXCLUDED.value,
source = EXCLUDED.source,
frequency = EXCLUDED.frequency,
unit = EXCLUDED.unit,
comment = EXCLUDED.comment,
updated_at = now()
"""
)
# ЕМИСС-ряды (source='emiss'): period_type параметризован — 'year' | 'quarter' |
# 'month' (из _emiss_period_granularity). Это ключевое отличие от не-ЕМИСС источников:
# период ЕМИСС-ряда несёт granularity, необходимую для разделения годового агрегата
# ('год' → 'year') и Q1 ('I квартал' → 'quarter'), оба с obs_date=YYYY-01-01 (#1606).
# frequency параметризована (quarterly для доходов, monthly для ИПЦ когда добавится).
# CAST(:x AS type) — НИКОГДА :x::type (psycopg v3).
UPSERT_EMISS_SQL = text(
"""
INSERT INTO macro_indicator (
indicator_type, region, obs_date, period_type, value,
source, frequency, unit, comment
) VALUES (
CAST(:itype AS text), CAST(:region AS text), CAST(:d AS date),
CAST(:period_type AS text), CAST(:v AS numeric),
'emiss', CAST(:freq AS text), CAST(:unit AS text), CAST(:comment AS text)
)
ON CONFLICT (indicator_type, region, obs_date, period_type) 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/period_type-полей;
# подставляем литералами. CAST(:x AS type) — psycopg v3.
UPSERT_ROSSTAT_MONTHLY_SQL = text(
"""
INSERT INTO macro_indicator (
indicator_type, region, obs_date, period_type, value,
source, frequency, unit, comment
) VALUES (
CAST(:itype AS text), CAST(:region AS text), CAST(:d AS date),
'unknown', CAST(:v AS numeric),
'rosstat', 'monthly', CAST(:unit AS text), CAST(:comment AS text)
)
ON CONFLICT (indicator_type, region, obs_date, period_type) 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+period_type per-row).
SAVEPOINT per-row, чтобы один битый ряд не откатывал всю транзакцию. Возвращает
число успешных upsert'ов.
period_type (из r.period_type) — часть нового PK (migration 163): позволяет
годовому агрегату ('year') и Q1 ('quarter') за один год коexist в таблице без
взаимной перезаписи (#1606).
"""
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,
"period_type": r.period_type,
"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]=%s failed: %s",
r.indicator_type,
r.region,
r.obs_date,
r.period_type,
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()