fix(ptica): подпись сетевого обременения называет все виды, а не первый (#2464)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 2m49s
Deploy / build-worker (push) Successful in 6m39s
Deploy / deploy (push) Successful in 1m53s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 13s

Часть эпика #2464.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-08-20 18:53:32 +00:00
commit c979cf886a
2 changed files with 145 additions and 8 deletions

View file

@ -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"участка — застройка МКД невозможна"
),
)

View file

@ -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"]