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

View file

@ -13,8 +13,11 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Путь к директории шаблонов (относительно этого файла — 2 уровня вверх, затем templates) # Путь к директории шаблонов: app/templates/parcel_snapshot.html (#1739).
_TEMPLATE_DIR = pathlib.Path(__file__).parent.parent / "templates" # Файл лежит в 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 Sans (Ubuntu/Debian Docker-образ + Alpine резерв)
_DEJAVU_CANDIDATES: list[str] = [ _DEJAVU_CANDIDATES: list[str] = [
@ -61,6 +64,10 @@ def _find_font_url() -> str:
WeasyPrint умеет сам находить системные шрифты через fonttools/fontconfig, WeasyPrint умеет сам находить системные шрифты через fonttools/fontconfig,
поэтому пустая строка допустима шрифт тогда подбирается CSS generic. поэтому пустая строка допустима шрифт тогда подбирается 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: for path in _DEJAVU_CANDIDATES:
if pathlib.Path(path).exists(): if pathlib.Path(path).exists():

View file

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

View file

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

View file

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

View file

@ -22,10 +22,33 @@ import datetime as dt
from typing import Any from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.main import app 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" _CAD = "66:41:0204016:10"
@ -289,7 +312,11 @@ def test_get_forecast_ready_when_run_present() -> None:
db = MagicMock() db = MagicMock()
app.dependency_overrides[get_db] = _override_db(db) app.dependency_overrides[get_db] = _override_db(db)
try: 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) client = TestClient(app)
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast") resp = client.get(f"/api/v1/parcels/{_CAD}/forecast")
assert resp.status_code == 200, resp.text 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["status"] == "ready"
assert body["run_id"] == 66 assert body["run_id"] == 66
assert body["report"] == report_dict assert body["report"] == report_dict
# Читаем именно §22-схему "1.0" (3b-i schema-filtered seam). # Читаем именно §22-схему "1.0" (3b-i schema-filtered seam) — первый вызов.
_, kwargs = lrf.call_args _, kwargs = lrf.call_args_list[0]
assert kwargs.get("schema_version") == "1.0" assert kwargs.get("schema_version") == "1.0"
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
@ -590,8 +617,54 @@ def test_export_forecast_no_run_returns_404() -> None:
app.dependency_overrides.clear() 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: 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 from app.core.db import get_db
db = MagicMock() db = MagicMock()
@ -599,7 +672,7 @@ def test_export_forecast_invalid_format_returns_422() -> None:
try: try:
with patch("app.api.v1.parcels.latest_run_for", return_value=None) as lrf: with patch("app.api.v1.parcels.latest_run_for", return_value=None) as lrf:
client = TestClient(app) 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 assert resp.status_code == 422, resp.text
lrf.assert_not_called() lrf.assert_not_called()
finally: 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]; if (FACTOR_RU[key]) return FACTOR_RU[key];
// component_2..N → «Компонент N» (note уже несёт «вкладывающий сервис: …») // Fallback для старых конвертов без `label`: component / component_2..N → «Компонент N».
const m = key.match(/^component_(\d+)$/); const m = key.match(/^component(?:_(\d+))?$/);
if (m) return `Компонент ${m[1]}`; if (m) return m[1] ? `Компонент ${m[1]}` : "Компонент";
return key; return key;
} }
@ -142,7 +146,7 @@ export function ForecastConfidenceBlock({ confidence }: Props) {
color: "var(--fg-primary)", color: "var(--fg-primary)",
}} }}
> >
{factorLabel(key)} {factorLabel(key, factor)}
{valueStr != null && ( {valueStr != null && (
<span <span
style={{ style={{

View file

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

View file

@ -158,6 +158,13 @@ export interface ConfidenceFactor {
note: string; note: string;
level: ConfidenceLevel; level: ConfidenceLevel;
value: number | null; 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 { export interface ReportConfidence {