From 256909d28b6452e57a4707f64bb8410fe22b0b37 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 09:53:05 +0000 Subject: [PATCH] =?UTF-8?q?feat(layouts):=20PDF=20=D0=A2=D0=97=20endpoint?= =?UTF-8?q?=20+=20BestLayoutsBlock=20UI=20(#113=20PR=20D)=20(#199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/parcels.py | 36 + .../app/services/exporters/layout_tz_pdf.py | 161 ++++ backend/tests/test_layout_tz_pdf.py | 136 ++++ .../site-finder/BestLayoutsBlock.tsx | 761 ++++++++++++++++++ .../src/components/site-finder/MarketTab.tsx | 4 + frontend/src/hooks/useBestLayouts.ts | 29 + frontend/src/types/best-layouts.ts | 63 ++ 7 files changed, 1190 insertions(+) create mode 100644 backend/app/services/exporters/layout_tz_pdf.py create mode 100644 backend/tests/test_layout_tz_pdf.py create mode 100644 frontend/src/components/site-finder/BestLayoutsBlock.tsx create mode 100644 frontend/src/hooks/useBestLayouts.ts create mode 100644 frontend/src/types/best-layouts.ts diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index cf9d82dd..075983ab 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -24,6 +24,7 @@ from app.schemas.parcel import ( ParcelSearchRequest, ParcelSearchResponse, ) +from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf from app.services.site_finder.best_layouts import get_best_layouts from app.services.site_finder.cadastre_fetch import ( cad_exists_in_db, @@ -2130,3 +2131,38 @@ async def get_parcel_best_layouts( except Exception as exc: logger.error("best_layouts endpoint failed for %s: %s", cad_num, exc) raise HTTPException(status_code=500, detail="Internal server error") from exc + + +@router.post("/{cad_num}/best-layouts/pdf") +async def get_parcel_best_layouts_pdf( + cad_num: str, + body: BestLayoutsRequest, + db: Annotated[Session, Depends(get_db)], +) -> Response: + """ТЗ на проектирование (PDF) — генерируется из /best-layouts данных. + + Issue #113 Phase 2.1: data-driven unit-mix recommendation для тендера. + """ + try: + response = get_best_layouts(db=db, cad_num=cad_num, request=body) + pdf_bytes = render_layout_tz_pdf( + response, + cad_num=cad_num, + radius_km=body.radius_km, + time_window=body.time_window, + ) + today = _dt.date.today().strftime("%Y-%m-%d") + cad_safe = cad_num.replace(":", "-") + filename = f"tz-layout-{cad_safe}-{today}.pdf" + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + except HTTPException: + raise + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except Exception as exc: + logger.error("best_layouts PDF endpoint failed for %s: %s", cad_num, exc) + raise HTTPException(status_code=500, detail="Internal server error") from exc diff --git a/backend/app/services/exporters/layout_tz_pdf.py b/backend/app/services/exporters/layout_tz_pdf.py new file mode 100644 index 00000000..96178212 --- /dev/null +++ b/backend/app/services/exporters/layout_tz_pdf.py @@ -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"

Адрес: {safe_addr}

" if safe_addr else "" + + def _price_cell(val: float | None) -> str: + if val is None: + return "—" + return f"{val:,.0f}".replace(",", " ") + " ₽" + + # Top layouts table rows + top_rows = "".join( + "" + f"{r.rank}" + f"{_html.escape(r.room_bucket)}" + f"{_html.escape(r.area_bin)}" + f"{r.velocity_per_month:.1f}" + f"{r.avg_area_m2:.1f}" + f"{_price_cell(r.avg_price_per_m2_rub)}" + f"{r.total_sold_in_window}" + "" + for r in response.top_layouts + ) + + # Recommendation mix table rows + mix_rows = "".join( + "" + f"{_html.escape(m.room_bucket)}" + f"{m.pct}%" + f"{m.abs_units if m.abs_units is not None else '—'}" + f"{f'{m.avg_target_area_m2:.1f}' if m.avg_target_area_m2 is not None else '—'}" + "" + 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""" + + + +ТЗ на проектирование — {safe_cad} + + + +

Техническое задание на проектирование (data-driven)

+
+

Кадастровый номер: {safe_cad}

+ {addr_line} +

Радиус анализа: {radius_km} км · Окно: {safe_time_window}

+

Дата формирования: {today}

+
+ +

Рекомендуемая структура квартирографии (unit-mix)

+
{safe_rationale}
+ + + + + {mix_rows} +
КомнатностьДоляКол-во (от target)Целевая площадь, м²
+

Средневзвешенная цена benchmark: {weighted_price}

+

Основано на {rec.based_on_obj_count} ЖК / {rec.based_on_total_deals} сделок

+

Период данных: + {rec.data_window_start.strftime("%d.%m.%Y")} – {rec.data_window_end.strftime("%d.%m.%Y")} +

+ +

Топ планировок конкурентов по продажам

+ + + + + + {top_rows} +
#КомнатыПлощадьПродажи/месСр. площадь, м²Ср. цена, ₽/м²Продано (окно)
+ +

Качество данных

+

+ Покрытие: {dq.objects_with_velocity_data} из + {dq.objects_total_in_radius} ЖК с данными velocity + ({dq.velocity_coverage_pct:.1f}%) +

+

+ Уверенность: + + {dq.confidence.upper()} + +

+ + + +""" + + 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 diff --git a/backend/tests/test_layout_tz_pdf.py b/backend/tests/test_layout_tz_pdf.py new file mode 100644 index 00000000..1fbe2dd2 --- /dev/null +++ b/backend/tests/test_layout_tz_pdf.py @@ -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" diff --git a/frontend/src/components/site-finder/BestLayoutsBlock.tsx b/frontend/src/components/site-finder/BestLayoutsBlock.tsx new file mode 100644 index 00000000..63224696 --- /dev/null +++ b/frontend/src/components/site-finder/BestLayoutsBlock.tsx @@ -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 = { + last_month: "Последний месяц", + last_quarter: "Последний квартал", + last_year: "Последний год", +}; + +const ROOM_BUCKET_LABELS: Record = { + 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 ( +
+ + Качество данных: + + + {style.label} + + + Покрытие {dq.velocity_coverage_pct.toFixed(0)}% ( + {dq.objects_with_velocity_data} из {dq.objects_total_in_radius} ЖК) + +
+ ); +} + +function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) { + if (rows.length === 0) { + return ( +
+ Данных недостаточно для ранжирования планировок +
+ ); + } + + const headers = [ + "#", + "Тип", + "Площадь", + "Скорость / мес", + "Средн. площадь, м²", + "Средн. цена, ₽/м²", + "Продано, %", + ]; + + return ( +
+
+ Топ планировок ({rows.length}) +
+
+ + + + {headers.map((h) => ( + + ))} + + + + {rows.map((row, i) => ( + + + + + + + + + + ))} + +
+ {h} +
+ {row.rank} + + {ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket} + + {row.area_bin} м² + + {row.velocity_per_month.toFixed(2)} + + {row.avg_area_m2.toFixed(1)} + + {row.avg_price_per_m2_rub != null + ? Math.round(row.avg_price_per_m2_rub).toLocaleString( + "ru-RU", + ) + : "—"} + + {row.sold_pct_of_supply != null + ? `${(row.sold_pct_of_supply ?? 0).toFixed(0)}%` + : "—"} +
+
+
+ ); +} + +function UnitMixBar({ mix }: { mix: LayoutTzMixRow[] }) { + const COLORS = [ + "#1d4ed8", + "#7c3aed", + "#059669", + "#d97706", + "#dc2626", + "#0891b2", + ]; + + return ( +
+ {/* Horizontal stacked bar */} +
+ {mix.map((row, i) => ( +
+ ))} +
+ {/* Legend */} +
+ {mix.map((row, i) => ( +
+
+ + {ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket} {row.pct} + % + +
+ ))} +
+
+ ); +} + +function MixTable({ mix }: { mix: LayoutTzMixRow[] }) { + return ( + + + + {["Тип", "Доля, %", "Кол-во квартир", "Ср. площадь, м²"].map((h) => ( + + ))} + + + + {mix.map((row, i) => ( + + + + + + + ))} + +
+ {h} +
+ {ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket} + + {row.pct}% + + {row.abs_units != null + ? row.abs_units.toLocaleString("ru-RU") + : "—"} + + {row.avg_target_area_m2 != null + ? row.avg_target_area_m2.toFixed(1) + : "—"} +
+ ); +} + +function RecommendationCard({ + rec, +}: { + rec: BestLayoutsResponse["recommendation_for_tz"]; +}) { + return ( +
+
+ Рекомендация ТЗ +
+
+ {/* Rationale text — plain text only, no dangerouslySetInnerHTML */} +

+ {rec.rationale_text} +

+ + {/* Unit-mix bar chart */} + {rec.mix.length > 0 && ( +
+
+ Unit-mix +
+ +
+ )} + + {/* Mix table */} + {rec.mix.length > 0 && ( +
+ +
+ )} + + {/* Weighted avg price */} + {rec.weighted_avg_price_per_m2_rub != null && ( +
+ Средневзвешенная цена: + + {Math.round(rec.weighted_avg_price_per_m2_rub).toLocaleString( + "ru-RU", + )}{" "} + ₽/м² + +
+ )} + + {/* Meta */} +
+ Основано на {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")} +
+
+
+ ); +} + +// ── Main component ───────────────────────────────────────────────────────────── + +interface Props { + cadNum: string; + selectedCompetitorObjIds?: number[]; +} + +export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) { + const [radiusKm, setRadiusKm] = useState(1.0); + const [timeWindow, setTimeWindow] = useState("last_quarter"); + const [targetTotalFlats, setTargetTotalFlats] = useState("300"); + const [minVelocity, setMinVelocity] = useState(0.5); + const [isPdfLoading, setIsPdfLoading] = useState(false); + const [pdfError, setPdfError] = useState(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 ( +
+ {/* Header */} +
+
+ + Анализ планировок + + + data-driven ТЗ на проектирование + +
+ {data && ( +
+ + {pdfError && ( + PDF: {pdfError} + )} +
+ )} +
+ + {/* Controls */} +
+ {/* Radius slider */} +
+ + setRadiusKm(parseFloat(e.target.value))} + style={{ width: 160, accentColor: "#1d4ed8" }} + /> +
+ + {/* Min velocity slider */} +
+ + setMinVelocity(parseFloat(e.target.value))} + style={{ width: 160, accentColor: "#1d4ed8" }} + /> +
+ + {/* Time window radio */} +
+ + Период анализа + +
+ {(Object.keys(TIME_WINDOW_LABELS) as TimeWindow[]).map((tw) => ( + + ))} +
+
+ + {/* Target flats input */} +
+ + setTargetTotalFlats(e.target.value)} + placeholder="300" + style={{ + padding: "5px 10px", + border: "1px solid #d1d5db", + borderRadius: 6, + fontSize: 13, + width: 110, + color: "#111827", + }} + /> +
+ + {/* Calculate button */} + + + {selectedCompetitorObjIds && selectedCompetitorObjIds.length > 0 && ( + + Фильтр: {selectedCompetitorObjIds.length} выбр. ЖК + + )} +
+ + {/* Content area */} +
+ {/* Loading skeleton */} + {isPending && ( +
+ {[80, 60, 40].map((w) => ( +
+ ))} +
+ )} + + {/* Error */} + {error && !isPending && ( +
+ {error instanceof Error ? error.message : "Ошибка получения данных"} +
+ )} + + {/* Results */} + {data && !isPending && ( +
+ + + +
+ )} + + {/* Idle state */} + {!isPending && !error && !data && ( +
+ Настройте параметры и нажмите «Рассчитать» +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/site-finder/MarketTab.tsx b/frontend/src/components/site-finder/MarketTab.tsx index 4b408e1b..cfbc4df5 100644 --- a/frontend/src/components/site-finder/MarketTab.tsx +++ b/frontend/src/components/site-finder/MarketTab.tsx @@ -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) {
)} + {/* Issue #113 — data-driven ТЗ на проектирование */} + + {!hasAny && }
); diff --git a/frontend/src/hooks/useBestLayouts.ts b/frontend/src/hooks/useBestLayouts.ts new file mode 100644 index 00000000..f8928ff5 --- /dev/null +++ b/frontend/src/hooks/useBestLayouts.ts @@ -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 => + apiFetch( + `/api/v1/parcels/${encodeURIComponent(cadNum)}/best-layouts`, + { + method: "POST", + body: JSON.stringify(body), + }, + ), + }); +} diff --git a/frontend/src/types/best-layouts.ts b/frontend/src/types/best-layouts.ts new file mode 100644 index 00000000..15015fab --- /dev/null +++ b/frontend/src/types/best-layouts.ts @@ -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; +}