diff --git a/backend/app/services/exporters/full_report_docx.py b/backend/app/services/exporters/full_report_docx.py index 68171ea2..2860190d 100644 --- a/backend/app/services/exporters/full_report_docx.py +++ b/backend/app/services/exporters/full_report_docx.py @@ -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))) diff --git a/backend/app/services/exporters/full_report_html.py b/backend/app/services/exporters/full_report_html.py index e8d0f6ed..7ce3b393 100644 --- a/backend/app/services/exporters/full_report_html.py +++ b/backend/app/services/exporters/full_report_html.py @@ -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))) diff --git a/backend/tests/services/exporters/test_2464_zouit_count_label.py b/backend/tests/services/exporters/test_2464_zouit_count_label.py new file mode 100644 index 00000000..ec17ce91 --- /dev/null +++ b/backend/tests/services/exporters/test_2464_zouit_count_label.py @@ -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"\s*(.*?)\s*(.*?)\s*", out, re.S) + ] or [ + (re.sub(r"<[^>]+>", "", k).strip(), re.sub(r"<[^>]+>", "", v).strip()) + for k, v in re.findall( + r"\s*]*>(.*?)\s*]*>(.*?)\s*", 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}: типы ЗОУИТ пропали из сводки"