From b1dc5d5507d0440eb338ed264cf0a6122fef1227 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 20 Aug 2026 23:30:09 +0500 Subject: [PATCH] =?UTF-8?q?fix(ptica):=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D1=8C=20=D1=81=D0=B5=D1=82=D0=B5=D0=B2=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20=D0=BE=D0=B1=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BD=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=D0=B2=D1=81=D0=B5=20=D0=B2=D0=B8=D0=B4=D1=8B,=20=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BF=D0=B5=D1=80=D0=B2=D1=8B=D0=B9=20(#2464)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cad_utility_label` брался от ПЕРВОГО overlap'а с распознанным `network_kind`, а покрытие агрегировалось по ВСЕМУ bucket'у. Участок под двумя видами охранных зон получал в отчёте конкретную причину, дающую малую часть площади: «Сетевое обременение (теплоснабжение) покрывает 74% участка» ← теплоснабжение даёт 10%, остальные 64% — инженерные коммуникации Это не редкость. Прод 20.08.2026: 316 пересечений охранных зон РАЗНЫХ видов, 155 зон вовлечено; самая частая пара — «тепловых сетей» × «инженерных коммуникаций» (296 из 316). Теперь копятся ВСЕ различённые виды и называются через запятую; множественное число, когда их больше одного. Покрытие — их объединение, и подпись это отражает. Заодно снята зависимость от порядка: подпись бралась от первого overlap'а, а он определяется `ORDER BY reg_numb_border, id`, который к покрытию отношения не имеет. Виды сортируются. Проверка попутно опровергла ДОВОД пункта эпика. Пункт говорит о смешении сетевых зон с keyword-совпадениями без network_kind. На проде таких ноль: из 3493 строк cad_zouit — 103 СЗЗ-предупреждения, 1936 с распознанной сетью, 1454 generic warning, и НИ ОДНОЙ «только по ключевому слову» (classify_network_zone уже покрывает все встречающиеся шаблоны). Вывод пункта — «конкретная причина приписывается чужой площади» — верен, но по другой причине: смешиваются РАЗНЫЕ ВИДЫ СЕТЕЙ, а не сети с keyword'ами. Двусторонне: против origin/main три теста красные, головной — с полной строкой, которую увидел бы пользователь. Контроли зелёные с обеих сторон: один вид сохраняет прежнюю формулировку в единственном числе, две зоны одного вида не дают дубль в подписи, area-gate не тронут (тонкая полоса остаётся warning). pytest test_gate_verdict + services/site_finder — 727 passed. Co-Authored-By: Claude Opus 5 --- .../app/services/site_finder/gate_verdict.py | 29 ++-- .../test_2464_zouit_label_all_kinds.py | 124 ++++++++++++++++++ 2 files changed, 145 insertions(+), 8 deletions(-) create mode 100644 backend/tests/services/site_finder/test_2464_zouit_label_all_kinds.py diff --git a/backend/app/services/site_finder/gate_verdict.py b/backend/app/services/site_finder/gate_verdict.py index 00c833d5..4b3a8726 100644 --- a/backend/app/services/site_finder/gate_verdict.py +++ b/backend/app/services/site_finder/gate_verdict.py @@ -327,8 +327,15 @@ def compute_gate_verdict( sub17_overlaps: list[dict[str, Any]] = [] # cad_zouit path: сетевое обременение + keyword-blocker (утилитарная охранная зона). cad_utility_overlaps: list[dict[str, Any]] = [] - # Подпись вида сети для cad-detail (первый встреченный network_kind). - cad_utility_label: str | None = None + # Подписи видов сетей для cad-detail. Копим ВСЕ различённые виды, а не первый + # (#2464): покрытие ниже агрегируется по всем overlap'ам bucket'а, поэтому подпись + # от одного вида приписывала бы конкретную причину чужой площади. На проде + # 20.08.2026 это не редкость: 316 пересечений охранных зон РАЗНЫХ видов, 155 + # зон вовлечено (чаще всего «тепловых сетей» × «инженерных коммуникаций»). + # Порядок в списке — по появлению, но наружу отдаём отсортированным: порядок + # overlap'ов задан `ORDER BY reg_numb_border, id`, а он к покрытию отношения + # не имеет, и делать подпись зависящей от него незачем. + cad_utility_labels: list[str] = [] for overlap in nspd_zouit_overlaps or []: src = overlap.get("source", "nspd-quarter-dump") if src == "cad_zouit": @@ -360,10 +367,10 @@ def compute_gate_verdict( ): # Утилитарная охранная зона — копим для area-gate (см. ниже). cad_utility_overlaps.append(overlap) - if cad_utility_label is None and net_kind is not None: - cad_utility_label = overlap.get("network_kind_label") or network_kind_label( - net_kind - ) + if net_kind is not None: + _lbl = overlap.get("network_kind_label") or network_kind_label(net_kind) + if _lbl and _lbl not in cad_utility_labels: + cad_utility_labels.append(_lbl) else: warnings.append( Warning( @@ -438,14 +445,20 @@ def compute_gate_verdict( pct = _coverage_pct_label(coverage) # Код-различение сетевого обременения (#1070) vs общего охранного keyword-blocker: # blocker'у с network_kind отдаём ZOUIT_NETWORK_OBREMENENIE, иначе ZOUIT_CAD_BLOCKER. - is_network = cad_utility_label is not None + is_network = bool(cad_utility_labels) + # Все различённые виды через запятую: покрытие — их объединение, и подпись + # обязана это отражать. Множественное число, когда видов больше одного. + cad_utility_label = ", ".join(sorted(cad_utility_labels)) + _обременение = ( + "Сетевые обременения" if len(cad_utility_labels) > 1 else "Сетевое обременение" + ) if coverage > threshold: if is_network: blockers.append( Blocker( code="ZOUIT_NETWORK_OBREMENENIE", detail=( - f"Сетевое обременение ({cad_utility_label}) покрывает {pct}% " + f"{_обременение} ({cad_utility_label}) покрывает {pct}% " f"участка — застройка МКД невозможна" ), ) diff --git a/backend/tests/services/site_finder/test_2464_zouit_label_all_kinds.py b/backend/tests/services/site_finder/test_2464_zouit_label_all_kinds.py new file mode 100644 index 00000000..31c08f46 --- /dev/null +++ b/backend/tests/services/site_finder/test_2464_zouit_label_all_kinds.py @@ -0,0 +1,124 @@ +"""Подпись сетевого обременения называет ВСЕ виды, а не первый попавшийся (#2464). + +`cad_utility_label` брался от первого overlap'а с распознанным `network_kind`, а +покрытие агрегировалось по ВСЕМУ bucket'у. Участок под двумя видами охранных зон +получал в отчёте конкретную причину, дающую малую часть площади: + + «Сетевое обременение (теплосеть) покрывает 74% участка» + ← при том, что теплосеть даёт 10%, а остальные 64% — инженерные коммуникации. + +Это не редкость. Прод 20.08.2026: **316 пересечений охранных зон РАЗНЫХ видов**, +155 зон вовлечено; самая частая пара — «тепловых сетей» × «инженерных коммуникаций» +(296 из 316). + +Подпись, кроме того, зависела от порядка overlap'ов (`ORDER BY reg_numb_border, id`), +который к покрытию отношения не имеет. Теперь виды сортируются. +""" + +from __future__ import annotations + +from app.services.site_finder.gate_verdict import compute_gate_verdict + +_ЗОНА = {"zone_code": "Ж-2", "zone_name": "Жилая"} + + +def _overlap(type_zone: str, coverage: float) -> dict: + return { + "source": "cad_zouit", + "type_zone": type_zone, + "layer": type_zone, + "name": type_zone, + "coverage_pct": coverage, + } + + +def _блокер(verdict: dict, code: str) -> dict | None: + return next((b for b in verdict["blockers"] if b["code"] == code), None) + + +def test_label_names_every_kind_that_feeds_the_coverage() -> None: + """Головной: обе сети названы, потому что обе вошли в покрытие. + + На origin/main в подписи стоит только «теплосеть», хотя 64 % площади дают + инженерные коммуникации. + """ + overlaps = [ + _overlap("Охранная зона тепловых сетей", 0.10), + _overlap("Охранная зона инженерных коммуникаций", 0.64), + ] + v = compute_gate_verdict(_ЗОНА, overlaps, [], {"available": True}) + b = _блокер(v, "ZOUIT_NETWORK_OBREMENENIE") + assert b is not None, f"нет сетевого блокера: {v['blockers']}" + detail = b["detail"] + assert "инженер" in detail.lower(), f"вид, дающий большую часть покрытия, не назван: {detail!r}" + assert "тепл" in detail.lower(), f"второй вид пропал: {detail!r}" + + +def test_plural_wording_when_several_kinds() -> None: + """Множественное число, когда обременений несколько — читается как факт, а не описка.""" + overlaps = [ + _overlap("Охранная зона тепловых сетей", 0.30), + _overlap("Охранная зона объектов электроэнергетики", 0.44), + ] + v = compute_gate_verdict(_ЗОНА, overlaps, [], {"available": True}) + b = _блокер(v, "ZOUIT_NETWORK_OBREMENENIE") + assert b is not None + assert b["detail"].startswith("Сетевые обременения"), b["detail"] + + +def test_single_kind_wording_unchanged() -> None: + """Контроль от переусердствования: один вид — прежняя формулировка в единственном числе.""" + v = compute_gate_verdict( + _ЗОНА, + [_overlap("Охранная зона трубопроводов (газопроводов)", 0.8)], + [], + {"available": True}, + ) + b = _блокер(v, "ZOUIT_NETWORK_OBREMENENIE") + assert b is not None + assert b["detail"].startswith("Сетевое обременение ("), b["detail"] + + +def test_label_is_independent_of_overlap_order() -> None: + """Контроль детерминизма: порядок overlap'ов не должен менять подпись. + + Он задаётся `ORDER BY reg_numb_border, id` — к покрытию отношения не имеет. + """ + a = _overlap("Охранная зона тепловых сетей", 0.30) + b_ = _overlap("Охранная зона инженерных коммуникаций", 0.44) + d1 = _блокер( + compute_gate_verdict(_ЗОНА, [a, b_], [], {"available": True}), + "ZOUIT_NETWORK_OBREMENENIE", + ) + d2 = _блокер( + compute_gate_verdict(_ЗОНА, [b_, a], [], {"available": True}), + "ZOUIT_NETWORK_OBREMENENIE", + ) + assert d1 is not None and d2 is not None + assert ( + d1["detail"] == d2["detail"] + ), f"подпись зависит от порядка:\n{d1['detail']}\n{d2['detail']}" + + +def test_duplicate_kind_named_once() -> None: + """Контроль: две зоны ОДНОГО вида не дают дубль в подписи.""" + overlaps = [ + _overlap("Охранная зона тепловых сетей", 0.40), + _overlap("Охранная зона тепловых сетей", 0.35), + ] + v = compute_gate_verdict(_ЗОНА, overlaps, [], {"available": True}) + b = _блокер(v, "ZOUIT_NETWORK_OBREMENENIE") + assert b is not None + assert b["detail"].lower().count("тепл") == 1, b["detail"] + assert b["detail"].startswith("Сетевое обременение ("), b["detail"] + + +def test_thin_coverage_still_warning_not_blocker() -> None: + """Контроль: правка подписи не трогает area-gate — тонкая полоса остаётся warning.""" + v = compute_gate_verdict( + _ЗОНА, + [_overlap("Охранная зона тепловых сетей", 0.05)], + [{"name": "ТП-1"}], + {"available": True}, + ) + assert v["blockers"] == [], v["blockers"] -- 2.45.3