feat(layouts): PDF ТЗ endpoint + BestLayoutsBlock UI (#113 PR D) (#199)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m38s
Deploy / build-worker (push) Successful in 3m3s
Deploy / build-frontend (push) Successful in 2m8s
Deploy / deploy (push) Successful in 38s

This commit is contained in:
lekss361 2026-05-16 09:53:05 +00:00
parent ad16fc0e42
commit 256909d28b
7 changed files with 1190 additions and 0 deletions

View file

@ -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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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;
}