From e9e3cc7dcf6420fd224fcc7fdd35da00361fe62a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 19:33:11 +0000 Subject: [PATCH] =?UTF-8?q?fix(full-report):=20=C2=A77=20=D0=BD=D0=B0=20Mu?= =?UTF-8?q?ltiPolygon=20+=20=D1=87=D0=B8=D1=81=D1=82=D0=BA=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=87=D0=B0=D1=82=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=88=D1=83?= =?UTF-8?q?=D0=BC=D0=B0=20=C2=A73/=C2=A74=20(=D0=B2=D1=8B=D1=87=D0=B8?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=20=D0=B6=D0=B8=D0=B2=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=B0)=20(#2296)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/exporters/full_report_html.py | 360 +++++++++++++++--- .../app/services/exporters/full_report_pdf.py | 44 +++ .../exporters/test_full_report_html_part_a.py | 114 ++++++ .../exporters/test_full_report_html_part_b.py | 110 ++++++ .../exporters/test_full_report_pdf.py | 67 ++++ 5 files changed, 644 insertions(+), 51 deletions(-) diff --git a/backend/app/services/exporters/full_report_html.py b/backend/app/services/exporters/full_report_html.py index 05e715fa..73fa9cae 100644 --- a/backend/app/services/exporters/full_report_html.py +++ b/backend/app/services/exporters/full_report_html.py @@ -57,6 +57,39 @@ MAP_CONCEPT_PLACEHOLDER = "{{MAP_CONCEPT}}" _DASH = "—" _NO_DATA = "нет данных" +# ── §3 connection-capacity: кап-строки + фильтр «шума» области ─────────────────── +# Печатный отчёт по участку ЕКБ, а heat_system_reserves несёт ВСЕ ~56 систем области +# (Ирбит/Тавда/Красноуфимск/Первоуральск/Лесной/Нижняя Тура…). Не хардкодим список +# городов — отсекаем по ЯВНЫМ не-ЕКБ маркерам в имени организации/системы; остальное +# капим топ-N по резерву. Дефицит (отрицательный резерв) под кап НЕ прячем — честность. +_NON_EKB_MARKERS: tuple[str, ...] = ( + "ирбит", + "тавда", + "красноуфимск", + "первоуральск", + "лесной", + "нижняя тура", + "нижний тагил", + "каменск", + "серов", + "асбест", + "ревда", + "полевск", + "березовск", # покрывает «Березовский» / «Берёзовский» (ё нормализуем ниже) + "верхняя пышма", + "среднеуральск", + "заречный", + "новоуральск", + "качканар", + "краснотурьинск", +) +_EKB_MARKER = "екатеринбург" +# Кап видимых строк тепло/вода-таблиц (сверх — «и ещё K систем …»). +_HEAT_ROW_CAP = 15 +_WATER_ROW_CAP = 25 +# Усечение длинных бюрократических имён систем. +_SYSTEM_NAME_MAX = 120 + # Заголовки секций (якоря — id внутри) и титул документа. _TITLE_DOC = "Отчёт по участку — Site Finder ПТИЦА" _TITLE_S1 = "§1. Участок" @@ -149,7 +182,9 @@ p { margin: 4pt 0; } не рвётся внутри заголовка (WeasyPrint поддерживает break-before/inside). */ .section { margin-bottom: 16pt; break-inside: avoid-page; } .section + .section { break-before: page; } -h2, h3 { break-after: avoid-page; } +/* Заголовок НЕ должен осиротеть в конце страницы, оторвавшись от своей таблицы: + и логический break-after (WeasyPrint 60+), и легаси page-break-after (fallback). */ +h2, h3 { page-break-after: avoid; break-after: avoid-page; } table { break-inside: avoid-page; } /* Титул */ @@ -285,6 +320,17 @@ def _fmt_money_signed(value: Any) -> str: return f"{sign}{f'{round(magnitude):,}'.replace(',', ' ')} ₽" +def _fmt_money(value: Any) -> str: + """Деньги округлением до млн БЕЗ знака: «8 млн ₽» / «216 млн ₽». PURE. + + Для абсолютных величин-ЦЕН (средний чек, справочная цена), где «+» неуместен — это + не дельта. Отрицательные (маловероятны для цены) — с типографским минусом. Тонкая + обёртка над `_fmt_money_signed`: срезаем ведущий «+». + """ + signed = _fmt_money_signed(value) + return signed[1:] if signed.startswith("+") else signed + + def _fmt_pct(fraction: Any) -> str: """Доля 0.184 → «18.4%». Не-число → «—». PURE.""" if isinstance(fraction, bool) or not isinstance(fraction, int | float): @@ -440,20 +486,35 @@ def _build_zoning(result: dict[str, Any]) -> str: def _build_zouit(result: dict[str, Any]) -> str: """ЗОУИТ-ограничения: сводка + список пересечений (тип / № границы / покрытие).""" encumbrance = _as_dict(result.get("encumbrance")) - overlaps = _as_list(result.get("nspd_zouit_overlaps")) + overlaps = [ov for ov in _as_list(result.get("nspd_zouit_overlaps")) if isinstance(ov, dict)] + + has_zouit = encumbrance.get("has_zouit") + zouit_count = encumbrance.get("zouit_count") + zouit_types = _as_list(encumbrance.get("zouit_types")) + + # Противоречие: encumbrance говорит «нет ЗОУИТ», но НСПД-пересечения ЕСТЬ. Доверяем + # фактическим пересечениям (более свежий геослой) — иначе сводка «нет / 0» врёт под + # таблицей с реальными строками. Типы/кол-во достаём из самих overlaps. + if not has_zouit and overlaps: + has_zouit = "да (по данным НСПД)" + if zouit_count in (None, 0): + zouit_count = len(overlaps) + if not zouit_types: + zouit_types = [ + str(t) + for t in dict.fromkeys(ov.get("type_zone") or ov.get("name") for ov in overlaps) + if t not in (None, "") + ] summary_pairs: list[tuple[str, Any]] = [ - ("Есть ЗОУИТ", encumbrance.get("has_zouit")), - ("Кол-во типов ЗОУИТ", encumbrance.get("zouit_count")), + ("Есть ЗОУИТ", has_zouit), + ("Кол-во типов ЗОУИТ", zouit_count), ] - zouit_types = _as_list(encumbrance.get("zouit_types")) if zouit_types: summary_pairs.append(("Типы", ", ".join(str(t) for t in zouit_types))) rows: list[list[Any]] = [] - for ov in overlaps: - if not isinstance(ov, dict): - continue + for ov in overlaps: # already filtered to dicts above coverage = ov.get("coverage_pct") coverage_str = _fmt_pct(coverage) if isinstance(coverage, int | float) else _DASH rows.append( @@ -674,19 +735,24 @@ def _build_engineering_nearby(result: dict[str, Any]) -> str: """Инженерные сооружения НСПД рядом (name / назначение / расстояние из raw_props).""" items = _as_list(result.get("nspd_engineering_nearby")) rows: list[list[Any]] = [] + any_item = False for it in items: if not isinstance(it, dict): continue + any_item = True raw = _as_dict(it.get("raw_props")) name = it.get("name") or raw.get("params_name") or raw.get("cad_number") purpose = it.get("type") or raw.get("params_purpose") + # И имя, И назначение пустые → строка-призрак («— — 7»), скипаем. + if name in (None, "") and purpose in (None, ""): + continue rows.append([name, purpose, _fmt_int_ru(it.get("distance_m"))]) - if not rows: + # Секции вообще нет в payload → не рисуем заголовок. Есть записи, но все пустые → + # честное «нет данных» под заголовком (а не пустой невидимый блок). + if not any_item: return "" - return ( - f"

Инженерные сооружения рядом (НСПД)

" - f"{_data_table(['Сооружение', 'Назначение', 'Расстояние, м'], rows)}" - ) + table = _data_table(["Сооружение", "Назначение", "Расстояние, м"], rows) + return f"

Инженерные сооружения рядом (НСПД)

{table}" def _build_alternatives(result: dict[str, Any]) -> str: @@ -749,6 +815,96 @@ def _build_alternatives(result: dict[str, Any]) -> str: """ +def _truncate_name(value: Any, limit: int = _SYSTEM_NAME_MAX) -> Any: + """Усечь длинное бюрократическое имя системы до `limit` символов с «…». PURE. + + Не-строка / короткая строка → как есть (форматтер `_fmt`/`_esc` доработает). Режем по + границе символов, добавляя одноточечное многоточие (U+2026), чтобы длинные имена вроде + «Централизованная система теплоснабжения муниципального образования …» не разваливали + вёрстку печатной таблицы. + """ + if not isinstance(value, str) or len(value) <= limit: + return value + return value[: limit - 1].rstrip() + "…" + + +def _is_non_ekb(*fields: Any) -> bool: + """True, если В ЛЮБОМ из полей есть ЯВНЫЙ не-ЕКБ городской маркер (Ирбит/Тавда/…). PURE. + + Организация/система из другого города области — «шум» для отчёта по участку ЕКБ. + Нормализуем ё→е и регистр; НЕ хардкодим полный справочник городов (только явные + крупные маркеры) — прочее схлопывается капом, а не этим фильтром. + """ + haystack = " ".join(str(f) for f in fields if f).lower().replace("ё", "е") + if not haystack: + return False + return any(marker in haystack for marker in _NON_EKB_MARKERS) + + +def _mentions_ekb(*fields: Any) -> bool: + """True, если хотя бы одно поле явно упоминает Екатеринбург (ё-нормализовано). PURE.""" + haystack = " ".join(str(f) for f in fields if f).lower().replace("ё", "е") + return _EKB_MARKER in haystack + + +def _reserve_num(value: Any) -> float: + """Резерв как float для сортировки/суммы; не-число → 0.0 (нейтрально). PURE.""" + if isinstance(value, bool) or not isinstance(value, int | float): + return 0.0 + return float(value) + + +def _capacity_rows_capped( + items: list[Any], + *, + reserve_key: str, + name_fields: tuple[str, ...], + row_cap: int, + row_builder: Any, +) -> tuple[list[list[Any]], int]: + """Отфильтровать областной шум + капнуть строки резерв-таблицы (тепло/вода). PURE. + + Порядок честности: + 1. Дефицитные (резерв < 0) ЕКБ-системы — ВСЕГДА видимы, под кап не попадают. + 2. Остальные ЕКБ (или неопределённые по городу) сортируются по резерву ↓, берётся + топ до заполнения `row_cap` (с учётом уже показанных дефицитных). + 3. Явно не-ЕКБ (Ирбит/Тавда/…) и хвост сверх капа схлопываются в счётчик `hidden`. + + Args: + items: сырые dict-строки (`heat.systems` / `cap.water`). + reserve_key: ключ резерва в dict (`reserve_gcal_h` / `reserve_thousand_m3_day`). + name_fields: ключи, по которым определяем город (org / system_name). + row_cap: максимум видимых строк. + row_builder: `dict -> list[cell]` — построитель ячеек строки данных. + + Returns: + `(visible_rows, hidden_count)` — строки для таблицы + сколько схлопнуто. + """ + dicts = [it for it in items if isinstance(it, dict)] + deficit: list[dict[str, Any]] = [] + surplus_ekb: list[dict[str, Any]] = [] + hidden = 0 + for it in dicts: + names = tuple(it.get(f) for f in name_fields) + reserve = _reserve_num(it.get(reserve_key)) + # Дефицит ЕКБ/неопределённых — всегда показываем (даже если город не-ЕКБ: + # отрицательный резерв рядом — сигнал, честнее показать, чем спрятать). + if reserve < 0 and not _is_non_ekb(*names): + deficit.append(it) + continue + if _is_non_ekb(*names) and not _mentions_ekb(*names): + hidden += 1 + continue + surplus_ekb.append(it) + + surplus_ekb.sort(key=lambda d: _reserve_num(d.get(reserve_key)), reverse=True) + remaining = max(row_cap - len(deficit), 0) + shown_surplus = surplus_ekb[:remaining] + hidden += len(surplus_ekb) - len(shown_surplus) + visible = [row_builder(it) for it in (*deficit, *shown_surplus)] + return visible, hidden + + def _build_connection_capacity(cap: dict[str, Any] | None) -> str: """Ресурсные резервы (ЦП/вода/газ/тепло) + сети рядом из connection-capacity (#2259 PR-D). @@ -781,12 +937,22 @@ def _build_connection_capacity(cap: dict[str, Any] | None) -> str: if pairs: blocks.append("

Электроснабжение — свободная мощность

" + _kv_table(pairs)) - # Вода: строки резервов ЦСВ/ЦСК за последний период. - water_rows = [ - [w.get("system_name"), _fmt(w.get("reserve_thousand_m3_day")), w.get("period")] - for w in _as_list(cap.get("water")) - if isinstance(w, dict) - ] + # Вода: строки резервов ЦСВ/ЦСК за последний период (ЕКБ-системы + кап ~25). + water_rows, water_hidden = _capacity_rows_capped( + _as_list(cap.get("water")), + reserve_key="reserve_thousand_m3_day", + name_fields=("system_name", "org"), + row_cap=_WATER_ROW_CAP, + row_builder=lambda w: [ + _truncate_name(w.get("system_name")), + _fmt(w.get("reserve_thousand_m3_day")), + w.get("period"), + ], + ) + if water_hidden: + water_rows.append( + [f"… и ещё {water_hidden} систем (полный список в веб-версии §3)", _DASH, _DASH] + ) if water_rows: blocks.append( "

Водоснабжение/водоотведение — резервы

" @@ -810,17 +976,47 @@ def _build_connection_capacity(cap: dict[str, Any] | None) -> str: + _data_table(["ГРС", "Свободно, тыс. м³/ч", "Свободно, %"], gas_rows) ) - # Тепло: резервы систем теплоснабжения. + # Тепло: резервы систем теплоснабжения. heat_system_reserves несёт ВСЕ ~56 систем + # области — сначала агрегат по ЕКБ (сумма резервов ЕКБ-систем), затем топ-15 + кап. heat = _as_dict(cap.get("heat")) - heat_rows = [ - [h.get("org"), h.get("system_name"), _fmt(h.get("reserve_gcal_h")), h.get("period")] - for h in _as_list(heat.get("systems")) - if isinstance(h, dict) - ] - if heat_rows: + heat_systems = [h for h in _as_list(heat.get("systems")) if isinstance(h, dict)] + if heat_systems: + ekb_systems = [ + h for h in heat_systems if not _is_non_ekb(h.get("org"), h.get("system_name")) + ] + ekb_total = sum(_reserve_num(h.get("reserve_gcal_h")) for h in ekb_systems) + heat_rows, heat_hidden = _capacity_rows_capped( + heat_systems, + reserve_key="reserve_gcal_h", + name_fields=("org", "system_name"), + row_cap=_HEAT_ROW_CAP, + row_builder=lambda h: [ + _truncate_name(h.get("org")), + _truncate_name(h.get("system_name")), + _fmt(h.get("reserve_gcal_h")), + h.get("period"), + ], + ) + # Агрегатная строка «Суммарно (ЕКБ)» сверху. + agg_row = [ + "Суммарно (ЕКБ)", + f"{len(ekb_systems)} систем", + _fmt(round(ekb_total, 1)), + _DASH, + ] + rows: list[list[Any]] = [agg_row, *heat_rows] + if heat_hidden: + rows.append( + [ + f"… и ещё {heat_hidden} систем (полный список в веб-версии §3)", + _DASH, + _DASH, + _DASH, + ] + ) blocks.append( "

Теплоснабжение — резервы систем

" - + _data_table(["Организация", "Система", "Резерв, Гкал/ч", "Период"], heat_rows) + + _data_table(["Организация", "Система", "Резерв, Гкал/ч", "Период"], rows) ) # Позитив-разрез: сетевые охранные зоны рядом (где физически проходит сеть). @@ -914,7 +1110,9 @@ def _build_market_metrics(forecast: dict[str, Any]) -> str: ("Темп продаж (velocity), ед./мес", metrics.get("unit_velocity")), ("Темп продаж (площадь), м²/мес", metrics.get("area_velocity")), ("Окно расчёта, мес", metrics.get("window_months")), - ("Ставка абсорбции", metrics.get("absorption_rate")), + # absorption_rate — доля стока, продаваемая в месяц (0.0112). Как «0.01» это + # нечитаемо → проценты («1.1%»). Темп в штуках уже есть в unit_velocity выше. + ("Ставка абсорбции (в мес.)", _fmt_pct(metrics.get("absorption_rate"))), ("Месяцев запаса (months of supply)", metrics.get("months_of_supply")), ("Индекс затоварки (overstock)", metrics.get("overstock_index")), ("Sell-through, %", metrics.get("sell_through_pct")), @@ -923,22 +1121,70 @@ def _build_market_metrics(forecast: dict[str, Any]) -> str: return _kv_table(pairs) +def _competitor_lots(c: dict[str, Any]) -> int: + """Число лотов конкурента как int; не-число / None → 0. PURE.""" + n = c.get("flat_count") + if isinstance(n, bool) or not isinstance(n, int | float): + return 0 + return int(n) + + def _build_market_competitors(forecast: dict[str, Any]) -> str: - """Конкуренты рынка сейчас: ЖК / девелопер / класс / расстояние / лотов.""" + """Конкуренты рынка сейчас: ЖК / девелопер / класс / расстояние / лотов. + + Прод-payload несёт по строке НА КОРПУС: один ЖК с 6 корпусами → 6 строк, а безымянные + записи с 0 лотов — мусор. Схлопываем: (а) строки без имени И с 0 лотов скипаем; + (б) группируем по (имя, девелопер) → ближайшая дистанция, лоты суммой, класс/девелопер + от первого, «(K корпусов)» в имени при K>1. Порядок групп — по первому появлению. + """ market_now = _fc_as_dict(forecast.get("market_now")) - competitors = _as_list(market_now.get("competitors")) + competitors = [c for c in _as_list(market_now.get("competitors")) if isinstance(c, dict)] + + groups: dict[tuple[str, str], dict[str, Any]] = {} + order: list[tuple[str, str]] = [] + for c in competitors: + name = c.get("comm_name") + lots = _competitor_lots(c) + # Безымянная запись без лотов — мусор (пустые «—» корпуса-призраки). + if name in (None, "") and lots == 0: + continue + key = (str(name or ""), str(c.get("dev_name") or "")) + dist = c.get("distance_m") + dist_val = ( + float(dist) if isinstance(dist, int | float) and not isinstance(dist, bool) else None + ) + if key not in groups: + order.append(key) + groups[key] = { + "name": name, + "dev_name": c.get("dev_name"), + "obj_class": c.get("obj_class"), + "distance_m": dist_val, + "lots": lots, + "corpus": 1, + } + else: + g = groups[key] + g["lots"] += lots + g["corpus"] += 1 + if dist_val is not None and (g["distance_m"] is None or dist_val < g["distance_m"]): + g["distance_m"] = dist_val + if g["obj_class"] in (None, "") and c.get("obj_class"): + g["obj_class"] = c.get("obj_class") rows: list[list[Any]] = [] - for c in competitors: - if not isinstance(c, dict): - continue + for key in order: + g = groups[key] + name = g["name"] + if g["corpus"] > 1: + name = f"{_fmt(name)} ({g['corpus']} корпусов)" rows.append( [ - c.get("comm_name"), - c.get("dev_name"), - c.get("obj_class"), - _fmt_int_ru(c.get("distance_m")), - _fmt_int_ru(c.get("flat_count")), + name, + g["dev_name"], + g["obj_class"], + _fmt_int_ru(g["distance_m"]), + _fmt_int_ru(g["lots"]), ] ) return _data_table(["ЖК", "Девелопер", "Класс", "Расстояние, м", "Лотов"], rows) @@ -949,19 +1195,33 @@ def _build_market_coverage(forecast: dict[str, Any]) -> str: confidence = _fc_as_dict(forecast.get("confidence")) factors = _fc_as_dict(confidence.get("factors")) - rows: list[list[Any]] = [] + # «Комментарий» почти всегда дублирует «Фактор» (label==note, либо note ⊃ label) — + # тогда две колонки = визуальный шум. Показываем 3-ю колонку ТОЛЬКО если хоть у одного + # фактора комментарий несёт что-то сверх метки; иначе схлопываем в «Фактор/уровень». + parsed: list[tuple[Any, Any, Any]] = [] for _key, payload in factors.items(): data = _fc_as_dict(payload) if not data: continue - rows.append( - [ - data.get("label") or data.get("note"), - _fc_level_ru(data.get("level")), - data.get("note"), - ] - ) - coverage_table = _data_table(["Фактор", "Уровень", "Комментарий"], rows) + label = data.get("label") or data.get("note") + note = data.get("note") + parsed.append((label, _fc_level_ru(data.get("level")), note)) + + def _note_adds_info(label: Any, note: Any) -> bool: + if not isinstance(note, str) or note == "": + return False + if not isinstance(label, str): + return True + return note.strip() != label.strip() and label.strip() not in note + + show_note = any(_note_adds_info(label, note) for label, _level, note in parsed) + if show_note: + rows = [[label, level, note] for label, level, note in parsed] + coverage_table = _data_table(["Фактор", "Уровень", "Комментарий"], rows) + else: + # Комментарий ничего не добавляет → две колонки «Фактор» + «Уровень». + rows = [[label, level] for label, level, _note in parsed] + coverage_table = _data_table(["Фактор", "Уровень"], rows) level_pairs: list[tuple[str, Any]] = [ ("Итоговая уверенность отчёта", _fc_level_ru(confidence.get("level"))), @@ -1098,7 +1358,7 @@ def _build_market_affordability(forecast: dict[str, Any]) -> str: return "" pairs: list[tuple[str, Any]] = [ ("Рыночная цена, ₽/м²", _fmt_int_ru(detail.get("price_per_m2"))), - ("Средний чек лота, ₽", _fmt_money_signed(detail.get("avg_ticket_rub"))), + ("Средний чек лота, ₽", _fmt_money(detail.get("avg_ticket_rub"))), ("Референс-площадь, м²", detail.get("ref_area_m2")), ("Индекс избытка предложения", detail.get("oversupply_risk")), ] @@ -1276,9 +1536,7 @@ def _build_concept_program(variant: dict[str, Any]) -> str: if not order: return "" rows = [[stype, floors, groups[(stype, floors)]] for stype, floors in order] - return ( - "

Программа застройки

" f"{_data_table(['Тип дома', 'Этажность', 'Секций'], rows)}" - ) + return f"

Программа застройки

{_data_table(['Тип дома', 'Этажность', 'Секций'], rows)}" def _build_concept_variant(variant: dict[str, Any]) -> str: diff --git a/backend/app/services/exporters/full_report_pdf.py b/backend/app/services/exporters/full_report_pdf.py index b7502d97..4742b9b0 100644 --- a/backend/app/services/exporters/full_report_pdf.py +++ b/backend/app/services/exporters/full_report_pdf.py @@ -93,6 +93,43 @@ def render_full_report_pdf(html: str) -> bytes: return pdf_bytes +def _largest_polygon_geojson(geom: dict[str, Any]) -> dict[str, Any]: + """MultiPolygon → GeoJSON крупнейшего полигона-контура; Polygon и прочее — как есть. + + Многоконтурный участок приходит как MultiPolygon, а concept-стек (`parse_parcel` + + `_parcel_centroid_wkt`) принимает только Polygon → `ParcelGeometryError`. Берём + контур с максимальной площадью (тот же приём, что generative-геометрия применяет к + buildable-мультиполигону после буфера, geometry.py:263-265). Любой сбой парсинга / + не-MultiPolygon → возвращаем geom без изменений (best-effort, не роняем концепцию). + + Args: + geom: GeoJSON-геометрия участка (Polygon / MultiPolygon / Feature-обёртка). + + Returns: + GeoJSON Polygon крупнейшего контура (если вход был MultiPolygon), иначе `geom`. + """ + geom_dict = geom.get("geometry") if geom.get("type") == "Feature" else geom + if not isinstance(geom_dict, dict) or geom_dict.get("type") != "MultiPolygon": + return geom + + try: + from shapely.geometry import mapping, shape + + multi = shape(geom_dict) + if multi.is_empty or not hasattr(multi, "geoms"): + return geom + largest = max(multi.geoms, key=lambda g: g.area) + logger.info( + "build_full_report: multi-contour участок — взят крупнейший контур из %d", + len(list(multi.geoms)), + ) + return dict(mapping(largest)) + except Exception: + # Вырожденная/битая геометрия — отдаём как есть, concept-стек сам решит (best-effort). + logger.exception("build_full_report: не удалось выделить крупнейший контур MultiPolygon") + return geom + + def _generate_concept_result(db: Session, analyze: dict[str, Any]) -> dict[str, Any] | None: """Сгенерировать концепцию server-side как это делает POST /concepts (best-effort). @@ -115,6 +152,13 @@ def _generate_concept_result(db: Session, analyze: dict[str, Any]) -> dict[str, logger.info("build_full_report: analyze-payload без geom_geojson → §7-концепция пропущена") return None + # Многоконтурный участок → geom = MultiPolygon, а concept-стек (parse_parcel + + # _parcel_centroid_wkt через _parse_polygon) принимает ТОЛЬКО Polygon и роняет + # ParcelGeometryError("expected Polygon, got MultiPolygon"). Берём крупнейший контур — + # ровно как generative-геометрия после буфера (geometry.py:263-265). Иначе §7 и + # market-price молча деградируют на любом мультиконтуре. + geom = _largest_polygon_geojson(geom) + try: # Lazy import — тяжёлый generative-стек не нужен на module-load; concepts-хелперы # цены живут в API-слое (он знает БД), переиспользуем ИМЕННО их (single source). diff --git a/backend/tests/services/exporters/test_full_report_html_part_a.py b/backend/tests/services/exporters/test_full_report_html_part_a.py index c4009996..2d8f9d2b 100644 --- a/backend/tests/services/exporters/test_full_report_html_part_a.py +++ b/backend/tests/services/exporters/test_full_report_html_part_a.py @@ -386,6 +386,120 @@ def test_connection_capacity_absent_when_none() -> None: assert "Газоснабжение — свободная мощность ГРС" not in html2 +# ── FIX 5: противоречие ЗОУИТ-блоков (has_zouit falsy, но overlaps есть) ───────── + + +def test_zouit_reconciles_falsy_flag_with_overlaps() -> None: + """has_zouit=нет + zouit_count=0, но nspd_zouit_overlaps непуст → «да (по данным НСПД)».""" + result = { + "encumbrance": {"has_zouit": False, "zouit_count": 0}, + "nspd_zouit_overlaps": [ + {"type_zone": "Охранная зона ЛЭП", "reg_numb_border": "66:41-1", "coverage_pct": 0.3}, + {"type_zone": "Санитарная зона", "reg_numb_border": "66:41-2", "coverage_pct": 0.1}, + ], + } + html = build_full_report_html_part_a(result, cad="X") + assert "да (по данным НСПД)" in html + # Кол-во типов = число пересечений; типы восстановлены из overlaps. + assert "Охранная зона ЛЭП" in html + assert "Санитарная зона" in html + + +def test_zouit_flag_true_unchanged() -> None: + """has_zouit=да → сводка не переписывается (FIX 5 трогает только противоречие).""" + result = { + "encumbrance": {"has_zouit": True, "zouit_count": 1, "zouit_types": ["Охранная"]}, + "nspd_zouit_overlaps": [{"type_zone": "Охранная", "coverage_pct": 0.5}], + } + html = build_full_report_html_part_a(result, cad="X") + assert "да (по данным НСПД)" not in html + + +# ── FIX 6a: НСПД-сооружения — пропуск строк-призраков ──────────────────────────── + + +def test_engineering_nearby_skips_ghost_rows() -> None: + """Строки где И имя, И назначение пустые («— — 7») скипаются (FIX 6a).""" + result = { + "nspd_engineering_nearby": [ + {"name": None, "type": None, "distance_m": 7, "raw_props": {}}, # призрак + {"name": "Трубопровод", "type": "тепло", "distance_m": 40, "raw_props": {}}, + ] + } + html = build_full_report_html_part_a(result, cad="X") + assert "Трубопровод" in html + # Заголовок есть (записи были), призрак-строки нет. + assert "Инженерные сооружения рядом (НСПД)" in html + + +def test_engineering_nearby_all_empty_shows_no_data() -> None: + """Все записи пустые → заголовок + «нет данных» (не пустой невидимый блок, FIX 6a).""" + result = { + "nspd_engineering_nearby": [ + {"name": None, "type": None, "distance_m": 7, "raw_props": {}}, + {"name": "", "type": "", "distance_m": 9, "raw_props": {}}, + ] + } + html = build_full_report_html_part_a(result, cad="X") + assert "Инженерные сооружения рядом (НСПД)" in html + assert _NO_DATA in html + + +# ── FIX 3: тепло/вода — фильтр областного шума + агрегат ЕКБ + кап ─────────────── + + +def _heat_systems_province() -> dict[str, Any]: + """56-строчная тепло-выборка: горсть ЕКБ + много не-ЕКБ (Ирбит/Тавда/…).""" + systems: list[dict[str, Any]] = [ + {"org": "ЕТК", "system_name": "СТ Екатеринбург №54", "reserve_gcal_h": 3.1}, + {"org": "Т Плюс", "system_name": "СТ Екатеринбург ТЭЦ", "reserve_gcal_h": 12.0}, + # Дефицит ЕКБ — под кап НЕ прячем. + {"org": "ЕТК", "system_name": "СТ Екатеринбург №7", "reserve_gcal_h": -2.5}, + ] + # Много областного шума (не-ЕКБ). + for city in ("Ирбит", "Тавда", "Красноуфимск", "Первоуральск", "Лесной", "Нижняя Тура"): + systems.append( + {"org": "Регионгаз-инвест", "system_name": f"СТ {city} №1", "reserve_gcal_h": 1.0} + ) + return {"systems": systems, "total_reserve_gcal_h": 100.0} + + +def test_heat_table_aggregates_ekb_and_hides_province_noise() -> None: + """Тепло: агрегат «Суммарно (ЕКБ)» + не-ЕКБ схлопнуты в «… и ещё K систем» (FIX 3).""" + cap = {"heat": _heat_systems_province()} + html = build_full_report_html_part_a(_synthetic_result(), cad="X", connection_capacity=cap) + assert "Суммарно (ЕКБ)" in html + # ЕКБ-системы видны (в т.ч. дефицит), не-ЕКБ схлопнуты. + assert "Екатеринбург №54" in html + assert "Екатеринбург №7" in html # дефицит НЕ спрятан + assert "и ещё" in html # областной шум схлопнут + # Не-ЕКБ город не должен фигурировать строкой. + assert "Ирбит" not in html + + +def test_heat_deficit_not_hidden_under_cap() -> None: + """Честность: отрицательный резерв ЕКБ виден даже при переполнении капа (FIX 3).""" + systems = [{"org": "ЕТК", "system_name": "СТ Екатеринбург дефицит", "reserve_gcal_h": -5.0}] + # Забиваем кап 20 профицитными ЕКБ-системами. + for i in range(20): + systems.append( + {"org": "ЕТК", "system_name": f"СТ Екатеринбург плюс {i}", "reserve_gcal_h": float(i)} + ) + html = build_full_report_html_part_a( + _synthetic_result(), cad="X", connection_capacity={"heat": {"systems": systems}} + ) + assert "Екатеринбург дефицит" in html + + +def test_long_system_name_truncated() -> None: + """Длинное бюрократическое имя системы усечено до ~120 символов с «…» (FIX 3).""" + long_name = "Централизованная система теплоснабжения " * 6 # >200 симв. + cap = {"heat": {"systems": [{"org": "ЕТК Екатеринбург", "system_name": long_name}]}} + html = build_full_report_html_part_a(_synthetic_result(), cad="X", connection_capacity=cap) + assert "…" in html + assert long_name not in html # полное имя не попало целиком + + # ── 9. Каркас документа: титул / оглавление / опциональный Part B ──────────────── diff --git a/backend/tests/services/exporters/test_full_report_html_part_b.py b/backend/tests/services/exporters/test_full_report_html_part_b.py index fe44ed0f..d81d3c6e 100644 --- a/backend/tests/services/exporters/test_full_report_html_part_b.py +++ b/backend/tests/services/exporters/test_full_report_html_part_b.py @@ -567,3 +567,113 @@ def test_document_frame_with_both_parts() -> None: assert f'href="#{_ANCHOR_S7}"' in doc # Ровно один общий