feat(site-finder): inline POI weights pass-through в /analyze (#201 Phase 1) #206

Merged
lekss361 merged 4 commits from feat/201-inline-weights into main 2026-05-16 11:00:42 +00:00
12 changed files with 1643 additions and 33 deletions

View file

@ -6,7 +6,7 @@ import time
from typing import Annotated, Any
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response
from shapely import wkt as _shp_wkt
from shapely.geometry import Polygon
from sqlalchemy import text
@ -15,6 +15,7 @@ from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import get_db
from app.schemas.parcel import (
AnalyzeRequest,
BestLayoutsRequest,
BestLayoutsResponse,
CompetitorsRequest,
@ -24,6 +25,7 @@ from app.schemas.parcel import (
ParcelSearchRequest,
ParcelSearchResponse,
)
from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf
from app.services.site_finder.best_layouts import get_best_layouts
from app.services.site_finder.cadastre_fetch import (
cad_exists_in_db,
@ -40,6 +42,21 @@ from app.services.site_finder.quarter_dump_lookup import (
make_empty_result,
)
from app.services.site_finder.velocity import compute_velocity
from app.services.site_finder.weight_profiles import (
_SYSTEM_POI_WEIGHTS as _POI_WEIGHTS,
)
from app.services.site_finder.weight_profiles import (
ALLOWED_CATEGORIES as _ALLOWED_CATEGORIES,
)
from app.services.site_finder.weight_profiles import (
MAX_WEIGHT as _MAX_WEIGHT,
)
from app.services.site_finder.weight_profiles import (
MIN_WEIGHT as _MIN_WEIGHT,
)
from app.services.site_finder.weight_profiles import (
resolve_weights as _resolve_weights,
)
logger = logging.getLogger(__name__)
@ -284,21 +301,6 @@ def _confidence_label(c: float) -> str:
return "low"
# Веса POI-категорий для scoring (Максим: трамвай = минус)
_POI_WEIGHTS: dict[str, float] = {
"school": 1.5,
"kindergarten": 1.5,
"pharmacy": 0.8,
"hospital": 0.6,
"shop_mall": 1.2,
"shop_supermarket": 1.0,
"shop_small": 0.5,
"park": 1.8,
"bus_stop": 0.3,
"metro_stop": 1.5,
"tram_stop": -0.5, # негативный вес — шум / вибрация
}
# Человеко-читаемые имена категорий для verbal breakdown (X1).
_POI_CATEGORY_RU: dict[str, str] = {
"school": "Школа",
@ -1047,6 +1049,10 @@ def analyze_parcel(
str | None,
Query(description="user_id для fallback на default-профиль пользователя"),
] = None,
body: Annotated[
AnalyzeRequest | None,
Body(description="Опциональное тело запроса: inline POI-веса (#201)"),
] = None,
) -> dict[str, Any]:
"""Анализ участка: близость к социалке + district context + конкуренты.
@ -1222,15 +1228,43 @@ def analyze_parcel(
.all()
)
# 3b) Resolve effective POI weights (profile → user default → system)
from app.services.site_finder.weight_profiles import resolve_weights as _resolve_weights
# 3b) Resolve effective POI weights (inline → profile → user default → system)
_inline_weights: dict[str, float] | None = body.weights if body is not None else None
_effective_weights = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id)
_weights_source = (
"profile"
if profile_id is not None
else ("user_default" if profile_user_id is not None else "system")
)
if _inline_weights is not None:
# Validate inline weights: keys и диапазон значений (#201)
bad_keys = set(_inline_weights.keys()) - _ALLOWED_CATEGORIES
if bad_keys:
raise HTTPException(
status_code=422,
detail=(
f"Неизвестные POI-категории: {sorted(bad_keys)}. "
f"Допустимые: {sorted(_ALLOWED_CATEGORIES)}"
),
)
out_of_range = {
k: v
for k, v in _inline_weights.items()
if not math.isfinite(v) or v < _MIN_WEIGHT or v > _MAX_WEIGHT
}
if out_of_range:
raise HTTPException(
status_code=422,
detail=(
f"Веса за пределами допустимого диапазона "
f"[{_MIN_WEIGHT}, {_MAX_WEIGHT}]: {out_of_range}"
),
)
# Inline weights applied — merge поверх системных defaults (partial override)
_effective_weights = {**_POI_WEIGHTS, **_inline_weights}
_weights_source = "inline"
else:
_effective_weights = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id)
_weights_source = (
"profile"
if profile_id is not None
else ("user_default" if profile_user_id is not None else "system")
)
# 4) Scoring: weighted sum с distance decay
score = 0.0
@ -1925,12 +1959,13 @@ def analyze_parcel(
nspd_engineering_nearby=nspd_dump_data["nspd_engineering_nearby"],
nspd_dump=nspd_dump_data["nspd_dump"],
),
# #114: кастомные веса POI — source + applied dict для прозрачности.
# #114/#201: кастомные веса POI — source + applied dict для прозрачности.
"weights_profile": {
"source": _weights_source,
"profile_id": profile_id,
"user_id": profile_user_id,
"weights_applied": _effective_weights,
"inline_weights": _inline_weights,
},
}
@ -2130,3 +2165,38 @@ async def get_parcel_best_layouts(
except Exception as exc:
logger.error("best_layouts endpoint failed for %s: %s", cad_num, exc)
raise HTTPException(status_code=500, detail="Internal server error") from exc
@router.post("/{cad_num}/best-layouts/pdf")
async def get_parcel_best_layouts_pdf(
cad_num: str,
body: BestLayoutsRequest,
db: Annotated[Session, Depends(get_db)],
) -> Response:
"""ТЗ на проектирование (PDF) — генерируется из /best-layouts данных.
Issue #113 Phase 2.1: data-driven unit-mix recommendation для тендера.
"""
try:
response = get_best_layouts(db=db, cad_num=cad_num, request=body)
pdf_bytes = render_layout_tz_pdf(
response,
cad_num=cad_num,
radius_km=body.radius_km,
time_window=body.time_window,
)
today = _dt.date.today().strftime("%Y-%m-%d")
cad_safe = cad_num.replace(":", "-")
filename = f"tz-layout-{cad_safe}-{today}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
except HTTPException:
raise
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc:
logger.error("best_layouts PDF endpoint failed for %s: %s", cad_num, exc)
raise HTTPException(status_code=500, detail="Internal server error") from exc

View file

@ -213,3 +213,24 @@ class BestLayoutsResponse(BaseModel):
top_layouts: list[TopLayoutRow]
recommendation_for_tz: LayoutTzRecommendation
data_quality: LayoutDataQuality
# ── Analyze endpoint inline weights (#201) ────────────────────────────────────
class AnalyzeRequest(BaseModel):
"""Опциональное тело запроса POST /analyze.
Позволяет передать inline POI-веса напрямую в запросе без сохранения
профиля. Если задан weights применяется с наивысшим приоритетом
(выше profile_id и user default).
"""
weights: dict[str, float] | None = Field(
default=None,
description=(
"Inline POI weights override (категория → weight). "
"Если задан — применяется к scoring, без обязательного profile save. "
"Validated против ALLOWED_CATEGORIES + MIN_WEIGHT/MAX_WEIGHT."
),
)

View file

@ -0,0 +1,161 @@
"""PDF render для ТЗ (Layout Analysis #113 PR D).
Pattern reference: backend/app/services/exporters/pdf.py (existing WeasyPrint).
"""
from __future__ import annotations
import datetime as dt
import html as _html
import logging
from weasyprint import HTML
from app.schemas.parcel import BestLayoutsResponse
logger = logging.getLogger(__name__)
def render_layout_tz_pdf(
response: BestLayoutsResponse,
*,
cad_num: str,
parcel_address: str | None = None,
radius_km: float,
time_window: str,
) -> bytes:
"""Render ТЗ PDF от best-layouts response.
Args:
response: BestLayoutsResponse от /best-layouts endpoint
cad_num: кадастровый номер участка
parcel_address: optional human address (если known через geocoder)
radius_km: радиус анализа конкурентов
time_window: окно анализа (last_month/quarter/year)
Returns:
PDF bytes (готово для StreamingResponse)
"""
today = dt.date.today().strftime("%d.%m.%Y")
safe_cad = _html.escape(cad_num)
safe_addr = _html.escape(parcel_address) if parcel_address else None
safe_time_window = _html.escape(time_window)
addr_line = f"<p>Адрес: {safe_addr}</p>" if safe_addr else ""
def _price_cell(val: float | None) -> str:
if val is None:
return "<td>—</td>"
return f"<td>{val:,.0f}".replace(",", " ") + " ₽</td>"
# Top layouts table rows
top_rows = "".join(
"<tr>"
f"<td>{r.rank}</td>"
f"<td>{_html.escape(r.room_bucket)}</td>"
f"<td>{_html.escape(r.area_bin)}</td>"
f"<td>{r.velocity_per_month:.1f}</td>"
f"<td>{r.avg_area_m2:.1f}</td>"
f"{_price_cell(r.avg_price_per_m2_rub)}"
f"<td>{r.total_sold_in_window}</td>"
"</tr>"
for r in response.top_layouts
)
# Recommendation mix table rows
mix_rows = "".join(
"<tr>"
f"<td>{_html.escape(m.room_bucket)}</td>"
f"<td>{m.pct}%</td>"
f"<td>{m.abs_units if m.abs_units is not None else ''}</td>"
f"<td>{f'{m.avg_target_area_m2:.1f}' if m.avg_target_area_m2 is not None else ''}</td>"
"</tr>"
for m in response.recommendation_for_tz.mix
)
rec = response.recommendation_for_tz
safe_rationale = _html.escape(rec.rationale_text)
weighted_price = (
f"{rec.weighted_avg_price_per_m2_rub:,.0f}".replace(",", " ") + " ₽/м²"
if rec.weighted_avg_price_per_m2_rub is not None
else "нет данных"
)
dq = response.data_quality
html = f"""<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>ТЗ на проектирование {safe_cad}</title>
<style>
body {{ font-family: 'Helvetica', sans-serif; font-size: 11pt; color: #222; }}
h1 {{ font-size: 18pt; margin-bottom: 0.2em; }}
h2 {{ font-size: 14pt; margin-top: 1.2em; border-bottom: 1px solid #ccc; }}
.meta {{ color: #666; font-size: 10pt; margin-bottom: 1em; }}
table {{ width: 100%; border-collapse: collapse; margin: 0.5em 0; }}
th, td {{ padding: 6px 10px; border: 1px solid #ddd; text-align: left; }}
th {{ background: #f5f5f5; font-weight: bold; }}
.rationale {{ background: #f8f8f8; padding: 10px; border-left: 3px solid #4a90e2;
margin: 1em 0; }}
.footer {{ margin-top: 2em; padding-top: 1em; border-top: 1px solid #ddd;
color: #888; font-size: 9pt; }}
.confidence-high {{ color: #2a8c2a; }}
.confidence-medium {{ color: #c9a132; }}
.confidence-low {{ color: #b03434; }}
</style>
</head>
<body>
<h1>Техническое задание на проектирование (data-driven)</h1>
<div class="meta">
<p>Кадастровый номер: <strong>{safe_cad}</strong></p>
{addr_line}
<p>Радиус анализа: {radius_km} км · Окно: {safe_time_window}</p>
<p>Дата формирования: {today}</p>
</div>
<h2>Рекомендуемая структура квартирографии (unit-mix)</h2>
<div class="rationale">{safe_rationale}</div>
<table>
<thead><tr>
<th>Комнатность</th><th>Доля</th><th>Кол-во (от target)</th><th>Целевая площадь, м²</th>
</tr></thead>
<tbody>{mix_rows}</tbody>
</table>
<p>Средневзвешенная цена benchmark: <strong>{weighted_price}</strong></p>
<p>Основано на {rec.based_on_obj_count} ЖК / {rec.based_on_total_deals} сделок</p>
<p>Период данных:
{rec.data_window_start.strftime("%d.%m.%Y")} {rec.data_window_end.strftime("%d.%m.%Y")}
</p>
<h2>Топ планировок конкурентов по продажам</h2>
<table>
<thead><tr>
<th>#</th><th>Комнаты</th><th>Площадь</th><th>Продажи/мес</th>
<th>Ср. площадь, м²</th><th>Ср. цена, /м²</th><th>Продано (окно)</th>
</tr></thead>
<tbody>{top_rows}</tbody>
</table>
<h2>Качество данных</h2>
<p>
Покрытие: {dq.objects_with_velocity_data} из
{dq.objects_total_in_radius} ЖК с данными velocity
({dq.velocity_coverage_pct:.1f}%)
</p>
<p>
Уверенность:
<span class="confidence-{dq.confidence}">
{dq.confidence.upper()}
</span>
</p>
<div class="footer">
<p>GenDesign Site Finder · сгенерировано из данных DOM.РФ + Objective + Росреестр</p>
<p>Phase 2.1: без layout_type (евро/классика/панорама) и balcony_count.</p>
</div>
</body>
</html>"""
pdf_bytes = HTML(string=html).write_pdf()
logger.info("Generated layout TZ PDF for cad %s: %d bytes", cad_num, len(pdf_bytes))
return pdf_bytes

View file

@ -17,6 +17,7 @@ from __future__ import annotations
import json
import logging
import math
from datetime import datetime
from typing import Any
@ -25,7 +26,7 @@ from sqlalchemy import text
logger = logging.getLogger(__name__)
# Allowed POI categories — mirrors _POI_WEIGHTS keys in api/v1/parcels.py
# Allowed POI categories — single source of truth; imported by api/v1/parcels.py
ALLOWED_CATEGORIES: set[str] = {
"school",
"kindergarten",
@ -44,7 +45,7 @@ ALLOWED_CATEGORIES: set[str] = {
MIN_WEIGHT: float = -2.0
MAX_WEIGHT: float = 3.0
# System defaults — keep in sync with _POI_WEIGHTS in parcels.py
# System defaults — single source of truth; imported as _POI_WEIGHTS by api/v1/parcels.py
_SYSTEM_POI_WEIGHTS: dict[str, float] = {
"school": 1.5,
"kindergarten": 1.5,
@ -115,7 +116,7 @@ def _validate_weights_dict(v: dict[str, float]) -> dict[str, float]:
for k, w in v.items():
if not isinstance(w, int | float):
raise ValueError(f"Weight for '{k}' must be number, got {type(w).__name__}")
if w < MIN_WEIGHT or w > MAX_WEIGHT:
if not math.isfinite(w) or w < MIN_WEIGHT or w > MAX_WEIGHT:
raise ValueError(f"Weight for '{k}' = {w} out of bounds [{MIN_WEIGHT}, {MAX_WEIGHT}]")
return v

View file

@ -0,0 +1,327 @@
"""Тесты для inline POI-weights в POST /api/v1/parcels/{cad_num}/analyze (#201).
Покрывает:
1. POST /analyze без body system defaults (no regression)
2. POST /analyze с inline weights applied (source = "inline")
3. POST /analyze с невалидной категорией 422
4. POST /analyze с весом вне диапазона 422
5. POST /analyze с body.weights + profile_id body.weights wins (priority)
Стратегия mock: DB патчим через dependency_overrides, тяжёлые service-функции
(weather, velocity, dump и т.д.) патчим через unittest.mock.patch чтобы не
дублировать все 18 db.execute call'ов в каждом тесте.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.main import app
# ── Константы ─────────────────────────────────────────────────────────────────
_CAD = "66:41:0204016:10"
_WKT = "POLYGON((60.6 56.838, 60.61 56.838, 60.61 56.845, 60.6 56.845, 60.6 56.838))"
_GEOJSON = '{"type":"Polygon","coordinates":[[[60.6,56.838],[60.61,56.838]]]}'
# ── Mock factories ─────────────────────────────────────────────────────────────
def _make_mapping(data: dict[str, Any]) -> MagicMock:
"""Создать mock-строку (mapping) с __getitem__ + .get() для dict-like доступа."""
m = MagicMock()
m.__getitem__ = lambda self, k: data[k]
m.get = lambda k, default=None: data.get(k, default)
return m
def _make_db_for_analyze(
geom_found: bool = True,
district_found: bool = True,
poi_rows: list[Any] | None = None,
) -> MagicMock:
"""Сконструировать mock DB Session для analyze_parcel.
Порядок db.execute calls в analyze_parcel:
0. UNION ALL geom + source .mappings().first()
1. WKT query .mappings().first()
2. District .mappings().first()
3. POI rows .mappings().all()
4. Competitor rows .mappings().all()
5. Pipeline rows .mappings().all()
6. Centroid lat/lon .mappings().first()
7. Noise rows .mappings().all()
8. Hydrology .mappings().all()
9. Utilities .mappings().all()
10. Market trend .mappings().first()
11. Zoning (begin_nested) .mappings().first()
12. Success recommendation (begin_nested) .mappings().all()
13. _geotech_risk (industrial count) .scalar()
14. _neighbors_summary (neighbor_rows) .mappings().all()
15. _neighbors_summary (overlap_row) .mappings().first()
begin_nested() возвращаем context manager чтобы поддержать `with` statement.
"""
db = MagicMock()
geom_row = (
_make_mapping({"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"})
if geom_found
else None
)
wkt_row = _make_mapping({"wkt": _WKT}) if geom_found else None
district_row = (
_make_mapping(
{
"district_name": "Октябрьский",
"median_price_per_m2": 120000,
"dist_to_center": 1500.0,
}
)
if district_found
else None
)
centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605})
_poi_rows = poi_rows or []
# Счётчик вызовов execute — разводим first() / all() / scalar() по очерёдности
call_idx = [0]
# Ответы в порядке вызовов:
responses: list[Any] = [
("first", geom_row), # 0: geom UNION ALL
("first", wkt_row), # 1: WKT
("first", district_row), # 2: district
("all", _poi_rows), # 3: POI rows
("all", []), # 4: competitor rows
("all", []), # 5: pipeline rows
("first", centroid_row), # 6: centroid
("all", []), # 7: noise rows
("all", []), # 8: hydrology rows
("all", []), # 9: utilities rows
("first", None), # 10: market trend
("first", None), # 11: zoning (inside begin_nested)
("all", []), # 12: success recommendation (inside begin_nested)
("scalar", 0), # 13: geotech_risk industrial count
("all", []), # 14: neighbors
("first", None), # 15: overlap
]
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
idx = call_idx[0]
call_idx[0] += 1
if idx >= len(responses):
# Безопасный fallback для непредусмотренных вызовов
r = MagicMock()
r.mappings.return_value.first.return_value = None
r.mappings.return_value.all.return_value = []
r.scalar.return_value = 0
return r
kind, data = responses[idx]
r = MagicMock()
r.mappings.return_value.first.return_value = data
r.mappings.return_value.all.return_value = data if isinstance(data, list) else []
r.scalar.return_value = data if kind == "scalar" else 0
return r
db.execute.side_effect = _execute_side_effect
# begin_nested() → context manager, остальные execute внутри него проходят
# через тот же side_effect (because db.execute is the same mock).
ctx = MagicMock()
ctx.__enter__ = MagicMock(return_value=ctx)
ctx.__exit__ = MagicMock(return_value=False)
db.begin_nested.return_value = ctx
return db
def _override_db(db: MagicMock):
def _get_db_override():
yield db
return _get_db_override
# Патчим тяжёлые внешние вызовы (weather / velocity / nspd-dump),
# чтобы тесты не зависели от сети и не требовали полного mock DB.
_PATCHES = [
patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None),
patch("app.api.v1.parcels._fetch_weather_sync", return_value=None),
patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None),
patch(
"app.api.v1.parcels.get_quarter_dump_data",
return_value={
"nspd_zoning": None,
"nspd_zouit_overlaps": [],
"nspd_engineering_nearby": [],
"nspd_dump": {"available": False, "stale": False, "harvest_triggered": False},
},
),
patch("app.api.v1.parcels.compute_velocity", return_value=None),
patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}),
]
def _start_patches() -> list[Any]:
started = [p.start() for p in _PATCHES]
return started
def _stop_patches() -> None:
for p in _PATCHES:
p.stop()
# ── Тесты ─────────────────────────────────────────────────────────────────────
def test_analyze_no_body_uses_system_defaults() -> None:
"""POST /analyze без body → source = 'system', нет регрессии."""
from app.core.db import get_db
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
body = resp.json()
assert "weights_profile" in body
assert body["weights_profile"]["source"] == "system"
assert body["weights_profile"]["inline_weights"] is None
finally:
app.dependency_overrides.clear()
_stop_patches()
def test_analyze_inline_weights_applied() -> None:
"""POST /analyze с body.weights → source = 'inline', веса применены."""
from app.core.db import get_db
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
client = TestClient(app)
resp = client.post(
f"/api/v1/parcels/{_CAD}/analyze",
json={"weights": {"kindergarten": 2.5}},
)
assert resp.status_code == 200, resp.text
body = resp.json()
wp = body["weights_profile"]
assert wp["source"] == "inline"
assert wp["inline_weights"] == {"kindergarten": 2.5}
# applied weights содержат inline override поверх defaults
assert wp["weights_applied"]["kindergarten"] == pytest.approx(2.5)
finally:
app.dependency_overrides.clear()
_stop_patches()
def test_analyze_invalid_category_returns_422() -> None:
"""POST /analyze с невалидной POI-категорией → 422."""
from app.core.db import get_db
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
client = TestClient(app)
resp = client.post(
f"/api/v1/parcels/{_CAD}/analyze",
json={"weights": {"nonexistent_category": 1.0}},
)
assert resp.status_code == 422, resp.text
detail = resp.json()["detail"]
assert "nonexistent_category" in detail
finally:
app.dependency_overrides.clear()
_stop_patches()
def test_analyze_weight_out_of_range_returns_422() -> None:
"""POST /analyze с весом вне [-2, 3] → 422."""
from app.core.db import get_db
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
client = TestClient(app)
# Слишком большой вес
resp = client.post(
f"/api/v1/parcels/{_CAD}/analyze",
json={"weights": {"school": 99.9}},
)
assert resp.status_code == 422, resp.text
detail = resp.json()["detail"]
assert "school" in detail
# Слишком маленький вес
resp2 = client.post(
f"/api/v1/parcels/{_CAD}/analyze",
json={"weights": {"park": -5.0}},
)
assert resp2.status_code == 422, resp2.text
assert "park" in resp2.json()["detail"]
finally:
app.dependency_overrides.clear()
_stop_patches()
def test_inline_weights_rejects_nan() -> None:
"""NaN weight должен вернуть 422, а не propagate в score."""
from app.core.db import get_db
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
client = TestClient(app)
# Отправляем raw JSON с NaN — httpx.Client не умеет encode float('nan'),
# поэтому используем content= с явным bytes-телом.
raw_body = b'{"weights": {"school": NaN}}'
resp = client.post(
f"/api/v1/parcels/{_CAD}/analyze",
content=raw_body,
headers={"Content-Type": "application/json"},
)
assert (
resp.status_code == 422
), f"Ожидали 422 для NaN-weight, получили {resp.status_code}: {resp.text}"
finally:
app.dependency_overrides.clear()
_stop_patches()
def test_analyze_inline_weights_beats_profile_id() -> None:
"""body.weights + profile_id → body.weights имеет приоритет (source = 'inline')."""
from app.core.db import get_db
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
client = TestClient(app)
# Передаём и profile_id=1, и inline weights — inline должен победить
resp = client.post(
f"/api/v1/parcels/{_CAD}/analyze?profile_id=1",
json={"weights": {"metro_stop": 2.0}},
)
assert resp.status_code == 200, resp.text
wp = resp.json()["weights_profile"]
assert wp["source"] == "inline", f"Ожидали source='inline', получили '{wp['source']}'"
assert wp["weights_applied"]["metro_stop"] == pytest.approx(2.0)
# profile_id всё ещё присутствует в ответе для трассировки
assert wp["profile_id"] == 1
finally:
app.dependency_overrides.clear()
_stop_patches()

View file

@ -0,0 +1,136 @@
"""Tests для layout_tz_pdf renderer (Issue #113 PR D).
WeasyPrint requires native GTK/Pango/GObject shared libraries. These are present
in the Docker container (Linux) but absent on Windows dev machines. All tests in
this module are skipped automatically when the native libs are unavailable.
"""
import datetime as dt
import pytest
# Attempt to import the module under test; skip entire module if native libs missing.
try:
from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf
except (OSError, ImportError) as _e: # GTK libs missing on Windows, or weasyprint not installed
pytest.skip(f"WeasyPrint deps missing: {_e}", allow_module_level=True)
from app.schemas.parcel import (
BestLayoutsResponse,
LayoutDataQuality,
LayoutTzMixRow,
LayoutTzRecommendation,
TopLayoutRow,
)
def _sample_response() -> BestLayoutsResponse:
return BestLayoutsResponse(
top_layouts=[
TopLayoutRow(
rank=1,
room_bucket="1",
area_bin="25-40",
signature="1__25-40",
competitor_obj_ids=[1234, 5678],
competitor_count=2,
total_sold_in_window=67,
velocity_per_month=8.4,
avg_price_per_m2_rub=148000.0,
avg_area_m2=38.5,
supply_units_in_radius=312,
sold_pct_of_supply=21.5,
),
TopLayoutRow(
rank=2,
room_bucket="studio",
area_bin="<25",
signature="studio__<25",
competitor_obj_ids=[1234],
competitor_count=1,
total_sold_in_window=40,
velocity_per_month=5.0,
avg_price_per_m2_rub=160000.0,
avg_area_m2=22.0,
supply_units_in_radius=100,
sold_pct_of_supply=40.0,
),
],
recommendation_for_tz=LayoutTzRecommendation(
rationale_text="Test rationale текст с кириллицей",
mix=[
LayoutTzMixRow(room_bucket="studio", pct=10, abs_units=30, avg_target_area_m2=22.0),
LayoutTzMixRow(room_bucket="1", pct=60, abs_units=180, avg_target_area_m2=38.5),
LayoutTzMixRow(room_bucket="2", pct=30, abs_units=90, avg_target_area_m2=55.0),
],
weighted_avg_price_per_m2_rub=152000.0,
based_on_obj_count=5,
based_on_total_deals=107,
data_window_start=dt.date(2026, 2, 1),
data_window_end=dt.date(2026, 5, 1),
),
data_quality=LayoutDataQuality(
objects_with_velocity_data=5,
objects_total_in_radius=8,
velocity_coverage_pct=62.5,
confidence="medium",
),
)
def test_pdf_renders_non_empty_bytes() -> None:
pdf = render_layout_tz_pdf(
_sample_response(),
cad_num="66:41:0204016:10",
radius_km=1.0,
time_window="last_quarter",
)
assert len(pdf) > 1000 # PDF минимум ~1KB
def test_pdf_starts_with_pdf_magic() -> None:
pdf = render_layout_tz_pdf(
_sample_response(),
cad_num="66:41:0204016:10",
radius_km=1.0,
time_window="last_quarter",
)
assert pdf[:4] == b"%PDF"
def test_pdf_renders_cyrillic_correctly() -> None:
"""Smoke — WeasyPrint должен handle кириллический rationale_text без UnicodeEncodeError."""
response = _sample_response()
pdf = render_layout_tz_pdf(
response,
cad_num="66:41:0303161:42",
radius_km=1.5,
time_window="last_year",
)
# Embedded text может быть compressed, но без exception = OK
assert len(pdf) > 1000
def test_pdf_handles_empty_top_layouts() -> None:
response = _sample_response()
response.top_layouts = []
pdf = render_layout_tz_pdf(
response,
cad_num="66:41:0204016:10",
radius_km=1.0,
time_window="last_quarter",
)
assert pdf[:4] == b"%PDF"
def test_pdf_handles_null_avg_price() -> None:
"""avg_price_per_m2_rub=None (ЖК не покрыт Objective) → должно рендериться как ''."""
response = _sample_response()
response.top_layouts[0].avg_price_per_m2_rub = None
pdf = render_layout_tz_pdf(
response,
cad_num="66:41:0204016:10",
radius_km=1.0,
time_window="last_quarter",
)
assert pdf[:4] == b"%PDF"

View file

@ -147,14 +147,18 @@ function SiteFinderContent() {
function handleAnalyze(cadNum: string) {
setIsochrones(undefined);
setTab("overview");
// Priority: named profile → user default profile → inline draft weights.
// When activeProfileId is set, backend uses that profile (ignores inline).
// When no profile is selected, pass currentWeights as inline so draft
// slider values are always respected even without a saved profile (#201).
mutate({
cad: cadNum,
options:
activeProfileId != null
? { profileId: activeProfileId }
: profileUserId
? { profileUserId }
: undefined,
? { profileUserId, weights: currentWeights }
: { weights: currentWeights },
});
}
@ -163,10 +167,20 @@ function SiteFinderContent() {
profileId: number | null,
) {
setCurrentWeights(weights);
// Store the active profile id so we can pass it to the analyze call.
// If user edited weights without saving a named profile, profileId=null
// and backend will use system defaults (sub-PR 5 would enable inline weights).
setActiveProfileId(profileId);
// Re-analyze with the new weights if a parcel is already loaded (#201).
if (data?.cad_num) {
setIsochrones(undefined);
mutate({
cad: data.cad_num,
options:
profileId != null
? { profileId }
: profileUserId
? { profileUserId, weights }
: { weights },
});
}
}
// Derive KPI values from data

View file

@ -0,0 +1,761 @@
"use client";
import { useState } from "react";
import { useBestLayouts } from "@/hooks/useBestLayouts";
import { API_BASE_URL } from "@/lib/api";
import type {
BestLayoutsRequest,
BestLayoutsResponse,
Confidence,
LayoutTzMixRow,
TimeWindow,
TopLayoutRow,
} from "@/types/best-layouts";
// ── Constants ─────────────────────────────────────────────────────────────────
const CONFIDENCE_STYLES: Record<
Confidence,
{ bg: string; fg: string; label: string }
> = {
high: { bg: "#dcfce7", fg: "#166534", label: "Высокое" },
medium: { bg: "#fef3c7", fg: "#854d0e", label: "Среднее" },
low: { bg: "#fee2e2", fg: "#991b1b", label: "Низкое" },
};
const TIME_WINDOW_LABELS: Record<TimeWindow, string> = {
last_month: "Последний месяц",
last_quarter: "Последний квартал",
last_year: "Последний год",
};
const ROOM_BUCKET_LABELS: Record<string, string> = {
studio: "Студия",
"1": "1-комн.",
"2": "2-комн.",
"3": "3-комн.",
"4+": "4+ комн.",
};
// ── Sub-components ────────────────────────────────────────────────────────────
function DataQualityCard({ dq }: { dq: BestLayoutsResponse["data_quality"] }) {
const style = CONFIDENCE_STYLES[dq.confidence];
return (
<div className="border border-gray-200 rounded-xl px-[18px] py-[14px] bg-white flex items-center gap-4 flex-wrap">
<span className="font-semibold text-[13px] text-gray-700 mr-1">
Качество данных:
</span>
<span
style={{ background: style.bg, color: style.fg }}
className="px-[10px] py-[2px] rounded-md text-xs font-semibold"
>
{style.label}
</span>
<span className="text-xs text-gray-500">
Покрытие {dq.velocity_coverage_pct.toFixed(0)}% (
{dq.objects_with_velocity_data} из {dq.objects_total_in_radius} ЖК)
</span>
</div>
);
}
function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
if (rows.length === 0) {
return (
<div className="text-gray-400 text-[13px] py-3">
Данных недостаточно для ранжирования планировок
</div>
);
}
const headers = [
"#",
"Тип",
"Площадь",
"Скорость / мес",
"Средн. площадь, м²",
"Средн. цена, ₽/м²",
"Продано, %",
];
return (
<div className="border border-gray-200 rounded-xl overflow-hidden bg-white">
<div className="px-[18px] py-3 bg-gray-50 border-b border-gray-200 font-semibold text-[13px] text-gray-700">
Топ планировок ({rows.length})
</div>
<div style={{ overflowX: "auto" }}>
<table
style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}
>
<thead>
<tr style={{ background: "#f6f7f9" }}>
{headers.map((h) => (
<th
key={h}
className="px-3 py-2 text-left border-b border-gray-200 font-semibold text-gray-700 whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr
key={row.signature}
style={{
background: i % 2 === 0 ? "#fff" : "#fafbfc",
borderBottom: "1px solid #f3f4f6",
}}
>
<td
style={{
padding: "7px 12px",
fontWeight: 700,
color: "#1d4ed8",
fontVariantNumeric: "tabular-nums",
}}
>
{row.rank}
</td>
<td
style={{
padding: "7px 12px",
fontWeight: 500,
color: "#111827",
}}
>
{ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket}
</td>
<td style={{ padding: "7px 12px", color: "#374151" }}>
{row.area_bin} м²
</td>
<td
style={{
padding: "7px 12px",
color: "#374151",
fontVariantNumeric: "tabular-nums",
}}
>
{row.velocity_per_month.toFixed(2)}
</td>
<td
style={{
padding: "7px 12px",
textAlign: "right",
color: "#374151",
fontVariantNumeric: "tabular-nums",
}}
>
{row.avg_area_m2.toFixed(1)}
</td>
<td
style={{
padding: "7px 12px",
textAlign: "right",
color: "#374151",
fontVariantNumeric: "tabular-nums",
}}
>
{row.avg_price_per_m2_rub != null
? Math.round(row.avg_price_per_m2_rub).toLocaleString(
"ru-RU",
)
: "—"}
</td>
<td
style={{
padding: "7px 12px",
textAlign: "right",
color: "#374151",
fontVariantNumeric: "tabular-nums",
}}
>
{row.sold_pct_of_supply != null
? `${(row.sold_pct_of_supply ?? 0).toFixed(0)}%`
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function UnitMixBar({ mix }: { mix: LayoutTzMixRow[] }) {
const COLORS = [
"#1d4ed8",
"#7c3aed",
"#059669",
"#d97706",
"#dc2626",
"#0891b2",
];
return (
<div>
{/* Horizontal stacked bar */}
<div
style={{
display: "flex",
height: 24,
borderRadius: 6,
overflow: "hidden",
border: "1px solid #e5e7eb",
marginBottom: 10,
}}
>
{mix.map((row, i) => (
<div
key={row.room_bucket}
title={`${ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket}: ${row.pct}%`}
style={{
width: `${row.pct}%`,
background: COLORS[i % COLORS.length],
transition: "width 0.3s ease",
}}
/>
))}
</div>
{/* Legend */}
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
{mix.map((row, i) => (
<div
key={row.room_bucket}
style={{ display: "flex", alignItems: "center", gap: 4 }}
>
<div
style={{
width: 10,
height: 10,
borderRadius: 2,
background: COLORS[i % COLORS.length],
flexShrink: 0,
}}
/>
<span style={{ fontSize: 11, color: "#374151" }}>
{ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket} {row.pct}
%
</span>
</div>
))}
</div>
</div>
);
}
function MixTable({ mix }: { mix: LayoutTzMixRow[] }) {
return (
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}>
<thead>
<tr style={{ background: "#f6f7f9" }}>
{["Тип", "Доля, %", "Кол-во квартир", "Ср. площадь, м²"].map((h) => (
<th
key={h}
className="px-3 py-[7px] text-left border-b border-gray-200 font-semibold text-gray-700 whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{mix.map((row, i) => (
<tr
key={row.room_bucket}
style={{
background: i % 2 === 0 ? "#fff" : "#fafbfc",
borderBottom: "1px solid #f3f4f6",
}}
>
<td
style={{ padding: "7px 12px", fontWeight: 500, color: "#111827" }}
>
{ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket}
</td>
<td
style={{
padding: "7px 12px",
fontVariantNumeric: "tabular-nums",
color: "#374151",
}}
>
{row.pct}%
</td>
<td
style={{
padding: "7px 12px",
fontVariantNumeric: "tabular-nums",
color: "#374151",
}}
>
{row.abs_units != null
? row.abs_units.toLocaleString("ru-RU")
: "—"}
</td>
<td
style={{
padding: "7px 12px",
fontVariantNumeric: "tabular-nums",
color: "#374151",
}}
>
{row.avg_target_area_m2 != null
? row.avg_target_area_m2.toFixed(1)
: "—"}
</td>
</tr>
))}
</tbody>
</table>
);
}
function RecommendationCard({
rec,
}: {
rec: BestLayoutsResponse["recommendation_for_tz"];
}) {
return (
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
background: "#fff",
overflow: "hidden",
}}
>
<div className="px-[18px] py-3 bg-gray-50 border-b border-gray-200 font-semibold text-[13px] text-gray-700">
Рекомендация ТЗ
</div>
<div
style={{
padding: "14px 18px",
display: "flex",
flexDirection: "column",
gap: 16,
}}
>
{/* Rationale text — plain text only, no dangerouslySetInnerHTML */}
<p
style={{ fontSize: 13, color: "#374151", margin: 0, lineHeight: 1.6 }}
>
{rec.rationale_text}
</p>
{/* Unit-mix bar chart */}
{rec.mix.length > 0 && (
<div>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: "#6b7280",
marginBottom: 8,
textTransform: "uppercase",
letterSpacing: "0.04em",
}}
>
Unit-mix
</div>
<UnitMixBar mix={rec.mix} />
</div>
)}
{/* Mix table */}
{rec.mix.length > 0 && (
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 8,
overflow: "hidden",
}}
>
<MixTable mix={rec.mix} />
</div>
)}
{/* Weighted avg price */}
{rec.weighted_avg_price_per_m2_rub != null && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "10px 14px",
background: "#eff6ff",
borderRadius: 8,
fontSize: 13,
}}
>
<span style={{ color: "#6b7280" }}>Средневзвешенная цена:</span>
<span
style={{
fontWeight: 700,
color: "#1d4ed8",
fontVariantNumeric: "tabular-nums",
}}
>
{Math.round(rec.weighted_avg_price_per_m2_rub).toLocaleString(
"ru-RU",
)}{" "}
/м²
</span>
</div>
)}
{/* Meta */}
<div style={{ fontSize: 11, color: "#9ca3af" }}>
Основано на {rec.based_on_obj_count} ЖК ·{" "}
{rec.based_on_total_deals.toLocaleString("ru-RU")} сделках · период{" "}
{new Date(rec.data_window_start).toLocaleDateString("ru-RU")} {" "}
{new Date(rec.data_window_end).toLocaleDateString("ru-RU")}
</div>
</div>
</div>
);
}
// ── Main component ─────────────────────────────────────────────────────────────
interface Props {
cadNum: string;
selectedCompetitorObjIds?: number[];
}
export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
const [radiusKm, setRadiusKm] = useState(1.0);
const [timeWindow, setTimeWindow] = useState<TimeWindow>("last_quarter");
const [targetTotalFlats, setTargetTotalFlats] = useState<string>("300");
const [minVelocity, setMinVelocity] = useState(0.5);
const [isPdfLoading, setIsPdfLoading] = useState(false);
const [pdfError, setPdfError] = useState<string | null>(null);
const { mutate, data, isPending, error } = useBestLayouts(cadNum);
function buildRequest(): BestLayoutsRequest {
const parsed = parseInt(targetTotalFlats, 10);
return {
radius_km: radiusKm,
time_window: timeWindow,
filter_competitor_obj_ids:
selectedCompetitorObjIds && selectedCompetitorObjIds.length > 0
? selectedCompetitorObjIds
: null,
min_velocity_per_month: minVelocity,
target_total_flats:
!Number.isNaN(parsed) && parsed > 0
? Math.min(Math.max(parsed, 1), 10000)
: null,
};
}
function handleCalculate() {
mutate(buildRequest());
}
async function handleDownloadPdf() {
setIsPdfLoading(true);
try {
const req = buildRequest();
const res = await fetch(
`${API_BASE_URL}/api/v1/parcels/${encodeURIComponent(cadNum)}/best-layouts/pdf`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
},
);
if (!res.ok) {
throw new Error(`Ошибка генерации PDF: ${res.status}`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `tz-layout-${cadNum.replace(/:/g, "-")}-${new Date().toISOString().split("T")[0]}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) {
setPdfError(e instanceof Error ? e.message : "Не удалось скачать PDF");
} finally {
setIsPdfLoading(false);
}
}
return (
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 12,
background: "#fff",
overflow: "hidden",
}}
>
{/* Header */}
<div
style={{
padding: "14px 20px",
background: "#f9fafb",
borderBottom: "1px solid #e5e7eb",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
flexWrap: "wrap",
gap: 10,
}}
>
<div>
<span style={{ fontWeight: 600, fontSize: 14, color: "#111827" }}>
Анализ планировок
</span>
<span style={{ fontSize: 12, color: "#6b7280", marginLeft: 8 }}>
data-driven ТЗ на проектирование
</span>
</div>
{data && (
<div className="flex flex-col items-end gap-1">
<button
onClick={() => {
setPdfError(null);
void handleDownloadPdf();
}}
disabled={isPdfLoading}
style={{
padding: "7px 16px",
background: isPdfLoading ? "#9ca3af" : "#1d4ed8",
color: "#fff",
border: "none",
borderRadius: 7,
fontSize: 13,
fontWeight: 500,
cursor: isPdfLoading ? "not-allowed" : "pointer",
whiteSpace: "nowrap",
}}
>
{isPdfLoading ? "Генерация…" : "Скачать ТЗ (PDF)"}
</button>
{pdfError && (
<span className="text-red-600 text-xs">PDF: {pdfError}</span>
)}
</div>
)}
</div>
{/* Controls */}
<div
style={{
padding: "16px 20px",
borderBottom: "1px solid #f3f4f6",
display: "flex",
flexWrap: "wrap",
gap: 20,
alignItems: "flex-end",
}}
>
{/* Radius slider */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 4,
minWidth: 160,
}}
>
<label style={{ fontSize: 12, color: "#6b7280", fontWeight: 500 }}>
Радиус поиска: {radiusKm.toFixed(1)} км
</label>
<input
type="range"
min={0.1}
max={1.5}
step={0.1}
value={radiusKm}
onChange={(e) => setRadiusKm(parseFloat(e.target.value))}
style={{ width: 160, accentColor: "#1d4ed8" }}
/>
</div>
{/* Min velocity slider */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 4,
minWidth: 160,
}}
>
<label style={{ fontSize: 12, color: "#6b7280", fontWeight: 500 }}>
Мин. скорость: {minVelocity.toFixed(1)} кв/мес
</label>
<input
type="range"
min={0}
max={5}
step={0.1}
value={minVelocity}
onChange={(e) => setMinVelocity(parseFloat(e.target.value))}
style={{ width: 160, accentColor: "#1d4ed8" }}
/>
</div>
{/* Time window radio */}
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "#6b7280", fontWeight: 500 }}>
Период анализа
</span>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
{(Object.keys(TIME_WINDOW_LABELS) as TimeWindow[]).map((tw) => (
<label
key={tw}
style={{
display: "flex",
alignItems: "center",
gap: 4,
fontSize: 12,
cursor: "pointer",
color: timeWindow === tw ? "#1d4ed8" : "#374151",
fontWeight: timeWindow === tw ? 600 : 400,
}}
>
<input
type="radio"
name="time-window"
value={tw}
checked={timeWindow === tw}
onChange={() => setTimeWindow(tw)}
style={{ accentColor: "#1d4ed8" }}
/>
{TIME_WINDOW_LABELS[tw]}
</label>
))}
</div>
</div>
{/* Target flats input */}
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<label style={{ fontSize: 12, color: "#6b7280", fontWeight: 500 }}>
Целевой объём (квартир)
</label>
<input
type="number"
min={1}
max={10000}
value={targetTotalFlats}
onChange={(e) => setTargetTotalFlats(e.target.value)}
placeholder="300"
style={{
padding: "5px 10px",
border: "1px solid #d1d5db",
borderRadius: 6,
fontSize: 13,
width: 110,
color: "#111827",
}}
/>
</div>
{/* Calculate button */}
<button
onClick={handleCalculate}
disabled={isPending}
style={{
padding: "7px 20px",
background: isPending ? "#9ca3af" : "#1d4ed8",
color: "#fff",
border: "none",
borderRadius: 7,
fontSize: 13,
fontWeight: 600,
cursor: isPending ? "not-allowed" : "pointer",
whiteSpace: "nowrap",
alignSelf: "flex-end",
}}
>
{isPending ? "Расчёт…" : "Рассчитать"}
</button>
{selectedCompetitorObjIds && selectedCompetitorObjIds.length > 0 && (
<span
style={{
fontSize: 11,
color: "#1d4ed8",
background: "#eff6ff",
padding: "3px 8px",
borderRadius: 4,
alignSelf: "flex-end",
marginBottom: 2,
}}
>
Фильтр: {selectedCompetitorObjIds.length} выбр. ЖК
</span>
)}
</div>
{/* Content area */}
<div style={{ padding: "16px 20px" }}>
{/* Loading skeleton */}
{isPending && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{[80, 60, 40].map((w) => (
<div
key={w}
style={{
height: 18,
borderRadius: 6,
background: "#f3f4f6",
width: `${w}%`,
animation: "pulse 1.5s ease-in-out infinite",
}}
/>
))}
</div>
)}
{/* Error */}
{error && !isPending && (
<div
style={{
padding: "12px 16px",
background: "#fef2f2",
border: "1px solid #fca5a5",
borderRadius: 8,
color: "#dc2626",
fontSize: 13,
}}
>
{error instanceof Error ? error.message : "Ошибка получения данных"}
</div>
)}
{/* Results */}
{data && !isPending && (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<DataQualityCard dq={data.data_quality} />
<TopLayoutsTable rows={data.top_layouts} />
<RecommendationCard rec={data.recommendation_for_tz} />
</div>
)}
{/* Idle state */}
{!isPending && !error && !data && (
<div
style={{
padding: "24px 0",
textAlign: "center",
color: "#d1d5db",
fontSize: 13,
}}
>
Настройте параметры и нажмите «Рассчитать»
</div>
)}
</div>
</div>
);
}

View file

@ -4,6 +4,7 @@ import type { ParcelAnalysis } from "@/types/site-finder";
import { SectionLabel } from "@/components/ui/SectionLabel";
import { EmptyState } from "@/components/ui/EmptyState";
import { MarketTrendBlock } from "./MarketTrendBlock";
import { BestLayoutsBlock } from "./BestLayoutsBlock";
import { CompetitorTable } from "./CompetitorTable";
import { Pipeline24moBlock } from "./Pipeline24moBlock";
import { SuccessRecommendationBlock } from "./SuccessRecommendationBlock";
@ -81,6 +82,9 @@ export function MarketTab({ data }: Props) {
</div>
)}
{/* Issue #113 — data-driven ТЗ на проектирование */}
<BestLayoutsBlock cadNum={data.cad_num} />
{!hasAny && <EmptyState message="Рыночные данные недоступны" />}
</div>
);

View file

@ -0,0 +1,29 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api";
import type {
BestLayoutsRequest,
BestLayoutsResponse,
} from "@/types/best-layouts";
/**
* TanStack Query mutation for POST /api/v1/parcels/{cad_num}/best-layouts.
*
* Usage:
* const { mutate, data, isPending, error } = useBestLayouts(cadNum);
* mutate(requestBody);
*/
export function useBestLayouts(cadNum: string) {
return useMutation({
mutationKey: ["best-layouts", cadNum],
mutationFn: (body: BestLayoutsRequest): Promise<BestLayoutsResponse> =>
apiFetch<BestLayoutsResponse>(
`/api/v1/parcels/${encodeURIComponent(cadNum)}/best-layouts`,
{
method: "POST",
body: JSON.stringify(body),
},
),
});
}

View file

@ -44,6 +44,11 @@ export interface AnalyzeOptions {
profileId?: number;
/** If set together with no profileId, backend uses user's default profile. */
profileUserId?: string;
/**
* Inline POI weights override sent as request body.
* Priority: inline profileId profileUserId default system.
*/
weights?: Record<string, number> | null;
}
/**
@ -90,11 +95,23 @@ export function useSiteAnalysis() {
return qsStr ? `${base}?${qsStr}` : base;
};
// Build optional JSON body for inline weights (#201).
const bodyPayload =
options?.weights != null
? JSON.stringify({ weights: options.weights })
: undefined;
// First request — POST /analyze
const first = await apiFetchWithStatus<
ParcelAnalysis | AnalyzeAcceptedResponse
>(analyzeUrl(cad), {
method: "POST",
...(bodyPayload
? {
body: bodyPayload,
headers: { "Content-Type": "application/json" },
}
: {}),
});
if (first.status === 200) {
@ -127,6 +144,12 @@ export function useSiteAnalysis() {
// mutation сразу резолвится с data — render skipped.
const second = await apiFetch<ParcelAnalysis>(analyzeUrl(cad), {
method: "POST",
...(bodyPayload
? {
body: bodyPayload,
headers: { "Content-Type": "application/json" },
}
: {}),
});
setFetchingState(null);
return second;

View file

@ -0,0 +1,63 @@
// Manual TS types for /best-layouts endpoint (Issue #113)
// Source: backend/app/schemas/parcel.py — BestLayoutsRequest, BestLayoutsResponse et al.
// Update if Pydantic schemas change and codegen is available.
export type TimeWindow = "last_month" | "last_quarter" | "last_year";
export type RoomBucket = "studio" | "1" | "2" | "3" | "4+";
export type AreaBin = "<25" | "25-40" | "40-60" | "60-80" | "80-100" | "100+";
export type Confidence = "high" | "medium" | "low";
export interface BestLayoutsRequest {
radius_km: number;
time_window: TimeWindow;
filter_competitor_obj_ids?: number[] | null;
exclude_competitor_obj_ids?: number[];
min_velocity_per_month?: number;
obj_class_filter?: "economy" | "comfort" | "business" | null;
target_total_flats?: number | null;
}
export interface TopLayoutRow {
rank: number;
room_bucket: string;
area_bin: string;
signature: string;
competitor_obj_ids: number[];
competitor_count: number;
total_sold_in_window: number;
velocity_per_month: number;
avg_price_per_m2_rub: number | null;
avg_area_m2: number;
supply_units_in_radius: number;
sold_pct_of_supply: number | null;
}
export interface LayoutTzMixRow {
room_bucket: string;
pct: number;
abs_units: number | null;
avg_target_area_m2: number | null;
}
export interface LayoutTzRecommendation {
rationale_text: string;
mix: LayoutTzMixRow[];
weighted_avg_price_per_m2_rub: number | null;
based_on_obj_count: number;
based_on_total_deals: number;
data_window_start: string;
data_window_end: string;
}
export interface LayoutDataQuality {
objects_with_velocity_data: number;
objects_total_in_radius: number;
velocity_coverage_pct: number;
confidence: Confidence;
}
export interface BestLayoutsResponse {
top_layouts: TopLayoutRow[];
recommendation_for_tz: LayoutTzRecommendation;
data_quality: LayoutDataQuality;
}