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)
+
+
+ Рекомендуемая структура квартирографии (unit-mix)
+ {safe_rationale}
+
+
+ | Комнатность | Доля | Кол-во (от target) | Целевая площадь, м² |
+
+ {mix_rows}
+
+ Средневзвешенная цена 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) => (
+ |
+ {h}
+ |
+ ))}
+
+
+
+ {rows.map((row, i) => (
+
+ |
+ {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) => (
+ |
+ {h}
+ |
+ ))}
+
+
+
+ {mix.map((row, i) => (
+
+ |
+ {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 && (
+
+ )}
+
+ {/* 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;
+}