diff --git a/backend/app/services/site_finder/eesk_reserve_loader.py b/backend/app/services/site_finder/eesk_reserve_loader.py index 937e3b29..2677b82c 100644 --- a/backend/app/services/site_finder/eesk_reserve_loader.py +++ b/backend/app/services/site_finder/eesk_reserve_loader.py @@ -246,22 +246,19 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) -> reserve_unit = 'МВт', installed_capacity_mva = :installed, district = :district, - -- #2464-B: сюда БОЛЬШЕ НЕ пишем степень загрузки. - -- load_index — категориальная колонка - -- ('open'|'limited'|'closed'|NULL, см. - -- data/sql/180_connection_capacity.sql:35), её - -- заполняет rosseti_wfs_loader._map_load_index. - -- Раньше тут стоял COALESCE(load_index, - -- CAST(:load_pct AS text)) — при пустой ячейке - -- в колонку легло бы число строкой ("72.5"), - -- а фронтовый classifyLoadIndex такое значение - -- отбрасывает в null («неизвестно»), и в - -- power_summary.by_load_index появился бы - -- бакет с именем "72.5". - -- Сегодня не стреляло только потому, что у всех - -- 3416 строк load_index уже заполнен - -- (open 2741 / limited 346 / closed 329, NULL 0) - -- и COALESCE не проваливался. + -- #2464-B: сюда БОЛЬШЕ НЕ пишем степень загрузки: + -- load_index — категориальная колонка, её заполняет + -- rosseti_wfs_loader._map_load_index. Историю см. в + -- git log этого файла. + -- + -- ВАЖНО: в этом комментарии НЕЛЬЗЯ упоминать + -- бинд-параметры в синтаксисе «двоеточие + имя». + -- SQLAlchemy text() парсит бинды и внутри + -- SQL-комментариев: упоминание снятого параметра + -- «(двоеточие)load_pct» в тексте комментария + -- сделало его ОБЯЗАТЕЛЬНЫМ, все 71 UPDATE падали + -- с 18.08 по 02.09, а per-row except глотал это + -- как «битую строку» — задача оставалась зелёной. capacity_source = 'eesk_35_220', reserve_asof = :asof WHERE sc_name_norm = :name_norm diff --git a/backend/tests/test_eesk_loader_bind_params.py b/backend/tests/test_eesk_loader_bind_params.py new file mode 100644 index 00000000..9939ba48 --- /dev/null +++ b/backend/tests/test_eesk_loader_bind_params.py @@ -0,0 +1,68 @@ +"""Бинды SQL-стейтментов ЕЭСК-лоадера обязаны совпадать с передаваемыми params. + +ПОЧЕМУ ЭТОТ ТЕСТ СУЩЕСТВУЕТ. 18.08-02.09.2026 все 71 UPDATE резервов ПС +падали, а задача оставалась зелёной: упоминание СНЯТОГО параметра load_pct +в SQL-КОММЕНТАРИИ (в синтаксисе «двоеточие+имя») SQLAlchemy text() распарсил +как обязательный бинд, params его не содержал, каждая строка падала на +compile, а per-row except глотал это как «битую строку». ЕЭСК-резервы не +обновлялись две недели, freshness этого не видел (задача done). + +Тест ловит МЕХАНИЗМ: собирает все text()-стейтменты модуля и сверяет их +бинд-имена с ключами словаря params из того же контекста. Никакой БД не +нужно — дефект живёт на этапе компиляции стейтмента. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from sqlalchemy import text + +LOADER = ( + Path(__file__).resolve().parents[1] + / "app" + / "services" + / "site_finder" + / "eesk_reserve_loader.py" +) + + +def _extract_update_sql() -> str: + """Достаёт текст UPDATE power_supply_centers из исходника лоадера.""" + src = LOADER.read_text(encoding="utf-8") + m = re.search(r'text\("""\s*(UPDATE power_supply_centers.*?)"""\)', src, re.S) + assert m, "UPDATE power_supply_centers не найден в лоадере" + return m.group(1) + + +def _extract_params_keys() -> set[str]: + """Ключи словаря params, который передаётся в этот execute.""" + src = LOADER.read_text(encoding="utf-8") + m = re.search(r"params = \{(.*?)\}", src, re.S) + assert m, "словарь params не найден" + return set(re.findall(r'"([a-z_]+)":', m.group(1))) + + +def test_update_binds_are_subset_of_params() -> None: + """Каждый бинд стейтмента обязан приходить из params — иначе UPDATE падает + на КАЖДОЙ строке, и per-row SAVEPOINT-except прячет это под «битую строку».""" + stmt = text(_extract_update_sql()) + binds = {p.key for p in stmt._bindparams.values()} + params = _extract_params_keys() + missing = binds - params + assert not missing, ( + f"стейтмент требует биндов {sorted(missing)}, которых нет в params — " + "все строки батча упадут молча. Частая причина: упоминание " + "«двоеточие+имя» в SQL-комментарии (text() парсит бинды и там)." + ) + + +def test_comment_does_not_reintroduce_phantom_bind() -> None: + """Регресс 18.08: снятый параметр упомянули в комментарии в живом + синтаксисе бинда. Проверяем ЗНАЧЕНИЕМ: компиляция с ровно теми params, + что собирает лоадер, не требует ничего лишнего.""" + stmt = text(_extract_update_sql()) + have = _extract_params_keys() + for bp in stmt._bindparams.values(): + assert bp.key in have, f"фантомный бинд из комментария: {bp.key!r}"