feat(site-finder): inline POI weights pass-through в /analyze (#201 Phase 1) #206
12 changed files with 1643 additions and 33 deletions
|
|
@ -6,7 +6,7 @@ import time
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
import httpx
|
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 import wkt as _shp_wkt
|
||||||
from shapely.geometry import Polygon
|
from shapely.geometry import Polygon
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
@ -15,6 +15,7 @@ from sqlalchemy.orm import Session
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.schemas.parcel import (
|
from app.schemas.parcel import (
|
||||||
|
AnalyzeRequest,
|
||||||
BestLayoutsRequest,
|
BestLayoutsRequest,
|
||||||
BestLayoutsResponse,
|
BestLayoutsResponse,
|
||||||
CompetitorsRequest,
|
CompetitorsRequest,
|
||||||
|
|
@ -24,6 +25,7 @@ from app.schemas.parcel import (
|
||||||
ParcelSearchRequest,
|
ParcelSearchRequest,
|
||||||
ParcelSearchResponse,
|
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.best_layouts import get_best_layouts
|
||||||
from app.services.site_finder.cadastre_fetch import (
|
from app.services.site_finder.cadastre_fetch import (
|
||||||
cad_exists_in_db,
|
cad_exists_in_db,
|
||||||
|
|
@ -40,6 +42,21 @@ from app.services.site_finder.quarter_dump_lookup import (
|
||||||
make_empty_result,
|
make_empty_result,
|
||||||
)
|
)
|
||||||
from app.services.site_finder.velocity import compute_velocity
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -284,21 +301,6 @@ def _confidence_label(c: float) -> str:
|
||||||
return "low"
|
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).
|
# Человеко-читаемые имена категорий для verbal breakdown (X1).
|
||||||
_POI_CATEGORY_RU: dict[str, str] = {
|
_POI_CATEGORY_RU: dict[str, str] = {
|
||||||
"school": "Школа",
|
"school": "Школа",
|
||||||
|
|
@ -1047,6 +1049,10 @@ def analyze_parcel(
|
||||||
str | None,
|
str | None,
|
||||||
Query(description="user_id для fallback на default-профиль пользователя"),
|
Query(description="user_id для fallback на default-профиль пользователя"),
|
||||||
] = None,
|
] = None,
|
||||||
|
body: Annotated[
|
||||||
|
AnalyzeRequest | None,
|
||||||
|
Body(description="Опциональное тело запроса: inline POI-веса (#201)"),
|
||||||
|
] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Анализ участка: близость к социалке + district context + конкуренты.
|
"""Анализ участка: близость к социалке + district context + конкуренты.
|
||||||
|
|
||||||
|
|
@ -1222,15 +1228,43 @@ def analyze_parcel(
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3b) Resolve effective POI weights (profile → user default → system)
|
# 3b) Resolve effective POI weights (inline → profile → user default → system)
|
||||||
from app.services.site_finder.weight_profiles import resolve_weights as _resolve_weights
|
_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)
|
if _inline_weights is not None:
|
||||||
_weights_source = (
|
# Validate inline weights: keys и диапазон значений (#201)
|
||||||
"profile"
|
bad_keys = set(_inline_weights.keys()) - _ALLOWED_CATEGORIES
|
||||||
if profile_id is not None
|
if bad_keys:
|
||||||
else ("user_default" if profile_user_id is not None else "system")
|
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
|
# 4) Scoring: weighted sum с distance decay
|
||||||
score = 0.0
|
score = 0.0
|
||||||
|
|
@ -1925,12 +1959,13 @@ def analyze_parcel(
|
||||||
nspd_engineering_nearby=nspd_dump_data["nspd_engineering_nearby"],
|
nspd_engineering_nearby=nspd_dump_data["nspd_engineering_nearby"],
|
||||||
nspd_dump=nspd_dump_data["nspd_dump"],
|
nspd_dump=nspd_dump_data["nspd_dump"],
|
||||||
),
|
),
|
||||||
# #114: кастомные веса POI — source + applied dict для прозрачности.
|
# #114/#201: кастомные веса POI — source + applied dict для прозрачности.
|
||||||
"weights_profile": {
|
"weights_profile": {
|
||||||
"source": _weights_source,
|
"source": _weights_source,
|
||||||
"profile_id": profile_id,
|
"profile_id": profile_id,
|
||||||
"user_id": profile_user_id,
|
"user_id": profile_user_id,
|
||||||
"weights_applied": _effective_weights,
|
"weights_applied": _effective_weights,
|
||||||
|
"inline_weights": _inline_weights,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2130,3 +2165,38 @@ async def get_parcel_best_layouts(
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("best_layouts endpoint failed for %s: %s", cad_num, exc)
|
logger.error("best_layouts endpoint failed for %s: %s", cad_num, exc)
|
||||||
raise HTTPException(status_code=500, detail="Internal server error") from 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
|
||||||
|
|
|
||||||
|
|
@ -213,3 +213,24 @@ class BestLayoutsResponse(BaseModel):
|
||||||
top_layouts: list[TopLayoutRow]
|
top_layouts: list[TopLayoutRow]
|
||||||
recommendation_for_tz: LayoutTzRecommendation
|
recommendation_for_tz: LayoutTzRecommendation
|
||||||
data_quality: LayoutDataQuality
|
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."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
|
||||||
161
backend/app/services/exporters/layout_tz_pdf.py
Normal file
161
backend/app/services/exporters/layout_tz_pdf.py
Normal 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
|
||||||
|
|
@ -17,6 +17,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -25,7 +26,7 @@ from sqlalchemy import text
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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] = {
|
ALLOWED_CATEGORIES: set[str] = {
|
||||||
"school",
|
"school",
|
||||||
"kindergarten",
|
"kindergarten",
|
||||||
|
|
@ -44,7 +45,7 @@ ALLOWED_CATEGORIES: set[str] = {
|
||||||
MIN_WEIGHT: float = -2.0
|
MIN_WEIGHT: float = -2.0
|
||||||
MAX_WEIGHT: float = 3.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] = {
|
_SYSTEM_POI_WEIGHTS: dict[str, float] = {
|
||||||
"school": 1.5,
|
"school": 1.5,
|
||||||
"kindergarten": 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():
|
for k, w in v.items():
|
||||||
if not isinstance(w, int | float):
|
if not isinstance(w, int | float):
|
||||||
raise ValueError(f"Weight for '{k}' must be number, got {type(w).__name__}")
|
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}]")
|
raise ValueError(f"Weight for '{k}' = {w} out of bounds [{MIN_WEIGHT}, {MAX_WEIGHT}]")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
|
||||||
327
backend/tests/api/v1/test_analyze_inline_weights.py
Normal file
327
backend/tests/api/v1/test_analyze_inline_weights.py
Normal 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()
|
||||||
136
backend/tests/test_layout_tz_pdf.py
Normal file
136
backend/tests/test_layout_tz_pdf.py
Normal 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"
|
||||||
|
|
@ -147,14 +147,18 @@ function SiteFinderContent() {
|
||||||
function handleAnalyze(cadNum: string) {
|
function handleAnalyze(cadNum: string) {
|
||||||
setIsochrones(undefined);
|
setIsochrones(undefined);
|
||||||
setTab("overview");
|
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({
|
mutate({
|
||||||
cad: cadNum,
|
cad: cadNum,
|
||||||
options:
|
options:
|
||||||
activeProfileId != null
|
activeProfileId != null
|
||||||
? { profileId: activeProfileId }
|
? { profileId: activeProfileId }
|
||||||
: profileUserId
|
: profileUserId
|
||||||
? { profileUserId }
|
? { profileUserId, weights: currentWeights }
|
||||||
: undefined,
|
: { weights: currentWeights },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,10 +167,20 @@ function SiteFinderContent() {
|
||||||
profileId: number | null,
|
profileId: number | null,
|
||||||
) {
|
) {
|
||||||
setCurrentWeights(weights);
|
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);
|
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
|
// Derive KPI values from data
|
||||||
|
|
|
||||||
761
frontend/src/components/site-finder/BestLayoutsBlock.tsx
Normal file
761
frontend/src/components/site-finder/BestLayoutsBlock.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import type { ParcelAnalysis } from "@/types/site-finder";
|
||||||
import { SectionLabel } from "@/components/ui/SectionLabel";
|
import { SectionLabel } from "@/components/ui/SectionLabel";
|
||||||
import { EmptyState } from "@/components/ui/EmptyState";
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
import { MarketTrendBlock } from "./MarketTrendBlock";
|
import { MarketTrendBlock } from "./MarketTrendBlock";
|
||||||
|
import { BestLayoutsBlock } from "./BestLayoutsBlock";
|
||||||
import { CompetitorTable } from "./CompetitorTable";
|
import { CompetitorTable } from "./CompetitorTable";
|
||||||
import { Pipeline24moBlock } from "./Pipeline24moBlock";
|
import { Pipeline24moBlock } from "./Pipeline24moBlock";
|
||||||
import { SuccessRecommendationBlock } from "./SuccessRecommendationBlock";
|
import { SuccessRecommendationBlock } from "./SuccessRecommendationBlock";
|
||||||
|
|
@ -81,6 +82,9 @@ export function MarketTab({ data }: Props) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Issue #113 — data-driven ТЗ на проектирование */}
|
||||||
|
<BestLayoutsBlock cadNum={data.cad_num} />
|
||||||
|
|
||||||
{!hasAny && <EmptyState message="Рыночные данные недоступны" />}
|
{!hasAny && <EmptyState message="Рыночные данные недоступны" />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
29
frontend/src/hooks/useBestLayouts.ts
Normal file
29
frontend/src/hooks/useBestLayouts.ts
Normal 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),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -44,6 +44,11 @@ export interface AnalyzeOptions {
|
||||||
profileId?: number;
|
profileId?: number;
|
||||||
/** If set together with no profileId, backend uses user's default profile. */
|
/** If set together with no profileId, backend uses user's default profile. */
|
||||||
profileUserId?: string;
|
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;
|
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
|
// First request — POST /analyze
|
||||||
const first = await apiFetchWithStatus<
|
const first = await apiFetchWithStatus<
|
||||||
ParcelAnalysis | AnalyzeAcceptedResponse
|
ParcelAnalysis | AnalyzeAcceptedResponse
|
||||||
>(analyzeUrl(cad), {
|
>(analyzeUrl(cad), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
...(bodyPayload
|
||||||
|
? {
|
||||||
|
body: bodyPayload,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (first.status === 200) {
|
if (first.status === 200) {
|
||||||
|
|
@ -127,6 +144,12 @@ export function useSiteAnalysis() {
|
||||||
// mutation сразу резолвится с data — render skipped.
|
// mutation сразу резолвится с data — render skipped.
|
||||||
const second = await apiFetch<ParcelAnalysis>(analyzeUrl(cad), {
|
const second = await apiFetch<ParcelAnalysis>(analyzeUrl(cad), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
...(bodyPayload
|
||||||
|
? {
|
||||||
|
body: bodyPayload,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
setFetchingState(null);
|
setFetchingState(null);
|
||||||
return second;
|
return second;
|
||||||
|
|
|
||||||
63
frontend/src/types/best-layouts.ts
Normal file
63
frontend/src/types/best-layouts.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue