feat(macro): land construction price index (СМР) via open Rosstat xlsx (#946)
All checks were successful
CI / changes (push) Successful in 6s
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Successful in 6m27s
Deploy / build-frontend (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / backend-tests (push) Successful in 6m29s
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m43s
Deploy / build-worker (push) Successful in 3m3s
Deploy / deploy (push) Successful in 1m15s
All checks were successful
CI / changes (push) Successful in 6s
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Successful in 6m27s
Deploy / build-frontend (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / backend-tests (push) Successful in 6m29s
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m43s
Deploy / build-worker (push) Successful in 3m3s
Deploy / deploy (push) Successful in 1m15s
Closes the last #946 gap (СМР). Fetches «Индексы цен производителей на строительную продукцию по РФ» from the OPEN Rosstat xlsx (rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_MM-YYYY.xlsx), parses the monthly «к предыдущему месяцу» section → macro_indicator (indicator_type='construction_price_index', region='rf', monthly, %). The reCAPTCHA-gated fedstat dataGrid.do path is DELIBERATELY NOT used — the open Rosstat source needs no captcha. EMISS id=31108 documented as a prod-only SDMX alternative (not wired). URL is date-derived with bounded look-back (filename changes monthly; _fetch_construction_latest walks current month back to 6, first 200 wins; year-boundary handled, exhaustion raises, 404 breaks fast — no hang). Wired into rosstat_macro_sync with per-source try/except (can't break demography/income) + SAVEPOINT-per-row. None-not-0 on blank months. 33 tests (incl look-back + year-boundary + graceful), ruff clean. Note: no construction-cost channel in macro_coefficient yet (demand-side only) — series is ingested/available for future cost-side use + display. Refs #946
This commit is contained in:
parent
4adfef2ad0
commit
f9e045028c
5 changed files with 601 additions and 19 deletions
|
|
@ -7,7 +7,7 @@
|
|||
Celery-таска ``rosstat_macro_sync`` через SAVEPOINT-per-row.
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
ИСТОЧНИК ДАННЫХ — open-data + ЕМИСС-SDMX (разведано live 2026-06-07)
|
||||
ИСТОЧНИК ДАННЫХ — open-data + ЕМИСС-SDMX + открытый xlsx (разведано live 2026-06-07/08)
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
ЕМИСС / fedstat.ru — за WAF с DEV-машины (hard-403), но ДОСТУПЕН С ПРОДА (verified
|
||||
2026-06-07: ``POST https://www.fedstat.ru/indicator/data.do`` body ``id=<id>&format=sdmx``
|
||||
|
|
@ -30,9 +30,11 @@ Celery-таска ``rosstat_macro_sync`` через SAVEPOINT-per-row.
|
|||
id кода (НЕ значение ОКАТО), берётся отдельным filter-tree AJAX; плюс на странице
|
||||
индикатора reCAPTCHA. Это глубокий RE — отложено (закрывается тем же EmissIndicatorSpec
|
||||
+ параметризацией data.do-выборки, когда добудем filter_id-маппинг).
|
||||
• construction_price_index (индекс цен на СМР) — id не подтверждён: кандидаты
|
||||
(31448 «Средние потреб. цены», 37426 «Еженедельные цены») — много-мерные и БЕЗ
|
||||
Свердл в дефолт-экспорте (та же проблема, что у ИПЦ). НЕ landed.
|
||||
• construction_price_index (индекс цен на СМР) — из ЕМИСС НЕ берётся (кандидат
|
||||
EMISS id=31108 «Индексы цен производителей на строительную продукцию» доступен тем
|
||||
же открытым SDMX-путём POST data.do, но fedstat за WAF с dev — не верифицируем
|
||||
здесь). ПРИЗЕМЛЯЕТСЯ из ОТКРЫТОГО xlsx Росстата (см. блок «Индекс цен на СМР» ниже,
|
||||
region='rf'), reCAPTCHA-gated dataGrid.do НАМЕРЕННО НЕ используется.
|
||||
|
||||
``rosstat.gov.ru/opendata`` — БЕЗ WAF, машиночитаем (российский стандарт открытых
|
||||
данных 4.0):
|
||||
|
|
@ -50,9 +52,16 @@ Celery-таска ``rosstat_macro_sync`` через SAVEPOINT-per-row.
|
|||
муниципальный срез; берём субъект-агрегат ОКТМО ``6500000000`` + г. Екатеринбург
|
||||
``657010000011000``). Это § 7.11 «демография (численность)».
|
||||
|
||||
Что НЕ приземляется из open-data (но среднедушевые доходы теперь идут через ЕМИСС-прод):
|
||||
• ИПЦ, индекс цен на СМР — EMISS-only и в open-data их нет (0 совпадений по реестру);
|
||||
из ЕМИСС пока не landed (много-мерные, см. EMISS_INDICATORS-блок выше).
|
||||
Индекс цен на СМР приземляется из ОТКРЫТОГО xlsx Росстата (НЕ из opendata-реестра — там
|
||||
0 совпадений; и НЕ из ЕМИСС/dataGrid.do):
|
||||
• construction_price_index — «Индексы цен производителей на строительную продукцию по
|
||||
РФ» (region='rf', месячный, к предыдущему месяцу, %). Прямая ссылка на xlsx со
|
||||
страницы rosstat.gov.ru «Цены»; reCAPTCHA-gated dataGrid.do НАМЕРЕННО НЕ используется
|
||||
(см. блок «Индекс цен на СМР» ниже + HARD SAFETY CONSTRAINT #946).
|
||||
|
||||
Что НЕ приземляется из open-data (но среднедушевые доходы идут через ЕМИСС-прод):
|
||||
• ИПЦ — EMISS-only и в open-data его нет (0 совпадений по реестру); из ЕМИСС пока не
|
||||
landed (много-мерный, см. EMISS_INDICATORS-блок выше).
|
||||
|
||||
Standalone smoke (ручная проверка; open-data — c dev, ЕМИСС-fetch — только с прода):
|
||||
python -m app.services.scrapers.rosstat_emiss
|
||||
|
|
@ -70,8 +79,11 @@ import xml.etree.ElementTree as ET
|
|||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from openpyxl import load_workbook
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -83,6 +95,19 @@ ROSSTAT_OPENDATA_BASE = "https://rosstat.gov.ru/opendata"
|
|||
# dev (403), доступен с прода (см. шапку модуля). verify off — тот же российский гос-УЦ.
|
||||
FEDSTAT_DATA_URL = "https://www.fedstat.ru/indicator/data.do"
|
||||
|
||||
# Индекс цен на строительную продукцию (СМР) — ОТКРЫТЫЙ xlsx Росстата (БЕЗ captcha,
|
||||
# БЕЗ dataGrid.do): страница «Цены» → «Индексы цен и средние цены строительной
|
||||
# продукции» → «Индексы цен производителей на строительную продукцию по РФ». Имя файла
|
||||
# несёт месяц-вёрстки (Invest_ind_stroitel_MM-YYYY.xlsx) и обновляется ежемесячно →
|
||||
# URL НЕ хардкодим (иначе протух бы каждый месяц и ingest тихо перестал бы тянуться).
|
||||
# Резолвим от текущей даты с look-back (Росстат публикует с лагом 1-3 мес): пробуем
|
||||
# текущий месяц и назад до _CONSTRUCTION_MAX_LOOKBACK, берём первый 200 — см.
|
||||
# _fetch_construction_latest. Доступен с dev (verify off, российский гос-УЦ). live 2026-06-08.
|
||||
ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE = (
|
||||
"https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_{mm:02d}-{yyyy}.xlsx"
|
||||
)
|
||||
_CONSTRUCTION_MAX_LOOKBACK = 6 # мес назад от текущего (запас на лаг публикации Росстата)
|
||||
|
||||
# Браузероподобный UA: rosstat.gov.ru отдаёт open-data без WAF, но «голый» httpx-UA
|
||||
# иногда ловит 403 на edge-кэше — даём обычный desktop-Chrome UA (как nspd_client).
|
||||
USER_AGENT = (
|
||||
|
|
@ -637,8 +662,9 @@ INCOME_PER_CAPITA_SPEC = EmissIndicatorSpec(
|
|||
comment="ЕМИСС id=57039 'Среднедушевые денежные доходы населения' (#946 §7.11)",
|
||||
)
|
||||
|
||||
# Активные ЕМИСС-индикаторы. ИПЦ (id=31074) / индекс цен на СМР пока НЕ здесь —
|
||||
# многомерные, дефолт-экспорт data.do без Свердл, нужна dataGrid-выборка (см. шапку).
|
||||
# Активные ЕМИСС-индикаторы. ИПЦ (id=31074) пока НЕ здесь — многомерный, дефолт-экспорт
|
||||
# data.do без Свердл, нужна dataGrid-выборка (см. шапку). Индекс цен на СМР берётся НЕ из
|
||||
# ЕМИСС, а из открытого xlsx Росстата (CONSTRUCTION_PRICE_SPEC ниже).
|
||||
EMISS_INDICATORS: tuple[EmissIndicatorSpec, ...] = (INCOME_PER_CAPITA_SPEC,)
|
||||
|
||||
|
||||
|
|
@ -846,6 +872,239 @@ def fetch_all_emiss(
|
|||
return out
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════════
|
||||
# Индекс цен на строительную продукцию (СМР) — ОТКРЫТЫЙ xlsx Росстата (#946, EPIC2)
|
||||
# ══════════════════════════════════════════════════════════════════════════════════
|
||||
# Источник (разведано live 2026-06-08, fetch работает С DEV): страница
|
||||
# https://rosstat.gov.ru/statistics/price → раздел «строительная продукция» →
|
||||
# xlsx ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE «Индексы цен производителей на строительную
|
||||
# продукцию по РФ в 1995-2025 гг.». reCAPTCHA-gated dataGrid.do НЕ используется —
|
||||
# это статический файл прямой ссылкой (см. HARD SAFETY CONSTRAINT в шапке #946).
|
||||
# Форма листа «1» (широкий pivot, верифицировано на реальном файле):
|
||||
# r3 — заголовок; r5 — «на конец периода, в %»;
|
||||
# r6 — годы по колонкам (B..) c возможным сноска-маркером ('2022 1)' и т.п.);
|
||||
# r7 — секция «К предыдущему месяцу» (то, что берём: ИСТИННЫЙ месячный ряд);
|
||||
# r8..r19 — месяцы Январь..Декабрь, ячейка = индекс % за (месяц, год-колонка);
|
||||
# r20 — секция «К декабрю предыдущего года» (НЕ берём — иной базис, годовой).
|
||||
# Один (месяц, год) → одно наблюдение; obs_date = date(year, month, 1) — как и прочие
|
||||
# месячные ряды нормализуем к 1-му числу. Регион = 'rf' (файл — общефедеральный).
|
||||
# Будущие/неопубликованные месяцы текущего года приходят пустой ячейкой → None → скип
|
||||
# (None-not-0: пропуск, НИКОГДА не подставляем 0). Парсер pure → dev-тестируем на фикстуре.
|
||||
|
||||
# Заголовки секций листа: какую берём (месячная), какую игнорируем (годовая к декабрю).
|
||||
_CONSTR_SECTION_MONTHLY = "к предыдущему месяцу"
|
||||
_CONSTR_SECTION_TO_DEC = "к декабрю предыдущего года"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class XlsxIndicatorSpec:
|
||||
"""Описание приземляемого ряда из открытого xlsx Росстата (широкий pivot год×месяц).
|
||||
|
||||
Атрибуты:
|
||||
url_template: шаблон ссылки на xlsx с плейсхолдерами {mm:02d}/{yyyy}
|
||||
(ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE) — резолвится по дате в fetch.
|
||||
sheet: имя листа с данными (для construction — '1').
|
||||
indicator_type: тип ряда в macro_indicator ('construction_price_index').
|
||||
region: метка region (общефедеральный файл → 'rf').
|
||||
unit: единица (для индекса цен — '%'; справочно, в PK не участвует).
|
||||
frequency: 'monthly' — берём секцию «К предыдущему месяцу».
|
||||
comment: пояснение для колонки comment macro_indicator.
|
||||
"""
|
||||
|
||||
url_template: str
|
||||
sheet: str
|
||||
indicator_type: str
|
||||
region: str
|
||||
unit: str
|
||||
frequency: str
|
||||
comment: str = ""
|
||||
|
||||
|
||||
CONSTRUCTION_PRICE_SPEC = XlsxIndicatorSpec(
|
||||
url_template=ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE,
|
||||
sheet="1",
|
||||
indicator_type="construction_price_index",
|
||||
region="rf",
|
||||
unit="%",
|
||||
frequency="monthly",
|
||||
comment=(
|
||||
"Росстат 'Индексы цен производителей на строительную продукцию по РФ', "
|
||||
"к предыдущему месяцу (#946 §7.11; СМР, open-data, без dataGrid.do/captcha)"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _parse_year_cell(raw: Any) -> int | None:
|
||||
"""Год из ячейки-заголовка → int. Допускает сноска-маркер ('2022 1)' → 2022) и
|
||||
числовой год (openpyxl отдаёт int). Не-год / пусто → None (колонка пропускается)."""
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, int):
|
||||
return raw if 1900 <= raw <= 2100 else None
|
||||
digits = str(raw).strip()[:4]
|
||||
if digits.isdigit():
|
||||
year = int(digits)
|
||||
return year if 1900 <= year <= 2100 else None
|
||||
return None
|
||||
|
||||
|
||||
def parse_construction_index_xlsx(
|
||||
content: bytes, spec: XlsxIndicatorSpec
|
||||
) -> list[MacroRow]:
|
||||
"""Pure-парсер открытого xlsx Росстата (индекс цен на СМР) → список MacroRow.
|
||||
|
||||
Алгоритм (форма листа — см. блок выше):
|
||||
1. Открывает лист spec.sheet. Находит строку годов-заголовков (первая, где ≥2
|
||||
ячеек распарсились как год через _parse_year_cell) → карта колонка→год.
|
||||
2. Идёт по строкам ПОСЛЕ заголовка только внутри секции «К предыдущему месяцу»
|
||||
(по достижении секции «К декабрю предыдущего года» / иной — останавливается).
|
||||
Строка месяца (ячейка A ∈ _MONTH_NAME_TO_NUM) → для каждой год-колонки берёт
|
||||
значение через _parse_value; пусто/нечисло → пропуск (None-not-0).
|
||||
3. obs_date = date(year, month, 1). Дубли по (region, obs_date) схлопываются.
|
||||
|
||||
Graceful: пустой/битый xlsx, нет листа, нет строки годов → [] (+warning), не бросает.
|
||||
"""
|
||||
if not content:
|
||||
return []
|
||||
try:
|
||||
wb = load_workbook(BytesIO(content), read_only=True, data_only=True)
|
||||
except Exception as e: # openpyxl бросает разные типы на битом файле
|
||||
logger.warning("Rosstat construction: не удалось открыть xlsx: %s", e)
|
||||
return []
|
||||
try:
|
||||
if spec.sheet not in wb.sheetnames:
|
||||
logger.warning("Rosstat construction: нет листа %r в xlsx", spec.sheet)
|
||||
return []
|
||||
rows = list(wb[spec.sheet].iter_rows(values_only=True))
|
||||
finally:
|
||||
wb.close()
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
# 1. Строка годов: первая, где ≥2 ячеек (после A) распарсились как год.
|
||||
year_by_col: dict[int, int] = {}
|
||||
header_idx: int | None = None
|
||||
for r_idx, row in enumerate(rows):
|
||||
cols = {c: y for c in range(1, len(row)) if (y := _parse_year_cell(row[c]))}
|
||||
if len(cols) >= 2:
|
||||
header_idx, year_by_col = r_idx, cols
|
||||
break
|
||||
if header_idx is None:
|
||||
logger.warning("Rosstat construction: не найдена строка годов-заголовков")
|
||||
return []
|
||||
|
||||
# 2. Месяцы внутри секции «К предыдущему месяцу» (до начала годовой секции).
|
||||
by_key: dict[tuple[str, date], MacroRow] = {}
|
||||
in_monthly = False
|
||||
for row in rows[header_idx + 1 :]:
|
||||
if not row:
|
||||
continue
|
||||
label = str(row[0] or "").strip().lower()
|
||||
if label == _CONSTR_SECTION_MONTHLY:
|
||||
in_monthly = True
|
||||
continue
|
||||
if label == _CONSTR_SECTION_TO_DEC or (label and "к декабрю" in label):
|
||||
break # дальше идёт годовой базис — нам не нужен
|
||||
month = _MONTH_NAME_TO_NUM.get(label)
|
||||
if month is None or not in_monthly:
|
||||
continue
|
||||
for col, year in year_by_col.items():
|
||||
if col >= len(row):
|
||||
continue
|
||||
value = _parse_value(str(row[col]) if row[col] is not None else "")
|
||||
if value is None:
|
||||
continue # пустой/будущий месяц → пропуск (None-not-0)
|
||||
obs_date = date(year, month, 1)
|
||||
by_key[(spec.region, obs_date)] = MacroRow(
|
||||
indicator_type=spec.indicator_type,
|
||||
region=spec.region,
|
||||
obs_date=obs_date,
|
||||
value=value,
|
||||
unit=spec.unit,
|
||||
comment=spec.comment,
|
||||
)
|
||||
|
||||
return [by_key[k] for k in sorted(by_key, key=lambda k: (k[0], k[1]))]
|
||||
|
||||
|
||||
def _fetch_construction_latest(
|
||||
client: httpx.Client,
|
||||
*,
|
||||
url_template: str,
|
||||
today: date,
|
||||
retries: int = DEFAULT_RETRIES,
|
||||
max_lookback: int = _CONSTRUCTION_MAX_LOOKBACK,
|
||||
) -> bytes:
|
||||
"""Тело свежайшего xlsx индекса цен на СМР: резолв от today с look-back по месяцам.
|
||||
|
||||
Имя файла Росстата несёт месяц-вёрстки (Invest_ind_stroitel_MM-YYYY.xlsx) и меняется
|
||||
ежемесячно с лагом публикации. Пробуем текущий месяц и назад до max_lookback мес —
|
||||
возвращаем тело первого ответившего 200 (404 у _http_get ломается сразу, без ретрая,
|
||||
поэтому отсутствующие месяцы дёшевы). dataGrid.do/captcha НЕ задействованы.
|
||||
|
||||
Raises:
|
||||
RosstatScraperError: если ни один кандидат (today..-max_lookback мес) не отдал 200.
|
||||
"""
|
||||
year, month = today.year, today.month
|
||||
last_exc: Exception | None = None
|
||||
for _ in range(max_lookback + 1):
|
||||
url = url_template.format(mm=month, yyyy=year)
|
||||
try:
|
||||
return _http_get(client, url, retries=retries)
|
||||
except RosstatScraperError as exc:
|
||||
last_exc = exc
|
||||
logger.info("Rosstat СМР xlsx нет за %02d-%d, пробуем предыдущий месяц", month, year)
|
||||
month -= 1
|
||||
if month == 0:
|
||||
month, year = 12, year - 1
|
||||
raise RosstatScraperError(
|
||||
f"Rosstat СМР xlsx: не найден за {max_lookback + 1} мес от {today.isoformat()}: {last_exc}"
|
||||
)
|
||||
|
||||
|
||||
def fetch_construction_index(
|
||||
spec: XlsxIndicatorSpec = CONSTRUCTION_PRICE_SPEC,
|
||||
*,
|
||||
today: date | None = None,
|
||||
timeout_s: float = DEFAULT_TIMEOUT_S,
|
||||
retries: int = DEFAULT_RETRIES,
|
||||
client: httpx.Client | None = None,
|
||||
) -> list[MacroRow]:
|
||||
"""Загрузить открытый xlsx Росстата (индекс цен на СМР) → список MacroRow.
|
||||
|
||||
URL резолвится по дате с look-back (имя файла меняется ежемесячно — см.
|
||||
_fetch_construction_latest), затем pure-парсинг. Работает С DEV (verify off,
|
||||
российский гос-УЦ — как и прочие rosstat.gov.ru-эндпоинты). dataGrid.do/captcha
|
||||
НЕ задействованы. today=None → date.today() (инъекция для тестов).
|
||||
|
||||
Raises:
|
||||
RosstatScraperError: на сетевой/HTTP-ошибке после ретраев / если файл не найден.
|
||||
"""
|
||||
own_client = client is None
|
||||
if client is None:
|
||||
client = httpx.Client(
|
||||
timeout=timeout_s,
|
||||
headers={"User-Agent": USER_AGENT, "Accept-Language": "ru-RU,ru;q=0.9"},
|
||||
follow_redirects=True,
|
||||
verify=_VERIFY_TLS,
|
||||
)
|
||||
try:
|
||||
content = _fetch_construction_latest(
|
||||
client, url_template=spec.url_template, today=today or date.today(), retries=retries
|
||||
)
|
||||
rows = parse_construction_index_xlsx(content, spec)
|
||||
logger.info(
|
||||
"Rosstat construction (%s): распарсено %d месячных наблюдений (region=%s)",
|
||||
spec.indicator_type,
|
||||
len(rows),
|
||||
spec.region,
|
||||
)
|
||||
return rows
|
||||
finally:
|
||||
if own_client:
|
||||
client.close()
|
||||
|
||||
|
||||
# ── standalone smoke ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -867,6 +1126,19 @@ def _main() -> None:
|
|||
if not rows:
|
||||
logger.warning("No rows — проверь доступность rosstat.gov.ru/opendata.")
|
||||
|
||||
# Индекс цен на СМР — открытый xlsx, тоже доступен с dev (печатаем хвост ряда).
|
||||
constr = fetch_construction_index()
|
||||
logger.info("Rosstat construction rows fetched: %d", len(constr))
|
||||
for r in constr[-6:]:
|
||||
logger.info(
|
||||
" %s / %s @ %s = %s %s",
|
||||
r.indicator_type,
|
||||
r.region,
|
||||
r.obs_date.isoformat(),
|
||||
r.value,
|
||||
r.unit,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
"""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'.
|
||||
(среднедушевые доходы, ОКАТО 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_emiss).
|
||||
с прода (fedstat за WAF с dev); индекс цен на СМР теперь landed через открытый
|
||||
rosstat.gov.ru-xlsx (reCAPTCHA-gated dataGrid.do НАМЕРЕННО НЕ используется); ИПЦ пока
|
||||
не landed (см. rosstat_emiss).
|
||||
|
||||
Детерминированно, без LLM. ON CONFLICT делает повторные прогоны идемпотентными
|
||||
(re-run обновляет value + updated_at). Расписание — ежемесячно (эти ряды
|
||||
|
|
@ -35,6 +40,7 @@ from app.services.scrapers.rosstat_emiss import (
|
|||
MacroRow,
|
||||
fetch_all_datasets,
|
||||
fetch_all_emiss,
|
||||
fetch_construction_index,
|
||||
)
|
||||
from app.workers.celery_app import celery_app
|
||||
|
||||
|
|
@ -88,6 +94,30 @@ 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.
|
||||
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, чтобы один битый
|
||||
|
|
@ -121,6 +151,39 @@ def _upsert_rows(db: Session, rows: list[MacroRow]) -> int:
|
|||
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, чтобы один битый ряд не откатывал всю транзакцию. Возвращает
|
||||
|
|
@ -161,25 +224,27 @@ def _upsert_emiss_rows(db: Session, rows: list[EmissRow]) -> int:
|
|||
max_retries=2,
|
||||
)
|
||||
def rosstat_macro_sync(self: Any) -> dict[str, Any]:
|
||||
"""Загрузить региональные ряды Росстата (open-data + ЕМИСС) и апсертить в macro_indicator.
|
||||
"""Загрузить ряды Росстата (open-data + ЕМИСС + xlsx-СМР) и апсертить в macro_indicator.
|
||||
|
||||
Источники выполняются НЕЗАВИСИМО (per-source try/except): сбой одного источника
|
||||
логируется, апсертит здоровый источник, и в конце ошибка ПРОБРАСЫВАЕТСЯ (чтобы фейл
|
||||
был виден в Celery / GlitchTip, а не молча проглочен). Re-run идемпотентен (ON CONFLICT),
|
||||
поэтому Celery-retry безопасно повторит оба источника.
|
||||
поэтому Celery-retry безопасно повторит все источники.
|
||||
|
||||
Returns:
|
||||
Счётчики ``{"fetched": N, "upserted": M, "rosstat": ..., "emiss": ...}``.
|
||||
Счётчики ``{"fetched": N, "upserted": M, "rosstat": ..., "emiss": ...,
|
||||
"construction": ...}``.
|
||||
|
||||
Raises:
|
||||
Первую пойманную ошибку любого источника — ПОСЛЕ попытки обоих (surfaces в
|
||||
Первую пойманную ошибку любого источника — ПОСЛЕ попытки всех трёх (surfaces в
|
||||
Celery/GlitchTip). Частичный сбой отдельного датасета/индикатора внутри источника
|
||||
НЕ роняет источник (fetch_all_* graceful per-item).
|
||||
НЕ роняет источник (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:
|
||||
|
|
@ -209,14 +274,30 @@ def rosstat_macro_sync(self: Any) -> dict[str, Any]:
|
|||
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,
|
||||
"upserted": rosstat_upserted + emiss_upserted,
|
||||
"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()
|
||||
|
|
|
|||
BIN
backend/tests/fixtures/rosstat_construction_stroitel.xlsx
vendored
Normal file
BIN
backend/tests/fixtures/rosstat_construction_stroitel.xlsx
vendored
Normal file
Binary file not shown.
|
|
@ -11,19 +11,32 @@ from __future__ import annotations
|
|||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.scrapers.rosstat_emiss import (
|
||||
CONSTRUCTION_PRICE_SPEC,
|
||||
OKTMO_EKATERINBURG,
|
||||
OKTMO_SVERDLOVSK_OBLAST,
|
||||
POPULATION_SPEC,
|
||||
DatasetSpec,
|
||||
RegionTarget,
|
||||
_parse_value,
|
||||
_parse_year_cell,
|
||||
parse_construction_index_xlsx,
|
||||
parse_dataset_rows,
|
||||
parse_meta_data_url,
|
||||
parse_meta_modified_year,
|
||||
)
|
||||
|
||||
# Фикстура: обрезанный РЕАЛЬНЫЙ xlsx Росстата «Индексы цен производителей на строительную
|
||||
# продукцию по РФ» (лист '1', секции «К предыдущему месяцу» 2022-2025 + «К декабрю
|
||||
# предыдущего года»; снято live 2026-06-08 с Invest_ind_stroitel_*.xlsx). Декабрь-2025
|
||||
# намеренно ПУСТ (будущий/неопубликованный месяц → проверка None-not-0 пропуска).
|
||||
_FIXTURES = Path(__file__).resolve().parent.parent / "fixtures"
|
||||
CONSTRUCTION_XLSX = (_FIXTURES / "rosstat_construction_stroitel.xlsx").read_bytes()
|
||||
|
||||
# Реальный shape meta.csv Росстата (open-data 4.0): property,value; строка данных —
|
||||
# property 'data-<ts>-structure-<ts>.csv' → абсолютный URL CSV-данных.
|
||||
FIXTURE_META = (
|
||||
|
|
@ -171,3 +184,133 @@ def test_oktmo_constants_used_in_spec() -> None:
|
|||
oktmos = {t.oktmo for t in POPULATION_SPEC.targets}
|
||||
assert OKTMO_SVERDLOVSK_OBLAST in oktmos
|
||||
assert OKTMO_EKATERINBURG in oktmos
|
||||
|
||||
|
||||
# ── индекс цен на СМР (открытый xlsx Росстата) ────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_year_cell_plain_and_footnote() -> None:
|
||||
"""Год из ячейки: чистый int, строка-год и сноска-маркер ('2022 1)' → 2022)."""
|
||||
assert _parse_year_cell(2024) == 2024
|
||||
assert _parse_year_cell("2022 1)") == 2022
|
||||
assert _parse_year_cell("2025") == 2025
|
||||
# не-год / пусто / вне диапазона → None
|
||||
assert _parse_year_cell(None) is None
|
||||
assert _parse_year_cell("Январь") is None
|
||||
assert _parse_year_cell(1800) is None
|
||||
|
||||
|
||||
def test_construction_spec_contract() -> None:
|
||||
"""Спека СМР: тип, region='rf', monthly, % — открытый xlsx, без dataGrid.do/captcha."""
|
||||
assert CONSTRUCTION_PRICE_SPEC.indicator_type == "construction_price_index"
|
||||
assert CONSTRUCTION_PRICE_SPEC.region == "rf"
|
||||
assert CONSTRUCTION_PRICE_SPEC.frequency == "monthly"
|
||||
assert CONSTRUCTION_PRICE_SPEC.unit == "%"
|
||||
assert "dataGrid" not in CONSTRUCTION_PRICE_SPEC.url_template
|
||||
assert CONSTRUCTION_PRICE_SPEC.url_template.startswith("https://rosstat.gov.ru/")
|
||||
assert "{mm:02d}" in CONSTRUCTION_PRICE_SPEC.url_template # резолв по дате, НЕ хардкод месяца
|
||||
|
||||
|
||||
def test_construction_url_resolves_with_lookback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Имя файла меняется ежемесячно → резолвер идёт назад до первого 200 (не хардкод)."""
|
||||
from app.services.scrapers import rosstat_emiss as mod
|
||||
|
||||
tried: list[str] = []
|
||||
|
||||
def fake_get(client: object, url: str, *, retries: int = 0) -> bytes:
|
||||
tried.append(url)
|
||||
if len(tried) < 3: # первые два месяца → 404
|
||||
raise mod.RosstatScraperError(f"HTTP 404 {url}")
|
||||
return CONSTRUCTION_XLSX
|
||||
|
||||
monkeypatch.setattr(mod, "_http_get", fake_get)
|
||||
content = mod._fetch_construction_latest(
|
||||
client=object(), # type: ignore[arg-type] # не используется (get замокан)
|
||||
url_template=mod.ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE,
|
||||
today=date(2026, 6, 15),
|
||||
)
|
||||
assert content == CONSTRUCTION_XLSX
|
||||
assert tried == [
|
||||
"https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_06-2026.xlsx",
|
||||
"https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_05-2026.xlsx",
|
||||
"https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_04-2026.xlsx",
|
||||
]
|
||||
|
||||
|
||||
def test_construction_url_lookback_year_boundary_and_exhaustion(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Все кандидаты 404 → RosstatScraperError; look-back корректно переходит через год."""
|
||||
from app.services.scrapers import rosstat_emiss as mod
|
||||
|
||||
tried: list[str] = []
|
||||
|
||||
def fake_get(client: object, url: str, *, retries: int = 0) -> bytes:
|
||||
tried.append(url)
|
||||
raise mod.RosstatScraperError(f"HTTP 404 {url}")
|
||||
|
||||
monkeypatch.setattr(mod, "_http_get", fake_get)
|
||||
with pytest.raises(mod.RosstatScraperError):
|
||||
mod._fetch_construction_latest(
|
||||
client=object(), # type: ignore[arg-type]
|
||||
url_template=mod.ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE,
|
||||
today=date(2026, 2, 15),
|
||||
max_lookback=3,
|
||||
)
|
||||
assert tried == [ # переход через год: 02-2026 → 01-2026 → 12-2025 → 11-2025
|
||||
"https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_02-2026.xlsx",
|
||||
"https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_01-2026.xlsx",
|
||||
"https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_12-2025.xlsx",
|
||||
"https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_11-2025.xlsx",
|
||||
]
|
||||
|
||||
|
||||
def test_parse_construction_monthly_series() -> None:
|
||||
"""xlsx → месячный ряд СМР: region='rf', obs_date к 1-му числу, значение в %."""
|
||||
rows = parse_construction_index_xlsx(CONSTRUCTION_XLSX, CONSTRUCTION_PRICE_SPEC)
|
||||
assert rows, "ожидались строки из фикстуры"
|
||||
assert all(r.region == "rf" for r in rows)
|
||||
assert all(r.indicator_type == "construction_price_index" for r in rows)
|
||||
assert all(r.unit == "%" for r in rows)
|
||||
assert all(r.obs_date.day == 1 for r in rows)
|
||||
by_date = {r.obs_date: r.value for r in rows}
|
||||
# реальные значения «к предыдущему месяцу» из фикстуры (2022-2025)
|
||||
assert by_date[date(2022, 1, 1)] == Decimal("100.6")
|
||||
assert by_date[date(2025, 1, 1)] == Decimal("100.8")
|
||||
# годы покрывают 2022..2025
|
||||
assert {d.year for d in by_date} == {2022, 2023, 2024, 2025}
|
||||
|
||||
|
||||
def test_parse_construction_skips_blank_future_month() -> None:
|
||||
"""Пустая ячейка будущего/неопубликованного месяца → пропуск (None-not-0)."""
|
||||
rows = parse_construction_index_xlsx(CONSTRUCTION_XLSX, CONSTRUCTION_PRICE_SPEC)
|
||||
dates = {r.obs_date for r in rows}
|
||||
# Декабрь-2025 в фикстуре намеренно пуст → его НЕ должно быть (а не 0)
|
||||
assert date(2025, 12, 1) not in dates
|
||||
assert date(2025, 11, 1) in dates # соседний заполненный — есть
|
||||
|
||||
|
||||
def test_parse_construction_ignores_annual_section() -> None:
|
||||
"""Секция «К декабрю предыдущего года» (годовой базис) НЕ попадает в месячный ряд.
|
||||
|
||||
Годовые декабрьские значения большие (>105), месячные — около 100. Убеждаемся, что
|
||||
ни одно декабрьское наблюдение не унаследовало годовое значение.
|
||||
"""
|
||||
rows = parse_construction_index_xlsx(CONSTRUCTION_XLSX, CONSTRUCTION_PRICE_SPEC)
|
||||
dec_vals = [r.value for r in rows if r.obs_date.month == 12]
|
||||
assert dec_vals, "ожидались декабрьские месячные наблюдения"
|
||||
assert all(v < Decimal("105") for v in dec_vals)
|
||||
|
||||
|
||||
def test_parse_construction_empty_and_garbage_graceful() -> None:
|
||||
"""Пустые байты / не-xlsx → [] (+warning), не бросает."""
|
||||
assert parse_construction_index_xlsx(b"", CONSTRUCTION_PRICE_SPEC) == []
|
||||
assert parse_construction_index_xlsx(b"not a zip", CONSTRUCTION_PRICE_SPEC) == []
|
||||
|
||||
|
||||
def test_parse_construction_missing_sheet_graceful() -> None:
|
||||
"""Если запрошенного листа нет в книге → [] (+warning)."""
|
||||
from dataclasses import replace
|
||||
|
||||
spec_bad = replace(CONSTRUCTION_PRICE_SPEC, sheet="НЕТ_ТАКОГО")
|
||||
assert parse_construction_index_xlsx(CONSTRUCTION_XLSX, spec_bad) == []
|
||||
|
|
|
|||
|
|
@ -69,15 +69,18 @@ def test_task_upserts_each_row_with_correct_params() -> None:
|
|||
patch.object(task_mod, "SessionLocal", return_value=db),
|
||||
patch.object(task_mod, "fetch_all_datasets", return_value=fake_rows) as mock_fetch,
|
||||
patch.object(task_mod, "fetch_all_emiss", return_value=[]) as mock_emiss,
|
||||
patch.object(task_mod, "fetch_construction_index", return_value=[]) as mock_constr,
|
||||
):
|
||||
result = task_mod.rosstat_macro_sync.run()
|
||||
|
||||
mock_fetch.assert_called_once()
|
||||
mock_emiss.assert_called_once()
|
||||
mock_constr.assert_called_once()
|
||||
assert result["fetched"] == 2
|
||||
assert result["upserted"] == 2
|
||||
assert result["rosstat"] == {"fetched": 2, "upserted": 2}
|
||||
assert result["emiss"] == {"fetched": 0, "upserted": 0}
|
||||
assert result["construction"] == {"fetched": 0, "upserted": 0}
|
||||
assert captured == [
|
||||
{
|
||||
"itype": "population_total",
|
||||
|
|
@ -113,6 +116,7 @@ def test_task_reraises_fetch_failure() -> None:
|
|||
patch.object(task_mod, "SessionLocal", return_value=db),
|
||||
patch.object(task_mod, "fetch_all_datasets", side_effect=RuntimeError("rosstat down")),
|
||||
patch.object(task_mod, "fetch_all_emiss", return_value=[]),
|
||||
patch.object(task_mod, "fetch_construction_index", return_value=[]),
|
||||
):
|
||||
try:
|
||||
task_mod.rosstat_macro_sync.run()
|
||||
|
|
@ -137,6 +141,7 @@ def test_task_emiss_failure_does_not_block_opendata() -> None:
|
|||
patch.object(task_mod, "SessionLocal", return_value=db),
|
||||
patch.object(task_mod, "fetch_all_datasets", return_value=rosstat_rows),
|
||||
patch.object(task_mod, "fetch_all_emiss", side_effect=RuntimeError("emiss down")),
|
||||
patch.object(task_mod, "fetch_construction_index", return_value=[]),
|
||||
):
|
||||
try:
|
||||
task_mod.rosstat_macro_sync.run()
|
||||
|
|
@ -166,6 +171,7 @@ def test_task_upserts_emiss_rows_with_correct_params() -> None:
|
|||
patch.object(task_mod, "SessionLocal", return_value=db),
|
||||
patch.object(task_mod, "fetch_all_datasets", return_value=[]),
|
||||
patch.object(task_mod, "fetch_all_emiss", return_value=emiss_rows),
|
||||
patch.object(task_mod, "fetch_construction_index", return_value=[]),
|
||||
):
|
||||
result = task_mod.rosstat_macro_sync.run()
|
||||
|
||||
|
|
@ -198,6 +204,86 @@ def test_upsert_emiss_sql_contract() -> None:
|
|||
assert "::" not in sql
|
||||
|
||||
|
||||
def test_upsert_rosstat_monthly_sql_contract() -> None:
|
||||
"""UPSERT_ROSSTAT_MONTHLY_SQL: source='rosstat', frequency='monthly', CAST not ::."""
|
||||
from app.workers.tasks.rosstat_macro_sync import UPSERT_ROSSTAT_MONTHLY_SQL
|
||||
|
||||
sql = str(UPSERT_ROSSTAT_MONTHLY_SQL)
|
||||
upper = sql.upper()
|
||||
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 "CAST(:ITYPE AS TEXT)" in upper
|
||||
assert "CAST(:D AS DATE)" in upper
|
||||
assert "CAST(:V AS NUMERIC)" in upper
|
||||
assert "::" not in sql
|
||||
|
||||
|
||||
def test_task_upserts_construction_rows_with_correct_params() -> None:
|
||||
"""Каждая construction MacroRow → execute UPSERT_ROSSTAT_MONTHLY_SQL c bind-параметрами."""
|
||||
from app.services.scrapers.rosstat_emiss import MacroRow
|
||||
from app.workers.tasks import rosstat_macro_sync as task_mod
|
||||
|
||||
db, captured = _make_mock_db()
|
||||
constr_rows = [
|
||||
MacroRow(
|
||||
"construction_price_index", "rf", date(2025, 1, 1),
|
||||
Decimal("100.8"), "%", "smr",
|
||||
),
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(task_mod, "SessionLocal", return_value=db),
|
||||
patch.object(task_mod, "fetch_all_datasets", return_value=[]),
|
||||
patch.object(task_mod, "fetch_all_emiss", return_value=[]),
|
||||
patch.object(task_mod, "fetch_construction_index", return_value=constr_rows),
|
||||
):
|
||||
result = task_mod.rosstat_macro_sync.run()
|
||||
|
||||
assert result["construction"] == {"fetched": 1, "upserted": 1}
|
||||
assert result["fetched"] == 1
|
||||
assert captured == [
|
||||
{
|
||||
"itype": "construction_price_index",
|
||||
"region": "rf",
|
||||
"d": date(2025, 1, 1),
|
||||
"v": Decimal("100.8"),
|
||||
"unit": "%",
|
||||
"comment": "smr",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_task_construction_failure_does_not_block_others() -> None:
|
||||
"""Per-source guard: сбой СМР-источника НЕ мешает open-data/ЕМИСС, ошибка пробрасывается."""
|
||||
from app.services.scrapers.rosstat_emiss import MacroRow
|
||||
from app.workers.tasks import rosstat_macro_sync as task_mod
|
||||
|
||||
db, captured = _make_mock_db()
|
||||
rosstat_rows = [
|
||||
MacroRow("population_total", "sverdl", date(2014, 1, 1), Decimal("4320677"), "чел", "c"),
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(task_mod, "SessionLocal", return_value=db),
|
||||
patch.object(task_mod, "fetch_all_datasets", return_value=rosstat_rows),
|
||||
patch.object(task_mod, "fetch_all_emiss", return_value=[]),
|
||||
patch.object(
|
||||
task_mod, "fetch_construction_index", side_effect=RuntimeError("rosstat xlsx down")
|
||||
),
|
||||
):
|
||||
try:
|
||||
task_mod.rosstat_macro_sync.run()
|
||||
raise AssertionError("expected RuntimeError to propagate")
|
||||
except RuntimeError as e:
|
||||
assert "rosstat xlsx down" in str(e)
|
||||
|
||||
# open-data всё равно апсертнулся ДО проброса construction-ошибки
|
||||
assert any(p.get("itype") == "population_total" for p in captured)
|
||||
db.close.assert_called_once()
|
||||
|
||||
|
||||
def test_task_registered_in_beat_monthly() -> None:
|
||||
"""Задача зарегистрирована в beat как ежемесячная (1-е число)."""
|
||||
from app.workers.beat_schedule import build_beat_schedule
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue