diff --git a/backend/app/services/site_finder/eesk_reserve_loader.py b/backend/app/services/site_finder/eesk_reserve_loader.py index c396321b..937e3b29 100644 --- a/backend/app/services/site_finder/eesk_reserve_loader.py +++ b/backend/app/services/site_finder/eesk_reserve_loader.py @@ -169,7 +169,16 @@ def _cell(row: tuple, idx: int) -> object: def _pct_share_to_percent(value: object) -> float | None: """Доля загрузки (0.41) → проценты (41.0). Уже-проценты (>1) не трогаем. - В xlsx ЕЭСК степень загрузки хранится ДОЛЕЙ (0..1). Храним в процентах. + В xlsx ЕЭСК степень загрузки хранится ДОЛЕЙ (0..1). + + #2464-B: продакшен-вызывающих у функции СЕЙЧАС НЕТ. Значение колонки E + раньше писалось в `load_index`, но это категориальная колонка + ('open'|'limited'|'closed'|NULL) — число в ней фронт отбрасывает в + «неизвестно» и плодит мусорный бакет в `power_summary.by_load_index`. + Функцию оставляю с тестами: она описывает формат листа, и она понадобится + в тот момент, когда под процент загрузки заведут числовую колонку. + Если такого решения не будет — удалить вместе с тестом, а не держать молча. + None/мусор → None. """ num = parse_reserve_number(value) @@ -214,7 +223,9 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) -> rows_seen += 1 district = _cell(row, 1) # B - load_pct = _pct_share_to_percent(_cell(row, 4)) # E (доля → %) + # Колонку E (степень загрузки ЦП долей) НЕ читаем и не храним: места + # под неё в power_supply_centers нет — load_index категориальный, + # current_load_mva в мегавольт-амперах (#2464-B, см. UPDATE ниже). reserve = parse_reserve_number(_cell(row, 6)) # G (свободная МВт) name_norm = normalize_sc_name(str(sc_name)) @@ -223,7 +234,6 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) -> "reserve": reserve, "asof": reserve_asof, "district": str(district).strip() if district else None, - "load_pct": load_pct, "name_norm": name_norm, } @@ -236,10 +246,22 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) -> reserve_unit = 'МВт', installed_capacity_mva = :installed, district = :district, - load_index = COALESCE( - load_index, - CAST(:load_pct AS text) - ), + -- #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 не проваливался. capacity_source = 'eesk_35_220', reserve_asof = :asof WHERE sc_name_norm = :name_norm diff --git a/backend/tests/test_eesk_reserve_loader.py b/backend/tests/test_eesk_reserve_loader.py index df4dff70..3a1c9032 100644 --- a/backend/tests/test_eesk_reserve_loader.py +++ b/backend/tests/test_eesk_reserve_loader.py @@ -184,10 +184,36 @@ def test_load_ps_35_220_parse_and_match() -> None: assert first["installed"] == 40.0 assert first["reserve"] == 15.0 assert first["district"] == "Ленинский" - assert first["load_pct"] == 41.0 # доля 0.41 → 41.0% assert first["asof"] == date(2026, 6, 30) +def test_load_ps_35_220_does_not_write_load_percent() -> None: + """#2464-B: степень загрузки НЕ уходит в UPDATE и не попадает в load_index. + + Раньше значение колонки E писалось как + `load_index = COALESCE(load_index, CAST(:load_pct AS text))`. load_index — + категориальная колонка ('open'|'limited'|'closed'|NULL, + data/sql/180_connection_capacity.sql:35): число строкой фронт отбрасывает + в «неизвестно» (classifyLoadIndex), а в power_summary.by_load_index + появлялся бы бакет с именем вроде "41.0". + + На проде не стреляло только потому, что load_index заполнен у всех строк + (open 2741 / limited 346 / closed 329, NULL 0 — замер верификации 13.08), + и COALESCE не проваливался. + """ + from datetime import date + + db = _FakeSession(scalar_value=None, rowcount=1) + ee.load_ps_35_220(db, _build_ps_workbook(), date(2026, 6, 30)) + + # Комментарии из SQL убираем: слово load_index встречается в пояснении, + # а проверять надо ИСПОЛНЯЕМЫЙ текст, а не прозу вокруг него. + sql_code = "\n".join(line.split("--", 1)[0] for line in str(db.calls[0][0]).splitlines()) + assert "load_index" not in sql_code, sql_code + for _sql, params in db.calls: + assert "load_pct" not in params, params + + def test_load_ps_35_220_unmatched_counted() -> None: """ПС без совпадения (rowcount=0 — напр. не ЕЭСК) → unmatched, не падаем.""" from datetime import date