fix(site-finder): 5 дефектов из боевого фидбека analyze (#1736 #1737 #1738 #1739 #1740)
Some checks failed
CI / changes (push) Successful in 9s
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (push) Failing after 1m50s
CI / frontend-tests (pull_request) Failing after 1m48s
CI / openapi-codegen-check (pull_request) Failing after 2m39s
CI / openapi-codegen-check (push) Failing after 2m46s
CI / backend-tests (pull_request) Successful in 9m25s
CI / backend-tests (push) Successful in 9m30s

- #1736 MiniMap: проброс useConnectionPoints → точки подключения на карте analyze (были только в /legacy)
- #1737 confidence: пронесено имя сервиса → RU-ярлык (Рынок/Будущее предложение/…) вместо «Компонент вкладывающий сервис»
- #1738 pipeline: self-exclusion субъекта (ST_DWithin 80м) — проект не считает сам себя будущим конкурентом
- #1739 PDF: snapshot_pdf обёрнут в try/except+logger.exception (причина 500 видна) + format=pdf в forecast export + font_url fallback
- #1740 gate↔recommendation: при can_build_mkd=False — gate_caveat на обоих рекомендаторах (противоречие явное, не молчит)

Verify: py_compile 5/5, tsc 0, ruff clean, pytest confidence/forecast 95 passed.

Closes #1736
Closes #1737
Closes #1738
Closes #1739
Closes #1740
This commit is contained in:
Light1YT 2026-06-18 11:47:21 +05:00
parent e7901bc1e8
commit b1c0ea1268
9 changed files with 349 additions and 100 deletions

View file

@ -49,6 +49,7 @@ from app.services.exporters.report_md import (
render_report_markdown,
render_report_telegram_summary,
)
from app.services.exporters.report_pdf import export_report_pdf
from app.services.exporters.report_pptx import render_report_pptx
from app.services.exporters.snapshot_pdf import generate_snapshot_pdf
from app.services.site_finder.best_layouts import get_best_layouts
@ -244,6 +245,12 @@ PIPELINE_HORIZON_MONTHS = 24
PIPELINE_SEVERITY_MEDIUM_THRESHOLD = 500 # flats_total < это → low
PIPELINE_SEVERITY_HIGH_THRESHOLD = 3000 # flats_total >= это → high
PIPELINE_TOP_OBJECTS_LIMIT = 10
# #1738: самоисключение СУБЪЕКТА анализа из pipeline. Если анализируемый участок
# уже застраивается (его ЖК есть в domrf_kn_objects), он не должен считаться
# собственным «конкурентом». Отсекаем объекты, чья точка попадает в геометрию
# участка или в ~80м от неё (домрф-координата ставится на корпус/въезд, может
# не совпадать с центроидом ЗУ → буфер ловит «свой» объект).
PIPELINE_SELF_EXCLUDE_M = 80
def _coord_round(value: Any) -> float | None:
@ -1163,11 +1170,35 @@ def get_parcel_forecast(
return {"status": "pending"}
if run is not None:
report = run.result
# #1740: gate↔recommendation — если последний analyze-ран дал gate-вердикт
# «Нельзя строить МКД», помечаем forecast-рекомендатор (product_tz) caveat-флагом.
# Рекомендацию НЕ удаляем (строгость = продуктовое решение), только делаем
# противоречие явным. Best-effort: сбой чтения analyze-рана не валит выдачу форсайта.
try:
analyze_run = latest_run_for(db, cad_num, schema_version=ANALYZE_SCHEMA_VERSION)
gate = (analyze_run.result or {}).get("gate_verdict") if analyze_run else None
if (
isinstance(gate, dict)
and gate.get("can_build_mkd") is False
and isinstance(report, dict)
and isinstance(report.get("product_tz"), dict)
):
report["product_tz"]["gate_caveat"] = (
"Строительство МКД под вопросом (gate: Нельзя) — "
"рекомендация носит условный характер"
)
except Exception:
logger.warning(
"gate_caveat enrich failed for forecast cad=%s — report unaffected",
cad_num,
exc_info=True,
)
return {
"status": "ready",
"run_id": run.id,
"created_at": run.created_at,
"report": run.result,
"report": report,
}
# Форсайт ещё не посчитан (таска в работе или /analyze не вызывали) — клиент поллит.
@ -1180,14 +1211,14 @@ def export_parcel_forecast(
cad_num: str,
db: Annotated[Session, Depends(get_db)],
format: Annotated[
Literal["md", "json", "tg", "docx", "pptx"],
Literal["md", "json", "tg", "docx", "pptx", "pdf"],
Query(
description="Формат выгрузки: md (Markdown) | json (сырой отчёт) | "
"tg (сводка) | docx (Word-документ) | pptx (презентация)"
"tg (сводка) | docx (Word-документ) | pptx (презентация) | pdf (PDF-отчёт)"
),
] = "md",
) -> Response:
"""Экспорт §22-форсайта — Markdown / JSON / DOCX / PPTX (файл) или TG-сводка (#959).
"""Экспорт §22-форсайта — Markdown / JSON / DOCX / PPTX / PDF (файл) или TG-сводка (#959).
Читает ПОСЛЕДНИЙ §22-ран schema_version "1.0" (тот же блоб, что отдаёт GET
/{cad}/forecast inline) и отдаёт его в нужной форме.
@ -1197,6 +1228,8 @@ def export_parcel_forecast(
зеркало содержания md/pdf; python-docx).
format=pptx `render_report_pptx(run.result)` attachment (.pptx, презентация,
сжатое зеркало содержания docx; python-pptx).
format=pdf `export_report_pdf(run.result)` attachment (.pdf, §13-отчёт,
зеркало содержания md/docx; WeasyPrint).
format=tg `render_report_telegram_summary(run.result)` КРАТКАЯ plain-text сводка
для копипаста в Telegram, INLINE (без Content-Disposition: это сниппет читать/копировать
и отправить, а не файл-download).
@ -1208,10 +1241,10 @@ def export_parcel_forecast(
Args:
cad_num: кадастровый номер участка (в имени файла `:` `_`).
format: "md" (default) | "json" | "tg" | "docx" | "pptx".
format: "md" (default) | "json" | "tg" | "docx" | "pptx" | "pdf".
Returns:
Response для md/json/docx/pptx attachment с Content-Disposition (имя
Response для md/json/docx/pptx/pdf attachment с Content-Disposition (имя
`gendesign_forecast_<cad>_<YYYY-MM-DD>.<ext>`); для tg inline text/plain сводка.
"""
run = latest_run_for(db, cad_num, schema_version=_FORECAST_SCHEMA_VERSION)
@ -1249,6 +1282,15 @@ def export_parcel_forecast(
headers={"Content-Disposition": f'attachment; filename="{base_name}.pptx"'},
)
# pdf — байты (WeasyPrint), отдельной веткой (зеркало pptx early-return): §13-отчёт
# из export_report_pdf (#1739). md/json string-ветки остаются байт-в-байт прежними.
if format == "pdf":
return Response(
content=export_report_pdf(run.result),
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{base_name}.pdf"'},
)
if format == "json":
payload = json.dumps(run.result, ensure_ascii=False, default=str)
media_type = "application/json"
@ -1705,6 +1747,13 @@ def analyze_parcel(
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
:radius_m
)
-- #1738: исключаем СУБЪЕКТ анализа — объект на самом участке
-- (его точка внутри/в ~80м геометрии ЗУ) не считаем конкурентом.
AND NOT ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_GeomFromText(:wkt, 4326)::geography,
:self_exclude_m
)
AND ready_dt >= CURRENT_DATE
AND ready_dt < CURRENT_DATE + cast(:horizon_months || ' months' AS interval)
ORDER BY ready_dt ASC
@ -1712,6 +1761,7 @@ def analyze_parcel(
{
"wkt": geom_wkt,
"radius_m": PIPELINE_RADIUS_M,
"self_exclude_m": PIPELINE_SELF_EXCLUDE_M,
"horizon_months": str(PIPELINE_HORIZON_MONTHS),
},
)
@ -2731,6 +2781,26 @@ def analyze_parcel(
e,
)
# #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner.
# Вычисляем ДО сборки result_payload, чтобы при can_build_mkd is False (#1740)
# пометить рекомендации флагом-кейватом (противоречие gate↔рекомендация явное).
gate_verdict = compute_gate_verdict(
nspd_zoning=nspd_dump_data["nspd_zoning"],
nspd_zouit_overlaps=nspd_dump_data["nspd_zouit_overlaps"],
nspd_engineering_nearby=nspd_dump_data["nspd_engineering_nearby"],
nspd_dump=nspd_dump_data["nspd_dump"],
)
# #1740: gate говорит «Нельзя строить МКД», но рекомендатор всё равно выдаёт
# квартирографию — не удаляем рекомендацию (строгость = продуктовое решение),
# а делаем противоречие явным через caveat-флаг. success_recommendation —
# analyze-рекомендатор (топ-квартирография по district).
if gate_verdict.get("can_build_mkd") is False and success_recommendation is not None:
success_recommendation["gate_caveat"] = (
"Строительство МКД под вопросом (gate: Нельзя) — "
"рекомендация носит условный характер"
)
result_payload: dict[str, Any] = {
"cad_num": cad_num,
"source": source,
@ -2827,12 +2897,8 @@ def analyze_parcel(
"nspd_red_lines": [RedLine(**rl) for rl in nspd_dump_data.get("nspd_red_lines", [])],
"nspd_dump": nspd_dump_data["nspd_dump"],
# #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner
"gate_verdict": compute_gate_verdict(
nspd_zoning=nspd_dump_data["nspd_zoning"],
nspd_zouit_overlaps=nspd_dump_data["nspd_zouit_overlaps"],
nspd_engineering_nearby=nspd_dump_data["nspd_engineering_nearby"],
nspd_dump=nspd_dump_data["nspd_dump"],
),
# (вычислен выше, до сборки payload — нужен для gate_caveat #1740).
"gate_verdict": gate_verdict,
# #114/#201: кастомные веса POI — source + applied dict для прозрачности.
"weights_profile": {
"source": _weights_source,
@ -3301,52 +3367,70 @@ def parcel_snapshot_pdf(
Не является официальной выпиской ЕГРН только аналитические данные НСПД.
Генерация <2 сек. Открывается в Adobe Reader / Chrome.
"""
# 1) Получить метаданные участка из cad_parcels
parcel_row = (
db.execute(
text("""
SELECT readable_address AS address,
land_record_area AS area_m2,
land_record_category_type AS land_category,
permitted_use_established_by_document AS vri,
cost_value AS cadastral_cost,
updated_at AS last_update
FROM cad_parcels
WHERE cad_num = CAST(:c AS text)
LIMIT 1
"""),
{"c": cad_num},
)
.mappings()
.first()
)
if not parcel_row:
raise HTTPException(
status_code=404,
detail=f"Участок {cad_num} не найден в БД. Используйте POST /analyze для загрузки.",
# 1-6) Сбор данных участка из БД (метаданные + геометрия + POI + конкуренты +
# district + last_update). Обёрнуто в try/except (#1739): иначе сырая DB/PostGIS-
# ошибка всплывает 500-кой без объяснения причины. logger.exception сохраняет
# traceback; HTTPException(404) при отсутствии участка и (422) при отсутствии
# геометрии — re-raise НЕ оборачиваем (это валидные клиентские ответы, а не 500).
try:
# 1) Получить метаданные участка из cad_parcels
parcel_row = (
db.execute(
text("""
SELECT readable_address AS address,
land_record_area AS area_m2,
land_record_category_type AS land_category,
permitted_use_established_by_document AS vri,
cost_value AS cadastral_cost,
updated_at AS last_update
FROM cad_parcels
WHERE cad_num = CAST(:c AS text)
LIMIT 1
"""),
{"c": cad_num},
)
.mappings()
.first()
)
# 2) Получить геометрию (WKT) для POI / competitor queries
geom_row = (
db.execute(
text("""
SELECT ST_AsText(COALESCE(
(SELECT geom FROM cad_parcels_geom WHERE cad_num = CAST(:c AS text) LIMIT 1),
(SELECT geom FROM cad_parcels WHERE cad_num = CAST(:c AS text) LIMIT 1)
)) AS wkt
"""),
{"c": cad_num},
)
.mappings()
.first()
)
geom_wkt: str | None = geom_row["wkt"] if geom_row else None
if not parcel_row:
raise HTTPException(
status_code=404,
detail=(
f"Участок {cad_num} не найден в БД. "
"Используйте POST /analyze для загрузки."
),
)
# 3) POI в радиусе 1 км (только если есть геометрия)
poi_rows: list[dict[str, Any]] = []
if geom_wkt:
poi_rows = [
# 2) Получить геометрию (WKT) для POI / competitor queries
geom_row = (
db.execute(
text("""
SELECT ST_AsText(COALESCE(
(SELECT geom FROM cad_parcels_geom WHERE cad_num = CAST(:c AS text) LIMIT 1),
(SELECT geom FROM cad_parcels WHERE cad_num = CAST(:c AS text) LIMIT 1)
)) AS wkt
"""),
{"c": cad_num},
)
.mappings()
.first()
)
geom_wkt: str | None = geom_row["wkt"] if geom_row else None
# #1739: без геометрии участка PDF-снимок бессмыслен (нет POI/конкурентов/
# района). 422 — клиентский кейс «данные неполны», не серверная 500.
if not geom_wkt:
raise HTTPException(
status_code=422,
detail=(
f"Геометрия участка {cad_num} отсутствует в БД — "
"PDF-снимок недоступен. Запустите POST /analyze для дозагрузки."
),
)
# 3) POI в радиусе 1 км
poi_rows: list[dict[str, Any]] = [
dict(r)
for r in db.execute(
text("""
@ -3371,10 +3455,8 @@ def parcel_snapshot_pdf(
.all()
]
# 4) Конкуренты в радиусе 3 км (только если есть геометрия)
competitor_rows: list[dict[str, Any]] = []
if geom_wkt:
competitor_rows = [
# 4) Конкуренты в радиусе 3 км
competitor_rows: list[dict[str, Any]] = [
dict(r)
for r in db.execute(
text("""
@ -3408,9 +3490,8 @@ def parcel_snapshot_pdf(
.all()
]
# 5) Получить district (через пересечение с ekb_districts если есть геом)
district: str | None = None
if geom_wkt:
# 5) Получить district (через пересечение с ekb_districts)
district: str | None = None
district_row = (
db.execute(
text("""
@ -3427,16 +3508,25 @@ def parcel_snapshot_pdf(
if district_row:
district = district_row["district_name"]
# 6) Форматировать last_update
raw_update = parcel_row["last_update"]
last_update_str: str | None = None
if raw_update is not None:
try:
last_update_str = raw_update.strftime("%d.%m.%Y")
except AttributeError:
last_update_str = str(raw_update)[:10]
# 6) Форматировать last_update
raw_update = parcel_row["last_update"]
last_update_str: str | None = None
if raw_update is not None:
try:
last_update_str = raw_update.strftime("%d.%m.%Y")
except AttributeError:
last_update_str = str(raw_update)[:10]
except HTTPException:
# 404/422 — валидные клиентские ответы, пропускаем без обёртки в 500.
raise
except Exception as exc:
logger.exception("snapshot data fetch failed for %s", cad_num)
raise HTTPException(
status_code=500, detail="Ошибка сбора данных для PDF-снимка"
) from exc
# 7) Сгенерировать PDF
# 7) Сгенерировать PDF (WeasyPrint-рендер — отдельная зона отказа: шрифты,
# шаблон, движок). logger.exception сохраняет traceback причины 500 (#1739).
try:
pdf_bytes = generate_snapshot_pdf(
cad_num=cad_num,
@ -3456,7 +3546,7 @@ def parcel_snapshot_pdf(
competitors_limit=5,
)
except Exception as exc:
logger.error("snapshot PDF generation failed for %s: %s", cad_num, exc)
logger.exception("snapshot PDF render failed for %s", cad_num)
raise HTTPException(status_code=500, detail="Ошибка генерации PDF") from exc
cad_safe = cad_num.replace(":", "-")

View file

@ -13,8 +13,11 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape
logger = logging.getLogger(__name__)
# Путь к директории шаблонов (относительно этого файла — 2 уровня вверх, затем templates)
_TEMPLATE_DIR = pathlib.Path(__file__).parent.parent / "templates"
# Путь к директории шаблонов: app/templates/parcel_snapshot.html (#1739).
# Файл лежит в app/services/exporters/, поэтому до app/ — 3 уровня вверх
# (exporters → services → app), затем templates. parent.parent давал
# несуществующий app/services/templates → TemplateNotFound → 500 на snapshot.
_TEMPLATE_DIR = pathlib.Path(__file__).parent.parent.parent / "templates"
# Системные пути DejaVu Sans (Ubuntu/Debian Docker-образ + Alpine резерв)
_DEJAVU_CANDIDATES: list[str] = [
@ -61,6 +64,10 @@ def _find_font_url() -> str:
WeasyPrint умеет сам находить системные шрифты через fonttools/fontconfig,
поэтому пустая строка допустима шрифт тогда подбирается CSS generic.
#1739: при пустой строке шаблон НЕ эмитит `@font-face { src: url('') }`
(обёрнут в `{% if font_url %}`). Иначе WeasyPrint пытался резолвить пустой
URL 'failed to load font' ошибка вместо тихого fallback на Arial/sans-serif.
"""
for path in _DEJAVU_CANDIDATES:
if pathlib.Path(path).exists():

View file

@ -42,11 +42,16 @@ JSON-safe и ложится в слот ReportConfidence (#987): {level, rationa
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import Any, Literal
Confidence = Literal["high", "medium", "low"]
# Вход component_confidences может быть плоским уровнем (legacy) ИЛИ парой
# (имя-сервиса, уровень) — чтобы пронести ИМЯ сервиса сквозь стек (#1737).
ComponentConfidence = Confidence | tuple[str, Confidence]
# ── Порядок уверенности для weakest-link MIN (хуже = ниже). Зеркало vocab
# future_supply._CONFIDENCE_RANK / product_scoring._CONFIDENCE_RANK. ───────────────
_CONFIDENCE_RANK: dict[Confidence, int] = {"low": 0, "medium": 1, "high": 2}
@ -61,6 +66,18 @@ _F_CONFOUNDED_WINDOW: str = "confounded_window"
_F_ADVISORY_CAP: str = "advisory_cap"
_F_COMPONENT: str = "component" # один вкладывающий per-service confidence
# ── RU-имена вкладывающих под-сервисов (#1737). Машинное имя сервиса (как его кормит
# сборщик #988) → человекочитаемое RU-имя для отчёта/UI. Неизвестный/пустой ключ →
# дефолт «Компонент» (graceful — не теряем фактор, просто без точечного имени). ──────
_SERVICE_RU: dict[str, str] = {
"market_metrics": "Рынок",
"future_supply": "Будущее предложение",
"product_scores": "Скоринг продукта",
"special_indices": "Спец-индексы",
"forecasts": "Прогноз спрос/предложение",
}
_SERVICE_RU_DEFAULT: str = "Компонент"
# ── Пороги счётчиков (align с per-service gate'ами, прочитанными в #949/#951) ──────
# deal_count: число сделок (продаж) за окно. high — длинная плотная выборка,
@ -97,20 +114,29 @@ class ConfidenceFactor:
(число сделок / ЖК / покрытие / месяцы / bool шок-окна или None); `level`
его собственный вклад high/medium/low; `note` короткая RU-фраза с числом
(«7 сделок за 6 мес мало»), из которой собирается структурная причина §15.
`label` человекочитаемое RU-имя фактора (#1737: для component-факторов это ИМЯ
под-сервиса «Рынок» / «Будущее предложение» , чтобы UI не показывал сырой
«Компонент N»); пусто для счётчиков (у них своя RU-метка на стороне UI).
"""
name: str
value: Any # число/доля/флаг, обосновавшие level (или None)
level: Confidence
note: str # RU, с реальным числом
label: str = "" # RU-имя фактора (для component — имя сервиса, #1737); опционально
def as_dict(self) -> dict[str, Any]:
return {
# `label` кладём ТОЛЬКО когда задан — схема обратно-совместима (старые
# потребители без поля `label` продолжают читать name/value/level/note).
out: dict[str, Any] = {
"name": self.name,
"value": self.value,
"level": self.level,
"note": self.note,
}
if self.label:
out["label"] = self.label
return out
@dataclass(frozen=True)
@ -145,7 +171,12 @@ class ReportConfidenceResult:
while f"{key}_{i}" in factors:
i += 1
key = f"{key}_{i}"
factors[key] = {"value": f.value, "level": f.level, "note": f.note}
entry: dict[str, Any] = {"value": f.value, "level": f.level, "note": f.note}
# RU-имя фактора (для component — имя сервиса, #1737) кладём ТОЛЬКО когда
# задано — плоский dict остаётся обратно-совместимым по форме.
if f.label:
entry["label"] = f.label
factors[key] = entry
factors["advisory_capped"] = self.advisory_capped
return {
"level": self.level,
@ -256,17 +287,23 @@ def _confounded_factor(confounded: bool) -> ConfidenceFactor:
)
def _component_factor(level: Confidence) -> ConfidenceFactor:
def _component_factor(level: Confidence, service: str | None = None) -> ConfidenceFactor:
"""Один вкладывающий per-service confidence → ConfidenceFactor. PURE.
Per-service уже свернул свои тонкие сигналы в high/medium/low; берём как факт,
нота поясняет вклад. value=None (число внутри самого под-сервиса).
нота поясняет вклад. value=None (число внутри самого под-сервиса). `service`
машинное имя под-сервиса (#1737): из него берём человекочитаемое RU-имя
(«Рынок» / «Будущее предложение» / «Скоринг продукта» / «Спец-индексы») в
`label` и НАЗЫВАЕМ сервис в `note` (вместо безличного «вкладывающий сервис: high»).
Неизвестное/None имя дефолт «Компонент» (graceful фактор не теряется).
"""
ru_name = _SERVICE_RU.get(service or "", _SERVICE_RU_DEFAULT)
return ConfidenceFactor(
name=_F_COMPONENT,
value=None,
level=level,
note=f"вкладывающий сервис: {level}",
note=f"{ru_name}: {level}",
label=ru_name,
)
@ -370,7 +407,7 @@ def _join_notes(notes: list[str], *, limit: int = 3) -> str:
def compute_report_confidence(
*,
component_confidences: list[Confidence] | None = None,
component_confidences: Sequence[ComponentConfidence] | None = None,
deal_count: int | None = None,
analog_count: int | None = None,
domrf_coverage: float | None = None,
@ -398,7 +435,9 @@ def compute_report_confidence(
Args:
component_confidences: per-service confidence (#950/#952/#985/#986…), None/[]→
нет вкладывающих компонентов.
нет вкладывающих компонентов. Каждый элемент ЛИБО плоский уровень
'high|medium|low' (legacy), ЛИБО пара (имя-сервиса, уровень) (#1737:
имя сервиса проносится сквозь стек в RU-метку фактора).
deal_count: число сделок за окно (None нет данных, тянет в low).
analog_count: число ЖК-аналогов в выборке (= market_metrics.obj_count).
domrf_coverage: доля domrfobjective [0,1] (главный sparse-риск проекта).
@ -451,9 +490,16 @@ def compute_report_confidence(
factors.append(_confounded_factor(True))
# ── 2. Вкладывающие per-service confidence → факторы ──────────────────────
# Элемент — ЛИБО плоский уровень (legacy), ЛИБО пара (имя-сервиса, уровень)
# (#1737: проносим имя сервиса в RU-метку фактора). Мусор/неизвестный уровень
# отбрасываем (whitelist), как и раньше.
for c in component_confidences or []:
if c in _CONFIDENCE_RANK:
factors.append(_component_factor(c))
if isinstance(c, tuple):
service, level = c
else:
service, level = None, c
if level in _CONFIDENCE_RANK:
factors.append(_component_factor(level, service))
# ── 3. weakest-link агрегат (худший тянет вниз) ───────────────────────────
raw_level = _aggregate(factors)

View file

@ -246,23 +246,33 @@ def _component_confidences(
forecasts: Sequence[dict[str, Any]],
product_scores: dict[str, Any] | None,
special_indices: dict[str, Any] | None,
) -> list[Confidence]:
) -> list[tuple[str, Confidence]]:
"""Собрать per-service confidence вкладывающих под-сервисов — для #990. PURE.
#990 свернёт их weakest-link (MIN) вместе с сырыми счётчиками. Берём confidence-
метку каждого доступного под-вывода (market_metrics §9.2 / future_supply §9.3 /
каждый per-горизонт forecast #952 / product_scores #985 / special_indices #986).
Только whitelisted 'high|medium|low' (мусор/None отбрасываем).
#1737: возвращаем ПАРЫ (имя-сервиса, уровень) — машинное имя под-сервиса
(«market_metrics» / «future_supply» / «product_scores» / «special_indices» /
«forecasts»), чтобы #990 пронёс ИМЯ сервиса в RU-метку фактора и UI не показывал
безличный «Компонент N».
"""
out: list[Confidence] = []
for source in (market_metrics, future_supply, product_scores, special_indices):
out: list[tuple[str, Confidence]] = []
for name, source in (
("market_metrics", market_metrics),
("future_supply", future_supply),
("product_scores", product_scores),
("special_indices", special_indices),
):
conf = _confidence_of(source)
if conf is not None:
out.append(conf)
out.append((name, conf))
for f in forecasts:
conf = _confidence_of(f)
if conf is not None:
out.append(conf)
out.append(("forecasts", conf))
return out

View file

@ -4,10 +4,12 @@
<meta charset="UTF-8" />
<title>Карточка участка {{ cad_num }}</title>
<style>
{% if font_url %}
@font-face {
font-family: 'DejaVu Sans';
src: url('{{ font_url }}') format('truetype');
}
{% endif %}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'DejaVu Sans', Arial, sans-serif;

View file

@ -22,10 +22,33 @@ import datetime as dt
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.main import app
def _weasyprint_native_libs_available() -> tuple[bool, str]:
"""Probe whether WeasyPrint can render (native GTK/Pango/GObject libs present).
libpango/libgobject отсутствуют на macOS dev, присутствуют в Docker/CI-образе.
Зеркало гварда из tests/services/exporters/test_report_pdf.py пропускаем
PDF-рендер на dev, но прогоняем на CI (#1739).
"""
try:
from weasyprint import HTML
HTML(string="<p>x</p>").write_pdf()
except (OSError, ImportError) as e:
return False, str(e)
return True, ""
_WP_OK, _WP_ERR = _weasyprint_native_libs_available()
_skip_if_no_weasyprint = pytest.mark.skipif(
not _WP_OK, reason=f"WeasyPrint native libs missing: {_WP_ERR}"
)
# ── Константы ─────────────────────────────────────────────────────────────────
_CAD = "66:41:0204016:10"
@ -289,7 +312,11 @@ def test_get_forecast_ready_when_run_present() -> None:
db = MagicMock()
app.dependency_overrides[get_db] = _override_db(db)
try:
with patch("app.api.v1.parcels.latest_run_for", return_value=fake_run) as lrf:
# 1-й вызов — §22-ран ("1.0"); 2-й — analyze-ран для gate_caveat (#1740),
# здесь None (gate не применяется).
with patch(
"app.api.v1.parcels.latest_run_for", side_effect=[fake_run, None]
) as lrf:
client = TestClient(app)
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast")
assert resp.status_code == 200, resp.text
@ -297,8 +324,8 @@ def test_get_forecast_ready_when_run_present() -> None:
assert body["status"] == "ready"
assert body["run_id"] == 66
assert body["report"] == report_dict
# Читаем именно §22-схему "1.0" (3b-i schema-filtered seam).
_, kwargs = lrf.call_args
# Читаем именно §22-схему "1.0" (3b-i schema-filtered seam) — первый вызов.
_, kwargs = lrf.call_args_list[0]
assert kwargs.get("schema_version") == "1.0"
finally:
app.dependency_overrides.clear()
@ -590,8 +617,54 @@ def test_export_forecast_no_run_returns_404() -> None:
app.dependency_overrides.clear()
@_skip_if_no_weasyprint
def test_export_forecast_pdf_returns_pdf() -> None:
"""format=pdf → 200 application/pdf + attachment .pdf, тело — PDF-байты (%PDF) (#1739)."""
from app.core.db import get_db
fake_run = MagicMock()
fake_run.result = _REPORT_FIXTURE
db = MagicMock()
app.dependency_overrides[get_db] = _override_db(db)
try:
with patch("app.api.v1.parcels.latest_run_for", return_value=fake_run) as lrf:
client = TestClient(app)
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=pdf")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == "application/pdf"
cd = resp.headers["content-disposition"]
assert "attachment;" in cd
assert cd.endswith('.pdf"')
# cad-двоеточия санитизированы в имени файла.
assert f"gendesign_forecast_{_CAD.replace(':', '_')}_" in cd
# WeasyPrint PDF начинается с маркера %PDF.
assert resp.content[:4] == b"%PDF"
# Читаем именно §22-схему "1.0".
_, kwargs = lrf.call_args
assert kwargs.get("schema_version") == "1.0"
finally:
app.dependency_overrides.clear()
def test_export_forecast_pdf_no_run_returns_404() -> None:
"""format=pdf + рана нет → 404 (как md/json/docx/pptx export-вариант, НЕ 202/500)."""
from app.core.db import get_db
db = MagicMock()
app.dependency_overrides[get_db] = _override_db(db)
try:
with patch("app.api.v1.parcels.latest_run_for", return_value=None):
client = TestClient(app)
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=pdf")
assert resp.status_code == 404, resp.text
assert "посчитан" in resp.json()["detail"]
finally:
app.dependency_overrides.clear()
def test_export_forecast_invalid_format_returns_422() -> None:
"""format вне {md,json,tg,docx,pptx} → 422 (Literal-валидация ДО работы с БД)."""
"""format вне {md,json,tg,docx,pptx,pdf} → 422 (Literal-валидация ДО работы с БД)."""
from app.core.db import get_db
db = MagicMock()
@ -599,7 +672,7 @@ def test_export_forecast_invalid_format_returns_422() -> None:
try:
with patch("app.api.v1.parcels.latest_run_for", return_value=None) as lrf:
client = TestClient(app)
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=pdf")
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=xml")
assert resp.status_code == 422, resp.text
lrf.assert_not_called()
finally:

View file

@ -35,11 +35,15 @@ function isConfidenceFactor(v: unknown): v is ConfidenceFactor {
);
}
function factorLabel(key: string): string {
function factorLabel(key: string, factor: ConfidenceFactor): string {
// #1737: backend проносит RU-имя сервиса в `label` для component-факторов
// («Рынок» / «Будущее предложение» / …) — предпочитаем его, чтобы не показывать
// безличный «Компонент N».
if (factor.label) return factor.label;
if (FACTOR_RU[key]) return FACTOR_RU[key];
// component_2..N → «Компонент N» (note уже несёт «вкладывающий сервис: …»)
const m = key.match(/^component_(\d+)$/);
if (m) return `Компонент ${m[1]}`;
// Fallback для старых конвертов без `label`: component / component_2..N → «Компонент N».
const m = key.match(/^component(?:_(\d+))?$/);
if (m) return m[1] ? `Компонент ${m[1]}` : "Компонент";
return key;
}
@ -142,7 +146,7 @@ export function ForecastConfidenceBlock({ confidence }: Props) {
color: "var(--fg-primary)",
}}
>
{factorLabel(key)}
{factorLabel(key, factor)}
{valueStr != null && (
<span
style={{

View file

@ -16,6 +16,7 @@ import dynamic from "next/dynamic";
import type { ParcelAnalyzeResponse } from "@/lib/site-finder-api";
import type { ParcelAnalysis } from "@/types/site-finder";
import type { Geometry } from "geojson";
import { useConnectionPoints } from "@/hooks/useConnectionPoints";
// MiniMap высота тайла карты — компромисс между плотностью Section 1 и
// читаемостью. Layer-controls/легенда вне этого размера (flow ниже).
@ -87,10 +88,18 @@ function toSiteMapData(data: ParcelAnalyzeResponse): ParcelAnalysis {
export function MiniMap({ data }: Props) {
const adapted = toSiteMapData(data);
// #1736: точки подключения ресурсов (электро/газ/вода/тепло). Зеркалим
// обвязку legacy /site-finder (page.tsx:109,568) — тянем CP-данные по
// data.cad_num и пробрасываем prop `connectionPoints` в <SiteMap>, который
// рисует ConnectionPointsLayer + CpLayerControlPanel при truthy prop.
const { data: connectionPoints } = useConnectionPoints(data.cad_num);
// #1218: НЕ задаём height + overflow:hidden на обёртке. SiteMap сам
// ограничивает высоту карты-тайла (mapHeight), а легенда + layer-controls
// (Будущие проекты / Зоны риска / Перспективные ЗУ / Красные линии) лежат
// под картой потоком и должны быть видны/кликабельны.
// (Будущие проекты / Зоны риска / Перспективные ЗУ / Красные линии) + панель
// точек подключения (CpLayerControlPanel) лежат под картой потоком и должны
// быть видны/кликабельны. MINIMAP_HEIGHT ограничивает ТОЛЬКО тайл карты, не
// клипает CP-панель.
return (
<div
style={{
@ -101,6 +110,7 @@ export function MiniMap({ data }: Props) {
>
<SiteMap
data={adapted}
connectionPoints={connectionPoints}
competitors={data.competitors ?? []}
pipelineObjects={data.pipeline_24mo?.top_objects ?? []}
riskZones={data.nspd_risk_zones ?? []}

View file

@ -158,6 +158,13 @@ export interface ConfidenceFactor {
note: string;
level: ConfidenceLevel;
value: number | null;
/**
* Human-readable RU label (#1737). For per-service «component» factors this is
* the service name («Рынок» / «Будущее предложение» / ) so the UI never shows a
* bare «Компонент N». Optional older envelopes omit it; the UI falls back to
* the factor key.
*/
label?: string;
}
export interface ReportConfidence {