feat(layouts): PDF ТЗ endpoint + BestLayoutsBlock UI (#113 PR D) #198
9 changed files with 2170 additions and 0 deletions
|
|
@ -15,6 +15,8 @@ from sqlalchemy.orm import Session
|
|||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.schemas.parcel import (
|
||||
BestLayoutsRequest,
|
||||
BestLayoutsResponse,
|
||||
CompetitorsRequest,
|
||||
CompetitorsResponse,
|
||||
ConnectionPointsResponse,
|
||||
|
|
@ -22,6 +24,8 @@ 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,
|
||||
find_or_enqueue_fetch,
|
||||
|
|
@ -2106,3 +2110,57 @@ async def get_parcel_competitors(
|
|||
status_code=500,
|
||||
detail="Ошибка расчёта конкурентов",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/{cad_num}/best-layouts", response_model=BestLayoutsResponse)
|
||||
async def get_parcel_best_layouts(
|
||||
cad_num: str,
|
||||
body: BestLayoutsRequest,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> BestLayoutsResponse:
|
||||
"""Top layouts (rooms × area_bin) у конкурентов с ranking по velocity.
|
||||
|
||||
Issue #113 Phase 2.1: "Анализ лучших планировок конкурентов → ТЗ на проектирование".
|
||||
Reads from mv_layout_velocity (auto-populated via objective_corpus_room_month
|
||||
× objective_complex_mapping).
|
||||
"""
|
||||
try:
|
||||
return get_best_layouts(db=db, cad_num=cad_num, request=body)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
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 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
|
||||
|
|
|
|||
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>{r.room_bucket}</td>"
|
||||
f"<td>{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>{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
|
||||
583
backend/app/services/site_finder/best_layouts.py
Normal file
583
backend/app/services/site_finder/best_layouts.py
Normal file
|
|
@ -0,0 +1,583 @@
|
|||
"""Анализ лучших планировок конкурентов по velocity (Issue #113 Phase 2.1).
|
||||
|
||||
Источники:
|
||||
cad_parcels_geom / cad_quarters_geom — центроид участка
|
||||
domrf_kn_objects — ЖК в радиусе (latitude/longitude → geography)
|
||||
mv_layout_velocity — (obj_id, room_bucket) → агрегат продаж 24 мес
|
||||
domrf_kn_flats — supply count по (room_bucket, area_bin)
|
||||
|
||||
Алгоритм:
|
||||
Step 1: центроид участка (cad_parcels_geom → cad_quarters_geom fallback).
|
||||
Step 2: obj_id конкурентов в радиусе (domrf_kn_objects + фильтры).
|
||||
Step 3: JOIN mv_layout_velocity GROUP BY room_bucket.
|
||||
Step 4: scale velocity по time_window.
|
||||
Step 5: supply side из domrf_kn_flats — один батч-запрос.
|
||||
Step 6: per-row signature + sold_pct.
|
||||
Step 7: фильтр min_velocity + sort + rank.
|
||||
Step 8: build recommendation_for_tz (unit-mix, price, rationale).
|
||||
Step 9: data_quality (coverage + confidence).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.schemas.parcel import (
|
||||
BestLayoutsRequest,
|
||||
BestLayoutsResponse,
|
||||
LayoutDataQuality,
|
||||
LayoutTzMixRow,
|
||||
LayoutTzRecommendation,
|
||||
TopLayoutRow,
|
||||
)
|
||||
from app.services.site_finder.layout_signature import area_bin, layout_signature
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Confidence thresholds (per coverage % of objects with MV velocity data)
|
||||
# Tune via PR if business feedback требует.
|
||||
LAYOUT_CONFIDENCE_HIGH_PCT = 50.0
|
||||
LAYOUT_CONFIDENCE_MEDIUM_PCT = 20.0
|
||||
|
||||
# Делители velocity: 24 мес → масштаб на указанный window
|
||||
_VELOCITY_DIVISORS: dict[str, float] = {
|
||||
"last_month": 24.0,
|
||||
"last_quarter": 8.0,
|
||||
"last_year": 2.0,
|
||||
}
|
||||
|
||||
# ── SQL: центроид участка ─────────────────────────────────────────────────────
|
||||
|
||||
_PARCEL_CENTROID_SQL = text("""
|
||||
SELECT ST_X(pt) AS center_lon,
|
||||
ST_Y(pt) AS center_lat
|
||||
FROM (
|
||||
SELECT ST_Centroid(geom) AS pt
|
||||
FROM cad_parcels_geom
|
||||
WHERE cad_num = :cad_num AND geom IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT ST_Centroid(geom) AS pt
|
||||
FROM cad_quarters_geom
|
||||
WHERE cad_number = :quarter AND geom IS NOT NULL
|
||||
) sub
|
||||
LIMIT 1
|
||||
""")
|
||||
|
||||
# ── SQL: obj_id конкурентов в радиусе ─────────────────────────────────────────
|
||||
# Геометрия domrf_kn_objects вычисляется on-the-fly из (latitude, longitude)
|
||||
# как ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)::geography
|
||||
# (consistency с competitors.py).
|
||||
# obj_class_filter: NULL = все классы.
|
||||
# filter_competitor_obj_ids: NULL = не фильтровать по списку.
|
||||
|
||||
_COMPETITORS_IN_RADIUS_SQL = text("""
|
||||
SELECT DISTINCT ON (obj_id) obj_id
|
||||
FROM domrf_kn_objects
|
||||
WHERE latitude IS NOT NULL AND longitude IS NOT NULL
|
||||
AND ST_DWithin(
|
||||
ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)::geography,
|
||||
ST_SetSRID(
|
||||
ST_MakePoint(CAST(:center_lon AS float), CAST(:center_lat AS float)),
|
||||
4326
|
||||
)::geography,
|
||||
CAST(:radius_m AS float)
|
||||
)
|
||||
AND (
|
||||
CAST(:obj_class_filter AS text) IS NULL
|
||||
OR obj_class = CAST(:obj_class_filter AS text)
|
||||
)
|
||||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||||
""")
|
||||
|
||||
# ── SQL: mv_layout_velocity GROUP BY room_bucket ─────────────────────────────
|
||||
|
||||
_VELOCITY_BY_ROOM_SQL = text("""
|
||||
SELECT
|
||||
room_bucket,
|
||||
SUM(total_deals_24mo) AS sum_deals,
|
||||
AVG(avg_area_m2) AS avg_area_m2,
|
||||
AVG(avg_price_thousand_rub_per_m2) * 1000.0 AS avg_price_per_m2_rub,
|
||||
array_agg(DISTINCT obj_id) AS competitor_obj_ids,
|
||||
COUNT(DISTINCT obj_id) AS competitor_count,
|
||||
MIN(window_start) AS window_start,
|
||||
MAX(window_end) AS window_end
|
||||
FROM mv_layout_velocity
|
||||
WHERE obj_id = ANY(:obj_ids)
|
||||
GROUP BY room_bucket
|
||||
""")
|
||||
|
||||
# ── SQL: supply по (room_bucket, area_bin) за последний снимок ───────────────
|
||||
# Один батч-запрос вместо N — возвращает map (rb, ab) → count.
|
||||
# room_bucket и area_bin вычисляются в SQL аналогично layout_signature.py.
|
||||
|
||||
_SUPPLY_BATCH_SQL = text("""
|
||||
SELECT
|
||||
CASE
|
||||
WHEN f.is_studio = TRUE OR f.flat_type = 'Квартира-студия' THEN 'studio'
|
||||
WHEN f.rooms = 0 THEN 'studio'
|
||||
WHEN f.rooms IN (1, 2, 3) THEN f.rooms::text
|
||||
WHEN f.rooms >= 4 THEN '4+'
|
||||
ELSE '1'
|
||||
END AS rb,
|
||||
CASE
|
||||
WHEN f.total_area < 25 THEN '<25'
|
||||
WHEN f.total_area < 40 THEN '25-40'
|
||||
WHEN f.total_area < 60 THEN '40-60'
|
||||
WHEN f.total_area < 80 THEN '60-80'
|
||||
WHEN f.total_area < 100 THEN '80-100'
|
||||
ELSE '100+'
|
||||
END AS ab,
|
||||
COUNT(*) AS units
|
||||
FROM domrf_kn_flats f
|
||||
JOIN domrf_kn_objects o ON f.obj_id = o.obj_id
|
||||
WHERE o.latitude IS NOT NULL AND o.longitude IS NOT NULL
|
||||
AND ST_DWithin(
|
||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||
ST_SetSRID(
|
||||
ST_MakePoint(CAST(:center_lon AS float), CAST(:center_lat AS float)),
|
||||
4326
|
||||
)::geography,
|
||||
CAST(:radius_m AS float)
|
||||
)
|
||||
AND f.snapshot_date = CAST(:latest_snap AS date)
|
||||
GROUP BY rb, ab
|
||||
""")
|
||||
|
||||
|
||||
# ── Вспомогательные функции ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _quarter_from_cad(cad_num: str) -> str:
|
||||
"""Извлечь кадастровый квартал: '66:41:0303161:123' → '66:41:0303161'."""
|
||||
parts = cad_num.split(":")
|
||||
if len(parts) >= 3:
|
||||
return ":".join(parts[:3])
|
||||
return cad_num
|
||||
|
||||
|
||||
def _normalize_pct(buckets: dict[str, float]) -> dict[str, int]:
|
||||
"""Нормировать доли до целых процентов с суммой ровно 100.
|
||||
|
||||
Алгоритм largest-remainder (Hamilton method):
|
||||
1. Floor каждого значения.
|
||||
2. Остаток 100 − sum_floors распределить в top-bucket по дробной части.
|
||||
"""
|
||||
if not buckets:
|
||||
return {}
|
||||
|
||||
total = sum(buckets.values())
|
||||
if total <= 0:
|
||||
n = len(buckets)
|
||||
base = 100 // n
|
||||
result = {k: base for k in buckets}
|
||||
# распределить остаток
|
||||
remainder = 100 - base * n
|
||||
for k in list(buckets.keys())[:remainder]:
|
||||
result[k] += 1
|
||||
return result
|
||||
|
||||
raw = {k: v / total * 100.0 for k, v in buckets.items()}
|
||||
floors = {k: int(v) for k, v in raw.items()}
|
||||
remainder = 100 - sum(floors.values())
|
||||
# sort by fractional part desc
|
||||
fracs = sorted(buckets.keys(), key=lambda k: -(raw[k] - floors[k]))
|
||||
for k in fracs[:remainder]:
|
||||
floors[k] += 1
|
||||
return floors
|
||||
|
||||
|
||||
# ── Главная функция ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_best_layouts(
|
||||
db: Session,
|
||||
cad_num: str,
|
||||
request: BestLayoutsRequest,
|
||||
) -> BestLayoutsResponse:
|
||||
"""Top layouts (rooms × area_bin) конкурентов с рейтингом по velocity.
|
||||
|
||||
Raises:
|
||||
ValueError: если центроид участка не найден (caller → HTTP 404).
|
||||
"""
|
||||
quarter = _quarter_from_cad(cad_num)
|
||||
radius_m = request.radius_km * 1000.0
|
||||
|
||||
# ── Step 1: центроид участка ─────────────────────────────────────────────
|
||||
try:
|
||||
coord_row = (
|
||||
db.execute(
|
||||
_PARCEL_CENTROID_SQL,
|
||||
{"cad_num": cad_num, "quarter": quarter},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("best_layouts: centroid query failed for cad_num=%s", cad_num)
|
||||
raise
|
||||
|
||||
if not coord_row:
|
||||
raise ValueError(f"Геометрия для {cad_num} не найдена")
|
||||
|
||||
center_lon = float(coord_row["center_lon"])
|
||||
center_lat = float(coord_row["center_lat"])
|
||||
|
||||
# ── Step 2: obj_id конкурентов в радиусе ────────────────────────────────
|
||||
try:
|
||||
id_rows = (
|
||||
db.execute(
|
||||
_COMPETITORS_IN_RADIUS_SQL,
|
||||
{
|
||||
"center_lon": center_lon,
|
||||
"center_lat": center_lat,
|
||||
"radius_m": radius_m,
|
||||
"obj_class_filter": request.obj_class_filter,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("best_layouts: competitors-in-radius query failed for cad_num=%s", cad_num)
|
||||
raise
|
||||
|
||||
all_obj_ids: list[int] = [int(r["obj_id"]) for r in id_rows]
|
||||
objects_total_in_radius = len(all_obj_ids)
|
||||
|
||||
# Применить exclude / filter из request
|
||||
exclude_set = set(request.exclude_competitor_obj_ids)
|
||||
if exclude_set:
|
||||
all_obj_ids = [oid for oid in all_obj_ids if oid not in exclude_set]
|
||||
|
||||
if request.filter_competitor_obj_ids is not None:
|
||||
filter_set = set(request.filter_competitor_obj_ids)
|
||||
all_obj_ids = [oid for oid in all_obj_ids if oid in filter_set]
|
||||
|
||||
if not all_obj_ids:
|
||||
return _empty_response(
|
||||
radius_km=request.radius_km,
|
||||
time_window=request.time_window,
|
||||
objects_total_in_radius=objects_total_in_radius,
|
||||
)
|
||||
|
||||
# ── Step 3: mv_layout_velocity GROUP BY room_bucket ─────────────────────
|
||||
try:
|
||||
vel_rows = db.execute(_VELOCITY_BY_ROOM_SQL, {"obj_ids": all_obj_ids}).mappings().all()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"best_layouts: velocity query failed for cad_num=%s obj_count=%d",
|
||||
cad_num,
|
||||
len(all_obj_ids),
|
||||
)
|
||||
raise
|
||||
|
||||
if not vel_rows:
|
||||
return _empty_response(
|
||||
radius_km=request.radius_km,
|
||||
time_window=request.time_window,
|
||||
objects_total_in_radius=objects_total_in_radius,
|
||||
)
|
||||
|
||||
# ── Step 5: supply side (батч-запрос) ────────────────────────────────────
|
||||
# Pre-compute последний snapshot_date один раз — избегаем subquery на каждый scan.
|
||||
latest_snap: dt.date | None = db.scalar(text("SELECT MAX(snapshot_date) FROM domrf_kn_flats"))
|
||||
if latest_snap is None:
|
||||
logger.warning("best_layouts: domrf_kn_flats пустой (нет snapshot_date), supply=0 fallback")
|
||||
supply_rows = []
|
||||
else:
|
||||
try:
|
||||
supply_rows = (
|
||||
db.execute(
|
||||
_SUPPLY_BATCH_SQL,
|
||||
{
|
||||
"center_lon": center_lon,
|
||||
"center_lat": center_lat,
|
||||
"radius_m": radius_m,
|
||||
"latest_snap": latest_snap,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("best_layouts: supply query failed, supply=0 fallback")
|
||||
supply_rows = []
|
||||
|
||||
supply_map: dict[tuple[str, str], int] = {
|
||||
(str(r["rb"]), str(r["ab"])): int(r["units"]) for r in supply_rows
|
||||
}
|
||||
|
||||
# ── Step 4 + 6: scale velocity и enrichment per row ──────────────────────
|
||||
divisor = _VELOCITY_DIVISORS[request.time_window]
|
||||
|
||||
enriched: list[dict[str, Any]] = []
|
||||
window_start: dt.date | None = None
|
||||
window_end: dt.date | None = None
|
||||
|
||||
# Собираем obj_ids с данными в MV (для data_quality)
|
||||
obj_ids_with_data: set[int] = set()
|
||||
|
||||
for r in vel_rows:
|
||||
room_bucket = str(r["room_bucket"])
|
||||
sum_deals = float(r["sum_deals"]) if r["sum_deals"] is not None else 0.0
|
||||
avg_area = float(r["avg_area_m2"]) if r["avg_area_m2"] is not None else 0.0
|
||||
price_rub = (
|
||||
float(r["avg_price_per_m2_rub"]) if r["avg_price_per_m2_rub"] is not None else None
|
||||
)
|
||||
competitor_obj_ids: list[int] = (
|
||||
[int(oid) for oid in r["competitor_obj_ids"]] if r["competitor_obj_ids"] else []
|
||||
)
|
||||
competitor_count = int(r["competitor_count"])
|
||||
|
||||
obj_ids_with_data.update(competitor_obj_ids)
|
||||
|
||||
# Step 4: scale
|
||||
velocity_per_month = round(sum_deals / divisor, 2)
|
||||
|
||||
# Step 6: area_bin по avg_area (layout_signature.area_bin)
|
||||
ab = area_bin(avg_area) if avg_area > 0 else "<25"
|
||||
sig = layout_signature(room_bucket, ab) # type: ignore[arg-type]
|
||||
|
||||
supply_count = supply_map.get((room_bucket, ab), 0)
|
||||
sold_pct: float | None = None
|
||||
if supply_count > 0:
|
||||
sold_pct = round(sum_deals / supply_count * 100.0, 1)
|
||||
|
||||
# data window
|
||||
if r["window_start"] is not None:
|
||||
ws = r["window_start"]
|
||||
if isinstance(ws, str):
|
||||
ws = dt.date.fromisoformat(ws)
|
||||
elif isinstance(ws, dt.datetime):
|
||||
ws = ws.date()
|
||||
window_start = ws if window_start is None else min(window_start, ws)
|
||||
|
||||
if r["window_end"] is not None:
|
||||
we = r["window_end"]
|
||||
if isinstance(we, str):
|
||||
we = dt.date.fromisoformat(we)
|
||||
elif isinstance(we, dt.datetime):
|
||||
we = we.date()
|
||||
window_end = we if window_end is None else max(window_end, we)
|
||||
|
||||
enriched.append(
|
||||
{
|
||||
"room_bucket": room_bucket,
|
||||
"area_bin": ab,
|
||||
"signature": sig,
|
||||
"competitor_obj_ids": competitor_obj_ids,
|
||||
"competitor_count": competitor_count,
|
||||
"sum_deals": sum_deals,
|
||||
"velocity_per_month": velocity_per_month,
|
||||
"avg_price_per_m2_rub": price_rub,
|
||||
"avg_area_m2": avg_area,
|
||||
"supply_units_in_radius": supply_count,
|
||||
"sold_pct_of_supply": sold_pct,
|
||||
}
|
||||
)
|
||||
|
||||
# ── Step 7: фильтр min_velocity + sort + rank ────────────────────────────
|
||||
filtered = [
|
||||
row for row in enriched if row["velocity_per_month"] >= request.min_velocity_per_month
|
||||
]
|
||||
filtered.sort(key=lambda r: r["velocity_per_month"], reverse=True)
|
||||
|
||||
top_layouts: list[TopLayoutRow] = []
|
||||
for rank_idx, row in enumerate(filtered, start=1):
|
||||
top_layouts.append(
|
||||
TopLayoutRow(
|
||||
rank=rank_idx,
|
||||
room_bucket=row["room_bucket"],
|
||||
area_bin=row["area_bin"],
|
||||
signature=row["signature"],
|
||||
competitor_obj_ids=row["competitor_obj_ids"],
|
||||
competitor_count=row["competitor_count"],
|
||||
total_sold_in_window=int(row["sum_deals"]),
|
||||
velocity_per_month=row["velocity_per_month"],
|
||||
avg_price_per_m2_rub=row["avg_price_per_m2_rub"],
|
||||
avg_area_m2=round(row["avg_area_m2"], 1),
|
||||
supply_units_in_radius=row["supply_units_in_radius"],
|
||||
sold_pct_of_supply=row["sold_pct_of_supply"],
|
||||
)
|
||||
)
|
||||
|
||||
# ── Step 8: build recommendation_for_tz ─────────────────────────────────
|
||||
# Используем filtered (только > min_velocity) для recommendation.
|
||||
# Если после фильтрации всё пустое — используем enriched (все данные без фильтра).
|
||||
rec_source = filtered if filtered else enriched
|
||||
|
||||
today = dt.date.today()
|
||||
ws_date = window_start if window_start is not None else today
|
||||
we_date = window_end if window_end is not None else today
|
||||
|
||||
recommendation = _build_recommendation(
|
||||
rows=rec_source,
|
||||
radius_km=request.radius_km,
|
||||
time_window=request.time_window,
|
||||
target_total_flats=request.target_total_flats,
|
||||
window_start=ws_date,
|
||||
window_end=we_date,
|
||||
all_enriched=enriched,
|
||||
)
|
||||
|
||||
# ── Step 9: data_quality ─────────────────────────────────────────────────
|
||||
# Denominator = post-filter set (effective consideration set после exclude/filter).
|
||||
objects_total_after_filter = len(all_obj_ids)
|
||||
objects_with_data = len(obj_ids_with_data & set(all_obj_ids))
|
||||
coverage_pct = (
|
||||
round(objects_with_data / objects_total_after_filter * 100.0, 1)
|
||||
if objects_total_after_filter > 0
|
||||
else 0.0
|
||||
)
|
||||
if coverage_pct >= LAYOUT_CONFIDENCE_HIGH_PCT:
|
||||
confidence: str = "high"
|
||||
elif coverage_pct >= LAYOUT_CONFIDENCE_MEDIUM_PCT:
|
||||
confidence = "medium"
|
||||
else:
|
||||
confidence = "low"
|
||||
|
||||
data_quality = LayoutDataQuality(
|
||||
objects_with_velocity_data=objects_with_data,
|
||||
objects_total_in_radius=objects_total_after_filter,
|
||||
velocity_coverage_pct=coverage_pct,
|
||||
confidence=confidence, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
return BestLayoutsResponse(
|
||||
top_layouts=top_layouts,
|
||||
recommendation_for_tz=recommendation,
|
||||
data_quality=data_quality,
|
||||
)
|
||||
|
||||
|
||||
def _build_recommendation(
|
||||
rows: list[dict[str, Any]],
|
||||
radius_km: float,
|
||||
time_window: str,
|
||||
target_total_flats: int | None,
|
||||
window_start: dt.date,
|
||||
window_end: dt.date,
|
||||
all_enriched: list[dict[str, Any]],
|
||||
) -> LayoutTzRecommendation:
|
||||
"""Собрать LayoutTzRecommendation из enriched rows."""
|
||||
if not rows:
|
||||
return LayoutTzRecommendation(
|
||||
rationale_text=(
|
||||
f"В радиусе {radius_km}км: нет layout-паттернов с достаточной velocity."
|
||||
),
|
||||
mix=[],
|
||||
weighted_avg_price_per_m2_rub=None,
|
||||
based_on_obj_count=0,
|
||||
based_on_total_deals=0,
|
||||
data_window_start=window_start,
|
||||
data_window_end=window_end,
|
||||
)
|
||||
|
||||
# Группировка по room_bucket (строки уже могут быть per-bucket из MV GROUP BY)
|
||||
rb_deals: dict[str, float] = {}
|
||||
rb_area_weighted: dict[str, float] = {}
|
||||
rb_price_weighted: dict[str, float] = {}
|
||||
rb_price_total_deals: dict[str, float] = {}
|
||||
all_competitor_ids: set[int] = set()
|
||||
|
||||
for row in rows:
|
||||
rb = row["room_bucket"]
|
||||
sd = float(row["sum_deals"])
|
||||
rb_deals[rb] = rb_deals.get(rb, 0.0) + sd
|
||||
rb_area_weighted[rb] = rb_area_weighted.get(rb, 0.0) + row["avg_area_m2"] * sd
|
||||
all_competitor_ids.update(row["competitor_obj_ids"])
|
||||
if row["avg_price_per_m2_rub"] is not None:
|
||||
rb_price_weighted[rb] = rb_price_weighted.get(rb, 0.0) + (
|
||||
row["avg_price_per_m2_rub"] * sd
|
||||
)
|
||||
rb_price_total_deals[rb] = rb_price_total_deals.get(rb, 0.0) + sd
|
||||
|
||||
total_deals = sum(rb_deals.values())
|
||||
pct_map = _normalize_pct(rb_deals)
|
||||
|
||||
mix: list[LayoutTzMixRow] = []
|
||||
for rb, pct in sorted(pct_map.items(), key=lambda x: -x[1]):
|
||||
avg_area = (
|
||||
round(rb_area_weighted[rb] / rb_deals[rb], 1) if rb_deals.get(rb, 0) > 0 else None
|
||||
)
|
||||
abs_units: int | None = None
|
||||
if target_total_flats is not None:
|
||||
abs_units = round(pct / 100.0 * target_total_flats)
|
||||
mix.append(
|
||||
LayoutTzMixRow(
|
||||
room_bucket=rb,
|
||||
pct=pct,
|
||||
abs_units=abs_units,
|
||||
avg_target_area_m2=avg_area,
|
||||
)
|
||||
)
|
||||
|
||||
# Weighted avg price across all room_buckets
|
||||
total_price_deals = sum(rb_price_total_deals.values())
|
||||
weighted_price: float | None = None
|
||||
if total_price_deals > 0:
|
||||
weighted_price = round(sum(rb_price_weighted.values()) / total_price_deals, 0)
|
||||
|
||||
# Rationale
|
||||
competitor_count = len(all_competitor_ids)
|
||||
tw_label = {"last_month": "1 мес", "last_quarter": "квартал", "last_year": "год"}.get(
|
||||
time_window, time_window
|
||||
)
|
||||
rationale_text = (
|
||||
f"В радиусе {radius_km}км за {tw_label}: "
|
||||
f"{len(rows)} активных layout-паттернов, "
|
||||
f"total {int(total_deals)} продаж в {competitor_count} ЖК"
|
||||
)
|
||||
|
||||
# based_on_obj_count из all_enriched (уникальные obj_id с данными MV)
|
||||
all_mv_obj_ids: set[int] = set()
|
||||
for row in all_enriched:
|
||||
all_mv_obj_ids.update(row["competitor_obj_ids"])
|
||||
|
||||
return LayoutTzRecommendation(
|
||||
rationale_text=rationale_text,
|
||||
mix=mix,
|
||||
weighted_avg_price_per_m2_rub=weighted_price,
|
||||
based_on_obj_count=len(all_mv_obj_ids),
|
||||
based_on_total_deals=int(total_deals),
|
||||
data_window_start=window_start,
|
||||
data_window_end=window_end,
|
||||
)
|
||||
|
||||
|
||||
def _empty_response(
|
||||
radius_km: float,
|
||||
time_window: str,
|
||||
objects_total_in_radius: int,
|
||||
) -> BestLayoutsResponse:
|
||||
"""Ответ когда нет конкурентов или нет MV данных."""
|
||||
today = dt.date.today()
|
||||
tw_label = {"last_month": "1 мес", "last_quarter": "квартал", "last_year": "год"}.get(
|
||||
time_window, time_window
|
||||
)
|
||||
return BestLayoutsResponse(
|
||||
top_layouts=[],
|
||||
recommendation_for_tz=LayoutTzRecommendation(
|
||||
rationale_text=(
|
||||
f"В радиусе {radius_km}км за {tw_label}: "
|
||||
f"конкуренты не найдены или нет данных velocity."
|
||||
),
|
||||
mix=[],
|
||||
weighted_avg_price_per_m2_rub=None,
|
||||
based_on_obj_count=0,
|
||||
based_on_total_deals=0,
|
||||
data_window_start=today,
|
||||
data_window_end=today,
|
||||
),
|
||||
data_quality=LayoutDataQuality(
|
||||
objects_with_velocity_data=0,
|
||||
objects_total_in_radius=objects_total_in_radius,
|
||||
velocity_coverage_pct=0.0,
|
||||
confidence="low",
|
||||
),
|
||||
)
|
||||
379
backend/tests/api/v1/test_parcel_best_layouts.py
Normal file
379
backend/tests/api/v1/test_parcel_best_layouts.py
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
"""Тесты для POST /api/v1/parcels/{cad_num}/best-layouts (Issue #113 Phase 2.1).
|
||||
|
||||
Mock-based — не требуют живой БД.
|
||||
Паттерн mock DB: аналогично test_parcel_competitors.py — dependency_overrides[get_db].
|
||||
|
||||
Порядок вызовов в get_best_layouts:
|
||||
db.scalar() → MAX(snapshot_date) (только когда vel_rows non-empty)
|
||||
db.execute() calls:
|
||||
1. _PARCEL_CENTROID_SQL → .mappings().first()
|
||||
2. _COMPETITORS_IN_RADIUS_SQL → .mappings().all()
|
||||
3. _VELOCITY_BY_ROOM_SQL → .mappings().all()
|
||||
4. _SUPPLY_BATCH_SQL → .mappings().all() (пропускается если latest_snap is None)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
# ── Фабрики mock-строк ────────────────────────────────────────────────────────
|
||||
|
||||
CAD_NUM = "66:41:0303161:123"
|
||||
_TODAY = dt.date.today()
|
||||
|
||||
|
||||
def _coord_row(lon: float = 60.6, lat: float = 56.85) -> MagicMock:
|
||||
"""Центроид участка (EPSG:4326 lon/lat)."""
|
||||
r = MagicMock()
|
||||
r.__getitem__ = lambda self, k: {"center_lon": lon, "center_lat": lat}[k]
|
||||
return r
|
||||
|
||||
|
||||
def _obj_id_row(obj_id: int) -> MagicMock:
|
||||
"""Строка obj_id из _COMPETITORS_IN_RADIUS_SQL."""
|
||||
r = MagicMock()
|
||||
r.__getitem__ = lambda self, k: {"obj_id": obj_id}[k]
|
||||
return r
|
||||
|
||||
|
||||
def _vel_row(
|
||||
room_bucket: str = "2",
|
||||
sum_deals: float = 48.0,
|
||||
avg_area: float = 55.0,
|
||||
avg_price_rub: float | None = 120000.0,
|
||||
obj_ids: list[int] | None = None,
|
||||
window_start: dt.date | None = None,
|
||||
window_end: dt.date | None = None,
|
||||
) -> MagicMock:
|
||||
"""Строка из mv_layout_velocity GROUP BY room_bucket."""
|
||||
oids = obj_ids if obj_ids is not None else [1]
|
||||
ws = window_start or _TODAY - dt.timedelta(days=730)
|
||||
we = window_end or _TODAY
|
||||
|
||||
r = MagicMock()
|
||||
r.__getitem__ = lambda self, k: {
|
||||
"room_bucket": room_bucket,
|
||||
"sum_deals": sum_deals,
|
||||
"avg_area_m2": avg_area,
|
||||
"avg_price_per_m2_rub": avg_price_rub,
|
||||
"competitor_obj_ids": oids,
|
||||
"competitor_count": len(oids),
|
||||
"window_start": ws,
|
||||
"window_end": we,
|
||||
}[k]
|
||||
return r
|
||||
|
||||
|
||||
def _supply_row(rb: str, ab: str, units: int) -> MagicMock:
|
||||
"""Строка из _SUPPLY_BATCH_SQL."""
|
||||
r = MagicMock()
|
||||
r.__getitem__ = lambda self, k: {"rb": rb, "ab": ab, "units": units}[k]
|
||||
return r
|
||||
|
||||
|
||||
# ── Построение mock DB ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_db(
|
||||
coord: MagicMock | None = None,
|
||||
id_rows: list[MagicMock] | None = None,
|
||||
vel_rows: list[MagicMock] | None = None,
|
||||
supply_rows: list[MagicMock] | None = None,
|
||||
latest_snap: dt.date | None = None,
|
||||
) -> MagicMock:
|
||||
"""Сконструировать mock Session.
|
||||
|
||||
db.scalar() возвращает latest_snap (MAX snapshot_date) — вызывается перед supply.
|
||||
Порядок db.execute():
|
||||
1. centroid → .mappings().first()
|
||||
2. competitors-in-radius → .mappings().all()
|
||||
3. velocity → .mappings().all()
|
||||
4. supply → .mappings().all() (только если latest_snap is not None)
|
||||
"""
|
||||
db = MagicMock()
|
||||
|
||||
# db.scalar — pre-computed MAX(snapshot_date) для supply query
|
||||
db.scalar.return_value = latest_snap if latest_snap is not None else _TODAY
|
||||
|
||||
results: list[MagicMock] = []
|
||||
|
||||
# 1: centroid
|
||||
r0 = MagicMock()
|
||||
r0.mappings.return_value.first.return_value = coord
|
||||
results.append(r0)
|
||||
|
||||
# 2: competitors-in-radius
|
||||
r1 = MagicMock()
|
||||
r1.mappings.return_value.all.return_value = id_rows or []
|
||||
results.append(r1)
|
||||
|
||||
# 3: velocity (only queried if id_rows non-empty)
|
||||
r2 = MagicMock()
|
||||
r2.mappings.return_value.all.return_value = vel_rows or []
|
||||
results.append(r2)
|
||||
|
||||
# 4: supply
|
||||
r3 = MagicMock()
|
||||
r3.mappings.return_value.all.return_value = supply_rows or []
|
||||
results.append(r3)
|
||||
|
||||
db.execute.side_effect = results
|
||||
return db
|
||||
|
||||
|
||||
def _override_db(db: MagicMock):
|
||||
def _get_db_override():
|
||||
yield db
|
||||
|
||||
return _get_db_override
|
||||
|
||||
|
||||
def _post(client: TestClient, cad: str = CAD_NUM, **body_kwargs) -> dict:
|
||||
payload = {"radius_km": 1.0, "time_window": "last_quarter", **body_kwargs}
|
||||
resp = client.post(f"/api/v1/parcels/{cad}/best-layouts", json=payload)
|
||||
return resp
|
||||
|
||||
|
||||
# ── Тесты ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parcel_not_found_404() -> None:
|
||||
"""Если центроид не найден → 404."""
|
||||
db = _make_db(coord=None)
|
||||
from app.core.db import get_db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
resp = _post(TestClient(app), cad="99:99:9999999:999")
|
||||
assert resp.status_code == 404, resp.text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_empty_competitor_set_returns_low_confidence() -> None:
|
||||
"""Нет конкурентов в радиусе → пустые top_layouts + confidence=low."""
|
||||
db = _make_db(coord=_coord_row(), id_rows=[])
|
||||
from app.core.db import get_db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
resp = _post(TestClient(app))
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["top_layouts"] == []
|
||||
assert body["data_quality"]["confidence"] == "low"
|
||||
assert body["data_quality"]["objects_total_in_radius"] == 0
|
||||
rec = body["recommendation_for_tz"]
|
||||
assert rec["based_on_obj_count"] == 0
|
||||
assert rec["based_on_total_deals"] == 0
|
||||
assert rec["mix"] == []
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_three_obj_ids_ranking_and_pct_sum_100() -> None:
|
||||
"""3 obj_id, 3 room_buckets — ranking по velocity, sum pct = 100."""
|
||||
id_rows = [_obj_id_row(1), _obj_id_row(2), _obj_id_row(3)]
|
||||
vel_rows = [
|
||||
_vel_row("studio", sum_deals=8.0, avg_area=26.0, obj_ids=[1]),
|
||||
_vel_row("1", sum_deals=32.0, avg_area=40.0, obj_ids=[2]),
|
||||
_vel_row("2", sum_deals=48.0, avg_area=55.0, obj_ids=[3]),
|
||||
]
|
||||
supply_rows = [
|
||||
_supply_row("studio", "25-40", 20),
|
||||
_supply_row("1", "40-60", 60),
|
||||
_supply_row("2", "40-60", 80),
|
||||
]
|
||||
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows, supply_rows=supply_rows)
|
||||
from app.core.db import get_db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
resp = _post(TestClient(app), time_window="last_quarter")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
top = body["top_layouts"]
|
||||
assert len(top) == 3
|
||||
# rank 1 = самая высокая velocity (2-комн: 48/8=6.0 per month)
|
||||
assert top[0]["rank"] == 1
|
||||
assert top[0]["room_bucket"] == "2"
|
||||
# все ранги уникальны
|
||||
assert sorted(t["rank"] for t in top) == [1, 2, 3]
|
||||
# sum pct = 100
|
||||
mix = body["recommendation_for_tz"]["mix"]
|
||||
assert sum(m["pct"] for m in mix) == 100
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_exclude_competitor_obj_ids_filter() -> None:
|
||||
"""exclude_competitor_obj_ids исключает obj_id: при all excluded → пустой ответ."""
|
||||
# Если после исключения obj_id_list пуст → _empty_response → top_layouts=[]
|
||||
id_rows = [_obj_id_row(20)] # единственный конкурент
|
||||
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=[])
|
||||
from app.core.db import get_db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
resp = _post(TestClient(app), exclude_competitor_obj_ids=[20])
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
# После исключения obj_id=20 список пуст → пустой ответ
|
||||
assert body["top_layouts"] == []
|
||||
assert body["data_quality"]["confidence"] == "low"
|
||||
# objects_total_in_radius = 1 (до исключения)
|
||||
assert body["data_quality"]["objects_total_in_radius"] == 1
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_min_velocity_per_month_filters_low_rows() -> None:
|
||||
"""min_velocity_per_month=5 → строки с velocity<5 не попадают в top_layouts."""
|
||||
id_rows = [_obj_id_row(1), _obj_id_row(2)]
|
||||
# last_quarter divisor=8 → 16/8=2.0 (ниже порога), 80/8=10.0 (выше)
|
||||
vel_rows = [
|
||||
_vel_row("studio", sum_deals=16.0, obj_ids=[1]),
|
||||
_vel_row("1", sum_deals=80.0, obj_ids=[2]),
|
||||
]
|
||||
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
|
||||
from app.core.db import get_db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
resp = _post(TestClient(app), min_velocity_per_month=5.0)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
top = body["top_layouts"]
|
||||
assert len(top) == 1
|
||||
assert top[0]["room_bucket"] == "1"
|
||||
assert top[0]["velocity_per_month"] == pytest.approx(10.0)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_time_window_velocity_scaling() -> None:
|
||||
"""last_month vs last_year дают разный velocity_per_month для одних deals."""
|
||||
# sum_deals=24 → last_month: 24/24=1.0, last_year: 24/2=12.0
|
||||
id_rows = [_obj_id_row(1)]
|
||||
vel_rows_fixed = [_vel_row("2", sum_deals=24.0, obj_ids=[1])]
|
||||
|
||||
from app.core.db import get_db
|
||||
|
||||
# last_month
|
||||
db_m = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows_fixed)
|
||||
app.dependency_overrides[get_db] = _override_db(db_m)
|
||||
try:
|
||||
resp_m = _post(TestClient(app), time_window="last_month")
|
||||
assert resp_m.status_code == 200, resp_m.text
|
||||
v_month = resp_m.json()["top_layouts"][0]["velocity_per_month"]
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
# last_year
|
||||
db_y = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows_fixed)
|
||||
app.dependency_overrides[get_db] = _override_db(db_y)
|
||||
try:
|
||||
resp_y = _post(TestClient(app), time_window="last_year")
|
||||
assert resp_y.status_code == 200, resp_y.text
|
||||
v_year = resp_y.json()["top_layouts"][0]["velocity_per_month"]
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
# last_year velocity должна быть выше (делитель меньше: 2 vs 24)
|
||||
assert v_year > v_month
|
||||
assert v_month == pytest.approx(1.0)
|
||||
assert v_year == pytest.approx(12.0)
|
||||
|
||||
|
||||
def test_obj_class_filter_passes_through() -> None:
|
||||
"""obj_class_filter передаётся в SQL — endpoint не ломается, возвращает 200."""
|
||||
db = _make_db(
|
||||
coord=_coord_row(),
|
||||
id_rows=[_obj_id_row(5)],
|
||||
vel_rows=[_vel_row("2", obj_ids=[5])],
|
||||
supply_rows=[],
|
||||
)
|
||||
from app.core.db import get_db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
resp = _post(TestClient(app), obj_class_filter="comfort")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert len(body["top_layouts"]) > 0
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_mv_empty_for_competitors_returns_empty_top_layouts() -> None:
|
||||
"""Конкуренты есть в радиусе, но MV пустой → top_layouts=[], data_quality.confidence=low."""
|
||||
id_rows = [_obj_id_row(1), _obj_id_row(2)]
|
||||
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=[])
|
||||
from app.core.db import get_db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
resp = _post(TestClient(app))
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["top_layouts"] == []
|
||||
dq = body["data_quality"]
|
||||
assert dq["objects_total_in_radius"] == 2
|
||||
assert dq["objects_with_velocity_data"] == 0
|
||||
assert dq["confidence"] == "low"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_target_total_flats_fills_abs_units() -> None:
|
||||
"""target_total_flats=100 → abs_units заполнен в mix, sum примерно = 100."""
|
||||
id_rows = [_obj_id_row(1), _obj_id_row(2)]
|
||||
vel_rows = [
|
||||
_vel_row("1", sum_deals=60.0, obj_ids=[1]),
|
||||
_vel_row("2", sum_deals=40.0, obj_ids=[2]),
|
||||
]
|
||||
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
|
||||
from app.core.db import get_db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
resp = _post(TestClient(app), target_total_flats=100)
|
||||
assert resp.status_code == 200, resp.text
|
||||
mix = resp.json()["recommendation_for_tz"]["mix"]
|
||||
# все abs_units заполнены
|
||||
for m in mix:
|
||||
assert m["abs_units"] is not None
|
||||
# сумма abs_units близка к 100 (round-off ±1)
|
||||
total_abs = sum(m["abs_units"] for m in mix)
|
||||
assert 98 <= total_abs <= 102
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_filter_competitor_obj_ids_applied() -> None:
|
||||
"""filter_competitor_obj_ids=[1] оставляет только obj_id=1."""
|
||||
id_rows = [_obj_id_row(1), _obj_id_row(2), _obj_id_row(3)]
|
||||
# После фильтрации остаётся только obj_id=1, velocity запрос получит [1]
|
||||
vel_rows = [_vel_row("2", sum_deals=24.0, obj_ids=[1])]
|
||||
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
|
||||
from app.core.db import get_db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
resp = _post(TestClient(app), filter_competitor_obj_ids=[1])
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
top = body["top_layouts"]
|
||||
assert len(top) >= 1
|
||||
# competitor_obj_ids должен содержать только 1
|
||||
for row in top:
|
||||
for oid in row["competitor_obj_ids"]:
|
||||
assert oid == 1
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
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"
|
||||
757
frontend/src/components/site-finder/BestLayoutsBlock.tsx
Normal file
757
frontend/src/components/site-finder/BestLayoutsBlock.tsx
Normal file
|
|
@ -0,0 +1,757 @@
|
|||
"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")} сделках · период{" "}
|
||||
{rec.data_window_start} — {rec.data_window_end}
|
||||
</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 ? parsed : 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 { 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>
|
||||
);
|
||||
|
|
|
|||
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),
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
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