From 51701074058d0f0554acdb2ab34eee8e32a9a9d2 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 17 Jun 2026 21:27:34 +0300 Subject: [PATCH 1/2] fix(sql): EMISS PK includes period_type so yearly+Q1 coexist (#1606 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../app/services/scrapers/rosstat_emiss.py | 11 ++- backend/app/workers/tasks/cbr_macro_sync.py | 21 +++-- .../app/workers/tasks/rosstat_macro_sync.py | 55 +++++++----- backend/tests/scrapers/test_emiss_sdmx.py | 13 ++- backend/tests/workers/test_cbr_macro_sync.py | 9 +- .../tests/workers/test_rosstat_macro_sync.py | 42 ++++++--- data/sql/163_emiss_pk_period_type.sql | 86 +++++++++++++++++++ 7 files changed, 191 insertions(+), 46 deletions(-) create mode 100644 data/sql/163_emiss_pk_period_type.sql diff --git a/backend/app/services/scrapers/rosstat_emiss.py b/backend/app/services/scrapers/rosstat_emiss.py index 14b633bf..775a5751 100644 --- a/backend/app/services/scrapers/rosstat_emiss.py +++ b/backend/app/services/scrapers/rosstat_emiss.py @@ -667,7 +667,14 @@ class EmissRow: """Одна готовая к upsert строка macro_indicator из ЕМИСС (source='emiss'). Отдельно от ``MacroRow`` (open-data, source='rosstat', yearly): ЕМИСС-ряды несут - свою frequency (quarterly/monthly) и source — контракт upsert'а у них иной. + свою frequency (quarterly/monthly), source и period_type — контракт upsert'а у + них иной. + + period_type: гранулярность под-периода ('year' | 'quarter' | 'month' | 'unknown'). + Берётся из _emiss_period_granularity(PERIOD). Необходима как часть PK + macro_indicator (migration 163), чтобы годовой агрегат ('год' → 'year') и + Q1 ('I квартал' → 'quarter') за один год не перезаписывали друг друга при + ON CONFLICT DO UPDATE (оба дают obs_date=YYYY-01-01) (#1606). """ indicator_type: str @@ -677,6 +684,7 @@ class EmissRow: unit: str frequency: str comment: str + period_type: str = "unknown" def _emiss_period_to_month(period: str) -> int | None: @@ -817,6 +825,7 @@ def parse_emiss_sdmx(raw: bytes | str, spec: EmissIndicatorSpec) -> list[EmissRo unit=spec.unit, frequency=spec.frequency, comment=spec.comment, + period_type=granularity, ) return [by_key[k] for k in sorted(by_key, key=lambda k: (k[0], k[1]))] diff --git a/backend/app/workers/tasks/cbr_macro_sync.py b/backend/app/workers/tasks/cbr_macro_sync.py index 441612af..92c27a5d 100644 --- a/backend/app/workers/tasks/cbr_macro_sync.py +++ b/backend/app/workers/tasks/cbr_macro_sync.py @@ -41,19 +41,21 @@ 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). +# синтаксис на ::). Контракт колонок совпадает с 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). +# CBR-ряды используют period_type='unknown' (литерал) — они не несут sub-period +# granularity и различаются по obs_date. UPSERT_KEY_RATE_SQL = text( """ INSERT INTO macro_indicator ( - indicator_type, region, obs_date, value, + indicator_type, region, obs_date, period_type, value, source, frequency, unit, comment ) VALUES ( - 'key_rate', 'rf', CAST(:d AS date), CAST(:v AS numeric), + 'key_rate', 'rf', CAST(:d AS date), 'unknown', CAST(:v AS numeric), 'cbr', 'daily', '%', 'CBR key rate' ) - ON CONFLICT (indicator_type, region, obs_date) DO UPDATE SET + ON CONFLICT (indicator_type, region, obs_date, period_type) DO UPDATE SET value = EXCLUDED.value, updated_at = now() """ @@ -61,16 +63,17 @@ UPSERT_KEY_RATE_SQL = text( # Инфляция «% г/г» (ИПЦ YoY): indicator_type='inflation_yoy', monthly, region='rf'. # obs_date уже нормализован к 1-му числу месяца парсером (parse_inflation_xlsx). +# period_type='unknown' — месячный ряд без sub-period granularity. UPSERT_INFLATION_SQL = text( """ INSERT INTO macro_indicator ( - indicator_type, region, obs_date, value, + indicator_type, region, obs_date, period_type, value, source, frequency, unit, comment ) VALUES ( - 'inflation_yoy', 'rf', CAST(:d AS date), CAST(:v AS numeric), + 'inflation_yoy', 'rf', CAST(:d AS date), 'unknown', CAST(:v AS numeric), 'cbr', 'monthly', '%', 'CBR inflation YoY (ИПЦ, % г/г)' ) - ON CONFLICT (indicator_type, region, obs_date) DO UPDATE SET + ON CONFLICT (indicator_type, region, obs_date, period_type) DO UPDATE SET value = EXCLUDED.value, updated_at = now() """ diff --git a/backend/app/workers/tasks/rosstat_macro_sync.py b/backend/app/workers/tasks/rosstat_macro_sync.py index 3cbec22a..cf84c5a3 100644 --- a/backend/app/workers/tasks/rosstat_macro_sync.py +++ b/backend/app/workers/tasks/rosstat_macro_sync.py @@ -47,21 +47,23 @@ 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 января года). +# синтаксис на ::). Контракт колонок совпадает с 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, value, + 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(:v AS numeric), + 'unknown', CAST(:v AS numeric), 'rosstat', 'yearly', CAST(:unit AS text), CAST(:comment AS text) ) - ON CONFLICT (indicator_type, region, obs_date) DO UPDATE SET + ON CONFLICT (indicator_type, region, obs_date, period_type) DO UPDATE SET value = EXCLUDED.value, source = EXCLUDED.source, frequency = EXCLUDED.frequency, @@ -71,20 +73,23 @@ UPSERT_ROSSTAT_SQL = text( """ ) -# ЕМИСС-ряды (source='emiss'): frequency параметризована (quarterly для доходов, -# monthly для ИПЦ когда добавится) — в отличие от open-data, где она фикс 'yearly'. -# Тот же PK и ON CONFLICT-контракт. CAST(:x AS type) — НИКОГДА :x::type (psycopg v3). +# ЕМИСС-ряды (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, value, + 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(:v AS numeric), + 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) DO UPDATE SET + ON CONFLICT (indicator_type, region, obs_date, period_type) DO UPDATE SET value = EXCLUDED.value, source = EXCLUDED.source, frequency = EXCLUDED.frequency, @@ -96,19 +101,19 @@ 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. +# поэтому 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, value, + 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(:v AS numeric), + 'unknown', CAST(:v AS numeric), 'rosstat', 'monthly', CAST(:unit AS text), CAST(:comment AS text) ) - ON CONFLICT (indicator_type, region, obs_date) DO UPDATE SET + ON CONFLICT (indicator_type, region, obs_date, period_type) DO UPDATE SET value = EXCLUDED.value, source = EXCLUDED.source, frequency = EXCLUDED.frequency, @@ -185,9 +190,15 @@ def _upsert_monthly_rows(db: Session, rows: list[MacroRow]) -> int: def _upsert_emiss_rows(db: Session, rows: list[EmissRow]) -> int: - """Апсертит EmissRow в macro_indicator (source='emiss', frequency per-row). + """Апсертит EmissRow в macro_indicator (source='emiss', frequency+period_type per-row). + SAVEPOINT per-row, чтобы один битый ряд не откатывал всю транзакцию. Возвращает - число успешных upsert'ов.""" + число успешных upsert'ов. + + period_type (из r.period_type) — часть нового PK (migration 163): позволяет + годовому агрегату ('year') и Q1 ('quarter') за один год коexist в таблице без + взаимной перезаписи (#1606). + """ upserted = 0 for r in rows: try: @@ -198,6 +209,7 @@ def _upsert_emiss_rows(db: Session, rows: list[EmissRow]) -> int: "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, @@ -207,10 +219,11 @@ def _upsert_emiss_rows(db: Session, rows: list[EmissRow]) -> int: upserted += 1 except Exception as e: logger.warning( - "upsert emiss %s/%s@%s=%s failed: %s", + "upsert emiss %s/%s@%s[%s]=%s failed: %s", r.indicator_type, r.region, r.obs_date, + r.period_type, r.value, e, ) diff --git a/backend/tests/scrapers/test_emiss_sdmx.py b/backend/tests/scrapers/test_emiss_sdmx.py index ac6947a6..8b356e1d 100644 --- a/backend/tests/scrapers/test_emiss_sdmx.py +++ b/backend/tests/scrapers/test_emiss_sdmx.py @@ -108,19 +108,30 @@ def test_yearly_and_q1_both_survive_dedup() -> None: assert Decimal("42000") in values, "Q1-наблюдение потеряно" # obs_date у обоих одинаковый (это нормально — коллизия теперь на стороне DB-upsert) assert all(r.obs_date == date(2023, 1, 1) for r in rows) + # period_type: годовой → 'year', Q1 → 'quarter' (часть PK в macro_indicator, migration 163) + period_types = {r.period_type for r in rows} + assert period_types == { + "year", + "quarter", + }, f"ожидались period_type year+quarter, получили {period_types}" # ── real-fixture extraction (income id=57039) ───────────────────────────────────── def test_income_extracts_sverdlovsk_only() -> None: - """Из реальной SDMX-выгрузки извлекается ТОЛЬКО Свердл (ОКАТО 65) — РФ/ЦФО/Адыгея нет.""" + """Из реальной SDMX-выгрузки извлекается ТОЛЬКО Свердл (ОКАТО 65) — РФ/ЦФО/Адыгея нет. + Все строки квартальные → period_type='quarter' (часть PK macro_indicator, migration 163). + """ rows = parse_emiss_sdmx(_load("emiss_income_57039.xml"), INCOME_PER_CAPITA_SPEC) assert rows, "ожидались строки по Свердл" assert {r.region for r in rows} == {"sverdl"} assert all(r.indicator_type == "income_per_capita" for r in rows) assert all(r.unit == "руб" for r in rows) assert all(r.frequency == "quarterly" for r in rows) + assert all( + r.period_type == "quarter" for r in rows + ), "квартальные строки должны иметь period_type='quarter'" def test_income_concrete_values_and_dates() -> None: diff --git a/backend/tests/workers/test_cbr_macro_sync.py b/backend/tests/workers/test_cbr_macro_sync.py index 2b09c51f..5b89376d 100644 --- a/backend/tests/workers/test_cbr_macro_sync.py +++ b/backend/tests/workers/test_cbr_macro_sync.py @@ -48,14 +48,16 @@ def test_upsert_sql_contract() -> None: assert "'rf'" in sql assert "'cbr'" in sql assert "'daily'" in sql - # ON CONFLICT по полному PK + обновление value/updated_at - assert "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE) DO UPDATE" in upper + # ON CONFLICT по новому PK включает period_type (migration 163) + assert "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE, PERIOD_TYPE) DO UPDATE" in upper assert "VALUE = EXCLUDED.VALUE" in upper assert "UPDATED_AT = NOW()" in upper # psycopg v3: CAST(:x AS type), НИКОГДА :x::type assert "CAST(:D AS DATE)" in upper assert "CAST(:V AS NUMERIC)" in upper assert "::" not in sql + # CBR-строки используют period_type='unknown' (литерал в SQL) + assert "'unknown'" in sql def test_task_upserts_each_row_with_correct_params() -> None: @@ -192,13 +194,14 @@ def test_upsert_inflation_sql_contract() -> None: assert "'rf'" in sql assert "'cbr'" in sql assert "'monthly'" in sql - assert "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE) DO UPDATE" in upper + assert "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE, PERIOD_TYPE) DO UPDATE" in upper assert "VALUE = EXCLUDED.VALUE" in upper assert "UPDATED_AT = NOW()" in upper # psycopg v3: CAST(:x AS type), НИКОГДА :x::type assert "CAST(:D AS DATE)" in upper assert "CAST(:V AS NUMERIC)" in upper assert "::" not in sql + assert "'unknown'" in sql def test_task_registered_in_beat_weekly() -> None: diff --git a/backend/tests/workers/test_rosstat_macro_sync.py b/backend/tests/workers/test_rosstat_macro_sync.py index 267239c5..c9ca8c35 100644 --- a/backend/tests/workers/test_rosstat_macro_sync.py +++ b/backend/tests/workers/test_rosstat_macro_sync.py @@ -35,7 +35,7 @@ def _make_mock_db() -> tuple[MagicMock, list[dict[str, Any]]]: def test_upsert_sql_contract() -> None: """UPSERT_ROSSTAT_SQL содержит обязательные литералы контракта macro_indicator - и НЕ использует :x::type (psycopg v3).""" + и НЕ использует :x::type (psycopg v3). PK включает period_type (migration 163).""" from app.workers.tasks.rosstat_macro_sync import UPSERT_ROSSTAT_SQL sql = str(UPSERT_ROSSTAT_SQL) @@ -44,7 +44,7 @@ def test_upsert_sql_contract() -> None: assert "INTO MACRO_INDICATOR" in upper assert "'rosstat'" in sql assert "'yearly'" in sql - assert "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE) DO UPDATE" in upper + assert "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE, PERIOD_TYPE) DO UPDATE" in upper assert "VALUE = EXCLUDED.VALUE" in upper assert "UPDATED_AT = NOW()" in upper # psycopg v3: CAST(:x AS type), НИКОГДА :x::type @@ -52,6 +52,8 @@ def test_upsert_sql_contract() -> None: assert "CAST(:D AS DATE)" in upper assert "CAST(:V AS NUMERIC)" in upper assert "::" not in sql + # не-ЕМИСС строки используют period_type='unknown' (литерал в SQL) + assert "'unknown'" in sql def test_task_upserts_each_row_with_correct_params() -> None: @@ -155,15 +157,24 @@ def test_task_emiss_failure_does_not_block_opendata() -> None: def test_task_upserts_emiss_rows_with_correct_params() -> None: - """Каждая EmissRow → execute UPSERT_EMISS_SQL с itype/region/d/v/freq/unit/comment.""" + """Каждая EmissRow → execute UPSERT_EMISS_SQL с itype/region/d/period_type/v/freq/unit/comment. + + period_type передаётся из r.period_type (часть нового PK, migration 163). + """ from app.services.scrapers.rosstat_emiss import EmissRow from app.workers.tasks import rosstat_macro_sync as task_mod db, captured = _make_mock_db() emiss_rows = [ EmissRow( - "income_per_capita", "sverdl", date(2024, 1, 1), - Decimal("54006"), "руб", "quarterly", "c", + "income_per_capita", + "sverdl", + date(2024, 1, 1), + Decimal("54006"), + "руб", + "quarterly", + "c", + period_type="quarter", ), ] @@ -181,6 +192,7 @@ def test_task_upserts_emiss_rows_with_correct_params() -> None: "itype": "income_per_capita", "region": "sverdl", "d": date(2024, 1, 1), + "period_type": "quarter", "v": Decimal("54006"), "freq": "quarterly", "unit": "руб", @@ -190,7 +202,8 @@ def test_task_upserts_emiss_rows_with_correct_params() -> None: def test_upsert_emiss_sql_contract() -> None: - """UPSERT_EMISS_SQL: source='emiss', frequency параметризован, ON CONFLICT, CAST not ::.""" + """UPSERT_EMISS_SQL: source='emiss', frequency+period_type параметризованы, + ON CONFLICT включает period_type (migration 163), CAST not ::.""" from app.workers.tasks.rosstat_macro_sync import UPSERT_EMISS_SQL sql = str(UPSERT_EMISS_SQL) @@ -198,14 +211,16 @@ def test_upsert_emiss_sql_contract() -> None: assert "INTO MACRO_INDICATOR" in upper assert "'emiss'" in sql assert "CAST(:FREQ AS TEXT)" in upper - assert "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE) DO UPDATE" in upper + assert "CAST(:PERIOD_TYPE AS TEXT)" in upper + assert "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE, PERIOD_TYPE) DO UPDATE" in upper assert "CAST(:ITYPE AS TEXT)" in upper assert "CAST(:V AS NUMERIC)" in upper assert "::" not in sql def test_upsert_rosstat_monthly_sql_contract() -> None: - """UPSERT_ROSSTAT_MONTHLY_SQL: source='rosstat', frequency='monthly', CAST not ::.""" + """UPSERT_ROSSTAT_MONTHLY_SQL: source='rosstat', frequency='monthly', period_type='unknown', + ON CONFLICT включает period_type (migration 163), CAST not ::.""" from app.workers.tasks.rosstat_macro_sync import UPSERT_ROSSTAT_MONTHLY_SQL sql = str(UPSERT_ROSSTAT_MONTHLY_SQL) @@ -213,11 +228,12 @@ def test_upsert_rosstat_monthly_sql_contract() -> None: 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 "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE, PERIOD_TYPE) 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 + assert "'unknown'" in sql def test_task_upserts_construction_rows_with_correct_params() -> None: @@ -228,8 +244,12 @@ def test_task_upserts_construction_rows_with_correct_params() -> None: db, captured = _make_mock_db() constr_rows = [ MacroRow( - "construction_price_index", "rf", date(2025, 1, 1), - Decimal("100.8"), "%", "smr", + "construction_price_index", + "rf", + date(2025, 1, 1), + Decimal("100.8"), + "%", + "smr", ), ] diff --git a/data/sql/163_emiss_pk_period_type.sql b/data/sql/163_emiss_pk_period_type.sql new file mode 100644 index 00000000..40e6a2ef --- /dev/null +++ b/data/sql/163_emiss_pk_period_type.sql @@ -0,0 +1,86 @@ +-- 163_emiss_pk_period_type.sql +-- #1606 follow-up: расширить PK macro_indicator колонкой period_type, чтобы годовой +-- агрегат ('год' → 'year') и Q1 ('I квартал' → 'quarter') за один год не перезаписывали +-- друг друга при ON CONFLICT DO UPDATE (оба дают obs_date=YYYY-01-01). +-- +-- Контекст: +-- PR #1687 (#1606) исправил in-memory дедупликацию в parse_emiss_sdmx: ключ дедупа +-- стал трёхкомпонентным (region, obs_date, granularity). Но PK таблицы остался +-- (indicator_type, region, obs_date) — при апсерте в БД коллизия сохранялась. +-- Эта миграция фиксирует PK на стороне БД. +-- +-- Что делает: +-- 1. Добавляет колонку period_type TEXT NOT NULL DEFAULT 'unknown'. +-- Все существующие строки (CBR, rosstat open-data, domrf) получают 'unknown' — +-- это корректно: у них нет sub-period granularity (они уже различаются по obs_date). +-- 2. Бэкфилл: строки source='emiss' обновляем до 'quarterly' (единственный +-- активный ЕМИСС-индикатор — income_per_capita, он квартальный; годовых EMISS- +-- строк ещё нет в таблице до этой миграции). +-- 3. Пересоздаёт PRIMARY KEY: старый (indicator_type, region, obs_date) → новый +-- (indicator_type, region, obs_date, period_type). +-- 4. Пересоздаёт сопутствующий индекс idx_macro_type_region_date с тем же составом +-- (расширяется автоматически через новый PK-индекс; отдельный индекс DROP+CREATE). +-- +-- Зависимости: macro_indicator (migration 123_macro_indicator.sql). +-- Нет VIEW/FK зависимостей от macro_indicator PK (проверено через pg_depend). +-- Idempotent: ADD COLUMN IF NOT EXISTS + IF NOT EXISTS в CREATE INDEX. Повторный +-- прогон безопасен: DROP CONSTRAINT / ADD CONSTRAINT могут упасть если PK уже +-- переименован — обёрнуты в DO $$ BEGIN ... EXCEPTION WHEN ... END $$; блоки. +-- +-- Deploy order: SQL migration применяется ПЕРВОЙ (auto-deploy.yml), затем деплоится +-- backend со обновлёнными UPSERT_EMISS_SQL / UPSERT_ROSSTAT_SQL / UPSERT_KEY_RATE_SQL +-- / UPSERT_INFLATION_SQL / UPSERT_ROSSTAT_MONTHLY_SQL (они несут новый конфликт-таргет +-- + period_type в INSERT). + +BEGIN; + +-- ── 1. Добавить колонку period_type (DEFAULT 'unknown' для всех существующих строк) ── +ALTER TABLE macro_indicator + ADD COLUMN IF NOT EXISTS period_type TEXT NOT NULL DEFAULT 'unknown'; + +-- ── 2. Бэкфилл ЕМИСС-строк → 'quarterly' ──────────────────────────────────────────── +-- Все существующие ЕМИСС-строки — quarterly (income_per_capita). Если появятся 'year'- +-- строки (будущие индикаторы) — они придут с корректным period_type из нового скрейпера. +UPDATE macro_indicator + SET period_type = 'quarterly' + WHERE source = 'emiss' + AND period_type = 'unknown'; + +-- ── 3. Пересоздать PRIMARY KEY ──────────────────────────────────────────────────────── +-- PostgreSQL не поддерживает ALTER PRIMARY KEY напрямую — нужно DROP + ADD. +-- Ловим случай когда старый PK уже дропнут (повторный прогон). +DO $$ +BEGIN + ALTER TABLE macro_indicator DROP CONSTRAINT macro_indicator_pkey; +EXCEPTION + WHEN undefined_object THEN + NULL; -- PK уже удалён (повторный прогон) + WHEN feature_not_supported THEN + NULL; +END; +$$; + +-- Новый PK включает period_type. Если уже существует с правильным составом — +-- ADD CONSTRAINT бросит duplicate_table (конвенция Pg: PK → unique index), ловим. +DO $$ +BEGIN + ALTER TABLE macro_indicator + ADD CONSTRAINT macro_indicator_pkey + PRIMARY KEY (indicator_type, region, obs_date, period_type); +EXCEPTION + WHEN duplicate_table THEN + NULL; -- PK уже создан (повторный прогон) + WHEN invalid_table_definition THEN + NULL; -- уже существует тот же constraint +END; +$$; + +-- ── 4. Пересоздать сопутствующий индекс ────────────────────────────────────────────── +-- Старый индекс (без period_type в составе) уже покрыт новым PK-индексом по факту, +-- но явно дропаем и пересоздаём с period_type для консистентности плана запросов. +DROP INDEX IF EXISTS idx_macro_type_region_date; + +CREATE INDEX IF NOT EXISTS idx_macro_type_region_date + ON macro_indicator (indicator_type, region, obs_date DESC, period_type); + +COMMIT; From 26596003742e4280708a0c9d1af170420fc807dd Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 17 Jun 2026 21:45:12 +0300 Subject: [PATCH 2/2] fix(sql): emiss backfill period_type='quarter' to match scraper (#1606 review) --- data/sql/163_emiss_pk_period_type.sql | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/data/sql/163_emiss_pk_period_type.sql b/data/sql/163_emiss_pk_period_type.sql index 40e6a2ef..2aa64851 100644 --- a/data/sql/163_emiss_pk_period_type.sql +++ b/data/sql/163_emiss_pk_period_type.sql @@ -13,9 +13,11 @@ -- 1. Добавляет колонку period_type TEXT NOT NULL DEFAULT 'unknown'. -- Все существующие строки (CBR, rosstat open-data, domrf) получают 'unknown' — -- это корректно: у них нет sub-period granularity (они уже различаются по obs_date). --- 2. Бэкфилл: строки source='emiss' обновляем до 'quarterly' (единственный +-- 2. Бэкфилл: строки source='emiss' обновляем до 'quarter' (единственный -- активный ЕМИСС-индикатор — income_per_capita, он квартальный; годовых EMISS- --- строк ещё нет в таблице до этой миграции). +-- строк ещё нет в таблице до этой миграции). Значение ДОЛЖНО совпадать с тем, +-- что пишет UPSERT_EMISS_SQL (_emiss_period_granularity → 'quarter'/'year'/'month', +-- короткая форма), иначе ON CONFLICT не сматчит → дубли при следующем скрейпе. -- 3. Пересоздаёт PRIMARY KEY: старый (indicator_type, region, obs_date) → новый -- (indicator_type, region, obs_date, period_type). -- 4. Пересоздаёт сопутствующий индекс idx_macro_type_region_date с тем же составом @@ -38,11 +40,15 @@ BEGIN; ALTER TABLE macro_indicator ADD COLUMN IF NOT EXISTS period_type TEXT NOT NULL DEFAULT 'unknown'; --- ── 2. Бэкфилл ЕМИСС-строк → 'quarterly' ──────────────────────────────────────────── --- Все существующие ЕМИСС-строки — quarterly (income_per_capita). Если появятся 'year'- --- строки (будущие индикаторы) — они придут с корректным period_type из нового скрейпера. +-- ── 2. Бэкфилл ЕМИСС-строк → 'quarter' ──────────────────────────────────────────── +-- Все существующие ЕМИСС-строки — квартальные (income_per_capita). Значение 'quarter' +-- (короткая форма) обязано совпадать с UPSERT_EMISS_SQL, который параметризует +-- period_type из _emiss_period_granularity ('quarter'/'year'/'month'). Иначе после +-- деплоя следующий скрейп вставит 'quarter'-строки, не матчащие 'quarterly' на +-- ON CONFLICT → дубли для каждого квартального наблюдения. Если появятся 'year'-строки +-- (будущие индикаторы) — придут с корректным period_type из скрейпера. UPDATE macro_indicator - SET period_type = 'quarterly' + SET period_type = 'quarter' WHERE source = 'emiss' AND period_type = 'unknown'; @@ -60,16 +66,16 @@ EXCEPTION END; $$; --- Новый PK включает period_type. Если уже существует с правильным составом — --- ADD CONSTRAINT бросит duplicate_table (конвенция Pg: PK → unique index), ловим. +-- Новый PK включает period_type. Если constraint с этим именем уже существует — +-- ADD CONSTRAINT бросит duplicate_object (42710, дубль имени constraint), ловим. DO $$ BEGIN ALTER TABLE macro_indicator ADD CONSTRAINT macro_indicator_pkey PRIMARY KEY (indicator_type, region, obs_date, period_type); EXCEPTION - WHEN duplicate_table THEN - NULL; -- PK уже создан (повторный прогон) + WHEN duplicate_object THEN + NULL; -- constraint с таким именем уже создан (повторный прогон) WHEN invalid_table_definition THEN NULL; -- уже существует тот же constraint END;