fix(ptica): число ЗОУИТ перестаёт подписываться как число типов (#2464) #2961

Merged
bot-backend merged 1 commit from fix/2464-zouit-count-label into main 2026-08-20 09:17:07 +00:00
3 changed files with 130 additions and 2 deletions

View file

@ -298,7 +298,13 @@ def _build_zouit(doc: _DocxDocument, result: dict[str, Any]) -> None:
summary_pairs: list[tuple[str, Any]] = [
("Есть ЗОУИТ", has_zouit),
("Кол-во типов ЗОУИТ", zouit_count),
# Подпись именно «Кол-во ЗОУИТ», а не «типов»: значение приходит из
# encumbrance.zouit_count, а там `len(zouit_rows)` — число ЗАПИСЕЙ cad_zouit,
# пересёкших участок (parcels.py). Типы лежат отдельно, в zouit_types, и
# показаны строкой ниже. Прежняя подпись «Кол-во типов ЗОУИТ» расходилась со
# значением в 717 разборах из 1637 с ЗОУИТ — 43.8%, в среднем завышая «типы»
# в 1.35 раза (#2464).
("Кол-во ЗОУИТ", zouit_count),
]
if zouit_types:
summary_pairs.append(("Типы", ", ".join(str(t) for t in zouit_types)))

View file

@ -538,7 +538,13 @@ def _build_zouit(result: dict[str, Any]) -> str:
summary_pairs: list[tuple[str, Any]] = [
("Есть ЗОУИТ", has_zouit),
("Кол-во типов ЗОУИТ", zouit_count),
# Подпись именно «Кол-во ЗОУИТ», а не «типов»: значение приходит из
# encumbrance.zouit_count, а там `len(zouit_rows)` — число ЗАПИСЕЙ cad_zouit,
# пересёкших участок (parcels.py). Типы лежат отдельно, в zouit_types, и
# показаны строкой ниже. Прежняя подпись «Кол-во типов ЗОУИТ» расходилась со
# значением в 717 разборах из 1637 с ЗОУИТ — 43.8%, в среднем завышая «типы»
# в 1.35 раза (#2464).
("Кол-во ЗОУИТ", zouit_count),
]
if zouit_types:
summary_pairs.append(("Типы", ", ".join(str(t) for t in zouit_types)))

View file

@ -0,0 +1,116 @@
"""Подпись к числу ЗОУИТ не должна обещать «типы», когда там записи (#2464).
`encumbrance.zouit_count` приходит из `parcels.py` как `len(zouit_rows)` число ЗАПИСЕЙ
`cad_zouit`, пересёкших участок. Типы лежат отдельно, в `zouit_types`. Оба экспортёра
подписывали это число как «Кол-во типов ЗОУИТ».
Расхождение не редкое: из 1637 разборов с ЗОУИТ в 717 (43.8 %) число записей не равно
числу типов, в среднем завышая «типы» в 1.35 раза (максимум по проду 7 записей при
3 типах).
Инвариант проверяется по смыслу, а не сверкой с выбранной строкой: **если подпись обещает
типы, показанное число обязано равняться числу типов**. Такая формулировка переживёт любое
разумное переименование и не даст «починить» тест подгонкой подписи.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import re
from typing import Any
import pytest
# Пять записей ЗОУИТ, но всего два различных типа — ровно тот случай, где подпись врала.
_OVERLAPS: list[dict[str, Any]] = [
{"type_zone": "Охранная зона ЛЭП", "name": "ВЛ-110 №1", "coverage_pct": 12.0},
{"type_zone": "Охранная зона ЛЭП", "name": "ВЛ-110 №2", "coverage_pct": 8.0},
{"type_zone": "Охранная зона ЛЭП", "name": "ВЛ-110 №3", "coverage_pct": 3.0},
{"type_zone": "Приаэродромная территория", "name": "ПАТ подзона 3", "coverage_pct": 100.0},
{"type_zone": "Приаэродромная территория", "name": "ПАТ подзона 4", "coverage_pct": 100.0},
]
_RESULT: dict[str, Any] = {
"encumbrance": {
"has_zouit": True,
"zouit_count": 5,
"zouit_types": ["Охранная зона ЛЭП", "Приаэродромная территория"],
},
"nspd_zouit_overlaps": _OVERLAPS,
}
_RECORDS = 5
_TYPES = 2
def _html_pairs() -> list[tuple[str, str]]:
"""KV-пары из HTML-блока ЗОУИТ: [(подпись, значение), …]."""
from app.services.exporters.full_report_html import _build_zouit
out = _build_zouit(dict(_RESULT))
return [
(re.sub(r"<[^>]+>", "", k).strip(), re.sub(r"<[^>]+>", "", v).strip())
for k, v in re.findall(r"<tr>\s*<th>(.*?)</th>\s*<td>(.*?)</td>\s*</tr>", out, re.S)
] or [
(re.sub(r"<[^>]+>", "", k).strip(), re.sub(r"<[^>]+>", "", v).strip())
for k, v in re.findall(
r"<tr>\s*<td[^>]*>(.*?)</td>\s*<td[^>]*>(.*?)</td>\s*</tr>", out, re.S
)
]
def _docx_pairs() -> list[tuple[str, str]]:
"""KV-пары из DOCX-блока ЗОУИТ."""
from docx import Document
from app.services.exporters.full_report_docx import _build_zouit
doc = Document()
_build_zouit(doc, dict(_RESULT))
pairs: list[tuple[str, str]] = []
for table in doc.tables:
for row in table.rows:
cells = [c.text.strip() for c in row.cells]
if len(cells) == 2:
pairs.append((cells[0], cells[1]))
return pairs
@pytest.mark.parametrize("pairs_fn,fmt", [(_html_pairs, "html"), (_docx_pairs, "docx")])
def test_label_promising_types_must_show_types(pairs_fn: Any, fmt: str) -> None:
"""Подпись со словом «тип» обязана нести число типов, а не записей."""
pairs = pairs_fn()
assert pairs, f"{fmt}: не разобрал ни одной KV-пары — тест ослеп"
for label, value in pairs:
if "тип" not in label.lower():
continue
digits = re.fullmatch(r"\d+", value.strip())
if not digits:
continue # строка «Типы: A, B» — это перечисление, а не счётчик
assert int(value) == _TYPES, (
f"{fmt}: подпись {label!r} обещает типы, а показывает {value}"
f"это число ЗАПИСЕЙ ({_RECORDS}), типов же {_TYPES}"
)
@pytest.mark.parametrize("pairs_fn,fmt", [(_html_pairs, "html"), (_docx_pairs, "docx")])
def test_record_count_is_still_shown(pairs_fn: Any, fmt: str) -> None:
"""Контроль: само число записей из отчёта не пропало — подпись правится, не значение."""
pairs = pairs_fn()
counters = [(k, v) for k, v in pairs if re.fullmatch(r"\d+", v.strip())]
assert any(
int(v) == _RECORDS for _, v in counters
), f"{fmt}: число ЗОУИТ-записей ({_RECORDS}) исчезло из сводки: {counters}"
@pytest.mark.parametrize("pairs_fn,fmt", [(_html_pairs, "html"), (_docx_pairs, "docx")])
def test_types_are_still_listed(pairs_fn: Any, fmt: str) -> None:
"""Контроль: перечисление типов на месте — читатель по-прежнему видит, какие они."""
pairs = pairs_fn()
joined = " ".join(f"{k} {v}" for k, v in pairs)
assert (
"Охранная зона ЛЭП" in joined and "Приаэродромная территория" in joined
), f"{fmt}: типы ЗОУИТ пропали из сводки"