From 5878596bc5538b9f5252bed48129b28d65bdeead Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 12:36:24 +0300 Subject: [PATCH 1/4] =?UTF-8?q?feat(layouts):=20PDF=20=D0=A2=D0=97=20endpo?= =?UTF-8?q?int=20+=20BestLayoutsBlock=20UI=20(#113=20PR=20D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (~330 LOC): - POST /api/v1/parcels/{cad_num}/best-layouts/pdf via WeasyPrint - services/exporters/layout_tz_pdf.py — HTML template с XSS-escape (html.escape) для cad_num/parcel_address/rationale_text/time_window - 5 unit tests (skip on Windows если WeasyPrint deps missing) Frontend (~800 LOC): - BestLayoutsBlock.tsx — DataQualityCard + TopLayoutsTable + RecommendationCard + PDF download button (Blob → revokeObjectURL) - useBestLayouts.ts — TanStack useMutation hook - types/best-layouts.ts — manual TS types - MarketTab.tsx +4 LOC integration после competitors Pre-push fixes per review: - XSS escape user-data в PDF HTML - sold_pct_of_supply — убрать double ×100 (backend уже 0..100) - alert() → pdfError state - isNaN → Number.isNaN - 7 inline-styles → Tailwind utilities Phase 2.1 complete. Phase 2.2 (layout_type/balcony_count via B2B Объектив #52) — follow-up issue. --- backend/app/api/v1/parcels.py | 34 + .../app/services/exporters/layout_tz_pdf.py | 161 ++++ backend/tests/test_layout_tz_pdf.py | 136 ++++ .../site-finder/BestLayoutsBlock.tsx | 757 ++++++++++++++++++ .../src/components/site-finder/MarketTab.tsx | 4 + frontend/src/hooks/useBestLayouts.ts | 29 + frontend/src/types/best-layouts.ts | 63 ++ 7 files changed, 1184 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..de982a43 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,36 @@ 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 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..2e169630 --- /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"{r.room_bucket}" + f"{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"{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..79f0ba4c --- /dev/null +++ b/frontend/src/components/site-finder/BestLayoutsBlock.tsx @@ -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 = { + 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")} сделках · период{" "} + {rec.data_window_start} — {rec.data_window_end} +
+
+
+ ); +} + +// ── 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 ? 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 ( +
+ {/* 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; +} -- 2.45.3 From 434341e98f9801e06007a421d4ff903c80457fe8 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 12:50:10 +0300 Subject: [PATCH 2/4] fix(layouts): address review-bot minor items (#113 PR D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (parcels.py + layout_tz_pdf.py): - 🟡 /best-layouts/pdf 404 handling: catch ValueError → 404 (consistent с JSON endpoint handling геометрия не найдена) - 🟡 XSS defense-in-depth: html.escape() для r.room_bucket, r.area_bin, m.room_bucket в HTML row generators Frontend (BestLayoutsBlock.tsx): - 🟡 data_window dates → ru-RU locale (toLocaleDateString) - 🟢 target_total_flats clamped к [1, 10000] перед submit (paste-bypass на client защищён, backend Pydantic le=10000) Tests: 5+27 backend pass, frontend tsc/lint/build clean. --- backend/app/api/v1/parcels.py | 2 ++ backend/app/services/exporters/layout_tz_pdf.py | 6 +++--- frontend/src/components/site-finder/BestLayoutsBlock.tsx | 8 ++++++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index de982a43..075983ab 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -2161,6 +2161,8 @@ async def get_parcel_best_layouts_pdf( ) 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 index 2e169630..96178212 100644 --- a/backend/app/services/exporters/layout_tz_pdf.py +++ b/backend/app/services/exporters/layout_tz_pdf.py @@ -51,8 +51,8 @@ def render_layout_tz_pdf( top_rows = "".join( "" f"{r.rank}" - f"{r.room_bucket}" - f"{r.area_bin}" + 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)}" @@ -64,7 +64,7 @@ def render_layout_tz_pdf( # Recommendation mix table rows mix_rows = "".join( "" - f"{m.room_bucket}" + 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 '—'}" diff --git a/frontend/src/components/site-finder/BestLayoutsBlock.tsx b/frontend/src/components/site-finder/BestLayoutsBlock.tsx index 79f0ba4c..63224696 100644 --- a/frontend/src/components/site-finder/BestLayoutsBlock.tsx +++ b/frontend/src/components/site-finder/BestLayoutsBlock.tsx @@ -411,7 +411,8 @@ function RecommendationCard({
Основано на {rec.based_on_obj_count} ЖК ·{" "} {rec.based_on_total_deals.toLocaleString("ru-RU")} сделках · период{" "} - {rec.data_window_start} — {rec.data_window_end} + {new Date(rec.data_window_start).toLocaleDateString("ru-RU")} —{" "} + {new Date(rec.data_window_end).toLocaleDateString("ru-RU")}
@@ -445,7 +446,10 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) { ? selectedCompetitorObjIds : null, min_velocity_per_month: minVelocity, - target_total_flats: !Number.isNaN(parsed) && parsed > 0 ? parsed : null, + target_total_flats: + !Number.isNaN(parsed) && parsed > 0 + ? Math.min(Math.max(parsed, 1), 10000) + : null, }; } -- 2.45.3 From 038e39e2ec5a37ba248f44d3287e1e745652cc39 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 13:39:14 +0300 Subject: [PATCH 3/4] =?UTF-8?q?feat(site-finder):=20inline=20POI=20weights?= =?UTF-8?q?=20pass-through=20=D0=B2=20/analyze=20(#201=20Phase=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical UX fix для #114 — user-drag слайдеры в WeightProfilePanel теперь применяются immediately к scoring, без обязательного profile save. Backend (parcels.py + schemas/parcel.py + tests): - POST /analyze принимает optional AnalyzeRequest { weights: dict[str,float] | None } - Priority: inline → profile_id → user_default → system defaults - Validate against ALLOWED_CATEGORIES + [MIN_WEIGHT, MAX_WEIGHT] → 422 на violation - Partial override semantics - 5 mock tests Frontend (useSiteAnalysis.ts + page.tsx): - weights param в analyze mutation - handleAnalyze всегда передаёт currentWeights когда activeProfileId=null - handleWeightsChange re-trigger analyze immediately если parcel loaded Phase 2 (debounce) + Phase 3 (Edit/Delete UI) — follow-up. --- backend/app/api/v1/parcels.py | 64 +++- backend/app/schemas/parcel.py | 21 ++ .../api/v1/test_analyze_inline_weights.py | 302 ++++++++++++++++++ frontend/src/app/site-finder/page.tsx | 24 +- frontend/src/hooks/useSiteAnalysis.ts | 23 ++ 5 files changed, 419 insertions(+), 15 deletions(-) create mode 100644 backend/tests/api/v1/test_analyze_inline_weights.py diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 075983ab..25f718a0 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -6,7 +6,7 @@ import time from typing import Annotated, Any import httpx -from fastapi import APIRouter, Depends, HTTPException, Query, Response +from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response from shapely import wkt as _shp_wkt from shapely.geometry import Polygon from sqlalchemy import text @@ -15,6 +15,7 @@ from sqlalchemy.orm import Session from app.core.config import settings from app.core.db import get_db from app.schemas.parcel import ( + AnalyzeRequest, BestLayoutsRequest, BestLayoutsResponse, CompetitorsRequest, @@ -41,6 +42,18 @@ from app.services.site_finder.quarter_dump_lookup import ( make_empty_result, ) from app.services.site_finder.velocity import compute_velocity +from app.services.site_finder.weight_profiles import ( + ALLOWED_CATEGORIES as _ALLOWED_CATEGORIES, +) +from app.services.site_finder.weight_profiles import ( + MAX_WEIGHT as _MAX_WEIGHT, +) +from app.services.site_finder.weight_profiles import ( + MIN_WEIGHT as _MIN_WEIGHT, +) +from app.services.site_finder.weight_profiles import ( + resolve_weights as _resolve_weights, +) logger = logging.getLogger(__name__) @@ -1048,6 +1061,10 @@ def analyze_parcel( str | None, Query(description="user_id для fallback на default-профиль пользователя"), ] = None, + body: Annotated[ + AnalyzeRequest | None, + Body(description="Опциональное тело запроса: inline POI-веса (#201)"), + ] = None, ) -> dict[str, Any]: """Анализ участка: близость к социалке + district context + конкуренты. @@ -1223,15 +1240,41 @@ def analyze_parcel( .all() ) - # 3b) Resolve effective POI weights (profile → user default → system) - from app.services.site_finder.weight_profiles import resolve_weights as _resolve_weights + # 3b) Resolve effective POI weights (inline → profile → user default → system) + _inline_weights: dict[str, float] | None = body.weights if body is not None else None - _effective_weights = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id) - _weights_source = ( - "profile" - if profile_id is not None - else ("user_default" if profile_user_id is not None else "system") - ) + if _inline_weights is not None: + # Validate inline weights: keys и диапазон значений (#201) + bad_keys = set(_inline_weights.keys()) - _ALLOWED_CATEGORIES + if bad_keys: + raise HTTPException( + status_code=422, + detail=( + f"Неизвестные POI-категории: {sorted(bad_keys)}. " + f"Допустимые: {sorted(_ALLOWED_CATEGORIES)}" + ), + ) + out_of_range = { + k: v for k, v in _inline_weights.items() if v < _MIN_WEIGHT or v > _MAX_WEIGHT + } + if out_of_range: + raise HTTPException( + status_code=422, + detail=( + f"Веса за пределами допустимого диапазона " + f"[{_MIN_WEIGHT}, {_MAX_WEIGHT}]: {out_of_range}" + ), + ) + # Inline weights applied — merge поверх системных defaults (partial override) + _effective_weights = {**_POI_WEIGHTS, **_inline_weights} + _weights_source = "inline" + else: + _effective_weights = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id) + _weights_source = ( + "profile" + if profile_id is not None + else ("user_default" if profile_user_id is not None else "system") + ) # 4) Scoring: weighted sum с distance decay score = 0.0 @@ -1926,12 +1969,13 @@ def analyze_parcel( nspd_engineering_nearby=nspd_dump_data["nspd_engineering_nearby"], nspd_dump=nspd_dump_data["nspd_dump"], ), - # #114: кастомные веса POI — source + applied dict для прозрачности. + # #114/#201: кастомные веса POI — source + applied dict для прозрачности. "weights_profile": { "source": _weights_source, "profile_id": profile_id, "user_id": profile_user_id, "weights_applied": _effective_weights, + "inline_weights": _inline_weights, }, } diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 0a948e67..851904cd 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -213,3 +213,24 @@ class BestLayoutsResponse(BaseModel): top_layouts: list[TopLayoutRow] recommendation_for_tz: LayoutTzRecommendation data_quality: LayoutDataQuality + + +# ── Analyze endpoint inline weights (#201) ──────────────────────────────────── + + +class AnalyzeRequest(BaseModel): + """Опциональное тело запроса POST /analyze. + + Позволяет передать inline POI-веса напрямую в запросе без сохранения + профиля. Если задан weights — применяется с наивысшим приоритетом + (выше profile_id и user default). + """ + + weights: dict[str, float] | None = Field( + default=None, + description=( + "Inline POI weights override (категория → weight). " + "Если задан — применяется к scoring, без обязательного profile save. " + "Validated против ALLOWED_CATEGORIES + MIN_WEIGHT/MAX_WEIGHT." + ), + ) diff --git a/backend/tests/api/v1/test_analyze_inline_weights.py b/backend/tests/api/v1/test_analyze_inline_weights.py new file mode 100644 index 00000000..d4fcdcc4 --- /dev/null +++ b/backend/tests/api/v1/test_analyze_inline_weights.py @@ -0,0 +1,302 @@ +"""Тесты для inline POI-weights в POST /api/v1/parcels/{cad_num}/analyze (#201). + +Покрывает: +1. POST /analyze без body → system defaults (no regression) +2. POST /analyze с inline weights → applied (source = "inline") +3. POST /analyze с невалидной категорией → 422 +4. POST /analyze с весом вне диапазона → 422 +5. POST /analyze с body.weights + profile_id → body.weights wins (priority) + +Стратегия mock: DB патчим через dependency_overrides, тяжёлые service-функции +(weather, velocity, dump и т.д.) патчим через unittest.mock.patch — чтобы не +дублировать все 18 db.execute call'ов в каждом тесте. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + +# ── Константы ───────────────────────────────────────────────────────────────── + +_CAD = "66:41:0204016:10" +_WKT = "POLYGON((60.6 56.838, 60.61 56.838, 60.61 56.845, 60.6 56.845, 60.6 56.838))" +_GEOJSON = '{"type":"Polygon","coordinates":[[[60.6,56.838],[60.61,56.838]]]}' + + +# ── Mock factories ───────────────────────────────────────────────────────────── + + +def _make_mapping(data: dict[str, Any]) -> MagicMock: + """Создать mock-строку (mapping) с __getitem__ + .get() для dict-like доступа.""" + m = MagicMock() + m.__getitem__ = lambda self, k: data[k] + m.get = lambda k, default=None: data.get(k, default) + return m + + +def _make_db_for_analyze( + geom_found: bool = True, + district_found: bool = True, + poi_rows: list[Any] | None = None, +) -> MagicMock: + """Сконструировать mock DB Session для analyze_parcel. + + Порядок db.execute calls в analyze_parcel: + 0. UNION ALL geom + source → .mappings().first() + 1. WKT query → .mappings().first() + 2. District → .mappings().first() + 3. POI rows → .mappings().all() + 4. Competitor rows → .mappings().all() + 5. Pipeline rows → .mappings().all() + 6. Centroid lat/lon → .mappings().first() + 7. Noise rows → .mappings().all() + 8. Hydrology → .mappings().all() + 9. Utilities → .mappings().all() + 10. Market trend → .mappings().first() + 11. Zoning (begin_nested) → .mappings().first() + 12. Success recommendation (begin_nested) → .mappings().all() + 13. _geotech_risk (industrial count) → .scalar() + 14. _neighbors_summary (neighbor_rows) → .mappings().all() + 15. _neighbors_summary (overlap_row) → .mappings().first() + + begin_nested() — возвращаем context manager чтобы поддержать `with` statement. + """ + db = MagicMock() + + geom_row = ( + _make_mapping({"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"}) + if geom_found + else None + ) + wkt_row = _make_mapping({"wkt": _WKT}) if geom_found else None + district_row = ( + _make_mapping( + { + "district_name": "Октябрьский", + "median_price_per_m2": 120000, + "dist_to_center": 1500.0, + } + ) + if district_found + else None + ) + centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605}) + + _poi_rows = poi_rows or [] + + # Счётчик вызовов execute — разводим first() / all() / scalar() по очерёдности + call_idx = [0] + # Ответы в порядке вызовов: + responses: list[Any] = [ + ("first", geom_row), # 0: geom UNION ALL + ("first", wkt_row), # 1: WKT + ("first", district_row), # 2: district + ("all", _poi_rows), # 3: POI rows + ("all", []), # 4: competitor rows + ("all", []), # 5: pipeline rows + ("first", centroid_row), # 6: centroid + ("all", []), # 7: noise rows + ("all", []), # 8: hydrology rows + ("all", []), # 9: utilities rows + ("first", None), # 10: market trend + ("first", None), # 11: zoning (inside begin_nested) + ("all", []), # 12: success recommendation (inside begin_nested) + ("scalar", 0), # 13: geotech_risk industrial count + ("all", []), # 14: neighbors + ("first", None), # 15: overlap + ] + + def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: + idx = call_idx[0] + call_idx[0] += 1 + if idx >= len(responses): + # Безопасный fallback для непредусмотренных вызовов + r = MagicMock() + r.mappings.return_value.first.return_value = None + r.mappings.return_value.all.return_value = [] + r.scalar.return_value = 0 + return r + kind, data = responses[idx] + r = MagicMock() + r.mappings.return_value.first.return_value = data + r.mappings.return_value.all.return_value = data if isinstance(data, list) else [] + r.scalar.return_value = data if kind == "scalar" else 0 + return r + + db.execute.side_effect = _execute_side_effect + + # begin_nested() → context manager, остальные execute внутри него проходят + # через тот же side_effect (because db.execute is the same mock). + ctx = MagicMock() + ctx.__enter__ = MagicMock(return_value=ctx) + ctx.__exit__ = MagicMock(return_value=False) + db.begin_nested.return_value = ctx + + return db + + +def _override_db(db: MagicMock): + def _get_db_override(): + yield db + + return _get_db_override + + +# Патчим тяжёлые внешние вызовы (weather / velocity / nspd-dump), +# чтобы тесты не зависели от сети и не требовали полного mock DB. +_PATCHES = [ + patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), + patch("app.api.v1.parcels._fetch_weather_sync", return_value=None), + patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None), + patch( + "app.api.v1.parcels.get_quarter_dump_data", + return_value={ + "nspd_zoning": None, + "nspd_zouit_overlaps": [], + "nspd_engineering_nearby": [], + "nspd_dump": {"available": False, "stale": False, "harvest_triggered": False}, + }, + ), + patch("app.api.v1.parcels.compute_velocity", return_value=None), + patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}), +] + + +def _start_patches() -> list[Any]: + started = [p.start() for p in _PATCHES] + return started + + +def _stop_patches() -> None: + for p in _PATCHES: + p.stop() + + +# ── Тесты ───────────────────────────────────────────────────────────────────── + + +def test_analyze_no_body_uses_system_defaults() -> None: + """POST /analyze без body → source = 'system', нет регрессии.""" + from app.core.db import get_db + + db = _make_db_for_analyze() + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + try: + client = TestClient(app) + resp = client.post(f"/api/v1/parcels/{_CAD}/analyze") + assert resp.status_code == 200, resp.text + body = resp.json() + assert "weights_profile" in body + assert body["weights_profile"]["source"] == "system" + assert body["weights_profile"]["inline_weights"] is None + finally: + app.dependency_overrides.clear() + _stop_patches() + + +def test_analyze_inline_weights_applied() -> None: + """POST /analyze с body.weights → source = 'inline', веса применены.""" + from app.core.db import get_db + + db = _make_db_for_analyze() + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + try: + client = TestClient(app) + resp = client.post( + f"/api/v1/parcels/{_CAD}/analyze", + json={"weights": {"kindergarten": 2.5}}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + wp = body["weights_profile"] + assert wp["source"] == "inline" + assert wp["inline_weights"] == {"kindergarten": 2.5} + # applied weights содержат inline override поверх defaults + assert wp["weights_applied"]["kindergarten"] == pytest.approx(2.5) + finally: + app.dependency_overrides.clear() + _stop_patches() + + +def test_analyze_invalid_category_returns_422() -> None: + """POST /analyze с невалидной POI-категорией → 422.""" + from app.core.db import get_db + + db = _make_db_for_analyze() + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + try: + client = TestClient(app) + resp = client.post( + f"/api/v1/parcels/{_CAD}/analyze", + json={"weights": {"nonexistent_category": 1.0}}, + ) + assert resp.status_code == 422, resp.text + detail = resp.json()["detail"] + assert "nonexistent_category" in detail + finally: + app.dependency_overrides.clear() + _stop_patches() + + +def test_analyze_weight_out_of_range_returns_422() -> None: + """POST /analyze с весом вне [-2, 3] → 422.""" + from app.core.db import get_db + + db = _make_db_for_analyze() + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + try: + client = TestClient(app) + # Слишком большой вес + resp = client.post( + f"/api/v1/parcels/{_CAD}/analyze", + json={"weights": {"school": 99.9}}, + ) + assert resp.status_code == 422, resp.text + detail = resp.json()["detail"] + assert "school" in detail + + # Слишком маленький вес + resp2 = client.post( + f"/api/v1/parcels/{_CAD}/analyze", + json={"weights": {"park": -5.0}}, + ) + assert resp2.status_code == 422, resp2.text + assert "park" in resp2.json()["detail"] + finally: + app.dependency_overrides.clear() + _stop_patches() + + +def test_analyze_inline_weights_beats_profile_id() -> None: + """body.weights + profile_id → body.weights имеет приоритет (source = 'inline').""" + from app.core.db import get_db + + db = _make_db_for_analyze() + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + try: + client = TestClient(app) + # Передаём и profile_id=1, и inline weights — inline должен победить + resp = client.post( + f"/api/v1/parcels/{_CAD}/analyze?profile_id=1", + json={"weights": {"metro_stop": 2.0}}, + ) + assert resp.status_code == 200, resp.text + wp = resp.json()["weights_profile"] + assert wp["source"] == "inline", f"Ожидали source='inline', получили '{wp['source']}'" + assert wp["weights_applied"]["metro_stop"] == pytest.approx(2.0) + # profile_id всё ещё присутствует в ответе для трассировки + assert wp["profile_id"] == 1 + finally: + app.dependency_overrides.clear() + _stop_patches() diff --git a/frontend/src/app/site-finder/page.tsx b/frontend/src/app/site-finder/page.tsx index faeddd61..5f914ca2 100644 --- a/frontend/src/app/site-finder/page.tsx +++ b/frontend/src/app/site-finder/page.tsx @@ -147,14 +147,18 @@ function SiteFinderContent() { function handleAnalyze(cadNum: string) { setIsochrones(undefined); setTab("overview"); + // Priority: named profile → user default profile → inline draft weights. + // When activeProfileId is set, backend uses that profile (ignores inline). + // When no profile is selected, pass currentWeights as inline so draft + // slider values are always respected even without a saved profile (#201). mutate({ cad: cadNum, options: activeProfileId != null ? { profileId: activeProfileId } : profileUserId - ? { profileUserId } - : undefined, + ? { profileUserId, weights: currentWeights } + : { weights: currentWeights }, }); } @@ -163,10 +167,20 @@ function SiteFinderContent() { profileId: number | null, ) { setCurrentWeights(weights); - // Store the active profile id so we can pass it to the analyze call. - // If user edited weights without saving a named profile, profileId=null - // and backend will use system defaults (sub-PR 5 would enable inline weights). setActiveProfileId(profileId); + // Re-analyze with the new weights if a parcel is already loaded (#201). + if (data?.cad_num) { + setIsochrones(undefined); + mutate({ + cad: data.cad_num, + options: + profileId != null + ? { profileId } + : profileUserId + ? { profileUserId, weights } + : { weights }, + }); + } } // Derive KPI values from data diff --git a/frontend/src/hooks/useSiteAnalysis.ts b/frontend/src/hooks/useSiteAnalysis.ts index 7a2c064c..7abd1a64 100644 --- a/frontend/src/hooks/useSiteAnalysis.ts +++ b/frontend/src/hooks/useSiteAnalysis.ts @@ -44,6 +44,11 @@ export interface AnalyzeOptions { profileId?: number; /** If set together with no profileId, backend uses user's default profile. */ profileUserId?: string; + /** + * Inline POI weights override — sent as request body. + * Priority: inline → profileId → profileUserId default → system. + */ + weights?: Record | null; } /** @@ -90,11 +95,23 @@ export function useSiteAnalysis() { return qsStr ? `${base}?${qsStr}` : base; }; + // Build optional JSON body for inline weights (#201). + const bodyPayload = + options?.weights != null + ? JSON.stringify({ weights: options.weights }) + : undefined; + // First request — POST /analyze const first = await apiFetchWithStatus< ParcelAnalysis | AnalyzeAcceptedResponse >(analyzeUrl(cad), { method: "POST", + ...(bodyPayload + ? { + body: bodyPayload, + headers: { "Content-Type": "application/json" }, + } + : {}), }); if (first.status === 200) { @@ -127,6 +144,12 @@ export function useSiteAnalysis() { // mutation сразу резолвится с data — render skipped. const second = await apiFetch(analyzeUrl(cad), { method: "POST", + ...(bodyPayload + ? { + body: bodyPayload, + headers: { "Content-Type": "application/json" }, + } + : {}), }); setFetchingState(null); return second; -- 2.45.3 From cada96e874e1ef9e8a479c0f41f9408a429e52e0 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 13:53:46 +0300 Subject: [PATCH 4/4] fix(parcels): NaN/Inf bypass in inline weights validation, dedupe _POI_WEIGHTS (#201 #206 fixup) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - math.isfinite() guard в out_of_range check (NaN bypass: NaN str: return "low" -# Веса POI-категорий для scoring (Максим: трамвай = минус) -_POI_WEIGHTS: dict[str, float] = { - "school": 1.5, - "kindergarten": 1.5, - "pharmacy": 0.8, - "hospital": 0.6, - "shop_mall": 1.2, - "shop_supermarket": 1.0, - "shop_small": 0.5, - "park": 1.8, - "bus_stop": 0.3, - "metro_stop": 1.5, - "tram_stop": -0.5, # негативный вес — шум / вибрация -} - # Человеко-читаемые имена категорий для verbal breakdown (X1). _POI_CATEGORY_RU: dict[str, str] = { "school": "Школа", @@ -1255,7 +1243,9 @@ def analyze_parcel( ), ) out_of_range = { - k: v for k, v in _inline_weights.items() if v < _MIN_WEIGHT or v > _MAX_WEIGHT + k: v + for k, v in _inline_weights.items() + if not math.isfinite(v) or v < _MIN_WEIGHT or v > _MAX_WEIGHT } if out_of_range: raise HTTPException( diff --git a/backend/app/services/site_finder/weight_profiles.py b/backend/app/services/site_finder/weight_profiles.py index 7f3f0ad2..12259896 100644 --- a/backend/app/services/site_finder/weight_profiles.py +++ b/backend/app/services/site_finder/weight_profiles.py @@ -17,6 +17,7 @@ from __future__ import annotations import json import logging +import math from datetime import datetime from typing import Any @@ -25,7 +26,7 @@ from sqlalchemy import text logger = logging.getLogger(__name__) -# Allowed POI categories — mirrors _POI_WEIGHTS keys in api/v1/parcels.py +# Allowed POI categories — single source of truth; imported by api/v1/parcels.py ALLOWED_CATEGORIES: set[str] = { "school", "kindergarten", @@ -44,7 +45,7 @@ ALLOWED_CATEGORIES: set[str] = { MIN_WEIGHT: float = -2.0 MAX_WEIGHT: float = 3.0 -# System defaults — keep in sync with _POI_WEIGHTS in parcels.py +# System defaults — single source of truth; imported as _POI_WEIGHTS by api/v1/parcels.py _SYSTEM_POI_WEIGHTS: dict[str, float] = { "school": 1.5, "kindergarten": 1.5, @@ -115,7 +116,7 @@ def _validate_weights_dict(v: dict[str, float]) -> dict[str, float]: for k, w in v.items(): if not isinstance(w, int | float): raise ValueError(f"Weight for '{k}' must be number, got {type(w).__name__}") - if w < MIN_WEIGHT or w > MAX_WEIGHT: + if not math.isfinite(w) or w < MIN_WEIGHT or w > MAX_WEIGHT: raise ValueError(f"Weight for '{k}' = {w} out of bounds [{MIN_WEIGHT}, {MAX_WEIGHT}]") return v diff --git a/backend/tests/api/v1/test_analyze_inline_weights.py b/backend/tests/api/v1/test_analyze_inline_weights.py index d4fcdcc4..80a1a9d6 100644 --- a/backend/tests/api/v1/test_analyze_inline_weights.py +++ b/backend/tests/api/v1/test_analyze_inline_weights.py @@ -277,6 +277,31 @@ def test_analyze_weight_out_of_range_returns_422() -> None: _stop_patches() +def test_inline_weights_rejects_nan() -> None: + """NaN weight должен вернуть 422, а не propagate в score.""" + from app.core.db import get_db + + db = _make_db_for_analyze() + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + try: + client = TestClient(app) + # Отправляем raw JSON с NaN — httpx.Client не умеет encode float('nan'), + # поэтому используем content= с явным bytes-телом. + raw_body = b'{"weights": {"school": NaN}}' + resp = client.post( + f"/api/v1/parcels/{_CAD}/analyze", + content=raw_body, + headers={"Content-Type": "application/json"}, + ) + assert ( + resp.status_code == 422 + ), f"Ожидали 422 для NaN-weight, получили {resp.status_code}: {resp.text}" + finally: + app.dependency_overrides.clear() + _stop_patches() + + def test_analyze_inline_weights_beats_profile_id() -> None: """body.weights + profile_id → body.weights имеет приоритет (source = 'inline').""" from app.core.db import get_db -- 2.45.3